feat: add baseline gap details surfaces (#192)
## Summary - add baseline compare evidence gap detail modeling and a dedicated Livewire table surface - extend baseline compare landing and operation run detail surfaces to expose evidence gap details and stats - add spec artifacts for feature 162 and expand feature coverage with focused Filament and baseline tests ## Notes - branch: `162-baseline-gap-details` - commit: `a92dd812` - working tree was clean after push ## Validation - tests were not run in this step Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de> Reviewed-on: #192
This commit is contained in:
parent
1f0cc5de56
commit
7d4d607475
4
.github/agents/copilot-instructions.md
vendored
4
.github/agents/copilot-instructions.md
vendored
@ -103,6 +103,8 @@ ## Active Technologies
|
||||
- PHP 8.4.15 + Laravel 12, Filament 5, Livewire 4, Pest 4, Laravel queue workers, existing `OperationRunService`, `TrackOperationRun`, `OperationUxPresenter`, `ReasonPresenter`, `BadgeCatalog` domain badges, and current Operations Monitoring pages (160-operation-lifecycle-guarantees)
|
||||
- PostgreSQL for `operation_runs`, `jobs`, and `failed_jobs`; JSONB-backed `context`, `summary_counts`, and `failure_summary`; configuration in `config/queue.php` and `config/tenantpilot.php` (160-operation-lifecycle-guarantees)
|
||||
- PostgreSQL (via Sail) plus existing read models persisted in application tables (161-operator-explanation-layer)
|
||||
- PHP 8.4 / Laravel 12, Blade, Alpine via Filament, Tailwind CSS v4 + Filament v5, Livewire v4, Pest v4, Laravel Sail, existing `OperationRun` and baseline compare services (162-baseline-gap-details)
|
||||
- PostgreSQL with JSONB-backed `operation_runs.context`; no new tables required (162-baseline-gap-details)
|
||||
|
||||
- PHP 8.4.15 (feat/005-bulk-operations)
|
||||
|
||||
@ -122,8 +124,8 @@ ## Code Style
|
||||
PHP 8.4.15: Follow standard conventions
|
||||
|
||||
## Recent Changes
|
||||
- 162-baseline-gap-details: Added PHP 8.4 / Laravel 12, Blade, Alpine via Filament, Tailwind CSS v4 + Filament v5, Livewire v4, Pest v4, Laravel Sail, existing `OperationRun` and baseline compare services
|
||||
- 161-operator-explanation-layer: Added PHP 8.4 + Laravel 12, Filament v5, Livewire v4
|
||||
- 160-operation-lifecycle-guarantees: Added PHP 8.4.15 + Laravel 12, Filament 5, Livewire 4, Pest 4, Laravel queue workers, existing `OperationRunService`, `TrackOperationRun`, `OperationUxPresenter`, `ReasonPresenter`, `BadgeCatalog` domain badges, and current Operations Monitoring pages
|
||||
- 159-baseline-snapshot-truth: Added PHP 8.4 + Laravel 12, Filament v5, Livewire v4
|
||||
<!-- MANUAL ADDITIONS START -->
|
||||
<!-- MANUAL ADDITIONS END -->
|
||||
|
||||
@ -13,6 +13,7 @@
|
||||
use App\Services\Baselines\BaselineCompareService;
|
||||
use App\Support\Auth\Capabilities;
|
||||
use App\Support\Baselines\BaselineCaptureMode;
|
||||
use App\Support\Baselines\BaselineCompareEvidenceGapDetails;
|
||||
use App\Support\Baselines\BaselineCompareStats;
|
||||
use App\Support\OperationRunLinks;
|
||||
use App\Support\OpsUx\OperationUxPresenter;
|
||||
@ -86,6 +87,15 @@ class BaselineCompareLanding extends Page
|
||||
/** @var array<string, int>|null */
|
||||
public ?array $evidenceGapsTopReasons = null;
|
||||
|
||||
/** @var array<string, mixed>|null */
|
||||
public ?array $evidenceGapSummary = null;
|
||||
|
||||
/** @var list<array<string, mixed>>|null */
|
||||
public ?array $evidenceGapBuckets = null;
|
||||
|
||||
/** @var array<string, mixed>|null */
|
||||
public ?array $baselineCompareDiagnostics = null;
|
||||
|
||||
/** @var array<string, int>|null */
|
||||
public ?array $rbacRoleDefinitionSummary = null;
|
||||
|
||||
@ -142,6 +152,15 @@ public function refreshStats(): void
|
||||
|
||||
$this->evidenceGapsCount = $stats->evidenceGapsCount;
|
||||
$this->evidenceGapsTopReasons = $stats->evidenceGapsTopReasons !== [] ? $stats->evidenceGapsTopReasons : null;
|
||||
$this->evidenceGapSummary = is_array($stats->evidenceGapDetails['summary'] ?? null)
|
||||
? $stats->evidenceGapDetails['summary']
|
||||
: null;
|
||||
$this->evidenceGapBuckets = is_array($stats->evidenceGapDetails['buckets'] ?? null) && $stats->evidenceGapDetails['buckets'] !== []
|
||||
? $stats->evidenceGapDetails['buckets']
|
||||
: null;
|
||||
$this->baselineCompareDiagnostics = $stats->baselineCompareDiagnostics !== []
|
||||
? $stats->baselineCompareDiagnostics
|
||||
: null;
|
||||
$this->rbacRoleDefinitionSummary = $stats->rbacRoleDefinitionSummary !== [] ? $stats->rbacRoleDefinitionSummary : null;
|
||||
$this->operatorExplanation = $stats->operatorExplanation()->toArray();
|
||||
}
|
||||
@ -156,26 +175,32 @@ public function refreshStats(): void
|
||||
*/
|
||||
protected function getViewData(): array
|
||||
{
|
||||
$evidenceGapSummary = is_array($this->evidenceGapSummary) ? $this->evidenceGapSummary : [];
|
||||
$hasCoverageWarnings = in_array($this->coverageStatus, ['warning', 'unproven'], true);
|
||||
$evidenceGapsCountValue = (int) ($this->evidenceGapsCount ?? 0);
|
||||
$evidenceGapsCountValue = is_numeric($evidenceGapSummary['count'] ?? null)
|
||||
? (int) $evidenceGapSummary['count']
|
||||
: (int) ($this->evidenceGapsCount ?? 0);
|
||||
$hasEvidenceGaps = $evidenceGapsCountValue > 0;
|
||||
$hasWarnings = $hasCoverageWarnings || $hasEvidenceGaps;
|
||||
$hasRbacRoleDefinitionSummary = is_array($this->rbacRoleDefinitionSummary)
|
||||
&& array_sum($this->rbacRoleDefinitionSummary) > 0;
|
||||
$evidenceGapDetailState = is_string($evidenceGapSummary['detail_state'] ?? null)
|
||||
? (string) $evidenceGapSummary['detail_state']
|
||||
: 'no_gaps';
|
||||
$hasEvidenceGapDetailSection = $evidenceGapDetailState !== 'no_gaps';
|
||||
$hasEvidenceGapDiagnostics = is_array($this->baselineCompareDiagnostics) && $this->baselineCompareDiagnostics !== [];
|
||||
|
||||
$evidenceGapsSummary = null;
|
||||
$evidenceGapsTooltip = null;
|
||||
|
||||
if ($hasEvidenceGaps && is_array($this->evidenceGapsTopReasons) && $this->evidenceGapsTopReasons !== []) {
|
||||
$parts = [];
|
||||
|
||||
foreach (array_slice($this->evidenceGapsTopReasons, 0, 5, true) as $reason => $count) {
|
||||
if (! is_string($reason) || $reason === '' || ! is_numeric($count)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$parts[] = $reason.' ('.((int) $count).')';
|
||||
}
|
||||
if ($hasEvidenceGaps) {
|
||||
$parts = array_map(
|
||||
static fn (array $reason): string => $reason['reason_label'].' ('.$reason['count'].')',
|
||||
BaselineCompareEvidenceGapDetails::topReasons(
|
||||
is_array($evidenceGapSummary['by_reason'] ?? null) ? $evidenceGapSummary['by_reason'] : [],
|
||||
5,
|
||||
),
|
||||
);
|
||||
|
||||
if ($parts !== []) {
|
||||
$evidenceGapsSummary = implode(', ', $parts);
|
||||
@ -211,6 +236,9 @@ protected function getViewData(): array
|
||||
'hasEvidenceGaps' => $hasEvidenceGaps,
|
||||
'hasWarnings' => $hasWarnings,
|
||||
'hasRbacRoleDefinitionSummary' => $hasRbacRoleDefinitionSummary,
|
||||
'evidenceGapDetailState' => $evidenceGapDetailState,
|
||||
'hasEvidenceGapDetailSection' => $hasEvidenceGapDetailSection,
|
||||
'hasEvidenceGapDiagnostics' => $hasEvidenceGapDiagnostics,
|
||||
'evidenceGapsSummary' => $evidenceGapsSummary,
|
||||
'evidenceGapsTooltip' => $evidenceGapsTooltip,
|
||||
'findingsColorClass' => $findingsColorClass,
|
||||
|
||||
@ -11,6 +11,7 @@
|
||||
use App\Support\Badges\BadgeCatalog;
|
||||
use App\Support\Badges\BadgeDomain;
|
||||
use App\Support\Badges\BadgeRenderer;
|
||||
use App\Support\Baselines\BaselineCompareEvidenceGapDetails;
|
||||
use App\Support\Baselines\BaselineCompareReasonCode;
|
||||
use App\Support\Filament\FilterOptionCatalog;
|
||||
use App\Support\Filament\FilterPresets;
|
||||
@ -457,6 +458,9 @@ private static function enterpriseDetailPage(OperationRun $record): \App\Support
|
||||
if ((string) $record->type === 'baseline_compare') {
|
||||
$baselineCompareFacts = static::baselineCompareFacts($record, $factory);
|
||||
$baselineCompareEvidence = static::baselineCompareEvidencePayload($record);
|
||||
$gapDetails = BaselineCompareEvidenceGapDetails::fromOperationRun($record);
|
||||
$gapSummary = is_array($gapDetails['summary'] ?? null) ? $gapDetails['summary'] : [];
|
||||
$gapBuckets = is_array($gapDetails['buckets'] ?? null) ? $gapDetails['buckets'] : [];
|
||||
|
||||
if ($baselineCompareFacts !== []) {
|
||||
$builder->addSection(
|
||||
@ -469,6 +473,25 @@ private static function enterpriseDetailPage(OperationRun $record): \App\Support
|
||||
);
|
||||
}
|
||||
|
||||
if (($gapSummary['detail_state'] ?? 'no_gaps') !== 'no_gaps') {
|
||||
$builder->addSection(
|
||||
$factory->viewSection(
|
||||
id: 'baseline_compare_gap_details',
|
||||
kind: 'operational_context',
|
||||
title: 'Evidence gap details',
|
||||
description: 'Policies affected by evidence gaps, grouped by reason and searchable by reason, policy type, or subject key.',
|
||||
view: 'filament.infolists.entries.evidence-gap-subjects',
|
||||
viewData: [
|
||||
'summary' => $gapSummary,
|
||||
'buckets' => $gapBuckets,
|
||||
'searchId' => 'baseline-compare-gap-search-'.$record->getKey(),
|
||||
],
|
||||
collapsible: true,
|
||||
collapsed: false,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if ($baselineCompareEvidence !== []) {
|
||||
$builder->addSection(
|
||||
$factory->viewSection(
|
||||
@ -593,6 +616,8 @@ private static function baselineCompareFacts(
|
||||
\App\Support\Ui\EnterpriseDetail\EnterpriseDetailSectionFactory $factory,
|
||||
): array {
|
||||
$context = is_array($record->context) ? $record->context : [];
|
||||
$gapDetails = BaselineCompareEvidenceGapDetails::fromOperationRun($record);
|
||||
$gapSummary = is_array($gapDetails['summary'] ?? null) ? $gapDetails['summary'] : [];
|
||||
$facts = [];
|
||||
|
||||
$fidelity = data_get($context, 'baseline_compare.fidelity');
|
||||
@ -624,6 +649,17 @@ private static function baselineCompareFacts(
|
||||
);
|
||||
}
|
||||
|
||||
if ((int) ($gapSummary['count'] ?? 0) > 0) {
|
||||
$facts[] = $factory->keyFact(
|
||||
'Evidence gap detail',
|
||||
match ($gapSummary['detail_state'] ?? 'no_gaps') {
|
||||
'details_recorded' => 'Recorded subjects available',
|
||||
'details_not_recorded' => 'Detailed rows were not recorded',
|
||||
default => 'No evidence gaps recorded',
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
if ($uncoveredTypes !== []) {
|
||||
sort($uncoveredTypes, SORT_STRING);
|
||||
$facts[] = $factory->keyFact('Uncovered types', implode(', ', array_slice($uncoveredTypes, 0, 12)).(count($uncoveredTypes) > 12 ? '…' : ''));
|
||||
|
||||
@ -388,6 +388,7 @@ public function handle(
|
||||
];
|
||||
$phaseResult = [];
|
||||
$phaseGaps = [];
|
||||
$phaseGapSubjects = [];
|
||||
$resumeToken = null;
|
||||
|
||||
if ($captureMode === BaselineCaptureMode::FullContent) {
|
||||
@ -416,6 +417,7 @@ public function handle(
|
||||
|
||||
$phaseStats = is_array($phaseResult['stats'] ?? null) ? $phaseResult['stats'] : $phaseStats;
|
||||
$phaseGaps = is_array($phaseResult['gaps'] ?? null) ? $phaseResult['gaps'] : [];
|
||||
$phaseGapSubjects = is_array($phaseResult['gap_subjects'] ?? null) ? $phaseResult['gap_subjects'] : [];
|
||||
$resumeToken = is_string($phaseResult['resume_token'] ?? null) ? $phaseResult['resume_token'] : null;
|
||||
}
|
||||
|
||||
@ -495,6 +497,12 @@ public function handle(
|
||||
$gapsByReason = $this->mergeGapCounts($baselineGaps, $currentGaps, $phaseGaps, $driftGaps);
|
||||
$gapsCount = array_sum($gapsByReason);
|
||||
|
||||
$gapSubjects = $this->collectGapSubjects(
|
||||
ambiguousKeys: $ambiguousKeys,
|
||||
phaseGapSubjects: $phaseGapSubjects ?? [],
|
||||
driftGapSubjects: $computeResult['evidence_gap_subjects'] ?? [],
|
||||
);
|
||||
|
||||
$summaryCounts = [
|
||||
'total' => count($driftResults),
|
||||
'processed' => count($driftResults),
|
||||
@ -572,6 +580,7 @@ public function handle(
|
||||
'count' => $gapsCount,
|
||||
'by_reason' => $gapsByReason,
|
||||
...$gapsByReason,
|
||||
'subjects' => $gapSubjects !== [] ? $gapSubjects : null,
|
||||
],
|
||||
'resume_token' => $resumeToken,
|
||||
'coverage' => [
|
||||
@ -1134,6 +1143,7 @@ private function computeDrift(
|
||||
): array {
|
||||
$drift = [];
|
||||
$evidenceGaps = [];
|
||||
$evidenceGapSubjects = [];
|
||||
$rbacRoleDefinitionSummary = $this->emptyRbacRoleDefinitionSummary();
|
||||
$roleDefinitionNormalizer = app(IntuneRoleDefinitionNormalizer::class);
|
||||
|
||||
@ -1175,6 +1185,7 @@ private function computeDrift(
|
||||
if (! is_array($currentItem)) {
|
||||
if ($isRbacRoleDefinition && $baselinePolicyVersionId === null) {
|
||||
$evidenceGaps['missing_role_definition_baseline_version_reference'] = ($evidenceGaps['missing_role_definition_baseline_version_reference'] ?? 0) + 1;
|
||||
$evidenceGapSubjects['missing_role_definition_baseline_version_reference'][] = $key;
|
||||
|
||||
continue;
|
||||
}
|
||||
@ -1239,6 +1250,7 @@ private function computeDrift(
|
||||
|
||||
if (! $currentEvidence instanceof ResolvedEvidence) {
|
||||
$evidenceGaps['missing_current'] = ($evidenceGaps['missing_current'] ?? 0) + 1;
|
||||
$evidenceGapSubjects['missing_current'][] = $key;
|
||||
|
||||
continue;
|
||||
}
|
||||
@ -1255,12 +1267,14 @@ private function computeDrift(
|
||||
if ($isRbacRoleDefinition) {
|
||||
if ($baselinePolicyVersionId === null) {
|
||||
$evidenceGaps['missing_role_definition_baseline_version_reference'] = ($evidenceGaps['missing_role_definition_baseline_version_reference'] ?? 0) + 1;
|
||||
$evidenceGapSubjects['missing_role_definition_baseline_version_reference'][] = $key;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($currentPolicyVersionId === null) {
|
||||
$evidenceGaps['missing_role_definition_current_version_reference'] = ($evidenceGaps['missing_role_definition_current_version_reference'] ?? 0) + 1;
|
||||
$evidenceGapSubjects['missing_role_definition_current_version_reference'][] = $key;
|
||||
|
||||
continue;
|
||||
}
|
||||
@ -1274,6 +1288,7 @@ private function computeDrift(
|
||||
|
||||
if ($roleDefinitionDiff === null) {
|
||||
$evidenceGaps['missing_role_definition_compare_surface'] = ($evidenceGaps['missing_role_definition_compare_surface'] ?? 0) + 1;
|
||||
$evidenceGapSubjects['missing_role_definition_compare_surface'][] = $key;
|
||||
|
||||
continue;
|
||||
}
|
||||
@ -1354,6 +1369,7 @@ private function computeDrift(
|
||||
|
||||
if (! $currentEvidence instanceof ResolvedEvidence) {
|
||||
$evidenceGaps['missing_current'] = ($evidenceGaps['missing_current'] ?? 0) + 1;
|
||||
$evidenceGapSubjects['missing_current'][] = $key;
|
||||
|
||||
continue;
|
||||
}
|
||||
@ -1369,6 +1385,7 @@ private function computeDrift(
|
||||
|
||||
if ($isRbacRoleDefinition && $currentPolicyVersionId === null) {
|
||||
$evidenceGaps['missing_role_definition_current_version_reference'] = ($evidenceGaps['missing_role_definition_current_version_reference'] ?? 0) + 1;
|
||||
$evidenceGapSubjects['missing_role_definition_current_version_reference'][] = $key;
|
||||
|
||||
continue;
|
||||
}
|
||||
@ -1428,6 +1445,7 @@ private function computeDrift(
|
||||
return [
|
||||
'drift' => $drift,
|
||||
'evidence_gaps' => $evidenceGaps,
|
||||
'evidence_gap_subjects' => $evidenceGapSubjects,
|
||||
'rbac_role_definitions' => $rbacRoleDefinitionSummary,
|
||||
];
|
||||
}
|
||||
@ -1939,6 +1957,44 @@ private function mergeGapCounts(array ...$gaps): array
|
||||
return $merged;
|
||||
}
|
||||
|
||||
private const GAP_SUBJECTS_LIMIT = 50;
|
||||
|
||||
/**
|
||||
* @param list<string> $ambiguousKeys
|
||||
* @param array<string, list<string>> $phaseGapSubjects
|
||||
* @param array<string, list<string>> $driftGapSubjects
|
||||
* @return array<string, list<string>>
|
||||
*/
|
||||
private function collectGapSubjects(array $ambiguousKeys, array $phaseGapSubjects, array $driftGapSubjects): array
|
||||
{
|
||||
$subjects = [];
|
||||
|
||||
if ($ambiguousKeys !== []) {
|
||||
$subjects['ambiguous_match'] = array_slice($ambiguousKeys, 0, self::GAP_SUBJECTS_LIMIT);
|
||||
}
|
||||
|
||||
foreach ([$phaseGapSubjects, $driftGapSubjects] as $subjectMap) {
|
||||
foreach ($subjectMap as $reason => $keys) {
|
||||
if (! is_string($reason) || ! is_array($keys) || $keys === []) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$subjects[$reason] = array_slice(
|
||||
array_values(array_unique([
|
||||
...($subjects[$reason] ?? []),
|
||||
...array_values(array_filter($keys, 'is_string')),
|
||||
])),
|
||||
0,
|
||||
self::GAP_SUBJECTS_LIMIT,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
ksort($subjects);
|
||||
|
||||
return $subjects;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, array{subject_external_id: string, policy_type: string, meta_jsonb: array<string, mixed>}> $currentItems
|
||||
* @param array<string, ResolvedEvidence|null> $resolvedCurrentEvidence
|
||||
|
||||
191
app/Livewire/BaselineCompareEvidenceGapTable.php
Normal file
191
app/Livewire/BaselineCompareEvidenceGapTable.php
Normal file
@ -0,0 +1,191 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Livewire;
|
||||
|
||||
use App\Support\Badges\TagBadgeDomain;
|
||||
use App\Support\Badges\TagBadgeRenderer;
|
||||
use App\Support\Baselines\BaselineCompareEvidenceGapDetails;
|
||||
use App\Support\Filament\TablePaginationProfiles;
|
||||
use Filament\Tables\Columns\TextColumn;
|
||||
use Filament\Tables\Filters\SelectFilter;
|
||||
use Filament\Tables\Table;
|
||||
use Filament\Tables\TableComponent;
|
||||
use Illuminate\Contracts\View\View;
|
||||
use Illuminate\Pagination\LengthAwarePaginator;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class BaselineCompareEvidenceGapTable extends TableComponent
|
||||
{
|
||||
/**
|
||||
* @var list<array{
|
||||
* __id: string,
|
||||
* reason_code: string,
|
||||
* reason_label: string,
|
||||
* policy_type: string,
|
||||
* subject_key: string,
|
||||
* search_text: string
|
||||
* }>
|
||||
*/
|
||||
public array $gapRows = [];
|
||||
|
||||
public string $context = 'default';
|
||||
|
||||
/**
|
||||
* @param list<array<string, mixed>> $buckets
|
||||
*/
|
||||
public function mount(array $buckets = [], string $context = 'default'): void
|
||||
{
|
||||
$this->gapRows = BaselineCompareEvidenceGapDetails::tableRows($buckets);
|
||||
$this->context = $context;
|
||||
}
|
||||
|
||||
public function table(Table $table): Table
|
||||
{
|
||||
return $table
|
||||
->queryStringIdentifier('baselineCompareEvidenceGap'.Str::studly($this->context))
|
||||
->defaultSort('reason_label')
|
||||
->defaultPaginationPageOption(10)
|
||||
->paginated(TablePaginationProfiles::picker())
|
||||
->searchable()
|
||||
->searchPlaceholder(__('baseline-compare.evidence_gap_search_placeholder'))
|
||||
->records(function (
|
||||
?string $sortColumn,
|
||||
?string $sortDirection,
|
||||
?string $search,
|
||||
array $filters,
|
||||
int $page,
|
||||
int $recordsPerPage
|
||||
): LengthAwarePaginator {
|
||||
$rows = $this->filterRows(
|
||||
rows: collect($this->gapRows),
|
||||
search: $search,
|
||||
filters: $filters,
|
||||
);
|
||||
|
||||
$rows = $this->sortRows(
|
||||
rows: $rows,
|
||||
sortColumn: $sortColumn,
|
||||
sortDirection: $sortDirection,
|
||||
);
|
||||
|
||||
return $this->paginateRows(
|
||||
rows: $rows,
|
||||
page: $page,
|
||||
recordsPerPage: $recordsPerPage,
|
||||
);
|
||||
})
|
||||
->filters([
|
||||
SelectFilter::make('reason_code')
|
||||
->label(__('baseline-compare.evidence_gap_reason'))
|
||||
->options(BaselineCompareEvidenceGapDetails::reasonFilterOptions($this->gapRows)),
|
||||
SelectFilter::make('policy_type')
|
||||
->label(__('baseline-compare.evidence_gap_policy_type'))
|
||||
->options(BaselineCompareEvidenceGapDetails::policyTypeFilterOptions($this->gapRows)),
|
||||
])
|
||||
->striped()
|
||||
->deferLoading(! app()->runningUnitTests())
|
||||
->columns([
|
||||
TextColumn::make('reason_label')
|
||||
->label(__('baseline-compare.evidence_gap_reason'))
|
||||
->searchable()
|
||||
->sortable()
|
||||
->wrap(),
|
||||
TextColumn::make('policy_type')
|
||||
->label(__('baseline-compare.evidence_gap_policy_type'))
|
||||
->badge()
|
||||
->formatStateUsing(TagBadgeRenderer::label(TagBadgeDomain::PolicyType))
|
||||
->color(TagBadgeRenderer::color(TagBadgeDomain::PolicyType))
|
||||
->icon(TagBadgeRenderer::icon(TagBadgeDomain::PolicyType))
|
||||
->iconColor(TagBadgeRenderer::iconColor(TagBadgeDomain::PolicyType))
|
||||
->searchable()
|
||||
->sortable()
|
||||
->wrap(),
|
||||
TextColumn::make('subject_key')
|
||||
->label(__('baseline-compare.evidence_gap_subject_key'))
|
||||
->searchable()
|
||||
->sortable()
|
||||
->wrap()
|
||||
->extraAttributes(['class' => 'font-mono text-xs']),
|
||||
])
|
||||
->actions([])
|
||||
->bulkActions([])
|
||||
->emptyStateHeading(__('baseline-compare.evidence_gap_table_empty_heading'))
|
||||
->emptyStateDescription(__('baseline-compare.evidence_gap_table_empty_description'));
|
||||
}
|
||||
|
||||
public function render(): View
|
||||
{
|
||||
return view('livewire.baseline-compare-evidence-gap-table');
|
||||
}
|
||||
|
||||
/**
|
||||
* @param Collection<int, array<string, mixed>> $rows
|
||||
* @param array<string, mixed> $filters
|
||||
* @return Collection<int, array<string, mixed>>
|
||||
*/
|
||||
private function filterRows(Collection $rows, ?string $search, array $filters): Collection
|
||||
{
|
||||
$normalizedSearch = Str::lower(trim((string) $search));
|
||||
$reasonCode = $filters['reason_code']['value'] ?? null;
|
||||
$policyType = $filters['policy_type']['value'] ?? null;
|
||||
|
||||
return $rows
|
||||
->when(
|
||||
$normalizedSearch !== '',
|
||||
function (Collection $rows) use ($normalizedSearch): Collection {
|
||||
return $rows->filter(function (array $row) use ($normalizedSearch): bool {
|
||||
return str_contains(Str::lower((string) ($row['search_text'] ?? '')), $normalizedSearch);
|
||||
});
|
||||
}
|
||||
)
|
||||
->when(
|
||||
filled($reasonCode),
|
||||
fn (Collection $rows): Collection => $rows->where('reason_code', (string) $reasonCode)
|
||||
)
|
||||
->when(
|
||||
filled($policyType),
|
||||
fn (Collection $rows): Collection => $rows->where('policy_type', (string) $policyType)
|
||||
)
|
||||
->values();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param Collection<int, array<string, mixed>> $rows
|
||||
* @return Collection<int, array<string, mixed>>
|
||||
*/
|
||||
private function sortRows(Collection $rows, ?string $sortColumn, ?string $sortDirection): Collection
|
||||
{
|
||||
if (! filled($sortColumn)) {
|
||||
return $rows;
|
||||
}
|
||||
|
||||
$direction = Str::lower((string) ($sortDirection ?? 'asc')) === 'desc' ? 'desc' : 'asc';
|
||||
|
||||
return $rows->sortBy(
|
||||
fn (array $row): string => (string) ($row[$sortColumn] ?? ''),
|
||||
SORT_NATURAL | SORT_FLAG_CASE,
|
||||
$direction === 'desc'
|
||||
)->values();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param Collection<int, array<string, mixed>> $rows
|
||||
*/
|
||||
private function paginateRows(Collection $rows, int $page, int $recordsPerPage): LengthAwarePaginator
|
||||
{
|
||||
$perPage = max(1, $recordsPerPage);
|
||||
$currentPage = max(1, $page);
|
||||
$total = $rows->count();
|
||||
$items = $rows->forPage($currentPage, $perPage)->values();
|
||||
|
||||
return new LengthAwarePaginator(
|
||||
$items,
|
||||
$total,
|
||||
$perPage,
|
||||
$currentPage,
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -26,6 +26,7 @@ public function __construct(
|
||||
* @return array{
|
||||
* stats: array{requested: int, succeeded: int, skipped: int, failed: int, throttled: int},
|
||||
* gaps: array<string, int>,
|
||||
* gap_subjects: array<string, list<string>>,
|
||||
* resume_token: ?string,
|
||||
* captured_versions: array<string, array{
|
||||
* policy_type: string,
|
||||
@ -76,6 +77,8 @@ public function capture(
|
||||
|
||||
/** @var array<string, int> $gaps */
|
||||
$gaps = [];
|
||||
/** @var array<string, list<string>> $gapSubjects */
|
||||
$gapSubjects = [];
|
||||
$capturedVersions = [];
|
||||
|
||||
/**
|
||||
@ -90,6 +93,7 @@ public function capture(
|
||||
|
||||
if ($policyType === '' || $externalId === '') {
|
||||
$gaps['invalid_subject'] = ($gaps['invalid_subject'] ?? 0) + 1;
|
||||
$gapSubjects['invalid_subject'][] = ($policyType !== '' ? $policyType : 'unknown').'|'.($externalId !== '' ? $externalId : 'unknown');
|
||||
$stats['failed']++;
|
||||
|
||||
continue;
|
||||
@ -99,6 +103,7 @@ public function capture(
|
||||
|
||||
if (isset($seen[$subjectKey])) {
|
||||
$gaps['duplicate_subject'] = ($gaps['duplicate_subject'] ?? 0) + 1;
|
||||
$gapSubjects['duplicate_subject'][] = $subjectKey;
|
||||
$stats['skipped']++;
|
||||
|
||||
continue;
|
||||
@ -114,6 +119,7 @@ public function capture(
|
||||
|
||||
if (! $policy instanceof Policy) {
|
||||
$gaps['policy_not_found'] = ($gaps['policy_not_found'] ?? 0) + 1;
|
||||
$gapSubjects['policy_not_found'][] = $subjectKey;
|
||||
$stats['failed']++;
|
||||
|
||||
continue;
|
||||
@ -179,9 +185,11 @@ public function capture(
|
||||
|
||||
if ($isThrottled) {
|
||||
$gaps['throttled'] = ($gaps['throttled'] ?? 0) + 1;
|
||||
$gapSubjects['throttled'][] = $subjectKey;
|
||||
$stats['throttled']++;
|
||||
} else {
|
||||
$gaps['capture_failed'] = ($gaps['capture_failed'] ?? 0) + 1;
|
||||
$gapSubjects['capture_failed'][] = $subjectKey;
|
||||
$stats['failed']++;
|
||||
}
|
||||
|
||||
@ -202,14 +210,27 @@ public function capture(
|
||||
$remainingCount = max(0, count($subjects) - $processed);
|
||||
if ($remainingCount > 0) {
|
||||
$gaps['budget_exhausted'] = ($gaps['budget_exhausted'] ?? 0) + $remainingCount;
|
||||
|
||||
foreach (array_slice($subjects, $processed) as $remainingSubject) {
|
||||
$remainingPolicyType = trim((string) ($remainingSubject['policy_type'] ?? ''));
|
||||
$remainingExternalId = trim((string) ($remainingSubject['subject_external_id'] ?? ''));
|
||||
|
||||
if ($remainingPolicyType === '' || $remainingExternalId === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$gapSubjects['budget_exhausted'][] = $remainingPolicyType.'|'.$remainingExternalId;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ksort($gaps);
|
||||
ksort($gapSubjects);
|
||||
|
||||
return [
|
||||
'stats' => $stats,
|
||||
'gaps' => $gaps,
|
||||
'gap_subjects' => $gapSubjects,
|
||||
'resume_token' => $resumeTokenOut,
|
||||
'captured_versions' => $capturedVersions,
|
||||
];
|
||||
|
||||
557
app/Support/Baselines/BaselineCompareEvidenceGapDetails.php
Normal file
557
app/Support/Baselines/BaselineCompareEvidenceGapDetails.php
Normal file
@ -0,0 +1,557 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Support\Baselines;
|
||||
|
||||
use App\Models\OperationRun;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
final class BaselineCompareEvidenceGapDetails
|
||||
{
|
||||
/**
|
||||
* @return array{
|
||||
* summary: array{
|
||||
* count: int,
|
||||
* by_reason: array<string, int>,
|
||||
* detail_state: string,
|
||||
* recorded_subjects_total: int,
|
||||
* missing_detail_count: int
|
||||
* },
|
||||
* buckets: list<array{
|
||||
* reason_code: string,
|
||||
* reason_label: string,
|
||||
* count: int,
|
||||
* recorded_count: int,
|
||||
* missing_detail_count: int,
|
||||
* detail_state: string,
|
||||
* search_text: string,
|
||||
* rows: list<array{
|
||||
* reason_code: string,
|
||||
* reason_label: string,
|
||||
* policy_type: string,
|
||||
* subject_key: string,
|
||||
* search_text: string
|
||||
* }>
|
||||
* }>
|
||||
* }
|
||||
*/
|
||||
public static function fromOperationRun(?OperationRun $run): array
|
||||
{
|
||||
if (! $run instanceof OperationRun || ! is_array($run->context)) {
|
||||
return self::empty();
|
||||
}
|
||||
|
||||
return self::fromContext($run->context);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $context
|
||||
* @return array{
|
||||
* summary: array{
|
||||
* count: int,
|
||||
* by_reason: array<string, int>,
|
||||
* detail_state: string,
|
||||
* recorded_subjects_total: int,
|
||||
* missing_detail_count: int
|
||||
* },
|
||||
* buckets: list<array{
|
||||
* reason_code: string,
|
||||
* reason_label: string,
|
||||
* count: int,
|
||||
* recorded_count: int,
|
||||
* missing_detail_count: int,
|
||||
* detail_state: string,
|
||||
* search_text: string,
|
||||
* rows: list<array{
|
||||
* reason_code: string,
|
||||
* reason_label: string,
|
||||
* policy_type: string,
|
||||
* subject_key: string,
|
||||
* search_text: string
|
||||
* }>
|
||||
* }>
|
||||
* }
|
||||
*/
|
||||
public static function fromContext(array $context): array
|
||||
{
|
||||
$baselineCompare = $context['baseline_compare'] ?? null;
|
||||
|
||||
if (! is_array($baselineCompare)) {
|
||||
return self::empty();
|
||||
}
|
||||
|
||||
return self::fromBaselineCompare($baselineCompare);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $baselineCompare
|
||||
* @return array{
|
||||
* summary: array{
|
||||
* count: int,
|
||||
* by_reason: array<string, int>,
|
||||
* detail_state: string,
|
||||
* recorded_subjects_total: int,
|
||||
* missing_detail_count: int
|
||||
* },
|
||||
* buckets: list<array{
|
||||
* reason_code: string,
|
||||
* reason_label: string,
|
||||
* count: int,
|
||||
* recorded_count: int,
|
||||
* missing_detail_count: int,
|
||||
* detail_state: string,
|
||||
* search_text: string,
|
||||
* rows: list<array{
|
||||
* reason_code: string,
|
||||
* reason_label: string,
|
||||
* policy_type: string,
|
||||
* subject_key: string,
|
||||
* search_text: string
|
||||
* }>
|
||||
* }>
|
||||
* }
|
||||
*/
|
||||
public static function fromBaselineCompare(array $baselineCompare): array
|
||||
{
|
||||
$evidenceGaps = $baselineCompare['evidence_gaps'] ?? null;
|
||||
$evidenceGaps = is_array($evidenceGaps) ? $evidenceGaps : [];
|
||||
|
||||
$byReason = self::normalizeCounts($evidenceGaps['by_reason'] ?? null);
|
||||
$subjects = self::normalizeSubjects($evidenceGaps['subjects'] ?? null);
|
||||
|
||||
foreach ($subjects as $reason => $keys) {
|
||||
if (! array_key_exists($reason, $byReason)) {
|
||||
$byReason[$reason] = count($keys);
|
||||
}
|
||||
}
|
||||
|
||||
$count = self::normalizeTotalCount($evidenceGaps['count'] ?? null, $byReason, $subjects);
|
||||
$detailState = self::detailState($count, $subjects);
|
||||
|
||||
$buckets = [];
|
||||
|
||||
foreach (self::orderedReasons($byReason, $subjects) as $reason) {
|
||||
$rows = self::rowsForReason($reason, $subjects[$reason] ?? []);
|
||||
$reasonCount = $byReason[$reason] ?? count($rows);
|
||||
|
||||
if ($reasonCount <= 0 && $rows === []) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$recordedCount = count($rows);
|
||||
$searchText = trim(implode(' ', array_filter([
|
||||
Str::lower($reason),
|
||||
Str::lower(self::reasonLabel($reason)),
|
||||
...array_map(
|
||||
static fn (array $row): string => (string) ($row['search_text'] ?? ''),
|
||||
$rows,
|
||||
),
|
||||
])));
|
||||
|
||||
$buckets[] = [
|
||||
'reason_code' => $reason,
|
||||
'reason_label' => self::reasonLabel($reason),
|
||||
'count' => $reasonCount,
|
||||
'recorded_count' => $recordedCount,
|
||||
'missing_detail_count' => max(0, $reasonCount - $recordedCount),
|
||||
'detail_state' => $recordedCount > 0 ? 'details_recorded' : 'details_not_recorded',
|
||||
'search_text' => $searchText,
|
||||
'rows' => $rows,
|
||||
];
|
||||
}
|
||||
|
||||
$recordedSubjectsTotal = array_sum(array_map(
|
||||
static fn (array $bucket): int => (int) ($bucket['recorded_count'] ?? 0),
|
||||
$buckets,
|
||||
));
|
||||
|
||||
return [
|
||||
'summary' => [
|
||||
'count' => $count,
|
||||
'by_reason' => $byReason,
|
||||
'detail_state' => $detailState,
|
||||
'recorded_subjects_total' => $recordedSubjectsTotal,
|
||||
'missing_detail_count' => max(0, $count - $recordedSubjectsTotal),
|
||||
],
|
||||
'buckets' => $buckets,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $baselineCompare
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public static function diagnosticsPayload(array $baselineCompare): array
|
||||
{
|
||||
return array_filter([
|
||||
'reason_code' => self::stringOrNull($baselineCompare['reason_code'] ?? null),
|
||||
'subjects_total' => self::intOrNull($baselineCompare['subjects_total'] ?? null),
|
||||
'resume_token' => self::stringOrNull($baselineCompare['resume_token'] ?? null),
|
||||
'fidelity' => self::stringOrNull($baselineCompare['fidelity'] ?? null),
|
||||
'coverage' => is_array($baselineCompare['coverage'] ?? null) ? $baselineCompare['coverage'] : null,
|
||||
'evidence_capture' => is_array($baselineCompare['evidence_capture'] ?? null) ? $baselineCompare['evidence_capture'] : null,
|
||||
'evidence_gaps' => is_array($baselineCompare['evidence_gaps'] ?? null) ? $baselineCompare['evidence_gaps'] : null,
|
||||
], static fn (mixed $value): bool => $value !== null && $value !== []);
|
||||
}
|
||||
|
||||
public static function reasonLabel(string $reason): string
|
||||
{
|
||||
$reason = trim($reason);
|
||||
|
||||
return match ($reason) {
|
||||
'ambiguous_match' => 'Ambiguous inventory match',
|
||||
'policy_not_found' => 'Policy not found',
|
||||
'missing_current' => 'Missing current evidence',
|
||||
'invalid_subject' => 'Invalid subject',
|
||||
'duplicate_subject' => 'Duplicate subject',
|
||||
'capture_failed' => 'Evidence capture failed',
|
||||
'budget_exhausted' => 'Capture budget exhausted',
|
||||
'throttled' => 'Graph throttled',
|
||||
'missing_role_definition_baseline_version_reference' => 'Missing baseline role definition evidence',
|
||||
'missing_role_definition_current_version_reference' => 'Missing current role definition evidence',
|
||||
'missing_role_definition_compare_surface' => 'Missing role definition compare surface',
|
||||
'rollout_disabled' => 'Rollout disabled',
|
||||
default => Str::of($reason)->replace('_', ' ')->trim()->ucfirst()->toString(),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, int> $byReason
|
||||
* @return list<array{reason_code: string, reason_label: string, count: int}>
|
||||
*/
|
||||
public static function topReasons(array $byReason, int $limit = 5): array
|
||||
{
|
||||
$normalized = self::normalizeCounts($byReason);
|
||||
arsort($normalized);
|
||||
|
||||
return array_map(
|
||||
static fn (string $reason, int $count): array => [
|
||||
'reason_code' => $reason,
|
||||
'reason_label' => self::reasonLabel($reason),
|
||||
'count' => $count,
|
||||
],
|
||||
array_slice(array_keys($normalized), 0, $limit),
|
||||
array_slice(array_values($normalized), 0, $limit),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<array<string, mixed>> $buckets
|
||||
* @return list<array{
|
||||
* __id: string,
|
||||
* reason_code: string,
|
||||
* reason_label: string,
|
||||
* policy_type: string,
|
||||
* subject_key: string,
|
||||
* search_text: string
|
||||
* }>
|
||||
*/
|
||||
public static function tableRows(array $buckets): array
|
||||
{
|
||||
$rows = [];
|
||||
|
||||
foreach ($buckets as $bucket) {
|
||||
if (! is_array($bucket)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$bucketRows = is_array($bucket['rows'] ?? null) ? $bucket['rows'] : [];
|
||||
|
||||
foreach ($bucketRows as $row) {
|
||||
if (! is_array($row)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$reasonCode = self::stringOrNull($row['reason_code'] ?? null);
|
||||
$reasonLabel = self::stringOrNull($row['reason_label'] ?? null);
|
||||
$policyType = self::stringOrNull($row['policy_type'] ?? null);
|
||||
$subjectKey = self::stringOrNull($row['subject_key'] ?? null);
|
||||
|
||||
if ($reasonCode === null || $reasonLabel === null || $policyType === null || $subjectKey === null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$rows[] = [
|
||||
'__id' => md5(implode('|', [$reasonCode, $policyType, $subjectKey])),
|
||||
'reason_code' => $reasonCode,
|
||||
'reason_label' => $reasonLabel,
|
||||
'policy_type' => $policyType,
|
||||
'subject_key' => $subjectKey,
|
||||
'search_text' => Str::lower(implode(' ', [
|
||||
$reasonCode,
|
||||
$reasonLabel,
|
||||
$policyType,
|
||||
$subjectKey,
|
||||
])),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
return $rows;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<array<string, mixed>> $rows
|
||||
* @return array<string, string>
|
||||
*/
|
||||
public static function reasonFilterOptions(array $rows): array
|
||||
{
|
||||
return collect($rows)
|
||||
->filter(fn (array $row): bool => filled($row['reason_code'] ?? null) && filled($row['reason_label'] ?? null))
|
||||
->mapWithKeys(fn (array $row): array => [
|
||||
(string) $row['reason_code'] => (string) $row['reason_label'],
|
||||
])
|
||||
->sortBy(fn (string $label): string => Str::lower($label))
|
||||
->all();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<array<string, mixed>> $rows
|
||||
* @return array<string, string>
|
||||
*/
|
||||
public static function policyTypeFilterOptions(array $rows): array
|
||||
{
|
||||
return collect($rows)
|
||||
->pluck('policy_type')
|
||||
->filter(fn (mixed $value): bool => is_string($value) && trim($value) !== '')
|
||||
->mapWithKeys(fn (string $value): array => [$value => $value])
|
||||
->sortKeysUsing('strnatcasecmp')
|
||||
->all();
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{
|
||||
* summary: array{
|
||||
* count: int,
|
||||
* by_reason: array<string, int>,
|
||||
* detail_state: string,
|
||||
* recorded_subjects_total: int,
|
||||
* missing_detail_count: int
|
||||
* },
|
||||
* buckets: list<array{
|
||||
* reason_code: string,
|
||||
* reason_label: string,
|
||||
* count: int,
|
||||
* recorded_count: int,
|
||||
* missing_detail_count: int,
|
||||
* detail_state: string,
|
||||
* search_text: string,
|
||||
* rows: list<array{
|
||||
* reason_code: string,
|
||||
* reason_label: string,
|
||||
* policy_type: string,
|
||||
* subject_key: string,
|
||||
* search_text: string
|
||||
* }>
|
||||
* }>
|
||||
* }
|
||||
*/
|
||||
private static function empty(): array
|
||||
{
|
||||
return [
|
||||
'summary' => [
|
||||
'count' => 0,
|
||||
'by_reason' => [],
|
||||
'detail_state' => 'no_gaps',
|
||||
'recorded_subjects_total' => 0,
|
||||
'missing_detail_count' => 0,
|
||||
],
|
||||
'buckets' => [],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, int>
|
||||
*/
|
||||
private static function normalizeCounts(mixed $value): array
|
||||
{
|
||||
if (! is_array($value)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$normalized = [];
|
||||
|
||||
foreach ($value as $reason => $count) {
|
||||
if (! is_string($reason) || trim($reason) === '' || ! is_numeric($count)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$intCount = (int) $count;
|
||||
|
||||
if ($intCount <= 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$normalized[trim($reason)] = $intCount;
|
||||
}
|
||||
|
||||
arsort($normalized);
|
||||
|
||||
return $normalized;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, list<string>>
|
||||
*/
|
||||
private static function normalizeSubjects(mixed $value): array
|
||||
{
|
||||
if (! is_array($value)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$normalized = [];
|
||||
|
||||
foreach ($value as $reason => $keys) {
|
||||
if (! is_string($reason) || trim($reason) === '' || ! is_array($keys)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$items = array_values(array_unique(array_filter(array_map(
|
||||
static fn (mixed $item): ?string => is_string($item) && trim($item) !== '' ? trim($item) : null,
|
||||
$keys,
|
||||
))));
|
||||
|
||||
if ($items === []) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$normalized[trim($reason)] = $items;
|
||||
}
|
||||
|
||||
ksort($normalized);
|
||||
|
||||
return $normalized;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, int> $byReason
|
||||
* @param array<string, list<string>> $subjects
|
||||
* @return list<string>
|
||||
*/
|
||||
private static function orderedReasons(array $byReason, array $subjects): array
|
||||
{
|
||||
$reasons = array_keys($byReason);
|
||||
|
||||
foreach (array_keys($subjects) as $reason) {
|
||||
if (! in_array($reason, $reasons, true)) {
|
||||
$reasons[] = $reason;
|
||||
}
|
||||
}
|
||||
|
||||
return $reasons;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, int> $byReason
|
||||
* @param array<string, list<string>> $subjects
|
||||
*/
|
||||
private static function normalizeTotalCount(mixed $count, array $byReason, array $subjects): int
|
||||
{
|
||||
if (is_numeric($count)) {
|
||||
$intCount = (int) $count;
|
||||
|
||||
if ($intCount >= 0) {
|
||||
return $intCount;
|
||||
}
|
||||
}
|
||||
|
||||
$byReasonCount = array_sum($byReason);
|
||||
|
||||
if ($byReasonCount > 0) {
|
||||
return $byReasonCount;
|
||||
}
|
||||
|
||||
return array_sum(array_map(
|
||||
static fn (array $keys): int => count($keys),
|
||||
$subjects,
|
||||
));
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, list<string>> $subjects
|
||||
*/
|
||||
private static function detailState(int $count, array $subjects): string
|
||||
{
|
||||
if ($count <= 0) {
|
||||
return 'no_gaps';
|
||||
}
|
||||
|
||||
return $subjects !== [] ? 'details_recorded' : 'details_not_recorded';
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<string> $subjects
|
||||
* @return list<array{
|
||||
* reason_code: string,
|
||||
* reason_label: string,
|
||||
* policy_type: string,
|
||||
* subject_key: string,
|
||||
* search_text: string
|
||||
* }>
|
||||
*/
|
||||
private static function rowsForReason(string $reason, array $subjects): array
|
||||
{
|
||||
$rows = [];
|
||||
|
||||
foreach ($subjects as $subject) {
|
||||
[$policyType, $subjectKey] = self::splitSubject($subject);
|
||||
|
||||
if ($policyType === null || $subjectKey === null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$rows[] = [
|
||||
'reason_code' => $reason,
|
||||
'reason_label' => self::reasonLabel($reason),
|
||||
'policy_type' => $policyType,
|
||||
'subject_key' => $subjectKey,
|
||||
'search_text' => Str::lower(implode(' ', [
|
||||
$reason,
|
||||
self::reasonLabel($reason),
|
||||
$policyType,
|
||||
$subjectKey,
|
||||
])),
|
||||
];
|
||||
}
|
||||
|
||||
return $rows;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{0: ?string, 1: ?string}
|
||||
*/
|
||||
private static function splitSubject(string $subject): array
|
||||
{
|
||||
$parts = explode('|', $subject, 2);
|
||||
|
||||
if (count($parts) !== 2) {
|
||||
return [null, null];
|
||||
}
|
||||
|
||||
$policyType = trim($parts[0]);
|
||||
$subjectKey = trim($parts[1]);
|
||||
|
||||
if ($policyType === '' || $subjectKey === '') {
|
||||
return [null, null];
|
||||
}
|
||||
|
||||
return [$policyType, $subjectKey];
|
||||
}
|
||||
|
||||
private static function stringOrNull(mixed $value): ?string
|
||||
{
|
||||
if (! is_string($value)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$value = trim($value);
|
||||
|
||||
return $value !== '' ? $value : null;
|
||||
}
|
||||
|
||||
private static function intOrNull(mixed $value): ?int
|
||||
{
|
||||
return is_numeric($value) ? (int) $value : null;
|
||||
}
|
||||
}
|
||||
@ -24,6 +24,32 @@ final class BaselineCompareStats
|
||||
* @param array<string, int> $severityCounts
|
||||
* @param list<string> $uncoveredTypes
|
||||
* @param array<string, int> $evidenceGapsTopReasons
|
||||
* @param array{
|
||||
* summary: array{
|
||||
* count: int,
|
||||
* by_reason: array<string, int>,
|
||||
* detail_state: string,
|
||||
* recorded_subjects_total: int,
|
||||
* missing_detail_count: int
|
||||
* },
|
||||
* buckets: list<array{
|
||||
* reason_code: string,
|
||||
* reason_label: string,
|
||||
* count: int,
|
||||
* recorded_count: int,
|
||||
* missing_detail_count: int,
|
||||
* detail_state: string,
|
||||
* search_text: string,
|
||||
* rows: list<array{
|
||||
* reason_code: string,
|
||||
* reason_label: string,
|
||||
* policy_type: string,
|
||||
* subject_key: string,
|
||||
* search_text: string
|
||||
* }>
|
||||
* }>
|
||||
* } $evidenceGapDetails
|
||||
* @param array<string, mixed> $baselineCompareDiagnostics
|
||||
*/
|
||||
private function __construct(
|
||||
public readonly string $state,
|
||||
@ -47,6 +73,8 @@ private function __construct(
|
||||
public readonly ?int $evidenceGapsCount = null,
|
||||
public readonly array $evidenceGapsTopReasons = [],
|
||||
public readonly ?array $rbacRoleDefinitionSummary = null,
|
||||
public readonly array $evidenceGapDetails = [],
|
||||
public readonly array $baselineCompareDiagnostics = [],
|
||||
) {}
|
||||
|
||||
public static function forTenant(?Tenant $tenant): self
|
||||
@ -122,6 +150,8 @@ public static function forTenant(?Tenant $tenant): self
|
||||
[$evidenceGapsCount, $evidenceGapsTopReasons] = self::evidenceGapSummaryForRun($latestRun);
|
||||
[$reasonCode, $reasonMessage] = self::reasonInfoForRun($latestRun);
|
||||
$rbacRoleDefinitionSummary = self::rbacRoleDefinitionSummaryForRun($latestRun);
|
||||
$evidenceGapDetails = self::evidenceGapDetailsForRun($latestRun);
|
||||
$baselineCompareDiagnostics = self::baselineCompareDiagnosticsForRun($latestRun);
|
||||
|
||||
// Active run (queued/running)
|
||||
if ($latestRun instanceof OperationRun && in_array($latestRun->status, ['queued', 'running'], true)) {
|
||||
@ -147,6 +177,8 @@ public static function forTenant(?Tenant $tenant): self
|
||||
evidenceGapsCount: $evidenceGapsCount,
|
||||
evidenceGapsTopReasons: $evidenceGapsTopReasons,
|
||||
rbacRoleDefinitionSummary: $rbacRoleDefinitionSummary,
|
||||
evidenceGapDetails: $evidenceGapDetails,
|
||||
baselineCompareDiagnostics: $baselineCompareDiagnostics,
|
||||
);
|
||||
}
|
||||
|
||||
@ -179,6 +211,8 @@ public static function forTenant(?Tenant $tenant): self
|
||||
evidenceGapsCount: $evidenceGapsCount,
|
||||
evidenceGapsTopReasons: $evidenceGapsTopReasons,
|
||||
rbacRoleDefinitionSummary: $rbacRoleDefinitionSummary,
|
||||
evidenceGapDetails: $evidenceGapDetails,
|
||||
baselineCompareDiagnostics: $baselineCompareDiagnostics,
|
||||
);
|
||||
}
|
||||
|
||||
@ -233,6 +267,8 @@ public static function forTenant(?Tenant $tenant): self
|
||||
evidenceGapsCount: $evidenceGapsCount,
|
||||
evidenceGapsTopReasons: $evidenceGapsTopReasons,
|
||||
rbacRoleDefinitionSummary: $rbacRoleDefinitionSummary,
|
||||
evidenceGapDetails: $evidenceGapDetails,
|
||||
baselineCompareDiagnostics: $baselineCompareDiagnostics,
|
||||
);
|
||||
}
|
||||
|
||||
@ -261,6 +297,8 @@ public static function forTenant(?Tenant $tenant): self
|
||||
evidenceGapsCount: $evidenceGapsCount,
|
||||
evidenceGapsTopReasons: $evidenceGapsTopReasons,
|
||||
rbacRoleDefinitionSummary: $rbacRoleDefinitionSummary,
|
||||
evidenceGapDetails: $evidenceGapDetails,
|
||||
baselineCompareDiagnostics: $baselineCompareDiagnostics,
|
||||
);
|
||||
}
|
||||
|
||||
@ -286,6 +324,8 @@ public static function forTenant(?Tenant $tenant): self
|
||||
evidenceGapsCount: $evidenceGapsCount,
|
||||
evidenceGapsTopReasons: $evidenceGapsTopReasons,
|
||||
rbacRoleDefinitionSummary: $rbacRoleDefinitionSummary,
|
||||
evidenceGapDetails: $evidenceGapDetails,
|
||||
baselineCompareDiagnostics: $baselineCompareDiagnostics,
|
||||
);
|
||||
}
|
||||
|
||||
@ -515,48 +555,67 @@ private static function evidenceGapSummaryForRun(?OperationRun $run): array
|
||||
return [null, []];
|
||||
}
|
||||
|
||||
$details = self::evidenceGapDetailsForRun($run);
|
||||
$summary = is_array($details['summary'] ?? null) ? $details['summary'] : [];
|
||||
$count = is_numeric($summary['count'] ?? null) ? (int) $summary['count'] : null;
|
||||
$byReason = is_array($summary['by_reason'] ?? null) ? $summary['by_reason'] : [];
|
||||
|
||||
return [$count, array_slice($byReason, 0, 6, true)];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{
|
||||
* summary: array{
|
||||
* count: int,
|
||||
* by_reason: array<string, int>,
|
||||
* detail_state: string,
|
||||
* recorded_subjects_total: int,
|
||||
* missing_detail_count: int
|
||||
* },
|
||||
* buckets: list<array{
|
||||
* reason_code: string,
|
||||
* reason_label: string,
|
||||
* count: int,
|
||||
* recorded_count: int,
|
||||
* missing_detail_count: int,
|
||||
* detail_state: string,
|
||||
* search_text: string,
|
||||
* rows: list<array{
|
||||
* reason_code: string,
|
||||
* reason_label: string,
|
||||
* policy_type: string,
|
||||
* subject_key: string,
|
||||
* search_text: string
|
||||
* }>
|
||||
* }>
|
||||
* }
|
||||
*/
|
||||
private static function evidenceGapDetailsForRun(?OperationRun $run): array
|
||||
{
|
||||
if (! $run instanceof OperationRun) {
|
||||
return BaselineCompareEvidenceGapDetails::fromContext([]);
|
||||
}
|
||||
|
||||
return BaselineCompareEvidenceGapDetails::fromOperationRun($run);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private static function baselineCompareDiagnosticsForRun(?OperationRun $run): array
|
||||
{
|
||||
if (! $run instanceof OperationRun) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$context = is_array($run->context) ? $run->context : [];
|
||||
$baselineCompare = $context['baseline_compare'] ?? null;
|
||||
|
||||
if (! is_array($baselineCompare)) {
|
||||
return [null, []];
|
||||
return [];
|
||||
}
|
||||
|
||||
$gaps = $baselineCompare['evidence_gaps'] ?? null;
|
||||
|
||||
if (! is_array($gaps)) {
|
||||
return [null, []];
|
||||
}
|
||||
|
||||
$count = $gaps['count'] ?? null;
|
||||
$count = is_numeric($count) ? (int) $count : null;
|
||||
|
||||
$byReason = $gaps['by_reason'] ?? null;
|
||||
$byReason = is_array($byReason) ? $byReason : [];
|
||||
|
||||
$normalized = [];
|
||||
|
||||
foreach ($byReason as $reason => $value) {
|
||||
if (! is_string($reason) || trim($reason) === '' || ! is_numeric($value)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$intValue = (int) $value;
|
||||
|
||||
if ($intValue <= 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$normalized[trim($reason)] = $intValue;
|
||||
}
|
||||
|
||||
if ($count === null) {
|
||||
$count = array_sum($normalized);
|
||||
}
|
||||
|
||||
arsort($normalized);
|
||||
|
||||
return [$count, array_slice($normalized, 0, 6, true)];
|
||||
return BaselineCompareEvidenceGapDetails::diagnosticsPayload($baselineCompare);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@ -5,7 +5,7 @@ # Spec Candidates
|
||||
>
|
||||
> **Flow**: Inbox → Qualified → Planned → Spec created → moved to `Promoted to Spec`
|
||||
|
||||
**Last reviewed**: 2026-03-24 (added Operation Run Active-State Visibility & Stale Escalation candidate)
|
||||
**Last reviewed**: 2026-03-24 (added Baseline Compare Scope Guardrails & Ambiguity Guidance candidate)
|
||||
|
||||
---
|
||||
|
||||
@ -359,6 +359,50 @@ ### Baseline Snapshot Fidelity Semantics
|
||||
- **Dependencies**: Operator Outcome Taxonomy and Cross-Domain State Separation, Structured Snapshot Rendering (Spec 130)
|
||||
- **Priority**: high
|
||||
|
||||
### Baseline Compare Scope Guardrails & Ambiguity Guidance
|
||||
- **Type**: hardening
|
||||
- **Source**: product/operator-trust analysis 2026-03-24 — baseline compare ambiguity and scope communication review
|
||||
- **Vehicle**: new standalone candidate
|
||||
- **Problem**: Baseline Compare currently produces confusing results when the baseline snapshot contains generic Microsoft/Intune default objects or subjects with non-unique display names. Not-uniquely-matchable subjects are surfaced as "duplicates" in the UI, implying operator error even when the root cause is provider-side generic naming. Separate truth dimensions — identity confidence (could subjects be matched uniquely?), evidence fidelity (how deep was the compare?), and result trust (how reliable is the overall outcome?) — are collapsed into ambiguous operator signals such as `No Drift Detected` + `Limited confidence` + `Fidelity: Meta` without explaining whether the issue is baseline scope, generic names, limited compare capability, or actual tenant drift.
|
||||
- **Why it matters**: Operators reading compare results cannot distinguish between "everything is fine" and "we couldn't compare reliably." False reassurance (`No Drift Detected` at limited confidence) and false blame ("rename your duplicates" when subjects are provider-managed defaults) erode trust in the product's core governance promise. MSP operators managing baselines for multiple tenants need clear signals about what they can rely on and what requires scope curation — not academic-sounding fidelity labels next to misleading all-clear verdicts.
|
||||
- **Product decision**: Baseline Compare in V1 is designed for uniquely identifiable, intentionally curated governance policies — not for arbitrary tenant-wide default/enrollment/generic standard objects. When compare subjects cannot be reliably matched due to generic names or weak identity, TenantPilot treats this primarily as a scope/suitability problem of the current baseline content and a transparency/guidance topic in the product — not as an occasion for building a large identity classification engine.
|
||||
- **Proposed direction**:
|
||||
- **Compare wording correction**: replace pausal "rename your duplicates" messaging with neutral, scope-aware language explaining that some subjects cannot be matched uniquely by the current compare strategy, that this can happen with generic or provider-managed display names, and that the visible result is therefore only partially reliable
|
||||
- **Scope guidance on compare surfaces**: make explicit that Baseline Compare is for curated governance-scope policies, not for every tenant content. Baseline/capture surfaces must frame Golden Master as a deliberate governance scope, not an unfiltered tenant full-extract
|
||||
- **Actionable next-step guidance**: when ambiguity is detected, direct operators to review baseline profile scope, remove non-uniquely-identifiable subjects from governance scope, and re-run compare after scope cleanup — not to pauschal rename everything
|
||||
- **Meta-fidelity and limited-confidence separation**: separate identity-matchability, evidence/compare-depth, and overall result trustworthiness in the communication so operators can tell which dimension is limited and why
|
||||
- **Conservative framing for problematic V1 domains**: for known generically-named compare domains, allow conservative copy such as "not ideal for baseline compare," "limited compare confidence," "review scope before relying on result" — without introducing deep system-managed default detection
|
||||
- **Evidence/snapshot surface consistency**: terms like `Missing input`, `Not collected yet`, `Limited confidence` must not read as runtime errors when the actual issue is scope suitability
|
||||
- **Scope boundaries**:
|
||||
- **In scope**: compare result warning copy, limited-confidence explanation, next-step guidance, baseline profile/capture scope framing, conservative guardrail copy for problematic domains, evidence/snapshot surface term consistency
|
||||
- **Out of scope**: comprehensive Microsoft default policy detection, new global identity strategy engine, object-class-based system-managed vs user-managed classification, new deep fidelity matrix for all policy types, automatic exclusion or repair of problematic baseline items, compare engine architecture redesign
|
||||
- **UX direction**:
|
||||
- **Bad (current)**: "32 policies share the same display name" / "Please rename the duplicates" / `No Drift Detected` despite `Limited confidence`
|
||||
- **Good (target)**: neutral, honest, operator-actionable — e.g. "Some policies in the current baseline scope cannot be matched uniquely by the current compare strategy. This often happens with generic or provider-managed display names. Review your baseline scope and keep only uniquely identifiable governance policies before relying on this result."
|
||||
- **Acceptance criteria**:
|
||||
- AC1: ambiguous-match UI no longer pauschal blames operators for duplicates without explaining scope/generic-name context
|
||||
- AC2: limited-trust compare results are visually and linguistically distinguishable from fully reliable results; operators can tell the result is technically complete but content-wise only partially reliable
|
||||
- AC3: primary V1 guidance directs operators to baseline-scope review/cleanup and re-compare — not to pauschal rename or assume tenant misconfiguration
|
||||
- AC4: baseline/compare surfaces convey that Golden Master is a curated governance scope
|
||||
- AC5: `No Drift Detected` at `Limited confidence` is understandable as not-fully-trustworthy, not as definitive all-clear
|
||||
- **Tests / validation**:
|
||||
- Warning text for ambiguous matches uses neutral scope/matchability wording
|
||||
- Next-step guidance points to baseline scope review, not pauschal rename
|
||||
- `Limited confidence` + `No Drift Detected` is not presented as unambiguous all-clear
|
||||
- Baseline/compare surfaces include governance-scope hint
|
||||
- Known compare gaps do not produce misleading "user named everything wrong" messaging
|
||||
- Existing compare status/outcome logic remains intact
|
||||
- No new provider-specific special classification logic required for consistent UI
|
||||
- **Risks**:
|
||||
- R1: pure copy changes alone might address the symptom too weakly → mitigation: include scope/guidance framing, not just single-sentence edits
|
||||
- R2: too much guidance without technical guardrails might let operators keep building bad baselines → mitigation: conservative framing and later evolution via real usage data
|
||||
- R3: team reads this spec as a starting point for large identity architecture → mitigation: non-goals are explicitly and strictly scoped
|
||||
- **Roadmap fit**: Aligns directly with Release 1 — Golden Master Governance (R1.1 BaselineProfile, R1.3 baseline.compare, R1.4 Drift UI: Soll vs Ist). Improves V1 sellability without domain-model expansion: less confusing drift communication, clearer Golden Master story, no false operator blame, better trust basis for compare results.
|
||||
- **Dependencies**: Baseline drift engine stable (Specs 116–119), Baseline Snapshot Fidelity Semantics candidate (complementary — snapshot-level fidelity clarity), Operator Explanation Layer candidate (consumes explanation patterns), Governance Operator Outcome Compression candidate (complementary — governance artifact presentation)
|
||||
- **Related specs / candidates**: Specs 116–119 (baseline drift engine), Spec 101 (baseline governance), Baseline Capture Truthful Outcomes candidate, Baseline Snapshot Fidelity Semantics candidate, Operator Explanation Layer candidate, Governance Operator Outcome Compression candidate
|
||||
- **Recommendation**: Treat before any large matching/identity extension. Small enough for V1, reduces real operator confusion, protects against scope creep, and sharpens the product message: TenantPilot compares curated governance baselines — not blindly every generic tenant default content.
|
||||
- **Priority**: high
|
||||
|
||||
### Restore Lifecycle Semantic Clarity
|
||||
- **Type**: hardening
|
||||
- **Source**: semantic clarity & operator-language audit 2026-03-21
|
||||
@ -379,6 +423,70 @@ ### Inventory, Provider & Operability Semantics
|
||||
- **Dependencies**: Operator Outcome Taxonomy and Cross-Domain State Separation, provider connection vocabulary/cutover work, onboarding and verification spec family
|
||||
- **Priority**: medium
|
||||
|
||||
### Tenant Operational Readiness & Status Truth Hierarchy
|
||||
- **Type**: hardening
|
||||
- **Source**: product/operator-trust analysis 2026-03-24 — tenant-facing status presentation and source-of-truth hierarchy review
|
||||
- **Vehicle**: new standalone candidate
|
||||
- **Problem**: Tenant-facing surfaces expose multiple parallel status domains — lifecycle, legacy app status, provider connection state, provider health, verification report availability, RBAC readiness, and recent run evidence — without a clear hierarchy. Some domains are valid but poorly explained; at least one (`Tenant.app_status`) is legacy/orphaned truth still presented as if authoritative. The combined presentation does not answer the operator's actual question: "Can I trust this tenant right now, and is any action required?" Instead, operators must mentally reconcile six semi-related status fragments with no clear precedence, creating three distinct risks: legacy truth leakage (dead fields displayed as current truth), state collision without hierarchy (valid domains answering different questions but appearing to compete), and support/trust burden (operators asking why a tenant is "active" yet also "unknown," or provider is "connected" but health is "unknown," even when operational evidence proves usability).
|
||||
- **Why it matters**: TenantPilot is moving further into governance, evidence, reviews, drift, and portfolio visibility. As the product becomes more compliance- and operations-centric, source-of-truth quality on core tenant surfaces becomes more important, not less. If left unresolved: support load scales with tenant count, MSP operators learn to distrust or ignore status surfaces, future governance views inherit ambiguous foundations, and headline truth across baselines, evidence, findings, and reviews remains semantically inconsistent. For an enterprise governance platform, this is a product-truth and operator-confidence issue, not just a wording problem.
|
||||
- **Core insight**: Not every status belongs at the same level. The product currently exposes multiple truths belonging to different semantic layers:
|
||||
- **Layer 1 — Headline operator truth**: "Can I work with this tenant, and is action required?"
|
||||
- **Layer 2 — Domain truth**: lifecycle, provider consent/access, verification, RBAC, recent operational evidence
|
||||
- **Layer 3 — Diagnostic truth**: low-level or specialized states useful for investigation, not competing with headline summary
|
||||
- **Layer 4 — Legacy/orphaned truth**: stale, weakly maintained, deprecated, or no longer authoritative fields
|
||||
- **Proposed direction**:
|
||||
- **Headline readiness model**: define a single tenant-facing readiness summary answering whether the tenant is usable and whether action is needed. Concise operator-facing states such as: Ready, Ready with follow-up, Limited, Action required, Not ready.
|
||||
- **Source-of-truth hierarchy**: every tenant-facing status shown on primary surfaces classified as authoritative, derived, diagnostic, or legacy. Authoritative sources: lifecycle, canonical provider consent/access state, canonical verification state, RBAC readiness, recent operational evidence as supporting evidence.
|
||||
- **Domain breakdown beneath headline**: each supporting domain exists in a clearly subordinate role — lifecycle, provider access/consent, verification state, RBAC readiness, recent operational evidence.
|
||||
- **Action semantics clarity**: primary surfaces must distinguish between no action needed, recommended follow-up, required action, and informational only.
|
||||
- **Verification semantics**: UI must distinguish between never verified, verification unavailable, verification stale, verification failed, and verified but follow-up recommended. These must not collapse into ambiguous "unknown" messaging.
|
||||
- **Provider truth clarity**: provider access state must clearly differentiate access configured/consented, access verified, access usable but not freshly verified, access blocked or failed.
|
||||
- **RBAC semantics clarity**: RBAC readiness must clearly state whether write actions are blocked, without implying that all tenant operations are unavailable when read-only operations still function.
|
||||
- **Operational evidence handling**: recent successful operations may contribute supporting confidence, but must not silently overwrite or replace distinct provider verification truth.
|
||||
- **Legacy truth removal/demotion**: fields that are legacy, orphaned, or too weak to serve as source of truth must not remain prominent on tenant overview surfaces. Explicit disposition for orphaned fields like `Tenant.app_status`.
|
||||
- **Reusable semantics model**: the resulting truth hierarchy and readiness model must be reusable across tenant list/detail and future higher-level governance surfaces.
|
||||
- **Functional requirements**:
|
||||
- FR1 — Single tenant-facing readiness summary answering operability and action-needed
|
||||
- FR2 — Every primary-surface status classified as authoritative, derived, diagnostic, or legacy
|
||||
- FR3 — Legacy/orphaned fields not displayed as current operational truth on primary surfaces
|
||||
- FR4 — No peer-level contradiction on primary surfaces
|
||||
- FR5 — Verification semantics explicitly distinguishing not yet verified / unavailable / stale / failed / verified with follow-up
|
||||
- FR6 — Provider access state clearly differentiating configured, verified, usable-but-not-fresh, blocked
|
||||
- FR7 — RBAC readiness clarifying write-block vs full-block
|
||||
- FR8 — Operational evidence supportive but not substitutive for verification truth
|
||||
- FR9 — Actionability clarity on primary surfaces
|
||||
- FR10 — Reusable semantics for future governance surfaces
|
||||
- **UX/product rules**:
|
||||
- Same question, one answer: if several states contribute to the same operator decision, present one synthesized answer first
|
||||
- Summary before diagnostics: operator summary belongs first, domain detail underneath or behind expansion
|
||||
- "Unknown" is not enough: must not substitute for not checked, no report stored, stale result, legacy field, or unavailable artifact
|
||||
- Evidence is supportive, not substitutive: successful operations reinforce confidence but do not replace explicit verification
|
||||
- Lifecycle is not health: active does not mean provider access is verified or write operations are ready
|
||||
- Health is not onboarding history: historical onboarding verification is not automatically current operational truth
|
||||
- **Likely surfaces affected**:
|
||||
- Primary: tenant detail/overview page, tenant list presentation, tenant widgets/cards related to verification and recent operations, provider-related status presentation within tenant views, helper text/badge semantics on primary tenant surfaces
|
||||
- Secondary follow-up: provider connection detail pages, onboarding completion/follow-up states, future portfolio rollup views
|
||||
- **Scope boundaries**:
|
||||
- **In scope**: truth hierarchy definition, headline readiness model, tenant detail/overview presentation rules, provider state presentation rules on tenant surfaces, verification semantics on tenant surfaces, RBAC relationship to readiness, role of recent operational evidence, legacy truth cleanup on primary tenant surfaces
|
||||
- **Out of scope**: redesigning OperationRun result semantics in general, revisiting every badge/helper in the product, evidence/reporting semantics outside tenant readiness, changing onboarding lifecycle requirements unless directly necessary for truth consistency, provider architecture overhaul, full data-model cleanup beyond what is needed to remove legacy truth from primary surfaces, full badge taxonomy standardization everywhere, color palette / visual design overhaul, findings severity or workflow semantics, broad IA/navigation redesign, portfolio-level rollup semantics beyond stating compatibility goals
|
||||
- **Acceptance criteria**:
|
||||
- AC1: Primary tenant surfaces present a single operator-facing readiness truth rather than several equal-weight raw statuses
|
||||
- AC2: Lifecycle, provider access, verification, RBAC, and operational evidence shown with explicit semantic roles and no ambiguous precedence
|
||||
- AC3: Legacy/orphaned status fields no longer presented as live operational truth on primary surfaces
|
||||
- AC4: System clearly differentiates not yet verified / verification unavailable / stale / failed
|
||||
- AC5: Operator can tell within seconds whether tenant is usable / usable with follow-up / limited / blocked / in need of action
|
||||
- AC6: Recent successful operations reinforce confidence where appropriate but do not silently overwrite explicit verification truth
|
||||
- AC7: Primary tenant status communication suitable for MSP/enterprise use without requiring tribal knowledge to interpret contradictions
|
||||
- **Boundary with Tenant App Status False-Truth Removal**: That candidate is a quick, bounded removal of the single most obvious legacy truth field (`Tenant.app_status`). This candidate defines the broader truth hierarchy and presentation model that decides how all tenant status domains interrelate. The false-truth removal is a subset action that can proceed independently as a quick win; this candidate provides the architectural direction that prevents future truth leakage.
|
||||
- **Boundary with Provider Connection Status Vocabulary Cutover**: Provider vocabulary cutover owns the `ProviderConnection` model-level status field cleanup (canonical enum sources replacing legacy varchar fields). This candidate owns how provider truth appears on tenant-level surfaces in relation to other readiness domains. The vocabulary cutover provides cleaner provider truth; this candidate defines where and how that truth is presented alongside lifecycle, verification, RBAC, and evidence.
|
||||
- **Boundary with Inventory, Provider & Operability Semantics**: That candidate normalizes how inventory capture mode, provider health/prerequisites, and verification results are presented on their own surfaces. This candidate defines the tenant-level integration layer that synthesizes those domain truths into a single headline readiness answer. Complementary: domain-level semantic cleanup feeds into the readiness model.
|
||||
- **Boundary with Operator Explanation Layer**: The explanation layer defines cross-cutting interpretation patterns for degraded/partial/suppressed results. This candidate defines the tenant-specific readiness truth hierarchy. The explanation layer may inform how degraded readiness states are explained, but the readiness model itself is tenant-domain-specific.
|
||||
- **Boundary with Governance Operator Outcome Compression**: Governance compression improves governance artifact scan surfaces. This candidate improves tenant operational readiness surfaces. Both reduce operator cognitive load but on different product domains.
|
||||
- **Dependencies**: Operator Outcome Taxonomy and Cross-Domain State Separation (shared vocabulary foundation), Tenant App Status False-Truth Removal (quick win that removes the most obvious legacy truth — can proceed independently before or during this spec), Provider Connection Status Vocabulary Cutover (cleaner provider truth sources — soft dependency), existing tenant lifecycle semantics (Spec 143)
|
||||
- **Related specs / candidates**: Spec 143 (tenant lifecycle operability context semantics), Tenant App Status False-Truth Removal, Provider Connection Status Vocabulary Cutover, Provider Connection UX Clarity, Inventory Provider & Operability Semantics, Operator Outcome Taxonomy (Spec 156), Operation Run Active-State Visibility & Stale Escalation
|
||||
- **Strategic sequencing**: Best tackled after the Operator Outcome Taxonomy provides shared vocabulary and after Tenant App Status False-Truth Removal removes the most obvious legacy truth as a quick win. Should land before major portfolio, MSP rollup, or compliance readiness surfaces are built, since those surfaces will inherit the tenant readiness model as a foundational input.
|
||||
- **Priority**: high
|
||||
|
||||
### Exception / Risk-Acceptance Workflow for Findings
|
||||
- **Type**: feature
|
||||
- **Source**: HANDOVER gap analysis, Spec 111 follow-up
|
||||
|
||||
@ -29,6 +29,25 @@
|
||||
'badge_fidelity' => 'Fidelity: :level',
|
||||
'badge_evidence_gaps' => 'Evidence gaps: :count',
|
||||
'evidence_gaps_tooltip' => 'Top gaps: :summary',
|
||||
'evidence_gap_details_heading' => 'Evidence gap details',
|
||||
'evidence_gap_details_description' => 'Search recorded gap subjects by reason, policy type, or subject key before falling back to raw diagnostics.',
|
||||
'evidence_gap_search_label' => 'Search gap details',
|
||||
'evidence_gap_search_placeholder' => 'Search by reason, policy type, or subject key',
|
||||
'evidence_gap_search_help' => 'Filter matches across reason, policy type, and subject key.',
|
||||
'evidence_gap_bucket_help' => 'Reason summaries stay separate from the detailed row table below.',
|
||||
'evidence_gap_reason' => 'Reason',
|
||||
'evidence_gap_reason_affected' => ':count affected',
|
||||
'evidence_gap_reason_recorded' => ':count recorded',
|
||||
'evidence_gap_reason_missing_detail' => ':count missing detail',
|
||||
'evidence_gap_missing_details_title' => 'Detailed rows were not recorded for this run',
|
||||
'evidence_gap_missing_details_body' => 'Evidence gaps were counted for this compare run, but subject-level detail was not stored. Review the raw diagnostics below or rerun the comparison for fresh detail.',
|
||||
'evidence_gap_missing_reason_body' => ':count affected subjects were counted for this reason, but detailed rows were not recorded for this run.',
|
||||
'evidence_gap_diagnostics_heading' => 'Baseline compare evidence',
|
||||
'evidence_gap_diagnostics_description' => 'Raw diagnostics remain available for support and deep troubleshooting after the operator summary and searchable detail.',
|
||||
'evidence_gap_policy_type' => 'Policy type',
|
||||
'evidence_gap_subject_key' => 'Subject key',
|
||||
'evidence_gap_table_empty_heading' => 'No recorded gap rows match this view',
|
||||
'evidence_gap_table_empty_description' => 'Adjust the current search or filters to review other affected subjects.',
|
||||
|
||||
// Comparing state
|
||||
'comparing_indicator' => 'Comparing…',
|
||||
|
||||
118
public/js/tenantpilot/unhandled-rejection-logger.js
Normal file
118
public/js/tenantpilot/unhandled-rejection-logger.js
Normal file
@ -0,0 +1,118 @@
|
||||
(() => {
|
||||
if (typeof window === 'undefined') {
|
||||
return;
|
||||
}
|
||||
|
||||
if (window.__tenantpilotUnhandledRejectionLoggerApplied) {
|
||||
return;
|
||||
}
|
||||
|
||||
window.__tenantpilotUnhandledRejectionLoggerApplied = true;
|
||||
|
||||
const recentKeys = new Map();
|
||||
|
||||
const cleanupRecentKeys = (nowMs) => {
|
||||
for (const [key, timestampMs] of recentKeys.entries()) {
|
||||
if (nowMs - timestampMs > 5_000) {
|
||||
recentKeys.delete(key);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const normalizeReason = (value, depth = 0) => {
|
||||
if (depth > 3) {
|
||||
return '[max-depth-reached]';
|
||||
}
|
||||
|
||||
if (value instanceof Error) {
|
||||
return {
|
||||
type: 'Error',
|
||||
name: value.name,
|
||||
message: value.message,
|
||||
stack: value.stack,
|
||||
};
|
||||
}
|
||||
|
||||
if (value === null || value === undefined) {
|
||||
return value;
|
||||
}
|
||||
|
||||
if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') {
|
||||
return value;
|
||||
}
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
return value.slice(0, 10).map((item) => normalizeReason(item, depth + 1));
|
||||
}
|
||||
|
||||
if (typeof value === 'object') {
|
||||
const result = {};
|
||||
const allowedKeys = [
|
||||
'message',
|
||||
'stack',
|
||||
'name',
|
||||
'type',
|
||||
'status',
|
||||
'body',
|
||||
'json',
|
||||
'errors',
|
||||
'reason',
|
||||
'code',
|
||||
];
|
||||
|
||||
for (const key of allowedKeys) {
|
||||
if (Object.prototype.hasOwnProperty.call(value, key)) {
|
||||
result[key] = normalizeReason(value[key], depth + 1);
|
||||
}
|
||||
}
|
||||
|
||||
if (Object.keys(result).length > 0) {
|
||||
return result;
|
||||
}
|
||||
|
||||
const stringTag = Object.prototype.toString.call(value);
|
||||
|
||||
return {
|
||||
type: stringTag,
|
||||
value: String(value),
|
||||
};
|
||||
}
|
||||
|
||||
return String(value);
|
||||
};
|
||||
|
||||
const toStableJson = (payload) => {
|
||||
try {
|
||||
return JSON.stringify(payload);
|
||||
} catch {
|
||||
return JSON.stringify({
|
||||
source: payload.source,
|
||||
href: payload.href,
|
||||
timestamp: payload.timestamp,
|
||||
reason: '[unserializable]',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('unhandledrejection', (event) => {
|
||||
const payload = {
|
||||
source: 'window.unhandledrejection',
|
||||
href: window.location.href,
|
||||
timestamp: new Date().toISOString(),
|
||||
reason: normalizeReason(event.reason),
|
||||
};
|
||||
|
||||
const payloadJson = toStableJson(payload);
|
||||
const nowMs = Date.now();
|
||||
|
||||
cleanupRecentKeys(nowMs);
|
||||
|
||||
if (recentKeys.has(payloadJson)) {
|
||||
return;
|
||||
}
|
||||
|
||||
recentKeys.set(payloadJson, nowMs);
|
||||
|
||||
console.error(`TenantPilot unhandled promise rejection ${payloadJson}`);
|
||||
});
|
||||
})();
|
||||
@ -0,0 +1,98 @@
|
||||
@php
|
||||
$summary = is_array($summary ?? null) ? $summary : [];
|
||||
$buckets = is_array($buckets ?? null) ? $buckets : [];
|
||||
$detailState = is_string($summary['detail_state'] ?? null) ? $summary['detail_state'] : 'no_gaps';
|
||||
$tableContext = is_string($searchId ?? null) && $searchId !== '' ? $searchId : 'evidence-gap-search';
|
||||
@endphp
|
||||
|
||||
@if ($detailState === 'details_not_recorded' && $buckets === [])
|
||||
<div class="rounded-xl border border-warning-300 bg-warning-50/80 p-4 dark:border-warning-800 dark:bg-warning-950/30">
|
||||
<div class="space-y-1">
|
||||
<div class="text-sm font-semibold text-warning-950 dark:text-warning-100">
|
||||
{{ __('baseline-compare.evidence_gap_missing_details_title') }}
|
||||
</div>
|
||||
<p class="text-sm text-warning-900 dark:text-warning-200">
|
||||
{{ __('baseline-compare.evidence_gap_missing_details_body') }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@elseif ($buckets !== [])
|
||||
<div class="space-y-4">
|
||||
@if ($detailState === 'details_not_recorded')
|
||||
<div class="rounded-xl border border-warning-300 bg-warning-50/80 p-4 dark:border-warning-800 dark:bg-warning-950/30">
|
||||
<div class="space-y-1">
|
||||
<div class="text-sm font-semibold text-warning-950 dark:text-warning-100">
|
||||
{{ __('baseline-compare.evidence_gap_missing_details_title') }}
|
||||
</div>
|
||||
<p class="text-sm text-warning-900 dark:text-warning-200">
|
||||
{{ __('baseline-compare.evidence_gap_missing_details_body') }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<div class="grid gap-4 lg:grid-cols-2 xl:grid-cols-3">
|
||||
@foreach ($buckets as $bucket)
|
||||
@php
|
||||
$reasonLabel = is_string($bucket['reason_label'] ?? null) ? $bucket['reason_label'] : 'Evidence gap';
|
||||
$count = is_numeric($bucket['count'] ?? null) ? (int) $bucket['count'] : 0;
|
||||
$recordedCount = is_numeric($bucket['recorded_count'] ?? null) ? (int) $bucket['recorded_count'] : 0;
|
||||
$missingDetailCount = is_numeric($bucket['missing_detail_count'] ?? null) ? (int) $bucket['missing_detail_count'] : 0;
|
||||
@endphp
|
||||
|
||||
<section class="rounded-xl border border-gray-200 bg-white p-4 shadow-sm dark:border-gray-800 dark:bg-gray-900/70">
|
||||
<div class="space-y-3">
|
||||
<div class="space-y-1">
|
||||
<h4 class="text-sm font-semibold text-gray-950 dark:text-white">
|
||||
{{ $reasonLabel }}
|
||||
</h4>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400">
|
||||
{{ __('baseline-compare.evidence_gap_bucket_help') }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<span class="inline-flex items-center rounded-full bg-warning-100 px-2.5 py-1 text-xs font-medium text-warning-900 dark:bg-warning-900/40 dark:text-warning-100">
|
||||
{{ __('baseline-compare.evidence_gap_reason_affected', ['count' => $count]) }}
|
||||
</span>
|
||||
<span class="inline-flex items-center rounded-full bg-primary-100 px-2.5 py-1 text-xs font-medium text-primary-900 dark:bg-primary-900/40 dark:text-primary-100">
|
||||
{{ __('baseline-compare.evidence_gap_reason_recorded', ['count' => $recordedCount]) }}
|
||||
</span>
|
||||
@if ($missingDetailCount > 0)
|
||||
<span class="inline-flex items-center rounded-full bg-gray-100 px-2.5 py-1 text-xs font-medium text-gray-700 dark:bg-gray-800 dark:text-gray-200">
|
||||
{{ __('baseline-compare.evidence_gap_reason_missing_detail', ['count' => $missingDetailCount]) }}
|
||||
</span>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
@if ($missingDetailCount > 0)
|
||||
<div class="rounded-lg border border-gray-200 bg-gray-50 px-3 py-3 text-sm text-gray-700 dark:border-gray-800 dark:bg-gray-950/60 dark:text-gray-200">
|
||||
{{ __('baseline-compare.evidence_gap_missing_reason_body', ['count' => $missingDetailCount]) }}
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
</section>
|
||||
@endforeach
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<div class="space-y-1">
|
||||
<h4 class="text-sm font-semibold text-gray-950 dark:text-white">
|
||||
{{ __('baseline-compare.evidence_gap_search_label') }}
|
||||
</h4>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400">
|
||||
{{ __('baseline-compare.evidence_gap_search_help') }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@livewire(
|
||||
\App\Livewire\BaselineCompareEvidenceGapTable::class,
|
||||
[
|
||||
'buckets' => $buckets,
|
||||
'context' => $tableContext,
|
||||
],
|
||||
key('baseline-compare-evidence-gap-table-'.$tableContext)
|
||||
)
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
@ -353,6 +353,20 @@ class="w-fit"
|
||||
</x-filament::section>
|
||||
@endif
|
||||
|
||||
@if ($hasEvidenceGapDetailSection)
|
||||
<x-filament::section :heading="__('baseline-compare.evidence_gap_details_heading')">
|
||||
<x-slot name="description">
|
||||
{{ __('baseline-compare.evidence_gap_details_description') }}
|
||||
</x-slot>
|
||||
|
||||
@include('filament.infolists.entries.evidence-gap-subjects', [
|
||||
'summary' => $evidenceGapSummary,
|
||||
'buckets' => $evidenceGapBuckets ?? [],
|
||||
'searchId' => 'tenant-baseline-compare-gap-search',
|
||||
])
|
||||
</x-filament::section>
|
||||
@endif
|
||||
|
||||
{{-- Severity breakdown + actions --}}
|
||||
@if ($state === 'ready' && ($findingsCount ?? 0) > 0)
|
||||
<x-filament::section>
|
||||
@ -476,4 +490,14 @@ class="w-fit"
|
||||
</div>
|
||||
</x-filament::section>
|
||||
@endif
|
||||
|
||||
@if ($hasEvidenceGapDiagnostics)
|
||||
<x-filament::section :heading="__('baseline-compare.evidence_gap_diagnostics_heading')">
|
||||
<x-slot name="description">
|
||||
{{ __('baseline-compare.evidence_gap_diagnostics_description') }}
|
||||
</x-slot>
|
||||
|
||||
@include('filament.partials.json-viewer', ['value' => $baselineCompareDiagnostics])
|
||||
</x-filament::section>
|
||||
@endif
|
||||
</x-filament::page>
|
||||
|
||||
@ -1,3 +1,4 @@
|
||||
<script src="{{ asset('js/tenantpilot/livewire-intercept-shim.js') }}"></script>
|
||||
<script src="{{ asset('js/tenantpilot/filament-sidebar-store-fallback.js') }}"></script>
|
||||
<script src="{{ asset('js/tenantpilot/ops-ux-progress-widget-poller.js') }}"></script>
|
||||
<script src="{{ asset('js/tenantpilot/unhandled-rejection-logger.js') }}"></script>
|
||||
|
||||
@ -0,0 +1,3 @@
|
||||
<div class="space-y-2">
|
||||
{{ $this->table }}
|
||||
</div>
|
||||
36
specs/162-baseline-gap-details/checklists/requirements.md
Normal file
36
specs/162-baseline-gap-details/checklists/requirements.md
Normal file
@ -0,0 +1,36 @@
|
||||
# Specification Quality Checklist: Enterprise Evidence Gap Details for Baseline Compare
|
||||
|
||||
**Purpose**: Validate specification completeness and quality before proceeding to planning
|
||||
**Created**: 2026-03-24
|
||||
**Feature**: [spec.md](../spec.md)
|
||||
|
||||
## Content Quality
|
||||
|
||||
- [x] No implementation details (languages, frameworks, APIs)
|
||||
- [x] Focused on user value and business needs
|
||||
- [x] Written for non-technical stakeholders
|
||||
- [x] All mandatory sections completed
|
||||
|
||||
## Requirement Completeness
|
||||
|
||||
- [x] No [NEEDS CLARIFICATION] markers remain
|
||||
- [x] Requirements are testable and unambiguous
|
||||
- [x] Success criteria are measurable
|
||||
- [x] Success criteria are technology-agnostic (no implementation details)
|
||||
- [x] All acceptance scenarios are defined
|
||||
- [x] Edge cases are identified
|
||||
- [x] Scope is clearly bounded
|
||||
- [x] Dependencies and assumptions identified
|
||||
|
||||
## Feature Readiness
|
||||
|
||||
- [x] All functional requirements have clear acceptance criteria
|
||||
- [x] User scenarios cover primary flows
|
||||
- [x] Feature meets measurable outcomes defined in Success Criteria
|
||||
- [x] No implementation details leak into specification
|
||||
|
||||
## Notes
|
||||
|
||||
- Validation pass completed on 2026-03-24.
|
||||
- Spec intentionally covers both canonical Monitoring → Operation Run Detail and tenant-scoped Baseline Compare review because operators move between both surfaces during the same investigation flow.
|
||||
- No clarification blockers remain; spec is ready for /speckit.plan.
|
||||
@ -0,0 +1,108 @@
|
||||
openapi: 3.1.0
|
||||
info:
|
||||
title: Baseline Compare Evidence Gap Details Contract
|
||||
version: 0.1.0
|
||||
summary: Internal reference schema for the shared operator-safe evidence-gap read model.
|
||||
description: |
|
||||
This artifact defines the stable read shape for baseline compare evidence-gap detail.
|
||||
It is designed for canonical Monitoring run detail and tenant baseline compare review surfaces.
|
||||
In this feature slice it is a reference schema for the shared page read model, not a commitment to add new HTTP endpoints.
|
||||
servers:
|
||||
- url: https://tenantpilot.local
|
||||
paths: {}
|
||||
components:
|
||||
schemas:
|
||||
OperationRunReference:
|
||||
type: object
|
||||
required:
|
||||
- id
|
||||
- type
|
||||
- status
|
||||
- outcome
|
||||
properties:
|
||||
id:
|
||||
type: integer
|
||||
minimum: 1
|
||||
type:
|
||||
type: string
|
||||
enum:
|
||||
- baseline_compare
|
||||
status:
|
||||
type: string
|
||||
outcome:
|
||||
type: string
|
||||
tenant_id:
|
||||
type: integer
|
||||
nullable: true
|
||||
workspace_id:
|
||||
type: integer
|
||||
nullable: true
|
||||
EvidenceGapBucket:
|
||||
type: object
|
||||
required:
|
||||
- reason_code
|
||||
- reason_label
|
||||
- count
|
||||
- rows
|
||||
properties:
|
||||
reason_code:
|
||||
type: string
|
||||
reason_label:
|
||||
type: string
|
||||
count:
|
||||
type: integer
|
||||
minimum: 0
|
||||
rows:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/EvidenceGapRow'
|
||||
EvidenceGapRow:
|
||||
type: object
|
||||
required:
|
||||
- reason_code
|
||||
- policy_type
|
||||
- subject_key
|
||||
properties:
|
||||
reason_code:
|
||||
type: string
|
||||
policy_type:
|
||||
type: string
|
||||
subject_key:
|
||||
type: string
|
||||
EvidenceGapSummary:
|
||||
type: object
|
||||
required:
|
||||
- count
|
||||
- by_reason
|
||||
- detail_state
|
||||
properties:
|
||||
count:
|
||||
type: integer
|
||||
minimum: 0
|
||||
by_reason:
|
||||
type: object
|
||||
additionalProperties:
|
||||
type: integer
|
||||
minimum: 0
|
||||
detail_state:
|
||||
type: string
|
||||
enum:
|
||||
- no_gaps
|
||||
- details_recorded
|
||||
- details_not_recorded
|
||||
EvidenceGapDetailResponse:
|
||||
type: object
|
||||
required:
|
||||
- summary
|
||||
- buckets
|
||||
properties:
|
||||
summary:
|
||||
$ref: '#/components/schemas/EvidenceGapSummary'
|
||||
buckets:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/EvidenceGapBucket'
|
||||
filters:
|
||||
type: object
|
||||
additionalProperties:
|
||||
type: string
|
||||
139
specs/162-baseline-gap-details/data-model.md
Normal file
139
specs/162-baseline-gap-details/data-model.md
Normal file
@ -0,0 +1,139 @@
|
||||
# Data Model: Enterprise Evidence Gap Details for Baseline Compare
|
||||
|
||||
## Overview
|
||||
|
||||
This feature does not introduce new database tables. It extends the tenant-owned `OperationRun` JSONB context for `baseline_compare` runs and defines a derived operator-facing row model for evidence-gap detail rendering.
|
||||
|
||||
## Entity: OperationRun
|
||||
|
||||
- Ownership: Tenant-owned operational artifact with `workspace_id` and `tenant_id`
|
||||
- Storage: Existing `operation_runs` table
|
||||
- Relevant relationships:
|
||||
- Belongs to `Tenant`
|
||||
- Belongs to `Workspace`
|
||||
- Belongs to initiating `User`
|
||||
- Relevant invariant:
|
||||
- `status` and `outcome` remain service-owned through `OperationRunService`
|
||||
- `context` may be enriched by the compare job without changing lifecycle semantics
|
||||
|
||||
## Subdocument: `baseline_compare`
|
||||
|
||||
Stored under `OperationRun.context['baseline_compare']`.
|
||||
|
||||
### Fields
|
||||
|
||||
| Field | Type | Required | Description |
|
||||
|---|---|---|---|
|
||||
| `inventory_sync_run_id` | integer | no | Source inventory sync run used for coverage and freshness context |
|
||||
| `subjects_total` | integer | no | Total compare subjects considered for the run |
|
||||
| `evidence_capture` | object | no | Capture stats such as requested, succeeded, skipped, failed, throttled |
|
||||
| `coverage` | object | no | Coverage proof and covered/uncovered policy types |
|
||||
| `fidelity` | string | no | Compare fidelity, typically `meta` or `content` |
|
||||
| `reason_code` | string | no | Top-level compare reason code used by the explanation layer |
|
||||
| `resume_token` | string or null | no | Resume state for incomplete content capture |
|
||||
| `evidence_gaps` | object | no | Aggregate and subject-level evidence-gap contract |
|
||||
|
||||
## Subdocument: `baseline_compare.evidence_gaps`
|
||||
|
||||
### Fields
|
||||
|
||||
| Field | Type | Required | Description |
|
||||
|---|---|---|---|
|
||||
| `count` | integer | no | Total evidence-gap count across all reasons |
|
||||
| `by_reason` | object<string,int> | no | Aggregate counts keyed by evidence-gap reason code |
|
||||
| `subjects` | object<string,list<string>> | no | Bounded reason-grouped list of concrete subject keys |
|
||||
|
||||
### Validation Rules
|
||||
|
||||
- `count` must be a non-negative integer.
|
||||
- Each `by_reason` value must be a non-negative integer.
|
||||
- Each `subjects` key must be a non-empty reason code string.
|
||||
- Each `subjects[reason]` list item must be a non-empty string in `policy_type|subject_key` format.
|
||||
- Subject lists are deduplicated per reason.
|
||||
- Subject lists are capped at the compare job limit, currently 50 items per reason.
|
||||
|
||||
## Derived Entity: EvidenceGapDetailRow
|
||||
|
||||
This is not stored separately. It is derived from `baseline_compare.evidence_gaps.subjects` for rendering and filtering.
|
||||
|
||||
### Fields
|
||||
|
||||
| Field | Type | Source | Description |
|
||||
|---|---|---|---|
|
||||
| `reason_code` | string | map key | Stable reason identifier such as `ambiguous_match` or `policy_not_found` |
|
||||
| `reason_label` | string | derived | Operator-facing label from `reason_code` |
|
||||
| `policy_type` | string | parsed from subject string | Policy family segment before the first pipe |
|
||||
| `subject_key` | string | parsed from subject string | Subject identity segment after the first pipe |
|
||||
| `search_text` | string | derived | Lowercased concatenation of reason, policy type, and subject key for local filtering |
|
||||
|
||||
### Validation Rules
|
||||
|
||||
- `policy_type` must be non-empty.
|
||||
- `subject_key` may be human-readable text, GUID-like values, or workspace-safe identifiers, but must be non-empty once persisted.
|
||||
- `search_text` must be deterministic and derived only from persisted row values.
|
||||
|
||||
## Derived Entity: EvidenceGapReasonBucket
|
||||
|
||||
Groups detail rows for rendering.
|
||||
|
||||
### Fields
|
||||
|
||||
| Field | Type | Description |
|
||||
|---|---|---|
|
||||
| `reason_code` | string | Stable bucket key |
|
||||
| `reason_label` | string | Operator-facing label |
|
||||
| `count` | integer | Number of visible persisted subjects in the bucket |
|
||||
| `rows` | list<EvidenceGapDetailRow> | Rows shown for the reason |
|
||||
|
||||
## State Model
|
||||
|
||||
### Run detail evidence-gap states
|
||||
|
||||
1. `NoGaps`
|
||||
- `evidence_gaps.count` is absent or `0`
|
||||
- No evidence-gap detail section is required
|
||||
|
||||
2. `GapsWithRecordedSubjects`
|
||||
- `evidence_gaps.count > 0`
|
||||
- `evidence_gaps.subjects` exists with at least one non-empty reason bucket
|
||||
- Render searchable grouped rows
|
||||
|
||||
3. `GapsWithoutRecordedSubjects`
|
||||
- `evidence_gaps.count > 0`
|
||||
- `evidence_gaps.subjects` is absent or empty
|
||||
- Render explicit fallback copy indicating detail was not recorded for that run
|
||||
|
||||
## Example Payload
|
||||
|
||||
```json
|
||||
{
|
||||
"baseline_compare": {
|
||||
"subjects_total": 50,
|
||||
"fidelity": "meta",
|
||||
"reason_code": "evidence_capture_incomplete",
|
||||
"evidence_capture": {
|
||||
"requested": 50,
|
||||
"succeeded": 47,
|
||||
"skipped": 0,
|
||||
"failed": 3,
|
||||
"throttled": 0
|
||||
},
|
||||
"evidence_gaps": {
|
||||
"count": 5,
|
||||
"by_reason": {
|
||||
"ambiguous_match": 3,
|
||||
"policy_not_found": 2
|
||||
},
|
||||
"subjects": {
|
||||
"ambiguous_match": [
|
||||
"deviceConfiguration|WiFi-Corp-Profile",
|
||||
"deviceConfiguration|VPN-Always-On"
|
||||
],
|
||||
"policy_not_found": [
|
||||
"deviceConfiguration|Deleted-Policy-ABC"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
139
specs/162-baseline-gap-details/plan.md
Normal file
139
specs/162-baseline-gap-details/plan.md
Normal file
@ -0,0 +1,139 @@
|
||||
# Implementation Plan: Enterprise Evidence Gap Details for Baseline Compare
|
||||
|
||||
**Branch**: `162-baseline-gap-details` | **Date**: 2026-03-24 | **Spec**: `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/specs/162-baseline-gap-details/spec.md`
|
||||
**Input**: Feature specification from `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/specs/162-baseline-gap-details/spec.md`
|
||||
|
||||
## Summary
|
||||
|
||||
Expose baseline compare evidence gaps as an operator-first, searchable, tenant-safe detail experience backed by immutable `OperationRun.context` data. The implementation keeps compare execution and `OperationRun` lifecycle unchanged, persists bounded per-reason subject details during compare/capture work, renders them in the canonical run-detail and tenant compare surfaces ahead of raw JSON diagnostics, and validates the behavior with Pest feature coverage for persistence, rendering, and legacy-run fallbacks.
|
||||
|
||||
## Technical Context
|
||||
|
||||
**Language/Version**: PHP 8.4 / Laravel 12, Blade, Alpine via Filament, Tailwind CSS v4
|
||||
**Primary Dependencies**: Filament v5, Livewire v4, Pest v4, Laravel Sail, existing `OperationRun` and baseline compare services
|
||||
**Storage**: PostgreSQL with JSONB-backed `operation_runs.context`; no new tables required
|
||||
**Testing**: Pest feature tests run through Sail, plus existing Filament page rendering tests
|
||||
**Target Platform**: Laravel web application in Sail locally and Linux container deployment in staging/production
|
||||
**Project Type**: Web application
|
||||
**Performance Goals**: DB-only render path; no render-time Graph calls; searchable gap detail remains usable for bounded per-reason subject lists; operator can isolate a relevant subject in under 30 seconds
|
||||
**Constraints**: Preserve `OperationRunService` ownership of status/outcome, keep evidence-gap JSON bounded by existing caps, retain tenant-safe canonical monitoring behavior, add no destructive actions, and avoid new global/published Filament assets
|
||||
**Scale/Scope**: Tenant-scoped baseline compare runs for enterprise tenants, subject-detail persistence within the existing compare job/capture pipeline, read-only UX changes across the canonical run detail and tenant baseline compare landing surfaces
|
||||
|
||||
## Constitution Check
|
||||
|
||||
*GATE: Passed before Phase 0 research. Re-checked after Phase 1 design and still passing.*
|
||||
|
||||
- Inventory-first: Pass. This feature reads existing inventory and baseline snapshot outputs and only improves how compare gaps are persisted and presented.
|
||||
- Read/write separation: Pass. Compare initiation remains unchanged; the new surface is read-only and introduces no new mutation path.
|
||||
- Graph contract path: Pass. No new Graph calls or contract bypasses are introduced; all render behavior remains DB-only.
|
||||
- Deterministic capabilities: Pass. No new capability derivation is introduced.
|
||||
- RBAC-UX plane separation and tenant safety: Pass. Canonical `/admin/operations/{run}` remains tenant-safe through existing workspace + tenant entitlement checks, and tenant landing remains tenant-scoped.
|
||||
- Workspace isolation: Pass. No workspace-context semantics are broadened.
|
||||
- Destructive confirmation: Pass. No destructive action is added or changed.
|
||||
- Global search safety: Pass. No global-search behavior is introduced or modified.
|
||||
- Tenant isolation: Pass. Evidence-gap detail is persisted on the tenant-owned `OperationRun` and revealed only through already authorized run/tenant surfaces.
|
||||
- Run observability: Pass. Existing baseline compare `OperationRun` behavior stays intact; this feature enriches context only.
|
||||
- Ops-UX 3-surface feedback: Pass. No additional toasts, progress surfaces, or terminal notifications are introduced.
|
||||
- Ops-UX lifecycle ownership: Pass. `status`/`outcome` transitions remain service-owned; only `context` payload content is extended.
|
||||
- Ops-UX summary counts: Pass. No new `summary_counts` keys are added.
|
||||
- Ops-UX guards and system-run rules: Pass. Existing monitoring and notification invariants remain untouched.
|
||||
- Automation/backoff: Pass. Existing capture-phase retry/backoff behavior remains the only throttling mechanism in scope.
|
||||
- Data minimization: Pass. Persisted detail is bounded, operator-safe subject identity rather than raw payload dumps or secrets.
|
||||
- Badge semantics: Pass. Existing run outcome/trust badges remain centralized; evidence-gap detail is plain text/searchable rows.
|
||||
- UI naming: Pass. The operator copy uses domain terms such as `Evidence gap details`, `Policy type`, and `Subject key`.
|
||||
- Operator surfaces: Pass. Outcome and trust stay ahead of diagnostics, and the detail section is secondary to result meaning.
|
||||
- Filament Action Surface Contract: Pass. The change is read-only and does not alter header/row/bulk/destructive semantics.
|
||||
- UX-001 Layout and IA: Pass. The detail stays sectioned and readable within the existing enterprise detail layout.
|
||||
- Filament v5 / Livewire v4 compliance: Pass. The feature remains within the existing Filament v5 + Livewire v4 stack.
|
||||
- Provider registration location: Pass. No panel provider changes are required; Laravel 11+ registration remains in `bootstrap/providers.php`.
|
||||
- Global search hard rule: Pass. No resource searchability changes are required.
|
||||
- Asset strategy: Pass. The design relies on existing Filament/Blade/Alpine capabilities and does not require new published or on-demand assets.
|
||||
|
||||
## Phase 0 Research
|
||||
|
||||
Research outcomes are captured in `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/specs/162-baseline-gap-details/research.md`.
|
||||
|
||||
Key decisions:
|
||||
|
||||
- Persist subject-level evidence gaps as an immutable read model under `baseline_compare.evidence_gaps.subjects` inside `OperationRun.context`.
|
||||
- Merge subject detail from all compare evidence-gap sources: ambiguous inventory matches, capture-phase failures, and drift-time missing evidence.
|
||||
- Keep filtering local to the rendered detail section because the stored dataset is intentionally bounded and render-time network/database chatter would add unnecessary complexity.
|
||||
- Preserve operator-first reading order: result meaning and trust first, evidence-gap detail second, raw JSON last.
|
||||
- Treat legacy runs with counts but no recorded subjects as a first-class fallback state rather than as empty/healthy runs.
|
||||
|
||||
## Phase 1 Design
|
||||
|
||||
Design artifacts are created under `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/specs/162-baseline-gap-details/`:
|
||||
|
||||
- `data-model.md`: JSONB-backed read-model contract and derived UI row shape.
|
||||
- `contracts/baseline-gap-details.openapi.yaml`: internal reference schema for the shared evidence-gap read model used by both operator surfaces; it is not a commitment to add new HTTP endpoints in this slice.
|
||||
- `quickstart.md`: verification path covering focused tests, tenant-safety checks, render-safety checks, local run review, and legacy-run fallback checks.
|
||||
|
||||
Design decisions:
|
||||
|
||||
- No schema migration is required; the durable contract is an extension of the existing JSONB payload.
|
||||
- The canonical record remains `OperationRun`; no separate evidence-gap table or Eloquent model is introduced.
|
||||
- Filtering is modeled as reason/policy-type/subject-key matching over persisted row data in the page surface, not via new server-side filtering endpoints in this slice.
|
||||
- Tenant landing and canonical run detail must consume semantically identical evidence-gap groupings and preserve operator-first summary ordering ahead of diagnostics.
|
||||
- Regression coverage must explicitly prove tenant-safe access semantics and the no-external-calls-on-render rule on both affected surfaces.
|
||||
|
||||
## Project Structure
|
||||
|
||||
### Documentation (this feature)
|
||||
|
||||
```text
|
||||
specs/162-baseline-gap-details/
|
||||
├── plan.md
|
||||
├── research.md
|
||||
├── data-model.md
|
||||
├── quickstart.md
|
||||
├── contracts/
|
||||
│ └── baseline-gap-details.openapi.yaml
|
||||
└── tasks.md
|
||||
```
|
||||
|
||||
### Source Code (repository root)
|
||||
|
||||
```text
|
||||
app/
|
||||
├── Filament/
|
||||
│ └── Resources/
|
||||
│ └── OperationRunResource.php
|
||||
├── Jobs/
|
||||
│ └── CompareBaselineToTenantJob.php
|
||||
├── Models/
|
||||
│ └── OperationRun.php
|
||||
└── Services/
|
||||
└── Baselines/
|
||||
└── BaselineContentCapturePhase.php
|
||||
|
||||
resources/
|
||||
└── views/
|
||||
└── filament/
|
||||
└── infolists/
|
||||
└── entries/
|
||||
└── evidence-gap-subjects.blade.php
|
||||
|
||||
tests/
|
||||
└── Feature/
|
||||
├── Baselines/
|
||||
│ ├── BaselineCompareAmbiguousMatchGapTest.php
|
||||
│ └── BaselineCompareResumeTokenTest.php
|
||||
└── Filament/
|
||||
└── OperationRunEnterpriseDetailPageTest.php
|
||||
```
|
||||
|
||||
**Structure Decision**: Single Laravel web application. The feature stays inside the existing baseline compare pipeline, `OperationRun` read model, Filament resource rendering, Blade view composition, and Pest feature-test layout.
|
||||
|
||||
## Implementation Strategy
|
||||
|
||||
1. Normalize and persist subject-level gap detail in the compare/capture pipeline while preserving bounded payload size and legacy compare semantics.
|
||||
2. Render the evidence-gap detail section from `OperationRun.context` on canonical run detail and keep the tenant landing semantics aligned.
|
||||
3. Support operator filtering across reason, policy type, and subject key without introducing a new server-side search endpoint in the first implementation.
|
||||
4. Keep operator-first summary content ahead of diagnostics on both the canonical run detail and tenant landing surfaces.
|
||||
5. Add regression coverage for new-run persistence, tenant-safe access semantics, render-safety, UI visibility, and legacy/no-detail behavior.
|
||||
|
||||
## Complexity Tracking
|
||||
|
||||
No constitution violations or justified complexity exceptions were identified.
|
||||
|
||||
61
specs/162-baseline-gap-details/quickstart.md
Normal file
61
specs/162-baseline-gap-details/quickstart.md
Normal file
@ -0,0 +1,61 @@
|
||||
# Quickstart: Enterprise Evidence Gap Details for Baseline Compare
|
||||
|
||||
## Prerequisites
|
||||
|
||||
1. Start the local stack.
|
||||
|
||||
```bash
|
||||
vendor/bin/sail up -d
|
||||
```
|
||||
|
||||
2. Ensure the app is clean enough to run focused tests.
|
||||
|
||||
## Focused Verification
|
||||
|
||||
Run the minimum regression pack for the feature:
|
||||
|
||||
```bash
|
||||
vendor/bin/sail artisan test --compact tests/Feature/Baselines/BaselineCompareAmbiguousMatchGapTest.php tests/Feature/Baselines/BaselineCompareResumeTokenTest.php tests/Feature/Filament/OperationRunEnterpriseDetailPageTest.php
|
||||
```
|
||||
|
||||
Format touched files before shipping implementation updates:
|
||||
|
||||
```bash
|
||||
vendor/bin/sail bin pint --dirty --format agent
|
||||
```
|
||||
|
||||
## Manual Verification Flow
|
||||
|
||||
1. Trigger or locate a completed `baseline_compare` run with evidence gaps.
|
||||
2. Open the canonical run detail page at `/admin/operations/{run}`.
|
||||
3. Confirm the page shows outcome/trust guidance before diagnostics.
|
||||
4. Confirm the `Evidence gap details` section is visible when subject-level details exist.
|
||||
5. Use `Search gap details` to filter by:
|
||||
- reason text such as `ambiguous`
|
||||
- policy type such as `deviceConfiguration`
|
||||
- subject key fragment such as part of a display name or GUID
|
||||
6. Confirm raw JSON evidence remains available in the separate `Baseline compare evidence` section.
|
||||
|
||||
## Legacy-Run Verification
|
||||
|
||||
1. Open an older baseline compare run that contains `evidence_gaps.count` but no `evidence_gaps.subjects`.
|
||||
2. Confirm the UI distinguishes missing recorded detail from the absence of gaps.
|
||||
3. Confirm the page still renders successfully and does not imply a healthy compare result.
|
||||
|
||||
## Tenant-Safety Verification
|
||||
|
||||
1. Verify an entitled user can inspect the same run through canonical monitoring.
|
||||
2. Verify a non-member cannot discover tenant-owned detail through canonical or tenant-scoped surfaces.
|
||||
3. Verify member-but-underprivileged behavior remains enforced by existing authorization rules.
|
||||
|
||||
## Render-Safety Verification
|
||||
|
||||
1. Bind the fail-hard graph client in the affected UI tests.
|
||||
2. Verify the canonical run detail renders evidence-gap detail without invoking `GraphClientInterface`.
|
||||
3. Verify the tenant landing evidence-gap state renders without invoking `GraphClientInterface`.
|
||||
|
||||
## Deployment Notes
|
||||
|
||||
- No new database migration is required.
|
||||
- No new Filament assets are registered, so this feature does not add a new `filament:assets` deployment requirement.
|
||||
- Filament remains on Livewire v4-compatible patterns and requires no panel provider changes.
|
||||
57
specs/162-baseline-gap-details/research.md
Normal file
57
specs/162-baseline-gap-details/research.md
Normal file
@ -0,0 +1,57 @@
|
||||
# Research: Enterprise Evidence Gap Details for Baseline Compare
|
||||
|
||||
## Decision 1: Persist evidence-gap subjects inside `OperationRun.context`
|
||||
|
||||
- Decision: Store concrete evidence-gap subject detail in `baseline_compare.evidence_gaps.subjects` within the existing JSONB `OperationRun.context` payload.
|
||||
- Rationale: The canonical operator review surface is already backed by `OperationRun`, Monitoring pages must remain DB-only at render time, and compare runs are immutable operational artifacts. Extending the existing context preserves observability without introducing a new persistence model.
|
||||
- Alternatives considered:
|
||||
- Create a dedicated relational evidence-gap table: rejected because the feature needs a bounded, immutable run snapshot rather than an independently mutable dataset.
|
||||
- Recompute detail on demand from inventory and baseline state: rejected because it would violate DB-only render expectations and risk drift between recorded compare outcome and later inventory state.
|
||||
|
||||
## Decision 2: Merge all evidence-gap subject sources before persistence
|
||||
|
||||
- Decision: Consolidate gap subjects from ambiguous current-inventory matches, capture-phase failures, and drift-time missing evidence into one reason-grouped structure.
|
||||
- Rationale: Operators need one coherent explanation of why confidence is reduced. Splitting detail across multiple internal sources would force the UI to know too much about compare internals and would create inconsistent trust messaging.
|
||||
- Alternatives considered:
|
||||
- Persist only ambiguous matches: rejected because it would leave `policy_not_found`, `missing_current`, and similar reasons as counts-only.
|
||||
- Persist per-phase fragments separately: rejected because the UI contract is reason-oriented, not phase-oriented.
|
||||
|
||||
## Decision 3: Keep filtering local to the rendered detail surface
|
||||
|
||||
- Decision: Use local filtering across reason, policy type, and subject key in the evidence-gap detail surface.
|
||||
- Rationale: The payload is intentionally bounded by the compare job, the operator workflow is investigative rather than analytical, and local filtering avoids new server requests or additional read endpoints in the initial user experience.
|
||||
- Alternatives considered:
|
||||
- Server-side filtering via new query endpoints: rejected for the initial slice because it adds API surface without solving a current scale bottleneck.
|
||||
- No filtering at all: rejected because enterprise runs can accumulate enough subjects to make manual scanning too slow.
|
||||
|
||||
## Decision 4: Preserve operator-first information hierarchy
|
||||
|
||||
- Decision: Keep result meaning, trust, and next-step guidance ahead of evidence-gap detail, and keep raw JSON diagnostics last.
|
||||
- Rationale: The constitution requires operator-first `/admin` surfaces. Evidence-gap detail is important, but it supports the decision already summarized by the run outcome and explanation layers.
|
||||
- Alternatives considered:
|
||||
- Show raw JSON only: rejected because it fails the operator-first requirement.
|
||||
- Put evidence-gap rows ahead of result meaning: rejected because it would over-prioritize diagnostics and weaken the page contract.
|
||||
|
||||
## Decision 5: Explicitly model legacy and partial-detail runs
|
||||
|
||||
- Decision: Differentiate among runs with no evidence gaps, runs with gaps and recorded subjects, and runs with gaps but no recorded subject detail.
|
||||
- Rationale: Historical compare runs already exist, and silence must not be interpreted as health. The UI needs an explicit fallback state to preserve trust in old data.
|
||||
- Alternatives considered:
|
||||
- Treat missing subjects as empty subjects: rejected because it misrepresents historical/partial runs.
|
||||
- Hide the section when subjects are missing: rejected because operators would lose the signal that detail quality differs across runs.
|
||||
|
||||
## Decision 6: Use existing Filament/Blade patterns rather than new assets
|
||||
|
||||
- Decision: Implement the detail surface with existing Filament resource sections, Blade partials, and Alpine-powered filtering only.
|
||||
- Rationale: The feature does not require a new panel plugin, custom published asset, or heavy client library. Existing Filament v5 and Livewire v4 patterns already support the interaction.
|
||||
- Alternatives considered:
|
||||
- Introduce a custom JS table package: rejected because it adds operational overhead and does not materially improve the bounded use case.
|
||||
- Publish or override Filament internal views: rejected because render hooks and custom entries are sufficient.
|
||||
|
||||
## Decision 7: Validate with persistence and render-path regression tests
|
||||
|
||||
- Decision: Anchor verification in Pest feature tests for compare persistence, capture-phase subject storage, and run-detail rendering.
|
||||
- Rationale: The root failure was data not being persisted, not just a missing view. The test plan must cover both the job path and the operator surface.
|
||||
- Alternatives considered:
|
||||
- UI-only assertions: rejected because they would not prove the persistence contract.
|
||||
- Queue smoke tests only: rejected because they are too broad to protect the specific JSON contract.
|
||||
144
specs/162-baseline-gap-details/spec.md
Normal file
144
specs/162-baseline-gap-details/spec.md
Normal file
@ -0,0 +1,144 @@
|
||||
# Feature Specification: Enterprise Evidence Gap Details for Baseline Compare
|
||||
|
||||
**Feature Branch**: `162-baseline-gap-details`
|
||||
**Created**: 2026-03-24
|
||||
**Status**: Draft
|
||||
**Input**: User description: "Create an enterprise-grade baseline compare evidence gap details experience for operation runs, including searchable operator-first presentation of concrete gap subjects, diagnostic clarity, filtering expectations, audit-safe visibility, and best-practice information architecture for tenant-scoped operations."
|
||||
|
||||
## Spec Scope Fields *(mandatory)*
|
||||
|
||||
- **Scope**: tenant, canonical-view
|
||||
- **Primary Routes**: `/admin/operations/{run}`, `/admin/t/{tenant}/baseline-compare-landing`, and existing related navigation back to tenant operations and baseline compare entry points
|
||||
- **Data Ownership**: Tenant-owned `OperationRun` records remain the source of evidence-gap execution context. Workspace-owned baseline profiles and snapshots remain unchanged in ownership. This feature changes capture and presentation of tenant-owned evidence-gap detail, not record ownership.
|
||||
- **RBAC**: Existing workspace membership, tenant entitlement, and baseline compare or monitoring view capabilities remain authoritative. No new role or capability is introduced.
|
||||
- **Default filter behavior when tenant-context is active**: Canonical Monitoring entry points continue to respect active tenant context in navigation and related links, while direct run-detail access remains explicit to the run's tenant and must not silently widen visibility.
|
||||
- **Explicit entitlement checks preventing cross-tenant leakage**: Canonical run detail and tenant compare review surfaces must continue to enforce workspace entitlement first and tenant entitlement second, with deny-as-not-found behavior for non-members and no cross-tenant hinting through gap details.
|
||||
|
||||
## Operator Surface Contract *(mandatory when operator-facing surfaces are changed)*
|
||||
|
||||
| Surface | Primary Persona | Surface Type | Primary Operator Question | Default-visible Information | Diagnostics-only Information | Status Dimensions Used | Mutation Scope | Primary Actions | Dangerous Actions |
|
||||
|---|---|---|---|---|---|---|---|---|---|
|
||||
| Monitoring → Operation Run Detail for baseline compare runs | Workspace manager or entitled tenant operator | Canonical detail | Which specific subjects caused evidence gaps, and can I trust this compare result? | Outcome, trust statement, next step, grouped gap-detail summary, searchable gap subjects, related context | Raw JSON, internal payload fragments, low-level capture fragments | execution outcome, result trust, data completeness, follow-up readiness | Simulation only for compare results, TenantPilot only for page rendering | View run, search gap details, open related tenant operations | None |
|
||||
| Tenant Baseline Compare landing | Tenant operator | Tenant-scoped review surface | Which evidence gaps are blocking a trustworthy compare, and what should I inspect next? | Result meaning, gap counts, grouped reasons, searchable concrete subjects, next-step guidance | Raw evidence payload, secondary technical context | evaluation result, reliability, completeness, actionability | Simulation only | Compare now, inspect latest run, filter concrete gaps | None |
|
||||
|
||||
## User Scenarios & Testing *(mandatory)*
|
||||
|
||||
### User Story 1 - Inspect concrete evidence gaps quickly (Priority: P1)
|
||||
|
||||
An operator reviewing a degraded baseline compare run needs to see which exact policy subjects caused evidence gaps so they can determine whether the visible result is trustworthy and what needs follow-up.
|
||||
|
||||
**Why this priority**: Aggregate counts alone do not support operational action. The core value is turning an abstract warning into concrete, reviewable subjects.
|
||||
|
||||
**Independent Test**: Create a baseline compare run with evidence gaps and verify that the operator can identify the affected policy subjects from the default-visible detail surface without opening JSON or database tooling.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** a completed baseline compare run with evidence gaps, **When** an entitled operator opens the run detail page, **Then** the page shows grouped concrete gap subjects tied to the relevant evidence-gap reasons.
|
||||
2. **Given** a completed baseline compare run with both aggregate counts and concrete gap subjects, **When** the operator reads the page, **Then** the concrete details align with the same reason buckets as the counts and do not contradict the top-level trust statement.
|
||||
|
||||
---
|
||||
|
||||
### User Story 2 - Filter large gap sets without scanning raw diagnostics (Priority: P2)
|
||||
|
||||
An operator dealing with many evidence gaps needs to filter the list by reason, policy type, or subject key so they can isolate the specific policy family or identifier they are investigating.
|
||||
|
||||
**Why this priority**: Evidence-gap sets can be too large to inspect manually. Filtering is required for operational usefulness at enterprise scale.
|
||||
|
||||
**Independent Test**: Open a run with multiple reasons and many gap subjects, enter a partial policy type or subject key, and confirm that the visible rows narrow to the relevant subset without leaving the page.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** a run with many evidence-gap rows, **When** the operator filters by a policy type token, **Then** only matching reasons and rows remain visible.
|
||||
2. **Given** a run with GUID-like subject keys or mixed human-readable names, **When** the operator filters by a partial subject key value, **Then** matching rows remain visible regardless of whether the value is human-readable text or an identifier.
|
||||
|
||||
---
|
||||
|
||||
### User Story 3 - Distinguish missing detail from no gaps (Priority: P3)
|
||||
|
||||
An operator reviewing a legacy or partially recorded run needs the surface to distinguish between runs that had no evidence-gap details and runs where details were never recorded, so they do not misread silence as health.
|
||||
|
||||
**Why this priority**: Historical runs and partial payloads will continue to exist. The system must preserve trust even when detail quality varies over time.
|
||||
|
||||
**Independent Test**: Open one run with gaps but no recorded subject-level details and one run with no gaps, then verify the page communicates the difference clearly.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** a run with evidence-gap counts but no stored concrete subjects, **When** the operator opens the detail surface, **Then** the page explains that no detailed gap rows were recorded for that run.
|
||||
2. **Given** a run with no evidence gaps, **When** the operator opens the detail surface, **Then** no misleading gap-detail section appears and the run reads as having no gap details because none exist.
|
||||
|
||||
### Edge Cases
|
||||
|
||||
- A run contains aggregate evidence-gap counts, but only some reasons have concrete subject details. The surface must show what is known without implying the missing reasons have zero affected subjects.
|
||||
- A run predates subject-level evidence-gap storage. The surface must explicitly say that detailed rows were not recorded for that run.
|
||||
- The same subject appears under different reasons across the same run. The surface must preserve each reason association rather than collapsing away meaning.
|
||||
- Subject keys may contain spaces, GUIDs, underscores, or mixed human-readable and machine-generated values. Filtering must still work predictably.
|
||||
- Very large gap sets must remain searchable and readable without requiring raw JSON inspection.
|
||||
|
||||
## Requirements *(mandatory)*
|
||||
|
||||
**Constitution alignment (required):** This feature does not introduce new Microsoft Graph calls or new long-running job types. It reuses existing baseline compare execution and extends what tenant-owned `OperationRun` records capture and reveal about evidence gaps. Existing tenant isolation, audit, and safe execution rules remain unchanged.
|
||||
|
||||
**Constitution alignment (OPS-UX):** Existing baseline compare `OperationRun` behavior remains within the current three-surface feedback contract. `OperationRun.status` and `OperationRun.outcome` remain service-owned. Summary counts remain numeric and lifecycle-oriented. This feature adds richer evidence-gap interpretation and detail within existing run context rather than redefining lifecycle semantics. Scheduled or system-run behavior remains unchanged.
|
||||
|
||||
**Constitution alignment (RBAC-UX):** This feature changes what is visible on tenant and canonical run-detail surfaces, but not who is authorized. Non-members remain 404. Members without the relevant view capability remain 403 only after membership is established. No raw capability strings or role-specific shortcuts are introduced.
|
||||
|
||||
**Constitution alignment (OPS-EX-AUTH-001):** Not applicable beyond reaffirming that no auth-handshake behavior is added.
|
||||
|
||||
**Constitution alignment (BADGE-001):** Existing run outcome, trust, and completeness semantics remain centralized. This feature must not invent new ad-hoc badge mappings for evidence-gap states.
|
||||
|
||||
**Constitution alignment (UI-NAMING-001):** Operator-facing labels must use domain language such as “Evidence gap details”, “Policy type”, and “Subject key” rather than implementation-first phrasing. Internal reason codes remain diagnostic, not primary.
|
||||
|
||||
**Constitution alignment (OPSURF-001):** Default-visible content must remain operator-first. Outcome, trust, and next-step guidance remain ahead of raw JSON. Evidence-gap details are diagnostic, but promoted enough to support first-pass action without forcing operators into raw payloads.
|
||||
|
||||
**Constitution alignment (Filament Action Surfaces):** The affected Filament pages remain compliant with the Action Surface Contract. No new destructive actions are introduced. Existing compare actions remain unchanged. This feature only improves read and investigation behavior on existing surfaces.
|
||||
|
||||
**Constitution alignment (UX-001 — Layout & Information Architecture):** The evidence-gap experience must remain sectioned, searchable, and readable inside existing detail layouts. The searchable detail table is secondary to the result summary but primary within the diagnostics path.
|
||||
|
||||
### Functional Requirements
|
||||
|
||||
- **FR-001**: The system MUST persist subject-level evidence-gap details for new baseline compare runs whenever the compare process can identify affected subjects.
|
||||
- **FR-002**: The system MUST retain aggregate evidence-gap counts and reason groupings alongside subject-level evidence-gap details so both summary and detail remain available on the same run.
|
||||
- **FR-003**: The system MUST present evidence-gap details on the baseline compare run-detail experience in a searchable table-oriented format rather than as raw JSON alone.
|
||||
- **FR-004**: The system MUST let operators filter evidence-gap details by at least reason, policy type, and subject key from within the operator-facing surface.
|
||||
- **FR-005**: The system MUST group evidence-gap details by reason so operators can understand whether subjects are blocked by ambiguity, missing current evidence, missing policy references, or similar distinct causes.
|
||||
- **FR-006**: The system MUST preserve operator-safe default reading order on the run-detail surface so execution outcome, result trust, and next-step guidance appear before searchable evidence-gap detail and before raw JSON.
|
||||
- **FR-007**: The system MUST distinguish between “no evidence gaps exist” and “evidence-gap details were not recorded” so historical runs and partial payloads are not misread.
|
||||
- **FR-008**: The system MUST keep evidence-gap details tenant-safe on both tenant-scoped and canonical monitoring surfaces, revealing them only to entitled workspace and tenant members.
|
||||
- **FR-009**: The system MUST keep the baseline compare landing experience and the canonical run-detail experience semantically aligned when they reference the same evidence-gap state.
|
||||
- **FR-010**: The system MUST preserve existing compare initiation behavior, mutation scope messaging, and audit semantics for baseline compare actions. This feature MUST NOT add new dangerous actions or broaden mutation scope.
|
||||
- **FR-011**: The system MUST continue to support raw JSON diagnostics for support and deep troubleshooting, but those diagnostics MUST remain secondary to the searchable evidence-gap detail experience.
|
||||
- **FR-012**: The system MUST remain usable for large enterprise tenants by allowing an operator to isolate a relevant gap subject without manually scanning the full visible set.
|
||||
- **FR-013**: The system MUST continue rendering older runs that only contain aggregate evidence-gap counts without failing the page or hiding the existence of evidence gaps.
|
||||
- **FR-014**: The system MUST provide regression coverage for subject-level evidence-gap persistence, operator-surface rendering, and filtering-visible affordances on the affected pages.
|
||||
- **FR-015**: The system MUST preserve the current no-external-calls-on-render rule for Monitoring and run-detail surfaces.
|
||||
|
||||
### Assumptions
|
||||
|
||||
- This slice focuses on baseline compare evidence-gap detail, not every diagnostic surface in the product.
|
||||
- Existing baseline compare reason-code and trust semantics remain the semantic source of truth for the top-level operator reading path.
|
||||
- The primary enterprise need is fast investigation of concrete gap subjects, not full ad hoc reporting from the run detail page.
|
||||
- Historical runs may continue to exist without subject-level evidence-gap detail and must remain readable.
|
||||
|
||||
## UI Action Matrix *(mandatory when Filament is changed)*
|
||||
|
||||
| Surface | Location | Header Actions | Inspect Affordance (List/Table) | Row Actions (max 2 visible) | Bulk Actions (grouped) | Empty-State CTA(s) | View Header Actions | Create/Edit Save+Cancel | Audit log? | Notes / Exemptions |
|
||||
|---|---|---|---|---|---|---|---|---|---|---|
|
||||
| Baseline compare run detail | Existing canonical run detail surface | Existing navigation and refresh actions remain | Existing detail sections remain the inspect affordance | None added | None | No new CTA. If no gap rows are recorded, the section explains why. | Existing run-detail header actions remain | Not applicable | Existing run and compare audit semantics remain | Read-only information architecture change only |
|
||||
| Tenant baseline compare landing | Existing tenant compare review surface | Existing compare action remains | Existing navigation to latest run remains | None added | None | Existing compare CTA remains | Existing page-level actions remain | Not applicable | Existing run-backed audit semantics remain | Read-only information architecture change only |
|
||||
|
||||
### Key Entities *(include if feature involves data)*
|
||||
|
||||
- **Evidence Gap Detail**: A concrete affected subject associated with a specific evidence-gap reason for a baseline compare run, including enough operator-readable identity to investigate the issue.
|
||||
- **Evidence Gap Reason Group**: A reason bucket that explains why one or more subjects limited compare confidence, used to structure both counts and detailed rows.
|
||||
- **Baseline Compare Run Context**: The tenant-owned run context that stores both summary evidence-gap information and subject-level detail for later operator review.
|
||||
|
||||
## Success Criteria *(mandatory)*
|
||||
|
||||
### Measurable Outcomes
|
||||
|
||||
- **SC-001**: On a baseline compare run with recorded subject-level evidence gaps, an entitled operator can identify at least one affected subject from the default-visible detail surface in under 30 seconds without opening raw JSON.
|
||||
- **SC-002**: On a run with multiple gap reasons, an entitled operator can isolate the relevant reason, policy type, or subject key using the on-page filter in one filtering action.
|
||||
- **SC-003**: Legacy runs without subject-level detail continue to render successfully and clearly distinguish missing recorded detail from absence of evidence gaps.
|
||||
- **SC-004**: The canonical run-detail surface and tenant baseline compare review surface remain semantically consistent in how they describe evidence-gap-driven limited-confidence results.
|
||||
- **SC-005**: Regression coverage exists for subject-level detail persistence, operator-facing rendering, and search-visible affordances on the affected surfaces.
|
||||
|
||||
208
specs/162-baseline-gap-details/tasks.md
Normal file
208
specs/162-baseline-gap-details/tasks.md
Normal file
@ -0,0 +1,208 @@
|
||||
# Tasks: Enterprise Evidence Gap Details for Baseline Compare
|
||||
|
||||
**Input**: Design documents from `/specs/162-baseline-gap-details/`
|
||||
**Prerequisites**: `plan.md`, `spec.md`, `research.md`, `data-model.md`, `contracts/baseline-gap-details.openapi.yaml`, `quickstart.md`
|
||||
|
||||
**Tests**: Runtime behavior changes in this repo require Pest coverage. Each user story below includes the minimum focused tests needed to prove the slice works independently.
|
||||
**Organization**: Tasks are grouped by user story so each story can be implemented and validated independently.
|
||||
|
||||
## Phase 1: Setup (Shared Infrastructure)
|
||||
|
||||
**Purpose**: Confirm the feature slice, target files, and verification entrypoints before implementation changes start.
|
||||
|
||||
- [X] T001 Confirm the feature scope, affected files, and verification commands in `specs/162-baseline-gap-details/plan.md`, `specs/162-baseline-gap-details/quickstart.md`, and `specs/162-baseline-gap-details/contracts/baseline-gap-details.openapi.yaml`
|
||||
- [X] T002 Map the current baseline compare detail flow in `app/Jobs/CompareBaselineToTenantJob.php`, `app/Services/Baselines/BaselineContentCapturePhase.php`, `app/Filament/Resources/OperationRunResource.php`, and `app/Filament/Pages/BaselineCompareLanding.php`
|
||||
|
||||
---
|
||||
|
||||
## Phase 2: Foundational (Blocking Prerequisites)
|
||||
|
||||
**Purpose**: Establish the shared evidence-gap read model and rendering helpers required by all user stories.
|
||||
|
||||
**⚠️ CRITICAL**: No user story work should start until this phase is complete.
|
||||
|
||||
- [X] T003 Define the shared evidence-gap read-model contract and state mapping in `app/Filament/Resources/OperationRunResource.php` to match `specs/162-baseline-gap-details/data-model.md`
|
||||
- [X] T004 [P] Normalize canonical run-detail reason-bucket extraction helpers for `baseline_compare.evidence_gaps.subjects` in `app/Filament/Resources/OperationRunResource.php`
|
||||
- [X] T005 [P] Normalize tenant-landing evidence-gap summary/detail extraction in `app/Support/Baselines/BaselineCompareStats.php`
|
||||
- [X] T006 Establish the tenant-landing integration points for evidence-gap summary, detail, and diagnostics sections in `app/Filament/Pages/BaselineCompareLanding.php` and `resources/views/filament/pages/baseline-compare-landing.blade.php`
|
||||
|
||||
**Checkpoint**: Shared read model and page integration points are clear; user-story implementation can proceed.
|
||||
|
||||
---
|
||||
|
||||
## Phase 3: User Story 1 - Inspect concrete evidence gaps quickly (Priority: P1) 🎯 MVP
|
||||
|
||||
**Goal**: Persist and render grouped concrete evidence-gap subjects so an operator can inspect affected policies without opening raw JSON.
|
||||
|
||||
**Independent Test**: Create a baseline compare run with evidence gaps and verify the canonical run detail shows grouped concrete subjects aligned with the aggregate reason buckets.
|
||||
|
||||
### Tests for User Story 1
|
||||
|
||||
- [X] T007 [P] [US1] Extend compare persistence coverage for reason-grouped subject details in `tests/Feature/Baselines/BaselineCompareAmbiguousMatchGapTest.php`
|
||||
- [X] T008 [P] [US1] Extend capture-phase subject persistence coverage in `tests/Feature/Baselines/BaselineCompareResumeTokenTest.php`
|
||||
- [X] T009 [P] [US1] Add or update canonical run-detail rendering coverage for grouped evidence-gap rows in `tests/Feature/Filament/OperationRunEnterpriseDetailPageTest.php`
|
||||
- [X] T010 [P] [US1] Add canonical run-detail render-safety coverage with `bindFailHardGraphClient()` in `tests/Feature/Filament/OperationRunEnterpriseDetailPageTest.php`
|
||||
|
||||
### Implementation for User Story 1
|
||||
|
||||
- [X] T011 [US1] Persist bounded subject-level evidence-gap buckets for new compare runs in `app/Jobs/CompareBaselineToTenantJob.php`
|
||||
- [X] T012 [US1] Persist capture-phase `gap_subjects` alongside aggregate gaps in `app/Services/Baselines/BaselineContentCapturePhase.php`
|
||||
- [X] T013 [US1] Render grouped evidence-gap detail sections from the shared read model in `app/Filament/Resources/OperationRunResource.php`
|
||||
- [X] T014 [US1] Create or refine the grouped read-only evidence-gap detail view in `resources/views/filament/infolists/entries/evidence-gap-subjects.blade.php`
|
||||
- [X] T015 [US1] Keep raw JSON diagnostics secondary and aligned with the new evidence-gap detail contract in `app/Filament/Resources/OperationRunResource.php`
|
||||
|
||||
**Checkpoint**: Operators can inspect concrete evidence-gap subjects on the canonical run detail page without using raw JSON.
|
||||
|
||||
---
|
||||
|
||||
## Phase 4: User Story 2 - Filter large gap sets without scanning raw diagnostics (Priority: P2)
|
||||
|
||||
**Goal**: Let operators narrow evidence-gap detail by reason, policy type, or subject key directly on the page.
|
||||
|
||||
**Independent Test**: Open a run with multiple gap reasons and many subject rows, enter a partial search token, and confirm only the relevant grouped rows remain visible.
|
||||
|
||||
### Tests for User Story 2
|
||||
|
||||
- [X] T016 [P] [US2] Add filtering-affordance assertions for the canonical run detail in `tests/Feature/Filament/OperationRunEnterpriseDetailPageTest.php`
|
||||
- [X] T017 [P] [US2] Add tenant-landing parity and filtering visibility coverage in `tests/Feature/Filament/BaselineCompareLandingAdminTenantParityTest.php`
|
||||
- [X] T018 [P] [US2] Add tenant-landing render-safety coverage with `bindFailHardGraphClient()` in `tests/Feature/Filament/BaselineCompareLandingAdminTenantParityTest.php`
|
||||
- [X] T019 [P] [US2] Add unchanged compare-start surface and mutation-scope messaging coverage in `tests/Feature/Filament/BaselineCompareLandingStartSurfaceTest.php`
|
||||
- [X] T020 [P] [US2] Add unchanged baseline-compare audit semantics coverage in `tests/Feature/Baselines/BaselineCompareAuditEventsTest.php`
|
||||
|
||||
### Implementation for User Story 2
|
||||
|
||||
- [X] T021 [US2] Implement local filtering across reason, policy type, and subject key in `resources/views/filament/infolists/entries/evidence-gap-subjects.blade.php`
|
||||
- [X] T022 [US2] Render the tenant-landing evidence-gap detail block and search affordances in `app/Filament/Pages/BaselineCompareLanding.php` and `resources/views/filament/pages/baseline-compare-landing.blade.php`
|
||||
- [X] T023 [US2] Align `BaselineCompareStats` reason-group payloads with the canonical run-detail bucket contract in `app/Support/Baselines/BaselineCompareStats.php`, `app/Filament/Pages/BaselineCompareLanding.php`, and `app/Filament/Resources/OperationRunResource.php`
|
||||
- [X] T024 [US2] Keep tenant-landing raw diagnostics explicitly secondary to summary and evidence-gap detail in `app/Filament/Pages/BaselineCompareLanding.php` and `resources/views/filament/pages/baseline-compare-landing.blade.php`
|
||||
|
||||
**Checkpoint**: Operators can isolate relevant evidence-gap rows on both affected surfaces without leaving the page.
|
||||
|
||||
---
|
||||
|
||||
## Phase 5: User Story 3 - Distinguish missing detail from no gaps (Priority: P3)
|
||||
|
||||
**Goal**: Preserve operator trust by clearly distinguishing legacy or partially recorded runs from runs that truly have no evidence gaps.
|
||||
|
||||
**Independent Test**: Open one run with aggregate evidence-gap counts but no recorded subject details and another run with no evidence gaps, then verify the UI communicates the difference clearly.
|
||||
|
||||
### Tests for User Story 3
|
||||
|
||||
- [X] T025 [P] [US3] Add legacy-run fallback coverage to `tests/Feature/Filament/OperationRunEnterpriseDetailPageTest.php`
|
||||
- [X] T026 [P] [US3] Add tenant-landing fallback coverage for missing-detail versus no-gap states in `tests/Feature/Filament/BaselineCompareLandingWhyNoFindingsTest.php`
|
||||
- [X] T027 [P] [US3] Add explicit tenant-safety regression coverage for canonical and tenant surfaces in `tests/Feature/Filament/OperationRunEnterpriseDetailPageTest.php` and `tests/Feature/Filament/BaselineCompareLandingAdminTenantParityTest.php`
|
||||
|
||||
### Implementation for User Story 3
|
||||
|
||||
- [X] T028 [US3] Implement canonical run-detail fallback messaging for `details_not_recorded` versus `no_gaps` in `app/Filament/Resources/OperationRunResource.php` and `resources/views/filament/infolists/entries/evidence-gap-subjects.blade.php`
|
||||
- [X] T029 [US3] Implement tenant-landing fallback messaging for legacy and partial-detail compare runs in `app/Filament/Pages/BaselineCompareLanding.php` and `resources/views/filament/pages/baseline-compare-landing.blade.php`
|
||||
- [X] T030 [US3] Ensure partial reason coverage does not imply zero affected subjects for missing buckets in `app/Filament/Resources/OperationRunResource.php` and `app/Support/Baselines/BaselineCompareStats.php`
|
||||
|
||||
**Checkpoint**: Legacy and partial-detail runs remain readable and trustworthy without being mistaken for healthy no-gap runs.
|
||||
|
||||
---
|
||||
|
||||
## Phase 6: Polish & Cross-Cutting Concerns
|
||||
|
||||
**Purpose**: Final consistency, regression validation, and release readiness across all user stories.
|
||||
|
||||
- [X] T031 [P] Update feature artifacts for any final contract or wording changes in `specs/162-baseline-gap-details/spec.md`, `specs/162-baseline-gap-details/plan.md`, `specs/162-baseline-gap-details/quickstart.md`, and `specs/162-baseline-gap-details/contracts/baseline-gap-details.openapi.yaml`
|
||||
- [X] T032 Run focused formatting on touched PHP files with `vendor/bin/sail bin pint --dirty --format agent`
|
||||
- [X] T033 Run the focused canonical verification pack from `specs/162-baseline-gap-details/quickstart.md` with `vendor/bin/sail artisan test --compact tests/Feature/Baselines/BaselineCompareAmbiguousMatchGapTest.php tests/Feature/Baselines/BaselineCompareResumeTokenTest.php tests/Feature/Filament/OperationRunEnterpriseDetailPageTest.php`
|
||||
- [X] T034 [P] Run the tenant-landing evidence-gap verification tests with `vendor/bin/sail artisan test --compact tests/Feature/Filament/BaselineCompareLandingAdminTenantParityTest.php tests/Feature/Filament/BaselineCompareLandingWhyNoFindingsTest.php tests/Feature/Filament/BaselineCompareLandingStartSurfaceTest.php tests/Feature/Baselines/BaselineCompareAuditEventsTest.php`
|
||||
- [X] T035 Execute the manual run-review checks in `specs/162-baseline-gap-details/quickstart.md` against `/admin/operations/{run}` and `/admin/t/{tenant}/baseline-compare-landing`
|
||||
|
||||
---
|
||||
|
||||
## Dependencies & Execution Order
|
||||
|
||||
### Phase Dependencies
|
||||
|
||||
- **Phase 1: Setup**: No dependencies.
|
||||
- **Phase 2: Foundational**: Depends on Phase 1 and blocks all user stories.
|
||||
- **Phase 3: US1**: Starts after Phase 2 and delivers the MVP.
|
||||
- **Phase 4: US2**: Starts after Phase 2; depends functionally on the US1 detail structure being present.
|
||||
- **Phase 5: US3**: Starts after Phase 2; depends functionally on the shared read model and both surface integrations.
|
||||
- **Phase 6: Polish**: Starts after the desired user stories are complete.
|
||||
|
||||
### User Story Dependencies
|
||||
|
||||
- **US1 (P1)**: No dependency on other user stories once the foundational phase is complete.
|
||||
- **US2 (P2)**: Depends on US1 detail rendering because filtering operates on the rendered evidence-gap rows.
|
||||
- **US3 (P3)**: Depends on US1 read-model/rendering and should also align with US2 surface semantics where filtering is visible.
|
||||
|
||||
### Within Each User Story
|
||||
|
||||
- Tests should be written or updated before implementation and fail for the missing behavior.
|
||||
- Persistence work precedes rendering work.
|
||||
- Rendering work precedes parity and fallback polish.
|
||||
- Each story should be independently runnable through the focused tests listed above.
|
||||
|
||||
## Parallel Opportunities
|
||||
|
||||
- `T004` and `T005` can run in parallel after `T003`.
|
||||
- `T007`, `T008`, `T009`, and `T010` can run in parallel within US1.
|
||||
- `T016`, `T017`, `T018`, `T019`, and `T020` can run in parallel within US2.
|
||||
- `T025`, `T026`, and `T027` can run in parallel within US3.
|
||||
- `T031` and `T034` can run in parallel during Polish.
|
||||
|
||||
## Parallel Example: User Story 1
|
||||
|
||||
```bash
|
||||
# Parallelize the US1 regression updates first
|
||||
Task: T007 tests/Feature/Baselines/BaselineCompareAmbiguousMatchGapTest.php
|
||||
Task: T008 tests/Feature/Baselines/BaselineCompareResumeTokenTest.php
|
||||
Task: T009 tests/Feature/Filament/OperationRunEnterpriseDetailPageTest.php
|
||||
Task: T010 tests/Feature/Filament/OperationRunEnterpriseDetailPageTest.php
|
||||
|
||||
# Then implement the persistence and render paths
|
||||
Task: T011 app/Jobs/CompareBaselineToTenantJob.php
|
||||
Task: T012 app/Services/Baselines/BaselineContentCapturePhase.php
|
||||
Task: T013 app/Filament/Resources/OperationRunResource.php
|
||||
Task: T014 resources/views/filament/infolists/entries/evidence-gap-subjects.blade.php
|
||||
```
|
||||
|
||||
## Parallel Example: User Story 2
|
||||
|
||||
```bash
|
||||
# Parallelize test scaffolding for both surfaces
|
||||
Task: T016 tests/Feature/Filament/OperationRunEnterpriseDetailPageTest.php
|
||||
Task: T017 tests/Feature/Filament/BaselineCompareLandingAdminTenantParityTest.php
|
||||
Task: T018 tests/Feature/Filament/BaselineCompareLandingAdminTenantParityTest.php
|
||||
Task: T019 tests/Feature/Filament/BaselineCompareLandingStartSurfaceTest.php
|
||||
Task: T020 tests/Feature/Baselines/BaselineCompareAuditEventsTest.php
|
||||
|
||||
# Then implement filtering and surface parity
|
||||
Task: T021 resources/views/filament/infolists/entries/evidence-gap-subjects.blade.php
|
||||
Task: T022 app/Filament/Pages/BaselineCompareLanding.php
|
||||
Task: T023 app/Support/Baselines/BaselineCompareStats.php
|
||||
Task: T024 resources/views/filament/pages/baseline-compare-landing.blade.php
|
||||
```
|
||||
|
||||
## Parallel Example: User Story 3
|
||||
|
||||
```bash
|
||||
# Parallelize legacy-state tests
|
||||
Task: T025 tests/Feature/Filament/OperationRunEnterpriseDetailPageTest.php
|
||||
Task: T026 tests/Feature/Filament/BaselineCompareLandingWhyNoFindingsTest.php
|
||||
Task: T027 tests/Feature/Filament/BaselineCompareLandingAdminTenantParityTest.php
|
||||
|
||||
# Then implement fallback states on both surfaces
|
||||
Task: T028 app/Filament/Resources/OperationRunResource.php
|
||||
Task: T029 resources/views/filament/pages/baseline-compare-landing.blade.php
|
||||
Task: T030 app/Support/Baselines/BaselineCompareStats.php
|
||||
```
|
||||
|
||||
## Implementation Strategy
|
||||
|
||||
### MVP First
|
||||
|
||||
Deliver Phase 3 first. That yields the core value: subject-level persistence plus canonical run-detail visibility for evidence-gap details.
|
||||
|
||||
### Incremental Delivery
|
||||
|
||||
1. Complete Setup and Foundational phases.
|
||||
2. Deliver US1 as the MVP for operator visibility.
|
||||
3. Add US2 for enterprise-scale filtering and landing-page parity.
|
||||
4. Add US3 for legacy-run trust and fallback clarity.
|
||||
5. Finish with formatting, focused regression runs, and manual validation.
|
||||
@ -114,4 +114,11 @@
|
||||
|
||||
$context = is_array($run->context) ? $run->context : [];
|
||||
expect(data_get($context, 'baseline_compare.evidence_gaps.by_reason.ambiguous_match'))->toBe(1);
|
||||
|
||||
$gapSubjects = data_get($context, 'baseline_compare.evidence_gaps.subjects');
|
||||
expect($gapSubjects)->toBeArray()
|
||||
->and($gapSubjects)->toHaveKey('ambiguous_match')
|
||||
->and($gapSubjects['ambiguous_match'])->toBeArray()
|
||||
->and($gapSubjects['ambiguous_match'])->toHaveCount(1)
|
||||
->and($gapSubjects['ambiguous_match'][0])->toContain('deviceConfiguration|');
|
||||
});
|
||||
|
||||
@ -172,4 +172,6 @@ public function capture(
|
||||
expect($completedMeta)->toHaveKey('subjects_total');
|
||||
expect($completedMeta)->toHaveKey('evidence_capture');
|
||||
expect($completedMeta)->toHaveKey('gaps');
|
||||
expect($completedMeta)->not->toHaveKey('gap_subjects');
|
||||
expect(json_encode($completedMeta))->not->toContain('Audit Compare Policy');
|
||||
});
|
||||
|
||||
@ -170,3 +170,110 @@ public function capture(
|
||||
expect($state)->toHaveKey('offset');
|
||||
expect($state['offset'])->toBe(1);
|
||||
});
|
||||
|
||||
it('stores capture-phase gap subjects for policy_not_found evidence gaps', function (): void {
|
||||
config()->set('tenantpilot.baselines.full_content_capture.enabled', true);
|
||||
config()->set('tenantpilot.baselines.full_content_capture.max_items_per_run', 10);
|
||||
config()->set('tenantpilot.baselines.full_content_capture.max_concurrency', 1);
|
||||
config()->set('tenantpilot.baselines.full_content_capture.max_retries', 0);
|
||||
|
||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||
|
||||
$profile = BaselineProfile::factory()->active()->create([
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'capture_mode' => BaselineCaptureMode::FullContent->value,
|
||||
'scope_jsonb' => ['policy_types' => ['deviceConfiguration'], 'foundation_types' => []],
|
||||
]);
|
||||
|
||||
$snapshot = BaselineSnapshot::factory()->create([
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'baseline_profile_id' => (int) $profile->getKey(),
|
||||
'captured_at' => now()->subMinute(),
|
||||
]);
|
||||
|
||||
$profile->update(['active_snapshot_id' => (int) $snapshot->getKey()]);
|
||||
|
||||
$displayName = 'Missing Capture Policy';
|
||||
$subjectKey = BaselineSubjectKey::fromDisplayName($displayName);
|
||||
expect($subjectKey)->not->toBeNull();
|
||||
|
||||
BaselineSnapshotItem::factory()->create([
|
||||
'baseline_snapshot_id' => (int) $snapshot->getKey(),
|
||||
'subject_type' => 'policy',
|
||||
'subject_external_id' => BaselineSubjectKey::workspaceSafeSubjectExternalId('deviceConfiguration', (string) $subjectKey),
|
||||
'subject_key' => (string) $subjectKey,
|
||||
'policy_type' => 'deviceConfiguration',
|
||||
'baseline_hash' => hash('sha256', 'baseline-missing-capture-policy'),
|
||||
'meta_jsonb' => [
|
||||
'display_name' => $displayName,
|
||||
'evidence' => [
|
||||
'fidelity' => 'content',
|
||||
'source' => 'policy_version',
|
||||
'observed_at' => now()->toIso8601String(),
|
||||
],
|
||||
],
|
||||
]);
|
||||
|
||||
$inventorySyncRun = createInventorySyncOperationRunWithCoverage(
|
||||
tenant: $tenant,
|
||||
statusByType: ['deviceConfiguration' => 'succeeded'],
|
||||
);
|
||||
|
||||
InventoryItem::factory()->create([
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'external_id' => 'missing-capture-policy',
|
||||
'policy_type' => 'deviceConfiguration',
|
||||
'display_name' => $displayName,
|
||||
'meta_jsonb' => ['odata_type' => '#microsoft.graph.deviceConfiguration', 'etag' => fake()->uuid()],
|
||||
'last_seen_operation_run_id' => (int) $inventorySyncRun->getKey(),
|
||||
'last_seen_at' => now(),
|
||||
]);
|
||||
|
||||
$contentCapturePhase = new BaselineContentCapturePhase(new class extends PolicyCaptureOrchestrator
|
||||
{
|
||||
public function __construct() {}
|
||||
|
||||
public function capture(
|
||||
Policy $policy,
|
||||
\App\Models\Tenant $tenant,
|
||||
bool $includeAssignments = false,
|
||||
bool $includeScopeTags = false,
|
||||
?string $createdBy = null,
|
||||
array $metadata = [],
|
||||
PolicyVersionCapturePurpose $capturePurpose = PolicyVersionCapturePurpose::Backup,
|
||||
?int $operationRunId = null,
|
||||
?int $baselineProfileId = null,
|
||||
): array {
|
||||
throw new \RuntimeException('Should not be called for a missing policy capture case.');
|
||||
}
|
||||
});
|
||||
|
||||
$opService = app(OperationRunService::class);
|
||||
$run = $opService->ensureRunWithIdentity(
|
||||
tenant: $tenant,
|
||||
type: OperationRunType::BaselineCompare->value,
|
||||
identityInputs: ['baseline_profile_id' => (int) $profile->getKey()],
|
||||
context: [
|
||||
'baseline_profile_id' => (int) $profile->getKey(),
|
||||
'baseline_snapshot_id' => (int) $snapshot->getKey(),
|
||||
'effective_scope' => ['policy_types' => ['deviceConfiguration'], 'foundation_types' => []],
|
||||
'capture_mode' => BaselineCaptureMode::FullContent->value,
|
||||
],
|
||||
initiator: $user,
|
||||
);
|
||||
|
||||
(new CompareBaselineToTenantJob($run))->handle(
|
||||
app(BaselineSnapshotIdentity::class),
|
||||
app(AuditLogger::class),
|
||||
$opService,
|
||||
contentCapturePhase: $contentCapturePhase,
|
||||
);
|
||||
|
||||
$run->refresh();
|
||||
|
||||
expect(data_get($run->context, 'baseline_compare.evidence_gaps.by_reason.policy_not_found'))->toBe(1)
|
||||
->and(data_get($run->context, 'baseline_compare.evidence_gaps.subjects.policy_not_found'))->toBe([
|
||||
'deviceConfiguration|missing-capture-policy',
|
||||
]);
|
||||
});
|
||||
|
||||
114
tests/Feature/Filament/BaselineCompareEvidenceGapTableTest.php
Normal file
114
tests/Feature/Filament/BaselineCompareEvidenceGapTableTest.php
Normal file
@ -0,0 +1,114 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Livewire\BaselineCompareEvidenceGapTable;
|
||||
use App\Support\Badges\TagBadgeCatalog;
|
||||
use App\Support\Badges\TagBadgeDomain;
|
||||
use App\Support\Baselines\BaselineCompareEvidenceGapDetails;
|
||||
use Filament\Tables\Table;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Livewire\Features\SupportTesting\Testable;
|
||||
use Livewire\Livewire;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
function baselineCompareEvidenceGapTable(Testable $component): Table
|
||||
{
|
||||
return $component->instance()->getTable();
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<array<string, mixed>>
|
||||
*/
|
||||
function baselineCompareEvidenceGapBuckets(): array
|
||||
{
|
||||
return BaselineCompareEvidenceGapDetails::fromContext([
|
||||
'baseline_compare' => [
|
||||
'evidence_gaps' => [
|
||||
'count' => 5,
|
||||
'by_reason' => [
|
||||
'ambiguous_match' => 3,
|
||||
'policy_not_found' => 2,
|
||||
],
|
||||
'subjects' => [
|
||||
'ambiguous_match' => [
|
||||
'deviceConfiguration|WiFi-Corp-Profile',
|
||||
'deviceConfiguration|VPN-Always-On',
|
||||
'deviceCompliancePolicy|Windows-Encryption-Required',
|
||||
],
|
||||
'policy_not_found' => [
|
||||
'deviceConfiguration|Deleted-Policy-ABC',
|
||||
'deviceCompliancePolicy|Retired-Compliance-Policy',
|
||||
],
|
||||
],
|
||||
],
|
||||
],
|
||||
])['buckets'];
|
||||
}
|
||||
|
||||
it('uses a Filament table for evidence-gap rows with searchable visible columns', function (): void {
|
||||
$component = Livewire::test(BaselineCompareEvidenceGapTable::class, [
|
||||
'buckets' => baselineCompareEvidenceGapBuckets(),
|
||||
'context' => 'canonical-run',
|
||||
]);
|
||||
|
||||
$table = baselineCompareEvidenceGapTable($component);
|
||||
|
||||
expect($table->isSearchable())->toBeTrue();
|
||||
expect($table->getDefaultSortColumn())->toBe('reason_label');
|
||||
expect($table->getColumn('reason_label')?->isSearchable())->toBeTrue();
|
||||
expect($table->getColumn('policy_type')?->isSearchable())->toBeTrue();
|
||||
expect($table->getColumn('subject_key')?->isSearchable())->toBeTrue();
|
||||
|
||||
$component
|
||||
->assertSee('WiFi-Corp-Profile')
|
||||
->assertSee('Deleted-Policy-ABC')
|
||||
->assertSee('Reason')
|
||||
->assertSee('Policy type')
|
||||
->assertSee('Subject key')
|
||||
->assertSee(TagBadgeCatalog::spec(TagBadgeDomain::PolicyType, 'deviceConfiguration')->label)
|
||||
->assertSee(TagBadgeCatalog::spec(TagBadgeDomain::PolicyType, 'deviceCompliancePolicy')->label);
|
||||
});
|
||||
|
||||
it('filters evidence-gap rows by table search and select filters', function (): void {
|
||||
Livewire::test(BaselineCompareEvidenceGapTable::class, [
|
||||
'buckets' => baselineCompareEvidenceGapBuckets(),
|
||||
'context' => 'tenant-landing',
|
||||
])
|
||||
->searchTable('Deleted-Policy-ABC')
|
||||
->assertSee('Deleted-Policy-ABC')
|
||||
->assertDontSee('WiFi-Corp-Profile');
|
||||
|
||||
Livewire::test(BaselineCompareEvidenceGapTable::class, [
|
||||
'buckets' => baselineCompareEvidenceGapBuckets(),
|
||||
'context' => 'tenant-landing-filters',
|
||||
])
|
||||
->filterTable('reason_code', 'policy_not_found')
|
||||
->assertSee('Retired-Compliance-Policy')
|
||||
->assertDontSee('VPN-Always-On')
|
||||
->filterTable('policy_type', 'deviceCompliancePolicy')
|
||||
->assertSee('Retired-Compliance-Policy')
|
||||
->assertDontSee('Deleted-Policy-ABC');
|
||||
});
|
||||
|
||||
it('shows an explicit empty state when only missing-detail buckets exist', function (): void {
|
||||
$buckets = BaselineCompareEvidenceGapDetails::fromContext([
|
||||
'baseline_compare' => [
|
||||
'evidence_gaps' => [
|
||||
'count' => 2,
|
||||
'by_reason' => [
|
||||
'policy_not_found' => 2,
|
||||
],
|
||||
'subjects' => [],
|
||||
],
|
||||
],
|
||||
])['buckets'];
|
||||
|
||||
Livewire::test(BaselineCompareEvidenceGapTable::class, [
|
||||
'buckets' => $buckets,
|
||||
'context' => 'legacy-run',
|
||||
])
|
||||
->assertSee('No recorded gap rows match this view')
|
||||
->assertSee('Adjust the current search or filters to review other affected subjects.');
|
||||
});
|
||||
@ -3,12 +3,95 @@
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Filament\Pages\BaselineCompareLanding;
|
||||
use App\Models\BaselineProfile;
|
||||
use App\Models\BaselineSnapshot;
|
||||
use App\Models\BaselineTenantAssignment;
|
||||
use App\Models\OperationRun;
|
||||
use App\Support\OperationRunOutcome;
|
||||
use App\Support\OperationRunStatus;
|
||||
use App\Support\OperationRunType;
|
||||
use App\Support\Workspaces\WorkspaceContext;
|
||||
use Filament\Facades\Filament;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Livewire\Livewire;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
function baselineCompareLandingGapContext(): array
|
||||
{
|
||||
return [
|
||||
'baseline_compare' => [
|
||||
'subjects_total' => 50,
|
||||
'reason_code' => 'evidence_capture_incomplete',
|
||||
'fidelity' => 'meta',
|
||||
'coverage' => [
|
||||
'proof' => true,
|
||||
'covered_types' => ['deviceConfiguration'],
|
||||
'uncovered_types' => [],
|
||||
'effective_types' => ['deviceConfiguration'],
|
||||
],
|
||||
'evidence_capture' => [
|
||||
'requested' => 50,
|
||||
'succeeded' => 47,
|
||||
'skipped' => 0,
|
||||
'failed' => 3,
|
||||
'throttled' => 0,
|
||||
],
|
||||
'evidence_gaps' => [
|
||||
'count' => 5,
|
||||
'by_reason' => [
|
||||
'ambiguous_match' => 3,
|
||||
'policy_not_found' => 2,
|
||||
],
|
||||
'ambiguous_match' => 3,
|
||||
'policy_not_found' => 2,
|
||||
'subjects' => [
|
||||
'ambiguous_match' => [
|
||||
'deviceConfiguration|WiFi-Corp-Profile',
|
||||
'deviceConfiguration|VPN-Always-On',
|
||||
],
|
||||
'policy_not_found' => [
|
||||
'deviceConfiguration|Deleted-Policy-ABC',
|
||||
],
|
||||
],
|
||||
],
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
function seedBaselineCompareLandingGapRun(\App\Models\Tenant $tenant): OperationRun
|
||||
{
|
||||
$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 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' => baselineCompareLandingGapContext(),
|
||||
]);
|
||||
}
|
||||
|
||||
it('can access baseline compare when only the remembered admin tenant is available', function (): void {
|
||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||
|
||||
@ -24,3 +107,50 @@
|
||||
|
||||
expect(BaselineCompareLanding::canAccess())->toBeTrue();
|
||||
});
|
||||
|
||||
it('renders tenant landing evidence-gap details with the same search affordances as the canonical run detail', function (): void {
|
||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||
$this->actingAs($user);
|
||||
|
||||
$tenant->makeCurrent();
|
||||
Filament::setTenant($tenant, true);
|
||||
|
||||
seedBaselineCompareLandingGapRun($tenant);
|
||||
|
||||
Livewire::test(BaselineCompareLanding::class)
|
||||
->assertSee('Evidence gap details')
|
||||
->assertSee('Search gap details')
|
||||
->assertSee('Search by reason, policy type, or subject key')
|
||||
->assertSee('Reason')
|
||||
->assertSee('Ambiguous inventory match')
|
||||
->assertSee('Policy not found')
|
||||
->assertSee('WiFi-Corp-Profile')
|
||||
->assertSee('Baseline compare evidence');
|
||||
});
|
||||
|
||||
it('renders tenant landing evidence-gap details without invoking graph during render', function (): void {
|
||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||
$this->actingAs($user);
|
||||
|
||||
$tenant->makeCurrent();
|
||||
Filament::setTenant($tenant, true);
|
||||
bindFailHardGraphClient();
|
||||
|
||||
seedBaselineCompareLandingGapRun($tenant);
|
||||
|
||||
Livewire::test(BaselineCompareLanding::class)
|
||||
->assertSee('Evidence gap details')
|
||||
->assertSee('WiFi-Corp-Profile')
|
||||
->assertSee('Baseline compare evidence');
|
||||
});
|
||||
|
||||
it('returns 404 for non-members on the tenant landing even when evidence-gap details exist', function (): void {
|
||||
[$member, $tenant] = createUserWithTenant(role: 'owner');
|
||||
$nonMember = \App\Models\User::factory()->create();
|
||||
|
||||
seedBaselineCompareLandingGapRun($tenant);
|
||||
|
||||
$this->actingAs($nonMember)
|
||||
->get(BaselineCompareLanding::getUrl(tenant: $tenant, panel: 'tenant'))
|
||||
->assertNotFound();
|
||||
});
|
||||
|
||||
@ -11,6 +11,7 @@
|
||||
use App\Support\OperationRunStatus;
|
||||
use App\Support\OperationRunType;
|
||||
use App\Support\OpsUx\OpsUxBrowserEvents;
|
||||
use Filament\Actions\Action;
|
||||
use Filament\Facades\Filament;
|
||||
use Illuminate\Support\Facades\Queue;
|
||||
use Livewire\Livewire;
|
||||
@ -111,6 +112,39 @@
|
||||
expect($run?->status)->toBe('queued');
|
||||
});
|
||||
|
||||
it('keeps compare-now confirmation and mutation-scope messaging unchanged on the landing surface', function (): void {
|
||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||
$this->actingAs($user);
|
||||
|
||||
$tenant->makeCurrent();
|
||||
Filament::setTenant($tenant, true);
|
||||
|
||||
$profile = BaselineProfile::factory()->active()->create([
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'capture_mode' => \App\Support\Baselines\BaselineCaptureMode::FullContent->value,
|
||||
]);
|
||||
|
||||
$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(),
|
||||
]);
|
||||
|
||||
Livewire::test(BaselineCompareLanding::class)
|
||||
->assertActionExists('compareNow', function (Action $action): bool {
|
||||
return $action->getLabel() === 'Compare now (full content)'
|
||||
&& $action->isConfirmationRequired()
|
||||
&& $action->getModalDescription() === 'This will refresh content evidence on demand (redacted) before comparing the current tenant inventory against the assigned baseline snapshot.';
|
||||
});
|
||||
});
|
||||
|
||||
it('does not start full-content baseline compare when rollout is disabled', function (): void {
|
||||
Queue::fake();
|
||||
config()->set('tenantpilot.baselines.full_content_capture.enabled', false);
|
||||
|
||||
@ -62,3 +62,119 @@
|
||||
Livewire::test(BaselineCompareLanding::class)
|
||||
->assertSee(BaselineCompareReasonCode::NoDriftDetected->message());
|
||||
});
|
||||
|
||||
it('shows explicit missing-detail fallback when evidence gaps were counted without recorded subject rows', function (): void {
|
||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||
$this->actingAs($user);
|
||||
|
||||
$tenant->makeCurrent();
|
||||
Filament::setTenant($tenant, true);
|
||||
|
||||
$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::EvidenceCaptureIncomplete->value,
|
||||
'coverage' => [
|
||||
'effective_types' => ['deviceConfiguration'],
|
||||
'covered_types' => ['deviceConfiguration'],
|
||||
'uncovered_types' => [],
|
||||
'proof' => true,
|
||||
],
|
||||
'fidelity' => 'meta',
|
||||
'evidence_gaps' => [
|
||||
'count' => 2,
|
||||
'by_reason' => [
|
||||
'policy_not_found' => 2,
|
||||
],
|
||||
],
|
||||
],
|
||||
],
|
||||
]);
|
||||
|
||||
Livewire::test(BaselineCompareLanding::class)
|
||||
->assertSee('Evidence gap details')
|
||||
->assertSee('Detailed rows were not recorded for this run')
|
||||
->assertSee('Baseline compare evidence');
|
||||
});
|
||||
|
||||
it('does not imply missing detail when the latest run has no evidence gaps', function (): void {
|
||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||
$this->actingAs($user);
|
||||
|
||||
$tenant->makeCurrent();
|
||||
Filament::setTenant($tenant, true);
|
||||
|
||||
$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::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,
|
||||
],
|
||||
'fidelity' => 'meta',
|
||||
'evidence_gaps' => [
|
||||
'count' => 0,
|
||||
'by_reason' => [],
|
||||
'subjects' => [],
|
||||
],
|
||||
],
|
||||
],
|
||||
]);
|
||||
|
||||
Livewire::test(BaselineCompareLanding::class)
|
||||
->assertDontSee('Evidence gap details')
|
||||
->assertSee('Baseline compare evidence');
|
||||
});
|
||||
|
||||
@ -25,6 +25,54 @@ function visiblePageText(TestResponse $response): string
|
||||
return trim((string) preg_replace('/\s+/', ' ', strip_tags($html)));
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $overrides
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
function baselineCompareGapContext(array $overrides = []): array
|
||||
{
|
||||
return array_replace_recursive([
|
||||
'baseline_compare' => [
|
||||
'subjects_total' => 50,
|
||||
'reason_code' => 'evidence_capture_incomplete',
|
||||
'fidelity' => 'meta',
|
||||
'coverage' => [
|
||||
'proof' => true,
|
||||
'covered_types' => ['deviceConfiguration'],
|
||||
'uncovered_types' => [],
|
||||
'effective_types' => ['deviceConfiguration'],
|
||||
],
|
||||
'evidence_capture' => [
|
||||
'requested' => 50,
|
||||
'succeeded' => 47,
|
||||
'skipped' => 0,
|
||||
'failed' => 3,
|
||||
'throttled' => 0,
|
||||
],
|
||||
'evidence_gaps' => [
|
||||
'count' => 5,
|
||||
'by_reason' => [
|
||||
'ambiguous_match' => 3,
|
||||
'policy_not_found' => 2,
|
||||
],
|
||||
'ambiguous_match' => 3,
|
||||
'policy_not_found' => 2,
|
||||
'subjects' => [
|
||||
'ambiguous_match' => [
|
||||
'deviceConfiguration|WiFi-Corp-Profile',
|
||||
'deviceConfiguration|VPN-Always-On',
|
||||
'deviceConfiguration|Email-Exchange-Config',
|
||||
],
|
||||
'policy_not_found' => [
|
||||
'deviceConfiguration|Deleted-Policy-ABC',
|
||||
'deviceConfiguration|Removed-Config-XYZ',
|
||||
],
|
||||
],
|
||||
],
|
||||
],
|
||||
], $overrides);
|
||||
}
|
||||
|
||||
it('renders operation runs with summary content before counts and technical context', function (): void {
|
||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||
|
||||
@ -225,3 +273,148 @@ function visiblePageText(TestResponse $response): string
|
||||
->assertSee('Reconciled by')
|
||||
->assertSee('Adapter reconciler');
|
||||
});
|
||||
|
||||
it('renders evidence gap details section for baseline compare runs with gap subjects', function (): void {
|
||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||
|
||||
Filament::setTenant(null, true);
|
||||
|
||||
$run = OperationRun::factory()->create([
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'type' => 'baseline_compare',
|
||||
'status' => 'completed',
|
||||
'outcome' => 'partially_succeeded',
|
||||
'context' => baselineCompareGapContext(),
|
||||
'completed_at' => now(),
|
||||
]);
|
||||
|
||||
$response = $this->actingAs($user)
|
||||
->withSession([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id])
|
||||
->get(route('admin.operations.view', ['run' => (int) $run->getKey()]))
|
||||
->assertOk()
|
||||
->assertSee('Evidence gap details')
|
||||
->assertSee('Search gap details')
|
||||
->assertSee('Search by reason, policy type, or subject key')
|
||||
->assertSee('Reason')
|
||||
->assertSee('Ambiguous inventory match')
|
||||
->assertSee('Policy not found')
|
||||
->assertSee('3 affected')
|
||||
->assertSee('2 affected')
|
||||
->assertSee('WiFi-Corp-Profile')
|
||||
->assertSee('Deleted-Policy-ABC')
|
||||
->assertSee('Policy type')
|
||||
->assertSee('Subject key');
|
||||
});
|
||||
|
||||
it('renders baseline compare evidence-gap details without invoking graph during canonical run detail render', function (): void {
|
||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||
|
||||
Filament::setTenant(null, true);
|
||||
bindFailHardGraphClient();
|
||||
|
||||
$run = OperationRun::factory()->create([
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'type' => 'baseline_compare',
|
||||
'status' => 'completed',
|
||||
'outcome' => 'partially_succeeded',
|
||||
'context' => baselineCompareGapContext(),
|
||||
'completed_at' => now(),
|
||||
]);
|
||||
|
||||
$this->actingAs($user)
|
||||
->withSession([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id])
|
||||
->get(route('admin.operations.view', ['run' => (int) $run->getKey()]))
|
||||
->assertOk()
|
||||
->assertSee('Evidence gap details')
|
||||
->assertSee('WiFi-Corp-Profile');
|
||||
});
|
||||
|
||||
it('distinguishes missing recorded gap detail from no-gap runs on the canonical run detail surface', function (): void {
|
||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||
|
||||
Filament::setTenant(null, true);
|
||||
|
||||
$legacyRun = OperationRun::factory()->create([
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'type' => 'baseline_compare',
|
||||
'status' => 'completed',
|
||||
'outcome' => 'partially_succeeded',
|
||||
'context' => baselineCompareGapContext([
|
||||
'baseline_compare' => [
|
||||
'evidence_gaps' => [
|
||||
'subjects' => null,
|
||||
],
|
||||
],
|
||||
]),
|
||||
'completed_at' => now(),
|
||||
]);
|
||||
|
||||
$cleanRun = OperationRun::factory()->create([
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'type' => 'baseline_compare',
|
||||
'status' => 'completed',
|
||||
'outcome' => 'succeeded',
|
||||
'context' => baselineCompareGapContext([
|
||||
'baseline_compare' => [
|
||||
'reason_code' => 'no_drift_detected',
|
||||
'evidence_gaps' => [
|
||||
'count' => 0,
|
||||
'by_reason' => [],
|
||||
'subjects' => [],
|
||||
],
|
||||
],
|
||||
]),
|
||||
'completed_at' => now(),
|
||||
]);
|
||||
|
||||
$this->actingAs($user)
|
||||
->withSession([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id])
|
||||
->get(route('admin.operations.view', ['run' => (int) $legacyRun->getKey()]))
|
||||
->assertOk()
|
||||
->assertSee('Evidence gap details')
|
||||
->assertSee('Detailed rows were not recorded for this run')
|
||||
->assertSee('Baseline compare evidence');
|
||||
|
||||
$this->actingAs($user)
|
||||
->withSession([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id])
|
||||
->get(route('admin.operations.view', ['run' => (int) $cleanRun->getKey()]))
|
||||
->assertOk()
|
||||
->assertDontSee('Evidence gap details')
|
||||
->assertSee('Baseline compare evidence');
|
||||
});
|
||||
|
||||
it('returns 404 for workspace members without tenant entitlement when evidence-gap details exist on the canonical surface', function (): void {
|
||||
$workspace = Workspace::factory()->create();
|
||||
$tenant = Tenant::factory()->create([
|
||||
'workspace_id' => (int) $workspace->getKey(),
|
||||
]);
|
||||
|
||||
$run = OperationRun::factory()->create([
|
||||
'workspace_id' => (int) $workspace->getKey(),
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'type' => 'baseline_compare',
|
||||
'status' => 'completed',
|
||||
'outcome' => 'partially_succeeded',
|
||||
'context' => baselineCompareGapContext(),
|
||||
'completed_at' => now(),
|
||||
]);
|
||||
|
||||
$user = User::factory()->create();
|
||||
|
||||
WorkspaceMembership::factory()->create([
|
||||
'workspace_id' => (int) $workspace->getKey(),
|
||||
'user_id' => (int) $user->getKey(),
|
||||
'role' => 'owner',
|
||||
]);
|
||||
|
||||
Filament::setTenant(null, true);
|
||||
|
||||
$this->actingAs($user)
|
||||
->withSession([WorkspaceContext::SESSION_KEY => (int) $workspace->getKey()])
|
||||
->get(route('admin.operations.view', ['run' => (int) $run->getKey()]))
|
||||
->assertNotFound();
|
||||
});
|
||||
|
||||
20
tests/Feature/Filament/UnhandledRejectionLoggerAssetTest.php
Normal file
20
tests/Feature/Filament/UnhandledRejectionLoggerAssetTest.php
Normal file
@ -0,0 +1,20 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
it('injects the unhandled rejection logger into Filament pages', function (): void {
|
||||
$this->get('/admin/login')
|
||||
->assertSuccessful()
|
||||
->assertSee('js/tenantpilot/unhandled-rejection-logger.js', escape: false);
|
||||
});
|
||||
|
||||
it('ships a window unhandledrejection logger with structured payload output', function (): void {
|
||||
$js = file_get_contents(public_path('js/tenantpilot/unhandled-rejection-logger.js'));
|
||||
|
||||
expect($js)->toBeString();
|
||||
expect($js)
|
||||
->toContain('__tenantpilotUnhandledRejectionLoggerApplied')
|
||||
->toContain("window.addEventListener('unhandledrejection'")
|
||||
->toContain('TenantPilot unhandled promise rejection')
|
||||
->toContain('JSON.stringify');
|
||||
});
|
||||
Loading…
Reference in New Issue
Block a user