Compare commits
5 Commits
173-tenant
...
dev
| Author | SHA1 | Date | |
|---|---|---|---|
| f52d52540c | |||
| dc46c4fa58 | |||
| 98be510362 | |||
| 44898a98ac | |||
| 3a2a06e8d7 |
16
.github/agents/copilot-instructions.md
vendored
16
.github/agents/copilot-instructions.md
vendored
@ -123,6 +123,16 @@ ## Active Technologies
|
|||||||
- PHP 8.4, Laravel 12, Livewire v4, Filament v5, Tailwind CSS v4 + `laravel/framework`, `filament/filament`, `livewire/livewire`, `pestphp/pest` (171-operations-naming-consolidation)
|
- PHP 8.4, Laravel 12, Livewire v4, Filament v5, Tailwind CSS v4 + `laravel/framework`, `filament/filament`, `livewire/livewire`, `pestphp/pest` (171-operations-naming-consolidation)
|
||||||
- PostgreSQL with existing `operation_runs`, notification payloads, workspace records, and tenant records; no schema changes (171-operations-naming-consolidation)
|
- PostgreSQL with existing `operation_runs`, notification payloads, workspace records, and tenant records; no schema changes (171-operations-naming-consolidation)
|
||||||
- PostgreSQL with existing `operation_runs`, `managed_tenant_onboarding_sessions`, tenant records, and workspace records; no schema changes (172-deferred-operator-surfaces-retrofit)
|
- PostgreSQL with existing `operation_runs`, `managed_tenant_onboarding_sessions`, tenant records, and workspace records; no schema changes (172-deferred-operator-surfaces-retrofit)
|
||||||
|
- PHP 8.4.15 + Laravel 12, Filament v5, Livewire v4, Pest v4, existing `TenantDashboard`, `DashboardKpis`, `NeedsAttention`, `BaselineCompareNow`, `RecentDriftFindings`, `RecentOperations`, `TenantGovernanceAggregateResolver`, `BaselineCompareStats`, `BaselineCompareSummaryAssessor`, `FindingResource`, `OperationRunLinks`, and canonical admin Operations page (173-tenant-dashboard-truth-alignment)
|
||||||
|
- PostgreSQL unchanged; no new persistence, cache store, or durable dashboard summary artifac (173-tenant-dashboard-truth-alignment)
|
||||||
|
- PHP 8.4, Laravel 12, Filament v5, Livewire v4, Blade + Filament v5, Livewire v4, Pest v4, Laravel Sail, existing `ArtifactTruthPresenter`, `ArtifactTruthEnvelope`, `TenantReviewReadinessGate`, `EvidenceSnapshotService`, `TenantReviewRegisterService`, and current evidence/review/review-pack resources and pages (174-evidence-freshness-publication-trust)
|
||||||
|
- PostgreSQL with existing `evidence_snapshots`, `evidence_snapshot_items`, `tenant_reviews`, and `review_packs` tables using current summary JSON and timestamps; no schema change planned (174-evidence-freshness-publication-trust)
|
||||||
|
- PHP 8.4.15 + Laravel 12, Filament v5, Livewire v4, Pest v4, existing `WorkspaceOverviewBuilder`, `TenantGovernanceAggregateResolver`, `BaselineCompareStats`, `BaselineCompareSummaryAssessor`, `WorkspaceSummaryStats`, `WorkspaceNeedsAttention`, `WorkspaceRecentOperations`, `FindingResource`, `BaselineCompareLanding`, `EvidenceSnapshotResource`, `TenantReviewResource`, and canonical admin Operations routes (175-workspace-governance-attention)
|
||||||
|
- PostgreSQL unchanged; no new persistence, cache table, or materialized aggregate is introduced (175-workspace-governance-attention)
|
||||||
|
- PHP 8.4.15 + Laravel 12, Filament v5, Livewire v4, Pest v4, existing `TenantResource`, `ProviderConnectionResource`, `TenantVerificationReport`, `BadgeCatalog`, `BadgeRenderer`, `TenantOperabilityService`, `ProviderConsentStatus`, `ProviderVerificationStatus`, and shared provider-state Blade partials (179-provider-truth-cleanup)
|
||||||
|
- PostgreSQL unchanged; no new table, column, or persisted artifact is introduced (179-provider-truth-cleanup)
|
||||||
|
- PHP 8.4.15 + Laravel 12, Filament v5, Livewire v4, Pest v4, existing `InventoryItem`, `OperationRun`, `InventoryCoverage`, `InventoryPolicyTypeMeta`, `CoverageCapabilitiesResolver`, `InventoryKpiHeader`, `InventoryCoverage` page, and `OperationRunResource` enterprise-detail stack (177-inventory-coverage-truth)
|
||||||
|
- PostgreSQL; existing `inventory_items` rows and `operation_runs.context` / `operation_runs.summary_counts` JSONB are reused with no schema change (177-inventory-coverage-truth)
|
||||||
|
|
||||||
- PHP 8.4.15 (feat/005-bulk-operations)
|
- PHP 8.4.15 (feat/005-bulk-operations)
|
||||||
|
|
||||||
@ -142,8 +152,8 @@ ## Code Style
|
|||||||
PHP 8.4.15: Follow standard conventions
|
PHP 8.4.15: Follow standard conventions
|
||||||
|
|
||||||
## Recent Changes
|
## Recent Changes
|
||||||
- 172-deferred-operator-surfaces-retrofit: Added PHP 8.4, Laravel 12, Livewire v4, Filament v5, Tailwind CSS v4 + `laravel/framework`, `filament/filament`, `livewire/livewire`, `pestphp/pest`
|
- 177-inventory-coverage-truth: Added PHP 8.4.15 + Laravel 12, Filament v5, Livewire v4, Pest v4, existing `InventoryItem`, `OperationRun`, `InventoryCoverage`, `InventoryPolicyTypeMeta`, `CoverageCapabilitiesResolver`, `InventoryKpiHeader`, `InventoryCoverage` page, and `OperationRunResource` enterprise-detail stack
|
||||||
- 171-operations-naming-consolidation: Added PHP 8.4, Laravel 12, Livewire v4, Filament v5, Tailwind CSS v4 + `laravel/framework`, `filament/filament`, `livewire/livewire`, `pestphp/pest`
|
- 179-provider-truth-cleanup: Added PHP 8.4.15 + Laravel 12, Filament v5, Livewire v4, Pest v4, existing `TenantResource`, `ProviderConnectionResource`, `TenantVerificationReport`, `BadgeCatalog`, `BadgeRenderer`, `TenantOperabilityService`, `ProviderConsentStatus`, `ProviderVerificationStatus`, and shared provider-state Blade partials
|
||||||
- 170-system-operations-surface-alignment: Added PHP 8.4, Laravel 12, Livewire v4, Filament v5 + `laravel/framework`, `filament/filament`, `livewire/livewire`, `pestphp/pest`
|
- 175-workspace-governance-attention: Added PHP 8.4.15 + Laravel 12, Filament v5, Livewire v4, Pest v4, existing `WorkspaceOverviewBuilder`, `TenantGovernanceAggregateResolver`, `BaselineCompareStats`, `BaselineCompareSummaryAssessor`, `WorkspaceSummaryStats`, `WorkspaceNeedsAttention`, `WorkspaceRecentOperations`, `FindingResource`, `BaselineCompareLanding`, `EvidenceSnapshotResource`, `TenantReviewResource`, and canonical admin Operations routes
|
||||||
<!-- MANUAL ADDITIONS START -->
|
<!-- MANUAL ADDITIONS START -->
|
||||||
<!-- MANUAL ADDITIONS END -->
|
<!-- MANUAL ADDITIONS END -->
|
||||||
|
|||||||
@ -6,19 +6,20 @@
|
|||||||
|
|
||||||
use App\Filament\Clusters\Inventory\InventoryCluster;
|
use App\Filament\Clusters\Inventory\InventoryCluster;
|
||||||
use App\Filament\Concerns\ResolvesPanelTenantContext;
|
use App\Filament\Concerns\ResolvesPanelTenantContext;
|
||||||
|
use App\Filament\Resources\InventoryItemResource;
|
||||||
use App\Filament\Widgets\Inventory\InventoryKpiHeader;
|
use App\Filament\Widgets\Inventory\InventoryKpiHeader;
|
||||||
|
use App\Models\OperationRun;
|
||||||
use App\Models\Tenant;
|
use App\Models\Tenant;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use App\Services\Auth\CapabilityResolver;
|
use App\Services\Auth\CapabilityResolver;
|
||||||
use App\Services\Inventory\CoverageCapabilitiesResolver;
|
|
||||||
use App\Support\Auth\Capabilities;
|
use App\Support\Auth\Capabilities;
|
||||||
use App\Support\Badges\BadgeCatalog;
|
use App\Support\Badges\BadgeCatalog;
|
||||||
use App\Support\Badges\BadgeDomain;
|
use App\Support\Badges\BadgeDomain;
|
||||||
use App\Support\Badges\BadgeRenderer;
|
use App\Support\Badges\BadgeRenderer;
|
||||||
use App\Support\Badges\TagBadgeCatalog;
|
use App\Support\Badges\TagBadgeCatalog;
|
||||||
use App\Support\Badges\TagBadgeDomain;
|
use App\Support\Badges\TagBadgeDomain;
|
||||||
use App\Support\Badges\TagBadgeRenderer;
|
use App\Support\Inventory\TenantCoverageTruth;
|
||||||
use App\Support\Inventory\InventoryPolicyTypeMeta;
|
use App\Support\Inventory\TenantCoverageTruthResolver;
|
||||||
use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration;
|
use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration;
|
||||||
use App\Support\Ui\ActionSurface\ActionSurfaceDefaults;
|
use App\Support\Ui\ActionSurface\ActionSurfaceDefaults;
|
||||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile;
|
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile;
|
||||||
@ -36,6 +37,7 @@
|
|||||||
use Filament\Tables\Table;
|
use Filament\Tables\Table;
|
||||||
use Illuminate\Pagination\LengthAwarePaginator;
|
use Illuminate\Pagination\LengthAwarePaginator;
|
||||||
use Illuminate\Support\Collection;
|
use Illuminate\Support\Collection;
|
||||||
|
use Illuminate\Support\Facades\Gate;
|
||||||
use Illuminate\Support\Str;
|
use Illuminate\Support\Str;
|
||||||
use UnitEnum;
|
use UnitEnum;
|
||||||
|
|
||||||
@ -56,6 +58,8 @@ class InventoryCoverage extends Page implements HasTable
|
|||||||
|
|
||||||
protected string $view = 'filament.pages.inventory-coverage';
|
protected string $view = 'filament.pages.inventory-coverage';
|
||||||
|
|
||||||
|
protected ?TenantCoverageTruth $cachedCoverageTruth = null;
|
||||||
|
|
||||||
public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
|
public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
|
||||||
{
|
{
|
||||||
return ActionSurfaceDeclaration::forPage(ActionSurfaceProfile::ListOnlyReadOnly)
|
return ActionSurfaceDeclaration::forPage(ActionSurfaceProfile::ListOnlyReadOnly)
|
||||||
@ -67,7 +71,7 @@ public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
|
|||||||
->exempt(ActionSurfaceSlot::InspectAffordance, 'Inventory coverage rows are runtime-derived metadata and intentionally omit inspect affordances.')
|
->exempt(ActionSurfaceSlot::InspectAffordance, 'Inventory coverage rows are runtime-derived metadata and intentionally omit inspect affordances.')
|
||||||
->exempt(ActionSurfaceSlot::ListRowMoreMenu, 'Derived coverage rows do not expose row actions.')
|
->exempt(ActionSurfaceSlot::ListRowMoreMenu, 'Derived coverage rows do not expose row actions.')
|
||||||
->exempt(ActionSurfaceSlot::ListBulkMoreGroup, 'Derived coverage rows do not expose bulk actions.')
|
->exempt(ActionSurfaceSlot::ListBulkMoreGroup, 'Derived coverage rows do not expose bulk actions.')
|
||||||
->satisfy(ActionSurfaceSlot::ListEmptyState, 'The empty state provides a clear-filters CTA to return to the full coverage matrix.');
|
->satisfy(ActionSurfaceSlot::ListEmptyState, 'The empty state provides a clear-filters CTA to return to the full tenant coverage report.');
|
||||||
}
|
}
|
||||||
|
|
||||||
public static function shouldRegisterNavigation(): bool
|
public static function shouldRegisterNavigation(): bool
|
||||||
@ -110,9 +114,12 @@ protected function getHeaderWidgets(): array
|
|||||||
public function table(Table $table): Table
|
public function table(Table $table): Table
|
||||||
{
|
{
|
||||||
return $table
|
return $table
|
||||||
|
->persistFiltersInSession()
|
||||||
|
->persistSearchInSession()
|
||||||
|
->persistSortInSession()
|
||||||
->searchable()
|
->searchable()
|
||||||
->searchPlaceholder('Search by policy type or label')
|
->searchPlaceholder('Search by type or label')
|
||||||
->defaultSort('label')
|
->defaultSort('follow_up_priority')
|
||||||
->defaultPaginationPageOption(50)
|
->defaultPaginationPageOption(50)
|
||||||
->paginated(\App\Support\Filament\TablePaginationProfiles::customPage())
|
->paginated(\App\Support\Filament\TablePaginationProfiles::customPage())
|
||||||
->records(function (
|
->records(function (
|
||||||
@ -142,14 +149,16 @@ public function table(Table $table): Table
|
|||||||
);
|
);
|
||||||
})
|
})
|
||||||
->columns([
|
->columns([
|
||||||
TextColumn::make('type')
|
TextColumn::make('coverage_state')
|
||||||
->label('Type')
|
->label('Coverage state')
|
||||||
->sortable()
|
->badge()
|
||||||
->fontFamily(FontFamily::Mono)
|
->formatStateUsing(BadgeRenderer::label(BadgeDomain::InventoryCoverageState))
|
||||||
->copyable()
|
->color(BadgeRenderer::color(BadgeDomain::InventoryCoverageState))
|
||||||
->wrap(),
|
->icon(BadgeRenderer::icon(BadgeDomain::InventoryCoverageState))
|
||||||
|
->iconColor(BadgeRenderer::iconColor(BadgeDomain::InventoryCoverageState))
|
||||||
|
->sortable(),
|
||||||
TextColumn::make('label')
|
TextColumn::make('label')
|
||||||
->label('Label')
|
->label('Type')
|
||||||
->sortable()
|
->sortable()
|
||||||
->badge()
|
->badge()
|
||||||
->formatStateUsing(function (?string $state, array $record): string {
|
->formatStateUsing(function (?string $state, array $record): string {
|
||||||
@ -179,17 +188,29 @@ public function table(Table $table): Table
|
|||||||
return $spec->iconColor ?? $spec->color;
|
return $spec->iconColor ?? $spec->color;
|
||||||
})
|
})
|
||||||
->wrap(),
|
->wrap(),
|
||||||
TextColumn::make('risk')
|
TextColumn::make('follow_up_guidance')
|
||||||
->label('Risk')
|
->label('Follow-up guidance')
|
||||||
|
->wrap()
|
||||||
|
->toggleable(),
|
||||||
|
TextColumn::make('observed_item_count')
|
||||||
|
->label('Observed items')
|
||||||
|
->numeric()
|
||||||
|
->sortable(),
|
||||||
|
TextColumn::make('category')
|
||||||
->badge()
|
->badge()
|
||||||
->formatStateUsing(BadgeRenderer::label(BadgeDomain::PolicyRisk))
|
->formatStateUsing(fn (?string $state): string => TagBadgeCatalog::spec(TagBadgeDomain::PolicyCategory, $state)->label)
|
||||||
->color(BadgeRenderer::color(BadgeDomain::PolicyRisk))
|
->color(fn (?string $state): string => TagBadgeCatalog::spec(TagBadgeDomain::PolicyCategory, $state)->color)
|
||||||
->icon(BadgeRenderer::icon(BadgeDomain::PolicyRisk))
|
->icon(fn (?string $state): ?string => TagBadgeCatalog::spec(TagBadgeDomain::PolicyCategory, $state)->icon)
|
||||||
->iconColor(BadgeRenderer::iconColor(BadgeDomain::PolicyRisk)),
|
->iconColor(function (?string $state): ?string {
|
||||||
|
$spec = TagBadgeCatalog::spec(TagBadgeDomain::PolicyCategory, $state);
|
||||||
|
|
||||||
|
return $spec->iconColor ?? $spec->color;
|
||||||
|
})
|
||||||
|
->toggleable()
|
||||||
|
->wrap(),
|
||||||
TextColumn::make('restore')
|
TextColumn::make('restore')
|
||||||
->label('Restore')
|
->label('Restore')
|
||||||
->badge()
|
->badge()
|
||||||
->state(fn (array $record): ?string => $record['restore'])
|
|
||||||
->formatStateUsing(function (?string $state): string {
|
->formatStateUsing(function (?string $state): string {
|
||||||
return filled($state)
|
return filled($state)
|
||||||
? BadgeCatalog::spec(BadgeDomain::PolicyRestoreMode, $state)->label
|
? BadgeCatalog::spec(BadgeDomain::PolicyRestoreMode, $state)->label
|
||||||
@ -213,20 +234,7 @@ public function table(Table $table): Table
|
|||||||
$spec = BadgeCatalog::spec(BadgeDomain::PolicyRestoreMode, $state);
|
$spec = BadgeCatalog::spec(BadgeDomain::PolicyRestoreMode, $state);
|
||||||
|
|
||||||
return $spec->iconColor ?? $spec->color;
|
return $spec->iconColor ?? $spec->color;
|
||||||
}),
|
})
|
||||||
TextColumn::make('category')
|
|
||||||
->badge()
|
|
||||||
->formatStateUsing(TagBadgeRenderer::label(TagBadgeDomain::PolicyCategory))
|
|
||||||
->color(TagBadgeRenderer::color(TagBadgeDomain::PolicyCategory))
|
|
||||||
->icon(TagBadgeRenderer::icon(TagBadgeDomain::PolicyCategory))
|
|
||||||
->iconColor(TagBadgeRenderer::iconColor(TagBadgeDomain::PolicyCategory))
|
|
||||||
->toggleable()
|
|
||||||
->wrap(),
|
|
||||||
TextColumn::make('segment')
|
|
||||||
->label('Segment')
|
|
||||||
->badge()
|
|
||||||
->formatStateUsing(fn (?string $state): string => $state === 'foundation' ? 'Foundation' : 'Policy')
|
|
||||||
->color(fn (?string $state): string => $state === 'foundation' ? 'gray' : 'info')
|
|
||||||
->toggleable(),
|
->toggleable(),
|
||||||
IconColumn::make('dependencies')
|
IconColumn::make('dependencies')
|
||||||
->label('Dependencies')
|
->label('Dependencies')
|
||||||
@ -237,10 +245,31 @@ public function table(Table $table): Table
|
|||||||
->falseColor('gray')
|
->falseColor('gray')
|
||||||
->alignCenter()
|
->alignCenter()
|
||||||
->toggleable(),
|
->toggleable(),
|
||||||
|
TextColumn::make('type')
|
||||||
|
->label('Type key')
|
||||||
|
->sortable()
|
||||||
|
->fontFamily(FontFamily::Mono)
|
||||||
|
->copyable()
|
||||||
|
->wrap()
|
||||||
|
->toggleable(isToggledHiddenByDefault: true),
|
||||||
|
TextColumn::make('segment')
|
||||||
|
->label('Segment')
|
||||||
|
->badge()
|
||||||
|
->formatStateUsing(fn (?string $state): string => $state === 'foundation' ? 'Foundation' : 'Policy')
|
||||||
|
->color(fn (?string $state): string => $state === 'foundation' ? 'gray' : 'info')
|
||||||
|
->toggleable(isToggledHiddenByDefault: true),
|
||||||
|
TextColumn::make('risk')
|
||||||
|
->label('Risk')
|
||||||
|
->badge()
|
||||||
|
->formatStateUsing(BadgeRenderer::label(BadgeDomain::PolicyRisk))
|
||||||
|
->color(BadgeRenderer::color(BadgeDomain::PolicyRisk))
|
||||||
|
->icon(BadgeRenderer::icon(BadgeDomain::PolicyRisk))
|
||||||
|
->iconColor(BadgeRenderer::iconColor(BadgeDomain::PolicyRisk))
|
||||||
|
->toggleable(isToggledHiddenByDefault: true),
|
||||||
])
|
])
|
||||||
->filters($this->tableFilters())
|
->filters($this->tableFilters())
|
||||||
->emptyStateHeading('No coverage entries match this view')
|
->emptyStateHeading('No coverage rows match this report')
|
||||||
->emptyStateDescription('Clear the current search or filters to return to the full coverage matrix.')
|
->emptyStateDescription('Clear the current search or filters to return to the full tenant coverage report.')
|
||||||
->emptyStateIcon('heroicon-o-funnel')
|
->emptyStateIcon('heroicon-o-funnel')
|
||||||
->emptyStateActions([
|
->emptyStateActions([
|
||||||
Action::make('clear_filters')
|
Action::make('clear_filters')
|
||||||
@ -261,6 +290,14 @@ public function table(Table $table): Table
|
|||||||
protected function tableFilters(): array
|
protected function tableFilters(): array
|
||||||
{
|
{
|
||||||
$filters = [
|
$filters = [
|
||||||
|
SelectFilter::make('coverage_state')
|
||||||
|
->label('Coverage state')
|
||||||
|
->options([
|
||||||
|
'succeeded' => BadgeCatalog::spec(BadgeDomain::InventoryCoverageState, 'succeeded')->label,
|
||||||
|
'failed' => BadgeCatalog::spec(BadgeDomain::InventoryCoverageState, 'failed')->label,
|
||||||
|
'skipped' => BadgeCatalog::spec(BadgeDomain::InventoryCoverageState, 'skipped')->label,
|
||||||
|
'unknown' => BadgeCatalog::spec(BadgeDomain::InventoryCoverageState, 'unknown')->label,
|
||||||
|
]),
|
||||||
SelectFilter::make('category')
|
SelectFilter::make('category')
|
||||||
->label('Category')
|
->label('Category')
|
||||||
->options($this->categoryFilterOptions()),
|
->options($this->categoryFilterOptions()),
|
||||||
@ -279,84 +316,36 @@ protected function tableFilters(): array
|
|||||||
* @return Collection<string, array{
|
* @return Collection<string, array{
|
||||||
* __key: string,
|
* __key: string,
|
||||||
* key: string,
|
* key: string,
|
||||||
* segment: string,
|
|
||||||
* type: string,
|
* type: string,
|
||||||
|
* segment: string,
|
||||||
* label: string,
|
* label: string,
|
||||||
* category: string,
|
* category: string,
|
||||||
* dependencies: bool,
|
* platform: ?string,
|
||||||
|
* coverage_state: string,
|
||||||
|
* follow_up_required: bool,
|
||||||
|
* follow_up_priority: int,
|
||||||
|
* follow_up_guidance: string,
|
||||||
|
* observed_item_count: int,
|
||||||
|
* basis_item_count: ?int,
|
||||||
|
* basis_error_code: ?string,
|
||||||
* restore: ?string,
|
* restore: ?string,
|
||||||
* risk: string,
|
* risk: ?string,
|
||||||
* source_order: int
|
* dependencies: bool,
|
||||||
|
* is_basis_payload_backed: bool
|
||||||
* }>
|
* }>
|
||||||
*/
|
*/
|
||||||
protected function coverageRows(): Collection
|
protected function coverageRows(): Collection
|
||||||
{
|
{
|
||||||
$resolver = app(CoverageCapabilitiesResolver::class);
|
$truth = $this->coverageTruth();
|
||||||
|
|
||||||
$supported = $this->mapCoverageRows(
|
if (! $truth instanceof TenantCoverageTruth) {
|
||||||
rows: InventoryPolicyTypeMeta::supported(),
|
return collect();
|
||||||
segment: 'policy',
|
|
||||||
sourceOrderOffset: 0,
|
|
||||||
resolver: $resolver,
|
|
||||||
);
|
|
||||||
|
|
||||||
return $supported->merge($this->mapCoverageRows(
|
|
||||||
rows: InventoryPolicyTypeMeta::foundations(),
|
|
||||||
segment: 'foundation',
|
|
||||||
sourceOrderOffset: $supported->count(),
|
|
||||||
resolver: $resolver,
|
|
||||||
));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
return collect($truth->rows)
|
||||||
* @param array<int, array<string, mixed>> $rows
|
->mapWithKeys(static fn ($row): array => [
|
||||||
* @return Collection<string, array{
|
$row->key => $row->toArray(),
|
||||||
* __key: string,
|
]);
|
||||||
* key: string,
|
|
||||||
* segment: string,
|
|
||||||
* type: string,
|
|
||||||
* label: string,
|
|
||||||
* category: string,
|
|
||||||
* dependencies: bool,
|
|
||||||
* restore: ?string,
|
|
||||||
* risk: string,
|
|
||||||
* source_order: int
|
|
||||||
* }>
|
|
||||||
*/
|
|
||||||
protected function mapCoverageRows(
|
|
||||||
array $rows,
|
|
||||||
string $segment,
|
|
||||||
int $sourceOrderOffset,
|
|
||||||
CoverageCapabilitiesResolver $resolver
|
|
||||||
): Collection {
|
|
||||||
return collect($rows)
|
|
||||||
->values()
|
|
||||||
->mapWithKeys(function (array $row, int $index) use ($resolver, $segment, $sourceOrderOffset): array {
|
|
||||||
$type = (string) ($row['type'] ?? '');
|
|
||||||
|
|
||||||
if ($type === '') {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
$key = "{$segment}:{$type}";
|
|
||||||
$restore = $row['restore'] ?? null;
|
|
||||||
$risk = $row['risk'] ?? 'n/a';
|
|
||||||
|
|
||||||
return [
|
|
||||||
$key => [
|
|
||||||
'__key' => $key,
|
|
||||||
'key' => $key,
|
|
||||||
'segment' => $segment,
|
|
||||||
'type' => $type,
|
|
||||||
'label' => (string) ($row['label'] ?? $type),
|
|
||||||
'category' => (string) ($row['category'] ?? 'Other'),
|
|
||||||
'dependencies' => $segment === 'policy' && $resolver->supportsDependencies($type),
|
|
||||||
'restore' => is_string($restore) ? $restore : null,
|
|
||||||
'risk' => is_string($risk) ? $risk : 'n/a',
|
|
||||||
'source_order' => $sourceOrderOffset + $index,
|
|
||||||
],
|
|
||||||
];
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -367,6 +356,7 @@ protected function mapCoverageRows(
|
|||||||
protected function filterRows(Collection $rows, ?string $search, array $filters): Collection
|
protected function filterRows(Collection $rows, ?string $search, array $filters): Collection
|
||||||
{
|
{
|
||||||
$normalizedSearch = Str::lower(trim((string) $search));
|
$normalizedSearch = Str::lower(trim((string) $search));
|
||||||
|
$coverageState = $filters['coverage_state']['value'] ?? null;
|
||||||
$category = $filters['category']['value'] ?? null;
|
$category = $filters['category']['value'] ?? null;
|
||||||
$restore = $filters['restore']['value'] ?? null;
|
$restore = $filters['restore']['value'] ?? null;
|
||||||
|
|
||||||
@ -380,6 +370,10 @@ function (Collection $rows) use ($normalizedSearch): Collection {
|
|||||||
});
|
});
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
->when(
|
||||||
|
filled($coverageState),
|
||||||
|
fn (Collection $rows): Collection => $rows->where('coverage_state', (string) $coverageState),
|
||||||
|
)
|
||||||
->when(
|
->when(
|
||||||
filled($category),
|
filled($category),
|
||||||
fn (Collection $rows): Collection => $rows->where('category', (string) $category),
|
fn (Collection $rows): Collection => $rows->where('category', (string) $category),
|
||||||
@ -396,22 +390,35 @@ function (Collection $rows) use ($normalizedSearch): Collection {
|
|||||||
*/
|
*/
|
||||||
protected function sortRows(Collection $rows, ?string $sortColumn, ?string $sortDirection): Collection
|
protected function sortRows(Collection $rows, ?string $sortColumn, ?string $sortDirection): Collection
|
||||||
{
|
{
|
||||||
$sortColumn = in_array($sortColumn, ['type', 'label'], true) ? $sortColumn : null;
|
$sortColumn = in_array($sortColumn, ['type', 'label', 'observed_item_count', 'coverage_state', 'follow_up_priority'], true)
|
||||||
|
? $sortColumn
|
||||||
|
: null;
|
||||||
|
|
||||||
if ($sortColumn === null) {
|
if ($sortColumn === null) {
|
||||||
return $rows->sortBy('source_order');
|
return $rows;
|
||||||
}
|
}
|
||||||
|
|
||||||
$records = $rows->all();
|
$records = $rows->all();
|
||||||
|
|
||||||
uasort($records, function (array $left, array $right) use ($sortColumn, $sortDirection): int {
|
uasort($records, function (array $left, array $right) use ($sortColumn, $sortDirection): int {
|
||||||
$comparison = strnatcasecmp(
|
$comparison = match ($sortColumn) {
|
||||||
|
'observed_item_count' => ((int) ($left[$sortColumn] ?? 0)) <=> ((int) ($right[$sortColumn] ?? 0)),
|
||||||
|
'follow_up_priority' => ((int) ($left[$sortColumn] ?? 0)) <=> ((int) ($right[$sortColumn] ?? 0)),
|
||||||
|
default => strnatcasecmp(
|
||||||
(string) ($left[$sortColumn] ?? ''),
|
(string) ($left[$sortColumn] ?? ''),
|
||||||
(string) ($right[$sortColumn] ?? ''),
|
(string) ($right[$sortColumn] ?? ''),
|
||||||
);
|
),
|
||||||
|
};
|
||||||
|
|
||||||
|
if ($comparison === 0 && $sortColumn === 'follow_up_priority') {
|
||||||
|
$comparison = ((int) ($right['observed_item_count'] ?? 0)) <=> ((int) ($left['observed_item_count'] ?? 0));
|
||||||
|
}
|
||||||
|
|
||||||
if ($comparison === 0) {
|
if ($comparison === 0) {
|
||||||
$comparison = ((int) ($left['source_order'] ?? 0)) <=> ((int) ($right['source_order'] ?? 0));
|
$comparison = strnatcasecmp(
|
||||||
|
(string) ($left['label'] ?? ''),
|
||||||
|
(string) ($right['label'] ?? ''),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return $sortDirection === 'desc' ? ($comparison * -1) : $comparison;
|
return $sortDirection === 'desc' ? ($comparison * -1) : $comparison;
|
||||||
@ -468,4 +475,99 @@ protected function restoreFilterOptions(): array
|
|||||||
})
|
})
|
||||||
->all();
|
->all();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
public function coverageSummary(): array
|
||||||
|
{
|
||||||
|
$truth = $this->coverageTruth();
|
||||||
|
|
||||||
|
if (! $truth instanceof TenantCoverageTruth) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'supportedTypes' => $truth->supportedTypeCount,
|
||||||
|
'succeededTypes' => $truth->succeededTypeCount,
|
||||||
|
'followUpTypes' => $truth->followUpTypeCount,
|
||||||
|
'observedItems' => $truth->observedItemTotal,
|
||||||
|
'observedTypes' => $truth->observedTypeCount(),
|
||||||
|
'topFollowUpLabel' => $truth->topPriorityFollowUpRow()?->label,
|
||||||
|
'topFollowUpGuidance' => $truth->topPriorityFollowUpRow()?->followUpGuidance,
|
||||||
|
'hasCurrentCoverageResult' => $truth->hasCurrentCoverageResult,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
public function basisRunSummary(): array
|
||||||
|
{
|
||||||
|
$truth = $this->coverageTruth();
|
||||||
|
$tenant = static::resolveTenantContextForCurrentPanel();
|
||||||
|
$user = auth()->user();
|
||||||
|
|
||||||
|
if (! $truth instanceof TenantCoverageTruth || ! $tenant instanceof Tenant) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! $truth->basisRun instanceof OperationRun) {
|
||||||
|
return [
|
||||||
|
'title' => 'No current coverage basis',
|
||||||
|
'body' => $user instanceof User && $user->can(Capabilities::TENANT_INVENTORY_SYNC_RUN, $tenant)
|
||||||
|
? 'Run Inventory Sync from Inventory Items to establish current tenant coverage truth.'
|
||||||
|
: 'A tenant operator with inventory sync permission must establish current tenant coverage truth.',
|
||||||
|
'badgeLabel' => null,
|
||||||
|
'badgeColor' => null,
|
||||||
|
'runUrl' => null,
|
||||||
|
'historyUrl' => null,
|
||||||
|
'inventoryItemsUrl' => InventoryItemResource::getUrl('index', tenant: $tenant),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
$badge = BadgeRenderer::spec(BadgeDomain::OperationRunOutcome, (string) $truth->basisRun->outcome);
|
||||||
|
$canViewRun = $user instanceof User && Gate::forUser($user)->allows('view', $truth->basisRun);
|
||||||
|
|
||||||
|
return [
|
||||||
|
'title' => sprintf('Latest coverage-bearing sync completed %s.', $truth->basisCompletedAtLabel() ?? 'recently'),
|
||||||
|
'body' => $canViewRun
|
||||||
|
? 'Review the cited inventory sync to inspect provider or permission issues in detail.'
|
||||||
|
: 'The coverage basis is current, but your role cannot open the cited run detail.',
|
||||||
|
'badgeLabel' => $badge->label,
|
||||||
|
'badgeColor' => $badge->color,
|
||||||
|
'runUrl' => $canViewRun ? route('admin.operations.view', ['run' => (int) $truth->basisRun->getKey()]) : null,
|
||||||
|
'historyUrl' => $canViewRun ? $this->inventorySyncHistoryUrl($tenant) : null,
|
||||||
|
'inventoryItemsUrl' => InventoryItemResource::getUrl('index', tenant: $tenant),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function coverageTruth(): ?TenantCoverageTruth
|
||||||
|
{
|
||||||
|
if ($this->cachedCoverageTruth instanceof TenantCoverageTruth) {
|
||||||
|
return $this->cachedCoverageTruth;
|
||||||
|
}
|
||||||
|
|
||||||
|
$tenant = static::resolveTenantContextForCurrentPanel();
|
||||||
|
|
||||||
|
if (! $tenant instanceof Tenant) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->cachedCoverageTruth = app(TenantCoverageTruthResolver::class)->resolve($tenant);
|
||||||
|
|
||||||
|
return $this->cachedCoverageTruth;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function inventorySyncHistoryUrl(Tenant $tenant): string
|
||||||
|
{
|
||||||
|
return route('admin.operations.index', [
|
||||||
|
'tenant_id' => (int) $tenant->getKey(),
|
||||||
|
'tableFilters' => [
|
||||||
|
'type' => [
|
||||||
|
'value' => 'inventory_sync',
|
||||||
|
],
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -10,6 +10,7 @@
|
|||||||
use App\Models\Workspace;
|
use App\Models\Workspace;
|
||||||
use App\Services\Auth\WorkspaceCapabilityResolver;
|
use App\Services\Auth\WorkspaceCapabilityResolver;
|
||||||
use App\Support\Auth\Capabilities;
|
use App\Support\Auth\Capabilities;
|
||||||
|
use App\Support\Navigation\CanonicalNavigationContext;
|
||||||
use App\Support\OperateHub\OperateHubShell;
|
use App\Support\OperateHub\OperateHubShell;
|
||||||
use App\Support\Workspaces\WorkspaceContext;
|
use App\Support\Workspaces\WorkspaceContext;
|
||||||
use BackedEnum;
|
use BackedEnum;
|
||||||
@ -79,9 +80,23 @@ protected function getHeaderWidgets(): array
|
|||||||
*/
|
*/
|
||||||
protected function getHeaderActions(): array
|
protected function getHeaderActions(): array
|
||||||
{
|
{
|
||||||
return app(OperateHubShell::class)->headerActions(
|
$actions = app(OperateHubShell::class)->headerActions(
|
||||||
scopeActionName: 'operate_hub_scope_alerts',
|
scopeActionName: 'operate_hub_scope_alerts',
|
||||||
returnActionName: 'operate_hub_return_alerts',
|
returnActionName: 'operate_hub_return_alerts',
|
||||||
);
|
);
|
||||||
|
|
||||||
|
$navigationContext = CanonicalNavigationContext::fromRequest(request());
|
||||||
|
|
||||||
|
if ($navigationContext?->backLinkLabel !== null && $navigationContext->backLinkUrl !== null) {
|
||||||
|
array_splice($actions, 1, 0, [
|
||||||
|
Action::make('operate_hub_back_to_origin_alerts')
|
||||||
|
->label($navigationContext->backLinkLabel)
|
||||||
|
->icon('heroicon-o-arrow-left')
|
||||||
|
->color('gray')
|
||||||
|
->url($navigationContext->backLinkUrl),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $actions;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -7,9 +7,11 @@
|
|||||||
use App\Filament\Resources\EvidenceSnapshotResource;
|
use App\Filament\Resources\EvidenceSnapshotResource;
|
||||||
use App\Models\EvidenceSnapshot;
|
use App\Models\EvidenceSnapshot;
|
||||||
use App\Models\Tenant;
|
use App\Models\Tenant;
|
||||||
|
use App\Models\TenantReview;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use App\Support\Badges\BadgeCatalog;
|
use App\Support\Badges\BadgeCatalog;
|
||||||
use App\Support\Badges\BadgeDomain;
|
use App\Support\Badges\BadgeDomain;
|
||||||
|
use App\Support\TenantReviewStatus;
|
||||||
use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration;
|
use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration;
|
||||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceInspectAffordance;
|
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceInspectAffordance;
|
||||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile;
|
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile;
|
||||||
@ -90,14 +92,30 @@ public function mount(): void
|
|||||||
}
|
}
|
||||||
|
|
||||||
$snapshots = $query->get()->unique('tenant_id')->values();
|
$snapshots = $query->get()->unique('tenant_id')->values();
|
||||||
|
$currentReviewTenantIds = TenantReview::query()
|
||||||
|
->where('workspace_id', $workspaceId)
|
||||||
|
->whereIn('tenant_id', $snapshots->pluck('tenant_id')->map(static fn (mixed $tenantId): int => (int) $tenantId)->all())
|
||||||
|
->whereIn('status', [
|
||||||
|
TenantReviewStatus::Draft->value,
|
||||||
|
TenantReviewStatus::Ready->value,
|
||||||
|
TenantReviewStatus::Published->value,
|
||||||
|
])
|
||||||
|
->pluck('tenant_id')
|
||||||
|
->mapWithKeys(static fn (mixed $tenantId): array => [(int) $tenantId => true])
|
||||||
|
->all();
|
||||||
|
|
||||||
$this->rows = $snapshots->map(function (EvidenceSnapshot $snapshot): array {
|
$this->rows = $snapshots->map(function (EvidenceSnapshot $snapshot) use ($currentReviewTenantIds): array {
|
||||||
$truth = $this->snapshotTruth($snapshot);
|
$truth = $this->snapshotTruth($snapshot);
|
||||||
$freshnessSpec = BadgeCatalog::spec(BadgeDomain::GovernanceArtifactFreshness, $truth->freshnessState);
|
$freshnessSpec = BadgeCatalog::spec(BadgeDomain::GovernanceArtifactFreshness, $truth->freshnessState);
|
||||||
|
$tenantId = (int) $snapshot->tenant_id;
|
||||||
|
$hasCurrentReview = $currentReviewTenantIds[$tenantId] ?? false;
|
||||||
|
$nextStep = ! $hasCurrentReview && $truth->contentState === 'trusted' && $truth->freshnessState === 'current'
|
||||||
|
? 'Create a current review from this evidence snapshot'
|
||||||
|
: $truth->nextStepText();
|
||||||
|
|
||||||
return [
|
return [
|
||||||
'tenant_name' => $snapshot->tenant?->name ?? 'Unknown tenant',
|
'tenant_name' => $snapshot->tenant?->name ?? 'Unknown tenant',
|
||||||
'tenant_id' => (int) $snapshot->tenant_id,
|
'tenant_id' => $tenantId,
|
||||||
'snapshot_id' => (int) $snapshot->getKey(),
|
'snapshot_id' => (int) $snapshot->getKey(),
|
||||||
'completeness_state' => (string) $snapshot->completeness_state,
|
'completeness_state' => (string) $snapshot->completeness_state,
|
||||||
'generated_at' => $snapshot->generated_at?->toDateTimeString(),
|
'generated_at' => $snapshot->generated_at?->toDateTimeString(),
|
||||||
@ -114,7 +132,7 @@ public function mount(): void
|
|||||||
'color' => $freshnessSpec->color,
|
'color' => $freshnessSpec->color,
|
||||||
'icon' => $freshnessSpec->icon,
|
'icon' => $freshnessSpec->icon,
|
||||||
],
|
],
|
||||||
'next_step' => $truth->nextStepText(),
|
'next_step' => $nextStep,
|
||||||
'view_url' => $snapshot->tenant
|
'view_url' => $snapshot->tenant
|
||||||
? EvidenceSnapshotResource::getUrl('view', ['record' => $snapshot], tenant: $snapshot->tenant)
|
? EvidenceSnapshotResource::getUrl('view', ['record' => $snapshot], tenant: $snapshot->tenant)
|
||||||
: null,
|
: null,
|
||||||
|
|||||||
@ -74,6 +74,8 @@ public function mount(): void
|
|||||||
{
|
{
|
||||||
$this->navigationContextPayload = is_array(request()->query('nav')) ? request()->query('nav') : null;
|
$this->navigationContextPayload = is_array(request()->query('nav')) ? request()->query('nav') : null;
|
||||||
|
|
||||||
|
$this->applyRequestedTenantScope();
|
||||||
|
|
||||||
app(CanonicalAdminTenantFilterState::class)->sync(
|
app(CanonicalAdminTenantFilterState::class)->sync(
|
||||||
$this->getTableFiltersSessionKey(),
|
$this->getTableFiltersSessionKey(),
|
||||||
['type', 'initiator_name'],
|
['type', 'initiator_name'],
|
||||||
@ -81,6 +83,7 @@ public function mount(): void
|
|||||||
);
|
);
|
||||||
|
|
||||||
$this->mountInteractsWithTable();
|
$this->mountInteractsWithTable();
|
||||||
|
$this->applyRequestedDashboardPrefilter();
|
||||||
}
|
}
|
||||||
|
|
||||||
protected function getHeaderWidgets(): array
|
protected function getHeaderWidgets(): array
|
||||||
@ -186,6 +189,17 @@ public function table(Table $table): Table
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function applyRequestedTenantScope(): void
|
||||||
|
{
|
||||||
|
if (! $this->shouldForceWorkspaceWideTenantScope()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Filament::setTenant(null, true);
|
||||||
|
|
||||||
|
app(WorkspaceContext::class)->clearLastTenantId(request());
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @return array{likely_stale:int,reconciled:int}
|
* @return array{likely_stale:int,reconciled:int}
|
||||||
*/
|
*/
|
||||||
@ -206,40 +220,7 @@ public function lifecycleVisibilitySummary(): array
|
|||||||
|
|
||||||
$policy = app(OperationLifecyclePolicy::class);
|
$policy = app(OperationLifecyclePolicy::class);
|
||||||
$likelyStale = (clone $baseQuery)
|
$likelyStale = (clone $baseQuery)
|
||||||
->whereIn('status', [
|
->likelyStale($policy)
|
||||||
OperationRunStatus::Queued->value,
|
|
||||||
OperationRunStatus::Running->value,
|
|
||||||
])
|
|
||||||
->where(function (Builder $query) use ($policy): void {
|
|
||||||
foreach ($policy->coveredTypeNames() as $type) {
|
|
||||||
$query->orWhere(function (Builder $typeQuery) use ($policy, $type): void {
|
|
||||||
$typeQuery
|
|
||||||
->where('type', $type)
|
|
||||||
->where(function (Builder $stateQuery) use ($policy, $type): void {
|
|
||||||
$stateQuery
|
|
||||||
->where(function (Builder $queuedQuery) use ($policy, $type): void {
|
|
||||||
$queuedQuery
|
|
||||||
->where('status', OperationRunStatus::Queued->value)
|
|
||||||
->whereNull('started_at')
|
|
||||||
->where('created_at', '<=', now()->subSeconds($policy->queuedStaleAfterSeconds($type)));
|
|
||||||
})
|
|
||||||
->orWhere(function (Builder $runningQuery) use ($policy, $type): void {
|
|
||||||
$runningQuery
|
|
||||||
->where('status', OperationRunStatus::Running->value)
|
|
||||||
->where(function (Builder $startedAtQuery) use ($policy, $type): void {
|
|
||||||
$startedAtQuery
|
|
||||||
->where('started_at', '<=', now()->subSeconds($policy->runningStaleAfterSeconds($type)))
|
|
||||||
->orWhere(function (Builder $fallbackQuery) use ($policy, $type): void {
|
|
||||||
$fallbackQuery
|
|
||||||
->whereNull('started_at')
|
|
||||||
->where('created_at', '<=', now()->subSeconds($policy->runningStaleAfterSeconds($type)));
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
})
|
|
||||||
->count();
|
->count();
|
||||||
|
|
||||||
return [
|
return [
|
||||||
@ -251,13 +232,8 @@ public function lifecycleVisibilitySummary(): array
|
|||||||
private function applyActiveTab(Builder $query): Builder
|
private function applyActiveTab(Builder $query): Builder
|
||||||
{
|
{
|
||||||
return match ($this->activeTab) {
|
return match ($this->activeTab) {
|
||||||
'active' => $query->whereIn('status', [
|
'active' => $query->healthyActive(),
|
||||||
OperationRunStatus::Queued->value,
|
'blocked' => $query->dashboardNeedsFollowUp(),
|
||||||
OperationRunStatus::Running->value,
|
|
||||||
]),
|
|
||||||
'blocked' => $query
|
|
||||||
->where('status', OperationRunStatus::Completed->value)
|
|
||||||
->where('outcome', OperationRunOutcome::Blocked->value),
|
|
||||||
'succeeded' => $query
|
'succeeded' => $query
|
||||||
->where('status', OperationRunStatus::Completed->value)
|
->where('status', OperationRunStatus::Completed->value)
|
||||||
->where('outcome', OperationRunOutcome::Succeeded->value),
|
->where('outcome', OperationRunOutcome::Succeeded->value),
|
||||||
@ -292,4 +268,28 @@ private function scopedSummaryQuery(): ?Builder
|
|||||||
fn (Builder $query): Builder => $query->where('tenant_id', (int) $tenantFilter),
|
fn (Builder $query): Builder => $query->where('tenant_id', (int) $tenantFilter),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function applyRequestedDashboardPrefilter(): void
|
||||||
|
{
|
||||||
|
if (! $this->shouldForceWorkspaceWideTenantScope()) {
|
||||||
|
$requestedTenantId = request()->query('tenant_id');
|
||||||
|
|
||||||
|
if (is_numeric($requestedTenantId)) {
|
||||||
|
$tenantId = (string) $requestedTenantId;
|
||||||
|
$this->tableFilters['tenant_id']['value'] = $tenantId;
|
||||||
|
$this->tableDeferredFilters['tenant_id']['value'] = $tenantId;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$requestedTab = request()->query('activeTab');
|
||||||
|
|
||||||
|
if (in_array($requestedTab, ['all', 'active', 'blocked', 'succeeded', 'partial', 'failed'], true)) {
|
||||||
|
$this->activeTab = (string) $requestedTab;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function shouldForceWorkspaceWideTenantScope(): bool
|
||||||
|
{
|
||||||
|
return request()->query('tenant_scope') === 'all';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -87,7 +87,6 @@
|
|||||||
use Illuminate\Database\QueryException;
|
use Illuminate\Database\QueryException;
|
||||||
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
|
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
|
||||||
use Illuminate\Support\Facades\DB;
|
use Illuminate\Support\Facades\DB;
|
||||||
use Illuminate\Support\Facades\Gate;
|
|
||||||
use Illuminate\Validation\ValidationException;
|
use Illuminate\Validation\ValidationException;
|
||||||
use InvalidArgumentException;
|
use InvalidArgumentException;
|
||||||
use Livewire\Attributes\Locked;
|
use Livewire\Attributes\Locked;
|
||||||
@ -3334,7 +3333,7 @@ private function canInspectOperationRun(OperationRun $run): bool
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
return Gate::forUser($user)->allows('view', $run);
|
return $user->can('view', $run);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function verificationSucceeded(): bool
|
public function verificationSucceeded(): bool
|
||||||
|
|||||||
@ -57,6 +57,7 @@ public function mount(): void
|
|||||||
$this->syncCanonicalAdminTenantFilterState();
|
$this->syncCanonicalAdminTenantFilterState();
|
||||||
|
|
||||||
parent::mount();
|
parent::mount();
|
||||||
|
$this->applyRequestedDashboardPrefilter();
|
||||||
}
|
}
|
||||||
|
|
||||||
protected function getHeaderWidgets(): array
|
protected function getHeaderWidgets(): array
|
||||||
@ -357,6 +358,61 @@ private function syncCanonicalAdminTenantFilterState(): void
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function applyRequestedDashboardPrefilter(): void
|
||||||
|
{
|
||||||
|
$requestedTab = request()->query('tab');
|
||||||
|
$requestedStatus = request()->query('status');
|
||||||
|
$requestedFindingType = request()->query('finding_type');
|
||||||
|
$requestedGovernanceValidity = request()->query('governance_validity');
|
||||||
|
$requestedHighSeverity = request()->query('high_severity');
|
||||||
|
|
||||||
|
$hasDashboardPrefilter = $requestedTab !== null
|
||||||
|
|| $requestedStatus !== null
|
||||||
|
|| $requestedFindingType !== null
|
||||||
|
|| $requestedGovernanceValidity !== null
|
||||||
|
|| $requestedHighSeverity !== null;
|
||||||
|
|
||||||
|
if (! $hasDashboardPrefilter) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (['status', 'finding_type', 'workflow_family', 'governance_validity'] as $filterName) {
|
||||||
|
data_forget($this->tableFilters, $filterName);
|
||||||
|
data_forget($this->tableDeferredFilters, $filterName);
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (['high_severity', 'overdue', 'my_assigned'] as $filterName) {
|
||||||
|
data_forget($this->tableFilters, "{$filterName}.isActive");
|
||||||
|
data_forget($this->tableDeferredFilters, "{$filterName}.isActive");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (in_array($requestedTab, array_keys($this->getTabs()), true)) {
|
||||||
|
$this->activeTab = (string) $requestedTab;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (is_string($requestedStatus) && $requestedStatus !== '') {
|
||||||
|
$this->tableFilters['status']['value'] = $requestedStatus;
|
||||||
|
$this->tableDeferredFilters['status']['value'] = $requestedStatus;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (is_string($requestedFindingType) && $requestedFindingType !== '') {
|
||||||
|
$this->tableFilters['finding_type']['value'] = $requestedFindingType;
|
||||||
|
$this->tableDeferredFilters['finding_type']['value'] = $requestedFindingType;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (is_string($requestedGovernanceValidity) && $requestedGovernanceValidity !== '') {
|
||||||
|
$this->tableFilters['governance_validity']['value'] = $requestedGovernanceValidity;
|
||||||
|
$this->tableDeferredFilters['governance_validity']['value'] = $requestedGovernanceValidity;
|
||||||
|
}
|
||||||
|
|
||||||
|
$highSeverity = filter_var($requestedHighSeverity, FILTER_VALIDATE_BOOL, FILTER_NULL_ON_FAILURE);
|
||||||
|
|
||||||
|
if ($highSeverity === true) {
|
||||||
|
$this->tableFilters['high_severity']['isActive'] = true;
|
||||||
|
$this->tableDeferredFilters['high_severity']['isActive'] = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private function filterIsActive(string $filterName): bool
|
private function filterIsActive(string $filterName): bool
|
||||||
{
|
{
|
||||||
$state = $this->getTableFilterState($filterName);
|
$state = $this->getTableFilterState($filterName);
|
||||||
|
|||||||
@ -16,6 +16,9 @@
|
|||||||
use App\Support\Filament\FilterOptionCatalog;
|
use App\Support\Filament\FilterOptionCatalog;
|
||||||
use App\Support\Filament\FilterPresets;
|
use App\Support\Filament\FilterPresets;
|
||||||
use App\Support\Filament\TablePaginationProfiles;
|
use App\Support\Filament\TablePaginationProfiles;
|
||||||
|
use App\Support\Inventory\InventoryCoverage;
|
||||||
|
use App\Support\Inventory\InventoryPolicyTypeMeta;
|
||||||
|
use App\Support\Inventory\TenantCoverageTruthResolver;
|
||||||
use App\Support\Navigation\CrossResourceNavigationMatrix;
|
use App\Support\Navigation\CrossResourceNavigationMatrix;
|
||||||
use App\Support\Navigation\RelatedNavigationResolver;
|
use App\Support\Navigation\RelatedNavigationResolver;
|
||||||
use App\Support\OperateHub\OperateHubShell;
|
use App\Support\OperateHub\OperateHubShell;
|
||||||
@ -472,6 +475,21 @@ private static function enterpriseDetailPage(OperationRun $record): \App\Support
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$inventorySyncCoverageSection = static::inventorySyncCoverageSection($record);
|
||||||
|
|
||||||
|
if ($inventorySyncCoverageSection !== null) {
|
||||||
|
$builder->addSection(
|
||||||
|
$factory->viewSection(
|
||||||
|
id: 'inventory_sync_coverage',
|
||||||
|
kind: 'type_specific_detail',
|
||||||
|
title: 'Inventory sync coverage',
|
||||||
|
description: 'Per-type run results explain what this sync established without forcing operators into raw JSON first.',
|
||||||
|
view: 'filament.infolists.entries.inventory-coverage-truth',
|
||||||
|
viewData: $inventorySyncCoverageSection,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
if (VerificationReportViewer::shouldRenderForRun($record)) {
|
if (VerificationReportViewer::shouldRenderForRun($record)) {
|
||||||
$builder->addSection(
|
$builder->addSection(
|
||||||
$factory->viewSection(
|
$factory->viewSection(
|
||||||
@ -1169,6 +1187,106 @@ private static function reconciliationPayload(OperationRun $record): array
|
|||||||
return $reconciliation;
|
return $reconciliation;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array{
|
||||||
|
* rows: list<array{
|
||||||
|
* type: string,
|
||||||
|
* label: string,
|
||||||
|
* segment: string,
|
||||||
|
* category: string,
|
||||||
|
* coverageState: string,
|
||||||
|
* followUpRequired: bool,
|
||||||
|
* followUpPriority: int,
|
||||||
|
* followUpGuidance: string,
|
||||||
|
* itemCount: int,
|
||||||
|
* errorCode: ?string
|
||||||
|
* }>,
|
||||||
|
* summary: array{
|
||||||
|
* totalTypes: int,
|
||||||
|
* succeededTypes: int,
|
||||||
|
* failedTypes: int,
|
||||||
|
* skippedTypes: int,
|
||||||
|
* followUpTypes: int,
|
||||||
|
* observedItems: int
|
||||||
|
* },
|
||||||
|
* runOutcomeLabel: string,
|
||||||
|
* runOutcomeColor: string,
|
||||||
|
* runOutcomeIcon: ?string
|
||||||
|
* }|null
|
||||||
|
*/
|
||||||
|
private static function inventorySyncCoverageSection(OperationRun $record): ?array
|
||||||
|
{
|
||||||
|
if ((string) $record->type !== 'inventory_sync') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$coverage = $record->inventoryCoverage();
|
||||||
|
|
||||||
|
if (! $coverage instanceof InventoryCoverage) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$rows = collect($coverage->rows())
|
||||||
|
->map(function (array $row): array {
|
||||||
|
$type = (string) ($row['type'] ?? '');
|
||||||
|
$meta = InventoryPolicyTypeMeta::metaFor($type);
|
||||||
|
$status = is_string($row['status'] ?? null) ? (string) $row['status'] : InventoryCoverage::StatusFailed;
|
||||||
|
$errorCode = is_string($row['error_code'] ?? null) ? (string) $row['error_code'] : null;
|
||||||
|
$itemCount = is_int($row['item_count'] ?? null) ? (int) $row['item_count'] : 0;
|
||||||
|
|
||||||
|
return [
|
||||||
|
'type' => $type,
|
||||||
|
'label' => is_string($meta['label'] ?? null) && $meta['label'] !== ''
|
||||||
|
? (string) $meta['label']
|
||||||
|
: $type,
|
||||||
|
'segment' => (string) ($row['segment'] ?? 'policy'),
|
||||||
|
'category' => is_string($meta['category'] ?? null) && $meta['category'] !== ''
|
||||||
|
? (string) $meta['category']
|
||||||
|
: 'Other',
|
||||||
|
'coverageState' => $status,
|
||||||
|
'followUpRequired' => $status !== InventoryCoverage::StatusSucceeded,
|
||||||
|
'followUpPriority' => TenantCoverageTruthResolver::followUpPriorityForState($status),
|
||||||
|
'followUpGuidance' => TenantCoverageTruthResolver::followUpGuidanceForState($status, $errorCode),
|
||||||
|
'itemCount' => $itemCount,
|
||||||
|
'errorCode' => $errorCode,
|
||||||
|
];
|
||||||
|
})
|
||||||
|
->sort(function (array $left, array $right): int {
|
||||||
|
$priority = ((int) ($left['followUpPriority'] ?? 0)) <=> ((int) ($right['followUpPriority'] ?? 0));
|
||||||
|
|
||||||
|
if ($priority !== 0) {
|
||||||
|
return $priority;
|
||||||
|
}
|
||||||
|
|
||||||
|
$items = ((int) ($right['itemCount'] ?? 0)) <=> ((int) ($left['itemCount'] ?? 0));
|
||||||
|
|
||||||
|
if ($items !== 0) {
|
||||||
|
return $items;
|
||||||
|
}
|
||||||
|
|
||||||
|
return strnatcasecmp((string) ($left['label'] ?? ''), (string) ($right['label'] ?? ''));
|
||||||
|
})
|
||||||
|
->values()
|
||||||
|
->all();
|
||||||
|
|
||||||
|
$outcomeSpec = BadgeRenderer::spec(BadgeDomain::OperationRunOutcome, static::outcomeBadgeState($record));
|
||||||
|
|
||||||
|
return [
|
||||||
|
'rows' => $rows,
|
||||||
|
'summary' => [
|
||||||
|
'totalTypes' => count($rows),
|
||||||
|
'succeededTypes' => count(array_filter($rows, static fn (array $row): bool => ($row['coverageState'] ?? null) === InventoryCoverage::StatusSucceeded)),
|
||||||
|
'failedTypes' => count(array_filter($rows, static fn (array $row): bool => ($row['coverageState'] ?? null) === InventoryCoverage::StatusFailed)),
|
||||||
|
'skippedTypes' => count(array_filter($rows, static fn (array $row): bool => ($row['coverageState'] ?? null) === InventoryCoverage::StatusSkipped)),
|
||||||
|
'followUpTypes' => count(array_filter($rows, static fn (array $row): bool => (bool) ($row['followUpRequired'] ?? false))),
|
||||||
|
'observedItems' => array_sum(array_map(static fn (array $row): int => (int) ($row['itemCount'] ?? 0), $rows)),
|
||||||
|
],
|
||||||
|
'runOutcomeLabel' => $outcomeSpec->label,
|
||||||
|
'runOutcomeColor' => $outcomeSpec->color,
|
||||||
|
'runOutcomeIcon' => $outcomeSpec->icon,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
private static function formatDetailTimestamp(mixed $value): string
|
private static function formatDetailTimestamp(mixed $value): string
|
||||||
{
|
{
|
||||||
if (! $value instanceof \Illuminate\Support\Carbon) {
|
if (! $value instanceof \Illuminate\Support\Carbon) {
|
||||||
|
|||||||
@ -469,29 +469,12 @@ private static function migrationReviewDescription(?ProviderConnection $record):
|
|||||||
|
|
||||||
private static function consentStatusLabelFromState(mixed $state): string
|
private static function consentStatusLabelFromState(mixed $state): string
|
||||||
{
|
{
|
||||||
$value = $state instanceof BackedEnum ? $state->value : (is_string($state) ? $state : 'unknown');
|
return BadgeRenderer::spec(BadgeDomain::ProviderConsentStatus, $state)->label;
|
||||||
|
|
||||||
return match ($value) {
|
|
||||||
'required' => 'Required',
|
|
||||||
'granted' => 'Granted',
|
|
||||||
'failed' => 'Failed',
|
|
||||||
'revoked' => 'Revoked',
|
|
||||||
default => 'Unknown',
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private static function verificationStatusLabelFromState(mixed $state): string
|
private static function verificationStatusLabelFromState(mixed $state): string
|
||||||
{
|
{
|
||||||
$value = $state instanceof BackedEnum ? $state->value : (is_string($state) ? $state : 'unknown');
|
return BadgeRenderer::spec(BadgeDomain::ProviderVerificationStatus, $state)->label;
|
||||||
|
|
||||||
return match ($value) {
|
|
||||||
'pending' => 'Pending',
|
|
||||||
'healthy' => 'Healthy',
|
|
||||||
'degraded' => 'Degraded',
|
|
||||||
'blocked' => 'Blocked',
|
|
||||||
'error' => 'Error',
|
|
||||||
default => 'Unknown',
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public static function form(Schema $schema): Schema
|
public static function form(Schema $schema): Schema
|
||||||
@ -527,7 +510,7 @@ public static function form(Schema $schema): Schema
|
|||||||
])
|
])
|
||||||
->columns(2)
|
->columns(2)
|
||||||
->columnSpanFull(),
|
->columnSpanFull(),
|
||||||
Section::make('Status')
|
Section::make('Current state')
|
||||||
->schema([
|
->schema([
|
||||||
Placeholder::make('consent_status_display')
|
Placeholder::make('consent_status_display')
|
||||||
->label('Consent')
|
->label('Consent')
|
||||||
@ -535,18 +518,31 @@ public static function form(Schema $schema): Schema
|
|||||||
Placeholder::make('verification_status_display')
|
Placeholder::make('verification_status_display')
|
||||||
->label('Verification')
|
->label('Verification')
|
||||||
->content(fn (?ProviderConnection $record): string => static::verificationStatusLabelFromState($record?->verification_status)),
|
->content(fn (?ProviderConnection $record): string => static::verificationStatusLabelFromState($record?->verification_status)),
|
||||||
TextInput::make('status')
|
Placeholder::make('last_health_check_at_display')
|
||||||
->label('Status')
|
->label('Last check')
|
||||||
->disabled()
|
->content(fn (?ProviderConnection $record): string => $record?->last_health_check_at?->diffForHumans() ?? 'Never'),
|
||||||
->dehydrated(false),
|
])
|
||||||
TextInput::make('health_status')
|
->columns(2)
|
||||||
->label('Health')
|
->columnSpanFull(),
|
||||||
->disabled()
|
Section::make('Diagnostics')
|
||||||
->dehydrated(false),
|
->schema([
|
||||||
|
Placeholder::make('status_display')
|
||||||
|
->label('Legacy status')
|
||||||
|
->content(fn (?ProviderConnection $record): string => BadgeRenderer::spec(BadgeDomain::ProviderConnectionStatus, $record?->status)->label),
|
||||||
|
Placeholder::make('health_status_display')
|
||||||
|
->label('Legacy health')
|
||||||
|
->content(fn (?ProviderConnection $record): string => BadgeRenderer::spec(BadgeDomain::ProviderConnectionHealth, $record?->health_status)->label),
|
||||||
Placeholder::make('migration_review_status_display')
|
Placeholder::make('migration_review_status_display')
|
||||||
->label('Migration review')
|
->label('Migration review')
|
||||||
->content(fn (?ProviderConnection $record): string => static::migrationReviewLabel($record))
|
->content(fn (?ProviderConnection $record): string => static::migrationReviewLabel($record))
|
||||||
->hint(fn (?ProviderConnection $record): ?string => static::migrationReviewDescription($record)),
|
->hint(fn (?ProviderConnection $record): ?string => static::migrationReviewDescription($record)),
|
||||||
|
Placeholder::make('last_error_reason_code_display')
|
||||||
|
->label('Last error reason')
|
||||||
|
->content(fn (?ProviderConnection $record): string => filled($record?->last_error_reason_code) ? (string) $record->last_error_reason_code : 'n/a'),
|
||||||
|
Placeholder::make('last_error_message_display')
|
||||||
|
->label('Last error message')
|
||||||
|
->content(fn (?ProviderConnection $record): string => static::sanitizeErrorMessage($record?->last_error_message) ?? 'n/a')
|
||||||
|
->columnSpanFull(),
|
||||||
])
|
])
|
||||||
->columns(2)
|
->columns(2)
|
||||||
->columnSpanFull(),
|
->columnSpanFull(),
|
||||||
@ -580,22 +576,55 @@ public static function infolist(Schema $schema): Schema
|
|||||||
->state(fn (ProviderConnection $record): string => static::credentialSourceLabel($record)),
|
->state(fn (ProviderConnection $record): string => static::credentialSourceLabel($record)),
|
||||||
])
|
])
|
||||||
->columns(2),
|
->columns(2),
|
||||||
Section::make('Status')
|
Section::make('Current state')
|
||||||
->schema([
|
->schema([
|
||||||
Infolists\Components\TextEntry::make('consent_status')
|
Infolists\Components\TextEntry::make('consent_status')
|
||||||
->label('Consent')
|
->label('Consent')
|
||||||
->formatStateUsing(fn ($state): string => static::consentStatusLabelFromState($state)),
|
->badge()
|
||||||
|
->formatStateUsing(fn ($state): string => static::consentStatusLabelFromState($state))
|
||||||
|
->color(BadgeRenderer::color(BadgeDomain::ProviderConsentStatus))
|
||||||
|
->icon(BadgeRenderer::icon(BadgeDomain::ProviderConsentStatus))
|
||||||
|
->iconColor(BadgeRenderer::iconColor(BadgeDomain::ProviderConsentStatus)),
|
||||||
Infolists\Components\TextEntry::make('verification_status')
|
Infolists\Components\TextEntry::make('verification_status')
|
||||||
->label('Verification')
|
->label('Verification')
|
||||||
->formatStateUsing(fn ($state): string => static::verificationStatusLabelFromState($state)),
|
->badge()
|
||||||
|
->formatStateUsing(fn ($state): string => static::verificationStatusLabelFromState($state))
|
||||||
|
->color(BadgeRenderer::color(BadgeDomain::ProviderVerificationStatus))
|
||||||
|
->icon(BadgeRenderer::icon(BadgeDomain::ProviderVerificationStatus))
|
||||||
|
->iconColor(BadgeRenderer::iconColor(BadgeDomain::ProviderVerificationStatus)),
|
||||||
|
Infolists\Components\TextEntry::make('last_health_check_at')
|
||||||
|
->label('Last check')
|
||||||
|
->since(),
|
||||||
|
])
|
||||||
|
->columns(2),
|
||||||
|
Section::make('Diagnostics')
|
||||||
|
->schema([
|
||||||
Infolists\Components\TextEntry::make('status')
|
Infolists\Components\TextEntry::make('status')
|
||||||
->label('Status'),
|
->label('Legacy status')
|
||||||
|
->badge()
|
||||||
|
->formatStateUsing(BadgeRenderer::label(BadgeDomain::ProviderConnectionStatus))
|
||||||
|
->color(BadgeRenderer::color(BadgeDomain::ProviderConnectionStatus))
|
||||||
|
->icon(BadgeRenderer::icon(BadgeDomain::ProviderConnectionStatus))
|
||||||
|
->iconColor(BadgeRenderer::iconColor(BadgeDomain::ProviderConnectionStatus)),
|
||||||
Infolists\Components\TextEntry::make('health_status')
|
Infolists\Components\TextEntry::make('health_status')
|
||||||
->label('Health'),
|
->label('Legacy health')
|
||||||
|
->badge()
|
||||||
|
->formatStateUsing(BadgeRenderer::label(BadgeDomain::ProviderConnectionHealth))
|
||||||
|
->color(BadgeRenderer::color(BadgeDomain::ProviderConnectionHealth))
|
||||||
|
->icon(BadgeRenderer::icon(BadgeDomain::ProviderConnectionHealth))
|
||||||
|
->iconColor(BadgeRenderer::iconColor(BadgeDomain::ProviderConnectionHealth)),
|
||||||
Infolists\Components\TextEntry::make('migration_review_required')
|
Infolists\Components\TextEntry::make('migration_review_required')
|
||||||
->label('Migration review')
|
->label('Migration review')
|
||||||
->formatStateUsing(fn (ProviderConnection $record): string => static::migrationReviewLabel($record))
|
->formatStateUsing(fn (ProviderConnection $record): string => static::migrationReviewLabel($record))
|
||||||
->tooltip(fn (ProviderConnection $record): ?string => static::migrationReviewDescription($record)),
|
->tooltip(fn (ProviderConnection $record): ?string => static::migrationReviewDescription($record)),
|
||||||
|
Infolists\Components\TextEntry::make('last_error_reason_code')
|
||||||
|
->label('Last error reason')
|
||||||
|
->placeholder('n/a'),
|
||||||
|
Infolists\Components\TextEntry::make('last_error_message')
|
||||||
|
->label('Last error message')
|
||||||
|
->formatStateUsing(fn (?string $state): ?string => static::sanitizeErrorMessage($state))
|
||||||
|
->placeholder('n/a')
|
||||||
|
->columnSpanFull(),
|
||||||
])
|
])
|
||||||
->columns(2),
|
->columns(2),
|
||||||
]);
|
]);
|
||||||
@ -655,20 +684,36 @@ public static function table(Table $table): Table
|
|||||||
? 'Dedicated'
|
? 'Dedicated'
|
||||||
: 'Platform')
|
: 'Platform')
|
||||||
->color(fn (?ProviderConnectionType $state): string => $state === ProviderConnectionType::Dedicated ? 'info' : 'gray'),
|
->color(fn (?ProviderConnectionType $state): string => $state === ProviderConnectionType::Dedicated ? 'info' : 'gray'),
|
||||||
|
Tables\Columns\TextColumn::make('consent_status')
|
||||||
|
->label('Consent')
|
||||||
|
->badge()
|
||||||
|
->formatStateUsing(BadgeRenderer::label(BadgeDomain::ProviderConsentStatus))
|
||||||
|
->color(BadgeRenderer::color(BadgeDomain::ProviderConsentStatus))
|
||||||
|
->icon(BadgeRenderer::icon(BadgeDomain::ProviderConsentStatus))
|
||||||
|
->iconColor(BadgeRenderer::iconColor(BadgeDomain::ProviderConsentStatus)),
|
||||||
|
Tables\Columns\TextColumn::make('verification_status')
|
||||||
|
->label('Verification')
|
||||||
|
->badge()
|
||||||
|
->formatStateUsing(BadgeRenderer::label(BadgeDomain::ProviderVerificationStatus))
|
||||||
|
->color(BadgeRenderer::color(BadgeDomain::ProviderVerificationStatus))
|
||||||
|
->icon(BadgeRenderer::icon(BadgeDomain::ProviderVerificationStatus))
|
||||||
|
->iconColor(BadgeRenderer::iconColor(BadgeDomain::ProviderVerificationStatus)),
|
||||||
Tables\Columns\TextColumn::make('status')
|
Tables\Columns\TextColumn::make('status')
|
||||||
->label('Status')
|
->label('Legacy status')
|
||||||
->badge()
|
->badge()
|
||||||
->formatStateUsing(BadgeRenderer::label(BadgeDomain::ProviderConnectionStatus))
|
->formatStateUsing(BadgeRenderer::label(BadgeDomain::ProviderConnectionStatus))
|
||||||
->color(BadgeRenderer::color(BadgeDomain::ProviderConnectionStatus))
|
->color(BadgeRenderer::color(BadgeDomain::ProviderConnectionStatus))
|
||||||
->icon(BadgeRenderer::icon(BadgeDomain::ProviderConnectionStatus))
|
->icon(BadgeRenderer::icon(BadgeDomain::ProviderConnectionStatus))
|
||||||
->iconColor(BadgeRenderer::iconColor(BadgeDomain::ProviderConnectionStatus)),
|
->iconColor(BadgeRenderer::iconColor(BadgeDomain::ProviderConnectionStatus))
|
||||||
|
->toggleable(isToggledHiddenByDefault: true),
|
||||||
Tables\Columns\TextColumn::make('health_status')
|
Tables\Columns\TextColumn::make('health_status')
|
||||||
->label('Health')
|
->label('Legacy health')
|
||||||
->badge()
|
->badge()
|
||||||
->formatStateUsing(BadgeRenderer::label(BadgeDomain::ProviderConnectionHealth))
|
->formatStateUsing(BadgeRenderer::label(BadgeDomain::ProviderConnectionHealth))
|
||||||
->color(BadgeRenderer::color(BadgeDomain::ProviderConnectionHealth))
|
->color(BadgeRenderer::color(BadgeDomain::ProviderConnectionHealth))
|
||||||
->icon(BadgeRenderer::icon(BadgeDomain::ProviderConnectionHealth))
|
->icon(BadgeRenderer::icon(BadgeDomain::ProviderConnectionHealth))
|
||||||
->iconColor(BadgeRenderer::iconColor(BadgeDomain::ProviderConnectionHealth)),
|
->iconColor(BadgeRenderer::iconColor(BadgeDomain::ProviderConnectionHealth))
|
||||||
|
->toggleable(isToggledHiddenByDefault: true),
|
||||||
Tables\Columns\TextColumn::make('migration_review_required')
|
Tables\Columns\TextColumn::make('migration_review_required')
|
||||||
->label('Migration review')
|
->label('Migration review')
|
||||||
->badge()
|
->badge()
|
||||||
@ -714,8 +759,45 @@ public static function table(Table $table): Table
|
|||||||
|
|
||||||
return $query->where('provider_connections.provider', $value);
|
return $query->where('provider_connections.provider', $value);
|
||||||
}),
|
}),
|
||||||
|
SelectFilter::make('consent_status')
|
||||||
|
->label('Consent')
|
||||||
|
->options([
|
||||||
|
'unknown' => 'Unknown',
|
||||||
|
'required' => 'Required',
|
||||||
|
'granted' => 'Granted',
|
||||||
|
'failed' => 'Failed',
|
||||||
|
'revoked' => 'Revoked',
|
||||||
|
])
|
||||||
|
->query(function (Builder $query, array $data): Builder {
|
||||||
|
$value = $data['value'] ?? null;
|
||||||
|
|
||||||
|
if (! is_string($value) || $value === '') {
|
||||||
|
return $query;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $query->where('provider_connections.consent_status', $value);
|
||||||
|
}),
|
||||||
|
SelectFilter::make('verification_status')
|
||||||
|
->label('Verification')
|
||||||
|
->options([
|
||||||
|
'unknown' => 'Unknown',
|
||||||
|
'pending' => 'Pending',
|
||||||
|
'healthy' => 'Healthy',
|
||||||
|
'degraded' => 'Degraded',
|
||||||
|
'blocked' => 'Blocked',
|
||||||
|
'error' => 'Error',
|
||||||
|
])
|
||||||
|
->query(function (Builder $query, array $data): Builder {
|
||||||
|
$value = $data['value'] ?? null;
|
||||||
|
|
||||||
|
if (! is_string($value) || $value === '') {
|
||||||
|
return $query;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $query->where('provider_connections.verification_status', $value);
|
||||||
|
}),
|
||||||
SelectFilter::make('status')
|
SelectFilter::make('status')
|
||||||
->label('Status')
|
->label('Diagnostic status')
|
||||||
->options([
|
->options([
|
||||||
'connected' => 'Connected',
|
'connected' => 'Connected',
|
||||||
'needs_consent' => 'Needs consent',
|
'needs_consent' => 'Needs consent',
|
||||||
@ -732,7 +814,7 @@ public static function table(Table $table): Table
|
|||||||
return $query->where('provider_connections.status', $value);
|
return $query->where('provider_connections.status', $value);
|
||||||
}),
|
}),
|
||||||
SelectFilter::make('health_status')
|
SelectFilter::make('health_status')
|
||||||
->label('Health')
|
->label('Diagnostic health')
|
||||||
->options([
|
->options([
|
||||||
'ok' => 'OK',
|
'ok' => 'OK',
|
||||||
'degraded' => 'Degraded',
|
'degraded' => 'Degraded',
|
||||||
|
|||||||
@ -284,13 +284,6 @@ public static function table(Table $table): Table
|
|||||||
->iconColor(BadgeRenderer::iconColor(BadgeDomain::TenantStatus))
|
->iconColor(BadgeRenderer::iconColor(BadgeDomain::TenantStatus))
|
||||||
->description(fn (Tenant $record): string => static::tenantLifecyclePresentation($record)->shortDescription)
|
->description(fn (Tenant $record): string => static::tenantLifecyclePresentation($record)->shortDescription)
|
||||||
->sortable(),
|
->sortable(),
|
||||||
Tables\Columns\TextColumn::make('app_status')
|
|
||||||
->badge()
|
|
||||||
->formatStateUsing(BadgeRenderer::label(BadgeDomain::TenantAppStatus))
|
|
||||||
->color(BadgeRenderer::color(BadgeDomain::TenantAppStatus))
|
|
||||||
->icon(BadgeRenderer::icon(BadgeDomain::TenantAppStatus))
|
|
||||||
->iconColor(BadgeRenderer::iconColor(BadgeDomain::TenantAppStatus))
|
|
||||||
->toggleable(isToggledHiddenByDefault: true),
|
|
||||||
Tables\Columns\TextColumn::make('created_at')
|
Tables\Columns\TextColumn::make('created_at')
|
||||||
->dateTime()
|
->dateTime()
|
||||||
->since()
|
->since()
|
||||||
@ -310,13 +303,6 @@ public static function table(Table $table): Table
|
|||||||
'staging' => 'STAGING',
|
'staging' => 'STAGING',
|
||||||
'other' => 'Other',
|
'other' => 'Other',
|
||||||
]),
|
]),
|
||||||
Tables\Filters\SelectFilter::make('app_status')
|
|
||||||
->options([
|
|
||||||
'ok' => 'OK',
|
|
||||||
'consent_required' => 'Consent required',
|
|
||||||
'error' => 'Error',
|
|
||||||
'unknown' => 'Unknown',
|
|
||||||
]),
|
|
||||||
])
|
])
|
||||||
->actions([
|
->actions([
|
||||||
Actions\Action::make('related_onboarding')
|
Actions\Action::make('related_onboarding')
|
||||||
@ -842,12 +828,6 @@ public static function infolist(Schema $schema): Schema
|
|||||||
->label('Lifecycle summary')
|
->label('Lifecycle summary')
|
||||||
->state(fn (Tenant $record): string => static::tenantLifecyclePresentation($record)->longDescription)
|
->state(fn (Tenant $record): string => static::tenantLifecyclePresentation($record)->longDescription)
|
||||||
->columnSpanFull(),
|
->columnSpanFull(),
|
||||||
Infolists\Components\TextEntry::make('app_status')
|
|
||||||
->badge()
|
|
||||||
->formatStateUsing(BadgeRenderer::label(BadgeDomain::TenantAppStatus))
|
|
||||||
->color(BadgeRenderer::color(BadgeDomain::TenantAppStatus))
|
|
||||||
->icon(BadgeRenderer::icon(BadgeDomain::TenantAppStatus))
|
|
||||||
->iconColor(BadgeRenderer::iconColor(BadgeDomain::TenantAppStatus)),
|
|
||||||
])
|
])
|
||||||
->columns(2)
|
->columns(2)
|
||||||
->columnSpanFull(),
|
->columnSpanFull(),
|
||||||
@ -1492,19 +1472,30 @@ private static function providerConnectionState(Tenant $tenant): array
|
|||||||
{
|
{
|
||||||
$ctaUrl = ProviderConnectionResource::getUrl('index', ['tenant_id' => (string) $tenant->external_id], panel: 'admin');
|
$ctaUrl = ProviderConnectionResource::getUrl('index', ['tenant_id' => (string) $tenant->external_id], panel: 'admin');
|
||||||
|
|
||||||
$connection = ProviderConnection::query()
|
$defaultConnection = ProviderConnection::query()
|
||||||
|
->where('tenant_id', (int) $tenant->getKey())
|
||||||
|
->where('provider', 'microsoft')
|
||||||
|
->where('is_default', true)
|
||||||
|
->orderBy('id')
|
||||||
|
->first();
|
||||||
|
|
||||||
|
$connection = $defaultConnection instanceof ProviderConnection
|
||||||
|
? $defaultConnection
|
||||||
|
: ProviderConnection::query()
|
||||||
->where('tenant_id', (int) $tenant->getKey())
|
->where('tenant_id', (int) $tenant->getKey())
|
||||||
->where('provider', 'microsoft')
|
->where('provider', 'microsoft')
|
||||||
->orderByDesc('is_default')
|
|
||||||
->orderBy('id')
|
->orderBy('id')
|
||||||
->first();
|
->first();
|
||||||
|
|
||||||
if (! $connection instanceof ProviderConnection) {
|
if (! $connection instanceof ProviderConnection) {
|
||||||
return [
|
return [
|
||||||
'state' => 'needs_action',
|
'state' => 'missing',
|
||||||
'cta_url' => $ctaUrl,
|
'cta_url' => $ctaUrl,
|
||||||
|
'needs_default_connection' => false,
|
||||||
'display_name' => null,
|
'display_name' => null,
|
||||||
'provider' => null,
|
'provider' => null,
|
||||||
|
'consent_status' => null,
|
||||||
|
'verification_status' => null,
|
||||||
'status' => null,
|
'status' => null,
|
||||||
'health_status' => null,
|
'health_status' => null,
|
||||||
'last_health_check_at' => null,
|
'last_health_check_at' => null,
|
||||||
@ -1515,8 +1506,15 @@ private static function providerConnectionState(Tenant $tenant): array
|
|||||||
return [
|
return [
|
||||||
'state' => $connection->is_default ? 'default_configured' : 'configured',
|
'state' => $connection->is_default ? 'default_configured' : 'configured',
|
||||||
'cta_url' => $ctaUrl,
|
'cta_url' => $ctaUrl,
|
||||||
|
'needs_default_connection' => ! $connection->is_default,
|
||||||
'display_name' => (string) $connection->display_name,
|
'display_name' => (string) $connection->display_name,
|
||||||
'provider' => (string) $connection->provider,
|
'provider' => (string) $connection->provider,
|
||||||
|
'consent_status' => $connection->consent_status instanceof BackedEnum
|
||||||
|
? (string) $connection->consent_status->value
|
||||||
|
: (is_string($connection->consent_status) ? $connection->consent_status : null),
|
||||||
|
'verification_status' => $connection->verification_status instanceof BackedEnum
|
||||||
|
? (string) $connection->verification_status->value
|
||||||
|
: (is_string($connection->verification_status) ? $connection->verification_status : null),
|
||||||
'status' => is_string($connection->status) ? $connection->status : null,
|
'status' => is_string($connection->status) ? $connection->status : null,
|
||||||
'health_status' => is_string($connection->health_status) ? $connection->health_status : null,
|
'health_status' => is_string($connection->health_status) ? $connection->health_status : null,
|
||||||
'last_health_check_at' => optional($connection->last_health_check_at)->toDateTimeString(),
|
'last_health_check_at' => optional($connection->last_health_check_at)->toDateTimeString(),
|
||||||
|
|||||||
@ -6,10 +6,16 @@
|
|||||||
|
|
||||||
use App\Filament\Pages\BaselineCompareLanding;
|
use App\Filament\Pages\BaselineCompareLanding;
|
||||||
use App\Filament\Resources\FindingResource;
|
use App\Filament\Resources\FindingResource;
|
||||||
|
use App\Models\FindingException;
|
||||||
|
use App\Models\OperationRun;
|
||||||
use App\Models\Tenant;
|
use App\Models\Tenant;
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Support\Auth\Capabilities;
|
||||||
|
use App\Support\Baselines\BaselineCompareSummaryAssessment;
|
||||||
use App\Support\Baselines\TenantGovernanceAggregate;
|
use App\Support\Baselines\TenantGovernanceAggregate;
|
||||||
use App\Support\Baselines\TenantGovernanceAggregateResolver;
|
use App\Support\Baselines\TenantGovernanceAggregateResolver;
|
||||||
use App\Support\OperationRunLinks;
|
use App\Support\OperationRunLinks;
|
||||||
|
use App\Support\Rbac\UiTooltips;
|
||||||
use Filament\Facades\Filament;
|
use Filament\Facades\Filament;
|
||||||
use Filament\Widgets\Widget;
|
use Filament\Widgets\Widget;
|
||||||
|
|
||||||
@ -31,7 +37,9 @@ protected function getViewData(): array
|
|||||||
'landingUrl' => null,
|
'landingUrl' => null,
|
||||||
'runUrl' => null,
|
'runUrl' => null,
|
||||||
'findingsUrl' => null,
|
'findingsUrl' => null,
|
||||||
|
'nextActionLabel' => null,
|
||||||
'nextActionUrl' => null,
|
'nextActionUrl' => null,
|
||||||
|
'nextActionHelperText' => null,
|
||||||
'summaryAssessment' => null,
|
'summaryAssessment' => null,
|
||||||
];
|
];
|
||||||
|
|
||||||
@ -46,16 +54,25 @@ protected function getViewData(): array
|
|||||||
}
|
}
|
||||||
|
|
||||||
$tenantLandingUrl = BaselineCompareLanding::getUrl(panel: 'tenant', tenant: $tenant);
|
$tenantLandingUrl = BaselineCompareLanding::getUrl(panel: 'tenant', tenant: $tenant);
|
||||||
$runUrl = $aggregate->stats->operationRunId !== null
|
$operationsFollowUpCount = (int) OperationRun::query()
|
||||||
? OperationRunLinks::view($aggregate->stats->operationRunId, $tenant)
|
->where('tenant_id', (int) $tenant->getKey())
|
||||||
: null;
|
->dashboardNeedsFollowUp()
|
||||||
$findingsUrl = FindingResource::getUrl('index', panel: 'tenant', tenant: $tenant);
|
->count();
|
||||||
$nextActionUrl = match ($aggregate->nextActionTarget) {
|
$summaryAssessment = $this->dashboardSummaryAssessment($aggregate, $operationsFollowUpCount);
|
||||||
|
$runUrl = $this->runUrl($tenant, $aggregate);
|
||||||
|
$findingsUrl = $this->findingsUrl($tenant, $aggregate);
|
||||||
|
$nextActionTarget = (string) ($summaryAssessment['dashboardNextActionTarget'] ?? (($summaryAssessment['nextAction']['target'] ?? 'none') ?: 'none'));
|
||||||
|
$nextActionLabel = (string) ($summaryAssessment['nextAction']['label'] ?? '');
|
||||||
|
$nextActionUrl = match ($nextActionTarget) {
|
||||||
'run' => $runUrl,
|
'run' => $runUrl,
|
||||||
'findings' => $findingsUrl,
|
'findings' => $findingsUrl,
|
||||||
'landing' => $tenantLandingUrl,
|
'landing' => $tenantLandingUrl,
|
||||||
|
'operations' => OperationRunLinks::index($tenant, activeTab: 'blocked'),
|
||||||
default => null,
|
default => null,
|
||||||
};
|
};
|
||||||
|
$nextActionHelperText = in_array($nextActionTarget, ['run', 'findings'], true) && $nextActionUrl === null
|
||||||
|
? UiTooltips::INSUFFICIENT_PERMISSION
|
||||||
|
: null;
|
||||||
|
|
||||||
return [
|
return [
|
||||||
'hasAssignment' => true,
|
'hasAssignment' => true,
|
||||||
@ -64,11 +81,123 @@ protected function getViewData(): array
|
|||||||
'landingUrl' => $tenantLandingUrl,
|
'landingUrl' => $tenantLandingUrl,
|
||||||
'runUrl' => $runUrl,
|
'runUrl' => $runUrl,
|
||||||
'findingsUrl' => $findingsUrl,
|
'findingsUrl' => $findingsUrl,
|
||||||
|
'nextActionLabel' => $nextActionLabel !== '' ? $nextActionLabel : null,
|
||||||
'nextActionUrl' => $nextActionUrl,
|
'nextActionUrl' => $nextActionUrl,
|
||||||
'summaryAssessment' => $aggregate->summaryAssessment->toArray(),
|
'nextActionHelperText' => $nextActionHelperText,
|
||||||
|
'summaryAssessment' => $summaryAssessment,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
private function dashboardSummaryAssessment(TenantGovernanceAggregate $aggregate, int $operationsFollowUpCount): array
|
||||||
|
{
|
||||||
|
$summaryAssessment = $aggregate->summaryAssessment->toArray();
|
||||||
|
|
||||||
|
if (($summaryAssessment['stateFamily'] ?? null) !== BaselineCompareSummaryAssessment::STATE_POSITIVE) {
|
||||||
|
return $summaryAssessment;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($aggregate->highSeverityActiveFindingsCount > 0) {
|
||||||
|
$count = $aggregate->highSeverityActiveFindingsCount;
|
||||||
|
|
||||||
|
return array_merge($summaryAssessment, [
|
||||||
|
'stateFamily' => BaselineCompareSummaryAssessment::STATE_ACTION_REQUIRED,
|
||||||
|
'tone' => 'danger',
|
||||||
|
'headline' => sprintf('%d high-severity active finding%s need review.', $count, $count === 1 ? '' : 's'),
|
||||||
|
'supportingMessage' => 'The latest compare may be healthy, but the tenant still has active high-severity findings.',
|
||||||
|
'highSeverityCount' => $count,
|
||||||
|
'nextAction' => [
|
||||||
|
'label' => 'Open findings',
|
||||||
|
'target' => BaselineCompareSummaryAssessment::NEXT_TARGET_FINDINGS,
|
||||||
|
],
|
||||||
|
'dashboardNextActionTarget' => 'findings',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($operationsFollowUpCount > 0) {
|
||||||
|
return array_merge($summaryAssessment, [
|
||||||
|
'stateFamily' => BaselineCompareSummaryAssessment::STATE_ACTION_REQUIRED,
|
||||||
|
'tone' => 'danger',
|
||||||
|
'headline' => sprintf('%d operation%s need follow-up.', $operationsFollowUpCount, $operationsFollowUpCount === 1 ? '' : 's'),
|
||||||
|
'supportingMessage' => 'Failed, warning, or stalled runs still need review before this tenant reads as fully calm.',
|
||||||
|
'nextAction' => [
|
||||||
|
'label' => 'Open operations',
|
||||||
|
'target' => BaselineCompareSummaryAssessment::NEXT_TARGET_NONE,
|
||||||
|
],
|
||||||
|
'dashboardNextActionTarget' => 'operations',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $summaryAssessment;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function runUrl(Tenant $tenant, TenantGovernanceAggregate $aggregate): ?string
|
||||||
|
{
|
||||||
|
$runId = $aggregate->stats->operationRunId;
|
||||||
|
|
||||||
|
if (! is_int($runId)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$run = OperationRun::query()->find($runId);
|
||||||
|
|
||||||
|
if (! $run instanceof OperationRun || ! $this->canOpenRun($run)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return OperationRunLinks::view($run, $tenant);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function findingsUrl(Tenant $tenant, TenantGovernanceAggregate $aggregate): ?string
|
||||||
|
{
|
||||||
|
if (! $this->canOpenFindings($tenant)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$parameters = match (true) {
|
||||||
|
$aggregate->lapsedGovernanceCount > 0 => [
|
||||||
|
'tab' => 'risk_accepted',
|
||||||
|
'governance_validity' => FindingException::VALIDITY_MISSING_SUPPORT,
|
||||||
|
],
|
||||||
|
$aggregate->overdueOpenFindingsCount > 0 => [
|
||||||
|
'tab' => 'overdue',
|
||||||
|
],
|
||||||
|
$aggregate->expiringGovernanceCount > 0 => [
|
||||||
|
'tab' => 'risk_accepted',
|
||||||
|
'governance_validity' => FindingException::VALIDITY_EXPIRING,
|
||||||
|
],
|
||||||
|
$aggregate->highSeverityActiveFindingsCount > 0 => [
|
||||||
|
'tab' => 'needs_action',
|
||||||
|
'high_severity' => 1,
|
||||||
|
],
|
||||||
|
$aggregate->visibleDriftFindingsCount > 0 => [
|
||||||
|
'tab' => 'needs_action',
|
||||||
|
'finding_type' => 'drift',
|
||||||
|
],
|
||||||
|
default => [],
|
||||||
|
};
|
||||||
|
|
||||||
|
return FindingResource::getUrl('index', $parameters, panel: 'tenant', tenant: $tenant);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function canOpenFindings(Tenant $tenant): bool
|
||||||
|
{
|
||||||
|
$user = auth()->user();
|
||||||
|
|
||||||
|
return $user instanceof User
|
||||||
|
&& $user->canAccessTenant($tenant)
|
||||||
|
&& $user->can(Capabilities::TENANT_FINDINGS_VIEW, $tenant);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function canOpenRun(OperationRun $run): bool
|
||||||
|
{
|
||||||
|
$user = auth()->user();
|
||||||
|
|
||||||
|
return $user instanceof User && $user->can('view', $run);
|
||||||
|
}
|
||||||
|
|
||||||
private function governanceAggregate(Tenant $tenant): TenantGovernanceAggregate
|
private function governanceAggregate(Tenant $tenant): TenantGovernanceAggregate
|
||||||
{
|
{
|
||||||
/** @var TenantGovernanceAggregateResolver $resolver */
|
/** @var TenantGovernanceAggregateResolver $resolver */
|
||||||
|
|||||||
@ -8,7 +8,11 @@
|
|||||||
use App\Models\Finding;
|
use App\Models\Finding;
|
||||||
use App\Models\OperationRun;
|
use App\Models\OperationRun;
|
||||||
use App\Models\Tenant;
|
use App\Models\Tenant;
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Support\Auth\Capabilities;
|
||||||
|
use App\Support\OperationRunLinks;
|
||||||
use App\Support\OpsUx\ActiveRuns;
|
use App\Support\OpsUx\ActiveRuns;
|
||||||
|
use App\Support\Rbac\UiTooltips;
|
||||||
use Filament\Facades\Filament;
|
use Filament\Facades\Filament;
|
||||||
use Filament\Widgets\StatsOverviewWidget;
|
use Filament\Widgets\StatsOverviewWidget;
|
||||||
use Filament\Widgets\StatsOverviewWidget\Stat;
|
use Filament\Widgets\StatsOverviewWidget\Stat;
|
||||||
@ -36,56 +40,107 @@ protected function getStats(): array
|
|||||||
$tenant = Filament::getTenant();
|
$tenant = Filament::getTenant();
|
||||||
|
|
||||||
if (! $tenant instanceof Tenant) {
|
if (! $tenant instanceof Tenant) {
|
||||||
return [
|
return $this->emptyStats();
|
||||||
Stat::make('Open drift findings', 0),
|
|
||||||
Stat::make('High severity drift', 0),
|
|
||||||
Stat::make('Active operations', 0),
|
|
||||||
Stat::make('Inventory active', 0),
|
|
||||||
];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
$tenantId = (int) $tenant->getKey();
|
$tenantId = (int) $tenant->getKey();
|
||||||
|
|
||||||
$openDriftFindings = (int) Finding::query()
|
$openDriftFindings = (int) Finding::query()
|
||||||
->where('tenant_id', $tenantId)
|
->where('tenant_id', $tenantId)
|
||||||
->where('finding_type', Finding::FINDING_TYPE_DRIFT)
|
->openDrift()
|
||||||
->where('status', Finding::STATUS_NEW)
|
|
||||||
->count();
|
->count();
|
||||||
|
|
||||||
$highSeverityDriftFindings = (int) Finding::query()
|
$highSeverityActiveFindings = (int) Finding::query()
|
||||||
->where('tenant_id', $tenantId)
|
->where('tenant_id', $tenantId)
|
||||||
->where('finding_type', Finding::FINDING_TYPE_DRIFT)
|
->highSeverityActive()
|
||||||
->where('status', Finding::STATUS_NEW)
|
|
||||||
->where('severity', Finding::SEVERITY_HIGH)
|
|
||||||
->count();
|
->count();
|
||||||
|
|
||||||
$activeRuns = (int) OperationRun::query()
|
$activeRuns = (int) OperationRun::query()
|
||||||
->where('tenant_id', $tenantId)
|
->where('tenant_id', $tenantId)
|
||||||
->active()
|
->healthyActive()
|
||||||
->count();
|
->count();
|
||||||
|
|
||||||
$inventoryActiveRuns = (int) OperationRun::query()
|
$followUpRuns = (int) OperationRun::query()
|
||||||
->where('tenant_id', $tenantId)
|
->where('tenant_id', $tenantId)
|
||||||
->where('type', 'inventory_sync')
|
->dashboardNeedsFollowUp()
|
||||||
->active()
|
|
||||||
->count();
|
->count();
|
||||||
|
|
||||||
|
$openDriftUrl = $openDriftFindings > 0
|
||||||
|
? $this->findingsUrl($tenant, [
|
||||||
|
'tab' => 'needs_action',
|
||||||
|
'finding_type' => Finding::FINDING_TYPE_DRIFT,
|
||||||
|
])
|
||||||
|
: null;
|
||||||
|
$highSeverityUrl = $highSeverityActiveFindings > 0
|
||||||
|
? $this->findingsUrl($tenant, [
|
||||||
|
'tab' => 'needs_action',
|
||||||
|
'high_severity' => 1,
|
||||||
|
])
|
||||||
|
: null;
|
||||||
|
$findingsHelperText = $this->findingsHelperText($tenant);
|
||||||
|
|
||||||
return [
|
return [
|
||||||
Stat::make('Open drift findings', $openDriftFindings)
|
Stat::make('Open drift findings', $openDriftFindings)
|
||||||
->description('across all policy types')
|
->description($openDriftUrl === null && $openDriftFindings > 0
|
||||||
->url(FindingResource::getUrl('index', tenant: $tenant)),
|
? $findingsHelperText
|
||||||
Stat::make('High severity drift', $highSeverityDriftFindings)
|
: 'active drift workflow items')
|
||||||
->description('requiring immediate review')
|
->color($openDriftFindings > 0 ? 'warning' : 'gray')
|
||||||
->color($highSeverityDriftFindings > 0 ? 'danger' : 'gray')
|
->url($openDriftUrl),
|
||||||
->url(FindingResource::getUrl('index', tenant: $tenant)),
|
Stat::make('High severity active findings', $highSeverityActiveFindings)
|
||||||
|
->description($highSeverityUrl === null && $highSeverityActiveFindings > 0
|
||||||
|
? $findingsHelperText
|
||||||
|
: 'high or critical findings needing review')
|
||||||
|
->color($highSeverityActiveFindings > 0 ? 'danger' : 'gray')
|
||||||
|
->url($highSeverityUrl),
|
||||||
Stat::make('Active operations', $activeRuns)
|
Stat::make('Active operations', $activeRuns)
|
||||||
->description('backup, sync & compare operations')
|
->description('healthy queued or running tenant work')
|
||||||
->color($activeRuns > 0 ? 'warning' : 'gray')
|
->color($activeRuns > 0 ? 'info' : 'gray')
|
||||||
->url(route('admin.operations.index')),
|
->url($activeRuns > 0 ? OperationRunLinks::index($tenant, activeTab: 'active') : null),
|
||||||
Stat::make('Inventory syncs running', $inventoryActiveRuns)
|
Stat::make('Operations needing follow-up', $followUpRuns)
|
||||||
->description('active inventory sync jobs')
|
->description('failed, warning, or stalled runs')
|
||||||
->color($inventoryActiveRuns > 0 ? 'warning' : 'gray')
|
->color($followUpRuns > 0 ? 'danger' : 'gray')
|
||||||
->url(route('admin.operations.index')),
|
->url($followUpRuns > 0 ? OperationRunLinks::index($tenant, activeTab: 'blocked') : null),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<Stat>
|
||||||
|
*/
|
||||||
|
private function emptyStats(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
Stat::make('Open drift findings', 0),
|
||||||
|
Stat::make('High severity active findings', 0),
|
||||||
|
Stat::make('Active operations', 0),
|
||||||
|
Stat::make('Operations needing follow-up', 0),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $parameters
|
||||||
|
*/
|
||||||
|
private function findingsUrl(Tenant $tenant, array $parameters): ?string
|
||||||
|
{
|
||||||
|
if (! $this->canOpenFindings($tenant)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return FindingResource::getUrl('index', $parameters, panel: 'tenant', tenant: $tenant);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function findingsHelperText(Tenant $tenant): string
|
||||||
|
{
|
||||||
|
return $this->canOpenFindings($tenant)
|
||||||
|
? 'Open findings'
|
||||||
|
: UiTooltips::INSUFFICIENT_PERMISSION;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function canOpenFindings(Tenant $tenant): bool
|
||||||
|
{
|
||||||
|
$user = auth()->user();
|
||||||
|
|
||||||
|
return $user instanceof User
|
||||||
|
&& $user->canAccessTenant($tenant)
|
||||||
|
&& $user->can(Capabilities::TENANT_FINDINGS_VIEW, $tenant);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -4,10 +4,18 @@
|
|||||||
|
|
||||||
namespace App\Filament\Widgets\Dashboard;
|
namespace App\Filament\Widgets\Dashboard;
|
||||||
|
|
||||||
|
use App\Filament\Pages\BaselineCompareLanding;
|
||||||
|
use App\Filament\Resources\FindingResource;
|
||||||
|
use App\Models\FindingException;
|
||||||
|
use App\Models\OperationRun;
|
||||||
use App\Models\Tenant;
|
use App\Models\Tenant;
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Support\Auth\Capabilities;
|
||||||
use App\Support\Baselines\TenantGovernanceAggregate;
|
use App\Support\Baselines\TenantGovernanceAggregate;
|
||||||
use App\Support\Baselines\TenantGovernanceAggregateResolver;
|
use App\Support\Baselines\TenantGovernanceAggregateResolver;
|
||||||
|
use App\Support\OperationRunLinks;
|
||||||
use App\Support\OpsUx\ActiveRuns;
|
use App\Support\OpsUx\ActiveRuns;
|
||||||
|
use App\Support\Rbac\UiTooltips;
|
||||||
use Filament\Facades\Filament;
|
use Filament\Facades\Filament;
|
||||||
use Filament\Widgets\Widget;
|
use Filament\Widgets\Widget;
|
||||||
|
|
||||||
@ -40,69 +48,109 @@ protected function getViewData(): array
|
|||||||
$lapsedGovernanceCount = $aggregate->lapsedGovernanceCount;
|
$lapsedGovernanceCount = $aggregate->lapsedGovernanceCount;
|
||||||
$expiringGovernanceCount = $aggregate->expiringGovernanceCount;
|
$expiringGovernanceCount = $aggregate->expiringGovernanceCount;
|
||||||
$highSeverityCount = $aggregate->highSeverityActiveFindingsCount;
|
$highSeverityCount = $aggregate->highSeverityActiveFindingsCount;
|
||||||
|
$operationsFollowUpCount = (int) OperationRun::query()
|
||||||
|
->where('tenant_id', $tenantId)
|
||||||
|
->dashboardNeedsFollowUp()
|
||||||
|
->count();
|
||||||
|
$activeRuns = (int) OperationRun::query()
|
||||||
|
->where('tenant_id', $tenantId)
|
||||||
|
->healthyActive()
|
||||||
|
->count();
|
||||||
|
|
||||||
if ($lapsedGovernanceCount > 0) {
|
if ($lapsedGovernanceCount > 0) {
|
||||||
$items[] = [
|
$items[] = [
|
||||||
|
'key' => 'lapsed_governance',
|
||||||
'title' => 'Lapsed accepted-risk governance',
|
'title' => 'Lapsed accepted-risk governance',
|
||||||
'body' => "{$lapsedGovernanceCount} finding(s) need governance follow-up before accepted risk is safe to rely on.",
|
'body' => "{$lapsedGovernanceCount} accepted-risk finding(s) no longer have valid supporting governance.",
|
||||||
'badge' => 'Governance',
|
'badge' => 'Governance',
|
||||||
'badgeColor' => 'danger',
|
'badgeColor' => 'danger',
|
||||||
|
...$this->findingsAction(
|
||||||
|
$tenant,
|
||||||
|
'Open findings',
|
||||||
|
[
|
||||||
|
'tab' => 'risk_accepted',
|
||||||
|
'governance_validity' => FindingException::VALIDITY_MISSING_SUPPORT,
|
||||||
|
],
|
||||||
|
),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($overdueOpenCount > 0) {
|
if ($overdueOpenCount > 0) {
|
||||||
$items[] = [
|
$items[] = [
|
||||||
|
'key' => 'overdue_findings',
|
||||||
'title' => 'Overdue findings',
|
'title' => 'Overdue findings',
|
||||||
'body' => "{$overdueOpenCount} open finding(s) are overdue and still need workflow follow-up.",
|
'body' => "{$overdueOpenCount} open finding(s) are overdue and still need workflow follow-up.",
|
||||||
'badge' => 'Findings',
|
'badge' => 'Findings',
|
||||||
'badgeColor' => 'danger',
|
'badgeColor' => 'danger',
|
||||||
|
...$this->findingsAction(
|
||||||
|
$tenant,
|
||||||
|
'Open findings',
|
||||||
|
['tab' => 'overdue'],
|
||||||
|
),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($expiringGovernanceCount > 0) {
|
if ($expiringGovernanceCount > 0) {
|
||||||
$items[] = [
|
$items[] = [
|
||||||
|
'key' => 'expiring_governance',
|
||||||
'title' => 'Expiring accepted-risk governance',
|
'title' => 'Expiring accepted-risk governance',
|
||||||
'body' => "{$expiringGovernanceCount} finding(s) will need governance review soon.",
|
'body' => "{$expiringGovernanceCount} accepted-risk finding(s) need governance review soon.",
|
||||||
'badge' => 'Governance',
|
'badge' => 'Governance',
|
||||||
'badgeColor' => 'warning',
|
'badgeColor' => 'warning',
|
||||||
|
...$this->findingsAction(
|
||||||
|
$tenant,
|
||||||
|
'Open findings',
|
||||||
|
[
|
||||||
|
'tab' => 'risk_accepted',
|
||||||
|
'governance_validity' => FindingException::VALIDITY_EXPIRING,
|
||||||
|
],
|
||||||
|
),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($highSeverityCount > 0) {
|
if ($highSeverityCount > 0) {
|
||||||
$items[] = [
|
$items[] = [
|
||||||
|
'key' => 'high_severity_active_findings',
|
||||||
'title' => 'High severity active findings',
|
'title' => 'High severity active findings',
|
||||||
'body' => "{$highSeverityCount} active finding(s) need review.",
|
'body' => "{$highSeverityCount} high or critical finding(s) are still active.",
|
||||||
'badge' => 'Drift',
|
'badge' => 'Findings',
|
||||||
'badgeColor' => 'danger',
|
'badgeColor' => 'danger',
|
||||||
|
...$this->findingsAction(
|
||||||
|
$tenant,
|
||||||
|
'Open findings',
|
||||||
|
[
|
||||||
|
'tab' => 'needs_action',
|
||||||
|
'high_severity' => 1,
|
||||||
|
],
|
||||||
|
),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($compareAssessment->stateFamily !== 'positive') {
|
if ($compareAssessment->stateFamily !== 'positive') {
|
||||||
$items[] = [
|
$items[] = [
|
||||||
|
'key' => 'baseline_compare_posture',
|
||||||
'title' => 'Baseline compare posture',
|
'title' => 'Baseline compare posture',
|
||||||
'body' => $compareAssessment->headline,
|
'body' => $compareAssessment->headline,
|
||||||
'supportingMessage' => $compareAssessment->supportingMessage,
|
'supportingMessage' => $compareAssessment->supportingMessage,
|
||||||
'badge' => 'Baseline',
|
'badge' => 'Baseline',
|
||||||
'badgeColor' => $compareAssessment->tone,
|
'badgeColor' => $compareAssessment->tone,
|
||||||
'nextStep' => $aggregate->nextActionLabel,
|
'actionLabel' => 'Open Baseline Compare',
|
||||||
|
'actionUrl' => BaselineCompareLanding::getUrl(panel: 'tenant', tenant: $tenant),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
$activeRuns = ActiveRuns::existForTenant($tenant)
|
if ($operationsFollowUpCount > 0) {
|
||||||
? (int) \App\Models\OperationRun::query()->where('tenant_id', $tenantId)->active()->count()
|
|
||||||
: 0;
|
|
||||||
|
|
||||||
if ($activeRuns > 0) {
|
|
||||||
$items[] = [
|
$items[] = [
|
||||||
'title' => 'Operations in progress',
|
'key' => 'operations_follow_up',
|
||||||
'body' => "{$activeRuns} run(s) are active.",
|
'title' => 'Operations need follow-up',
|
||||||
|
'body' => "{$operationsFollowUpCount} run(s) failed, completed with warnings, or look stalled.",
|
||||||
'badge' => 'Operations',
|
'badge' => 'Operations',
|
||||||
'badgeColor' => 'warning',
|
'badgeColor' => 'danger',
|
||||||
|
'actionLabel' => 'Open operations',
|
||||||
|
'actionUrl' => OperationRunLinks::index($tenant, activeTab: 'blocked'),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
$items = array_slice($items, 0, 5);
|
|
||||||
|
|
||||||
$healthyChecks = [];
|
$healthyChecks = [];
|
||||||
|
|
||||||
if ($items === []) {
|
if ($items === []) {
|
||||||
@ -123,7 +171,12 @@ protected function getViewData(): array
|
|||||||
'title' => 'No high severity active findings',
|
'title' => 'No high severity active findings',
|
||||||
'body' => 'No high severity findings are currently open for this tenant.',
|
'body' => 'No high severity findings are currently open for this tenant.',
|
||||||
],
|
],
|
||||||
[
|
$activeRuns > 0
|
||||||
|
? [
|
||||||
|
'title' => 'Operations are active',
|
||||||
|
'body' => "{$activeRuns} run(s) are active, but nothing currently needs follow-up.",
|
||||||
|
]
|
||||||
|
: [
|
||||||
'title' => 'No active operations',
|
'title' => 'No active operations',
|
||||||
'body' => 'Nothing is currently running for this tenant.',
|
'body' => 'Nothing is currently running for this tenant.',
|
||||||
],
|
],
|
||||||
@ -137,6 +190,33 @@ protected function getViewData(): array
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $parameters
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
private function findingsAction(Tenant $tenant, string $label, array $parameters): array
|
||||||
|
{
|
||||||
|
$url = $this->canOpenFindings($tenant)
|
||||||
|
? FindingResource::getUrl('index', $parameters, panel: 'tenant', tenant: $tenant)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
return [
|
||||||
|
'actionLabel' => $label,
|
||||||
|
'actionUrl' => $url,
|
||||||
|
'actionDisabled' => $url === null,
|
||||||
|
'helperText' => $url === null ? UiTooltips::INSUFFICIENT_PERMISSION : null,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function canOpenFindings(Tenant $tenant): bool
|
||||||
|
{
|
||||||
|
$user = auth()->user();
|
||||||
|
|
||||||
|
return $user instanceof User
|
||||||
|
&& $user->canAccessTenant($tenant)
|
||||||
|
&& $user->can(Capabilities::TENANT_FINDINGS_VIEW, $tenant);
|
||||||
|
}
|
||||||
|
|
||||||
private function governanceAggregate(Tenant $tenant): TenantGovernanceAggregate
|
private function governanceAggregate(Tenant $tenant): TenantGovernanceAggregate
|
||||||
{
|
{
|
||||||
/** @var TenantGovernanceAggregateResolver $resolver */
|
/** @var TenantGovernanceAggregateResolver $resolver */
|
||||||
|
|||||||
@ -5,19 +5,20 @@
|
|||||||
namespace App\Filament\Widgets\Inventory;
|
namespace App\Filament\Widgets\Inventory;
|
||||||
|
|
||||||
use App\Filament\Concerns\ResolvesPanelTenantContext;
|
use App\Filament\Concerns\ResolvesPanelTenantContext;
|
||||||
use App\Models\InventoryItem;
|
|
||||||
use App\Models\OperationRun;
|
use App\Models\OperationRun;
|
||||||
use App\Models\Tenant;
|
use App\Models\Tenant;
|
||||||
use App\Services\Inventory\CoverageCapabilitiesResolver;
|
use App\Models\User;
|
||||||
|
use App\Support\Auth\Capabilities;
|
||||||
use App\Support\Badges\BadgeDomain;
|
use App\Support\Badges\BadgeDomain;
|
||||||
use App\Support\Badges\BadgeRenderer;
|
use App\Support\Badges\BadgeRenderer;
|
||||||
use App\Support\Inventory\InventoryKpiBadges;
|
use App\Support\Inventory\InventoryKpiBadges;
|
||||||
use App\Support\Inventory\InventoryPolicyTypeMeta;
|
use App\Support\Inventory\TenantCoverageTruth;
|
||||||
use App\Support\OperationRunOutcome;
|
use App\Support\Inventory\TenantCoverageTruthResolver;
|
||||||
use App\Support\OperationRunStatus;
|
use App\Support\OperationRunLinks;
|
||||||
use Filament\Widgets\StatsOverviewWidget;
|
use Filament\Widgets\StatsOverviewWidget;
|
||||||
use Filament\Widgets\StatsOverviewWidget\Stat;
|
use Filament\Widgets\StatsOverviewWidget\Stat;
|
||||||
use Illuminate\Support\Facades\Blade;
|
use Illuminate\Support\Facades\Blade;
|
||||||
|
use Illuminate\Support\Facades\Gate;
|
||||||
use Illuminate\Support\HtmlString;
|
use Illuminate\Support\HtmlString;
|
||||||
|
|
||||||
class InventoryKpiHeader extends StatsOverviewWidget
|
class InventoryKpiHeader extends StatsOverviewWidget
|
||||||
@ -28,12 +29,9 @@ class InventoryKpiHeader extends StatsOverviewWidget
|
|||||||
|
|
||||||
protected int|string|array $columnSpan = 'full';
|
protected int|string|array $columnSpan = 'full';
|
||||||
|
|
||||||
|
protected ?string $pollingInterval = null;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Inventory KPI aggregation source-of-truth:
|
|
||||||
* - `inventory_items.policy_type`
|
|
||||||
* - `config('tenantpilot.supported_policy_types')` + `config('tenantpilot.foundation_types')` meta (`restore`, `risk`)
|
|
||||||
* - dependency capability via `CoverageCapabilitiesResolver`
|
|
||||||
*
|
|
||||||
* @return array<Stat>
|
* @return array<Stat>
|
||||||
*/
|
*/
|
||||||
protected function getStats(): array
|
protected function getStats(): array
|
||||||
@ -43,126 +41,85 @@ protected function getStats(): array
|
|||||||
if (! $tenant instanceof Tenant) {
|
if (! $tenant instanceof Tenant) {
|
||||||
return [
|
return [
|
||||||
Stat::make('Total items', 0),
|
Stat::make('Total items', 0),
|
||||||
Stat::make('Coverage', '0%')->description('Select a tenant to load coverage.'),
|
Stat::make('Covered types', '—')->description('Select a tenant to load coverage truth.'),
|
||||||
Stat::make('Last inventory sync', '—')->description('Select a tenant to see the latest sync.'),
|
Stat::make('Need follow-up', '—')->description('Select a tenant to review follow-up types.'),
|
||||||
|
Stat::make('Coverage basis', '—')->description('Select a tenant to see the latest coverage basis.'),
|
||||||
Stat::make('Active ops', 0),
|
Stat::make('Active ops', 0),
|
||||||
Stat::make('Inventory ops', 0)->description('Select a tenant to load dependency and risk counts.'),
|
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
$tenantId = (int) $tenant->getKey();
|
$truth = app(TenantCoverageTruthResolver::class)->resolve($tenant);
|
||||||
|
|
||||||
/** @var array<string, int> $countsByPolicyType */
|
|
||||||
$countsByPolicyType = InventoryItem::query()
|
|
||||||
->where('tenant_id', $tenantId)
|
|
||||||
->selectRaw('policy_type, COUNT(*) as aggregate')
|
|
||||||
->groupBy('policy_type')
|
|
||||||
->pluck('aggregate', 'policy_type')
|
|
||||||
->map(fn ($value): int => (int) $value)
|
|
||||||
->all();
|
|
||||||
|
|
||||||
$totalItems = array_sum($countsByPolicyType);
|
|
||||||
|
|
||||||
$restorableItems = 0;
|
|
||||||
$partialItems = 0;
|
|
||||||
$riskItems = 0;
|
|
||||||
|
|
||||||
foreach ($countsByPolicyType as $policyType => $count) {
|
|
||||||
if (InventoryPolicyTypeMeta::isRestorable($policyType)) {
|
|
||||||
$restorableItems += $count;
|
|
||||||
} elseif (InventoryPolicyTypeMeta::isPartial($policyType)) {
|
|
||||||
$partialItems += $count;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (InventoryPolicyTypeMeta::isHighRisk($policyType)) {
|
|
||||||
$riskItems += $count;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
$coveragePercent = $totalItems > 0
|
|
||||||
? (int) round(($restorableItems / $totalItems) * 100)
|
|
||||||
: 0;
|
|
||||||
|
|
||||||
$lastRun = OperationRun::query()
|
|
||||||
->where('tenant_id', $tenantId)
|
|
||||||
->where('type', 'inventory_sync')
|
|
||||||
->where('status', OperationRunStatus::Completed->value)
|
|
||||||
->whereNotNull('completed_at')
|
|
||||||
->latest('completed_at')
|
|
||||||
->latest('id')
|
|
||||||
->first();
|
|
||||||
|
|
||||||
$lastInventorySyncTimeLabel = '—';
|
|
||||||
$lastInventorySyncStatusLabel = '—';
|
|
||||||
$lastInventorySyncStatusColor = 'gray';
|
|
||||||
$lastInventorySyncStatusIcon = 'heroicon-m-clock';
|
|
||||||
$lastInventorySyncViewUrl = null;
|
|
||||||
|
|
||||||
if ($lastRun instanceof OperationRun) {
|
|
||||||
$timestamp = $lastRun->completed_at ?? $lastRun->started_at ?? $lastRun->created_at;
|
|
||||||
|
|
||||||
if ($timestamp) {
|
|
||||||
$lastInventorySyncTimeLabel = $timestamp->diffForHumans(['short' => true]);
|
|
||||||
}
|
|
||||||
|
|
||||||
$outcome = (string) ($lastRun->outcome ?? OperationRunOutcome::Pending->value);
|
|
||||||
$badge = BadgeRenderer::spec(BadgeDomain::OperationRunOutcome, $outcome);
|
|
||||||
$lastInventorySyncStatusLabel = $badge->label;
|
|
||||||
$lastInventorySyncStatusColor = $badge->color;
|
|
||||||
$lastInventorySyncStatusIcon = (string) ($badge->icon ?? 'heroicon-m-clock');
|
|
||||||
|
|
||||||
$lastInventorySyncViewUrl = route('admin.operations.view', ['run' => (int) $lastRun->getKey()]);
|
|
||||||
}
|
|
||||||
|
|
||||||
$badgeColor = $lastInventorySyncStatusColor;
|
|
||||||
|
|
||||||
$lastInventorySyncDescription = Blade::render(<<<'BLADE'
|
|
||||||
<div class="flex items-center gap-2">
|
|
||||||
<x-filament::badge :color="$badgeColor" size="sm">
|
|
||||||
{{ $statusLabel }}
|
|
||||||
</x-filament::badge>
|
|
||||||
|
|
||||||
@if ($viewUrl)
|
|
||||||
<x-filament::link :href="$viewUrl" size="sm">
|
|
||||||
Open operation
|
|
||||||
</x-filament::link>
|
|
||||||
@endif
|
|
||||||
</div>
|
|
||||||
BLADE, [
|
|
||||||
'badgeColor' => $badgeColor,
|
|
||||||
'statusLabel' => $lastInventorySyncStatusLabel,
|
|
||||||
'viewUrl' => $lastInventorySyncViewUrl,
|
|
||||||
]);
|
|
||||||
|
|
||||||
$activeOps = (int) OperationRun::query()
|
$activeOps = (int) OperationRun::query()
|
||||||
->where('tenant_id', $tenantId)
|
->where('tenant_id', (int) $tenant->getKey())
|
||||||
->active()
|
->active()
|
||||||
->count();
|
->count();
|
||||||
|
|
||||||
$inventoryOps = (int) OperationRun::query()
|
$inventoryOps = (int) OperationRun::query()
|
||||||
->where('tenant_id', $tenantId)
|
->where('tenant_id', (int) $tenant->getKey())
|
||||||
->where('type', 'inventory_sync')
|
->where('type', 'inventory_sync')
|
||||||
->active()
|
->active()
|
||||||
->count();
|
->count();
|
||||||
|
|
||||||
$resolver = app(CoverageCapabilitiesResolver::class);
|
|
||||||
|
|
||||||
$dependenciesItems = 0;
|
|
||||||
foreach ($countsByPolicyType as $policyType => $count) {
|
|
||||||
if ($policyType !== '' && $resolver->supportsDependencies($policyType)) {
|
|
||||||
$dependenciesItems += $count;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return [
|
return [
|
||||||
Stat::make('Total items', $totalItems),
|
Stat::make('Total items', $truth->observedItemTotal)
|
||||||
Stat::make('Coverage', $coveragePercent.'%')
|
->description(sprintf('Observed across %d supported types.', $truth->observedTypeCount())),
|
||||||
->description(new HtmlString(InventoryKpiBadges::coverage($restorableItems, $partialItems))),
|
Stat::make('Covered types', sprintf('%d / %d', $truth->succeededTypeCount, $truth->supportedTypeCount))
|
||||||
Stat::make('Last inventory sync', $lastInventorySyncTimeLabel)
|
->description(new HtmlString(InventoryKpiBadges::coverageBreakdown(
|
||||||
->description(new HtmlString($lastInventorySyncDescription)),
|
$truth->failedTypeCount,
|
||||||
Stat::make('Active ops', $activeOps),
|
$truth->skippedTypeCount,
|
||||||
Stat::make('Inventory ops', $inventoryOps)
|
$truth->unknownTypeCount,
|
||||||
->description(new HtmlString(InventoryKpiBadges::inventoryOps($dependenciesItems, $riskItems))),
|
))),
|
||||||
|
Stat::make('Need follow-up', $truth->followUpTypeCount)
|
||||||
|
->description(new HtmlString(InventoryKpiBadges::followUpSummary(
|
||||||
|
$truth->topPriorityFollowUpRow(),
|
||||||
|
$truth->observedItemTotal,
|
||||||
|
$truth->observedTypeCount(),
|
||||||
|
))),
|
||||||
|
$this->coverageBasisStat($truth, $tenant),
|
||||||
|
Stat::make('Active ops', $activeOps)
|
||||||
|
->description($inventoryOps > 0 ? 'A tenant inventory sync is queued or running.' : 'No inventory sync is currently active.'),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function coverageBasisStat(TenantCoverageTruth $truth, Tenant $tenant): Stat
|
||||||
|
{
|
||||||
|
$user = auth()->user();
|
||||||
|
|
||||||
|
if (! $truth->basisRun instanceof OperationRun) {
|
||||||
|
return Stat::make('Coverage basis', 'No current result')
|
||||||
|
->description($user instanceof User && $user->can(Capabilities::TENANT_INVENTORY_SYNC_RUN, $tenant)
|
||||||
|
? 'Run Inventory Sync from Inventory Items to establish current coverage truth.'
|
||||||
|
: 'A tenant operator with inventory sync permission must establish current coverage truth.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$outcomeBadge = BadgeRenderer::spec(BadgeDomain::OperationRunOutcome, (string) $truth->basisRun->outcome);
|
||||||
|
$canViewRun = $user instanceof User && Gate::forUser($user)->allows('view', $truth->basisRun);
|
||||||
|
|
||||||
|
$description = Blade::render(<<<'BLADE'
|
||||||
|
<div class="flex flex-wrap items-center gap-2">
|
||||||
|
<x-filament::badge :color="$badgeColor" size="sm">
|
||||||
|
{{ $statusLabel }}
|
||||||
|
</x-filament::badge>
|
||||||
|
|
||||||
|
@if ($canViewRun && $viewUrl)
|
||||||
|
<x-filament::link :href="$viewUrl" size="sm">
|
||||||
|
Open basis run
|
||||||
|
</x-filament::link>
|
||||||
|
@else
|
||||||
|
<span class="text-xs text-gray-600 dark:text-gray-300">
|
||||||
|
Latest run detail is not available with your current role.
|
||||||
|
</span>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
BLADE, [
|
||||||
|
'badgeColor' => $outcomeBadge->color,
|
||||||
|
'statusLabel' => $outcomeBadge->label,
|
||||||
|
'canViewRun' => $canViewRun,
|
||||||
|
'viewUrl' => $canViewRun ? OperationRunLinks::view($truth->basisRun, $tenant) : null,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return Stat::make('Coverage basis', $truth->basisCompletedAtLabel() ?? 'Completed')
|
||||||
|
->description(new HtmlString($description));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -201,7 +201,7 @@ protected function getViewData(): array
|
|||||||
&& $user->can(Capabilities::PROVIDER_RUN, $tenant);
|
&& $user->can(Capabilities::PROVIDER_RUN, $tenant);
|
||||||
|
|
||||||
$lifecycleNotice = $isTenantMember && ! $canOperate
|
$lifecycleNotice = $isTenantMember && ! $canOperate
|
||||||
? 'Verification can be started from tenant management only while the tenant is active.'
|
? 'Verification can be started from tenant management only while the tenant is active. Consent and connection configuration remain separate from this stored verification report.'
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
$runData = null;
|
$runData = null;
|
||||||
|
|||||||
@ -16,11 +16,21 @@ class WorkspaceNeedsAttention extends Widget
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* @var array<int, array{
|
* @var array<int, array{
|
||||||
|
* key: string,
|
||||||
|
* tenant_id: int,
|
||||||
|
* tenant_label: string,
|
||||||
|
* tenant_route_key: string,
|
||||||
|
* family: string,
|
||||||
|
* urgency: string,
|
||||||
* title: string,
|
* title: string,
|
||||||
* body: string,
|
* body: string,
|
||||||
* url: string,
|
* supporting_message: ?string,
|
||||||
* badge: string,
|
* badge: string,
|
||||||
* badge_color: string
|
* badge_color: string,
|
||||||
|
* destination: array<string, mixed>,
|
||||||
|
* action_disabled: bool,
|
||||||
|
* helper_text: ?string,
|
||||||
|
* url: ?string
|
||||||
* }>
|
* }>
|
||||||
*/
|
*/
|
||||||
public array $items = [];
|
public array $items = [];
|
||||||
@ -37,11 +47,21 @@ class WorkspaceNeedsAttention extends Widget
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* @param array<int, array{
|
* @param array<int, array{
|
||||||
|
* key: string,
|
||||||
|
* tenant_id: int,
|
||||||
|
* tenant_label: string,
|
||||||
|
* tenant_route_key: string,
|
||||||
|
* family: string,
|
||||||
|
* urgency: string,
|
||||||
* title: string,
|
* title: string,
|
||||||
* body: string,
|
* body: string,
|
||||||
* url: string,
|
* supporting_message: ?string,
|
||||||
* badge: string,
|
* badge: string,
|
||||||
* badge_color: string
|
* badge_color: string,
|
||||||
|
* destination: array<string, mixed>,
|
||||||
|
* action_disabled: bool,
|
||||||
|
* helper_text: ?string,
|
||||||
|
* url: ?string
|
||||||
* }> $items
|
* }> $items
|
||||||
* @param array{
|
* @param array{
|
||||||
* title: string,
|
* title: string,
|
||||||
|
|||||||
@ -25,6 +25,7 @@ class WorkspaceRecentOperations extends Widget
|
|||||||
* outcome_color: string,
|
* outcome_color: string,
|
||||||
* guidance: ?string,
|
* guidance: ?string,
|
||||||
* started_at: string,
|
* started_at: string,
|
||||||
|
* destination: array<string, mixed>,
|
||||||
* url: string
|
* url: string
|
||||||
* }>
|
* }>
|
||||||
*/
|
*/
|
||||||
@ -51,6 +52,7 @@ class WorkspaceRecentOperations extends Widget
|
|||||||
* outcome_color: string,
|
* outcome_color: string,
|
||||||
* guidance: ?string,
|
* guidance: ?string,
|
||||||
* started_at: string,
|
* started_at: string,
|
||||||
|
* destination: array<string, mixed>,
|
||||||
* url: string
|
* url: string
|
||||||
* }> $operations
|
* }> $operations
|
||||||
* @param array{
|
* @param array{
|
||||||
|
|||||||
@ -20,9 +20,11 @@ class WorkspaceSummaryStats extends StatsOverviewWidget
|
|||||||
* key: string,
|
* key: string,
|
||||||
* label: string,
|
* label: string,
|
||||||
* value: int,
|
* value: int,
|
||||||
|
* category: string,
|
||||||
* description: string,
|
* description: string,
|
||||||
|
* destination: ?array<string, mixed>,
|
||||||
* destination_url: ?string,
|
* destination_url: ?string,
|
||||||
* color: string
|
* color: string,
|
||||||
* }>
|
* }>
|
||||||
*/
|
*/
|
||||||
public array $metrics = [];
|
public array $metrics = [];
|
||||||
@ -32,9 +34,11 @@ class WorkspaceSummaryStats extends StatsOverviewWidget
|
|||||||
* key: string,
|
* key: string,
|
||||||
* label: string,
|
* label: string,
|
||||||
* value: int,
|
* value: int,
|
||||||
|
* category: string,
|
||||||
* description: string,
|
* description: string,
|
||||||
|
* destination: ?array<string, mixed>,
|
||||||
* destination_url: ?string,
|
* destination_url: ?string,
|
||||||
* color: string
|
* color: string,
|
||||||
* }> $metrics
|
* }> $metrics
|
||||||
*/
|
*/
|
||||||
public function mount(array $metrics = []): void
|
public function mount(array $metrics = []): void
|
||||||
@ -53,8 +57,13 @@ protected function getStats(): array
|
|||||||
->description($metric['description'])
|
->description($metric['description'])
|
||||||
->color($metric['color']);
|
->color($metric['color']);
|
||||||
|
|
||||||
if ($metric['destination_url'] !== null) {
|
$destination = $metric['destination'] ?? null;
|
||||||
$stat->url($metric['destination_url']);
|
$destinationUrl = is_array($destination) && ($destination['disabled'] ?? false) === false
|
||||||
|
? ($destination['url'] ?? null)
|
||||||
|
: ($metric['destination_url'] ?? null);
|
||||||
|
|
||||||
|
if (is_string($destinationUrl) && $destinationUrl !== '') {
|
||||||
|
$stat->url($destinationUrl);
|
||||||
}
|
}
|
||||||
|
|
||||||
return $stat;
|
return $stat;
|
||||||
|
|||||||
@ -103,11 +103,15 @@ public function handle(InventorySyncService $inventorySyncService, AuditLogger $
|
|||||||
$this->operationRun,
|
$this->operationRun,
|
||||||
$tenant,
|
$tenant,
|
||||||
$context,
|
$context,
|
||||||
function (string $policyType, bool $success, ?string $errorCode) use (&$processedPolicyTypes, &$coverageStatusByType, &$successCount, &$failedCount): void {
|
function (string $policyType, bool $success, ?string $errorCode, int $itemCount) use (&$processedPolicyTypes, &$coverageStatusByType, &$successCount, &$failedCount): void {
|
||||||
$processedPolicyTypes[] = $policyType;
|
$processedPolicyTypes[] = $policyType;
|
||||||
$coverageStatusByType[$policyType] = $success
|
$coverageStatusByType[$policyType] = array_filter([
|
||||||
|
'status' => $success
|
||||||
? InventoryCoverage::StatusSucceeded
|
? InventoryCoverage::StatusSucceeded
|
||||||
: InventoryCoverage::StatusFailed;
|
: InventoryCoverage::StatusFailed,
|
||||||
|
'item_count' => $itemCount,
|
||||||
|
'error_code' => $success ? null : $errorCode,
|
||||||
|
], static fn (mixed $value): bool => $value !== null);
|
||||||
|
|
||||||
if ($success) {
|
if ($success) {
|
||||||
$successCount++;
|
$successCount++;
|
||||||
@ -126,7 +130,10 @@ function (string $policyType, bool $success, ?string $errorCode) use (&$processe
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
$statusByType[$type] = InventoryCoverage::StatusSkipped;
|
$statusByType[$type] = [
|
||||||
|
'status' => InventoryCoverage::StatusSkipped,
|
||||||
|
'item_count' => 0,
|
||||||
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
foreach ($coverageStatusByType as $type => $status) {
|
foreach ($coverageStatusByType as $type => $status) {
|
||||||
@ -138,8 +145,16 @@ function (string $policyType, bool $success, ?string $errorCode) use (&$processe
|
|||||||
}
|
}
|
||||||
|
|
||||||
if ((string) ($result['status'] ?? '') === 'skipped') {
|
if ((string) ($result['status'] ?? '') === 'skipped') {
|
||||||
|
$skippedErrorCode = is_string($result['error_codes'][0] ?? null)
|
||||||
|
? (string) $result['error_codes'][0]
|
||||||
|
: null;
|
||||||
|
|
||||||
foreach ($statusByType as $type => $status) {
|
foreach ($statusByType as $type => $status) {
|
||||||
$statusByType[$type] = InventoryCoverage::StatusSkipped;
|
$statusByType[$type] = array_filter([
|
||||||
|
'status' => InventoryCoverage::StatusSkipped,
|
||||||
|
'item_count' => 0,
|
||||||
|
'error_code' => $skippedErrorCode,
|
||||||
|
], static fn (mixed $value): bool => $value !== null);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -143,6 +143,17 @@ public static function openStatusesForQuery(): array
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<int, string>
|
||||||
|
*/
|
||||||
|
public static function highSeverityValues(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
self::SEVERITY_HIGH,
|
||||||
|
self::SEVERITY_CRITICAL,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
public static function canonicalizeStatus(?string $status): ?string
|
public static function canonicalizeStatus(?string $status): ?string
|
||||||
{
|
{
|
||||||
if ($status === self::STATUS_ACKNOWLEDGED) {
|
if ($status === self::STATUS_ACKNOWLEDGED) {
|
||||||
@ -245,4 +256,41 @@ public function scopeWithSubjectDisplayName(Builder $query): Builder
|
|||||||
->limit(1),
|
->limit(1),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function scopeOpenWorkflow(Builder $query): Builder
|
||||||
|
{
|
||||||
|
return $query->whereIn('status', self::openStatusesForQuery());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function scopeDrift(Builder $query): Builder
|
||||||
|
{
|
||||||
|
return $query->where('finding_type', self::FINDING_TYPE_DRIFT);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function scopeOpenDrift(Builder $query): Builder
|
||||||
|
{
|
||||||
|
return $query
|
||||||
|
->drift()
|
||||||
|
->openWorkflow();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function scopeOverdueOpen(Builder $query): Builder
|
||||||
|
{
|
||||||
|
return $query
|
||||||
|
->openWorkflow()
|
||||||
|
->whereNotNull('due_at')
|
||||||
|
->where('due_at', '<', now());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function scopeHighSeverity(Builder $query): Builder
|
||||||
|
{
|
||||||
|
return $query->whereIn('severity', self::highSeverityValues());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function scopeHighSeverityActive(Builder $query): Builder
|
||||||
|
{
|
||||||
|
return $query
|
||||||
|
->openWorkflow()
|
||||||
|
->highSeverity();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -2,7 +2,11 @@
|
|||||||
|
|
||||||
namespace App\Models;
|
namespace App\Models;
|
||||||
|
|
||||||
|
use App\Support\Inventory\InventoryCoverage;
|
||||||
use App\Support\OperationCatalog;
|
use App\Support\OperationCatalog;
|
||||||
|
use App\Support\OperationRunOutcome;
|
||||||
|
use App\Support\OperationRunStatus;
|
||||||
|
use App\Support\Operations\OperationLifecyclePolicy;
|
||||||
use App\Support\Operations\OperationRunFreshnessState;
|
use App\Support\Operations\OperationRunFreshnessState;
|
||||||
use Illuminate\Database\Eloquent\Builder;
|
use Illuminate\Database\Eloquent\Builder;
|
||||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
@ -66,7 +70,127 @@ public function user(): BelongsTo
|
|||||||
|
|
||||||
public function scopeActive(Builder $query): Builder
|
public function scopeActive(Builder $query): Builder
|
||||||
{
|
{
|
||||||
return $query->whereIn('status', ['queued', 'running']);
|
return $query->whereIn('status', [
|
||||||
|
OperationRunStatus::Queued->value,
|
||||||
|
OperationRunStatus::Running->value,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function scopeTerminalFailure(Builder $query): Builder
|
||||||
|
{
|
||||||
|
return $query
|
||||||
|
->where('status', OperationRunStatus::Completed->value)
|
||||||
|
->where('outcome', OperationRunOutcome::Failed->value);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function scopeLikelyStale(Builder $query, ?OperationLifecyclePolicy $policy = null): Builder
|
||||||
|
{
|
||||||
|
$policy ??= app(OperationLifecyclePolicy::class);
|
||||||
|
|
||||||
|
return $query
|
||||||
|
->active()
|
||||||
|
->where(function (Builder $query) use ($policy): void {
|
||||||
|
foreach ($policy->coveredTypeNames() as $type) {
|
||||||
|
$query->orWhere(function (Builder $typeQuery) use ($policy, $type): void {
|
||||||
|
$typeQuery
|
||||||
|
->where('type', $type)
|
||||||
|
->where(function (Builder $stateQuery) use ($policy, $type): void {
|
||||||
|
$stateQuery
|
||||||
|
->where(function (Builder $queuedQuery) use ($policy, $type): void {
|
||||||
|
$queuedQuery
|
||||||
|
->where('status', OperationRunStatus::Queued->value)
|
||||||
|
->whereNull('started_at')
|
||||||
|
->where('created_at', '<=', now()->subSeconds($policy->queuedStaleAfterSeconds($type)));
|
||||||
|
})
|
||||||
|
->orWhere(function (Builder $runningQuery) use ($policy, $type): void {
|
||||||
|
$runningQuery
|
||||||
|
->where('status', OperationRunStatus::Running->value)
|
||||||
|
->where(function (Builder $startedAtQuery) use ($policy, $type): void {
|
||||||
|
$startedAtQuery
|
||||||
|
->where('started_at', '<=', now()->subSeconds($policy->runningStaleAfterSeconds($type)))
|
||||||
|
->orWhere(function (Builder $fallbackQuery) use ($policy, $type): void {
|
||||||
|
$fallbackQuery
|
||||||
|
->whereNull('started_at')
|
||||||
|
->where('created_at', '<=', now()->subSeconds($policy->runningStaleAfterSeconds($type)));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function scopeHealthyActive(Builder $query, ?OperationLifecyclePolicy $policy = null): Builder
|
||||||
|
{
|
||||||
|
$policy ??= app(OperationLifecyclePolicy::class);
|
||||||
|
$coveredTypes = $policy->coveredTypeNames();
|
||||||
|
|
||||||
|
if ($coveredTypes === []) {
|
||||||
|
return $query->active();
|
||||||
|
}
|
||||||
|
|
||||||
|
return $query
|
||||||
|
->active()
|
||||||
|
->where(function (Builder $query) use ($coveredTypes, $policy): void {
|
||||||
|
$query->whereNotIn('type', $coveredTypes);
|
||||||
|
|
||||||
|
foreach ($coveredTypes as $type) {
|
||||||
|
$query->orWhere(function (Builder $typeQuery) use ($policy, $type): void {
|
||||||
|
$typeQuery
|
||||||
|
->where('type', $type)
|
||||||
|
->where(function (Builder $stateQuery) use ($policy, $type): void {
|
||||||
|
$stateQuery
|
||||||
|
->where(function (Builder $queuedQuery) use ($policy, $type): void {
|
||||||
|
$queuedQuery
|
||||||
|
->where('status', OperationRunStatus::Queued->value)
|
||||||
|
->where(function (Builder $freshQueuedQuery) use ($policy, $type): void {
|
||||||
|
$freshQueuedQuery
|
||||||
|
->whereNotNull('started_at')
|
||||||
|
->orWhereNull('created_at')
|
||||||
|
->orWhere('created_at', '>', now()->subSeconds($policy->queuedStaleAfterSeconds($type)));
|
||||||
|
});
|
||||||
|
})
|
||||||
|
->orWhere(function (Builder $runningQuery) use ($policy, $type): void {
|
||||||
|
$runningQuery
|
||||||
|
->where('status', OperationRunStatus::Running->value)
|
||||||
|
->where(function (Builder $freshRunningQuery) use ($policy, $type): void {
|
||||||
|
$freshRunningQuery
|
||||||
|
->where('started_at', '>', now()->subSeconds($policy->runningStaleAfterSeconds($type)))
|
||||||
|
->orWhere(function (Builder $fallbackQuery) use ($policy, $type): void {
|
||||||
|
$fallbackQuery
|
||||||
|
->whereNull('started_at')
|
||||||
|
->where(function (Builder $createdAtQuery) use ($policy, $type): void {
|
||||||
|
$createdAtQuery
|
||||||
|
->whereNull('created_at')
|
||||||
|
->orWhere('created_at', '>', now()->subSeconds($policy->runningStaleAfterSeconds($type)));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function scopeDashboardNeedsFollowUp(Builder $query): Builder
|
||||||
|
{
|
||||||
|
return $query->where(function (Builder $query): void {
|
||||||
|
$query
|
||||||
|
->where(function (Builder $terminalQuery): void {
|
||||||
|
$terminalQuery
|
||||||
|
->where('status', OperationRunStatus::Completed->value)
|
||||||
|
->whereIn('outcome', [
|
||||||
|
OperationRunOutcome::Blocked->value,
|
||||||
|
OperationRunOutcome::PartiallySucceeded->value,
|
||||||
|
OperationRunOutcome::Failed->value,
|
||||||
|
]);
|
||||||
|
})
|
||||||
|
->orWhere(function (Builder $activeQuery): void {
|
||||||
|
$activeQuery->likelyStale();
|
||||||
|
});
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
public function getSelectionHashAttribute(): ?string
|
public function getSelectionHashAttribute(): ?string
|
||||||
@ -130,11 +254,33 @@ public function setFinishedAtAttribute(mixed $value): void
|
|||||||
$this->completed_at = $value;
|
$this->completed_at = $value;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function inventoryCoverage(): ?InventoryCoverage
|
||||||
|
{
|
||||||
|
return InventoryCoverage::fromContext($this->context);
|
||||||
|
}
|
||||||
|
|
||||||
public function isGovernanceArtifactOperation(): bool
|
public function isGovernanceArtifactOperation(): bool
|
||||||
{
|
{
|
||||||
return OperationCatalog::isGovernanceArtifactOperation((string) $this->type);
|
return OperationCatalog::isGovernanceArtifactOperation((string) $this->type);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public static function latestCompletedCoverageBearingInventorySyncForTenant(int $tenantId): ?self
|
||||||
|
{
|
||||||
|
if ($tenantId <= 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return static::query()
|
||||||
|
->where('tenant_id', $tenantId)
|
||||||
|
->where('type', 'inventory_sync')
|
||||||
|
->where('status', OperationRunStatus::Completed->value)
|
||||||
|
->whereNotNull('completed_at')
|
||||||
|
->latest('completed_at')
|
||||||
|
->latest('id')
|
||||||
|
->cursor()
|
||||||
|
->first(static fn (self $run): bool => $run->inventoryCoverage() instanceof InventoryCoverage);
|
||||||
|
}
|
||||||
|
|
||||||
public function supportsOperatorExplanation(): bool
|
public function supportsOperatorExplanation(): bool
|
||||||
{
|
{
|
||||||
return OperationCatalog::supportsOperatorExplanation((string) $this->type);
|
return OperationCatalog::supportsOperatorExplanation((string) $this->type);
|
||||||
@ -194,6 +340,19 @@ public function freshnessState(): OperationRunFreshnessState
|
|||||||
return OperationRunFreshnessState::forRun($this);
|
return OperationRunFreshnessState::forRun($this);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function requiresDashboardFollowUp(): bool
|
||||||
|
{
|
||||||
|
if ((string) $this->status === OperationRunStatus::Completed->value) {
|
||||||
|
return in_array((string) $this->outcome, [
|
||||||
|
OperationRunOutcome::Blocked->value,
|
||||||
|
OperationRunOutcome::PartiallySucceeded->value,
|
||||||
|
OperationRunOutcome::Failed->value,
|
||||||
|
], true);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->freshnessState()->isLikelyStale();
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @return array<string, mixed>
|
* @return array<string, mixed>
|
||||||
*/
|
*/
|
||||||
|
|||||||
@ -82,17 +82,24 @@ public function syncNow(Tenant $tenant, array $selectionPayload): OperationRun
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
$statusByType[$type] = InventoryCoverage::StatusSkipped;
|
$statusByType[$type] = [
|
||||||
|
'status' => InventoryCoverage::StatusSkipped,
|
||||||
|
'item_count' => 0,
|
||||||
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
$result = $this->executeSelection(
|
$result = $this->executeSelection(
|
||||||
$operationRun,
|
$operationRun,
|
||||||
$tenant,
|
$tenant,
|
||||||
$normalizedSelection,
|
$normalizedSelection,
|
||||||
function (string $policyType, bool $success, ?string $errorCode) use (&$statusByType): void {
|
function (string $policyType, bool $success, ?string $errorCode, int $itemCount) use (&$statusByType): void {
|
||||||
$statusByType[$policyType] = $success
|
$statusByType[$policyType] = array_filter([
|
||||||
|
'status' => $success
|
||||||
? InventoryCoverage::StatusSucceeded
|
? InventoryCoverage::StatusSucceeded
|
||||||
: InventoryCoverage::StatusFailed;
|
: InventoryCoverage::StatusFailed,
|
||||||
|
'item_count' => $itemCount,
|
||||||
|
'error_code' => $success ? null : $errorCode,
|
||||||
|
], static fn (mixed $value): bool => $value !== null);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
@ -126,10 +133,15 @@ function (string $policyType, bool $success, ?string $errorCode) use (&$statusBy
|
|||||||
$updatedContext = is_array($operationRun->context) ? $operationRun->context : [];
|
$updatedContext = is_array($operationRun->context) ? $operationRun->context : [];
|
||||||
|
|
||||||
$coverageStatusByType = $statusByType;
|
$coverageStatusByType = $statusByType;
|
||||||
|
$skippedErrorCode = is_string($errorCodes[0] ?? null) ? (string) $errorCodes[0] : null;
|
||||||
|
|
||||||
if ($status === 'skipped') {
|
if ($status === 'skipped') {
|
||||||
foreach ($coverageStatusByType as $type => $coverageStatus) {
|
foreach ($coverageStatusByType as $type => $coverageStatus) {
|
||||||
$coverageStatusByType[$type] = InventoryCoverage::StatusSkipped;
|
$coverageStatusByType[$type] = array_filter([
|
||||||
|
'status' => InventoryCoverage::StatusSkipped,
|
||||||
|
'item_count' => 0,
|
||||||
|
'error_code' => $skippedErrorCode,
|
||||||
|
], static fn (mixed $value): bool => $value !== null);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -176,7 +188,7 @@ function (string $policyType, bool $success, ?string $errorCode) use (&$statusBy
|
|||||||
* This method MUST NOT create or update legacy sync-run rows; OperationRun is canonical.
|
* This method MUST NOT create or update legacy sync-run rows; OperationRun is canonical.
|
||||||
*
|
*
|
||||||
* @param array<string, mixed> $selectionPayload
|
* @param array<string, mixed> $selectionPayload
|
||||||
* @param null|callable(string $policyType, bool $success, ?string $errorCode): void $onPolicyTypeProcessed
|
* @param null|callable(string $policyType, bool $success, ?string $errorCode, int $itemCount): void $onPolicyTypeProcessed
|
||||||
* @return array{status: string, had_errors: bool, error_codes: list<string>, error_context: array<string, mixed>|null, items_observed_count: int, items_upserted_count: int, errors_count: int}
|
* @return array{status: string, had_errors: bool, error_codes: list<string>, error_context: array<string, mixed>|null, items_observed_count: int, items_upserted_count: int, errors_count: int}
|
||||||
*/
|
*/
|
||||||
public function executeSelection(OperationRun $operationRun, Tenant $tenant, array $selectionPayload, ?callable $onPolicyTypeProcessed = null): array
|
public function executeSelection(OperationRun $operationRun, Tenant $tenant, array $selectionPayload, ?callable $onPolicyTypeProcessed = null): array
|
||||||
@ -245,7 +257,7 @@ public function normalizeAndHashSelection(array $selectionPayload): array
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* @param array{policy_types: list<string>, categories: list<string>, include_foundations: bool, include_dependencies: bool} $normalizedSelection
|
* @param array{policy_types: list<string>, categories: list<string>, include_foundations: bool, include_dependencies: bool} $normalizedSelection
|
||||||
* @param null|callable(string $policyType, bool $success, ?string $errorCode): void $onPolicyTypeProcessed
|
* @param null|callable(string $policyType, bool $success, ?string $errorCode, int $itemCount): void $onPolicyTypeProcessed
|
||||||
* @return array{status: string, had_errors: bool, error_codes: list<string>, error_context: array<string, mixed>|null, items_observed_count: int, items_upserted_count: int, errors_count: int}
|
* @return array{status: string, had_errors: bool, error_codes: list<string>, error_context: array<string, mixed>|null, items_observed_count: int, items_upserted_count: int, errors_count: int}
|
||||||
*/
|
*/
|
||||||
private function executeSelectionUnderLock(OperationRun $operationRun, Tenant $tenant, array $normalizedSelection, ?callable $onPolicyTypeProcessed = null): array
|
private function executeSelectionUnderLock(OperationRun $operationRun, Tenant $tenant, array $normalizedSelection, ?callable $onPolicyTypeProcessed = null): array
|
||||||
@ -256,6 +268,7 @@ private function executeSelectionUnderLock(OperationRun $operationRun, Tenant $t
|
|||||||
$errorCodes = [];
|
$errorCodes = [];
|
||||||
$hadErrors = false;
|
$hadErrors = false;
|
||||||
$warnings = [];
|
$warnings = [];
|
||||||
|
$observedByType = [];
|
||||||
|
|
||||||
try {
|
try {
|
||||||
$connection = $this->resolveProviderConnection($tenant);
|
$connection = $this->resolveProviderConnection($tenant);
|
||||||
@ -277,7 +290,7 @@ private function executeSelectionUnderLock(OperationRun $operationRun, Tenant $t
|
|||||||
$hadErrors = true;
|
$hadErrors = true;
|
||||||
$errors++;
|
$errors++;
|
||||||
$errorCodes[] = 'unsupported_type';
|
$errorCodes[] = 'unsupported_type';
|
||||||
$onPolicyTypeProcessed && $onPolicyTypeProcessed($policyType, false, 'unsupported_type');
|
$onPolicyTypeProcessed && $onPolicyTypeProcessed($policyType, false, 'unsupported_type', 0);
|
||||||
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@ -293,7 +306,7 @@ private function executeSelectionUnderLock(OperationRun $operationRun, Tenant $t
|
|||||||
$errors++;
|
$errors++;
|
||||||
$errorCode = $this->mapGraphFailureToErrorCode($response);
|
$errorCode = $this->mapGraphFailureToErrorCode($response);
|
||||||
$errorCodes[] = $errorCode;
|
$errorCodes[] = $errorCode;
|
||||||
$onPolicyTypeProcessed && $onPolicyTypeProcessed($policyType, false, $errorCode);
|
$onPolicyTypeProcessed && $onPolicyTypeProcessed($policyType, false, $errorCode, 0);
|
||||||
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@ -313,6 +326,7 @@ private function executeSelectionUnderLock(OperationRun $operationRun, Tenant $t
|
|||||||
}
|
}
|
||||||
|
|
||||||
$observed++;
|
$observed++;
|
||||||
|
$observedByType[$policyType] = (int) ($observedByType[$policyType] ?? 0) + 1;
|
||||||
|
|
||||||
$includeDeps = (bool) ($normalizedSelection['include_dependencies'] ?? true);
|
$includeDeps = (bool) ($normalizedSelection['include_dependencies'] ?? true);
|
||||||
|
|
||||||
@ -384,7 +398,12 @@ private function executeSelectionUnderLock(OperationRun $operationRun, Tenant $t
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
$onPolicyTypeProcessed && $onPolicyTypeProcessed($policyType, true, null);
|
$onPolicyTypeProcessed && $onPolicyTypeProcessed(
|
||||||
|
$policyType,
|
||||||
|
true,
|
||||||
|
null,
|
||||||
|
(int) ($observedByType[$policyType] ?? 0),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return [
|
return [
|
||||||
|
|||||||
@ -27,6 +27,7 @@ final class BadgeCatalog
|
|||||||
BadgeDomain::BaselineSnapshotGapStatus->value => Domains\BaselineSnapshotGapStatusBadge::class,
|
BadgeDomain::BaselineSnapshotGapStatus->value => Domains\BaselineSnapshotGapStatusBadge::class,
|
||||||
BadgeDomain::OperationRunStatus->value => Domains\OperationRunStatusBadge::class,
|
BadgeDomain::OperationRunStatus->value => Domains\OperationRunStatusBadge::class,
|
||||||
BadgeDomain::OperationRunOutcome->value => Domains\OperationRunOutcomeBadge::class,
|
BadgeDomain::OperationRunOutcome->value => Domains\OperationRunOutcomeBadge::class,
|
||||||
|
BadgeDomain::InventoryCoverageState->value => Domains\InventoryCoverageStateBadge::class,
|
||||||
BadgeDomain::BackupSetStatus->value => Domains\BackupSetStatusBadge::class,
|
BadgeDomain::BackupSetStatus->value => Domains\BackupSetStatusBadge::class,
|
||||||
BadgeDomain::RestoreRunStatus->value => Domains\RestoreRunStatusBadge::class,
|
BadgeDomain::RestoreRunStatus->value => Domains\RestoreRunStatusBadge::class,
|
||||||
BadgeDomain::RestoreCheckSeverity->value => Domains\RestoreCheckSeverityBadge::class,
|
BadgeDomain::RestoreCheckSeverity->value => Domains\RestoreCheckSeverityBadge::class,
|
||||||
@ -46,6 +47,8 @@ final class BadgeCatalog
|
|||||||
BadgeDomain::IgnoredAt->value => Domains\IgnoredAtBadge::class,
|
BadgeDomain::IgnoredAt->value => Domains\IgnoredAtBadge::class,
|
||||||
BadgeDomain::RestorePreviewDecision->value => Domains\RestorePreviewDecisionBadge::class,
|
BadgeDomain::RestorePreviewDecision->value => Domains\RestorePreviewDecisionBadge::class,
|
||||||
BadgeDomain::RestoreResultStatus->value => Domains\RestoreResultStatusBadge::class,
|
BadgeDomain::RestoreResultStatus->value => Domains\RestoreResultStatusBadge::class,
|
||||||
|
BadgeDomain::ProviderConsentStatus->value => Domains\ProviderConsentStatusBadge::class,
|
||||||
|
BadgeDomain::ProviderVerificationStatus->value => Domains\ProviderVerificationStatusBadge::class,
|
||||||
BadgeDomain::ProviderConnectionStatus->value => Domains\ProviderConnectionStatusBadge::class,
|
BadgeDomain::ProviderConnectionStatus->value => Domains\ProviderConnectionStatusBadge::class,
|
||||||
BadgeDomain::ProviderConnectionHealth->value => Domains\ProviderConnectionHealthBadge::class,
|
BadgeDomain::ProviderConnectionHealth->value => Domains\ProviderConnectionHealthBadge::class,
|
||||||
BadgeDomain::ManagedTenantOnboardingVerificationStatus->value => Domains\ManagedTenantOnboardingVerificationStatusBadge::class,
|
BadgeDomain::ManagedTenantOnboardingVerificationStatus->value => Domains\ManagedTenantOnboardingVerificationStatusBadge::class,
|
||||||
@ -166,6 +169,32 @@ public static function normalizeProviderConnectionStatus(mixed $value): ?string
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public static function normalizeProviderConsentStatus(mixed $value): ?string
|
||||||
|
{
|
||||||
|
$state = self::normalizeState($value);
|
||||||
|
|
||||||
|
return match ($state) {
|
||||||
|
'needs_admin_consent', 'needs_consent', 'consent_required' => 'required',
|
||||||
|
'connected' => 'granted',
|
||||||
|
'error' => 'failed',
|
||||||
|
default => $state,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function normalizeProviderVerificationStatus(mixed $value): ?string
|
||||||
|
{
|
||||||
|
$state = self::normalizeState($value);
|
||||||
|
|
||||||
|
return match ($state) {
|
||||||
|
'not_started', 'never_checked' => 'unknown',
|
||||||
|
'in_progress' => 'pending',
|
||||||
|
'ok' => 'healthy',
|
||||||
|
'warning', 'needs_attention' => 'degraded',
|
||||||
|
'failed' => 'error',
|
||||||
|
default => $state,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
public static function normalizeProviderConnectionHealth(mixed $value): ?string
|
public static function normalizeProviderConnectionHealth(mixed $value): ?string
|
||||||
{
|
{
|
||||||
$state = self::normalizeState($value);
|
$state = self::normalizeState($value);
|
||||||
|
|||||||
@ -18,6 +18,7 @@ enum BadgeDomain: string
|
|||||||
case BaselineSnapshotGapStatus = 'baseline_snapshot_gap_status';
|
case BaselineSnapshotGapStatus = 'baseline_snapshot_gap_status';
|
||||||
case OperationRunStatus = 'operation_run_status';
|
case OperationRunStatus = 'operation_run_status';
|
||||||
case OperationRunOutcome = 'operation_run_outcome';
|
case OperationRunOutcome = 'operation_run_outcome';
|
||||||
|
case InventoryCoverageState = 'inventory_coverage_state';
|
||||||
case BackupSetStatus = 'backup_set_status';
|
case BackupSetStatus = 'backup_set_status';
|
||||||
case RestoreRunStatus = 'restore_run_status';
|
case RestoreRunStatus = 'restore_run_status';
|
||||||
case RestoreCheckSeverity = 'restore_check_severity';
|
case RestoreCheckSeverity = 'restore_check_severity';
|
||||||
@ -37,6 +38,8 @@ enum BadgeDomain: string
|
|||||||
case IgnoredAt = 'ignored_at';
|
case IgnoredAt = 'ignored_at';
|
||||||
case RestorePreviewDecision = 'restore_preview_decision';
|
case RestorePreviewDecision = 'restore_preview_decision';
|
||||||
case RestoreResultStatus = 'restore_result_status';
|
case RestoreResultStatus = 'restore_result_status';
|
||||||
|
case ProviderConsentStatus = 'provider_connection.consent_status';
|
||||||
|
case ProviderVerificationStatus = 'provider_connection.verification_status';
|
||||||
case ProviderConnectionStatus = 'provider_connection.status';
|
case ProviderConnectionStatus = 'provider_connection.status';
|
||||||
case ProviderConnectionHealth = 'provider_connection.health';
|
case ProviderConnectionHealth = 'provider_connection.health';
|
||||||
case ManagedTenantOnboardingVerificationStatus = 'managed_tenant_onboarding.verification_status';
|
case ManagedTenantOnboardingVerificationStatus = 'managed_tenant_onboarding.verification_status';
|
||||||
|
|||||||
25
app/Support/Badges/Domains/InventoryCoverageStateBadge.php
Normal file
25
app/Support/Badges/Domains/InventoryCoverageStateBadge.php
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Support\Badges\Domains;
|
||||||
|
|
||||||
|
use App\Support\Badges\BadgeCatalog;
|
||||||
|
use App\Support\Badges\BadgeMapper;
|
||||||
|
use App\Support\Badges\BadgeSpec;
|
||||||
|
|
||||||
|
final class InventoryCoverageStateBadge implements BadgeMapper
|
||||||
|
{
|
||||||
|
public function spec(mixed $value): BadgeSpec
|
||||||
|
{
|
||||||
|
$state = BadgeCatalog::normalizeState($value);
|
||||||
|
|
||||||
|
return match ($state) {
|
||||||
|
'succeeded' => new BadgeSpec('Succeeded', 'success', 'heroicon-m-check-circle'),
|
||||||
|
'failed' => new BadgeSpec('Failed', 'danger', 'heroicon-m-x-circle'),
|
||||||
|
'skipped' => new BadgeSpec('Skipped', 'warning', 'heroicon-m-minus-circle'),
|
||||||
|
'unknown' => new BadgeSpec('Unknown', 'gray', 'heroicon-m-question-mark-circle'),
|
||||||
|
default => BadgeSpec::unknown(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
24
app/Support/Badges/Domains/ProviderConsentStatusBadge.php
Normal file
24
app/Support/Badges/Domains/ProviderConsentStatusBadge.php
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Support\Badges\Domains;
|
||||||
|
|
||||||
|
use App\Support\Badges\BadgeCatalog;
|
||||||
|
use App\Support\Badges\BadgeMapper;
|
||||||
|
use App\Support\Badges\BadgeSpec;
|
||||||
|
|
||||||
|
final class ProviderConsentStatusBadge implements BadgeMapper
|
||||||
|
{
|
||||||
|
public function spec(mixed $value): BadgeSpec
|
||||||
|
{
|
||||||
|
$state = BadgeCatalog::normalizeProviderConsentStatus($value);
|
||||||
|
|
||||||
|
return match ($state) {
|
||||||
|
'required' => new BadgeSpec('Required', 'warning', 'heroicon-m-exclamation-triangle'),
|
||||||
|
'granted' => new BadgeSpec('Granted', 'success', 'heroicon-m-check-circle'),
|
||||||
|
'failed' => new BadgeSpec('Failed', 'danger', 'heroicon-m-x-circle'),
|
||||||
|
'revoked' => new BadgeSpec('Revoked', 'danger', 'heroicon-m-no-symbol'),
|
||||||
|
'unknown' => new BadgeSpec('Unknown', 'gray', 'heroicon-m-question-mark-circle'),
|
||||||
|
default => BadgeSpec::unknown(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,25 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Support\Badges\Domains;
|
||||||
|
|
||||||
|
use App\Support\Badges\BadgeCatalog;
|
||||||
|
use App\Support\Badges\BadgeMapper;
|
||||||
|
use App\Support\Badges\BadgeSpec;
|
||||||
|
|
||||||
|
final class ProviderVerificationStatusBadge implements BadgeMapper
|
||||||
|
{
|
||||||
|
public function spec(mixed $value): BadgeSpec
|
||||||
|
{
|
||||||
|
$state = BadgeCatalog::normalizeProviderVerificationStatus($value);
|
||||||
|
|
||||||
|
return match ($state) {
|
||||||
|
'pending' => new BadgeSpec('Pending', 'info', 'heroicon-m-clock'),
|
||||||
|
'healthy' => new BadgeSpec('Healthy', 'success', 'heroicon-m-check-circle'),
|
||||||
|
'degraded' => new BadgeSpec('Degraded', 'warning', 'heroicon-m-exclamation-triangle'),
|
||||||
|
'blocked' => new BadgeSpec('Blocked', 'danger', 'heroicon-m-no-symbol'),
|
||||||
|
'error' => new BadgeSpec('Error', 'danger', 'heroicon-m-x-circle'),
|
||||||
|
'unknown' => new BadgeSpec('Unknown', 'gray', 'heroicon-m-question-mark-circle'),
|
||||||
|
default => BadgeSpec::unknown(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -68,10 +68,56 @@ public function coveredTypes(): array
|
|||||||
return array_values(array_unique($covered));
|
return array_values(array_unique($covered));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, array{
|
||||||
|
* segment: 'policy'|'foundation',
|
||||||
|
* type: string,
|
||||||
|
* status: string,
|
||||||
|
* item_count?: int,
|
||||||
|
* error_code?: string|null
|
||||||
|
* }>
|
||||||
|
*/
|
||||||
|
public function rows(): array
|
||||||
|
{
|
||||||
|
$rows = [];
|
||||||
|
|
||||||
|
foreach ($this->policyTypes as $type => $meta) {
|
||||||
|
$rows[$type] = array_merge($meta, [
|
||||||
|
'segment' => 'policy',
|
||||||
|
'type' => $type,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($this->foundationTypes as $type => $meta) {
|
||||||
|
$rows[$type] = array_merge($meta, [
|
||||||
|
'segment' => 'foundation',
|
||||||
|
'type' => $type,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
ksort($rows);
|
||||||
|
|
||||||
|
return $rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array{
|
||||||
|
* segment: 'policy'|'foundation',
|
||||||
|
* type: string,
|
||||||
|
* status: string,
|
||||||
|
* item_count?: int,
|
||||||
|
* error_code?: string|null
|
||||||
|
* }|null
|
||||||
|
*/
|
||||||
|
public function row(string $type): ?array
|
||||||
|
{
|
||||||
|
return $this->rows()[$type] ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Build the canonical `inventory.coverage.*` payload for OperationRun.context.
|
* Build the canonical `inventory.coverage.*` payload for OperationRun.context.
|
||||||
*
|
*
|
||||||
* @param array<string, string> $statusByType
|
* @param array<string, string|array{status: string, item_count?: int, error_code?: string|null}> $statusByType
|
||||||
* @param list<string> $foundationTypes
|
* @param list<string> $foundationTypes
|
||||||
* @return array{policy_types: array<string, array{status: string}>, foundation_types: array<string, array{status: string}>}
|
* @return array{policy_types: array<string, array{status: string}>, foundation_types: array<string, array{status: string}>}
|
||||||
*/
|
*/
|
||||||
@ -88,14 +134,12 @@ public static function buildPayload(array $statusByType, array $foundationTypes)
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
$normalizedStatus = self::normalizeStatus($status);
|
$row = self::normalizeBuildRow($status);
|
||||||
|
|
||||||
if ($normalizedStatus === null) {
|
if ($row === null) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
$row = ['status' => $normalizedStatus];
|
|
||||||
|
|
||||||
if (array_key_exists($type, $foundationLookup)) {
|
if (array_key_exists($type, $foundationLookup)) {
|
||||||
$foundations[$type] = $row;
|
$foundations[$type] = $row;
|
||||||
|
|
||||||
@ -114,6 +158,40 @@ public static function buildPayload(array $statusByType, array $foundationTypes)
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array{status: string, item_count?: int, error_code?: string|null}|null
|
||||||
|
*/
|
||||||
|
private static function normalizeBuildRow(mixed $value): ?array
|
||||||
|
{
|
||||||
|
if (is_string($value)) {
|
||||||
|
$status = self::normalizeStatus($value);
|
||||||
|
|
||||||
|
return $status === null ? null : ['status' => $status];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! is_array($value)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$status = self::normalizeStatus($value['status'] ?? null);
|
||||||
|
|
||||||
|
if ($status === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$row = ['status' => $status];
|
||||||
|
|
||||||
|
if (array_key_exists('item_count', $value) && is_int($value['item_count'])) {
|
||||||
|
$row['item_count'] = $value['item_count'];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (array_key_exists('error_code', $value) && (is_string($value['error_code']) || $value['error_code'] === null)) {
|
||||||
|
$row['error_code'] = $value['error_code'];
|
||||||
|
}
|
||||||
|
|
||||||
|
return $row;
|
||||||
|
}
|
||||||
|
|
||||||
private static function normalizeStatus(mixed $status): ?string
|
private static function normalizeStatus(mixed $status): ?string
|
||||||
{
|
{
|
||||||
if (! is_string($status)) {
|
if (! is_string($status)) {
|
||||||
|
|||||||
@ -8,39 +8,75 @@
|
|||||||
|
|
||||||
class InventoryKpiBadges
|
class InventoryKpiBadges
|
||||||
{
|
{
|
||||||
public static function coverage(int $restorableCount, int $partialCount): string
|
public static function coverageBreakdown(int $failedCount, int $skippedCount, int $unknownCount): string
|
||||||
{
|
{
|
||||||
|
if ($failedCount === 0 && $skippedCount === 0 && $unknownCount === 0) {
|
||||||
return Blade::render(<<<'BLADE'
|
return Blade::render(<<<'BLADE'
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<x-filament::badge color="success" size="sm">
|
<x-filament::badge color="success" size="sm">
|
||||||
Restorable {{ $restorableCount }}
|
No follow-up
|
||||||
</x-filament::badge>
|
|
||||||
|
|
||||||
<x-filament::badge color="warning" size="sm">
|
|
||||||
Partial {{ $partialCount }}
|
|
||||||
</x-filament::badge>
|
</x-filament::badge>
|
||||||
</div>
|
</div>
|
||||||
|
BLADE);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Blade::render(<<<'BLADE'
|
||||||
|
<div class="flex flex-wrap items-center gap-2">
|
||||||
|
@if ($failedCount > 0)
|
||||||
|
<x-filament::badge color="danger" size="sm">
|
||||||
|
Failed {{ $failedCount }}
|
||||||
|
</x-filament::badge>
|
||||||
|
@endif
|
||||||
|
|
||||||
|
@if ($skippedCount > 0)
|
||||||
|
<x-filament::badge color="warning" size="sm">
|
||||||
|
Skipped {{ $skippedCount }}
|
||||||
|
</x-filament::badge>
|
||||||
|
@endif
|
||||||
|
|
||||||
|
@if ($unknownCount > 0)
|
||||||
|
<x-filament::badge color="gray" size="sm">
|
||||||
|
Unknown {{ $unknownCount }}
|
||||||
|
</x-filament::badge>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
BLADE, [
|
BLADE, [
|
||||||
'restorableCount' => $restorableCount,
|
'failedCount' => $failedCount,
|
||||||
'partialCount' => $partialCount,
|
'skippedCount' => $skippedCount,
|
||||||
|
'unknownCount' => $unknownCount,
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
public static function inventoryOps(int $dependenciesCount, int $riskCount): string
|
public static function followUpSummary(?TenantCoverageTypeTruth $topPriorityRow, int $observedItemTotal, int $observedTypeCount): string
|
||||||
{
|
{
|
||||||
|
if (! $topPriorityRow instanceof TenantCoverageTypeTruth) {
|
||||||
return Blade::render(<<<'BLADE'
|
return Blade::render(<<<'BLADE'
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<x-filament::badge color="gray" size="sm">
|
<x-filament::badge color="success" size="sm">
|
||||||
Dependencies {{ $dependenciesCount }}
|
All covered
|
||||||
</x-filament::badge>
|
|
||||||
|
|
||||||
<x-filament::badge color="danger" size="sm">
|
|
||||||
Risk {{ $riskCount }}
|
|
||||||
</x-filament::badge>
|
</x-filament::badge>
|
||||||
</div>
|
</div>
|
||||||
|
BLADE);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Blade::render(<<<'BLADE'
|
||||||
|
<div class="flex flex-wrap items-center gap-2">
|
||||||
|
<x-filament::badge color="gray" size="sm">
|
||||||
|
{{ $topPriorityLabel }}
|
||||||
|
</x-filament::badge>
|
||||||
|
|
||||||
|
<x-filament::badge color="info" size="sm">
|
||||||
|
Observed {{ $observedItemTotal }}
|
||||||
|
</x-filament::badge>
|
||||||
|
|
||||||
|
<span class="text-xs text-gray-600 dark:text-gray-300">
|
||||||
|
{{ $observedTypeCount }} supported types currently observed
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
BLADE, [
|
BLADE, [
|
||||||
'dependenciesCount' => $dependenciesCount,
|
'topPriorityLabel' => $topPriorityRow->label,
|
||||||
'riskCount' => $riskCount,
|
'observedItemTotal' => $observedItemTotal,
|
||||||
|
'observedTypeCount' => $observedTypeCount,
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
136
app/Support/Inventory/TenantCoverageTruth.php
Normal file
136
app/Support/Inventory/TenantCoverageTruth.php
Normal file
@ -0,0 +1,136 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Support\Inventory;
|
||||||
|
|
||||||
|
use App\Models\OperationRun;
|
||||||
|
use InvalidArgumentException;
|
||||||
|
|
||||||
|
final readonly class TenantCoverageTruth
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @param list<TenantCoverageTypeTruth> $rows
|
||||||
|
*/
|
||||||
|
public function __construct(
|
||||||
|
public int $tenantId,
|
||||||
|
public ?OperationRun $basisRun,
|
||||||
|
public bool $hasCurrentCoverageResult,
|
||||||
|
public int $supportedTypeCount,
|
||||||
|
public int $succeededTypeCount,
|
||||||
|
public int $failedTypeCount,
|
||||||
|
public int $skippedTypeCount,
|
||||||
|
public int $unknownTypeCount,
|
||||||
|
public int $followUpTypeCount,
|
||||||
|
public int $observedItemTotal,
|
||||||
|
public array $rows,
|
||||||
|
) {
|
||||||
|
if ($this->tenantId <= 0) {
|
||||||
|
throw new InvalidArgumentException('Tenant coverage truth requires a positive tenant id.');
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($this->supportedTypeCount < 0 || $this->observedItemTotal < 0) {
|
||||||
|
throw new InvalidArgumentException('Tenant coverage truth counts must be zero or greater.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function basisRunId(): ?int
|
||||||
|
{
|
||||||
|
return $this->basisRun instanceof OperationRun
|
||||||
|
? (int) $this->basisRun->getKey()
|
||||||
|
: null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function basisRunOutcome(): ?string
|
||||||
|
{
|
||||||
|
return $this->basisRun instanceof OperationRun
|
||||||
|
? (string) $this->basisRun->outcome
|
||||||
|
: null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function basisCompletedAtLabel(): ?string
|
||||||
|
{
|
||||||
|
if (! $this->basisRun instanceof OperationRun) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$timestamp = $this->basisRun->completed_at ?? $this->basisRun->started_at ?? $this->basisRun->created_at;
|
||||||
|
|
||||||
|
return $timestamp?->diffForHumans(['short' => true]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function topPriorityFollowUpRow(): ?TenantCoverageTypeTruth
|
||||||
|
{
|
||||||
|
foreach ($this->rows as $row) {
|
||||||
|
if ($row->followUpRequired) {
|
||||||
|
return $row;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function observedTypeCount(): int
|
||||||
|
{
|
||||||
|
return count(array_filter(
|
||||||
|
$this->rows,
|
||||||
|
static fn (TenantCoverageTypeTruth $row): bool => $row->observedItemCount > 0,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return list<TenantCoverageTypeTruth>
|
||||||
|
*/
|
||||||
|
public function followUpRows(): array
|
||||||
|
{
|
||||||
|
return array_values(array_filter(
|
||||||
|
$this->rows,
|
||||||
|
static fn (TenantCoverageTypeTruth $row): bool => $row->followUpRequired,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array{
|
||||||
|
* tenantId: int,
|
||||||
|
* basisRun: array{id: int, outcome: string, completedAt: string|null}|null,
|
||||||
|
* hasCurrentCoverageResult: bool,
|
||||||
|
* summary: array{
|
||||||
|
* supportedTypes: int,
|
||||||
|
* succeededTypes: int,
|
||||||
|
* failedTypes: int,
|
||||||
|
* skippedTypes: int,
|
||||||
|
* unknownTypes: int,
|
||||||
|
* followUpTypes: int,
|
||||||
|
* observedItems: int
|
||||||
|
* },
|
||||||
|
* rows: list<array<string, mixed>>
|
||||||
|
* }
|
||||||
|
*/
|
||||||
|
public function toArray(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'tenantId' => $this->tenantId,
|
||||||
|
'basisRun' => $this->basisRun instanceof OperationRun
|
||||||
|
? [
|
||||||
|
'id' => (int) $this->basisRun->getKey(),
|
||||||
|
'outcome' => (string) $this->basisRun->outcome,
|
||||||
|
'completedAt' => $this->basisRun->completed_at?->toIso8601String(),
|
||||||
|
]
|
||||||
|
: null,
|
||||||
|
'hasCurrentCoverageResult' => $this->hasCurrentCoverageResult,
|
||||||
|
'summary' => [
|
||||||
|
'supportedTypes' => $this->supportedTypeCount,
|
||||||
|
'succeededTypes' => $this->succeededTypeCount,
|
||||||
|
'failedTypes' => $this->failedTypeCount,
|
||||||
|
'skippedTypes' => $this->skippedTypeCount,
|
||||||
|
'unknownTypes' => $this->unknownTypeCount,
|
||||||
|
'followUpTypes' => $this->followUpTypeCount,
|
||||||
|
'observedItems' => $this->observedItemTotal,
|
||||||
|
],
|
||||||
|
'rows' => array_map(
|
||||||
|
static fn (TenantCoverageTypeTruth $row): array => $row->toArray(),
|
||||||
|
$this->rows,
|
||||||
|
),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
170
app/Support/Inventory/TenantCoverageTruthResolver.php
Normal file
170
app/Support/Inventory/TenantCoverageTruthResolver.php
Normal file
@ -0,0 +1,170 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Support\Inventory;
|
||||||
|
|
||||||
|
use App\Models\InventoryItem;
|
||||||
|
use App\Models\OperationRun;
|
||||||
|
use App\Models\Tenant;
|
||||||
|
use App\Services\Inventory\CoverageCapabilitiesResolver;
|
||||||
|
use Illuminate\Support\Collection;
|
||||||
|
|
||||||
|
final class TenantCoverageTruthResolver
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly CoverageCapabilitiesResolver $coverageCapabilities,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function resolve(Tenant|int $tenant): TenantCoverageTruth
|
||||||
|
{
|
||||||
|
$tenantId = $tenant instanceof Tenant
|
||||||
|
? (int) $tenant->getKey()
|
||||||
|
: (int) $tenant;
|
||||||
|
|
||||||
|
$basisRun = OperationRun::latestCompletedCoverageBearingInventorySyncForTenant($tenantId);
|
||||||
|
$basisCoverage = $basisRun?->inventoryCoverage();
|
||||||
|
|
||||||
|
/** @var array<string, int> $countsByType */
|
||||||
|
$countsByType = InventoryItem::query()
|
||||||
|
->where('tenant_id', $tenantId)
|
||||||
|
->selectRaw('policy_type, COUNT(*) as aggregate')
|
||||||
|
->groupBy('policy_type')
|
||||||
|
->pluck('aggregate', 'policy_type')
|
||||||
|
->map(static fn (mixed $value): int => (int) $value)
|
||||||
|
->all();
|
||||||
|
|
||||||
|
$rows = $this->supportedTypes()
|
||||||
|
->map(function (array $meta) use ($basisCoverage, $countsByType): TenantCoverageTypeTruth {
|
||||||
|
$type = (string) $meta['type'];
|
||||||
|
$segment = (string) $meta['segment'];
|
||||||
|
$basisRow = $basisCoverage?->row($type);
|
||||||
|
$coverageState = is_string($basisRow['status'] ?? null)
|
||||||
|
? (string) $basisRow['status']
|
||||||
|
: TenantCoverageTypeTruth::StateUnknown;
|
||||||
|
$observedItemCount = (int) ($countsByType[$type] ?? 0);
|
||||||
|
$basisItemCount = is_int($basisRow['item_count'] ?? null)
|
||||||
|
? (int) $basisRow['item_count']
|
||||||
|
: null;
|
||||||
|
$basisErrorCode = is_string($basisRow['error_code'] ?? null)
|
||||||
|
? (string) $basisRow['error_code']
|
||||||
|
: null;
|
||||||
|
$followUpRequired = $coverageState !== TenantCoverageTypeTruth::StateSucceeded;
|
||||||
|
|
||||||
|
return new TenantCoverageTypeTruth(
|
||||||
|
key: sprintf('%s:%s', $segment, $type),
|
||||||
|
type: $type,
|
||||||
|
segment: $segment,
|
||||||
|
label: (string) ($meta['label'] ?? $type),
|
||||||
|
category: (string) ($meta['category'] ?? 'Other'),
|
||||||
|
platform: is_string($meta['platform'] ?? null) ? (string) $meta['platform'] : null,
|
||||||
|
coverageState: $coverageState,
|
||||||
|
followUpRequired: $followUpRequired,
|
||||||
|
followUpPriority: self::followUpPriorityForState($coverageState),
|
||||||
|
observedItemCount: $observedItemCount,
|
||||||
|
basisItemCount: $basisItemCount,
|
||||||
|
basisErrorCode: $basisErrorCode,
|
||||||
|
restoreMode: is_string($meta['restore'] ?? null) ? (string) $meta['restore'] : null,
|
||||||
|
riskLevel: is_string($meta['risk'] ?? null) ? (string) $meta['risk'] : null,
|
||||||
|
supportsDependencies: $segment === 'policy' && $this->coverageCapabilities->supportsDependencies($type),
|
||||||
|
followUpGuidance: self::followUpGuidanceForState($coverageState, $basisErrorCode),
|
||||||
|
isBasisPayloadBacked: $basisRow !== null,
|
||||||
|
);
|
||||||
|
})
|
||||||
|
->sort(function (TenantCoverageTypeTruth $left, TenantCoverageTypeTruth $right): int {
|
||||||
|
$priority = $left->followUpPriority <=> $right->followUpPriority;
|
||||||
|
|
||||||
|
if ($priority !== 0) {
|
||||||
|
return $priority;
|
||||||
|
}
|
||||||
|
|
||||||
|
$observed = $right->observedItemCount <=> $left->observedItemCount;
|
||||||
|
|
||||||
|
if ($observed !== 0) {
|
||||||
|
return $observed;
|
||||||
|
}
|
||||||
|
|
||||||
|
return strnatcasecmp($left->label, $right->label);
|
||||||
|
})
|
||||||
|
->values()
|
||||||
|
->all();
|
||||||
|
|
||||||
|
return new TenantCoverageTruth(
|
||||||
|
tenantId: $tenantId,
|
||||||
|
basisRun: $basisRun,
|
||||||
|
hasCurrentCoverageResult: $basisCoverage instanceof InventoryCoverage,
|
||||||
|
supportedTypeCount: count($rows),
|
||||||
|
succeededTypeCount: $this->countRowsByState($rows, TenantCoverageTypeTruth::StateSucceeded),
|
||||||
|
failedTypeCount: $this->countRowsByState($rows, TenantCoverageTypeTruth::StateFailed),
|
||||||
|
skippedTypeCount: $this->countRowsByState($rows, TenantCoverageTypeTruth::StateSkipped),
|
||||||
|
unknownTypeCount: $this->countRowsByState($rows, TenantCoverageTypeTruth::StateUnknown),
|
||||||
|
followUpTypeCount: count(array_filter(
|
||||||
|
$rows,
|
||||||
|
static fn (TenantCoverageTypeTruth $row): bool => $row->followUpRequired,
|
||||||
|
)),
|
||||||
|
observedItemTotal: array_sum($countsByType),
|
||||||
|
rows: $rows,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return Collection<int, array{type: string, label: string, category: string, platform?: string|null, restore?: string|null, risk?: string|null, segment: 'policy'|'foundation'}>
|
||||||
|
*/
|
||||||
|
private function supportedTypes(): Collection
|
||||||
|
{
|
||||||
|
$supported = collect(InventoryPolicyTypeMeta::supported())
|
||||||
|
->filter(static fn (array $row): bool => is_string($row['type'] ?? null) && $row['type'] !== '')
|
||||||
|
->map(static fn (array $row): array => array_merge($row, ['segment' => 'policy']));
|
||||||
|
|
||||||
|
$foundations = collect(InventoryPolicyTypeMeta::foundations())
|
||||||
|
->filter(static fn (array $row): bool => is_string($row['type'] ?? null) && $row['type'] !== '')
|
||||||
|
->map(static fn (array $row): array => array_merge($row, ['segment' => 'foundation']));
|
||||||
|
|
||||||
|
return $supported
|
||||||
|
->merge($foundations)
|
||||||
|
->values();
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function followUpPriorityForState(string $coverageState): int
|
||||||
|
{
|
||||||
|
return match ($coverageState) {
|
||||||
|
TenantCoverageTypeTruth::StateFailed => 0,
|
||||||
|
TenantCoverageTypeTruth::StateUnknown => 1,
|
||||||
|
TenantCoverageTypeTruth::StateSkipped => 2,
|
||||||
|
default => 3,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function followUpGuidanceForState(string $coverageState, ?string $basisErrorCode): string
|
||||||
|
{
|
||||||
|
return match (true) {
|
||||||
|
$coverageState === TenantCoverageTypeTruth::StateFailed && in_array($basisErrorCode, [
|
||||||
|
'graph_forbidden',
|
||||||
|
'provider_consent_missing',
|
||||||
|
'provider_permission_missing',
|
||||||
|
'provider_permission_denied',
|
||||||
|
], true) => 'Review provider consent or permissions, then rerun inventory sync.',
|
||||||
|
$coverageState === TenantCoverageTypeTruth::StateFailed && in_array($basisErrorCode, [
|
||||||
|
'graph_throttled',
|
||||||
|
'graph_transient',
|
||||||
|
'rate_limited',
|
||||||
|
'network_unreachable',
|
||||||
|
], true) => 'Retry inventory sync after the provider recovers.',
|
||||||
|
$coverageState === TenantCoverageTypeTruth::StateFailed => 'Review the latest inventory sync details before retrying.',
|
||||||
|
$coverageState === TenantCoverageTypeTruth::StateSkipped => 'Run inventory sync again with the required types selected.',
|
||||||
|
$coverageState === TenantCoverageTypeTruth::StateUnknown => 'No current basis result exists for this type. Run inventory sync to confirm coverage.',
|
||||||
|
default => 'No follow-up is currently required.',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param list<TenantCoverageTypeTruth> $rows
|
||||||
|
*/
|
||||||
|
private function countRowsByState(array $rows, string $state): int
|
||||||
|
{
|
||||||
|
return count(array_filter(
|
||||||
|
$rows,
|
||||||
|
static fn (TenantCoverageTypeTruth $row): bool => $row->coverageState === $state,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
88
app/Support/Inventory/TenantCoverageTypeTruth.php
Normal file
88
app/Support/Inventory/TenantCoverageTypeTruth.php
Normal file
@ -0,0 +1,88 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Support\Inventory;
|
||||||
|
|
||||||
|
use InvalidArgumentException;
|
||||||
|
|
||||||
|
final readonly class TenantCoverageTypeTruth
|
||||||
|
{
|
||||||
|
public const string StateSucceeded = InventoryCoverage::StatusSucceeded;
|
||||||
|
|
||||||
|
public const string StateFailed = InventoryCoverage::StatusFailed;
|
||||||
|
|
||||||
|
public const string StateSkipped = InventoryCoverage::StatusSkipped;
|
||||||
|
|
||||||
|
public const string StateUnknown = 'unknown';
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
public string $key,
|
||||||
|
public string $type,
|
||||||
|
public string $segment,
|
||||||
|
public string $label,
|
||||||
|
public string $category,
|
||||||
|
public ?string $platform,
|
||||||
|
public string $coverageState,
|
||||||
|
public bool $followUpRequired,
|
||||||
|
public int $followUpPriority,
|
||||||
|
public int $observedItemCount,
|
||||||
|
public ?int $basisItemCount,
|
||||||
|
public ?string $basisErrorCode,
|
||||||
|
public ?string $restoreMode,
|
||||||
|
public ?string $riskLevel,
|
||||||
|
public bool $supportsDependencies,
|
||||||
|
public string $followUpGuidance,
|
||||||
|
public bool $isBasisPayloadBacked,
|
||||||
|
) {
|
||||||
|
if ($this->key === '' || $this->type === '' || $this->label === '') {
|
||||||
|
throw new InvalidArgumentException('Coverage truth rows require non-empty identity fields.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array{
|
||||||
|
* __key: string,
|
||||||
|
* key: string,
|
||||||
|
* type: string,
|
||||||
|
* segment: string,
|
||||||
|
* label: string,
|
||||||
|
* category: string,
|
||||||
|
* platform: ?string,
|
||||||
|
* coverage_state: string,
|
||||||
|
* follow_up_required: bool,
|
||||||
|
* follow_up_priority: int,
|
||||||
|
* follow_up_guidance: string,
|
||||||
|
* observed_item_count: int,
|
||||||
|
* basis_item_count: ?int,
|
||||||
|
* basis_error_code: ?string,
|
||||||
|
* restore: ?string,
|
||||||
|
* risk: ?string,
|
||||||
|
* dependencies: bool,
|
||||||
|
* is_basis_payload_backed: bool
|
||||||
|
* }
|
||||||
|
*/
|
||||||
|
public function toArray(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'__key' => $this->key,
|
||||||
|
'key' => $this->key,
|
||||||
|
'type' => $this->type,
|
||||||
|
'segment' => $this->segment,
|
||||||
|
'label' => $this->label,
|
||||||
|
'category' => $this->category,
|
||||||
|
'platform' => $this->platform,
|
||||||
|
'coverage_state' => $this->coverageState,
|
||||||
|
'follow_up_required' => $this->followUpRequired,
|
||||||
|
'follow_up_priority' => $this->followUpPriority,
|
||||||
|
'follow_up_guidance' => $this->followUpGuidance,
|
||||||
|
'observed_item_count' => $this->observedItemCount,
|
||||||
|
'basis_item_count' => $this->basisItemCount,
|
||||||
|
'basis_error_code' => $this->basisErrorCode,
|
||||||
|
'restore' => $this->restoreMode,
|
||||||
|
'risk' => $this->riskLevel,
|
||||||
|
'dependencies' => $this->supportsDependencies,
|
||||||
|
'is_basis_payload_backed' => $this->isBasisPayloadBacked,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -188,13 +188,15 @@ private function resolveTenantRouteParameter(mixed $routeTenant): ?Tenant
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$tenantKeyColumn = (new Tenant)->getQualifiedKeyName();
|
||||||
|
|
||||||
return Tenant::query()
|
return Tenant::query()
|
||||||
->withTrashed()
|
->withTrashed()
|
||||||
->where(static function ($query) use ($routeTenant): void {
|
->where(static function ($query) use ($routeTenant, $tenantKeyColumn): void {
|
||||||
$query->where('external_id', $routeTenant);
|
$query->where('external_id', $routeTenant);
|
||||||
|
|
||||||
if (ctype_digit($routeTenant)) {
|
if (ctype_digit($routeTenant)) {
|
||||||
$query->orWhereKey((int) $routeTenant);
|
$query->orWhere($tenantKeyColumn, (int) $routeTenant);
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
->first();
|
->first();
|
||||||
|
|||||||
@ -75,9 +75,25 @@ public static function identifier(OperationRun|int $run): string
|
|||||||
return 'Operation #'.$runId;
|
return 'Operation #'.$runId;
|
||||||
}
|
}
|
||||||
|
|
||||||
public static function index(?Tenant $tenant = null, ?CanonicalNavigationContext $context = null): string
|
public static function index(
|
||||||
{
|
?Tenant $tenant = null,
|
||||||
return route('admin.operations.index', $context?->toQuery() ?? []);
|
?CanonicalNavigationContext $context = null,
|
||||||
|
?string $activeTab = null,
|
||||||
|
bool $allTenants = false,
|
||||||
|
): string {
|
||||||
|
$parameters = $context?->toQuery() ?? [];
|
||||||
|
|
||||||
|
if ($tenant instanceof Tenant) {
|
||||||
|
$parameters['tenant_id'] = (int) $tenant->getKey();
|
||||||
|
} elseif ($allTenants) {
|
||||||
|
$parameters['tenant_scope'] = 'all';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (is_string($activeTab) && $activeTab !== '') {
|
||||||
|
$parameters['activeTab'] = $activeTab;
|
||||||
|
}
|
||||||
|
|
||||||
|
return route('admin.operations.index', $parameters);
|
||||||
}
|
}
|
||||||
|
|
||||||
public static function tenantlessView(OperationRun|int $run, ?CanonicalNavigationContext $context = null): string
|
public static function tenantlessView(OperationRun|int $run, ?CanonicalNavigationContext $context = null): string
|
||||||
|
|||||||
@ -17,6 +17,7 @@
|
|||||||
use App\Services\Baselines\SnapshotRendering\FidelityState;
|
use App\Services\Baselines\SnapshotRendering\FidelityState;
|
||||||
use App\Support\Badges\BadgeCatalog;
|
use App\Support\Badges\BadgeCatalog;
|
||||||
use App\Support\Badges\BadgeDomain;
|
use App\Support\Badges\BadgeDomain;
|
||||||
|
use App\Support\Evidence\EvidenceCompletenessState;
|
||||||
use App\Support\OperationCatalog;
|
use App\Support\OperationCatalog;
|
||||||
use App\Support\OperationRunLinks;
|
use App\Support\OperationRunLinks;
|
||||||
use App\Support\OperationRunOutcome;
|
use App\Support\OperationRunOutcome;
|
||||||
@ -349,7 +350,9 @@ private function buildEvidenceSnapshotEnvelope(EvidenceSnapshot $snapshot): Arti
|
|||||||
'required' => 'Refresh evidence before using this snapshot',
|
'required' => 'Refresh evidence before using this snapshot',
|
||||||
'optional' => in_array($status, ['queued', 'generating'], true)
|
'optional' => in_array($status, ['queued', 'generating'], true)
|
||||||
? 'Wait for evidence generation to finish'
|
? 'Wait for evidence generation to finish'
|
||||||
: 'Review the evidence freshness before relying on this snapshot',
|
: ($freshnessState === 'stale'
|
||||||
|
? 'Refresh the stale evidence before relying on this snapshot'
|
||||||
|
: 'Review the evidence freshness before relying on this snapshot'),
|
||||||
default => null,
|
default => null,
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
@ -402,7 +405,7 @@ public function forTenantReviewFresh(TenantReview $review): ArtifactTruthEnvelop
|
|||||||
|
|
||||||
private function buildTenantReviewEnvelope(TenantReview $review): ArtifactTruthEnvelope
|
private function buildTenantReviewEnvelope(TenantReview $review): ArtifactTruthEnvelope
|
||||||
{
|
{
|
||||||
$review->loadMissing(['tenant', 'currentExportReviewPack']);
|
$review->loadMissing(['tenant', 'currentExportReviewPack', 'evidenceSnapshot']);
|
||||||
|
|
||||||
$summary = is_array($review->summary) ? $review->summary : [];
|
$summary = is_array($review->summary) ? $review->summary : [];
|
||||||
$publishBlockers = $review->publishBlockers();
|
$publishBlockers = $review->publishBlockers();
|
||||||
@ -410,6 +413,7 @@ private function buildTenantReviewEnvelope(TenantReview $review): ArtifactTruthE
|
|||||||
$completeness = $review->completenessEnum()->value;
|
$completeness = $review->completenessEnum()->value;
|
||||||
$sectionCounts = is_array($summary['section_state_counts'] ?? null) ? $summary['section_state_counts'] : [];
|
$sectionCounts = is_array($summary['section_state_counts'] ?? null) ? $summary['section_state_counts'] : [];
|
||||||
$staleSections = (int) ($sectionCounts['stale'] ?? 0);
|
$staleSections = (int) ($sectionCounts['stale'] ?? 0);
|
||||||
|
$sourceEvidence = $this->evidenceTrustBurden($review->evidenceSnapshot);
|
||||||
|
|
||||||
$artifactExistence = match ($status) {
|
$artifactExistence = match ($status) {
|
||||||
TenantReviewStatus::Archived, TenantReviewStatus::Superseded => 'historical_only',
|
TenantReviewStatus::Archived, TenantReviewStatus::Superseded => 'historical_only',
|
||||||
@ -417,24 +421,27 @@ private function buildTenantReviewEnvelope(TenantReview $review): ArtifactTruthE
|
|||||||
default => 'created',
|
default => 'created',
|
||||||
};
|
};
|
||||||
|
|
||||||
$contentState = match ($completeness) {
|
$contentState = match (true) {
|
||||||
TenantReviewCompletenessState::Complete->value => 'trusted',
|
$completeness === TenantReviewCompletenessState::Missing->value => 'missing_input',
|
||||||
TenantReviewCompletenessState::Partial->value => 'partial',
|
$completeness === TenantReviewCompletenessState::Partial->value || $sourceEvidence['isPartial'] => 'partial',
|
||||||
TenantReviewCompletenessState::Missing->value => 'missing_input',
|
$sourceEvidence['isMissing'] => 'missing_input',
|
||||||
TenantReviewCompletenessState::Stale->value => 'trusted',
|
$completeness === TenantReviewCompletenessState::Complete->value => 'trusted',
|
||||||
|
$completeness === TenantReviewCompletenessState::Stale->value => 'trusted',
|
||||||
default => 'partial',
|
default => 'partial',
|
||||||
};
|
};
|
||||||
|
|
||||||
$freshnessState = match (true) {
|
$freshnessState = match (true) {
|
||||||
$artifactExistence === 'historical_only' => 'stale',
|
$artifactExistence === 'historical_only' => 'stale',
|
||||||
$completeness === TenantReviewCompletenessState::Stale->value || $staleSections > 0 => 'stale',
|
$completeness === TenantReviewCompletenessState::Stale->value || $staleSections > 0 || $sourceEvidence['isStale'] => 'stale',
|
||||||
default => 'current',
|
default => 'current',
|
||||||
};
|
};
|
||||||
|
|
||||||
$publicationReadiness = match (true) {
|
$publicationReadiness = match (true) {
|
||||||
$artifactExistence === 'historical_only' => 'internal_only',
|
$artifactExistence === 'historical_only' => 'internal_only',
|
||||||
$status === TenantReviewStatus::Published => 'publishable',
|
|
||||||
$publishBlockers !== [] => 'blocked',
|
$publishBlockers !== [] => 'blocked',
|
||||||
|
$contentState === 'missing_input' => 'blocked',
|
||||||
|
$freshnessState === 'stale' || $contentState === 'partial' => 'internal_only',
|
||||||
|
$status === TenantReviewStatus::Published => 'publishable',
|
||||||
$status === TenantReviewStatus::Ready => 'publishable',
|
$status === TenantReviewStatus::Ready => 'publishable',
|
||||||
default => 'internal_only',
|
default => 'internal_only',
|
||||||
};
|
};
|
||||||
@ -442,16 +449,16 @@ private function buildTenantReviewEnvelope(TenantReview $review): ArtifactTruthE
|
|||||||
$actionability = match (true) {
|
$actionability = match (true) {
|
||||||
$artifactExistence === 'historical_only' => 'none',
|
$artifactExistence === 'historical_only' => 'none',
|
||||||
$publicationReadiness === 'publishable' && $freshnessState === 'current' => 'none',
|
$publicationReadiness === 'publishable' && $freshnessState === 'current' => 'none',
|
||||||
$publicationReadiness === 'internal_only' && $contentState === 'trusted' => 'optional',
|
$publicationReadiness === 'blocked' => 'required',
|
||||||
$freshnessState === 'stale' && $publishBlockers === [] => 'optional',
|
$publicationReadiness === 'internal_only' => 'optional',
|
||||||
default => 'required',
|
default => 'required',
|
||||||
};
|
};
|
||||||
|
|
||||||
$reasonCode = match (true) {
|
$reasonCode = match (true) {
|
||||||
$publishBlockers !== [] => 'review_publish_blocked',
|
$publishBlockers !== [] => 'review_publish_blocked',
|
||||||
$status === TenantReviewStatus::Failed => 'review_generation_failed',
|
$status === TenantReviewStatus::Failed => 'review_generation_failed',
|
||||||
$completeness === TenantReviewCompletenessState::Missing->value => 'review_missing_sections',
|
$contentState === 'missing_input' => 'review_missing_sections',
|
||||||
$completeness === TenantReviewCompletenessState::Stale->value => 'review_stale_sections',
|
$freshnessState === 'stale' => 'review_stale_sections',
|
||||||
default => null,
|
default => null,
|
||||||
};
|
};
|
||||||
$reason = $this->reasonPresenter->forArtifactTruth($reasonCode, 'artifact_truth');
|
$reason = $this->reasonPresenter->forArtifactTruth($reasonCode, 'artifact_truth');
|
||||||
@ -470,7 +477,11 @@ private function buildTenantReviewEnvelope(TenantReview $review): ArtifactTruthE
|
|||||||
$publicationReadiness === 'internal_only' => [
|
$publicationReadiness === 'internal_only' => [
|
||||||
BadgeDomain::GovernanceArtifactPublicationReadiness,
|
BadgeDomain::GovernanceArtifactPublicationReadiness,
|
||||||
'internal_only',
|
'internal_only',
|
||||||
'This review exists and is useful internally, but it is not yet ready for stakeholder publication.',
|
match (true) {
|
||||||
|
$freshnessState === 'stale' => 'This review is useful internally, but stale evidence should be refreshed before stakeholder publication.',
|
||||||
|
$contentState === 'partial' => 'This review is useful internally, but the evidence basis is partial and should be completed before stakeholder publication.',
|
||||||
|
default => 'This review exists and is useful internally, but it is not yet ready for stakeholder publication.',
|
||||||
|
},
|
||||||
],
|
],
|
||||||
$freshnessState === 'stale' => [
|
$freshnessState === 'stale' => [
|
||||||
BadgeDomain::GovernanceArtifactFreshness,
|
BadgeDomain::GovernanceArtifactFreshness,
|
||||||
@ -490,6 +501,8 @@ private function buildTenantReviewEnvelope(TenantReview $review): ArtifactTruthE
|
|||||||
|
|
||||||
if ($publishBlockers !== [] && $review->tenant !== null) {
|
if ($publishBlockers !== [] && $review->tenant !== null) {
|
||||||
$nextActionUrl = TenantReviewResource::tenantScopedUrl('view', ['record' => $review], $review->tenant);
|
$nextActionUrl = TenantReviewResource::tenantScopedUrl('view', ['record' => $review], $review->tenant);
|
||||||
|
} elseif (($freshnessState === 'stale' || $contentState === 'partial') && $review->tenant !== null && $review->evidenceSnapshot !== null) {
|
||||||
|
$nextActionUrl = EvidenceSnapshotResource::getUrl('view', ['record' => $review->evidenceSnapshot], tenant: $review->tenant);
|
||||||
}
|
}
|
||||||
|
|
||||||
return $this->makeEnvelope(
|
return $this->makeEnvelope(
|
||||||
@ -514,9 +527,11 @@ private function buildTenantReviewEnvelope(TenantReview $review): ArtifactTruthE
|
|||||||
nextActionLabel: $this->nextActionLabel(
|
nextActionLabel: $this->nextActionLabel(
|
||||||
$actionability,
|
$actionability,
|
||||||
$reason,
|
$reason,
|
||||||
match ($actionability) {
|
match (true) {
|
||||||
'required' => 'Resolve the review blockers before publication',
|
$publicationReadiness === 'blocked' => 'Resolve the review blockers before publication',
|
||||||
'optional' => 'Complete the remaining review work before publication',
|
$freshnessState === 'stale' => 'Refresh the evidence basis before publishing this review',
|
||||||
|
$contentState === 'partial' => 'Complete the evidence basis before publishing this review',
|
||||||
|
$actionability === 'optional' => 'Complete the remaining review work before publication',
|
||||||
default => null,
|
default => null,
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
@ -568,14 +583,18 @@ public function forReviewPackFresh(ReviewPack $pack): ArtifactTruthEnvelope
|
|||||||
|
|
||||||
private function buildReviewPackEnvelope(ReviewPack $pack): ArtifactTruthEnvelope
|
private function buildReviewPackEnvelope(ReviewPack $pack): ArtifactTruthEnvelope
|
||||||
{
|
{
|
||||||
$pack->loadMissing(['tenant', 'tenantReview']);
|
$pack->loadMissing(['tenant', 'tenantReview', 'evidenceSnapshot']);
|
||||||
|
|
||||||
$summary = is_array($pack->summary) ? $pack->summary : [];
|
$summary = is_array($pack->summary) ? $pack->summary : [];
|
||||||
$status = (string) $pack->status;
|
$status = (string) $pack->status;
|
||||||
$evidenceResolution = is_array($summary['evidence_resolution'] ?? null) ? $summary['evidence_resolution'] : [];
|
$evidenceResolution = is_array($summary['evidence_resolution'] ?? null) ? $summary['evidence_resolution'] : [];
|
||||||
$sourceReview = $pack->tenantReview;
|
$sourceReview = $pack->tenantReview;
|
||||||
|
$sourceReviewTruth = $sourceReview instanceof TenantReview
|
||||||
|
? $this->forTenantReviewFresh($sourceReview)
|
||||||
|
: null;
|
||||||
$sourceBlockers = $sourceReview instanceof TenantReview ? $sourceReview->publishBlockers() : [];
|
$sourceBlockers = $sourceReview instanceof TenantReview ? $sourceReview->publishBlockers() : [];
|
||||||
$sourceReviewStatus = $sourceReview instanceof TenantReview ? $sourceReview->statusEnum() : null;
|
$sourceReviewStatus = $sourceReview instanceof TenantReview ? $sourceReview->statusEnum() : null;
|
||||||
|
$sourceEvidence = $this->evidenceTrustBurden($pack->evidenceSnapshot);
|
||||||
|
|
||||||
$artifactExistence = match ($status) {
|
$artifactExistence = match ($status) {
|
||||||
ReviewPackStatus::Queued->value, ReviewPackStatus::Generating->value => 'not_created',
|
ReviewPackStatus::Queued->value, ReviewPackStatus::Generating->value => 'not_created',
|
||||||
@ -588,23 +607,31 @@ private function buildReviewPackEnvelope(ReviewPack $pack): ArtifactTruthEnvelop
|
|||||||
$artifactExistence === 'not_created' => 'missing_input',
|
$artifactExistence === 'not_created' => 'missing_input',
|
||||||
$status === ReviewPackStatus::Failed->value => 'missing_input',
|
$status === ReviewPackStatus::Failed->value => 'missing_input',
|
||||||
($evidenceResolution['outcome'] ?? null) !== 'resolved' => 'missing_input',
|
($evidenceResolution['outcome'] ?? null) !== 'resolved' => 'missing_input',
|
||||||
$sourceReview instanceof TenantReview && $sourceBlockers !== [] => 'partial',
|
$sourceReviewTruth?->contentState === 'missing_input' => 'missing_input',
|
||||||
|
$sourceReviewTruth?->contentState === 'partial' || $sourceEvidence['isPartial'] => 'partial',
|
||||||
default => 'trusted',
|
default => 'trusted',
|
||||||
};
|
};
|
||||||
|
|
||||||
$freshnessState = $artifactExistence === 'historical_only' ? 'stale' : 'current';
|
$freshnessState = match (true) {
|
||||||
|
$artifactExistence === 'historical_only' => 'stale',
|
||||||
|
$sourceReviewTruth?->freshnessState === 'stale' || $sourceEvidence['isStale'] => 'stale',
|
||||||
|
default => 'current',
|
||||||
|
};
|
||||||
|
|
||||||
$publicationReadiness = match (true) {
|
$publicationReadiness = match (true) {
|
||||||
$artifactExistence === 'historical_only' => 'internal_only',
|
$artifactExistence === 'historical_only' => 'internal_only',
|
||||||
$artifactExistence === 'not_created' => 'blocked',
|
$artifactExistence === 'not_created' => 'blocked',
|
||||||
$status === ReviewPackStatus::Failed->value => 'blocked',
|
$status === ReviewPackStatus::Failed->value => 'blocked',
|
||||||
$sourceReview instanceof TenantReview && $sourceBlockers !== [] => 'blocked',
|
$sourceReviewTruth?->publicationReadiness === 'blocked' => 'blocked',
|
||||||
|
$sourceReviewTruth?->publicationReadiness === 'internal_only' => 'internal_only',
|
||||||
|
$freshnessState === 'stale' || $contentState === 'partial' => 'internal_only',
|
||||||
$sourceReviewStatus === TenantReviewStatus::Draft || $sourceReviewStatus === TenantReviewStatus::Failed => 'internal_only',
|
$sourceReviewStatus === TenantReviewStatus::Draft || $sourceReviewStatus === TenantReviewStatus::Failed => 'internal_only',
|
||||||
default => $status === ReviewPackStatus::Ready->value ? 'publishable' : 'blocked',
|
default => $status === ReviewPackStatus::Ready->value ? 'publishable' : 'blocked',
|
||||||
};
|
};
|
||||||
|
|
||||||
$actionability = match (true) {
|
$actionability = match (true) {
|
||||||
$artifactExistence === 'historical_only' => 'none',
|
$artifactExistence === 'historical_only' => 'none',
|
||||||
$publicationReadiness === 'publishable' => 'none',
|
$publicationReadiness === 'publishable' && $freshnessState === 'current' => 'none',
|
||||||
$publicationReadiness === 'internal_only' => 'optional',
|
$publicationReadiness === 'internal_only' => 'optional',
|
||||||
default => 'required',
|
default => 'required',
|
||||||
};
|
};
|
||||||
@ -612,7 +639,7 @@ private function buildReviewPackEnvelope(ReviewPack $pack): ArtifactTruthEnvelop
|
|||||||
$reasonCode = match (true) {
|
$reasonCode = match (true) {
|
||||||
$status === ReviewPackStatus::Failed->value => 'review_pack_generation_failed',
|
$status === ReviewPackStatus::Failed->value => 'review_pack_generation_failed',
|
||||||
($evidenceResolution['outcome'] ?? null) !== 'resolved' => 'review_pack_missing_snapshot',
|
($evidenceResolution['outcome'] ?? null) !== 'resolved' => 'review_pack_missing_snapshot',
|
||||||
$sourceReview instanceof TenantReview && $sourceBlockers !== [] => 'review_pack_source_not_publishable',
|
$sourceReviewTruth?->publicationReadiness === 'blocked' || $sourceReviewTruth?->publicationReadiness === 'internal_only' => 'review_pack_source_not_publishable',
|
||||||
$artifactExistence === 'historical_only' => 'review_pack_expired',
|
$artifactExistence === 'historical_only' => 'review_pack_expired',
|
||||||
default => null,
|
default => null,
|
||||||
};
|
};
|
||||||
@ -632,7 +659,11 @@ private function buildReviewPackEnvelope(ReviewPack $pack): ArtifactTruthEnvelop
|
|||||||
$publicationReadiness === 'internal_only' => [
|
$publicationReadiness === 'internal_only' => [
|
||||||
BadgeDomain::GovernanceArtifactPublicationReadiness,
|
BadgeDomain::GovernanceArtifactPublicationReadiness,
|
||||||
'internal_only',
|
'internal_only',
|
||||||
'This pack can be reviewed internally, but the source review is not currently publishable.',
|
match (true) {
|
||||||
|
$freshnessState === 'stale' => 'This pack is downloadable, but the source review relies on stale evidence and should stay internal until refreshed.',
|
||||||
|
$contentState === 'partial' => 'This pack is downloadable, but the source review relies on partial evidence and should stay internal until the evidence basis is completed.',
|
||||||
|
default => 'This pack can be reviewed internally, but the source review is not currently publishable.',
|
||||||
|
},
|
||||||
],
|
],
|
||||||
default => [
|
default => [
|
||||||
BadgeDomain::GovernanceArtifactPublicationReadiness,
|
BadgeDomain::GovernanceArtifactPublicationReadiness,
|
||||||
@ -645,6 +676,8 @@ private function buildReviewPackEnvelope(ReviewPack $pack): ArtifactTruthEnvelop
|
|||||||
|
|
||||||
if ($sourceReview instanceof TenantReview && $pack->tenant !== null) {
|
if ($sourceReview instanceof TenantReview && $pack->tenant !== null) {
|
||||||
$nextActionUrl = TenantReviewResource::tenantScopedUrl('view', ['record' => $sourceReview], $pack->tenant);
|
$nextActionUrl = TenantReviewResource::tenantScopedUrl('view', ['record' => $sourceReview], $pack->tenant);
|
||||||
|
} elseif (($freshnessState === 'stale' || $contentState === 'partial') && $pack->tenant !== null && $pack->evidenceSnapshot !== null) {
|
||||||
|
$nextActionUrl = EvidenceSnapshotResource::getUrl('view', ['record' => $pack->evidenceSnapshot], tenant: $pack->tenant);
|
||||||
} elseif ($pack->operation_run_id !== null) {
|
} elseif ($pack->operation_run_id !== null) {
|
||||||
$nextActionUrl = OperationRunLinks::tenantlessView((int) $pack->operation_run_id);
|
$nextActionUrl = OperationRunLinks::tenantlessView((int) $pack->operation_run_id);
|
||||||
}
|
}
|
||||||
@ -671,9 +704,11 @@ private function buildReviewPackEnvelope(ReviewPack $pack): ArtifactTruthEnvelop
|
|||||||
nextActionLabel: $this->nextActionLabel(
|
nextActionLabel: $this->nextActionLabel(
|
||||||
$actionability,
|
$actionability,
|
||||||
$reason,
|
$reason,
|
||||||
match ($actionability) {
|
match (true) {
|
||||||
'required' => 'Open the source review before sharing this pack',
|
$publicationReadiness === 'blocked' => 'Open the source review before sharing this pack',
|
||||||
'optional' => 'Review the source review before sharing this pack',
|
$freshnessState === 'stale' => 'Refresh the source review before sharing this pack',
|
||||||
|
$contentState === 'partial' => 'Complete the source review before sharing this pack',
|
||||||
|
$actionability === 'optional' => 'Review the source review before sharing this pack',
|
||||||
default => null,
|
default => null,
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
@ -704,6 +739,30 @@ private function buildReviewPackEnvelope(ReviewPack $pack): ArtifactTruthEnvelop
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array{isMissing: bool, isPartial: bool, isStale: bool}
|
||||||
|
*/
|
||||||
|
private function evidenceTrustBurden(?EvidenceSnapshot $snapshot): array
|
||||||
|
{
|
||||||
|
if (! $snapshot instanceof EvidenceSnapshot) {
|
||||||
|
return [
|
||||||
|
'isMissing' => false,
|
||||||
|
'isPartial' => false,
|
||||||
|
'isStale' => false,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
$summary = is_array($snapshot->summary) ? $snapshot->summary : [];
|
||||||
|
$completeness = $snapshot->completenessState();
|
||||||
|
$staleDimensions = (int) ($summary['stale_dimensions'] ?? 0);
|
||||||
|
|
||||||
|
return [
|
||||||
|
'isMissing' => $completeness === EvidenceCompletenessState::Missing,
|
||||||
|
'isPartial' => $completeness === EvidenceCompletenessState::Partial,
|
||||||
|
'isStale' => $completeness === EvidenceCompletenessState::Stale || $staleDimensions > 0,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
public function forOperationRun(OperationRun $run): ArtifactTruthEnvelope
|
public function forOperationRun(OperationRun $run): ArtifactTruthEnvelope
|
||||||
{
|
{
|
||||||
return $this->resolveEnvelope(
|
return $this->resolveEnvelope(
|
||||||
@ -808,7 +867,7 @@ private function buildOperationRunEnvelope(OperationRun $run): ArtifactTruthEnve
|
|||||||
reason: ArtifactTruthCause::fromReasonResolutionEnvelope($reason, ReasonPresenter::GOVERNANCE_ARTIFACT_TRUTH_ARTIFACT),
|
reason: ArtifactTruthCause::fromReasonResolutionEnvelope($reason, ReasonPresenter::GOVERNANCE_ARTIFACT_TRUTH_ARTIFACT),
|
||||||
nextActionLabel: $reason?->firstNextStep()?->label
|
nextActionLabel: $reason?->firstNextStep()?->label
|
||||||
?? ($actionability === 'required'
|
?? ($actionability === 'required'
|
||||||
? 'Inspect the blocked operation details before retrying'
|
? 'Inspect the blocked run details before retrying'
|
||||||
: 'Wait for the artifact-producing operation to finish'),
|
: 'Wait for the artifact-producing operation to finish'),
|
||||||
nextActionUrl: null,
|
nextActionUrl: null,
|
||||||
relatedRunId: (int) $run->getKey(),
|
relatedRunId: (int) $run->getKey(),
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@ -0,0 +1,140 @@
|
|||||||
|
@php
|
||||||
|
use App\Support\Badges\BadgeCatalog;
|
||||||
|
use App\Support\Badges\BadgeDomain;
|
||||||
|
use App\Support\Badges\TagBadgeCatalog;
|
||||||
|
use App\Support\Badges\TagBadgeDomain;
|
||||||
|
|
||||||
|
$rows = array_values(array_filter($rows ?? [], 'is_array'));
|
||||||
|
$summary = is_array($summary ?? null) ? $summary : [];
|
||||||
|
$followUpRows = array_values(array_filter($rows, static fn (array $row): bool => (bool) ($row['followUpRequired'] ?? false)));
|
||||||
|
$topFollowUp = $followUpRows[0] ?? null;
|
||||||
|
@endphp
|
||||||
|
|
||||||
|
<div class="space-y-4">
|
||||||
|
<div class="grid gap-3 sm:grid-cols-2 xl:grid-cols-4">
|
||||||
|
<div class="rounded-2xl border border-gray-200 bg-white px-4 py-4 shadow-sm dark:border-gray-800 dark:bg-gray-900/60">
|
||||||
|
<div class="text-xs font-medium uppercase tracking-[0.18em] text-gray-500 dark:text-gray-400">
|
||||||
|
Types in run
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-2 text-2xl font-semibold text-gray-950 dark:text-white">
|
||||||
|
{{ (int) ($summary['totalTypes'] ?? count($rows)) }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-2 text-sm text-gray-600 dark:text-gray-300">
|
||||||
|
Succeeded: {{ (int) ($summary['succeededTypes'] ?? 0) }}. Failed: {{ (int) ($summary['failedTypes'] ?? 0) }}. Skipped: {{ (int) ($summary['skippedTypes'] ?? 0) }}.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="rounded-2xl border border-gray-200 bg-white px-4 py-4 shadow-sm dark:border-gray-800 dark:bg-gray-900/60">
|
||||||
|
<div class="text-xs font-medium uppercase tracking-[0.18em] text-gray-500 dark:text-gray-400">
|
||||||
|
Need follow-up
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-2 text-2xl font-semibold text-gray-950 dark:text-white">
|
||||||
|
{{ (int) ($summary['followUpTypes'] ?? count($followUpRows)) }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-2 text-sm text-gray-600 dark:text-gray-300">
|
||||||
|
Execution outcome stays separate from the per-type results below.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="rounded-2xl border border-gray-200 bg-white px-4 py-4 shadow-sm dark:border-gray-800 dark:bg-gray-900/60">
|
||||||
|
<div class="text-xs font-medium uppercase tracking-[0.18em] text-gray-500 dark:text-gray-400">
|
||||||
|
Observed items
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-2 text-2xl font-semibold text-gray-950 dark:text-white">
|
||||||
|
{{ (int) ($summary['observedItems'] ?? 0) }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-2 text-sm text-gray-600 dark:text-gray-300">
|
||||||
|
Item counts show what this run observed for the listed types.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="rounded-2xl border border-gray-200 bg-white px-4 py-4 shadow-sm dark:border-gray-800 dark:bg-gray-900/60">
|
||||||
|
<div class="text-xs font-medium uppercase tracking-[0.18em] text-gray-500 dark:text-gray-400">
|
||||||
|
Run outcome
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-2">
|
||||||
|
<x-filament::badge :color="$runOutcomeColor ?? 'gray'" :icon="$runOutcomeIcon ?? null">
|
||||||
|
{{ $runOutcomeLabel ?? 'Unknown' }}
|
||||||
|
</x-filament::badge>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-2 text-sm text-gray-600 dark:text-gray-300">
|
||||||
|
Coverage truth below explains which types created the follow-up.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if ($topFollowUp !== null)
|
||||||
|
<div class="rounded-2xl border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-900 dark:border-amber-900/60 dark:bg-amber-950/30 dark:text-amber-100">
|
||||||
|
Highest-priority follow-up: {{ $topFollowUp['label'] ?? ($topFollowUp['type'] ?? 'Unknown type') }}. {{ $topFollowUp['followUpGuidance'] ?? 'Review the latest inventory sync details before retrying.' }}
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
|
||||||
|
<div class="space-y-3">
|
||||||
|
@foreach ($rows as $row)
|
||||||
|
@php
|
||||||
|
$typeSpec = TagBadgeCatalog::spec(TagBadgeDomain::PolicyType, $row['type'] ?? null);
|
||||||
|
$categorySpec = TagBadgeCatalog::spec(TagBadgeDomain::PolicyCategory, $row['category'] ?? null);
|
||||||
|
$stateSpec = BadgeCatalog::spec(BadgeDomain::InventoryCoverageState, $row['coverageState'] ?? null);
|
||||||
|
@endphp
|
||||||
|
|
||||||
|
<div class="rounded-2xl border border-gray-200 bg-white px-4 py-4 shadow-sm dark:border-gray-800 dark:bg-gray-900/60">
|
||||||
|
<div class="flex flex-wrap items-start justify-between gap-3">
|
||||||
|
<div class="min-w-0 space-y-2">
|
||||||
|
<div class="flex flex-wrap items-center gap-2">
|
||||||
|
<x-filament::badge :color="$typeSpec->color" :icon="$typeSpec->icon">
|
||||||
|
{{ $typeSpec->label }}
|
||||||
|
</x-filament::badge>
|
||||||
|
|
||||||
|
<x-filament::badge :color="$stateSpec->color" :icon="$stateSpec->icon">
|
||||||
|
{{ $stateSpec->label }}
|
||||||
|
</x-filament::badge>
|
||||||
|
|
||||||
|
<x-filament::badge :color="$categorySpec->color" :icon="$categorySpec->icon">
|
||||||
|
{{ $categorySpec->label }}
|
||||||
|
</x-filament::badge>
|
||||||
|
|
||||||
|
<x-filament::badge color="{{ ($row['segment'] ?? 'policy') === 'foundation' ? 'gray' : 'info' }}">
|
||||||
|
{{ ($row['segment'] ?? 'policy') === 'foundation' ? 'Foundation' : 'Policy' }}
|
||||||
|
</x-filament::badge>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="text-sm font-medium text-gray-900 dark:text-white">
|
||||||
|
{{ $row['label'] ?? ($row['type'] ?? 'Unknown type') }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
{{ $row['type'] ?? 'unknown' }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="rounded-xl bg-gray-50 px-3 py-2 text-right dark:bg-gray-950/60">
|
||||||
|
<div class="text-[11px] font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">
|
||||||
|
Observed items
|
||||||
|
</div>
|
||||||
|
<div class="mt-1 text-lg font-semibold text-gray-950 dark:text-white">
|
||||||
|
{{ (int) ($row['itemCount'] ?? 0) }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-3 text-sm text-gray-700 dark:text-gray-200">
|
||||||
|
{{ $row['followUpGuidance'] ?? 'No follow-up is currently required.' }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (filled($row['errorCode'] ?? null))
|
||||||
|
<div class="mt-2 text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
Reason code: {{ $row['errorCode'] }}
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
@endforeach
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@ -2,17 +2,24 @@
|
|||||||
$state = $getState();
|
$state = $getState();
|
||||||
$state = is_array($state) ? $state : [];
|
$state = is_array($state) ? $state : [];
|
||||||
|
|
||||||
$connectionState = is_string($state['state'] ?? null) ? (string) $state['state'] : 'needs_action';
|
$connectionState = is_string($state['state'] ?? null) ? (string) $state['state'] : 'missing';
|
||||||
$ctaUrl = is_string($state['cta_url'] ?? null) ? (string) $state['cta_url'] : '#';
|
$ctaUrl = is_string($state['cta_url'] ?? null) ? (string) $state['cta_url'] : '#';
|
||||||
|
$needsDefaultConnection = (bool) ($state['needs_default_connection'] ?? false);
|
||||||
|
|
||||||
$displayName = is_string($state['display_name'] ?? null) ? (string) $state['display_name'] : null;
|
$displayName = is_string($state['display_name'] ?? null) ? (string) $state['display_name'] : null;
|
||||||
$provider = is_string($state['provider'] ?? null) ? (string) $state['provider'] : null;
|
$provider = is_string($state['provider'] ?? null) ? (string) $state['provider'] : null;
|
||||||
|
$consentStatus = is_string($state['consent_status'] ?? null) ? (string) $state['consent_status'] : null;
|
||||||
|
$verificationStatus = is_string($state['verification_status'] ?? null) ? (string) $state['verification_status'] : null;
|
||||||
$status = is_string($state['status'] ?? null) ? (string) $state['status'] : null;
|
$status = is_string($state['status'] ?? null) ? (string) $state['status'] : null;
|
||||||
$healthStatus = is_string($state['health_status'] ?? null) ? (string) $state['health_status'] : null;
|
$healthStatus = is_string($state['health_status'] ?? null) ? (string) $state['health_status'] : null;
|
||||||
$lastCheck = is_string($state['last_health_check_at'] ?? null) ? (string) $state['last_health_check_at'] : null;
|
$lastCheck = is_string($state['last_health_check_at'] ?? null) ? (string) $state['last_health_check_at'] : null;
|
||||||
$lastErrorReason = is_string($state['last_error_reason_code'] ?? null) ? (string) $state['last_error_reason_code'] : null;
|
$lastErrorReason = is_string($state['last_error_reason_code'] ?? null) ? (string) $state['last_error_reason_code'] : null;
|
||||||
|
|
||||||
$isMissing = $connectionState === 'needs_action';
|
$isMissing = $connectionState === 'missing';
|
||||||
|
$consentSpec = \App\Support\Badges\BadgeRenderer::spec(\App\Support\Badges\BadgeDomain::ProviderConsentStatus, $consentStatus);
|
||||||
|
$verificationSpec = \App\Support\Badges\BadgeRenderer::spec(\App\Support\Badges\BadgeDomain::ProviderVerificationStatus, $verificationStatus);
|
||||||
|
$legacyStatusSpec = \App\Support\Badges\BadgeRenderer::spec(\App\Support\Badges\BadgeDomain::ProviderConnectionStatus, $status);
|
||||||
|
$legacyHealthSpec = \App\Support\Badges\BadgeRenderer::spec(\App\Support\Badges\BadgeDomain::ProviderConnectionHealth, $healthStatus);
|
||||||
@endphp
|
@endphp
|
||||||
|
|
||||||
<div class="space-y-3 rounded-md border border-gray-200 bg-white p-4 shadow-sm">
|
<div class="space-y-3 rounded-md border border-gray-200 bg-white p-4 shadow-sm">
|
||||||
@ -20,7 +27,9 @@
|
|||||||
<div>
|
<div>
|
||||||
<div class="text-sm font-semibold text-gray-800">Provider connection</div>
|
<div class="text-sm font-semibold text-gray-800">Provider connection</div>
|
||||||
@if ($isMissing)
|
@if ($isMissing)
|
||||||
<div class="mt-1 text-sm text-amber-700">Needs action: no default Microsoft provider connection is configured.</div>
|
<div class="mt-1 text-sm text-amber-700">Needs action: no Microsoft provider connection is configured.</div>
|
||||||
|
@elseif ($needsDefaultConnection)
|
||||||
|
<div class="mt-1 text-sm text-amber-700">Needs action: set a default Microsoft provider connection.</div>
|
||||||
@else
|
@else
|
||||||
<div class="mt-1 text-sm text-gray-700">{{ $displayName ?? 'Unnamed connection' }}</div>
|
<div class="mt-1 text-sm text-gray-700">{{ $displayName ?? 'Unnamed connection' }}</div>
|
||||||
@endif
|
@endif
|
||||||
@ -32,18 +41,32 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
@unless ($isMissing)
|
@unless ($isMissing)
|
||||||
|
@if ($needsDefaultConnection && $displayName)
|
||||||
|
<div class="text-sm text-gray-700">
|
||||||
|
Current connection: <span class="font-medium">{{ $displayName }}</span>
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
|
||||||
<dl class="grid grid-cols-1 gap-2 text-sm text-gray-700 sm:grid-cols-2">
|
<dl class="grid grid-cols-1 gap-2 text-sm text-gray-700 sm:grid-cols-2">
|
||||||
<div>
|
<div>
|
||||||
<dt class="text-xs uppercase tracking-wide text-gray-500">Provider</dt>
|
<dt class="text-xs uppercase tracking-wide text-gray-500">Provider</dt>
|
||||||
<dd>{{ $provider ?? 'n/a' }}</dd>
|
<dd>{{ $provider ?? 'n/a' }}</dd>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<dt class="text-xs uppercase tracking-wide text-gray-500">Status</dt>
|
<dt class="text-xs uppercase tracking-wide text-gray-500">Consent</dt>
|
||||||
<dd>{{ $status ?? 'n/a' }}</dd>
|
<dd>
|
||||||
|
<x-filament::badge :color="$consentSpec->color" :icon="$consentSpec->icon" size="sm">
|
||||||
|
{{ $consentSpec->label }}
|
||||||
|
</x-filament::badge>
|
||||||
|
</dd>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<dt class="text-xs uppercase tracking-wide text-gray-500">Health</dt>
|
<dt class="text-xs uppercase tracking-wide text-gray-500">Verification</dt>
|
||||||
<dd>{{ $healthStatus ?? 'n/a' }}</dd>
|
<dd>
|
||||||
|
<x-filament::badge :color="$verificationSpec->color" :icon="$verificationSpec->icon" size="sm">
|
||||||
|
{{ $verificationSpec->label }}
|
||||||
|
</x-filament::badge>
|
||||||
|
</dd>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<dt class="text-xs uppercase tracking-wide text-gray-500">Last check</dt>
|
<dt class="text-xs uppercase tracking-wide text-gray-500">Last check</dt>
|
||||||
@ -51,10 +74,32 @@
|
|||||||
</div>
|
</div>
|
||||||
</dl>
|
</dl>
|
||||||
|
|
||||||
|
<div class="space-y-2 rounded-md border border-gray-200 bg-gray-50 p-3 text-sm text-gray-700">
|
||||||
|
<div class="text-xs font-semibold uppercase tracking-wide text-gray-500">Diagnostics</div>
|
||||||
|
<dl class="grid grid-cols-1 gap-2 sm:grid-cols-2">
|
||||||
|
<div>
|
||||||
|
<dt class="text-xs uppercase tracking-wide text-gray-500">Legacy status</dt>
|
||||||
|
<dd>
|
||||||
|
<x-filament::badge :color="$legacyStatusSpec->color" :icon="$legacyStatusSpec->icon" size="sm">
|
||||||
|
{{ $legacyStatusSpec->label }}
|
||||||
|
</x-filament::badge>
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt class="text-xs uppercase tracking-wide text-gray-500">Legacy health</dt>
|
||||||
|
<dd>
|
||||||
|
<x-filament::badge :color="$legacyHealthSpec->color" :icon="$legacyHealthSpec->icon" size="sm">
|
||||||
|
{{ $legacyHealthSpec->label }}
|
||||||
|
</x-filament::badge>
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
</dl>
|
||||||
|
|
||||||
@if ($lastErrorReason)
|
@if ($lastErrorReason)
|
||||||
<div class="rounded-md border border-amber-300 bg-amber-50 p-2 text-xs text-amber-800">
|
<div class="rounded-md border border-amber-300 bg-amber-50 p-2 text-xs text-amber-800">
|
||||||
Last error reason: {{ $lastErrorReason }}
|
Last error reason: {{ $lastErrorReason }}
|
||||||
</div>
|
</div>
|
||||||
@endif
|
@endif
|
||||||
|
</div>
|
||||||
@endunless
|
@endunless
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -1,16 +1,121 @@
|
|||||||
<x-filament-panels::page>
|
<x-filament-panels::page>
|
||||||
|
@php
|
||||||
|
$summary = $this->coverageSummary();
|
||||||
|
$basis = $this->basisRunSummary();
|
||||||
|
@endphp
|
||||||
|
|
||||||
<x-filament::section>
|
<x-filament::section>
|
||||||
<div class="flex flex-col gap-3">
|
<div class="grid gap-4 xl:grid-cols-[minmax(0,1.8fr)_minmax(0,1fr)]">
|
||||||
<div class="text-lg font-semibold text-gray-900 dark:text-gray-100">
|
<div class="space-y-4">
|
||||||
Searchable support matrix
|
<div class="space-y-2">
|
||||||
|
<div class="text-lg font-semibold text-gray-900 dark:text-white">
|
||||||
|
Tenant coverage truth
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="text-sm text-gray-600 dark:text-gray-300">
|
<div class="text-sm text-gray-600 dark:text-gray-300">
|
||||||
Search by policy type or label, sort the primary columns, and filter the runtime-derived coverage matrix without leaving the tenant inventory workspace.
|
This report shows which supported inventory types are currently covered for the active tenant, which ones still need follow-up, and what the statement is based on.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid gap-3 sm:grid-cols-3">
|
||||||
|
<div class="rounded-2xl border border-gray-200 bg-white px-4 py-4 shadow-sm dark:border-gray-800 dark:bg-gray-900/60">
|
||||||
|
<div class="text-xs font-medium uppercase tracking-[0.18em] text-gray-500 dark:text-gray-400">
|
||||||
|
Covered types
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-2 text-2xl font-semibold text-gray-950 dark:text-white">
|
||||||
|
{{ $summary['succeededTypes'] ?? 0 }} / {{ $summary['supportedTypes'] ?? 0 }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-2 text-sm text-gray-600 dark:text-gray-300">
|
||||||
|
Current supported types with a successful basis result.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="rounded-2xl border border-gray-200 bg-white px-4 py-4 shadow-sm dark:border-gray-800 dark:bg-gray-900/60">
|
||||||
|
<div class="text-xs font-medium uppercase tracking-[0.18em] text-gray-500 dark:text-gray-400">
|
||||||
|
Need follow-up
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-2 text-2xl font-semibold text-gray-950 dark:text-white">
|
||||||
|
{{ $summary['followUpTypes'] ?? 0 }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-2 text-sm text-gray-600 dark:text-gray-300">
|
||||||
|
@if (filled($summary['topFollowUpLabel'] ?? null))
|
||||||
|
Highest-priority type: {{ $summary['topFollowUpLabel'] }}.
|
||||||
|
@else
|
||||||
|
No follow-up types are currently highlighted.
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="rounded-2xl border border-gray-200 bg-white px-4 py-4 shadow-sm dark:border-gray-800 dark:bg-gray-900/60">
|
||||||
|
<div class="text-xs font-medium uppercase tracking-[0.18em] text-gray-500 dark:text-gray-400">
|
||||||
|
Observed items
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-2 text-2xl font-semibold text-gray-950 dark:text-white">
|
||||||
|
{{ $summary['observedItems'] ?? 0 }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-2 text-sm text-gray-600 dark:text-gray-300">
|
||||||
|
{{ $summary['observedTypes'] ?? 0 }} supported types currently have observed inventory rows.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (filled($summary['topFollowUpGuidance'] ?? null))
|
||||||
|
<div class="rounded-2xl border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-900 dark:border-amber-900/60 dark:bg-amber-950/30 dark:text-amber-100">
|
||||||
|
{{ $summary['topFollowUpGuidance'] }}
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="rounded-2xl border border-gray-200 bg-white px-4 py-4 shadow-sm dark:border-gray-800 dark:bg-gray-900/60">
|
||||||
|
<div class="space-y-3">
|
||||||
|
<div class="flex flex-wrap items-start justify-between gap-3">
|
||||||
|
<div class="space-y-1">
|
||||||
|
<div class="text-xs font-medium uppercase tracking-[0.18em] text-gray-500 dark:text-gray-400">
|
||||||
|
Coverage basis
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="text-base font-semibold text-gray-950 dark:text-white">
|
||||||
|
{{ $basis['title'] ?? 'No current coverage basis' }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (filled($basis['badgeLabel'] ?? null))
|
||||||
|
<x-filament::badge :color="$basis['badgeColor'] ?? 'gray'" size="sm">
|
||||||
|
{{ $basis['badgeLabel'] }}
|
||||||
|
</x-filament::badge>
|
||||||
|
@endif
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="text-sm text-gray-600 dark:text-gray-300">
|
<div class="text-sm text-gray-600 dark:text-gray-300">
|
||||||
Coverage rows combine supported policy types and foundations in a single read-only table so Segment and Dependencies stay easy to scan.
|
{{ $basis['body'] ?? 'No current coverage basis is available.' }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex flex-wrap items-center gap-3">
|
||||||
|
@if (filled($basis['runUrl'] ?? null))
|
||||||
|
<x-filament::link :href="$basis['runUrl']" size="sm">
|
||||||
|
Open basis run
|
||||||
|
</x-filament::link>
|
||||||
|
@endif
|
||||||
|
|
||||||
|
@if (filled($basis['historyUrl'] ?? null))
|
||||||
|
<x-filament::link :href="$basis['historyUrl']" size="sm">
|
||||||
|
Inventory sync history
|
||||||
|
</x-filament::link>
|
||||||
|
@endif
|
||||||
|
|
||||||
|
@if (filled($basis['inventoryItemsUrl'] ?? null))
|
||||||
|
<x-filament::link :href="$basis['inventoryItemsUrl']" size="sm">
|
||||||
|
Open inventory items
|
||||||
|
</x-filament::link>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</x-filament::section>
|
</x-filament::section>
|
||||||
|
|||||||
@ -18,7 +18,7 @@
|
|||||||
:active="$this->activeTab === 'blocked'"
|
:active="$this->activeTab === 'blocked'"
|
||||||
wire:click="$set('activeTab', 'blocked')"
|
wire:click="$set('activeTab', 'blocked')"
|
||||||
>
|
>
|
||||||
Blocked by prerequisite
|
Needs follow-up
|
||||||
</x-filament::tabs.item>
|
</x-filament::tabs.item>
|
||||||
<x-filament::tabs.item
|
<x-filament::tabs.item
|
||||||
:active="$this->activeTab === 'succeeded'"
|
:active="$this->activeTab === 'succeeded'"
|
||||||
|
|||||||
@ -26,7 +26,7 @@
|
|||||||
</h1>
|
</h1>
|
||||||
|
|
||||||
<p class="max-w-2xl text-sm leading-6 text-gray-600 dark:text-gray-300">
|
<p class="max-w-2xl text-sm leading-6 text-gray-600 dark:text-gray-300">
|
||||||
This home stays workspace-scoped even when you were previously working in a tenant. Tenant drill-down remains explicit so the overview never silently narrows itself.
|
This home stays workspace-scoped even when you were previously working in a tenant. Governance risk is ranked ahead of execution noise, and calm wording only appears when the checked workspace domains are genuinely quiet.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</x-filament::section>
|
</x-filament::section>
|
||||||
@ -82,6 +82,18 @@ class="rounded-xl border border-gray-200 bg-white px-4 py-3 text-left transition
|
|||||||
@endif
|
@endif
|
||||||
|
|
||||||
<div class="space-y-6">
|
<div class="space-y-6">
|
||||||
|
<div class="flex flex-wrap gap-2 text-xs text-gray-600 dark:text-gray-300">
|
||||||
|
<span class="inline-flex items-center rounded-full border border-danger-200 bg-danger-50 px-3 py-1 font-medium text-danger-700 dark:border-danger-700/50 dark:bg-danger-950/30 dark:text-danger-200">
|
||||||
|
Governance risk counts affected tenants
|
||||||
|
</span>
|
||||||
|
<span class="inline-flex items-center rounded-full border border-warning-200 bg-warning-50 px-3 py-1 font-medium text-warning-700 dark:border-warning-700/50 dark:bg-warning-950/30 dark:text-warning-200">
|
||||||
|
Activity counts execution load only
|
||||||
|
</span>
|
||||||
|
<span class="inline-flex items-center rounded-full border border-gray-200 bg-white px-3 py-1 font-medium text-gray-600 dark:border-white/10 dark:bg-white/5 dark:text-gray-300">
|
||||||
|
Recent operations stay diagnostic
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
@livewire(\App\Filament\Widgets\Workspace\WorkspaceSummaryStats::class, [
|
@livewire(\App\Filament\Widgets\Workspace\WorkspaceSummaryStats::class, [
|
||||||
'metrics' => $overview['summary_metrics'] ?? [],
|
'metrics' => $overview['summary_metrics'] ?? [],
|
||||||
], key('workspace-overview-summary-' . ($workspace['id'] ?? 'none')))
|
], key('workspace-overview-summary-' . ($workspace['id'] ?? 'none')))
|
||||||
|
|||||||
@ -120,13 +120,23 @@
|
|||||||
<div>
|
<div>
|
||||||
@if (filled($nextActionUrl))
|
@if (filled($nextActionUrl))
|
||||||
<x-filament::link :href="$nextActionUrl" size="sm" class="font-medium">
|
<x-filament::link :href="$nextActionUrl" size="sm" class="font-medium">
|
||||||
{{ $nextAction['label'] }}
|
{{ $nextActionLabel ?? $nextAction['label'] }}
|
||||||
</x-filament::link>
|
</x-filament::link>
|
||||||
|
@elseif (filled($nextActionLabel ?? null))
|
||||||
|
<div class="text-xs font-medium uppercase tracking-wide text-gray-600 dark:text-gray-300">
|
||||||
|
{{ $nextActionLabel }}
|
||||||
|
</div>
|
||||||
@elseif (filled($nextAction['label'] ?? null))
|
@elseif (filled($nextAction['label'] ?? null))
|
||||||
<div class="text-xs font-medium uppercase tracking-wide text-gray-600 dark:text-gray-300">
|
<div class="text-xs font-medium uppercase tracking-wide text-gray-600 dark:text-gray-300">
|
||||||
{{ $nextAction['label'] }}
|
{{ $nextAction['label'] }}
|
||||||
</div>
|
</div>
|
||||||
@endif
|
@endif
|
||||||
|
|
||||||
|
@if (filled($nextActionHelperText ?? null))
|
||||||
|
<div class="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
{{ $nextActionHelperText }}
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -7,7 +7,7 @@
|
|||||||
@if (count($items) === 0)
|
@if (count($items) === 0)
|
||||||
<div class="flex flex-col gap-3">
|
<div class="flex flex-col gap-3">
|
||||||
<div class="text-sm text-gray-600 dark:text-gray-300">
|
<div class="text-sm text-gray-600 dark:text-gray-300">
|
||||||
Current dashboard signals look trustworthy.
|
Current governance and findings signals look trustworthy.
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex flex-col gap-3">
|
<div class="flex flex-col gap-3">
|
||||||
@ -41,9 +41,23 @@ class="mt-0.5 h-5 w-5 text-success-600 dark:text-success-400"
|
|||||||
</div>
|
</div>
|
||||||
@endif
|
@endif
|
||||||
|
|
||||||
@if (filled($item['nextStep'] ?? null))
|
@if (filled($item['actionLabel'] ?? null))
|
||||||
<div class="mt-2 text-xs font-medium uppercase tracking-wide text-gray-500 dark:text-gray-400">
|
<div class="mt-3">
|
||||||
{{ $item['nextStep'] }}
|
@if (filled($item['actionUrl'] ?? null))
|
||||||
|
<x-filament::link :href="$item['actionUrl']" size="sm" class="font-medium">
|
||||||
|
{{ $item['actionLabel'] }}
|
||||||
|
</x-filament::link>
|
||||||
|
@else
|
||||||
|
<div class="text-xs font-medium uppercase tracking-wide text-gray-500 dark:text-gray-400">
|
||||||
|
{{ $item['actionLabel'] }}
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
|
||||||
|
@if (filled($item['helperText'] ?? null))
|
||||||
|
<div class="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
{{ $item['helperText'] }}
|
||||||
</div>
|
</div>
|
||||||
@endif
|
@endif
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -27,12 +27,12 @@
|
|||||||
|
|
||||||
<x-filament::section
|
<x-filament::section
|
||||||
heading="Verification report"
|
heading="Verification report"
|
||||||
description="Latest verification state for this tenant (DB-only rendering)."
|
description="Latest stored verification result for this tenant. Consent and connection configuration are summarized separately above."
|
||||||
>
|
>
|
||||||
<div class="space-y-4">
|
<div class="space-y-4">
|
||||||
@if ($run === null)
|
@if ($run === null)
|
||||||
<div class="rounded-lg border border-gray-200 bg-white p-4 text-sm text-gray-600 shadow-sm dark:border-gray-800 dark:bg-gray-900 dark:text-gray-300">
|
<div class="rounded-lg border border-gray-200 bg-white p-4 text-sm text-gray-600 shadow-sm dark:border-gray-800 dark:bg-gray-900 dark:text-gray-300">
|
||||||
No verification operation has been started yet.
|
No provider verification check has been recorded yet.
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
|
|||||||
@ -22,25 +22,66 @@
|
|||||||
@else
|
@else
|
||||||
<div class="space-y-3">
|
<div class="space-y-3">
|
||||||
@foreach ($items as $item)
|
@foreach ($items as $item)
|
||||||
<a
|
@php
|
||||||
href="{{ $item['url'] }}"
|
$destination = $item['destination'] ?? null;
|
||||||
class="block rounded-xl border border-gray-200 bg-gray-50 p-4 transition hover:border-gray-300 hover:bg-white dark:border-white/10 dark:bg-white/5 dark:hover:border-white/20 dark:hover:bg-white/10"
|
$actionUrl = is_array($destination) && ($destination['disabled'] ?? false) === false
|
||||||
>
|
? ($destination['url'] ?? null)
|
||||||
<div class="flex items-start justify-between gap-3">
|
: null;
|
||||||
<div class="space-y-1">
|
@endphp
|
||||||
<div class="text-sm font-semibold text-gray-950 dark:text-white">
|
|
||||||
{{ $item['title'] }}
|
<div class="rounded-xl border border-gray-200 bg-gray-50 p-4 dark:border-white/10 dark:bg-white/5">
|
||||||
</div>
|
<div class="space-y-3">
|
||||||
<div class="text-sm leading-6 text-gray-600 dark:text-gray-300">
|
<div class="flex flex-wrap items-start justify-between gap-3">
|
||||||
{{ $item['body'] }}
|
<div class="space-y-2">
|
||||||
</div>
|
<div class="flex flex-wrap items-center gap-2">
|
||||||
</div>
|
<span class="inline-flex items-center rounded-full border border-gray-200 bg-white px-2.5 py-0.5 text-xs font-medium text-gray-700 dark:border-white/10 dark:bg-white/10 dark:text-gray-200">
|
||||||
|
{{ $item['tenant_label'] }}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<span class="inline-flex items-center rounded-full border border-gray-200 bg-white px-2.5 py-0.5 text-xs font-medium uppercase tracking-wide text-gray-500 dark:border-white/10 dark:bg-white/10 dark:text-gray-300">
|
||||||
|
{{ str_replace('_', ' ', $item['urgency']) }}
|
||||||
|
</span>
|
||||||
|
|
||||||
<x-filament::badge :color="$item['badge_color']" size="sm">
|
<x-filament::badge :color="$item['badge_color']" size="sm">
|
||||||
{{ $item['badge'] }}
|
{{ $item['badge'] }}
|
||||||
</x-filament::badge>
|
</x-filament::badge>
|
||||||
</div>
|
</div>
|
||||||
</a>
|
|
||||||
|
<div class="text-sm font-semibold text-gray-950 dark:text-white">
|
||||||
|
{{ $item['title'] }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="text-sm leading-6 text-gray-600 dark:text-gray-300">
|
||||||
|
{{ $item['body'] }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (filled($item['supporting_message'] ?? null))
|
||||||
|
<p class="text-xs leading-5 text-gray-500 dark:text-gray-400">
|
||||||
|
{{ $item['supporting_message'] }}
|
||||||
|
</p>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex flex-wrap items-center gap-3 text-sm">
|
||||||
|
@if (is_string($actionUrl) && $actionUrl !== '')
|
||||||
|
<x-filament::link :href="$actionUrl" size="sm">
|
||||||
|
{{ $destination['label'] ?? 'Open' }}
|
||||||
|
</x-filament::link>
|
||||||
|
@else
|
||||||
|
<span class="text-gray-500 dark:text-gray-400">
|
||||||
|
{{ $destination['label'] ?? 'Unavailable' }}
|
||||||
|
</span>
|
||||||
|
@endif
|
||||||
|
|
||||||
|
@if (filled($item['helper_text'] ?? null))
|
||||||
|
<span class="text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
{{ $item['helper_text'] }}
|
||||||
|
</span>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@endforeach
|
@endforeach
|
||||||
</div>
|
</div>
|
||||||
@endif
|
@endif
|
||||||
|
|||||||
@ -1,4 +1,8 @@
|
|||||||
<x-filament::section heading="Recent operations">
|
<x-filament::section heading="Recent operations">
|
||||||
|
<p class="mb-4 text-xs leading-5 text-gray-500 dark:text-gray-400">
|
||||||
|
Diagnostic recency across your visible workspace slice. This does not define governance health on its own.
|
||||||
|
</p>
|
||||||
|
|
||||||
@if ($operations === [])
|
@if ($operations === [])
|
||||||
<div class="flex h-full flex-col justify-between gap-4">
|
<div class="flex h-full flex-col justify-between gap-4">
|
||||||
<div class="space-y-2">
|
<div class="space-y-2">
|
||||||
@ -22,10 +26,13 @@
|
|||||||
@else
|
@else
|
||||||
<div class="space-y-3">
|
<div class="space-y-3">
|
||||||
@foreach ($operations as $operation)
|
@foreach ($operations as $operation)
|
||||||
<a
|
@php
|
||||||
href="{{ $operation['url'] }}"
|
$destination = $operation['destination'] ?? null;
|
||||||
class="block rounded-xl border border-gray-200 bg-gray-50 p-4 transition hover:border-gray-300 hover:bg-white dark:border-white/10 dark:bg-white/5 dark:hover:border-white/20 dark:hover:bg-white/10"
|
$actionUrl = is_array($destination) ? ($destination['url'] ?? null) : ($operation['url'] ?? null);
|
||||||
>
|
@endphp
|
||||||
|
|
||||||
|
<div class="rounded-xl border border-gray-200 bg-gray-50 p-4 dark:border-white/10 dark:bg-white/5">
|
||||||
|
<div class="space-y-3">
|
||||||
<div class="flex items-start justify-between gap-3">
|
<div class="flex items-start justify-between gap-3">
|
||||||
<div class="min-w-0 space-y-2">
|
<div class="min-w-0 space-y-2">
|
||||||
<div class="flex flex-wrap items-center gap-2">
|
<div class="flex flex-wrap items-center gap-2">
|
||||||
@ -60,7 +67,16 @@ class="block rounded-xl border border-gray-200 bg-gray-50 p-4 transition hover:b
|
|||||||
{{ $operation['started_at'] }}
|
{{ $operation['started_at'] }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</a>
|
|
||||||
|
@if (is_string($actionUrl) && $actionUrl !== '')
|
||||||
|
<div>
|
||||||
|
<x-filament::link :href="$actionUrl" size="sm">
|
||||||
|
{{ $destination['label'] ?? 'Open operation' }}
|
||||||
|
</x-filament::link>
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@endforeach
|
@endforeach
|
||||||
</div>
|
</div>
|
||||||
@endif
|
@endif
|
||||||
|
|||||||
@ -0,0 +1,36 @@
|
|||||||
|
# Specification Quality Checklist: Tenant Dashboard KPI & Attention Truth Alignment
|
||||||
|
|
||||||
|
**Purpose**: Validate specification completeness and quality before proceeding to planning
|
||||||
|
**Created**: 2026-04-03
|
||||||
|
**Feature**: [spec.md](../spec.md)
|
||||||
|
|
||||||
|
## Content Quality
|
||||||
|
|
||||||
|
- [x] No implementation details (languages, frameworks, APIs)
|
||||||
|
- [x] Focused on user value and business needs
|
||||||
|
- [x] Written for non-technical stakeholders
|
||||||
|
- [x] All mandatory sections completed
|
||||||
|
|
||||||
|
## Requirement Completeness
|
||||||
|
|
||||||
|
- [x] No [NEEDS CLARIFICATION] markers remain
|
||||||
|
- [x] Requirements are testable and unambiguous
|
||||||
|
- [x] Success criteria are measurable
|
||||||
|
- [x] Success criteria are technology-agnostic (no implementation details)
|
||||||
|
- [x] All acceptance scenarios are defined
|
||||||
|
- [x] Edge cases are identified
|
||||||
|
- [x] Scope is clearly bounded
|
||||||
|
- [x] Dependencies and assumptions identified
|
||||||
|
|
||||||
|
## Feature Readiness
|
||||||
|
|
||||||
|
- [x] All functional requirements have clear acceptance criteria
|
||||||
|
- [x] User scenarios cover primary flows
|
||||||
|
- [x] Feature meets measurable outcomes defined in Success Criteria
|
||||||
|
- [x] No implementation details leak into specification
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- Validation pass 1: complete.
|
||||||
|
- The spec stays bounded to the existing tenant dashboard surface family and explicitly rejects a new global tenant-posture component or persisted dashboard aggregate.
|
||||||
|
- The main validation focus was keeping tenant-level KPI, attention, compare, and recent surfaces aligned around the same tenant truth while preserving clear separation between posture, activity, and recency.
|
||||||
@ -0,0 +1,553 @@
|
|||||||
|
openapi: 3.1.0
|
||||||
|
info:
|
||||||
|
title: Tenant Dashboard Truth Alignment Internal Surface Contract
|
||||||
|
version: 0.1.0
|
||||||
|
summary: Internal logical contract for aligned tenant dashboard KPI, attention, compare, and recency surfaces
|
||||||
|
description: |
|
||||||
|
This contract is an internal planning artifact for Spec 173. It documents how
|
||||||
|
the tenant dashboard's summary and recency surfaces must derive their meaning
|
||||||
|
from existing tenant truth and how drill-through destinations must preserve
|
||||||
|
that meaning. The rendered routes still return HTML. The structured schemas
|
||||||
|
below describe the internal page and widget models that must be derivable
|
||||||
|
before rendering. This does not add a public HTTP API.
|
||||||
|
servers:
|
||||||
|
- url: /internal
|
||||||
|
x-dashboard-consumers:
|
||||||
|
- surface: tenant.dashboard.kpis
|
||||||
|
summarySource:
|
||||||
|
- finding_status_helpers
|
||||||
|
- finding_destination_filters
|
||||||
|
- canonical_operations_links
|
||||||
|
guardScope:
|
||||||
|
- app/Filament/Widgets/Dashboard/DashboardKpis.php
|
||||||
|
expectedContract:
|
||||||
|
- each_kpi_label_matches_its_count_universe
|
||||||
|
- each_kpi_destination_reproduces_or_explicitly_broadens_the_named_subset
|
||||||
|
- surface: tenant.dashboard.needs_attention
|
||||||
|
summarySource:
|
||||||
|
- tenant_governance_aggregate
|
||||||
|
- operation_run_activity
|
||||||
|
guardScope:
|
||||||
|
- app/Filament/Widgets/Dashboard/NeedsAttention.php
|
||||||
|
expectedContract:
|
||||||
|
- each_primary_item_has_one_tenant_safe_destination
|
||||||
|
- healthy_fallback_is_hidden_when_any_attention_condition_exists
|
||||||
|
- surface: tenant.dashboard.baseline_compare_now
|
||||||
|
summarySource:
|
||||||
|
- tenant_governance_aggregate
|
||||||
|
- baseline_compare_summary_assessment
|
||||||
|
guardScope:
|
||||||
|
- app/Filament/Widgets/Dashboard/BaselineCompareNow.php
|
||||||
|
expectedContract:
|
||||||
|
- positive_compare_claims_do_not_outvote_stronger_attention_conditions
|
||||||
|
- primary_compare_destination_uses_existing_baseline_compare_landing
|
||||||
|
- surface: tenant.dashboard.recent_drift_findings
|
||||||
|
summarySource:
|
||||||
|
- recent_drift_query
|
||||||
|
guardScope:
|
||||||
|
- app/Filament/Widgets/Dashboard/RecentDriftFindings.php
|
||||||
|
expectedContract:
|
||||||
|
- surface_role_is_diagnostic_recency_not_primary_queue
|
||||||
|
- row_click_uses_canonical_finding_detail
|
||||||
|
- surface: tenant.dashboard.recent_operations
|
||||||
|
summarySource:
|
||||||
|
- recent_operations_query
|
||||||
|
- canonical_operations_links
|
||||||
|
guardScope:
|
||||||
|
- app/Filament/Widgets/Dashboard/RecentOperations.php
|
||||||
|
expectedContract:
|
||||||
|
- surface_role_is_diagnostic_recency_not_primary_queue
|
||||||
|
- row_click_uses_canonical_operation_detail
|
||||||
|
paths:
|
||||||
|
/admin/t/{tenant}:
|
||||||
|
get:
|
||||||
|
summary: Render the aligned tenant dashboard summary bundle
|
||||||
|
operationId: viewTenantDashboardAlignedTruth
|
||||||
|
parameters:
|
||||||
|
- name: tenant
|
||||||
|
in: path
|
||||||
|
required: true
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: Tenant dashboard rendered with aligned KPI, attention, compare, and recency semantics
|
||||||
|
content:
|
||||||
|
text/html:
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
application/vnd.tenantpilot.tenant-dashboard-truth+json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/TenantDashboardTruthBundle'
|
||||||
|
'404':
|
||||||
|
description: Tenant is outside workspace or tenant entitlement scope
|
||||||
|
/admin/t/{tenant}/findings:
|
||||||
|
get:
|
||||||
|
summary: Tenant findings destination used by KPI and attention drill-throughs
|
||||||
|
operationId: openTenantFindingsFromDashboard
|
||||||
|
parameters:
|
||||||
|
- name: tenant
|
||||||
|
in: path
|
||||||
|
required: true
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
- name: tab
|
||||||
|
in: query
|
||||||
|
required: false
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/FindingsTab'
|
||||||
|
- name: high_severity
|
||||||
|
in: query
|
||||||
|
required: false
|
||||||
|
schema:
|
||||||
|
type: boolean
|
||||||
|
- name: status
|
||||||
|
in: query
|
||||||
|
required: false
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
- name: finding_type
|
||||||
|
in: query
|
||||||
|
required: false
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: Tenant findings list opened with tenant-safe dashboard continuity filters
|
||||||
|
'403':
|
||||||
|
description: Actor is in scope but lacks findings inspection capability
|
||||||
|
'404':
|
||||||
|
description: Tenant is outside workspace or tenant entitlement scope
|
||||||
|
/admin/t/{tenant}/baseline-compare-landing:
|
||||||
|
get:
|
||||||
|
summary: Tenant baseline compare landing used by compare and attention drill-throughs
|
||||||
|
operationId: openTenantBaselineCompareLandingFromDashboard
|
||||||
|
parameters:
|
||||||
|
- name: tenant
|
||||||
|
in: path
|
||||||
|
required: true
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: Tenant baseline compare landing opened with the same tenant-context compare posture the dashboard summarized
|
||||||
|
'403':
|
||||||
|
description: Actor is in scope but lacks baseline compare inspection capability
|
||||||
|
'404':
|
||||||
|
description: Tenant is outside workspace or tenant entitlement scope
|
||||||
|
/admin/t/{tenant}/findings/{record}:
|
||||||
|
get:
|
||||||
|
summary: Tenant finding detail opened from recent drift row-click inspection
|
||||||
|
operationId: openTenantFindingDetailFromDashboardRecency
|
||||||
|
parameters:
|
||||||
|
- name: tenant
|
||||||
|
in: path
|
||||||
|
required: true
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
- name: record
|
||||||
|
in: path
|
||||||
|
required: true
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: Tenant finding detail opened from the recent drift diagnostic surface
|
||||||
|
'403':
|
||||||
|
description: Actor is in scope but lacks finding detail inspection capability
|
||||||
|
'404':
|
||||||
|
description: Tenant or finding is outside entitlement scope
|
||||||
|
/admin/operations:
|
||||||
|
get:
|
||||||
|
summary: Canonical operations destination with tenant-prefilter continuity from the tenant dashboard
|
||||||
|
operationId: openCanonicalOperationsFromDashboard
|
||||||
|
parameters:
|
||||||
|
- name: tenant_id
|
||||||
|
in: query
|
||||||
|
required: false
|
||||||
|
schema:
|
||||||
|
type:
|
||||||
|
- integer
|
||||||
|
- string
|
||||||
|
description: Tenant filter carried forward from tenant-context dashboard navigation.
|
||||||
|
- name: activeTab
|
||||||
|
in: query
|
||||||
|
required: false
|
||||||
|
description: Uses `active` for healthy queued or running activity, `blocked` for warning, stalled, or unusually long-running follow-up needing review, and `failed` for terminal failure follow-up.
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/OperationsTab'
|
||||||
|
- name: navigationContext
|
||||||
|
in: query
|
||||||
|
required: false
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
description: Optional serialized canonical navigation context carried from tenant dashboard drill-throughs.
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: Canonical admin operations list filtered to the originating tenant when opened from the dashboard
|
||||||
|
'403':
|
||||||
|
description: Actor is in scope but lacks operations inspection capability
|
||||||
|
'404':
|
||||||
|
description: Tenant context is outside entitlement scope
|
||||||
|
/admin/operations/{run}:
|
||||||
|
get:
|
||||||
|
summary: Canonical operation detail opened from recent operations row-click inspection
|
||||||
|
operationId: openOperationDetailFromDashboardRecency
|
||||||
|
parameters:
|
||||||
|
- name: run
|
||||||
|
in: path
|
||||||
|
required: true
|
||||||
|
schema:
|
||||||
|
type:
|
||||||
|
- integer
|
||||||
|
- string
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: Canonical operation detail opened from the recent operations diagnostic surface
|
||||||
|
'403':
|
||||||
|
description: Actor is in scope but lacks operation detail inspection capability
|
||||||
|
'404':
|
||||||
|
description: Operation run is outside entitlement scope
|
||||||
|
components:
|
||||||
|
schemas:
|
||||||
|
FindingsTab:
|
||||||
|
type: string
|
||||||
|
enum:
|
||||||
|
- all
|
||||||
|
- needs_action
|
||||||
|
- overdue
|
||||||
|
- risk_accepted
|
||||||
|
- resolved
|
||||||
|
OperationsTab:
|
||||||
|
type: string
|
||||||
|
description: Shared canonical operations tab semantics where `active` represents healthy queued or running work, `blocked` reproduces warning, stalled, or unusually long-running follow-up, and `failed` reproduces terminal failure follow-up.
|
||||||
|
enum:
|
||||||
|
- all
|
||||||
|
- active
|
||||||
|
- blocked
|
||||||
|
- succeeded
|
||||||
|
- partial
|
||||||
|
- failed
|
||||||
|
ProblemFamily:
|
||||||
|
type: string
|
||||||
|
enum:
|
||||||
|
- findings
|
||||||
|
- governance
|
||||||
|
- compare
|
||||||
|
- operations
|
||||||
|
FindingUniverse:
|
||||||
|
type: string
|
||||||
|
enum:
|
||||||
|
- new_drift_only
|
||||||
|
- open_drift
|
||||||
|
- active_findings
|
||||||
|
SeverityUniverse:
|
||||||
|
type: string
|
||||||
|
enum:
|
||||||
|
- high_only
|
||||||
|
- high_and_critical
|
||||||
|
FindingsDestinationFilterState:
|
||||||
|
type: object
|
||||||
|
additionalProperties: false
|
||||||
|
required:
|
||||||
|
- tenant
|
||||||
|
properties:
|
||||||
|
tenant:
|
||||||
|
type: string
|
||||||
|
tab:
|
||||||
|
oneOf:
|
||||||
|
- $ref: '#/components/schemas/FindingsTab'
|
||||||
|
- type: 'null'
|
||||||
|
status:
|
||||||
|
type:
|
||||||
|
- string
|
||||||
|
- 'null'
|
||||||
|
high_severity:
|
||||||
|
type:
|
||||||
|
- boolean
|
||||||
|
- 'null'
|
||||||
|
finding_type:
|
||||||
|
type:
|
||||||
|
- string
|
||||||
|
- 'null'
|
||||||
|
OperationsDestinationFilterState:
|
||||||
|
type: object
|
||||||
|
additionalProperties: false
|
||||||
|
required:
|
||||||
|
- workspace_id
|
||||||
|
- tenant_id
|
||||||
|
properties:
|
||||||
|
workspace_id:
|
||||||
|
type: integer
|
||||||
|
tenant_id:
|
||||||
|
type:
|
||||||
|
- integer
|
||||||
|
- string
|
||||||
|
activeTab:
|
||||||
|
oneOf:
|
||||||
|
- $ref: '#/components/schemas/OperationsTab'
|
||||||
|
- type: 'null'
|
||||||
|
navigationContext:
|
||||||
|
type:
|
||||||
|
- string
|
||||||
|
- 'null'
|
||||||
|
DestinationKind:
|
||||||
|
type: string
|
||||||
|
enum:
|
||||||
|
- tenant_findings
|
||||||
|
- baseline_compare_landing
|
||||||
|
- canonical_operations
|
||||||
|
- operation_detail
|
||||||
|
- finding_detail
|
||||||
|
- none
|
||||||
|
SurfaceDestination:
|
||||||
|
type: object
|
||||||
|
additionalProperties: false
|
||||||
|
allOf:
|
||||||
|
- if:
|
||||||
|
properties:
|
||||||
|
actionDisabled:
|
||||||
|
const: true
|
||||||
|
then:
|
||||||
|
required:
|
||||||
|
- helperText
|
||||||
|
required:
|
||||||
|
- kind
|
||||||
|
- tenantScoped
|
||||||
|
- semanticsLabel
|
||||||
|
properties:
|
||||||
|
kind:
|
||||||
|
$ref: '#/components/schemas/DestinationKind'
|
||||||
|
tenantScoped:
|
||||||
|
type: boolean
|
||||||
|
semanticsLabel:
|
||||||
|
type: string
|
||||||
|
filterState:
|
||||||
|
oneOf:
|
||||||
|
- $ref: '#/components/schemas/FindingsDestinationFilterState'
|
||||||
|
- $ref: '#/components/schemas/OperationsDestinationFilterState'
|
||||||
|
- type: 'null'
|
||||||
|
actionDisabled:
|
||||||
|
type:
|
||||||
|
- boolean
|
||||||
|
- 'null'
|
||||||
|
helperText:
|
||||||
|
type:
|
||||||
|
- string
|
||||||
|
- 'null'
|
||||||
|
ActionableDestinationKind:
|
||||||
|
type: string
|
||||||
|
enum:
|
||||||
|
- tenant_findings
|
||||||
|
- baseline_compare_landing
|
||||||
|
- canonical_operations
|
||||||
|
KpiDestinationKind:
|
||||||
|
type: string
|
||||||
|
enum:
|
||||||
|
- tenant_findings
|
||||||
|
- canonical_operations
|
||||||
|
- none
|
||||||
|
ActionableSurfaceDestination:
|
||||||
|
allOf:
|
||||||
|
- $ref: '#/components/schemas/SurfaceDestination'
|
||||||
|
- type: object
|
||||||
|
properties:
|
||||||
|
kind:
|
||||||
|
$ref: '#/components/schemas/ActionableDestinationKind'
|
||||||
|
KpiSurfaceDestination:
|
||||||
|
allOf:
|
||||||
|
- $ref: '#/components/schemas/SurfaceDestination'
|
||||||
|
- type: object
|
||||||
|
properties:
|
||||||
|
kind:
|
||||||
|
$ref: '#/components/schemas/KpiDestinationKind'
|
||||||
|
KpiMetric:
|
||||||
|
type: object
|
||||||
|
additionalProperties: false
|
||||||
|
required:
|
||||||
|
- key
|
||||||
|
- label
|
||||||
|
- count
|
||||||
|
- problemFamily
|
||||||
|
- destination
|
||||||
|
properties:
|
||||||
|
key:
|
||||||
|
type: string
|
||||||
|
label:
|
||||||
|
type: string
|
||||||
|
count:
|
||||||
|
type: integer
|
||||||
|
minimum: 0
|
||||||
|
problemFamily:
|
||||||
|
type: string
|
||||||
|
enum:
|
||||||
|
- findings
|
||||||
|
- operations
|
||||||
|
findingUniverse:
|
||||||
|
oneOf:
|
||||||
|
- $ref: '#/components/schemas/FindingUniverse'
|
||||||
|
- type: 'null'
|
||||||
|
severityUniverse:
|
||||||
|
oneOf:
|
||||||
|
- type: string
|
||||||
|
enum:
|
||||||
|
- high_only
|
||||||
|
- high_and_critical
|
||||||
|
- type: 'null'
|
||||||
|
destination:
|
||||||
|
$ref: '#/components/schemas/KpiSurfaceDestination'
|
||||||
|
AttentionItem:
|
||||||
|
type: object
|
||||||
|
additionalProperties: false
|
||||||
|
anyOf:
|
||||||
|
- required:
|
||||||
|
- actionLabel
|
||||||
|
- required:
|
||||||
|
- nextStepLabel
|
||||||
|
- properties:
|
||||||
|
actionDisabled:
|
||||||
|
const: true
|
||||||
|
required:
|
||||||
|
- actionDisabled
|
||||||
|
- helperText
|
||||||
|
required:
|
||||||
|
- key
|
||||||
|
- title
|
||||||
|
- body
|
||||||
|
- badge
|
||||||
|
- tone
|
||||||
|
- problemFamily
|
||||||
|
- destination
|
||||||
|
properties:
|
||||||
|
key:
|
||||||
|
type: string
|
||||||
|
title:
|
||||||
|
type: string
|
||||||
|
body:
|
||||||
|
type: string
|
||||||
|
supportingMessage:
|
||||||
|
type:
|
||||||
|
- string
|
||||||
|
- 'null'
|
||||||
|
badge:
|
||||||
|
type: string
|
||||||
|
tone:
|
||||||
|
type: string
|
||||||
|
enum:
|
||||||
|
- success
|
||||||
|
- warning
|
||||||
|
- danger
|
||||||
|
- info
|
||||||
|
- gray
|
||||||
|
problemFamily:
|
||||||
|
$ref: '#/components/schemas/ProblemFamily'
|
||||||
|
actionLabel:
|
||||||
|
type:
|
||||||
|
- string
|
||||||
|
- 'null'
|
||||||
|
actionDisabled:
|
||||||
|
type:
|
||||||
|
- boolean
|
||||||
|
- 'null'
|
||||||
|
helperText:
|
||||||
|
type:
|
||||||
|
- string
|
||||||
|
- 'null'
|
||||||
|
nextStepLabel:
|
||||||
|
type:
|
||||||
|
- string
|
||||||
|
- 'null'
|
||||||
|
destination:
|
||||||
|
$ref: '#/components/schemas/ActionableSurfaceDestination'
|
||||||
|
CompareSummary:
|
||||||
|
type: object
|
||||||
|
additionalProperties: false
|
||||||
|
required:
|
||||||
|
- stateFamily
|
||||||
|
- tone
|
||||||
|
- headline
|
||||||
|
- positiveClaimAllowed
|
||||||
|
- nextAction
|
||||||
|
properties:
|
||||||
|
stateFamily:
|
||||||
|
type: string
|
||||||
|
enum:
|
||||||
|
- positive
|
||||||
|
- caution
|
||||||
|
- stale
|
||||||
|
- action_required
|
||||||
|
- in_progress
|
||||||
|
- unavailable
|
||||||
|
tone:
|
||||||
|
type: string
|
||||||
|
enum:
|
||||||
|
- success
|
||||||
|
- warning
|
||||||
|
- danger
|
||||||
|
- info
|
||||||
|
- gray
|
||||||
|
headline:
|
||||||
|
type: string
|
||||||
|
supportingMessage:
|
||||||
|
type:
|
||||||
|
- string
|
||||||
|
- 'null'
|
||||||
|
positiveClaimAllowed:
|
||||||
|
type: boolean
|
||||||
|
nextAction:
|
||||||
|
$ref: '#/components/schemas/SurfaceDestination'
|
||||||
|
DiagnosticSurfaceSummary:
|
||||||
|
type: object
|
||||||
|
additionalProperties: false
|
||||||
|
required:
|
||||||
|
- heading
|
||||||
|
- role
|
||||||
|
- doesNotDefinePosture
|
||||||
|
- fullRowClick
|
||||||
|
- detailDestinationKind
|
||||||
|
properties:
|
||||||
|
heading:
|
||||||
|
type: string
|
||||||
|
role:
|
||||||
|
type: string
|
||||||
|
enum:
|
||||||
|
- diagnostic_recency
|
||||||
|
doesNotDefinePosture:
|
||||||
|
type: boolean
|
||||||
|
fullRowClick:
|
||||||
|
type: boolean
|
||||||
|
detailDestinationKind:
|
||||||
|
type: string
|
||||||
|
enum:
|
||||||
|
- finding_detail
|
||||||
|
- operation_detail
|
||||||
|
TenantDashboardTruthBundle:
|
||||||
|
type: object
|
||||||
|
additionalProperties: false
|
||||||
|
required:
|
||||||
|
- tenantId
|
||||||
|
- workspaceId
|
||||||
|
- kpis
|
||||||
|
- attentionItems
|
||||||
|
- compareSummary
|
||||||
|
- recentFindings
|
||||||
|
- recentOperations
|
||||||
|
properties:
|
||||||
|
tenantId:
|
||||||
|
type: integer
|
||||||
|
workspaceId:
|
||||||
|
type: integer
|
||||||
|
kpis:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
$ref: '#/components/schemas/KpiMetric'
|
||||||
|
attentionItems:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
$ref: '#/components/schemas/AttentionItem'
|
||||||
|
compareSummary:
|
||||||
|
$ref: '#/components/schemas/CompareSummary'
|
||||||
|
recentFindings:
|
||||||
|
$ref: '#/components/schemas/DiagnosticSurfaceSummary'
|
||||||
|
recentOperations:
|
||||||
|
$ref: '#/components/schemas/DiagnosticSurfaceSummary'
|
||||||
267
specs/173-tenant-dashboard-truth-alignment/data-model.md
Normal file
267
specs/173-tenant-dashboard-truth-alignment/data-model.md
Normal file
@ -0,0 +1,267 @@
|
|||||||
|
# Phase 1 Data Model: Tenant Dashboard KPI & Attention Truth Alignment
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
This feature does not add a table, persisted summary entity, or new runtime domain subsystem. It aligns existing persistent tenant truth and existing derived summary contracts so the tenant dashboard's KPI, attention, compare, and recency surfaces describe the same tenant reality.
|
||||||
|
|
||||||
|
## Persistent Source Truths
|
||||||
|
|
||||||
|
### Tenant
|
||||||
|
|
||||||
|
**Purpose**: Scope boundary for every dashboard query and every destination opened from the tenant dashboard.
|
||||||
|
|
||||||
|
**Key fields**:
|
||||||
|
- `id`
|
||||||
|
- `workspace_id`
|
||||||
|
- `external_id`
|
||||||
|
|
||||||
|
**Validation rules**:
|
||||||
|
- Every dashboard summary and destination must resolve for one explicit tenant scope at a time.
|
||||||
|
- Canonical admin destinations opened from the dashboard must preserve this tenant scope through filters or navigation context.
|
||||||
|
|
||||||
|
### Finding
|
||||||
|
|
||||||
|
**Purpose**: Source of drift, workflow, severity, and due-state truth used by tenant dashboard KPI and attention surfaces.
|
||||||
|
|
||||||
|
**Key fields**:
|
||||||
|
- `tenant_id`
|
||||||
|
- `workspace_id`
|
||||||
|
- `finding_type`
|
||||||
|
- `status`
|
||||||
|
- `severity`
|
||||||
|
- `due_at`
|
||||||
|
- `assignee_user_id`
|
||||||
|
- `scope_key`
|
||||||
|
- `baseline_operation_run_id`
|
||||||
|
- `current_operation_run_id`
|
||||||
|
|
||||||
|
**Validation rules**:
|
||||||
|
- Canonical active/open semantics come from `Finding::openStatusesForQuery()`.
|
||||||
|
- Canonical high-severity tenant-summary semantics use `SEVERITY_HIGH` plus `SEVERITY_CRITICAL`.
|
||||||
|
- If a dashboard metric intentionally uses a narrower subset, the label and destination must say so explicitly.
|
||||||
|
|
||||||
|
**Relevant state families**:
|
||||||
|
- `new`
|
||||||
|
- `acknowledged`
|
||||||
|
- `triaged`
|
||||||
|
- `in_progress`
|
||||||
|
- `reopened`
|
||||||
|
- `risk_accepted`
|
||||||
|
- `resolved`
|
||||||
|
- `closed`
|
||||||
|
|
||||||
|
### FindingException / Governance Validity
|
||||||
|
|
||||||
|
**Purpose**: Supplies expiring and lapsed accepted-risk governance truth used by tenant-level attention and calmness guards.
|
||||||
|
|
||||||
|
**Key fields**:
|
||||||
|
- `tenant_id`
|
||||||
|
- `workspace_id`
|
||||||
|
- `finding_id`
|
||||||
|
- `status`
|
||||||
|
- `current_validity_state`
|
||||||
|
- `review_due_at`
|
||||||
|
- `expires_at`
|
||||||
|
|
||||||
|
**Validation rules**:
|
||||||
|
- Expiring and lapsed governance must remain derived from existing validity state and timing rules.
|
||||||
|
- No dashboard-local governance state family may replace or reinterpret existing validity truth.
|
||||||
|
|
||||||
|
### OperationRun
|
||||||
|
|
||||||
|
**Purpose**: Source of tenant activity, compare execution state, and canonical operation detail navigation.
|
||||||
|
|
||||||
|
**Key fields**:
|
||||||
|
- `id`
|
||||||
|
- `tenant_id`
|
||||||
|
- `workspace_id`
|
||||||
|
- `type`
|
||||||
|
- `status`
|
||||||
|
- `outcome`
|
||||||
|
- `created_at`
|
||||||
|
- `started_at`
|
||||||
|
- `completed_at`
|
||||||
|
- `context`
|
||||||
|
|
||||||
|
**Validation rules**:
|
||||||
|
- Canonical active operations semantics come from `OperationRun::scopeActive()` and the Operations page `active` tab.
|
||||||
|
- Dashboard activity signals must remain distinct from governance posture signals.
|
||||||
|
- Attention-worthy operations follow-up is narrower than generic activity and is limited to failed, warning, or unusually long-running or stalled tenant runs that require operator review.
|
||||||
|
|
||||||
|
## Existing Runtime Source Objects
|
||||||
|
|
||||||
|
### BaselineCompareStats
|
||||||
|
|
||||||
|
**Purpose**: Existing compare- and governance-aware source object that already owns overdue, expiring, lapsed, and high-severity active findings counts alongside compare posture inputs.
|
||||||
|
|
||||||
|
**Key consumed fields**:
|
||||||
|
- `profileName`
|
||||||
|
- `state`
|
||||||
|
- `operationRunId`
|
||||||
|
- `lastComparedHuman`
|
||||||
|
- `findingsCount`
|
||||||
|
- `overdueOpenFindingsCount`
|
||||||
|
- `expiringGovernanceCount`
|
||||||
|
- `lapsedGovernanceCount`
|
||||||
|
- `activeNonNewFindingsCount`
|
||||||
|
- `highSeverityActiveFindingsCount`
|
||||||
|
|
||||||
|
**Validation rules**:
|
||||||
|
- Existing compare and governance counts remain the source of truth for compare-backed dashboard calmness guards.
|
||||||
|
- The feature must not create a second competing count path for the same compare-backed summary family.
|
||||||
|
|
||||||
|
### BaselineCompareSummaryAssessment
|
||||||
|
|
||||||
|
**Purpose**: Existing summary contract that maps compare stats into posture family, tone, headline, supporting message, and next-action intent.
|
||||||
|
|
||||||
|
**Key consumed fields**:
|
||||||
|
- `stateFamily`
|
||||||
|
- `tone`
|
||||||
|
- `headline`
|
||||||
|
- `supportingMessage`
|
||||||
|
- `reasonCode`
|
||||||
|
- `positiveClaimAllowed`
|
||||||
|
- `nextActionLabel()`
|
||||||
|
- `nextActionTarget()`
|
||||||
|
|
||||||
|
**Validation rules**:
|
||||||
|
- Dashboard compare calmness must remain sourced from this assessment path.
|
||||||
|
- If the dashboard suppresses additional calm claims beyond compare posture, it must do so by consuming existing tenant attention truth, not by inventing a new tone system.
|
||||||
|
|
||||||
|
### TenantGovernanceAggregate
|
||||||
|
|
||||||
|
**Purpose**: Existing derived tenant-scoped summary contract that already combines compare posture and governance-related counts for tenant dashboard consumers.
|
||||||
|
|
||||||
|
**Key consumed fields**:
|
||||||
|
- `headline`
|
||||||
|
- `profileName`
|
||||||
|
- `lastComparedLabel`
|
||||||
|
- `compareState`
|
||||||
|
- `summaryAssessment`
|
||||||
|
- `overdueOpenFindingsCount`
|
||||||
|
- `expiringGovernanceCount`
|
||||||
|
- `lapsedGovernanceCount`
|
||||||
|
- `highSeverityActiveFindingsCount`
|
||||||
|
- `nextActionLabel`
|
||||||
|
- `nextActionTarget`
|
||||||
|
- `stats`
|
||||||
|
|
||||||
|
**Validation rules**:
|
||||||
|
- The feature should extend or reuse this existing contract rather than creating a new dashboard-only aggregate.
|
||||||
|
- Widgets using this aggregate must not re-query the same owned summary fields locally.
|
||||||
|
|
||||||
|
## Derived Dashboard View Contracts
|
||||||
|
|
||||||
|
### Dashboard KPI Metric
|
||||||
|
|
||||||
|
**Purpose**: Compact tenant dashboard stat that names one count universe and one matching destination.
|
||||||
|
|
||||||
|
#### Fields
|
||||||
|
|
||||||
|
| Field | Type | Required | Description |
|
||||||
|
|-------|------|----------|-------------|
|
||||||
|
| `key` | string | yes | Stable metric identity such as `new_drift`, `high_severity_active`, or `active_operations` |
|
||||||
|
| `label` | string | yes | Operator-facing label that must match the actual count universe |
|
||||||
|
| `count` | integer | yes | Metric value |
|
||||||
|
| `problemFamily` | enum | yes | Shared dashboard problem family limited to `findings` or `operations` for the KPI strip in this slice |
|
||||||
|
| `findingUniverse` | enum nullable | no | `new_drift_only`, `open_drift`, or `active_findings` when applicable |
|
||||||
|
| `severityUniverse` | enum nullable | no | `high_only` or `high_and_critical` when applicable |
|
||||||
|
| `destination` | object | yes | Shared destination contract carrying kind, tenant-scoping, semantics label, any filter state needed to reproduce the same subset, and disabled/helper-text state when a visible affordance is intentionally non-clickable |
|
||||||
|
|
||||||
|
#### Validation rules
|
||||||
|
|
||||||
|
- The metric label must accurately reflect `findingUniverse` and `severityUniverse` when either is present.
|
||||||
|
- The destination must reproduce the same subset or explicitly broaden it with visible framing.
|
||||||
|
- The metric `problemFamily` must use the same shared family naming used by `AttentionItem` and the internal OpenAPI contract.
|
||||||
|
- If a KPI remains visible for an in-scope member who lacks destination capability, the shared `destination` contract must carry the disabled state and helper text instead of implying a clickable drill-through.
|
||||||
|
- In this slice, KPI destinations are limited to `tenant_findings` or `canonical_operations`, with `none` reserved only for intentionally passive reassurance states.
|
||||||
|
- A canonical operations destination must carry tenant filter state from the dashboard context.
|
||||||
|
|
||||||
|
### Needs Attention Item
|
||||||
|
|
||||||
|
**Purpose**: One dashboard attention row that describes a tenant-level problem and tells the operator where to go next.
|
||||||
|
|
||||||
|
#### Fields
|
||||||
|
|
||||||
|
| Field | Type | Required | Description |
|
||||||
|
|-------|------|----------|-------------|
|
||||||
|
| `key` | string | yes | Stable attention identity such as `overdue_findings`, `lapsed_governance`, or `baseline_compare_posture` |
|
||||||
|
| `title` | string | yes | Operator-facing problem name |
|
||||||
|
| `body` | string | yes | Short explanation of the risk or follow-up need |
|
||||||
|
| `supportingMessage` | string nullable | no | Secondary explanatory text when the item needs more context without changing its primary destination |
|
||||||
|
| `badge` | string | yes | Existing summary family label such as `Findings`, `Governance`, `Baseline`, or `Operations` |
|
||||||
|
| `tone` | string | yes | Existing tone family used by the shared contract |
|
||||||
|
| `problemFamily` | enum | yes | `findings`, `governance`, `compare`, or `operations` |
|
||||||
|
| `actionLabel` | string nullable | no | Primary follow-up verb for this item |
|
||||||
|
| `actionDisabled` | boolean nullable | no | Whether the visible follow-up state is intentionally non-clickable for an in-scope member lacking destination capability |
|
||||||
|
| `helperText` | string nullable | no | Helper text explaining why the visible follow-up state is disabled or non-clickable |
|
||||||
|
| `nextStepLabel` | string nullable | no | Secondary text when the compare assessment already defines the next step |
|
||||||
|
| `destination` | object | yes | Shared destination contract carrying the target surface semantics, including disabled/helper-text state when the visible follow-up is intentionally non-clickable |
|
||||||
|
|
||||||
|
#### Validation rules
|
||||||
|
|
||||||
|
- Central attention items must be actionable: `destination` must be present, and the item must expose either `actionLabel`, `nextStepLabel`, or a disabled explanatory state for an in-scope member who lacks the downstream capability.
|
||||||
|
- If the visible follow-up is disabled, `actionDisabled` must be `true` and `helperText` must be populated.
|
||||||
|
- Central attention item destinations are limited to `tenant_findings`, `baseline_compare_landing`, or `canonical_operations` in this slice.
|
||||||
|
- Each item may expose one primary destination only.
|
||||||
|
- Items derived from `TenantGovernanceAggregate` must reuse its count and posture fields rather than recompute them.
|
||||||
|
|
||||||
|
### Findings Destination Filter State
|
||||||
|
|
||||||
|
**Purpose**: Structured state needed to make a dashboard findings drill-through semantically recoverable on the tenant findings list.
|
||||||
|
|
||||||
|
#### Fields
|
||||||
|
|
||||||
|
| Field | Type | Required | Description |
|
||||||
|
|-------|------|----------|-------------|
|
||||||
|
| `tenant` | tenant route key | yes | Tenant route scope |
|
||||||
|
| `tab` | enum nullable | no | `needs_action`, `overdue`, `risk_accepted`, `resolved`, or `all` |
|
||||||
|
| `status` | string nullable | no | Explicit status filter when a narrower subset is intended |
|
||||||
|
| `high_severity` | boolean nullable | no | Whether the destination must enable the high-severity quick filter |
|
||||||
|
| `finding_type` | string nullable | no | `drift` when the dashboard metric is drift-only |
|
||||||
|
|
||||||
|
#### Validation rules
|
||||||
|
|
||||||
|
- A dashboard findings link must use at least one of `tab`, `status`, `high_severity`, or `finding_type` when the originating KPI or attention item names a subset narrower than the default list.
|
||||||
|
- `high_severity=true` must align with the findings list's existing `HIGH + CRITICAL` filter semantics.
|
||||||
|
|
||||||
|
### Operations Destination Filter State
|
||||||
|
|
||||||
|
**Purpose**: Structured state needed to keep `/admin/operations` tenant-safe and semantically continuous when opened from the tenant dashboard.
|
||||||
|
|
||||||
|
#### Fields
|
||||||
|
|
||||||
|
| Field | Type | Required | Description |
|
||||||
|
|-------|------|----------|-------------|
|
||||||
|
| `workspace_id` | integer | yes | Existing workspace context |
|
||||||
|
| `tenant_id` | integer or tenant route key | yes | Active tenant filter for canonical admin operations |
|
||||||
|
| `activeTab` | enum nullable | no | `active`, `failed`, `blocked`, `succeeded`, `partial`, or `all`; `blocked` reproduces warning, stalled, or unusually long-running follow-up and `failed` reproduces terminal failure follow-up |
|
||||||
|
| `navigationContext` | string nullable | no | Optional serialized canonical back-link context carried through the canonical operations destination |
|
||||||
|
|
||||||
|
#### Validation rules
|
||||||
|
|
||||||
|
- Dashboard operations links must preserve tenant filter state.
|
||||||
|
- Operations activity KPIs should use the `activeTab=active` semantics when the metric names active work.
|
||||||
|
- Operations follow-up links must use `failed` for terminal failure follow-up and `blocked` for warning, stalled, or unusually long-running follow-up so dashboard semantics stay recoverable on `/admin/operations`.
|
||||||
|
|
||||||
|
## Relationships
|
||||||
|
|
||||||
|
- One `Tenant` owns many `Finding` rows and many `OperationRun` rows.
|
||||||
|
- One `Finding` may have zero or one current effective `FindingException` governance state relevant to this slice.
|
||||||
|
- One `TenantGovernanceAggregate` summarizes one tenant using one `BaselineCompareStats` instance and one `BaselineCompareSummaryAssessment` instance.
|
||||||
|
- `DashboardKpis`, `NeedsAttention`, and `BaselineCompareNow` consume overlapping derived truth and must remain semantically aligned.
|
||||||
|
- `RecentDriftFindings` and `RecentOperations` consume the same tenant scope but are diagnostic-only consumers, not posture owners.
|
||||||
|
|
||||||
|
## Lifecycle Notes
|
||||||
|
|
||||||
|
1. Tenant dashboard loads for one current tenant.
|
||||||
|
2. Aggregate-backed summary surfaces resolve the current tenant's compare and governance truth.
|
||||||
|
3. KPI and attention surfaces expose destinations whose filter state must preserve the originating problem family.
|
||||||
|
4. Recency surfaces expose recent records for context only.
|
||||||
|
5. Canonical operations and tenant findings destinations resolve within the same tenant scope and remain subject to existing server-side authorization.
|
||||||
|
|
||||||
|
## Migration Notes
|
||||||
|
|
||||||
|
- No schema migration is required.
|
||||||
|
- No new persisted artifact is required.
|
||||||
|
- If implementation needs a new helper, it should stay local to existing `OperationRunLinks`, findings list filter handling, or the existing aggregate path rather than introducing a new dashboard framework.
|
||||||
278
specs/173-tenant-dashboard-truth-alignment/plan.md
Normal file
278
specs/173-tenant-dashboard-truth-alignment/plan.md
Normal file
@ -0,0 +1,278 @@
|
|||||||
|
# Implementation Plan: Tenant Dashboard KPI & Attention Truth Alignment
|
||||||
|
|
||||||
|
**Branch**: `173-tenant-dashboard-truth-alignment` | **Date**: 2026-04-03 | **Spec**: `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/specs/173-tenant-dashboard-truth-alignment/spec.md`
|
||||||
|
**Input**: Feature specification from `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/specs/173-tenant-dashboard-truth-alignment/spec.md`
|
||||||
|
|
||||||
|
**Note**: This template is filled in by the `/speckit.plan` command. See `.specify/scripts/` for helper scripts.
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
Align the tenant dashboard's five existing overview surfaces around one honest tenant truth without adding new persistence, a new dashboard aggregate, or a new posture framework. The first implementation slice will tighten KPI semantics and tenant-safe drill-throughs using the existing findings and operations destination models, make `NeedsAttention` action-capable while preserving its aggregate-backed attention logic, and keep `BaselineCompareNow` on the existing compare and governance guard path so the dashboard cannot look calmer than the tenant's real state. The second slice will protect the distinction between posture, activity, and recency with focused cross-widget regression coverage and tenant-prefilter continuity tests.
|
||||||
|
|
||||||
|
## Technical Context
|
||||||
|
|
||||||
|
**Language/Version**: PHP 8.4.15
|
||||||
|
**Primary Dependencies**: Laravel 12, Filament v5, Livewire v4, Pest v4, existing `TenantDashboard`, `DashboardKpis`, `NeedsAttention`, `BaselineCompareNow`, `RecentDriftFindings`, `RecentOperations`, `TenantGovernanceAggregateResolver`, `BaselineCompareStats`, `BaselineCompareSummaryAssessor`, `FindingResource`, `OperationRunLinks`, and canonical admin Operations page
|
||||||
|
**Storage**: PostgreSQL unchanged; no new persistence, cache store, or durable dashboard summary artifact
|
||||||
|
**Testing**: Pest 4 feature and Livewire component tests through Laravel Sail; existing dashboard tenant-scope and DB-only tests remain part of the verification pack
|
||||||
|
**Target Platform**: Laravel monolith web application in Sail locally and containerized Linux deployment in staging/production
|
||||||
|
**Project Type**: web application
|
||||||
|
**Performance Goals**: Keep tenant dashboard rendering DB-only, preserve canonical drill-through routes, and avoid broadening the current dashboard surface family; DB-only rendering remains the explicit verification target for this slice
|
||||||
|
**Constraints**: No new tables, no new global tenant-posture component, no new dashboard route family, no cross-tenant leakage, no new destructive actions, recent tables must remain diagnostic surfaces, and canonical admin operations routes must preserve tenant context when entered from the tenant dashboard
|
||||||
|
**Scale/Scope**: One tenant dashboard page, five existing dashboard surfaces, three main destination families (`/admin/t/{tenant}/findings`, `/admin/t/{tenant}/baseline-compare-landing`, `/admin/operations`), and targeted regression coverage for cross-widget consistency and drill-through continuity
|
||||||
|
|
||||||
|
## Constitution Check
|
||||||
|
|
||||||
|
*GATE: Passed before Phase 0 research. Re-checked after Phase 1 design and still passing.*
|
||||||
|
|
||||||
|
| Principle | Pre-Research | Post-Design | Notes |
|
||||||
|
|-----------|--------------|-------------|-------|
|
||||||
|
| Inventory-first / snapshots-second | PASS | PASS | No new inventory or snapshot truth is introduced. The feature reuses existing findings, compare, and operation records. |
|
||||||
|
| Read/write separation | PASS | PASS | The slice is read-time dashboard alignment only. No new write path, preview path, or mutation surface is added. |
|
||||||
|
| Graph contract path | N/A | N/A | No Graph calls or contract-registry changes are required. |
|
||||||
|
| Deterministic capabilities | PASS | PASS | Existing server-side authorization remains authoritative for dashboard destinations. |
|
||||||
|
| Workspace + tenant isolation | PASS | PASS | Every dashboard destination remains tenant-scoped or canonical-view with tenant-prefilter continuity and existing entitlement checks. |
|
||||||
|
| RBAC-UX authorization semantics | PASS | PASS | Non-members remain `404`, in-scope members lacking a destination capability remain `403`, and no raw capability checks are introduced. |
|
||||||
|
| Run observability / Ops-UX | PASS | PASS | No new `OperationRun` lifecycle or feedback path is added. Existing operations routes remain canonical. |
|
||||||
|
| Data minimization | PASS | PASS | No new persistence or broader route exposure is introduced; drill-throughs reuse existing destination data only. |
|
||||||
|
| Proportionality / no premature abstraction | PASS | PASS | The plan explicitly reuses `TenantGovernanceAggregate`, `Finding` status helpers, and existing route helpers instead of adding a dashboard-specific framework. |
|
||||||
|
| Persisted truth / behavioral state | PASS | PASS | No new tables, status families, or persisted summary artifacts are planned. |
|
||||||
|
| UI semantics / few layers | PASS | PASS | The feature aligns existing widgets and helper seams rather than creating a new presentation taxonomy. |
|
||||||
|
| Badge semantics (BADGE-001) | PASS | PASS | Existing badge and tone domains remain authoritative for finding severity, compare posture, and operation status/outcome. |
|
||||||
|
| Filament-native UI / Action Surface Contract | PASS | PASS | Existing Filament widgets and tables remain in place. No redundant inspect affordances or new destructive actions are introduced. |
|
||||||
|
| Filament UX-001 | PASS | PASS | No create/edit/view layout changes. The dashboard keeps attention and compare posture above recency surfaces. |
|
||||||
|
| Filament v5 / Livewire v4 compliance | PASS | PASS | The design remains within the current Filament v5 + Livewire v4 stack. |
|
||||||
|
| Provider registration location | PASS | PASS | No panel or provider registration change is required; Laravel 11+ registration remains in `bootstrap/providers.php`. |
|
||||||
|
| Global search hard rule | PASS | PASS | No globally searchable resource behavior changes are part of this slice. |
|
||||||
|
| Destructive action safety | PASS | PASS | The feature adds no destructive action. |
|
||||||
|
| Asset strategy | PASS | PASS | No new assets or `filament:assets` deployment changes are needed. |
|
||||||
|
| Testing truth (TEST-TRUTH-001) | PASS | PASS | The plan adds cross-widget and drill-through tests that verify business truth, not thin UI wiring alone. |
|
||||||
|
|
||||||
|
## Phase 0 Research
|
||||||
|
|
||||||
|
Research outcomes are captured in `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/specs/173-tenant-dashboard-truth-alignment/research.md`.
|
||||||
|
|
||||||
|
Key decisions:
|
||||||
|
|
||||||
|
- Reuse the existing `TenantGovernanceAggregate` and `BaselineCompareStats` truth path instead of creating a new tenant-dashboard aggregate.
|
||||||
|
- Treat `Finding::openStatusesForQuery()` and the existing findings list tabs and quick filters as the canonical active/open universe for dashboard drill-through continuity.
|
||||||
|
- Treat high severity at tenant-summary level as the existing `HIGH + CRITICAL` active-finding universe; any narrower KPI subset must be explicitly labeled as narrower.
|
||||||
|
- Make `NeedsAttention` directly actionable by routing each item to an existing tenant-safe findings, compare, or operations destination instead of leaving it as summary-only text.
|
||||||
|
- Treat materially relevant operations follow-up for this slice as tenant-scoped runs in failed, warning, or unusually long-running or stalled states that require operator review; healthy queued or running activity alone remains an activity signal.
|
||||||
|
- When a tenant member can see a dashboard summary state but lacks the downstream capability for its destination, keep the state visible only as a disabled or non-clickable affordance with helper text instead of a clickable dead-end link.
|
||||||
|
- Push tenant-prefilter continuity for canonical Operations routes into existing link and filter helpers rather than leaving raw `route('admin.operations.index')` calls in dashboard widgets.
|
||||||
|
- Keep `RecentDriftFindings` and `RecentOperations` as diagnostic recency surfaces instead of expanding them into the posture layer.
|
||||||
|
|
||||||
|
## Phase 1 Design
|
||||||
|
|
||||||
|
Design artifacts are created under `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/specs/173-tenant-dashboard-truth-alignment/`:
|
||||||
|
|
||||||
|
- `data-model.md`: existing persistent source truth plus the derived dashboard signal and drill-through contracts for this slice
|
||||||
|
- `contracts/tenant-dashboard-truth-alignment.openapi.yaml`: internal logical contract for tenant dashboard summary semantics and destination continuity
|
||||||
|
- `quickstart.md`: focused implementation and verification workflow
|
||||||
|
|
||||||
|
Design decisions:
|
||||||
|
|
||||||
|
- `TenantGovernanceAggregate` and `BaselineCompareSummaryAssessor` remain the governance and compare truth anchors; the implementation will primarily align widgets and existing link or filter helpers around those outputs, with helper changes kept narrow instead of introducing new aggregate logic.
|
||||||
|
- KPI semantics are aligned by canonically reusing active-status and severity universes where appropriate and by explicitly renaming or filtering any intentionally narrower subset.
|
||||||
|
- Canonical operations navigation remains `/admin/operations` and `/admin/operations/{run}`; the change is tenant-prefilter continuity, not a new route family.
|
||||||
|
- `NeedsAttention` becomes action-capable without becoming a mutation surface or a second diagnostics page, and expiring governance plus high-severity active findings remain first-class attention states alongside overdue, lapsed, compare-limited, and defined operations-follow-up states.
|
||||||
|
- Permission-limited members keep visible dashboard truth, but destination affordances must be disabled or non-clickable with helper text instead of clickable links that only fail after navigation.
|
||||||
|
- Recent tables keep their existing row-click model and remain clearly subordinate to attention and compare posture.
|
||||||
|
|
||||||
|
## Project Structure
|
||||||
|
|
||||||
|
### Documentation (this feature)
|
||||||
|
|
||||||
|
```text
|
||||||
|
specs/173-tenant-dashboard-truth-alignment/
|
||||||
|
├── spec.md
|
||||||
|
├── plan.md
|
||||||
|
├── research.md
|
||||||
|
├── data-model.md
|
||||||
|
├── quickstart.md
|
||||||
|
├── contracts/
|
||||||
|
│ └── tenant-dashboard-truth-alignment.openapi.yaml
|
||||||
|
├── checklists/
|
||||||
|
│ └── requirements.md
|
||||||
|
└── tasks.md
|
||||||
|
```
|
||||||
|
|
||||||
|
### Source Code (repository root)
|
||||||
|
|
||||||
|
```text
|
||||||
|
app/
|
||||||
|
├── Filament/
|
||||||
|
│ ├── Pages/
|
||||||
|
│ │ ├── TenantDashboard.php
|
||||||
|
│ │ └── Monitoring/
|
||||||
|
│ │ └── Operations.php
|
||||||
|
│ ├── Resources/
|
||||||
|
│ │ └── FindingResource/
|
||||||
|
│ │ └── Pages/
|
||||||
|
│ │ └── ListFindings.php
|
||||||
|
│ └── Widgets/
|
||||||
|
│ └── Dashboard/
|
||||||
|
│ ├── DashboardKpis.php
|
||||||
|
│ ├── NeedsAttention.php
|
||||||
|
│ ├── BaselineCompareNow.php
|
||||||
|
│ ├── RecentDriftFindings.php
|
||||||
|
│ └── RecentOperations.php
|
||||||
|
├── Models/
|
||||||
|
│ ├── Finding.php
|
||||||
|
│ └── OperationRun.php
|
||||||
|
└── Support/
|
||||||
|
├── Baselines/
|
||||||
|
│ ├── BaselineCompareStats.php
|
||||||
|
│ ├── BaselineCompareSummaryAssessor.php
|
||||||
|
│ ├── BaselineCompareSummaryAssessment.php
|
||||||
|
│ ├── TenantGovernanceAggregate.php
|
||||||
|
│ └── TenantGovernanceAggregateResolver.php
|
||||||
|
├── Navigation/
|
||||||
|
│ └── CanonicalNavigationContext.php
|
||||||
|
└── OperationRunLinks.php
|
||||||
|
|
||||||
|
tests/
|
||||||
|
├── Feature/
|
||||||
|
│ ├── Filament/
|
||||||
|
│ │ ├── NeedsAttentionWidgetTest.php
|
||||||
|
│ │ ├── BaselineCompareNowWidgetTest.php
|
||||||
|
│ │ ├── BaselineCompareSummaryConsistencyTest.php
|
||||||
|
│ │ ├── TenantDashboardDbOnlyTest.php
|
||||||
|
│ │ ├── TenantDashboardTenantScopeTest.php
|
||||||
|
│ │ ├── DashboardKpisWidgetTest.php
|
||||||
|
│ │ └── TenantDashboardTruthAlignmentTest.php
|
||||||
|
│ ├── Findings/
|
||||||
|
│ │ ├── FindingsListDefaultsTest.php
|
||||||
|
│ │ ├── FindingsListFiltersTest.php
|
||||||
|
│ │ └── FindingAdminTenantParityTest.php
|
||||||
|
│ ├── Monitoring/
|
||||||
|
│ │ └── OperationsDashboardDrillthroughTest.php
|
||||||
|
│ └── OpsUx/
|
||||||
|
│ └── CanonicalViewRunLinksTest.php
|
||||||
|
```
|
||||||
|
|
||||||
|
**Structure Decision**: Keep the existing Laravel monolith structure. The implementation should extend current dashboard widgets, current findings and operations destinations, and current baseline aggregate helpers instead of introducing new directories or a dashboard-specific domain layer.
|
||||||
|
|
||||||
|
## Implementation Strategy
|
||||||
|
|
||||||
|
### Phase A — Align Canonical Dashboard Truth Definitions
|
||||||
|
|
||||||
|
**Goal**: Make dashboard count meaning and severity meaning reuse existing source truth instead of widget-local interpretations.
|
||||||
|
|
||||||
|
| Step | File | Change |
|
||||||
|
|------|------|--------|
|
||||||
|
| A.1 | `app/Models/Finding.php` and `app/Filament/Resources/FindingResource/Pages/ListFindings.php` | Treat `openStatusesForQuery()`, existing `Needs action` and `Overdue` tabs, and the existing `high_severity` quick filter as the canonical active/open and high-severity destination semantics for tenant findings drill-throughs. |
|
||||||
|
| A.2 | `app/Support/Baselines/BaselineCompareStats.php`, `app/Support/Baselines/TenantGovernanceAggregate.php`, and `app/Support/Baselines/TenantGovernanceAggregateResolver.php` | Preserve the current aggregate-backed compare and governance count family as the canonical tenant-level attention guard set. |
|
||||||
|
| A.3 | `app/Filament/Widgets/Dashboard/DashboardKpis.php` | Replace ambiguous KPI wording and local count universes with metrics that either match the canonical active/severity meaning or are explicitly labeled as narrower subsets, and degrade visible KPI drill-throughs to disabled or non-clickable helper-text affordances when destination capability is missing. |
|
||||||
|
|
||||||
|
### Phase B — Make KPI Drill-Throughs Semantically Continuous
|
||||||
|
|
||||||
|
**Goal**: Ensure clicking a KPI leads to a target surface where the same problem family is recognizable.
|
||||||
|
|
||||||
|
| Step | File | Change |
|
||||||
|
|------|------|--------|
|
||||||
|
| B.1 | `app/Filament/Widgets/Dashboard/DashboardKpis.php` and `app/Filament/Resources/FindingResource/Pages/ListFindings.php` | Add or reuse explicit findings tab/filter state so KPI destinations reproduce the named subset instead of opening a broader unqualified findings list. |
|
||||||
|
| B.2 | `app/Support/OperationRunLinks.php` and `app/Filament/Pages/Monitoring/Operations.php` | Push tenant-prefilter continuity for canonical operations links into the existing operations link helper and destination filter handling instead of relying on raw route calls. |
|
||||||
|
| B.3 | `tests/Feature/Filament/DashboardKpisWidgetTest.php`, `tests/Feature/Filament/TenantDashboardTruthAlignmentTest.php`, and `tests/Feature/Monitoring/OperationsDashboardDrillthroughTest.php` | Prove KPI naming, subset meaning, destination continuity, and permission-limited disabled or non-clickable affordance behavior for findings and operations drill-throughs. |
|
||||||
|
|
||||||
|
### Phase C — Make `NeedsAttention` Action-Capable
|
||||||
|
|
||||||
|
**Goal**: Turn the existing attention summary into a start surface without changing its role into a mutation or diagnostics surface.
|
||||||
|
|
||||||
|
| Step | File | Change |
|
||||||
|
|------|------|--------|
|
||||||
|
| C.1 | `app/Filament/Widgets/Dashboard/NeedsAttention.php` | Add direct destination metadata for central attention items while keeping the existing aggregate-backed problem detection, high-severity active-finding coverage, expiring-governance coverage, defined operations-follow-up semantics, and healthy fallback rules. |
|
||||||
|
| C.2 | `resources/views/filament/widgets/dashboard/needs-attention.blade.php` | Render one primary action or one explicit next-step affordance per attention item while preserving the surface's summary-first hierarchy. |
|
||||||
|
| C.3 | `tests/Feature/Filament/NeedsAttentionWidgetTest.php` and `tests/Feature/Filament/TenantDashboardTruthAlignmentTest.php` | Prove that overdue findings, high-severity active findings, lapsed or expiring governance, compare posture limitations, relevant operations follow-up, and permission-limited dashboard states now expose the correct tenant-safe destination behavior and suppress false calm. |
|
||||||
|
|
||||||
|
### Phase D — Keep Compare Calmness and Dashboard Calmness Aligned
|
||||||
|
|
||||||
|
**Goal**: Ensure `BaselineCompareNow` and the dashboard's healthy fallback do not outvote stronger tenant attention conditions.
|
||||||
|
|
||||||
|
| Step | File | Change |
|
||||||
|
|------|------|--------|
|
||||||
|
| D.1 | `app/Filament/Widgets/Dashboard/BaselineCompareNow.php` | Preserve the existing compare summary guard path and baseline-compare landing continuity by consuming the current aggregate-backed summary outputs consistently, without introducing a second helper-owned compare logic path in this slice. |
|
||||||
|
| D.2 | `tests/Feature/Filament/BaselineCompareNowWidgetTest.php`, `tests/Feature/Filament/BaselineCompareSummaryConsistencyTest.php`, and `tests/Feature/Filament/TenantDashboardTruthAlignmentTest.php` | Prove that compare summary and dashboard attention continue to agree for stale, unavailable, overdue, expiring-governance, lapsed-governance, and trustworthy scenarios. |
|
||||||
|
|
||||||
|
### Phase E — Preserve Posture vs Activity vs Recency Separation
|
||||||
|
|
||||||
|
**Goal**: Keep recent tables diagnostic and keep operations activity distinct from governance posture.
|
||||||
|
|
||||||
|
| Step | File | Change |
|
||||||
|
|------|------|--------|
|
||||||
|
| E.1 | `app/Filament/Widgets/Dashboard/RecentDriftFindings.php` and `app/Filament/Widgets/Dashboard/RecentOperations.php` | Preserve current row-click, empty-state, and recency-only semantics; tighten only copy or surrounding truth signals if needed so these surfaces do not read as the tenant's primary queue. |
|
||||||
|
| E.2 | `tests/Feature/Filament/TenantDashboardDbOnlyTest.php`, `tests/Feature/Filament/TenantDashboardTruthAlignmentTest.php`, and existing table standards tests | Prove that recency surfaces remain secondary to attention and compare posture, that healthy operations-only scenarios do not produce governance-problem wording, and that failed, warning, or stalled operations follow-up remains distinguishable from simple activity. |
|
||||||
|
|
||||||
|
### Phase F — Regression Protection and Verification
|
||||||
|
|
||||||
|
**Goal**: Protect the dashboard truth contract against future drift.
|
||||||
|
|
||||||
|
| Step | File | Change |
|
||||||
|
|------|------|--------|
|
||||||
|
| F.1 | `tests/Feature/Filament/DashboardKpisWidgetTest.php` and `tests/Feature/Filament/TenantDashboardTruthAlignmentTest.php` | Add a focused matrix for active findings, high severity, overdue, lapsed, or expiring governance, compare limitations, healthy operations-only activity, attention-worthy operations follow-up, and calm all-clear scenarios. |
|
||||||
|
| F.2 | `tests/Feature/Monitoring/OperationsDashboardDrillthroughTest.php`, `tests/Feature/OpsUx/CanonicalViewRunLinksTest.php`, and `tests/Feature/Filament/TenantDashboardTenantScopeTest.php` | Protect tenant-filter continuity, tenant-safe access, and disabled or non-clickable dashboard affordances for canonical operations routes opened from the dashboard. |
|
||||||
|
| F.3 | `vendor/bin/sail bin pint --dirty --format agent` plus focused Pest runs | Apply formatting and run the smallest verification pack that covers widgets, dashboard integration, findings filters, and operations drill-through continuity. |
|
||||||
|
|
||||||
|
## Key Design Decisions
|
||||||
|
|
||||||
|
### D-001 — Reuse the existing tenant governance aggregate instead of creating a dashboard-specific truth layer
|
||||||
|
|
||||||
|
The repo already has `TenantGovernanceAggregate`, `BaselineCompareStats`, and `BaselineCompareSummaryAssessor` for governance and compare truth. Spec 173 should extend that existing ownership to dashboard alignment work instead of introducing a second tenant summary abstraction. Unless regression coverage exposes a defect in those helpers themselves, this slice changes widget consumption and drill-through continuity rather than modifying compare helper behavior.
|
||||||
|
|
||||||
|
### D-002 — Canonical finding semantics come from existing model helpers and findings list filters
|
||||||
|
|
||||||
|
`Finding::openStatusesForQuery()`, the findings list tabs, and the existing `high_severity` quick filter already define the best current meaning of active/open and high-severity work. KPI and attention drill-through continuity should reuse that semantics instead of inventing dashboard-only filters.
|
||||||
|
|
||||||
|
### D-003 — Canonical operations drill-through stays canonical but must preserve tenant context
|
||||||
|
|
||||||
|
The correct destination remains `/admin/operations` and `/admin/operations/{run}`. The fix is to carry tenant filter state into those existing routes, not to build tenant-specific duplicate operations pages.
|
||||||
|
|
||||||
|
### D-004 — `NeedsAttention` becomes actionable but remains a summary surface
|
||||||
|
|
||||||
|
The attention widget should tell the operator where to go next, but it should not become a new diagnostics or mutation surface. One action per item is the right level.
|
||||||
|
|
||||||
|
### D-005 — Recent tables remain diagnostic recency surfaces
|
||||||
|
|
||||||
|
`RecentDriftFindings` and `RecentOperations` should continue to provide recent context only. The plan explicitly avoids turning them into primary queue or posture owners.
|
||||||
|
|
||||||
|
### D-006 — Attention-worthy operations follow-up is narrower than generic activity
|
||||||
|
|
||||||
|
For this slice, only failed, warning, or unusually long-running or stalled tenant runs count as attention-worthy operations follow-up. Healthy queued or running activity remains visible as activity, not governance risk.
|
||||||
|
|
||||||
|
### D-007 — Permission-limited members must not get clickable dead-end drill-throughs
|
||||||
|
|
||||||
|
If a tenant member can see dashboard truth but lacks a downstream capability, the dashboard may still expose the state, but the affordance must be disabled or non-clickable with helper text rather than a clickable control that only ends in `403` after navigation.
|
||||||
|
|
||||||
|
## Risk Assessment
|
||||||
|
|
||||||
|
| Risk | Impact | Likelihood | Mitigation |
|
||||||
|
|------|--------|------------|------------|
|
||||||
|
| KPI semantics over-expand and lose the compact value of the strip | Medium | Medium | Keep narrow subsets only when they are explicitly named and filter-matched on the destination. |
|
||||||
|
| Canonical operations links still drop tenant context | High | Medium | Push tenant-prefilter continuity into `OperationRunLinks` and destination filter handling, backed by regression tests. |
|
||||||
|
| `NeedsAttention` becomes clickable but semantically drifts from the aggregate-backed truth or exposes dead-end links | High | Medium | Source item semantics from the existing aggregate, define operations follow-up narrowly, and keep one destination per item with disabled or non-clickable fallback for permission-limited members. |
|
||||||
|
| Compare summary stays calmer than stronger dashboard attention conditions | High | Medium | Add explicit all-clear suppression tests across `NeedsAttention`, `BaselineCompareNow`, and the dashboard bundle. |
|
||||||
|
| Recent tables begin to read as the primary action queue | Medium | Low | Preserve current headings, empty-state semantics, and row-click-only interaction; verify this in integration coverage. |
|
||||||
|
|
||||||
|
## Test Strategy
|
||||||
|
|
||||||
|
- Extend or add focused Livewire and feature tests for `DashboardKpis`, `NeedsAttention`, and `BaselineCompareNow` so the dashboard's core summary surfaces agree on active findings, high severity, overdue, lapsed, and expiring governance, compare limitations, healthy operations-only activity, attention-worthy operations follow-up, and calm all-clear states.
|
||||||
|
- Reuse the current `TenantDashboardDbOnlyTest` and `TenantDashboardTenantScopeTest` to preserve DB-only rendering and tenant isolation.
|
||||||
|
- Add explicit drill-through coverage so KPI and attention clicks land on findings or operations destinations with recognizable tenant-filtered semantics, and permission-limited members see disabled or non-clickable explanatory states instead of clickable dead ends.
|
||||||
|
- Reuse current findings list filter coverage to protect the destination-side meaning of `needs_action`, `overdue`, and `high_severity` continuity.
|
||||||
|
- Preserve existing compare summary consistency tests so compare posture continues to agree across dashboard and landing surfaces.
|
||||||
|
- Use the quickstart manual smoke check to verify the 10-second operator comprehension outcome on seeded tenants in addition to the automated regression pack.
|
||||||
|
- Keep all coverage Pest-based and run through Sail with the smallest targeted verification pack.
|
||||||
|
|
||||||
|
## Complexity Tracking
|
||||||
|
|
||||||
|
No constitution exception or BLOAT-001-triggering addition is planned. The intended implementation reuses existing aggregate, helper, and page/widget structure.
|
||||||
|
|
||||||
|
## Proportionality Review
|
||||||
|
|
||||||
|
- **Current operator problem**: The tenant dashboard currently lets KPI counts, attention logic, compare calmness, and recent-history surfaces describe different semantic worlds on the same page.
|
||||||
|
- **Existing structure is insufficient because**: Widget-local queries and raw route links have diverged from existing canonical findings and compare semantics, and canonical operations links do not currently preserve tenant context from the dashboard.
|
||||||
|
- **Narrowest correct implementation**: Align the existing widgets, existing aggregate-backed compare truth, existing findings filters, and existing operations link helpers instead of adding new persistence, new routes, or a new dashboard domain model.
|
||||||
|
- **Ownership cost created**: A focused set of widget and drill-through tests plus a small amount of helper and copy maintenance.
|
||||||
|
- **Alternative intentionally rejected**: A new tenant-dashboard aggregate, a global posture component, or a layout-level dashboard rewrite was rejected because the current-release problem is semantic alignment on existing surfaces.
|
||||||
|
- **Release truth**: Current-release truth. The affected widgets and routes already exist and are already used by operators.
|
||||||
128
specs/173-tenant-dashboard-truth-alignment/quickstart.md
Normal file
128
specs/173-tenant-dashboard-truth-alignment/quickstart.md
Normal file
@ -0,0 +1,128 @@
|
|||||||
|
# Quickstart: Tenant Dashboard KPI & Attention Truth Alignment
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
Validate that the tenant dashboard no longer appears calmer than the tenant's real governance, findings, compare, and operations state, and that KPI and attention drill-throughs lead to semantically matching destinations.
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
|
||||||
|
1. Start Sail.
|
||||||
|
2. Ensure you have a tenant with dashboard access and current workspace context.
|
||||||
|
3. Seed or create tenant scenarios for:
|
||||||
|
- no attention-worthy conditions
|
||||||
|
- overdue active findings with no new drift
|
||||||
|
- lapsed accepted-risk governance
|
||||||
|
- expiring governance
|
||||||
|
- high-severity active findings
|
||||||
|
- compare trust limitations or stale compare posture
|
||||||
|
- healthy operations-only activity with otherwise healthy governance
|
||||||
|
- failed, warning, or unusually long-running or stalled operations that require follow-up
|
||||||
|
4. Ensure the current user is entitled to the tenant dashboard, tenant findings list, Baseline Compare landing, and canonical Operations routes.
|
||||||
|
5. Prepare one in-scope tenant member who can open the dashboard but lacks at least one downstream destination capability so disabled or non-clickable affordances can be verified.
|
||||||
|
|
||||||
|
## Implementation Validation Order
|
||||||
|
|
||||||
|
### 1. Run existing compare and attention truth baselines
|
||||||
|
|
||||||
|
```bash
|
||||||
|
vendor/bin/sail artisan test --compact tests/Feature/Filament/NeedsAttentionWidgetTest.php
|
||||||
|
vendor/bin/sail artisan test --compact tests/Feature/Filament/BaselineCompareNowWidgetTest.php
|
||||||
|
vendor/bin/sail artisan test --compact tests/Feature/Filament/BaselineCompareSummaryConsistencyTest.php
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected outcome:
|
||||||
|
- Existing aggregate-backed compare and governance truth remains stable.
|
||||||
|
- Compare posture still suppresses false calm for stale, unavailable, failed, and limited-confidence scenarios.
|
||||||
|
|
||||||
|
### 2. Run focused dashboard truth-alignment coverage
|
||||||
|
|
||||||
|
```bash
|
||||||
|
vendor/bin/sail artisan test --compact tests/Feature/Filament/DashboardKpisWidgetTest.php
|
||||||
|
vendor/bin/sail artisan test --compact tests/Feature/Filament/TenantDashboardTruthAlignmentTest.php
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected outcome:
|
||||||
|
- KPI labels match their count universe.
|
||||||
|
- High-severity and active findings semantics are consistent or explicitly differentiated.
|
||||||
|
- The full tenant dashboard does not present an all-clear when overdue, lapsed, or expiring governance, compare-limited conditions, or attention-worthy operations follow-up exist.
|
||||||
|
|
||||||
|
### 3. Run destination continuity and tenant-scope coverage
|
||||||
|
|
||||||
|
```bash
|
||||||
|
vendor/bin/sail artisan test --compact tests/Feature/Monitoring/OperationsDashboardDrillthroughTest.php
|
||||||
|
vendor/bin/sail artisan test --compact tests/Feature/OpsUx/CanonicalViewRunLinksTest.php
|
||||||
|
vendor/bin/sail artisan test --compact tests/Feature/Filament/TenantDashboardTenantScopeTest.php
|
||||||
|
vendor/bin/sail artisan test --compact tests/Feature/Filament/TenantDashboardDbOnlyTest.php
|
||||||
|
vendor/bin/sail artisan test --compact tests/Feature/Filament/TableStandardsBaselineTest.php
|
||||||
|
vendor/bin/sail artisan test --compact tests/Feature/Filament/TableDetailVisibilityTest.php
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected outcome:
|
||||||
|
- Canonical Operations links opened from the tenant dashboard preserve tenant context.
|
||||||
|
- Dashboard drill-throughs remain tenant-safe and DB-only at render time.
|
||||||
|
- Members who can see a dashboard state but lack the downstream capability get disabled or non-clickable explanatory affordances instead of clickable dead-end links.
|
||||||
|
- Recent table surfaces retain their diagnostic framing and detail-visibility rules in the final verification pack.
|
||||||
|
|
||||||
|
### 4. Run destination-side findings filter coverage
|
||||||
|
|
||||||
|
```bash
|
||||||
|
vendor/bin/sail artisan test --compact tests/Feature/Findings/FindingsListDefaultsTest.php
|
||||||
|
vendor/bin/sail artisan test --compact tests/Feature/Findings/FindingsListFiltersTest.php
|
||||||
|
vendor/bin/sail artisan test --compact tests/Feature/Findings/FindingAdminTenantParityTest.php
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected outcome:
|
||||||
|
- Findings destinations still honor the tabs and filters that dashboard drill-throughs depend on.
|
||||||
|
- Active, overdue, and high-severity continuity remains recognizable on the target list.
|
||||||
|
|
||||||
|
### 5. Format touched implementation files
|
||||||
|
|
||||||
|
```bash
|
||||||
|
vendor/bin/sail bin pint --dirty --format agent
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected outcome:
|
||||||
|
- All changed implementation files conform to project formatting rules.
|
||||||
|
|
||||||
|
### 6. Re-run the final verification pack
|
||||||
|
|
||||||
|
```bash
|
||||||
|
vendor/bin/sail artisan test --compact tests/Feature/Filament/DashboardKpisWidgetTest.php
|
||||||
|
vendor/bin/sail artisan test --compact tests/Feature/Filament/TenantDashboardTruthAlignmentTest.php
|
||||||
|
vendor/bin/sail artisan test --compact tests/Feature/Monitoring/OperationsDashboardDrillthroughTest.php
|
||||||
|
vendor/bin/sail artisan test --compact tests/Feature/Filament/NeedsAttentionWidgetTest.php
|
||||||
|
vendor/bin/sail artisan test --compact tests/Feature/Filament/BaselineCompareNowWidgetTest.php
|
||||||
|
vendor/bin/sail artisan test --compact tests/Feature/Filament/BaselineCompareSummaryConsistencyTest.php
|
||||||
|
vendor/bin/sail artisan test --compact tests/Feature/Findings/FindingsListDefaultsTest.php
|
||||||
|
vendor/bin/sail artisan test --compact tests/Feature/Findings/FindingsListFiltersTest.php
|
||||||
|
vendor/bin/sail artisan test --compact tests/Feature/Findings/FindingAdminTenantParityTest.php
|
||||||
|
vendor/bin/sail artisan test --compact tests/Feature/OpsUx/CanonicalViewRunLinksTest.php
|
||||||
|
vendor/bin/sail artisan test --compact tests/Feature/Filament/TenantDashboardTenantScopeTest.php
|
||||||
|
vendor/bin/sail artisan test --compact tests/Feature/Filament/TenantDashboardDbOnlyTest.php
|
||||||
|
vendor/bin/sail artisan test --compact tests/Feature/Filament/TableStandardsBaselineTest.php
|
||||||
|
vendor/bin/sail artisan test --compact tests/Feature/Filament/TableDetailVisibilityTest.php
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected outcome:
|
||||||
|
- The formatted implementation still passes the same consolidated verification pack described in the tasks artifact.
|
||||||
|
|
||||||
|
## Manual Smoke Check
|
||||||
|
|
||||||
|
1. Start a 10-second timer and open `/admin/t/{tenant}` for seeded tenants representing overdue findings, expiring governance, compare limitations, healthy operations-only activity, and attention-worthy operations follow-up.
|
||||||
|
2. Within the 10-second scan, confirm an operator can tell whether the tenant has governance attention, compare caution, operations-only activity, attention-worthy operations follow-up, or no immediate action required.
|
||||||
|
3. For a tenant with overdue findings but no `new` drift findings, confirm the dashboard still reads as needing attention and does not fall back to calm or trustworthy wording.
|
||||||
|
4. Click the relevant KPI and confirm the findings destination shows the same subset or explicitly broader related framing.
|
||||||
|
5. Click a `Needs Attention` item for overdue findings, high-severity active findings, lapsed governance, expiring governance, compare posture, and operations follow-up and confirm each lands on the correct tenant-scoped working surface.
|
||||||
|
6. Open the operations KPI or operations attention path and confirm `/admin/operations` opens with tenant context preserved.
|
||||||
|
7. Sign in as the permission-limited in-scope member and confirm any visible dashboard state without downstream capability renders helper text with a disabled or non-clickable affordance instead of a clickable dead-end link.
|
||||||
|
8. Verify that `Recent Drift Findings` and `Recent Operations` still read as recent context rather than the page's primary queue.
|
||||||
|
9. Click one row in `Recent Drift Findings` and one row in `Recent Operations` and confirm each opens the expected canonical detail surface.
|
||||||
|
10. Switch to a tenant with healthy compare posture and no attention-worthy conditions and confirm calm or healthy signals return consistently.
|
||||||
|
|
||||||
|
## Non-Goals For This Slice
|
||||||
|
|
||||||
|
- No new database migration.
|
||||||
|
- No new Graph contract or provider workflow.
|
||||||
|
- No new tenant-posture hero component.
|
||||||
|
- No new dashboard route family.
|
||||||
|
- No conversion of recent tables into full action queues.
|
||||||
73
specs/173-tenant-dashboard-truth-alignment/research.md
Normal file
73
specs/173-tenant-dashboard-truth-alignment/research.md
Normal file
@ -0,0 +1,73 @@
|
|||||||
|
# Phase 0 Research: Tenant Dashboard KPI & Attention Truth Alignment
|
||||||
|
|
||||||
|
## Decision: Reuse the existing `TenantGovernanceAggregate` and compare summary path instead of creating a new dashboard aggregate
|
||||||
|
|
||||||
|
**Rationale**: `NeedsAttention` and `BaselineCompareNow` already depend on `TenantGovernanceAggregate`, which itself is derived from `BaselineCompareStats` and `BaselineCompareSummaryAssessor`. The tenant dashboard truth problem is not missing data; it is divergent interpretation and drill-through behavior across existing widgets. Extending the current aggregate-backed guard family is narrower than inventing a new dashboard-specific summary service.
|
||||||
|
|
||||||
|
**Alternatives considered**:
|
||||||
|
- Create a new persisted tenant-dashboard summary record: rejected because the spec explicitly forbids new persistence and the problem is request-time semantics, not a new lifecycle truth.
|
||||||
|
- Create a second query-backed dashboard aggregate: rejected because it would split ownership away from the existing compare and governance summary path.
|
||||||
|
|
||||||
|
## Decision: Treat `Finding::openStatusesForQuery()` and existing findings tabs and quick filters as the canonical active/open universe for dashboard continuity
|
||||||
|
|
||||||
|
**Rationale**: The tenant findings destination already defines `Needs action` and `Overdue` tabs using `Finding::openStatusesForQuery()`, and the findings list already exposes `high_severity`, `overdue`, and status-based quick filtering. These are the closest existing source of truth for what the operator should recognize after clicking a dashboard signal. Dashboard metrics should align to these semantics or be visibly renamed when intentionally narrower.
|
||||||
|
|
||||||
|
**Alternatives considered**:
|
||||||
|
- Keep dashboard-local status semantics and accept looser drill-through continuity: rejected because it preserves the trust gap the spec is trying to remove.
|
||||||
|
- Introduce a new dashboard-only filter vocabulary: rejected because it would add a second surface contract for the same findings universe.
|
||||||
|
|
||||||
|
## Decision: Treat high severity at tenant-summary level as `HIGH + CRITICAL` active findings unless a metric is explicitly labeled as narrower
|
||||||
|
|
||||||
|
**Rationale**: `BaselineCompareStats` already counts `highSeverityActiveFindingsCount` using open statuses plus `HIGH + CRITICAL`, and the findings list `high_severity` quick filter uses the same severity set. `DashboardKpis` is the current outlier because it only counts `SEVERITY_HIGH` and `STATUS_NEW`. The narrowest fix is to align `high severity` language to the existing broader tenant-summary meaning and reserve narrower wording for explicitly `new` or `drift-only` subsets.
|
||||||
|
|
||||||
|
**Alternatives considered**:
|
||||||
|
- Downgrade every other surface to `SEVERITY_HIGH` only: rejected because it would discard an existing criticality distinction already present in the findings destination and aggregate.
|
||||||
|
- Let `high severity` continue to mean different things by widget: rejected because the label collision is part of the operator trust problem.
|
||||||
|
|
||||||
|
## Decision: Make `NeedsAttention` directly actionable using existing tenant-safe destinations instead of leaving it as summary-only text
|
||||||
|
|
||||||
|
**Rationale**: The current widget already computes the right tenant-level problem families but only exposes summary text and, for compare posture, a `nextStep` label without navigation. The spec requires central attention states to become genuine start points. Existing findings, baseline compare, and operations destinations already exist and are the right follow-up surfaces.
|
||||||
|
|
||||||
|
**Alternatives considered**:
|
||||||
|
- Keep `NeedsAttention` non-navigational and rely on adjacent widgets for follow-up: rejected because it leaves the primary attention surface incomplete.
|
||||||
|
- Introduce a new dedicated attention page: rejected because the spec explicitly avoids new overview architecture.
|
||||||
|
|
||||||
|
## Decision: Keep canonical Operations routes and push tenant-prefilter continuity into existing link and filter helpers
|
||||||
|
|
||||||
|
**Rationale**: `/admin/operations` and `/admin/operations/{run}` are already the canonical operations destinations. The current dashboard KPI uses a raw `route('admin.operations.index')`, which loses tenant context. `OperationRunLinks` and the Operations page already provide the right seam to carry tenant-aware filter or navigation context without multiplying route families.
|
||||||
|
|
||||||
|
**Alternatives considered**:
|
||||||
|
- Add tenant-specific operations pages under `/admin/t/{tenant}`: rejected because the repo already standardized operations on canonical admin routes.
|
||||||
|
- Leave dashboard operations drill-through unfiltered: rejected because it breaks the spec's drill-through continuity requirement.
|
||||||
|
|
||||||
|
## Decision: Treat attention-worthy operations follow-up as failed, warning, or unusually long-running or stalled tenant runs
|
||||||
|
|
||||||
|
**Rationale**: Spec 173 distinguishes governance posture from operations activity. Healthy queued or running operations should remain visible as activity, but they must not be allowed to suppress or replace governance signals. Only runs whose current status or outcome indicates failure, warning, or unusually long-running or stalled execution should escalate into `NeedsAttention` follow-up.
|
||||||
|
|
||||||
|
**Alternatives considered**:
|
||||||
|
- Treat any active operation as attention-worthy: rejected because it would collapse activity and risk into one noisy signal.
|
||||||
|
- Ignore operations follow-up completely on the dashboard: rejected because failed or stalled tenant runs do require operator follow-up and are explicitly in scope.
|
||||||
|
|
||||||
|
## Decision: Permission-limited members may see truth but must not get clickable dead-end drill-throughs
|
||||||
|
|
||||||
|
**Rationale**: The constitution allows visible disabled actions for in-scope members who lack capability, while the spec forbids dead-end drill-throughs. The narrowest consistent behavior is to keep dashboard truth visible but render the affordance as disabled or non-clickable with helper text instead of a clickable link that would only fail after navigation.
|
||||||
|
|
||||||
|
**Alternatives considered**:
|
||||||
|
- Hide the state entirely: rejected because it can make the dashboard look calmer than reality for an otherwise entitled tenant member.
|
||||||
|
- Allow the click and rely on a downstream `403`: rejected because it violates the no-dead-end drill-through requirement and weakens operator trust.
|
||||||
|
|
||||||
|
## Decision: Preserve `RecentDriftFindings` and `RecentOperations` as diagnostic recency surfaces rather than queue surfaces
|
||||||
|
|
||||||
|
**Rationale**: Both table widgets already use row-click inspection, default sort, and domain-specific empty states, and their queries intentionally include recent history without filtering to only active work. The spec calls out that recent surfaces can remain diagnostic if that role is explicit. Treating them as recency/context surfaces is narrower and avoids conflating history with posture.
|
||||||
|
|
||||||
|
**Alternatives considered**:
|
||||||
|
- Convert recent tables into tightly filtered actionable queues: rejected because it would expand the feature into a dashboard redesign and overlap with existing findings and operations destinations.
|
||||||
|
- Remove recent surfaces from the dashboard: rejected because the spec is about truth alignment, not surface removal.
|
||||||
|
|
||||||
|
## Decision: Protect the feature with cross-widget parity and drill-through tests instead of one-off manual smoke checks only
|
||||||
|
|
||||||
|
**Rationale**: The highest-risk regressions are semantic: mismatched count universes, false calm, and tenant context loss on destinations. Focused Pest tests around widgets and route continuity can protect those business truths directly and stay aligned with the repo's existing Livewire-heavy testing style.
|
||||||
|
|
||||||
|
**Alternatives considered**:
|
||||||
|
- Rely on manual dashboard review only: rejected because subtle truth drift will recur.
|
||||||
|
- Add a broad browser-only suite: rejected because the core logic is already well served by focused Livewire and feature tests, with existing browser coverage reserved for route and smoke scenarios where needed.
|
||||||
231
specs/173-tenant-dashboard-truth-alignment/spec.md
Normal file
231
specs/173-tenant-dashboard-truth-alignment/spec.md
Normal file
@ -0,0 +1,231 @@
|
|||||||
|
title + explanation + exactly 1 CTA, and tables provide search/sort/filters for core dimensions.
|
||||||
|
# Feature Specification: Tenant Dashboard KPI & Attention Truth Alignment
|
||||||
|
|
||||||
|
**Feature Branch**: `173-tenant-dashboard-truth-alignment`
|
||||||
|
**Created**: 2026-04-03
|
||||||
|
**Status**: Draft
|
||||||
|
**Input**: User description: "Spec 173 — Tenant Dashboard KPI & Attention Truth Alignment"
|
||||||
|
|
||||||
|
## Spec Scope Fields *(mandatory)*
|
||||||
|
|
||||||
|
- **Scope**: tenant + canonical-view
|
||||||
|
- **Primary Routes**:
|
||||||
|
- `/admin/t/{tenant}` as the tenant dashboard where `DashboardKpis`, `NeedsAttention`, `BaselineCompareNow`, `RecentDriftFindings`, and `RecentOperations` currently appear together
|
||||||
|
- `/admin/t/{tenant}/findings` and tenant finding detail routes as the primary findings drill-through destinations from dashboard summary signals
|
||||||
|
- `/admin/t/{tenant}/baseline-compare-landing` as the tenant baseline compare truth and next-action destination
|
||||||
|
- `/admin/operations` and canonical operation detail routes as the operations destinations reached from tenant dashboard activity signals
|
||||||
|
- **Data Ownership**:
|
||||||
|
- Tenant-owned: findings, finding governance state, operation runs, and other tenant-scoped operational records that the dashboard summarizes for one selected tenant
|
||||||
|
- Workspace-owned but tenant-resolved: baseline assignment, baseline profile, and related prerequisites that shape compare posture for the current tenant
|
||||||
|
- This feature introduces no new tenant-dashboard summary record; KPI, attention, compare, and recent surfaces remain derived views over existing truth sources
|
||||||
|
- **RBAC**:
|
||||||
|
- Workspace membership and tenant entitlement remain required for the tenant dashboard and every tenant-context drill-through destination
|
||||||
|
- Existing findings, baseline compare, and operations inspection permissions remain the enforcement source for what a user may open from the dashboard
|
||||||
|
- Dashboard links and summary claims must not imply or reveal tenant data beyond the current entitled tenant scope
|
||||||
|
|
||||||
|
For canonical-view specs, the spec MUST define:
|
||||||
|
|
||||||
|
- **Default filter behavior when tenant-context is active**: Any canonical-view destination opened from the tenant dashboard, especially operations routes, opens prefiltered to the active tenant so the operator lands in the same tenant world they clicked from. Operators may broaden filters only within their entitled tenant set.
|
||||||
|
- **Explicit entitlement checks preventing cross-tenant leakage**: Dashboard counts, health claims, destination links, canonical route filters, and operation or finding drill-down results must all be resolved only after workspace membership and tenant entitlement checks. Unauthorized users must not learn whether another tenant has open findings, degraded compare trust, or active operations.
|
||||||
|
|
||||||
|
## UI/UX Surface Classification *(mandatory when operator-facing surfaces are changed)*
|
||||||
|
|
||||||
|
| Surface | Surface Type | Primary Inspect/Open Model | Row Click | Secondary Actions Placement | Destructive Actions Placement | Canonical Collection Route | Canonical Detail Route | Scope Signals | Canonical Noun | Critical Truth Visible by Default | Exception Type |
|
||||||
|
|---|---|---|---|---|---|---|---|---|---|---|---|
|
||||||
|
| Tenant dashboard KPI strip | Embedded status summary / drill-in surface | Each KPI opens one matching destination for the exact problem family it names | forbidden | none | none | Tenant findings list or tenant-prefiltered operations list, depending on the KPI | Existing finding detail or operation detail once the operator drills further | The selected tenant context from the dashboard must stay explicit in the landing filter state | Findings / Finding and Operations / Operation | Count meaning, severity meaning, and whether the number is posture or activity | Multi-destination summary surface |
|
||||||
|
| Tenant dashboard `Needs Attention` | Embedded attention summary | Each attention item exposes one direct destination or one explicit next step for the named problem | forbidden | none | none | Tenant findings list, baseline compare landing, or tenant-prefiltered operations list depending on item type | Existing finding detail or operation detail once the operator drills further | Attention labels must remain tenant-scoped and reflect the same tenant truth as the surrounding dashboard | Findings / Finding, Governance, Baseline Compare, Operations / Operation | Highest-priority tenant problem and the next place to act | Multi-domain attention surface |
|
||||||
|
| Tenant dashboard `BaselineCompareNow` | Embedded compare posture summary | One primary CTA aligned to the current compare posture or next action | forbidden | supportive links only if they reinforce the same posture | none | `/admin/t/{tenant}/baseline-compare-landing` as the primary compare collection destination | Existing operation detail or tenant findings list when the next action demands a deeper destination | Tenant context, compare posture, and any trust limitation remain visible before navigation | Baseline Compare | Compare posture, trust limits, and next action without conflicting calm claims | Compare-trust summary surface |
|
||||||
|
| Tenant dashboard `Recent Drift Findings` | Diagnostic recency table | Full-row click to the corresponding finding detail | required | none | none | `/admin/t/{tenant}/findings` | `/admin/t/{tenant}/findings/{record}` | Tenant dashboard context and findings labels keep the table anchored to the selected tenant | Findings / Finding | Recent drift history remains visible without claiming to be the whole active queue | Diagnostic recency surface |
|
||||||
|
| Tenant dashboard `Recent Operations` | Diagnostic recency table | Full-row click to the corresponding operation detail | required | none | none | `/admin/operations` with tenant prefilter | Existing canonical operation detail route | Tenant dashboard context and visible operation labels keep the table anchored to the selected tenant | Operations / Operation | Recent execution history remains visible without being mistaken for governance posture | Diagnostic recency surface |
|
||||||
|
|
||||||
|
## Operator Surface Contract *(mandatory when operator-facing surfaces are changed)*
|
||||||
|
|
||||||
|
| Surface | Primary Persona | Surface Type | Primary Operator Question | Default-visible Information | Diagnostics-only Information | Status Dimensions Used | Mutation Scope | Primary Actions | Dangerous Actions |
|
||||||
|
|---|---|---|---|---|---|---|---|---|---|
|
||||||
|
| Tenant dashboard KPI strip | Tenant operator | Embedded status summary / drill-in surface | Do I have active governance problems, critical findings, or live activity that changes what I should do next? | Small set of trustworthy counts with honest labels, clear problem-family separation, and consistent destination meaning | Raw query detail, low-level status taxonomy, and broader historical counts stay off the strip | active findings pressure, high-severity pressure, operations activity | none | Open the matching findings or operations destination from the KPI | none |
|
||||||
|
| Tenant dashboard `Needs Attention` | Tenant operator | Embedded attention summary | What needs action right now, and where should I start? | Highest-priority attention items, compare caution when relevant, and the next appropriate destination | Deep diagnostic cause chains, raw compare internals, and operation metadata remain secondary | governance attention, overdue urgency, compare trust posture, materially relevant operations status | none | Open findings, open baseline compare, or open tenant-filtered operations follow-up | none |
|
||||||
|
| Tenant dashboard `BaselineCompareNow` | Tenant operator | Embedded compare posture summary | Is baseline compare trustworthy enough to rely on, and what should I do next? | Compare posture, trust limits, last compared context, and one aligned next step | Deep evidence gaps, duplicate-name diagnostics, and low-level run internals remain secondary | compare availability, compare trust, compare freshness, governance attention | none | Open Baseline Compare, open findings, or open the current operation when that is the next action | none |
|
||||||
|
| Tenant dashboard `Recent Drift Findings` | Tenant operator | Diagnostic recency table | What drift findings were seen recently if I need context or history? | Recent finding rows with subject, severity, status, and recency | Full finding history, workflow detail, and evidence payload remain on detail pages | recency, severity, workflow status | none | Open the selected finding detail | none |
|
||||||
|
| Tenant dashboard `Recent Operations` | Tenant operator | Diagnostic recency table | What tenant operations ran recently if I need execution context? | Recent operation rows with operation label, status, outcome, and started time | Full summary counts, traces, and payload diagnostics remain on operation detail pages | recency, execution status, execution outcome | none | Open the selected operation detail | none |
|
||||||
|
|
||||||
|
## Proportionality Review *(mandatory when structural complexity is introduced)*
|
||||||
|
|
||||||
|
- **New source of truth?**: No. Findings, governance validity, compare posture, and operation-run truth remain the source data.
|
||||||
|
- **New persisted entity/table/artifact?**: No. This slice explicitly avoids new summary persistence.
|
||||||
|
- **New abstraction?**: No. The alignment should be achieved by tightening existing summary definitions, guard rules, and destination semantics rather than adding a new dashboard framework.
|
||||||
|
- **New enum/state/reason family?**: No. Existing finding, governance, compare, and operation state families remain authoritative.
|
||||||
|
- **New cross-domain UI framework/taxonomy?**: No. This spec aligns existing tenant-level overview surfaces and explicitly avoids a new global posture system.
|
||||||
|
- **Current operator problem**: The tenant dashboard can currently present narrower KPI counts, broader attention logic, calmer compare wording, and diagnostic recency tables in ways that make the whole page feel quieter or less coherent than the underlying tenant truth.
|
||||||
|
- **Existing structure is insufficient because**: Similar claims are owned by separate widget-local counting and guard logic, and some drill-through links lead into destinations that do not clearly match the number or problem statement the operator clicked.
|
||||||
|
- **Narrowest correct implementation**: Align the existing five dashboard surfaces and their target destinations around shared active-count meaning, severity meaning, calm-claim guards, and tenant-preserving drill-through semantics without redesigning the whole dashboard.
|
||||||
|
- **Ownership cost**: The repo takes on cross-widget regression coverage, some shared summary-definition hardening, and a small amount of copy or empty-state tightening to keep the dashboard semantically aligned over time.
|
||||||
|
- **Alternative intentionally rejected**: A full dashboard redesign, a new global tenant-posture indicator, or a new persisted dashboard aggregate was rejected because the immediate problem is truth alignment on existing tenant-level overview surfaces.
|
||||||
|
- **Release truth**: Current-release truth. The semantic drift is already present on the shipped tenant dashboard.
|
||||||
|
|
||||||
|
## User Scenarios & Testing *(mandatory)*
|
||||||
|
|
||||||
|
### User Story 1 - Trust The Dashboard At A Glance (Priority: P1)
|
||||||
|
|
||||||
|
As a tenant operator, I want the tenant dashboard to tell me within seconds whether governance or findings need action, so that I do not have to reconcile contradictory calm and warning signals across the same page.
|
||||||
|
|
||||||
|
**Why this priority**: The tenant dashboard is the main entry point for daily tenant triage. If it feels calmer than reality, every later decision starts from the wrong assumption.
|
||||||
|
|
||||||
|
**Independent Test**: Can be fully tested by seeding one tenant with combinations of overdue findings, lapsed governance, compare limitations, and active operations, then verifying that the dashboard surfaces agree on whether the tenant currently needs attention.
|
||||||
|
|
||||||
|
**Acceptance Scenarios**:
|
||||||
|
|
||||||
|
1. **Given** a tenant has overdue findings or lapsed accepted-risk governance, **When** an entitled operator opens the tenant dashboard, **Then** the page does not present an overall calm or healthy impression.
|
||||||
|
2. **Given** a tenant has no attention-worthy governance or findings conditions, **When** an entitled operator opens the tenant dashboard, **Then** the dashboard may present calm or healthy signals consistently across the covered summary surfaces.
|
||||||
|
3. **Given** a tenant has compare trust limitations but few active findings, **When** the tenant dashboard renders, **Then** compare caution remains visible instead of being masked by quieter KPI or recency surfaces.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### User Story 2 - Click A Number And Recover The Same Problem (Priority: P1)
|
||||||
|
|
||||||
|
As a tenant operator, I want any KPI or attention item I click to land me on a surface that clearly matches the count or problem family I clicked, so that the dashboard feels trustworthy and action-oriented.
|
||||||
|
|
||||||
|
**Why this priority**: A tenant-level number that cannot be rediscovered on its target page breaks operator trust immediately.
|
||||||
|
|
||||||
|
**Independent Test**: Can be fully tested by clicking representative KPI and attention states in seeded scenarios and verifying that the destination preserves tenant context and exposes the same problem family with recognizable count or label semantics.
|
||||||
|
|
||||||
|
**Acceptance Scenarios**:
|
||||||
|
|
||||||
|
1. **Given** a dashboard KPI names a findings subset, **When** the operator opens it, **Then** the destination shows that same subset or explicitly states that it is a broader related view.
|
||||||
|
2. **Given** a tenant dashboard attention item names compare posture, overdue findings, or operations follow-up, **When** the operator opens it, **Then** the destination is the matching tenant-scoped working surface for that problem.
|
||||||
|
3. **Given** a canonical-view destination is used, **When** the operator arrives from the tenant dashboard, **Then** the destination is already filtered to the originating tenant.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### User Story 3 - Separate Posture From Activity And History (Priority: P2)
|
||||||
|
|
||||||
|
As a tenant operator, I want the dashboard to distinguish governance posture from live operations and from recent historical lists, so that I can tell what is risky, what is merely running, and what is only recent context.
|
||||||
|
|
||||||
|
**Why this priority**: The dashboard should orient action, not flatten risk, activity, and history into one undifferentiated signal layer.
|
||||||
|
|
||||||
|
**Independent Test**: Can be fully tested by seeding one tenant with operations-only activity, governance-only issues, and historical-but-complete records, then verifying that each state is surfaced in the right part of the dashboard without diluting the operator's priority order.
|
||||||
|
|
||||||
|
**Acceptance Scenarios**:
|
||||||
|
|
||||||
|
1. **Given** a tenant has active operations but no current governance problems, **When** the dashboard renders, **Then** the page presents activity without implying governance trouble.
|
||||||
|
2. **Given** a tenant has active governance problems but no operations currently running, **When** the dashboard renders, **Then** the page prioritizes governance attention rather than recent or idle operations history.
|
||||||
|
3. **Given** recent lists contain historical successful operations or older drift findings, **When** the dashboard renders, **Then** those lists remain clearly diagnostic and do not override higher-priority attention signals.
|
||||||
|
|
||||||
|
### Edge Cases
|
||||||
|
|
||||||
|
- A tenant may have zero `new` drift findings while still having overdue active findings, lapsed governance, expiring governance, or other attention-worthy states; the dashboard must still read as action-needed.
|
||||||
|
- A tenant may have an active operation but otherwise healthy governance posture; the dashboard must show activity without recasting it as tenant risk.
|
||||||
|
- A tenant may have compare trust limitations, stale compare posture, or missing prerequisites while recent findings and operations look quiet; the compare summary must still prevent a falsely calm overall impression.
|
||||||
|
- Recent findings or recent operations may include historical or terminal records; those surfaces must not be interpreted as the tenant's primary action queue unless their visible framing says so.
|
||||||
|
- A user may be entitled to the tenant dashboard but only to a subset of downstream destinations; the dashboard must not expose dead-end drill-throughs that imply broader access than the user actually has. In that case the summary state may remain visible, but the affordance must be disabled or non-clickable with helper text instead of a clickable link that only fails after navigation.
|
||||||
|
|
||||||
|
## Requirements *(mandatory)*
|
||||||
|
|
||||||
|
**Constitution alignment (required):** This feature introduces no new Microsoft Graph calls, no new change operation, and no new long-running process. It hardens the tenant dashboard by aligning existing findings, governance, compare, and operations truth that is already available from current records and current summary logic.
|
||||||
|
|
||||||
|
**Constitution alignment (PROP-001 / ABSTR-001 / PERSIST-001 / STATE-001 / BLOAT-001):** This feature is intentionally narrow. It adds no new persistence, no new abstraction layer, no new status family, and no new cross-domain taxonomy. The required improvement is truthful alignment of existing summary meaning, not a new tenant-posture architecture.
|
||||||
|
|
||||||
|
**Constitution alignment (OPS-UX):** No new `OperationRun` type or execution path is introduced. Existing operation surfaces remain the only source for execution progress and terminal outcome. This slice only changes how tenant-dashboard summaries point to or describe existing operations.
|
||||||
|
|
||||||
|
**Constitution alignment (RBAC-UX):** The feature lives in the tenant/admin plane with tenant-context dashboard surfaces and tenant-preserving drill-throughs into existing tenant or canonical admin destinations. Non-members or users without tenant entitlement remain `404`. In-scope members lacking a destination capability remain `403` under the existing enforcement model. Server-side authorization remains the authority for every downstream destination. When an in-scope tenant member can see a dashboard summary state but lacks the downstream capability for its destination, the summary may remain visible but its affordance MUST be disabled or non-clickable with helper text rather than a clickable dead-end drill-through. No new destructive action is introduced.
|
||||||
|
|
||||||
|
**Constitution alignment (OPS-EX-AUTH-001):** Not applicable. No authentication handshake behavior is changed.
|
||||||
|
|
||||||
|
**Constitution alignment (BADGE-001):** Existing centralized badge semantics for finding severity, finding status, compare posture, operation status, and operation outcome remain the semantic source. This feature may change where those badge families appear or how they are grouped, but it must not introduce local badge vocabularies for dashboard calm, caution, or attention claims.
|
||||||
|
|
||||||
|
**Constitution alignment (UI-FIL-001):** The feature reuses existing Filament dashboard widgets, stats, cards, table widgets, links, and shared badge primitives. It should avoid custom local markup for dashboard truth emphasis and instead rely on aligned copy, aligned counts, and existing visual primitives.
|
||||||
|
|
||||||
|
**Constitution alignment (UI-NAMING-001):** The target objects are tenant dashboard KPI counts, attention items, compare-posture claims, and operations activity claims. Primary operator-facing vocabulary remains `Open drift findings`, `High severity`, `Needs attention`, `Baseline compare`, `Operations`, and related domain nouns. If a dashboard metric intentionally becomes narrower or broader than another surface, the naming must make that difference visible instead of leaving two similarly named signals to mean different things.
|
||||||
|
|
||||||
|
**Constitution alignment (UI-CONST-001 / UI-SURF-001 / UI-HARD-001 / UI-EX-001 / UI-REVIEW-001):** Each affected tenant dashboard surface must have exactly one primary inspect or drill-through model. KPI stats use one matching destination per metric. Attention items use one matching destination or one explicit next step per item. `BaselineCompareNow` uses one aligned next-action model. `RecentDriftFindings` and `RecentOperations` keep row-click inspection as diagnostic recency surfaces. No destructive action is introduced, and no redundant view affordance is required.
|
||||||
|
|
||||||
|
**Constitution alignment (OPSURF-001):** The dashboard must stay operator-first. Default-visible content must separate governance posture, compare trust, operations activity, and recency instead of collapsing them into one ambiguous signal. Diagnostics remain on downstream pages. Mutation scope remains none for the dashboard itself.
|
||||||
|
|
||||||
|
**Constitution alignment (UI-SEM-001 / LAYER-001 / TEST-TRUTH-001):** Direct per-widget interpretation has already drifted into contradictory dashboard meaning. This feature must reduce duplicate local ownership of count meaning and calm-claim guards, but it must do so by aligning existing definitions rather than adding a new presentation framework. Tests must focus on business truth across widgets and drill-throughs.
|
||||||
|
|
||||||
|
**Constitution alignment (Filament Action Surfaces):** The Action Surface Contract remains satisfied. The tenant dashboard page and its widgets remain inspection and drill-through surfaces with exactly one primary inspect or open model per surface, no new destructive actions, no empty action groups, and no redundant `View` buttons. UI-FIL-001 is satisfied and no exception is required.
|
||||||
|
|
||||||
|
**Constitution alignment (UX-001 — Layout & Information Architecture):** This feature does not add new create or edit screens. It refines existing dashboard summary and table surfaces. The dashboard must keep high-priority attention and compare posture above recency context, and existing table widgets must continue to use explicit headings, empty states, and current table affordances without pretending to be the dashboard's primary posture layer.
|
||||||
|
|
||||||
|
### Functional Requirements
|
||||||
|
|
||||||
|
- **FR-173-001**: Tenant dashboard surfaces that make findings-related summary claims MUST use a consistent active-findings meaning across `DashboardKpis`, `NeedsAttention`, `BaselineCompareNow`, and their related destination copy, unless a deliberately narrower subset is clearly named as such.
|
||||||
|
- **FR-173-002**: Tenant dashboard high-severity claims MUST use the same severity and activity meaning across KPI and attention surfaces, or visibly different labels that tell the operator they are not the same subset.
|
||||||
|
- **FR-173-003**: Positive, calm, healthy, or trustworthy tenant-level claims MUST be suppressed whenever the same tenant currently has attention-worthy overdue findings, lapsed accepted-risk governance, expiring governance requiring near-term follow-up, high-severity active findings, materially degraded compare posture, or materially relevant operations problems.
|
||||||
|
- **FR-173-004**: The overall tenant dashboard MUST NOT appear healthier than the strongest attention-worthy tenant condition visible on the page.
|
||||||
|
- **FR-173-005**: Every tenant dashboard KPI or attention item that names a non-zero count or actionable problem family MUST resolve to one semantically matching destination surface where the operator can recognize the same problem family without guesswork. Intentionally passive reassurance or no-assignment empty states may remain non-interactive when their copy does not imply a recoverable work queue.
|
||||||
|
- **FR-173-006**: When the matching destination is a canonical-view route, opening it from the tenant dashboard MUST preserve tenant context through a pre-applied tenant filter.
|
||||||
|
- **FR-173-007**: `NeedsAttention` MUST cover at least overdue findings, lapsed governance, expiring governance, high-severity active findings, baseline compare posture limitations, and materially relevant operations follow-up when those states exist. For this slice, materially relevant operations follow-up means tenant-scoped runs whose current status or outcome indicates failure, warning, or unusually long-running or stalled execution that requires operator review; healthy queued or running activity alone remains an activity signal, not a governance-risk signal.
|
||||||
|
- **FR-173-008**: Central `NeedsAttention` items MUST be actionable. They MUST either open the matching target surface directly or expose one explicit next step that sends the operator toward that surface. If the current in-scope user lacks the destination capability, the item may remain visible only as a disabled or non-clickable explanatory state and MUST NOT render as a clickable dead-end drill-through.
|
||||||
|
- **FR-173-009**: Tenant dashboard summary surfaces MUST distinguish governance posture from operations activity. Active operations MUST NOT be presented as though they are themselves governance health outcomes.
|
||||||
|
- **FR-173-010**: `RecentDriftFindings` and `RecentOperations` MUST remain visibly diagnostic or recency-oriented surfaces and MUST NOT dilute the tenant's priority order when higher-priority attention conditions exist elsewhere on the dashboard.
|
||||||
|
- **FR-173-011**: `BaselineCompareNow` MUST not present a positive or trustworthy posture when the same tenant dashboard already contains unresolved caution or attention conditions that materially limit that claim.
|
||||||
|
- **FR-173-012**: Dashboard count meaning, calm-claim guards, and drill-through continuity MUST be aligned using existing findings truth, existing governance truth, existing compare assessment logic, and existing destination pages rather than a new persisted dashboard aggregate.
|
||||||
|
- **FR-173-013**: The feature MUST stay bounded to the existing tenant dashboard surface family and MUST NOT require a new global tenant-posture indicator, a dashboard layout rebuild, or a new schema artifact.
|
||||||
|
- **FR-173-014**: Regression coverage MUST exercise cross-widget consistency for active findings, high severity, overdue, lapsed, or expiring governance, compare limitations, healthy operations-only activity, attention-worthy operations follow-up, and KPI or attention drill-through continuity.
|
||||||
|
|
||||||
|
## UI Action Matrix *(mandatory when Filament is changed)*
|
||||||
|
|
||||||
|
| Surface | Location | Header Actions | Inspect Affordance (List/Table) | Row Actions (max 2 visible) | Bulk Actions (grouped) | Empty-State CTA(s) | View Header Actions | Create/Edit Save+Cancel | Audit log? | Notes / Exemptions |
|
||||||
|
|---|---|---|---|---|---|---|---|---|---|---|
|
||||||
|
| Tenant dashboard page composition | `app/Filament/Pages/TenantDashboard.php` | Existing dashboard page header actions remain unchanged | n/a | n/a | n/a | n/a | n/a | n/a | no new audit behavior | Composition-only surface that must keep the current priority order of KPI, attention, compare, then recency |
|
||||||
|
| `DashboardKpis` | `app/Filament/Widgets/Dashboard/DashboardKpis.php` | none | Explicit stat click for each actionable or non-zero KPI | none | none | Existing zero-state stats remain intentionally passive reassurance | n/a | n/a | no new audit behavior | Each KPI must keep one matching destination and honest subset naming |
|
||||||
|
| `NeedsAttention` | `app/Filament/Widgets/Dashboard/NeedsAttention.php` | none | One explicit destination or next-step affordance per attention item | none | none | Healthy fallback remains read-only reassurance only when no attention condition exists | n/a | n/a | no new audit behavior | Surface currently acts as summary-only; this spec requires central items to become action-capable |
|
||||||
|
| `BaselineCompareNow` | `app/Filament/Widgets/Dashboard/BaselineCompareNow.php` | none | One aligned primary CTA based on compare posture or next action when compare work exists | none | none | Existing no-assignment state remains an intentionally passive summary state | n/a | n/a | no new audit behavior | Supportive links may remain only if they reinforce the same compare truth |
|
||||||
|
| `RecentDriftFindings` | `app/Filament/Widgets/Dashboard/RecentDriftFindings.php` | none | `recordUrl()` row click to finding detail | none | none | Existing empty state remains diagnostic, not posture-defining | n/a | n/a | no new audit behavior | Recency surface must not be mistaken for the full active findings queue |
|
||||||
|
| `RecentOperations` | `app/Filament/Widgets/Dashboard/RecentOperations.php` | none | `recordUrl()` row click to operation detail | none | none | Existing empty state remains diagnostic, not posture-defining | n/a | n/a | no new audit behavior | Canonical operations list remains the collection destination and must preserve tenant filter when reached from the dashboard |
|
||||||
|
|
||||||
|
### Key Entities *(include if feature involves data)*
|
||||||
|
|
||||||
|
- **Tenant dashboard summary signal**: A tenant-level count, posture claim, or attention message that compresses findings, governance, compare, or operations truth into one visible dashboard indicator.
|
||||||
|
- **Attention item**: A tenant-level named problem that should point to one matching working surface or one explicit next step.
|
||||||
|
- **Materially relevant operations follow-up**: A tenant-scoped operations condition that requires operator review because the current run state indicates failure, warning, or unusually long-running or stalled execution; healthy queued or running activity alone is not enough.
|
||||||
|
- **Drill-through contract**: The semantic promise that a dashboard number or message can be recognized again on the surface it opens.
|
||||||
|
- **Recent diagnostic surface**: A recency-oriented list that provides historical or active context without redefining the tenant's current governance posture.
|
||||||
|
|
||||||
|
## Success Criteria *(mandatory)*
|
||||||
|
|
||||||
|
### Measurable Outcomes
|
||||||
|
|
||||||
|
- **SC-173-001**: In regression coverage, every tested tenant scenario with overdue findings, lapsed or expiring governance, compare limitations, high-severity active findings, or attention-worthy operations follow-up produces no combination of dashboard summary signals that reads as an all-clear.
|
||||||
|
- **SC-173-002**: In regression coverage, covered KPI and attention drill-throughs land on destinations whose visible framing and filtered result set match the originating problem family in 100% of tested scenarios.
|
||||||
|
- **SC-173-003**: In operator review on seeded tenants, an operator can determine within 10 seconds whether the tenant currently has governance attention, compare caution, operations-only activity, or no immediate action required.
|
||||||
|
- **SC-173-004**: In regression coverage, operations-only scenarios do not trigger governance-problem wording, and governance-problem scenarios are not diluted by recent-history surfaces.
|
||||||
|
- **SC-173-005**: The feature ships without a required schema migration, a new persisted dashboard summary record, or a new global tenant-posture component.
|
||||||
|
|
||||||
|
## Assumptions
|
||||||
|
|
||||||
|
- Existing tenant findings surfaces, baseline compare landing, and canonical operations routes remain the correct downstream destinations for dashboard drill-throughs.
|
||||||
|
- Existing findings governance and compare-assessment logic are sufficient to align dashboard truth without introducing a new persisted dashboard aggregate.
|
||||||
|
- The current tenant dashboard surface set remains in place for this slice; the work changes truth alignment, not the overall page structure.
|
||||||
|
|
||||||
|
## Non-Goals
|
||||||
|
|
||||||
|
- Introducing a new global tenant-posture indicator or composite dashboard hero state
|
||||||
|
- Rebuilding the tenant dashboard layout or replacing the current five-surface composition
|
||||||
|
- Creating new tables, new persisted summary entities, or new findings-status families
|
||||||
|
- Rewriting workspace-level governance surfacing or the broader navigation and filter framework
|
||||||
|
- Treating recency tables as a new primary work queue beyond the targeted truth-alignment improvements
|
||||||
|
|
||||||
|
## Dependencies
|
||||||
|
|
||||||
|
- Existing `TenantDashboard` composition and current dashboard widgets
|
||||||
|
- Existing findings workflow and governance hardening work, especially tenant findings and accepted-risk governance truth
|
||||||
|
- Existing baseline compare summary and trust logic used by tenant compare surfaces
|
||||||
|
- Existing operations surface alignment and canonical operation drill-through behavior
|
||||||
|
|
||||||
|
## Follow-up Spec Candidates
|
||||||
|
|
||||||
|
- **Tenant Posture Indicator & Overview Hierarchy** for a future composite tenant posture layer once summary truth is aligned
|
||||||
|
- **Workspace Governance Posture Surfacing** for a later workspace-level summary that builds on tenant-safe tenant-posture truth
|
||||||
|
- **Recent Surface Queue/Diagnostic Separation** for a later sharper distinction between diagnostic recency lists and operator work queues
|
||||||
|
|
||||||
|
## Definition of Done
|
||||||
|
|
||||||
|
Spec 173 is complete when:
|
||||||
|
|
||||||
|
- tenant dashboard KPI, attention, compare, and recent surfaces no longer make contradictory governance or findings claims for the same tenant,
|
||||||
|
- the tenant dashboard no longer appears calmer than the underlying tenant truth,
|
||||||
|
- covered KPI and attention signals are semantically recoverable on their destinations,
|
||||||
|
- operations activity and governance posture are more clearly separated,
|
||||||
|
- positive compare or calm dashboard claims are sufficiently guarded by the same tenant-level risks that drive attention,
|
||||||
|
- and the improvement is achieved without a new persisted summary model or a full dashboard redesign.
|
||||||
215
specs/173-tenant-dashboard-truth-alignment/tasks.md
Normal file
215
specs/173-tenant-dashboard-truth-alignment/tasks.md
Normal file
@ -0,0 +1,215 @@
|
|||||||
|
# Tasks: Tenant Dashboard KPI & Attention Truth Alignment
|
||||||
|
|
||||||
|
**Input**: Design documents from `/specs/173-tenant-dashboard-truth-alignment/`
|
||||||
|
**Prerequisites**: `plan.md` (required), `spec.md` (required for user stories), `research.md`, `data-model.md`, `contracts/`, `quickstart.md`
|
||||||
|
|
||||||
|
**Tests**: Tests are REQUIRED for this feature. Use Pest coverage in `tests/Feature/Filament/DashboardKpisWidgetTest.php`, `tests/Feature/Filament/TenantDashboardTruthAlignmentTest.php`, `tests/Feature/Monitoring/OperationsDashboardDrillthroughTest.php`, `tests/Feature/Filament/NeedsAttentionWidgetTest.php`, `tests/Feature/Filament/BaselineCompareNowWidgetTest.php`, `tests/Feature/Filament/BaselineCompareSummaryConsistencyTest.php`, `tests/Feature/Findings/FindingsListDefaultsTest.php`, `tests/Feature/Findings/FindingsListFiltersTest.php`, `tests/Feature/Findings/FindingAdminTenantParityTest.php`, `tests/Feature/OpsUx/CanonicalViewRunLinksTest.php`, `tests/Feature/Filament/TenantDashboardTenantScopeTest.php`, `tests/Feature/Filament/TenantDashboardDbOnlyTest.php`, `tests/Feature/Filament/TableStandardsBaselineTest.php`, and `tests/Feature/Filament/TableDetailVisibilityTest.php`.
|
||||||
|
**Operations**: This feature does not create a new `OperationRun` type or change run lifecycle ownership. Existing canonical Operations routes remain the only operations destinations involved, and the work here is limited to tenant-prefilter continuity and operator-facing summary truth.
|
||||||
|
**RBAC**: Existing tenant-context membership, entitlement, and 404 vs 403 semantics remain unchanged. Tasks must preserve tenant-safe dashboard destinations and ensure canonical Operations drill-throughs remain filtered to the originating tenant when entered from tenant context.
|
||||||
|
**Operator Surfaces**: `DashboardKpis`, `NeedsAttention`, `BaselineCompareNow`, `RecentDriftFindings`, and `RecentOperations` must stay operator-first, with governance posture and compare caution above recency context.
|
||||||
|
**Filament UI Action Surfaces**: No new destructive actions or redundant inspect affordances are added. `DashboardKpis` and `NeedsAttention` remain drill-through summary surfaces, `BaselineCompareNow` remains a single-next-step compare surface, and `RecentDriftFindings` and `RecentOperations` remain row-click diagnostic tables.
|
||||||
|
**Filament UI UX-001**: No new create, edit, or view pages are introduced. Existing dashboard summary and table widgets keep their current layout while truth alignment and destination continuity are hardened.
|
||||||
|
**Badges**: Existing badge semantics for finding severity, finding status, compare posture, operation status, and operation outcome remain authoritative; no new page-local mappings are introduced.
|
||||||
|
|
||||||
|
**Organization**: Tasks are grouped by user story so each story can be implemented and verified as an independent increment.
|
||||||
|
|
||||||
|
## Phase 1: Setup (Dashboard Truth Test Scaffolding)
|
||||||
|
|
||||||
|
**Purpose**: Create the focused regression files and fixtures needed to implement Spec 173 safely.
|
||||||
|
|
||||||
|
- [X] T001 Create dashboard truth-alignment regression scaffolding in `tests/Feature/Filament/DashboardKpisWidgetTest.php` and `tests/Feature/Filament/TenantDashboardTruthAlignmentTest.php`
|
||||||
|
- [X] T002 [P] Create canonical operations drill-through regression scaffolding in `tests/Feature/Monitoring/OperationsDashboardDrillthroughTest.php`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 2: Foundational (Blocking Destination and Semantics Helpers)
|
||||||
|
|
||||||
|
**Purpose**: Establish the canonical findings and operations drill-through semantics that all stories depend on.
|
||||||
|
|
||||||
|
**⚠️ CRITICAL**: No user story work should begin until this phase is complete.
|
||||||
|
|
||||||
|
- [X] T003 Add destination-side continuity and permission-limited affordance assertions for dashboard-driven findings and operations filters in `tests/Feature/Findings/FindingsListFiltersTest.php` and `tests/Feature/OpsUx/CanonicalViewRunLinksTest.php`
|
||||||
|
- [X] T004 [P] Implement reusable findings subset semantics for dashboard links in `app/Models/Finding.php` and `app/Filament/Resources/FindingResource/Pages/ListFindings.php`
|
||||||
|
- [X] T005 [P] Implement tenant-prefiltered canonical Operations collection links in `app/Support/OperationRunLinks.php` and `app/Filament/Pages/Monitoring/Operations.php`
|
||||||
|
|
||||||
|
**Checkpoint**: Dashboard destinations now have one canonical findings subset model and one tenant-safe canonical Operations entry path.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 3: User Story 1 - Trust The Dashboard At A Glance (Priority: P1) 🎯 MVP
|
||||||
|
|
||||||
|
**Goal**: Make the tenant dashboard read honestly at a glance so it never looks calmer than the tenant's actual governance and findings state.
|
||||||
|
|
||||||
|
**Independent Test**: Seed one tenant with overdue findings, lapsed or expiring governance, compare limitations, attention-worthy operations follow-up, and healthy all-clear states, then verify the dashboard summary surfaces do not emit contradictory calm and warning signals.
|
||||||
|
|
||||||
|
### Tests for User Story 1
|
||||||
|
|
||||||
|
- [X] T006 [P] [US1] Add KPI truth, no-all-clear regression cases, and permission-limited KPI affordance coverage in `tests/Feature/Filament/DashboardKpisWidgetTest.php`
|
||||||
|
- [X] T007 [P] [US1] Add full-dashboard calmness suppression scenarios for overdue, lapsed, or expiring governance, compare limitations, and attention-worthy operations follow-up in `tests/Feature/Filament/TenantDashboardTruthAlignmentTest.php`
|
||||||
|
|
||||||
|
### Implementation for User Story 1
|
||||||
|
|
||||||
|
- [X] T008 [US1] Align KPI labels and count universes with canonical active/high-severity semantics and disabled/non-clickable fallback behavior in `app/Filament/Widgets/Dashboard/DashboardKpis.php`
|
||||||
|
- [X] T009 [US1] Gate healthy fallback and attention wording against aggregate-backed lapsed or expiring governance, severity truth, and defined operations follow-up in `app/Filament/Widgets/Dashboard/NeedsAttention.php` and `resources/views/filament/widgets/dashboard/needs-attention.blade.php`
|
||||||
|
- [X] T010 [US1] Keep compare calmness and baseline-compare landing next-step continuity aligned with tenant attention conditions from the existing compare assessment and governance aggregate outputs in `app/Filament/Widgets/Dashboard/BaselineCompareNow.php` and `resources/views/filament/widgets/dashboard/baseline-compare-now.blade.php`
|
||||||
|
- [X] T011 [US1] Run focused US1 verification against `tests/Feature/Filament/DashboardKpisWidgetTest.php`, `tests/Feature/Filament/TenantDashboardTruthAlignmentTest.php`, `tests/Feature/Filament/NeedsAttentionWidgetTest.php`, `tests/Feature/Filament/BaselineCompareNowWidgetTest.php`, and `tests/Feature/Filament/BaselineCompareSummaryConsistencyTest.php`
|
||||||
|
|
||||||
|
**Checkpoint**: The dashboard no longer presents an all-clear when overdue, lapsed, or compare-limited tenant states exist.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 4: User Story 2 - Click A Number And Recover The Same Problem (Priority: P1)
|
||||||
|
|
||||||
|
**Goal**: Make KPI and attention drill-throughs land on destinations where the same problem family is immediately recognizable.
|
||||||
|
|
||||||
|
**Independent Test**: Click representative KPI and attention states for findings, compare posture, and operations activity, then verify the destination preserves tenant context and exposes the same problem family through filters, tabs, or explicit framing, or renders a disabled or non-clickable explanatory state when the member lacks destination capability.
|
||||||
|
|
||||||
|
### Tests for User Story 2
|
||||||
|
|
||||||
|
- [X] T012 [P] [US2] Add findings drill-through continuity cases and permission-limited KPI fallback cases in `tests/Feature/Filament/DashboardKpisWidgetTest.php` and `tests/Feature/Findings/FindingsListFiltersTest.php`
|
||||||
|
- [X] T013 [P] [US2] Add high-severity attention, baseline-compare landing, and Operations drill-through continuity cases, including permission-limited disabled or non-clickable states, in `tests/Feature/Monitoring/OperationsDashboardDrillthroughTest.php` and `tests/Feature/Filament/TenantDashboardTruthAlignmentTest.php`
|
||||||
|
|
||||||
|
### Implementation for User Story 2
|
||||||
|
|
||||||
|
- [X] T014 [US2] Implement findings KPI URLs that reproduce named subsets through tabs and filters, with disabled or non-clickable fallback when destination capability is missing, in `app/Filament/Widgets/Dashboard/DashboardKpis.php` and `app/Filament/Resources/FindingResource/Pages/ListFindings.php`
|
||||||
|
- [X] T015 [US2] Add one tenant-safe primary action per central attention item, including high-severity active-findings coverage, baseline-compare landing continuity, and disabled or non-clickable helper text when destination capability is missing, in `app/Filament/Widgets/Dashboard/NeedsAttention.php` and `resources/views/filament/widgets/dashboard/needs-attention.blade.php`
|
||||||
|
- [X] T016 [US2] Preserve tenant context for dashboard Operations drill-throughs in `app/Support/OperationRunLinks.php` and `app/Filament/Pages/Monitoring/Operations.php`
|
||||||
|
- [X] T017 [US2] Run focused US2 verification against `tests/Feature/Monitoring/OperationsDashboardDrillthroughTest.php`, `tests/Feature/Filament/DashboardKpisWidgetTest.php`, `tests/Feature/Filament/TenantDashboardTruthAlignmentTest.php`, and `tests/Feature/Findings/FindingsListFiltersTest.php`
|
||||||
|
|
||||||
|
**Checkpoint**: KPI and attention signals now open tenant-safe findings, baseline-compare, and operations destinations that clearly match the originating problem family.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 5: User Story 3 - Separate Posture From Activity And History (Priority: P2)
|
||||||
|
|
||||||
|
**Goal**: Keep governance posture, operations activity, and recent diagnostic history visibly distinct so the dashboard remains a priority surface rather than a mixed feed.
|
||||||
|
|
||||||
|
**Independent Test**: Seed one tenant with healthy operations-only activity, failed, warning, stalled, or unusually long-running operations follow-up, governance-only issues, and recent successful history, then verify the dashboard preserves posture-first attention while leaving recent tables diagnostic.
|
||||||
|
|
||||||
|
### Tests for User Story 3
|
||||||
|
|
||||||
|
- [X] T018 [P] [US3] Add healthy operations-only, failed, warning, stalled, or unusually long-running follow-up, and recency-does-not-override-posture scenarios in `tests/Feature/Filament/TenantDashboardTruthAlignmentTest.php`
|
||||||
|
- [X] T019 [P] [US3] Add diagnostic-surface safeguards and row-click detail continuity checks for recent tables in `tests/Feature/Filament/TenantDashboardDbOnlyTest.php` and `tests/Feature/Filament/TableDetailVisibilityTest.php`
|
||||||
|
|
||||||
|
### Implementation for User Story 3
|
||||||
|
|
||||||
|
- [X] T020 [US3] Separate healthy operations activity wording from governance posture and map failed, warning, stalled, or unusually long-running runs to explicit follow-up attention in `app/Filament/Widgets/Dashboard/DashboardKpis.php` and `app/Filament/Widgets/Dashboard/NeedsAttention.php`
|
||||||
|
- [X] T021 [US3] Preserve diagnostic-only framing and existing full-row detail navigation for recent tables in `app/Filament/Widgets/Dashboard/RecentDriftFindings.php` and `app/Filament/Widgets/Dashboard/RecentOperations.php`
|
||||||
|
- [X] T022 [US3] Run focused US3 verification against `tests/Feature/Filament/TenantDashboardTruthAlignmentTest.php`, `tests/Feature/Filament/TenantDashboardDbOnlyTest.php`, `tests/Feature/Filament/TableStandardsBaselineTest.php`, and `tests/Feature/Filament/TableDetailVisibilityTest.php`
|
||||||
|
|
||||||
|
**Checkpoint**: The dashboard now distinguishes what is risky, what is merely running, and what is only recent context.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 6: Polish & Cross-Cutting Concerns
|
||||||
|
|
||||||
|
**Purpose**: Finish copy alignment, formatting, and the focused verification pack across all stories.
|
||||||
|
|
||||||
|
- [X] T023 [P] Align operator-facing dashboard copy for the 10-second operator scan, operations follow-up wording, and permission-limited helper text in `app/Filament/Widgets/Dashboard/DashboardKpis.php`, `app/Filament/Widgets/Dashboard/NeedsAttention.php`, `app/Filament/Widgets/Dashboard/BaselineCompareNow.php`, `resources/views/filament/widgets/dashboard/needs-attention.blade.php`, and `resources/views/filament/widgets/dashboard/baseline-compare-now.blade.php`
|
||||||
|
- [X] T024 Run formatting with `vendor/bin/sail bin pint --dirty --format agent` using `specs/173-tenant-dashboard-truth-alignment/quickstart.md`
|
||||||
|
- [X] T025 Run the final verification pack from `specs/173-tenant-dashboard-truth-alignment/quickstart.md` against `tests/Feature/Filament/DashboardKpisWidgetTest.php`, `tests/Feature/Filament/TenantDashboardTruthAlignmentTest.php`, `tests/Feature/Monitoring/OperationsDashboardDrillthroughTest.php`, `tests/Feature/Filament/NeedsAttentionWidgetTest.php`, `tests/Feature/Filament/BaselineCompareNowWidgetTest.php`, `tests/Feature/Filament/BaselineCompareSummaryConsistencyTest.php`, `tests/Feature/Findings/FindingsListDefaultsTest.php`, `tests/Feature/Findings/FindingsListFiltersTest.php`, `tests/Feature/Findings/FindingAdminTenantParityTest.php`, `tests/Feature/OpsUx/CanonicalViewRunLinksTest.php`, `tests/Feature/Filament/TenantDashboardTenantScopeTest.php`, `tests/Feature/Filament/TenantDashboardDbOnlyTest.php`, `tests/Feature/Filament/TableStandardsBaselineTest.php`, and `tests/Feature/Filament/TableDetailVisibilityTest.php`
|
||||||
|
- [X] T026 Run the timed operator-comprehension smoke check from `specs/173-tenant-dashboard-truth-alignment/quickstart.md` on seeded tenants, including a permission-limited member scenario
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Dependencies & Execution Order
|
||||||
|
|
||||||
|
### Phase Dependencies
|
||||||
|
|
||||||
|
- **Setup (Phase 1)**: Starts immediately and creates the new regression files for this slice.
|
||||||
|
- **Foundational (Phase 2)**: Depends on Setup and blocks all user stories until destination semantics are canonicalized.
|
||||||
|
- **User Stories (Phase 3+)**: All depend on Foundational completion.
|
||||||
|
- **Polish (Phase 6)**: Depends on all desired user stories being complete.
|
||||||
|
|
||||||
|
### User Story Dependencies
|
||||||
|
|
||||||
|
- **User Story 1 (P1)**: Can start after Foundational completion and delivers the MVP truth-alignment slice.
|
||||||
|
- **User Story 2 (P1)**: Can start after Foundational completion and should remain independently testable, though it touches some of the same dashboard widgets as US1.
|
||||||
|
- **User Story 3 (P2)**: Can start after Foundational completion and remains independently testable, though it overlaps with the same dashboard surface family.
|
||||||
|
|
||||||
|
### Within Each User Story
|
||||||
|
|
||||||
|
- Story tests should be written before or alongside the implementation tasks and must fail before the story is considered complete.
|
||||||
|
- Destination helper changes should land before the focused story-level verification run.
|
||||||
|
- Widget logic should land before any view-copy refinements tied to the same story.
|
||||||
|
|
||||||
|
### Parallel Opportunities
|
||||||
|
|
||||||
|
- `T001` and `T002` can run in parallel during Setup.
|
||||||
|
- `T004` and `T005` can run in parallel during Foundational work.
|
||||||
|
- `T006` and `T007` can run in parallel for User Story 1.
|
||||||
|
- `T012` and `T013` can run in parallel for User Story 2.
|
||||||
|
- `T018` and `T019` can run in parallel for User Story 3.
|
||||||
|
- `T023` can run while final verification commands are being prepared.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Parallel Example: User Story 1
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# User Story 1 tests in parallel:
|
||||||
|
Task: T006 tests/Feature/Filament/DashboardKpisWidgetTest.php
|
||||||
|
Task: T007 tests/Feature/Filament/TenantDashboardTruthAlignmentTest.php
|
||||||
|
|
||||||
|
# User Story 1 implementation split after foundational semantics are in place:
|
||||||
|
Task: T008 app/Filament/Widgets/Dashboard/DashboardKpis.php
|
||||||
|
Task: T010 app/Filament/Widgets/Dashboard/BaselineCompareNow.php and resources/views/filament/widgets/dashboard/baseline-compare-now.blade.php
|
||||||
|
```
|
||||||
|
|
||||||
|
## Parallel Example: User Story 2
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# User Story 2 tests in parallel:
|
||||||
|
Task: T012 tests/Feature/Filament/DashboardKpisWidgetTest.php and tests/Feature/Findings/FindingsListFiltersTest.php
|
||||||
|
Task: T013 tests/Feature/Monitoring/OperationsDashboardDrillthroughTest.php and tests/Feature/Filament/TenantDashboardTruthAlignmentTest.php
|
||||||
|
|
||||||
|
# User Story 2 implementation split after tests exist:
|
||||||
|
Task: T015 app/Filament/Widgets/Dashboard/NeedsAttention.php and resources/views/filament/widgets/dashboard/needs-attention.blade.php
|
||||||
|
Task: T016 app/Support/OperationRunLinks.php and app/Filament/Pages/Monitoring/Operations.php
|
||||||
|
```
|
||||||
|
|
||||||
|
## Parallel Example: User Story 3
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# User Story 3 tests in parallel:
|
||||||
|
Task: T018 tests/Feature/Filament/TenantDashboardTruthAlignmentTest.php
|
||||||
|
Task: T019 tests/Feature/Filament/TenantDashboardDbOnlyTest.php and tests/Feature/Filament/TableDetailVisibilityTest.php
|
||||||
|
|
||||||
|
# User Story 3 implementation split after posture/activity requirements are settled:
|
||||||
|
Task: T020 app/Filament/Widgets/Dashboard/DashboardKpis.php and app/Filament/Widgets/Dashboard/NeedsAttention.php
|
||||||
|
Task: T021 app/Filament/Widgets/Dashboard/RecentDriftFindings.php and app/Filament/Widgets/Dashboard/RecentOperations.php
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Implementation Strategy
|
||||||
|
|
||||||
|
### MVP First (User Story 1 Only)
|
||||||
|
|
||||||
|
1. Complete Phase 1: Setup.
|
||||||
|
2. Complete Phase 2: Foundational.
|
||||||
|
3. Complete Phase 3: User Story 1.
|
||||||
|
4. **STOP and VALIDATE**: Verify the dashboard no longer emits false calm when tenant attention conditions exist.
|
||||||
|
|
||||||
|
### Incremental Delivery
|
||||||
|
|
||||||
|
1. Complete Setup + Foundational to lock down canonical destination semantics.
|
||||||
|
2. Deliver User Story 1 as the MVP truth-alignment slice.
|
||||||
|
3. Add User Story 2 for drill-through continuity.
|
||||||
|
4. Add User Story 3 for posture/activity/recency separation.
|
||||||
|
5. Finish with polish, formatting, the timed operator smoke check, and the final focused verification pack.
|
||||||
|
|
||||||
|
### Parallel Team Strategy
|
||||||
|
|
||||||
|
1. One developer can prepare the new regression files while another hardens canonical findings and Operations destination helpers.
|
||||||
|
2. After Foundational work is complete, one developer can focus on KPI and compare calmness while another handles `NeedsAttention` actionability and Operations drill-through continuity.
|
||||||
|
3. Rejoin for story-level verification and final polish because several stories touch the same dashboard widget family.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- `[P]` tasks target different files or safe concurrent work after foundational semantics are in place.
|
||||||
|
- `[US1]`, `[US2]`, and `[US3]` labels map tasks directly to the feature specification user stories.
|
||||||
|
- The suggested MVP scope is Phase 1 through Phase 3 only.
|
||||||
|
- No task in this plan introduces new persistence, a new Graph contract, a new panel/provider registration, or a new destructive action.
|
||||||
@ -0,0 +1,35 @@
|
|||||||
|
# Specification Quality Checklist: Evidence Temporal Freshness & Review Publication Trust
|
||||||
|
|
||||||
|
**Purpose**: Validate specification completeness and quality before proceeding to planning
|
||||||
|
**Created**: 2026-04-04
|
||||||
|
**Feature**: [spec.md](../spec.md)
|
||||||
|
|
||||||
|
## Content Quality
|
||||||
|
|
||||||
|
- [x] No implementation details (languages, frameworks, APIs)
|
||||||
|
- [x] Focused on user value and business needs
|
||||||
|
- [x] Written for non-technical stakeholders
|
||||||
|
- [x] All mandatory sections completed
|
||||||
|
|
||||||
|
## Requirement Completeness
|
||||||
|
|
||||||
|
- [x] No [NEEDS CLARIFICATION] markers remain
|
||||||
|
- [x] Requirements are testable and unambiguous
|
||||||
|
- [x] Success criteria are measurable
|
||||||
|
- [x] Success criteria are technology-agnostic (no implementation details)
|
||||||
|
- [x] All acceptance scenarios are defined
|
||||||
|
- [x] Edge cases are identified
|
||||||
|
- [x] Scope is clearly bounded
|
||||||
|
- [x] Dependencies and assumptions identified
|
||||||
|
|
||||||
|
## Feature Readiness
|
||||||
|
|
||||||
|
- [x] All functional requirements have clear acceptance criteria
|
||||||
|
- [x] User scenarios cover primary flows
|
||||||
|
- [x] Feature meets measurable outcomes defined in Success Criteria
|
||||||
|
- [x] No implementation details leak into specification
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- Required repo contract references such as routes, capabilities, and affected operator surfaces are included because this repository's spec format requires them; the requirements and outcomes themselves remain user-facing and do not prescribe code structure.
|
||||||
|
- No clarification markers remain. The feature is ready for `/speckit.plan`.
|
||||||
@ -0,0 +1,396 @@
|
|||||||
|
openapi: 3.1.0
|
||||||
|
info:
|
||||||
|
title: Evidence Review Trust Surfaces Contract
|
||||||
|
version: 1.0.0
|
||||||
|
description: >-
|
||||||
|
Internal reference contract for the rendered HTML surfaces affected by Spec 174.
|
||||||
|
These routes continue to return HTML through Filament and Livewire. The vendor
|
||||||
|
media types below document the structured truth payloads that must be derivable
|
||||||
|
before rendering. This is not a public API commitment.
|
||||||
|
paths:
|
||||||
|
/admin/evidence/overview:
|
||||||
|
get:
|
||||||
|
summary: Canonical evidence overview
|
||||||
|
description: >-
|
||||||
|
Returns the rendered evidence overview for entitled tenants in the current workspace.
|
||||||
|
The vendor media type documents the derived row contract used to communicate
|
||||||
|
artifact truth, freshness, and next steps.
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: Rendered evidence overview page
|
||||||
|
content:
|
||||||
|
text/html:
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
application/vnd.tenantpilot.evidence-overview+json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/EvidenceOverviewPage'
|
||||||
|
'404':
|
||||||
|
description: Workspace context is missing or the viewer is not entitled to the relevant scope
|
||||||
|
/admin/reviews:
|
||||||
|
get:
|
||||||
|
summary: Canonical review register
|
||||||
|
description: >-
|
||||||
|
Returns the rendered review register for entitled tenants in the current workspace.
|
||||||
|
The vendor media type documents the row-level trust and publication contract.
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: Rendered review register page
|
||||||
|
content:
|
||||||
|
text/html:
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
application/vnd.tenantpilot.review-register+json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/ReviewRegisterPage'
|
||||||
|
'404':
|
||||||
|
description: Workspace context is missing or the viewer is not entitled to the relevant scope
|
||||||
|
/admin/t/{tenant}/evidence/{snapshot}:
|
||||||
|
get:
|
||||||
|
summary: Tenant-scoped evidence snapshot detail
|
||||||
|
parameters:
|
||||||
|
- name: tenant
|
||||||
|
in: path
|
||||||
|
required: true
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
- name: snapshot
|
||||||
|
in: path
|
||||||
|
required: true
|
||||||
|
schema:
|
||||||
|
type: integer
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: Rendered evidence snapshot detail page
|
||||||
|
content:
|
||||||
|
text/html:
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
application/vnd.tenantpilot.evidence-snapshot-detail+json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/EvidenceSnapshotDetailPage'
|
||||||
|
'403':
|
||||||
|
description: Viewer is in tenant scope but lacks the required manage capability for actions
|
||||||
|
'404':
|
||||||
|
description: Snapshot is not visible because it does not exist or tenant entitlement is missing
|
||||||
|
/admin/t/{tenant}/reviews/{review}:
|
||||||
|
get:
|
||||||
|
summary: Tenant-scoped review detail
|
||||||
|
parameters:
|
||||||
|
- name: tenant
|
||||||
|
in: path
|
||||||
|
required: true
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
- name: review
|
||||||
|
in: path
|
||||||
|
required: true
|
||||||
|
schema:
|
||||||
|
type: integer
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: Rendered tenant review detail page
|
||||||
|
content:
|
||||||
|
text/html:
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
application/vnd.tenantpilot.tenant-review-detail+json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/TenantReviewDetailPage'
|
||||||
|
'403':
|
||||||
|
description: Viewer is in tenant scope but lacks the required manage capability for actions
|
||||||
|
'404':
|
||||||
|
description: Review is not visible because it does not exist or tenant entitlement is missing
|
||||||
|
/admin/t/{tenant}/review-packs/{pack}:
|
||||||
|
get:
|
||||||
|
summary: Tenant-scoped review pack detail
|
||||||
|
parameters:
|
||||||
|
- name: tenant
|
||||||
|
in: path
|
||||||
|
required: true
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
- name: pack
|
||||||
|
in: path
|
||||||
|
required: true
|
||||||
|
schema:
|
||||||
|
type: integer
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: Rendered review pack detail page
|
||||||
|
content:
|
||||||
|
text/html:
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
application/vnd.tenantpilot.review-pack-detail+json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/ReviewPackDetailPage'
|
||||||
|
'403':
|
||||||
|
description: Viewer is in tenant scope but lacks the required manage capability for actions
|
||||||
|
'404':
|
||||||
|
description: Review pack is not visible because it does not exist or tenant entitlement is missing
|
||||||
|
components:
|
||||||
|
schemas:
|
||||||
|
ArtifactTruthSummary:
|
||||||
|
type: object
|
||||||
|
required:
|
||||||
|
- primaryLabel
|
||||||
|
- contentState
|
||||||
|
- freshnessState
|
||||||
|
- actionability
|
||||||
|
properties:
|
||||||
|
primaryLabel:
|
||||||
|
type: string
|
||||||
|
primaryExplanation:
|
||||||
|
type:
|
||||||
|
- string
|
||||||
|
- 'null'
|
||||||
|
contentState:
|
||||||
|
type: string
|
||||||
|
freshnessState:
|
||||||
|
type: string
|
||||||
|
enum:
|
||||||
|
- current
|
||||||
|
- stale
|
||||||
|
- unknown
|
||||||
|
publicationReadiness:
|
||||||
|
type:
|
||||||
|
- string
|
||||||
|
- 'null'
|
||||||
|
enum:
|
||||||
|
- publishable
|
||||||
|
- internal_only
|
||||||
|
- blocked
|
||||||
|
actionability:
|
||||||
|
type: string
|
||||||
|
enum:
|
||||||
|
- none
|
||||||
|
- optional
|
||||||
|
- required
|
||||||
|
nextActionLabel:
|
||||||
|
type:
|
||||||
|
- string
|
||||||
|
- 'null'
|
||||||
|
nextActionUrl:
|
||||||
|
type:
|
||||||
|
- string
|
||||||
|
- 'null'
|
||||||
|
diagnosticLabel:
|
||||||
|
type:
|
||||||
|
- string
|
||||||
|
- 'null'
|
||||||
|
Badge:
|
||||||
|
type: object
|
||||||
|
required:
|
||||||
|
- label
|
||||||
|
properties:
|
||||||
|
label:
|
||||||
|
type: string
|
||||||
|
color:
|
||||||
|
type:
|
||||||
|
- string
|
||||||
|
- 'null'
|
||||||
|
icon:
|
||||||
|
type:
|
||||||
|
- string
|
||||||
|
- 'null'
|
||||||
|
EvidenceOverviewRow:
|
||||||
|
type: object
|
||||||
|
required:
|
||||||
|
- tenantName
|
||||||
|
- tenantId
|
||||||
|
- snapshotId
|
||||||
|
- completenessState
|
||||||
|
- artifactTruth
|
||||||
|
- freshness
|
||||||
|
- nextStep
|
||||||
|
properties:
|
||||||
|
tenantName:
|
||||||
|
type: string
|
||||||
|
tenantId:
|
||||||
|
type: integer
|
||||||
|
snapshotId:
|
||||||
|
type: integer
|
||||||
|
completenessState:
|
||||||
|
type: string
|
||||||
|
generatedAt:
|
||||||
|
type:
|
||||||
|
- string
|
||||||
|
- 'null'
|
||||||
|
format: date-time
|
||||||
|
missingDimensions:
|
||||||
|
type: integer
|
||||||
|
staleDimensions:
|
||||||
|
type: integer
|
||||||
|
artifactTruth:
|
||||||
|
$ref: '#/components/schemas/ArtifactTruthSummary'
|
||||||
|
freshness:
|
||||||
|
$ref: '#/components/schemas/Badge'
|
||||||
|
nextStep:
|
||||||
|
type: string
|
||||||
|
viewUrl:
|
||||||
|
type:
|
||||||
|
- string
|
||||||
|
- 'null'
|
||||||
|
ReviewRegisterRow:
|
||||||
|
type: object
|
||||||
|
required:
|
||||||
|
- tenantName
|
||||||
|
- tenantId
|
||||||
|
- reviewId
|
||||||
|
- status
|
||||||
|
- completenessState
|
||||||
|
- artifactTruth
|
||||||
|
- publication
|
||||||
|
- nextStep
|
||||||
|
properties:
|
||||||
|
tenantName:
|
||||||
|
type: string
|
||||||
|
tenantId:
|
||||||
|
type: integer
|
||||||
|
reviewId:
|
||||||
|
type: integer
|
||||||
|
status:
|
||||||
|
type: string
|
||||||
|
completenessState:
|
||||||
|
type: string
|
||||||
|
generatedAt:
|
||||||
|
type:
|
||||||
|
- string
|
||||||
|
- 'null'
|
||||||
|
format: date-time
|
||||||
|
publishedAt:
|
||||||
|
type:
|
||||||
|
- string
|
||||||
|
- 'null'
|
||||||
|
format: date-time
|
||||||
|
artifactTruth:
|
||||||
|
$ref: '#/components/schemas/ArtifactTruthSummary'
|
||||||
|
publication:
|
||||||
|
$ref: '#/components/schemas/Badge'
|
||||||
|
nextStep:
|
||||||
|
type: string
|
||||||
|
viewUrl:
|
||||||
|
type:
|
||||||
|
- string
|
||||||
|
- 'null'
|
||||||
|
EvidenceOverviewPage:
|
||||||
|
type: object
|
||||||
|
required:
|
||||||
|
- rows
|
||||||
|
properties:
|
||||||
|
rows:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
$ref: '#/components/schemas/EvidenceOverviewRow'
|
||||||
|
ReviewRegisterPage:
|
||||||
|
type: object
|
||||||
|
required:
|
||||||
|
- rows
|
||||||
|
properties:
|
||||||
|
rows:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
$ref: '#/components/schemas/ReviewRegisterRow'
|
||||||
|
EvidenceSnapshotDetailPage:
|
||||||
|
type: object
|
||||||
|
required:
|
||||||
|
- recordId
|
||||||
|
- tenantId
|
||||||
|
- completenessState
|
||||||
|
- artifactTruth
|
||||||
|
properties:
|
||||||
|
recordId:
|
||||||
|
type: integer
|
||||||
|
tenantId:
|
||||||
|
type: integer
|
||||||
|
status:
|
||||||
|
type: string
|
||||||
|
completenessState:
|
||||||
|
type: string
|
||||||
|
generatedAt:
|
||||||
|
type:
|
||||||
|
- string
|
||||||
|
- 'null'
|
||||||
|
format: date-time
|
||||||
|
artifactTruth:
|
||||||
|
$ref: '#/components/schemas/ArtifactTruthSummary'
|
||||||
|
linkedReviewUrl:
|
||||||
|
type:
|
||||||
|
- string
|
||||||
|
- 'null'
|
||||||
|
linkedRunUrl:
|
||||||
|
type:
|
||||||
|
- string
|
||||||
|
- 'null'
|
||||||
|
TenantReviewDetailPage:
|
||||||
|
type: object
|
||||||
|
required:
|
||||||
|
- recordId
|
||||||
|
- tenantId
|
||||||
|
- status
|
||||||
|
- completenessState
|
||||||
|
- artifactTruth
|
||||||
|
properties:
|
||||||
|
recordId:
|
||||||
|
type: integer
|
||||||
|
tenantId:
|
||||||
|
type: integer
|
||||||
|
status:
|
||||||
|
type: string
|
||||||
|
completenessState:
|
||||||
|
type: string
|
||||||
|
generatedAt:
|
||||||
|
type:
|
||||||
|
- string
|
||||||
|
- 'null'
|
||||||
|
format: date-time
|
||||||
|
publishedAt:
|
||||||
|
type:
|
||||||
|
- string
|
||||||
|
- 'null'
|
||||||
|
format: date-time
|
||||||
|
artifactTruth:
|
||||||
|
$ref: '#/components/schemas/ArtifactTruthSummary'
|
||||||
|
linkedEvidenceUrl:
|
||||||
|
type:
|
||||||
|
- string
|
||||||
|
- 'null'
|
||||||
|
linkedPackUrl:
|
||||||
|
type:
|
||||||
|
- string
|
||||||
|
- 'null'
|
||||||
|
ReviewPackDetailPage:
|
||||||
|
type: object
|
||||||
|
required:
|
||||||
|
- recordId
|
||||||
|
- tenantId
|
||||||
|
- status
|
||||||
|
- artifactTruth
|
||||||
|
properties:
|
||||||
|
recordId:
|
||||||
|
type: integer
|
||||||
|
tenantId:
|
||||||
|
type: integer
|
||||||
|
status:
|
||||||
|
type: string
|
||||||
|
generatedAt:
|
||||||
|
type:
|
||||||
|
- string
|
||||||
|
- 'null'
|
||||||
|
format: date-time
|
||||||
|
expiresAt:
|
||||||
|
type:
|
||||||
|
- string
|
||||||
|
- 'null'
|
||||||
|
format: date-time
|
||||||
|
artifactTruth:
|
||||||
|
$ref: '#/components/schemas/ArtifactTruthSummary'
|
||||||
|
linkedReviewUrl:
|
||||||
|
type:
|
||||||
|
- string
|
||||||
|
- 'null'
|
||||||
|
linkedEvidenceUrl:
|
||||||
|
type:
|
||||||
|
- string
|
||||||
|
- 'null'
|
||||||
268
specs/174-evidence-freshness-publication-trust/data-model.md
Normal file
268
specs/174-evidence-freshness-publication-trust/data-model.md
Normal file
@ -0,0 +1,268 @@
|
|||||||
|
# Data Model: Evidence Temporal Freshness & Review Publication Trust
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
This feature does not add or modify persisted domain entities. It strengthens the derived trust-propagation model that transforms existing evidence, review, and pack records into operator-facing truth across tenant-scoped detail pages and canonical summary pages.
|
||||||
|
|
||||||
|
The key design constraint is that freshness and publication trust remain derived from existing fields and relationships:
|
||||||
|
|
||||||
|
- evidence-source freshness signals
|
||||||
|
- evidence snapshot completeness and summary state
|
||||||
|
- tenant review completeness and publish blockers
|
||||||
|
- review pack linkage back to a source review and source evidence snapshot
|
||||||
|
- the existing `ArtifactTruthEnvelope` dimensions
|
||||||
|
|
||||||
|
## Existing Persistent Entities
|
||||||
|
|
||||||
|
### 1. EvidenceSnapshot
|
||||||
|
|
||||||
|
- Purpose: Immutable tenant-scoped evidence basis assembled from multiple evidence dimensions.
|
||||||
|
- Key persisted fields used by this feature:
|
||||||
|
- `id`
|
||||||
|
- `workspace_id`
|
||||||
|
- `tenant_id`
|
||||||
|
- `status`
|
||||||
|
- `completeness_state`
|
||||||
|
- `summary`
|
||||||
|
- `generated_at`
|
||||||
|
- `expires_at`
|
||||||
|
- `operation_run_id`
|
||||||
|
- `fingerprint`
|
||||||
|
- Key summary fields used by this feature:
|
||||||
|
- `dimension_count`
|
||||||
|
- `missing_dimensions`
|
||||||
|
- `stale_dimensions`
|
||||||
|
- `dimensions[]`
|
||||||
|
- `finding_count`
|
||||||
|
- `report_count`
|
||||||
|
- `operation_count`
|
||||||
|
- Relationships used by this feature:
|
||||||
|
- `items`
|
||||||
|
- `tenantReviews`
|
||||||
|
- `reviewPacks`
|
||||||
|
- `operationRun`
|
||||||
|
- `tenant`
|
||||||
|
|
||||||
|
### 2. EvidenceSnapshotItem
|
||||||
|
|
||||||
|
- Purpose: Dimension-level evidence record inside one snapshot.
|
||||||
|
- Key persisted fields used by this feature:
|
||||||
|
- `evidence_snapshot_id`
|
||||||
|
- `tenant_id`
|
||||||
|
- `dimension_key`
|
||||||
|
- `state`
|
||||||
|
- `required`
|
||||||
|
- `measured_at`
|
||||||
|
- `freshness_at`
|
||||||
|
- `source_kind`
|
||||||
|
- `source_record_type`
|
||||||
|
- `source_record_id`
|
||||||
|
- `summary_payload`
|
||||||
|
- `sort_order`
|
||||||
|
- Relationships used by this feature:
|
||||||
|
- `snapshot`
|
||||||
|
- `tenant`
|
||||||
|
|
||||||
|
### 3. TenantReview
|
||||||
|
|
||||||
|
- Purpose: Tenant-scoped review artifact anchored to one evidence snapshot.
|
||||||
|
- Key persisted fields used by this feature:
|
||||||
|
- `id`
|
||||||
|
- `workspace_id`
|
||||||
|
- `tenant_id`
|
||||||
|
- `evidence_snapshot_id`
|
||||||
|
- `status`
|
||||||
|
- `completeness_state`
|
||||||
|
- `summary`
|
||||||
|
- `generated_at`
|
||||||
|
- `published_at`
|
||||||
|
- `archived_at`
|
||||||
|
- `current_export_review_pack_id`
|
||||||
|
- `operation_run_id`
|
||||||
|
- `fingerprint`
|
||||||
|
- Key summary fields used by this feature:
|
||||||
|
- `publish_blockers[]`
|
||||||
|
- `section_state_counts.complete`
|
||||||
|
- `section_state_counts.partial`
|
||||||
|
- `section_state_counts.missing`
|
||||||
|
- `section_state_counts.stale`
|
||||||
|
- `section_count`
|
||||||
|
- `finding_count`
|
||||||
|
- `report_count`
|
||||||
|
- `operation_count`
|
||||||
|
- Relationships used by this feature:
|
||||||
|
- `evidenceSnapshot`
|
||||||
|
- `sections`
|
||||||
|
- `currentExportReviewPack`
|
||||||
|
- `reviewPacks`
|
||||||
|
- `operationRun`
|
||||||
|
- `tenant`
|
||||||
|
|
||||||
|
### 4. ReviewPack
|
||||||
|
|
||||||
|
- Purpose: Tenant-scoped export artifact derived from a review and evidence basis.
|
||||||
|
- Key persisted fields used by this feature:
|
||||||
|
- `id`
|
||||||
|
- `workspace_id`
|
||||||
|
- `tenant_id`
|
||||||
|
- `tenant_review_id`
|
||||||
|
- `evidence_snapshot_id`
|
||||||
|
- `status`
|
||||||
|
- `summary`
|
||||||
|
- `generated_at`
|
||||||
|
- `expires_at`
|
||||||
|
- `file_disk`
|
||||||
|
- `file_path`
|
||||||
|
- `file_size`
|
||||||
|
- `operation_run_id`
|
||||||
|
- `fingerprint`
|
||||||
|
- Key summary fields used by this feature:
|
||||||
|
- `review_status`
|
||||||
|
- `review_completeness_state`
|
||||||
|
- `evidence_resolution.outcome`
|
||||||
|
- `evidence_resolution.snapshot_id`
|
||||||
|
- `evidence_resolution.snapshot_fingerprint`
|
||||||
|
- `evidence_resolution.completeness_state`
|
||||||
|
- `finding_count`
|
||||||
|
- `report_count`
|
||||||
|
- `operation_count`
|
||||||
|
- Relationships used by this feature:
|
||||||
|
- `tenantReview`
|
||||||
|
- `evidenceSnapshot`
|
||||||
|
- `operationRun`
|
||||||
|
- `tenant`
|
||||||
|
|
||||||
|
## Existing Derived State Chain
|
||||||
|
|
||||||
|
### A. Evidence Freshness Derivation
|
||||||
|
|
||||||
|
Evidence freshness is already determined in the evidence domain before any UI surface is rendered.
|
||||||
|
|
||||||
|
1. Each evidence source provider returns a dimension payload with:
|
||||||
|
- `state`
|
||||||
|
- `required`
|
||||||
|
- `measured_at`
|
||||||
|
- `freshness_at`
|
||||||
|
2. `EvidenceCompletenessEvaluator` rolls required dimension states into snapshot completeness:
|
||||||
|
- `missing` wins first
|
||||||
|
- then `stale`
|
||||||
|
- then `partial`
|
||||||
|
- otherwise `complete`
|
||||||
|
3. `EvidenceSnapshotService` persists:
|
||||||
|
- `completeness_state`
|
||||||
|
- `summary.missing_dimensions`
|
||||||
|
- `summary.stale_dimensions`
|
||||||
|
- per-dimension summary state
|
||||||
|
|
||||||
|
This feature must reuse that chain instead of replacing it.
|
||||||
|
|
||||||
|
### B. Review Readiness Derivation
|
||||||
|
|
||||||
|
Tenant review readiness is already derived from section completeness and blockers.
|
||||||
|
|
||||||
|
1. `TenantReviewSectionFactory` and related composition logic produce section completeness states.
|
||||||
|
2. `TenantReviewReadinessGate` derives:
|
||||||
|
- `blockersForSections()`
|
||||||
|
- `completenessForSections()`
|
||||||
|
- `statusForSections()`
|
||||||
|
3. `TenantReview.summary` persists:
|
||||||
|
- `publish_blockers[]`
|
||||||
|
- `section_state_counts.*`
|
||||||
|
|
||||||
|
This feature must reuse those publish blockers and section-state counts rather than adding a parallel publish-readiness source.
|
||||||
|
|
||||||
|
## Derived View Models
|
||||||
|
|
||||||
|
### 1. ArtifactTruthPropagationModel
|
||||||
|
|
||||||
|
This is the conceptual chain, not a new class.
|
||||||
|
|
||||||
|
| Stage | Inputs | Derived outputs |
|
||||||
|
|---|---|---|
|
||||||
|
| Evidence source item | source-specific timestamps and source-record state | per-dimension `state`, `freshness_at`, `required` |
|
||||||
|
| Evidence snapshot | item states, snapshot status, summary counts | `artifactExistence`, `contentState`, `freshnessState`, `actionability` |
|
||||||
|
| Tenant review | review status, review completeness, section-state counts, publish blockers, linked snapshot | `artifactExistence`, `contentState`, `freshnessState`, `publicationReadiness`, `actionability` |
|
||||||
|
| Review pack | pack status, evidence resolution, linked review trust burden, linked snapshot burden | `artifactExistence`, `contentState`, `freshnessState`, `publicationReadiness`, `actionability` |
|
||||||
|
| Canonical summary row | tenant label, record timestamps, derived artifact truth envelope | row-level artifact truth, publication signal, freshness signal, next step |
|
||||||
|
|
||||||
|
### 2. SnapshotTruthModel
|
||||||
|
|
||||||
|
Derived from `EvidenceSnapshot` plus `ArtifactTruthPresenter::buildEvidenceSnapshotEnvelope()`.
|
||||||
|
|
||||||
|
| Field | Meaning | Source |
|
||||||
|
|---|---|---|
|
||||||
|
| `artifactExistence` | whether a current or historical snapshot exists | snapshot `status` |
|
||||||
|
| `contentState` | whether the evidence basis is trusted, partial, missing, or empty | snapshot `completeness_state`, `summary.dimension_count` |
|
||||||
|
| `freshnessState` | whether the evidence basis is current or stale | snapshot `completeness_state`, `summary.stale_dimensions`, historical status |
|
||||||
|
| `actionability` | whether the operator must act, may optionally act, or can do nothing | derived from freshness and completeness |
|
||||||
|
| `nextStepText` | operator-facing refresh or wait guidance | existing presenter logic |
|
||||||
|
|
||||||
|
### 3. ReviewTruthModel
|
||||||
|
|
||||||
|
Derived from `TenantReview` plus `ArtifactTruthPresenter::buildTenantReviewEnvelope()`.
|
||||||
|
|
||||||
|
| Field | Meaning | Source |
|
||||||
|
|---|---|---|
|
||||||
|
| `artifactExistence` | whether the review is current, failed, or historical | review status |
|
||||||
|
| `contentState` | whether the review body is complete enough or partial | review completeness |
|
||||||
|
| `freshnessState` | whether the review evidence basis is stale | review completeness and `summary.section_state_counts.stale` |
|
||||||
|
| `publicationReadiness` | whether the review is blocked, internal-only, or publishable | review status, publish blockers, and this feature's stricter freshness/partiality propagation |
|
||||||
|
| `actionability` | whether the operator must resolve blockers, should refresh, or can leave the review alone | derived from publication and freshness burden |
|
||||||
|
| `nextStepText` | resolve blockers, complete work, refresh evidence, or no action needed | existing presenter logic tightened by this feature |
|
||||||
|
|
||||||
|
### 4. PackTruthModel
|
||||||
|
|
||||||
|
Derived from `ReviewPack` plus `ArtifactTruthPresenter::buildReviewPackEnvelope()`.
|
||||||
|
|
||||||
|
| Field | Meaning | Source |
|
||||||
|
|---|---|---|
|
||||||
|
| `artifactExistence` | whether a current, failed, or historical pack exists | pack status |
|
||||||
|
| `contentState` | whether the pack has a trustworthy source basis | evidence resolution and source review burden |
|
||||||
|
| `freshnessState` | whether the pack should be treated as current or stale for operator trust | pack expiration plus propagated source review/evidence staleness |
|
||||||
|
| `publicationReadiness` | whether the pack is blocked, internal-only, or publishable | pack status plus propagated review and evidence burden |
|
||||||
|
| `actionability` | whether the operator must revisit the review or simply note caution | derived from the trust posture |
|
||||||
|
| `nextStepText` | open source review, refresh evidence, or no action needed | existing presenter logic tightened by this feature |
|
||||||
|
|
||||||
|
### 5. CanonicalSummaryRowModel
|
||||||
|
|
||||||
|
Used conceptually by both `EvidenceOverview` and `ReviewRegister`.
|
||||||
|
|
||||||
|
| Field | Type | Purpose |
|
||||||
|
|---|---|---|
|
||||||
|
| `tenantName` | string | Maintain tenant context in canonical pages |
|
||||||
|
| `recordId` | int | Link the summary row back to the tenant-scoped detail page |
|
||||||
|
| `artifactTruth` | label + badge spec + explanation | Primary trust summary shown in the row |
|
||||||
|
| `freshness` | badge spec | Evidence overview freshness summary |
|
||||||
|
| `publication` | badge spec | Review register publication summary |
|
||||||
|
| `nextStep` | string | Operator-facing follow-up guidance that matches detail surfaces |
|
||||||
|
| `viewUrl` | string | Drill-through to the tenant-scoped detail page |
|
||||||
|
|
||||||
|
Rules:
|
||||||
|
|
||||||
|
- Canonical rows must never appear calmer than the corresponding detail pages.
|
||||||
|
- `nextStep` may be shorter than detail guidance, but not contradictory.
|
||||||
|
- Canonical rows must stay derived from the same truth envelope rather than page-local heuristics.
|
||||||
|
|
||||||
|
## Validation Rules
|
||||||
|
|
||||||
|
- A snapshot can be structurally complete and still stale; the derived truth must preserve that distinction.
|
||||||
|
- A review can be internally useful while not being publishable; the derived truth must preserve that distinction.
|
||||||
|
- A pack must not be calmer than its source review or source evidence basis.
|
||||||
|
- Canonical overview and register rows must not contradict the tenant-scoped detail view for the same artifact.
|
||||||
|
- Freshness, completeness, and publication readiness remain separate dimensions even when they jointly influence the primary label.
|
||||||
|
|
||||||
|
## State Notes
|
||||||
|
|
||||||
|
This feature introduces no new persisted state.
|
||||||
|
|
||||||
|
Existing state and badge families that remain canonical:
|
||||||
|
|
||||||
|
- `EvidenceCompletenessState`
|
||||||
|
- `EvidenceSnapshotStatus`
|
||||||
|
- `TenantReviewCompletenessState`
|
||||||
|
- `TenantReviewStatus`
|
||||||
|
- `ReviewPackStatus`
|
||||||
|
- `BadgeDomain::GovernanceArtifactFreshness`
|
||||||
|
- `BadgeDomain::GovernanceArtifactPublicationReadiness`
|
||||||
|
|
||||||
|
The feature only changes how these existing states combine into operator-facing trust across multiple related surfaces.
|
||||||
294
specs/174-evidence-freshness-publication-trust/plan.md
Normal file
294
specs/174-evidence-freshness-publication-trust/plan.md
Normal file
@ -0,0 +1,294 @@
|
|||||||
|
# Implementation Plan: Evidence Temporal Freshness & Review Publication Trust
|
||||||
|
|
||||||
|
**Branch**: `174-evidence-freshness-publication-trust` | **Date**: 2026-04-04 | **Spec**: `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/specs/174-evidence-freshness-publication-trust/spec.md`
|
||||||
|
**Input**: Feature specification from `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/specs/174-evidence-freshness-publication-trust/spec.md`
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
Harden evidence freshness and publication trust across the existing evidence snapshot, tenant review, review pack, evidence overview, and canonical review register surfaces without adding new persistence, a new trust layer, or a new reporting subsystem. The implementation will reuse the source-derived stale semantics and their existing source-defined freshness thresholds, tighten propagation in `ArtifactTruthPresenter`, keep review readiness and publication readiness distinct, preserve the current tenant and canonical routes and action inventory, and close the existing cross-surface gap where stale or partial evidence can still look publishable.
|
||||||
|
|
||||||
|
Key approach: keep the work inside the current `EvidenceSnapshotService` freshness semantics, `TenantReviewReadinessGate`, `ArtifactTruthPresenter`, tenant-scoped Filament resources, and canonical summary pages. The slice is primarily about better derivation and consistent display, not about new models or new workflows.
|
||||||
|
|
||||||
|
## Technical Context
|
||||||
|
|
||||||
|
**Language/Version**: PHP 8.4, Laravel 12, Filament v5, Livewire v4, Blade
|
||||||
|
**Primary Dependencies**: Filament v5, Livewire v4, Pest v4, Laravel Sail, existing `ArtifactTruthPresenter`, `ArtifactTruthEnvelope`, `TenantReviewReadinessGate`, `EvidenceSnapshotService`, `TenantReviewRegisterService`, and current evidence/review/review-pack resources and pages
|
||||||
|
**Storage**: PostgreSQL with existing `evidence_snapshots`, `evidence_snapshot_items`, `tenant_reviews`, and `review_packs` tables using current summary JSON and timestamps; no schema change planned
|
||||||
|
**Testing**: Pest feature tests and Livewire page tests run via Sail, plus existing governance-artifact fixture helpers
|
||||||
|
**Target Platform**: Laravel web application in Sail locally and containerized Linux deployment in staging and production
|
||||||
|
**Project Type**: Laravel monolith web application
|
||||||
|
**Performance Goals**: Preserve DB-only render behavior on detail and canonical surfaces, avoid any render-time external calls, keep list-row truth derivation lightweight enough for canonical table scans, and keep operator trust signals readable within a 5-10 second scan on summary surfaces
|
||||||
|
**Constraints**: No new tables, no new enum families, no new presenter or resolver subsystem, no route changes, no RBAC drift, no destructive-action placement drift, no global asset changes, and no new global freshness engine if existing source-derived stale semantics are sufficient
|
||||||
|
**Scale/Scope**: Five operator-facing surfaces (`/admin/evidence/overview`, `/admin/reviews`, `/admin/t/{tenant}/evidence/{snapshot}`, `/admin/t/{tenant}/reviews/{review}`, `/admin/t/{tenant}/review-packs/{pack}`), one central truth presenter, existing readiness helpers, and focused regression coverage across fresh, stale, partial, blocked, internal-only, and publishable scenarios
|
||||||
|
|
||||||
|
## Constitution Check
|
||||||
|
|
||||||
|
*GATE: Passed before Phase 0 research. Re-checked after Phase 1 design and still passing.*
|
||||||
|
|
||||||
|
| Principle | Status | Notes |
|
||||||
|
|-----------|--------|-------|
|
||||||
|
| Inventory-first | Pass | Evidence freshness remains derived from the existing snapshot-item and source-evaluation chain; no new snapshot ownership semantics |
|
||||||
|
| Read/write separation | Pass | This slice primarily changes truth derivation and display; existing mutate actions remain unchanged |
|
||||||
|
| Graph contract path | Pass | No new Graph calls or contract-registry changes are introduced |
|
||||||
|
| Deterministic capabilities | Pass | No new capability derivation or role mapping is added |
|
||||||
|
| RBAC-UX planes and 404 vs 403 | Pass | Tenant-scoped resources remain tenant-scoped; canonical `/admin` pages stay workspace- and tenant-entitlement-safe |
|
||||||
|
| Workspace isolation | Pass | Canonical summary pages continue to derive rows only from entitled tenants in the current workspace |
|
||||||
|
| Tenant isolation | Pass | Drill-through links remain tenant-scoped and non-entitled users remain deny-as-not-found |
|
||||||
|
| Destructive confirmation | Pass | Existing destructive actions (`Expire snapshot`, `Archive review`, `Expire pack`) already require confirmation and remain unchanged |
|
||||||
|
| Global search safety | Pass | No global-search behavior is added or broadened; `EvidenceSnapshotResource`, `TenantReviewResource`, and `ReviewPackResource` already have view pages |
|
||||||
|
| Run observability | Pass | Existing evidence, review, and pack generation flows keep their current `OperationRun` types and ownership; no new run type is introduced |
|
||||||
|
| Ops-UX 3-surface feedback | Pass | No toast, progress, or terminal-notification behavior changes |
|
||||||
|
| Ops-UX lifecycle ownership | Pass | No `OperationRun.status` or `outcome` transition logic is touched |
|
||||||
|
| Ops-UX summary counts | Pass | No changes to `summary_counts` contracts are required |
|
||||||
|
| Ops-UX guards | Pass | Existing operation lifecycle guards stay in place; this feature adds truth-surface regression tests instead |
|
||||||
|
| Data minimization | Pass | No new payload exposure or raw-report surface is introduced |
|
||||||
|
| Proportionality (PROP-001) | Pass | The implementation stays inside existing freshness, readiness, and truth layers rather than adding persistence or abstraction |
|
||||||
|
| No premature abstraction (ABSTR-001) | Pass | No new resolver, gate, presenter family, registry, or taxonomy is planned |
|
||||||
|
| Persisted truth (PERSIST-001) | Pass | All new semantics remain derived from existing timestamps, summary state, and linked records |
|
||||||
|
| Behavioral state (STATE-001) | Pass | No new persisted states are introduced; existing stale/partial/publishable/internal-only semantics become stricter |
|
||||||
|
| UI semantics (UI-SEM-001) | Pass | Existing badge and truth primitives are reused; no second interpretation framework is introduced |
|
||||||
|
| V1 explicitness / few layers (V1-EXP-001, LAYER-001) | Pass | One central presenter and existing pages remain the implementation seam |
|
||||||
|
| Badge semantics (BADGE-001) | Pass | Existing freshness, completeness, publication-readiness, and artifact-truth badge domains stay canonical |
|
||||||
|
| Filament-native UI (UI-FIL-001) | Pass | Existing Filament resources, pages, badges, tables, and infolists are reused; no page-local status language is added |
|
||||||
|
| UI/UX surface taxonomy (UI-CONST-001 / UI-SURF-001) | Pass | Evidence snapshot, tenant review, and review pack remain `CRUD / List-first Resource` surfaces with dedicated detail pages, while evidence overview and review register remain `Read-only Registry / Report Surface` surfaces |
|
||||||
|
| UI/UX inspect model (UI-HARD-001) | Pass | Clickable-row inspect remains primary on the affected lists; no redundant view action is introduced |
|
||||||
|
| UI/UX action hierarchy (UI-HARD-001 / UI-EX-001) | Pass | Existing one-inline-safe-shortcut patterns remain; no new row-level destructive actions are added |
|
||||||
|
| UI/UX scope, truth, and naming (UI-HARD-001 / UI-NAMING-001 / OPSURF-001) | Pass | The feature strengthens default-visible trust truth while preserving canonical nouns and existing verbs |
|
||||||
|
| List-surface review checklist (UI-STD-001) | Pass | The affected list and report surfaces will be reviewed against `docs/product/standards/list-surface-review-checklist.md` during implementation and final verification |
|
||||||
|
| Filament Action Surface Contract | Pass | Current surface declarations already match the required list/detail behavior; the feature changes truth semantics, not action topology |
|
||||||
|
| Filament UX-001 | Pass | Existing Infolist and table layouts remain; this slice strengthens the truth surfaces inside them |
|
||||||
|
| Filament v5 / Livewire v4 compliance | Pass | The work remains inside the current Filament v5 + Livewire v4 stack |
|
||||||
|
| Provider registration location | Pass | No panel/provider changes; Laravel 11+ provider registration remains in `bootstrap/providers.php` |
|
||||||
|
| Asset strategy | Pass | No new panel or shared assets are required; deployment `filament:assets` behavior remains unchanged |
|
||||||
|
|
||||||
|
## Phase 0 Research
|
||||||
|
|
||||||
|
Research outcomes are captured in `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/specs/174-evidence-freshness-publication-trust/research.md`.
|
||||||
|
|
||||||
|
Key decisions:
|
||||||
|
|
||||||
|
- Reuse the existing source-derived stale semantics from evidence providers and `EvidenceCompletenessEvaluator` instead of creating a new global freshness engine.
|
||||||
|
- Tighten `ArtifactTruthPresenter` rather than introducing a new publication-trust resolver or freshness-trust gate.
|
||||||
|
- Degrade tenant review and review pack publication trust when freshness is stale and downgrade partial evidence to `internal_only` unless stronger existing blockers already make the artifact `blocked`.
|
||||||
|
- Make review packs inherit stale and partial burden from their linked review and evidence basis instead of treating pack freshness as `current` whenever the file itself is not expired.
|
||||||
|
- Keep canonical register and overview surfaces aligned by reusing the same truth envelope and next-step language rather than adding page-local taxonomies or ad-hoc columns.
|
||||||
|
- Expand existing Pest coverage and fixture builders rather than creating a new UI test harness.
|
||||||
|
|
||||||
|
## Phase 1 Design
|
||||||
|
|
||||||
|
Design artifacts are created under `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/specs/174-evidence-freshness-publication-trust/`:
|
||||||
|
|
||||||
|
- `data-model.md`: derived trust-propagation model for evidence snapshots, tenant reviews, review packs, and canonical summary rows
|
||||||
|
- `contracts/evidence-review-trust-surfaces.openapi.yaml`: internal page-contract schema for the affected rendered HTML surfaces and their structured truth payloads
|
||||||
|
- `quickstart.md`: focused verification workflow for manual and automated validation
|
||||||
|
|
||||||
|
Design decisions:
|
||||||
|
|
||||||
|
- No schema migration is required; freshness remains derived from existing evidence-source timestamps and summary state.
|
||||||
|
- The primary implementation seam is `ArtifactTruthPresenter`, supported by the current evidence and review readiness services and existing page row builders.
|
||||||
|
- `TenantReviewReadinessGate` remains the publish-blocker authority for required stale or missing sections; this feature tightens how that burden is translated into operator-facing trust and publication surfaces.
|
||||||
|
- `EvidenceOverview` and `ReviewRegister` continue to render read-only summary rows, but must no longer sound calmer than the corresponding tenant-scoped detail surfaces.
|
||||||
|
- Existing destructive actions and capabilities remain unchanged; only truth presentation, next-step guidance, and consistency rules are hardened.
|
||||||
|
|
||||||
|
## Project Structure
|
||||||
|
|
||||||
|
### Documentation (this feature)
|
||||||
|
|
||||||
|
```text
|
||||||
|
specs/174-evidence-freshness-publication-trust/
|
||||||
|
├── spec.md
|
||||||
|
├── plan.md
|
||||||
|
├── research.md
|
||||||
|
├── data-model.md
|
||||||
|
├── quickstart.md
|
||||||
|
├── contracts/
|
||||||
|
│ └── evidence-review-trust-surfaces.openapi.yaml
|
||||||
|
├── checklists/
|
||||||
|
│ └── requirements.md
|
||||||
|
└── tasks.md
|
||||||
|
```
|
||||||
|
|
||||||
|
### Source Code (repository root)
|
||||||
|
|
||||||
|
```text
|
||||||
|
app/
|
||||||
|
├── Filament/
|
||||||
|
│ ├── Pages/
|
||||||
|
│ │ ├── Monitoring/
|
||||||
|
│ │ │ └── EvidenceOverview.php
|
||||||
|
│ │ └── Reviews/
|
||||||
|
│ │ └── ReviewRegister.php
|
||||||
|
│ └── Resources/
|
||||||
|
│ ├── EvidenceSnapshotResource.php
|
||||||
|
│ ├── ReviewPackResource.php
|
||||||
|
│ ├── TenantReviewResource.php
|
||||||
|
│ ├── EvidenceSnapshotResource/
|
||||||
|
│ │ └── Pages/
|
||||||
|
│ │ └── ViewEvidenceSnapshot.php
|
||||||
|
│ ├── ReviewPackResource/
|
||||||
|
│ │ └── Pages/
|
||||||
|
│ │ └── ViewReviewPack.php
|
||||||
|
│ └── TenantReviewResource/
|
||||||
|
│ └── Pages/
|
||||||
|
│ └── ViewTenantReview.php
|
||||||
|
├── Models/
|
||||||
|
│ ├── EvidenceSnapshot.php
|
||||||
|
│ ├── EvidenceSnapshotItem.php
|
||||||
|
│ ├── ReviewPack.php
|
||||||
|
│ └── TenantReview.php
|
||||||
|
├── Services/
|
||||||
|
│ ├── Evidence/
|
||||||
|
│ │ ├── EvidenceCompletenessEvaluator.php
|
||||||
|
│ │ ├── EvidenceSnapshotService.php
|
||||||
|
│ │ └── Sources/
|
||||||
|
│ │ ├── BaselineDriftPostureSource.php
|
||||||
|
│ │ ├── EntraAdminRolesSource.php
|
||||||
|
│ │ ├── FindingsSummarySource.php
|
||||||
|
│ │ ├── OperationsSummarySource.php
|
||||||
|
│ │ └── PermissionPostureSource.php
|
||||||
|
│ └── TenantReviews/
|
||||||
|
│ ├── TenantReviewReadinessGate.php
|
||||||
|
│ └── TenantReviewRegisterService.php
|
||||||
|
└── Support/
|
||||||
|
└── Ui/
|
||||||
|
└── GovernanceArtifactTruth/
|
||||||
|
├── ArtifactTruthEnvelope.php
|
||||||
|
└── ArtifactTruthPresenter.php
|
||||||
|
|
||||||
|
resources/
|
||||||
|
└── views/
|
||||||
|
└── filament/
|
||||||
|
├── infolists/
|
||||||
|
│ └── entries/
|
||||||
|
│ └── governance-artifact-truth.blade.php
|
||||||
|
└── pages/
|
||||||
|
├── monitoring/
|
||||||
|
│ └── evidence-overview.blade.php
|
||||||
|
└── reviews/
|
||||||
|
└── review-register.blade.php
|
||||||
|
|
||||||
|
routes/
|
||||||
|
└── web.php
|
||||||
|
|
||||||
|
tests/
|
||||||
|
├── Feature/
|
||||||
|
│ ├── Concerns/
|
||||||
|
│ │ └── BuildsGovernanceArtifactTruthFixtures.php
|
||||||
|
│ ├── Evidence/
|
||||||
|
│ │ ├── EvidenceOverviewPageTest.php
|
||||||
|
│ │ └── EvidenceSnapshotResourceTest.php
|
||||||
|
│ ├── Monitoring/
|
||||||
|
│ │ └── ArtifactTruthRunDetailTest.php
|
||||||
|
│ ├── ReviewPack/
|
||||||
|
│ │ └── ReviewPackResourceTest.php
|
||||||
|
│ └── TenantReview/
|
||||||
|
│ ├── TenantReviewLifecycleTest.php
|
||||||
|
│ └── TenantReviewRegisterTest.php
|
||||||
|
└── Pest.php
|
||||||
|
```
|
||||||
|
|
||||||
|
**Structure Decision**: Standard Laravel monolith. The change is concentrated in one existing truth-presenter seam, current readiness helpers, a small set of existing Filament resources/pages, and focused Pest coverage. No new base directories, services, or presentation frameworks are required.
|
||||||
|
|
||||||
|
## Implementation Strategy
|
||||||
|
|
||||||
|
### Phase A — Preserve Source-Derived Freshness As The Canonical Input
|
||||||
|
|
||||||
|
**Goal**: Keep existing provider-level stale semantics as the source of truth and document them clearly in the implementation so the feature does not accidentally invent a second freshness system.
|
||||||
|
|
||||||
|
| Step | File | Change |
|
||||||
|
|------|------|--------|
|
||||||
|
| A.1 | `app/Services/Evidence/EvidenceCompletenessEvaluator.php` | Confirm that source-level `stale` evaluation and snapshot completeness remain the canonical freshness input used by the UI truth layer |
|
||||||
|
| A.2 | `app/Support/Ui/GovernanceArtifactTruth/ArtifactTruthPresenter.php` | Keep evidence-snapshot truth derived from snapshot status, completeness, and `summary.stale_dimensions` rather than introducing new page-local age logic |
|
||||||
|
| A.3 | `specs/174-evidence-freshness-publication-trust/research.md` and `data-model.md` | Record the source-derived freshness chain so implementation does not drift into a new threshold engine |
|
||||||
|
|
||||||
|
### Phase B — Downgrade Tenant Review Publication Trust When Freshness Or Completeness Weakens Confidence
|
||||||
|
|
||||||
|
**Goal**: Ensure tenant reviews can remain useful internally while no longer appearing publishable when their evidence basis is stale or partial.
|
||||||
|
|
||||||
|
| Step | File | Change |
|
||||||
|
|------|------|--------|
|
||||||
|
| B.1 | `app/Support/Ui/GovernanceArtifactTruth/ArtifactTruthPresenter.php` | Tighten `buildTenantReviewEnvelope()` so stale freshness and partial evidence degrade publication readiness and next-step guidance appropriately |
|
||||||
|
| B.2 | `app/Services/TenantReviews/TenantReviewReadinessGate.php` | Reuse existing required-section stale and missing blocker semantics; do not create a second publish-blocker system |
|
||||||
|
| B.3 | `app/Filament/Resources/TenantReviewResource.php` and `app/Filament/Resources/TenantReviewResource/Pages/ViewTenantReview.php` | Verify the tightened truth envelope is visible on list and detail surfaces without changing action topology or copy vocabulary |
|
||||||
|
|
||||||
|
### Phase C — Propagate Source Review And Evidence Burden Into Review Pack Trust
|
||||||
|
|
||||||
|
**Goal**: Prevent review packs from appearing calmer than the review or evidence basis they were generated from.
|
||||||
|
|
||||||
|
| Step | File | Change |
|
||||||
|
|------|------|--------|
|
||||||
|
| C.1 | `app/Support/Ui/GovernanceArtifactTruth/ArtifactTruthPresenter.php` | Tighten `buildReviewPackEnvelope()` so pack freshness and publication readiness inherit stale or partial burden from the source review and evidence resolution |
|
||||||
|
| C.2 | `app/Filament/Resources/ReviewPackResource.php` and `app/Filament/Resources/ReviewPackResource/Pages/ViewReviewPack.php` | Ensure pack list/detail surfaces expose internal-only or cautionary trust and next-step caveats before download or sharing |
|
||||||
|
| C.3 | `resources/views/filament/infolists/entries/governance-artifact-truth.blade.php` | Reuse the existing truth partial to surface freshness and publication dimensions with stronger consistency rather than new local markup |
|
||||||
|
|
||||||
|
### Phase D — Align Canonical Overview And Register Rows With Tenant-Scoped Detail Truth
|
||||||
|
|
||||||
|
**Goal**: Ensure `/admin/evidence/overview` and `/admin/reviews` summarize the same truth direction as the tenant-scoped detail pages.
|
||||||
|
|
||||||
|
| Step | File | Change |
|
||||||
|
|------|------|--------|
|
||||||
|
| D.1 | `app/Filament/Pages/Monitoring/EvidenceOverview.php` | Keep row truth and next-step guidance sourced from the same envelope semantics used by snapshot detail |
|
||||||
|
| D.2 | `app/Filament/Pages/Reviews/ReviewRegister.php` | Keep artifact truth, publication readiness, and next-step rows aligned with tenant review detail without adding new ad-hoc row taxonomy |
|
||||||
|
| D.3 | `routes/web.php` and current navigation helpers | Preserve existing canonical route shape and tenant-prefilter continuity; no routing change |
|
||||||
|
|
||||||
|
### Phase E — Regression Protection And Focused Validation
|
||||||
|
|
||||||
|
**Goal**: Add the smallest useful test set that protects stale propagation, partial-evidence trust, review/pack consistency, and canonical summary alignment.
|
||||||
|
|
||||||
|
| Step | File | Change |
|
||||||
|
|------|------|--------|
|
||||||
|
| E.1 | `tests/Feature/Concerns/BuildsGovernanceArtifactTruthFixtures.php` | Add fixture helpers for stale and partial evidence scenarios so tests do not duplicate setup logic |
|
||||||
|
| E.2 | `tests/Feature/Evidence/EvidenceSnapshotResourceTest.php` | Add fresh-vs-stale snapshot truth assertions, including stale-dimension next-step behavior |
|
||||||
|
| E.3 | `tests/Feature/TenantReview/TenantReviewLifecycleTest.php` | Add stale and partial review publication-trust assertions without changing lifecycle behavior |
|
||||||
|
| E.4 | `tests/Feature/ReviewPack/ReviewPackResourceTest.php` | Add pack trust propagation assertions for stale or partial source review/evidence combinations |
|
||||||
|
| E.5 | `tests/Feature/TenantReview/TenantReviewRegisterTest.php` and `tests/Feature/Evidence/EvidenceOverviewPageTest.php` | Add canonical row-alignment assertions so summary pages do not sound calmer than detail |
|
||||||
|
| E.6 | `vendor/bin/sail bin pint --dirty --format agent` and focused Pest runs | Required formatting and targeted verification before task completion |
|
||||||
|
|
||||||
|
## Key Design Decisions
|
||||||
|
|
||||||
|
### D-001 — Source-specific stale evaluation stays canonical
|
||||||
|
|
||||||
|
Evidence sources already produce `stale` item states using their own domain-appropriate recency logic, and `EvidenceCompletenessEvaluator` already rolls those into snapshot completeness. The plan reuses that chain instead of inventing a separate global age-policy system.
|
||||||
|
|
||||||
|
### D-002 — `ArtifactTruthPresenter` remains the single trust-propagation seam
|
||||||
|
|
||||||
|
The current code already centralizes evidence snapshot, tenant review, and review pack trust in one presenter. Tightening that presenter is narrower and safer than introducing a new publication-trust resolver or freshness framework.
|
||||||
|
|
||||||
|
### D-003 — Publication readiness remains distinct from freshness and completeness
|
||||||
|
|
||||||
|
The feature must not collapse all trust dimensions into one vague `ready` state. Freshness, completeness, and publication readiness remain separate dimensions, but stale evidence downgrades share safety and partial evidence downgrades publication readiness to `internal_only` unless existing blockers already make the artifact `blocked`.
|
||||||
|
|
||||||
|
### D-004 — Canonical summary pages must reuse existing truth, not invent row-local semantics
|
||||||
|
|
||||||
|
`EvidenceOverview` and `ReviewRegister` already surface artifact truth, publication, and next-step information. The right fix is to align their inputs with the tightened truth envelope, not to add page-specific badges or prose.
|
||||||
|
|
||||||
|
### D-005 — No new persistence or reporting subsystem is justified
|
||||||
|
|
||||||
|
This is a current-release operator-trust problem, but it is still a derived-truth problem. Existing timestamps, summary state, and linked artifacts are enough to solve it without a StoredReport viewer, new export-governance model, or separate publication-trust entity.
|
||||||
|
|
||||||
|
## Risk Assessment
|
||||||
|
|
||||||
|
| Risk | Impact | Likelihood | Mitigation |
|
||||||
|
|------|--------|------------|------------|
|
||||||
|
| Review publication trust becomes too strict and downgrades healthy reviews | Medium | Medium | Base downgrades on existing stale/partial semantics already produced by evidence and review services rather than a new heuristic |
|
||||||
|
| Review pack truth diverges from review truth because pack code and review code evolve separately | High | Medium | Centralize propagation in `ArtifactTruthPresenter` and add explicit review-vs-pack consistency tests |
|
||||||
|
| Canonical summary pages become denser or harder to scan | Medium | Low | Reuse existing columns and next-step fields instead of adding more row furniture |
|
||||||
|
| `fresh` versus non-`fresh` envelope variants diverge across surfaces | Medium | Low | Keep one truth-building path and only use `fresh` variants when cache bypass is genuinely needed |
|
||||||
|
| Implementation accidentally introduces a new freshness policy or new abstraction | Medium | Low | Lock the design to current source-derived stale semantics and reject new persistence or resolver additions in review |
|
||||||
|
|
||||||
|
## Test Strategy
|
||||||
|
|
||||||
|
- Extend the current evidence, tenant review, review pack, and canonical summary Pest tests instead of adding a new testing layer.
|
||||||
|
- Use `BuildsGovernanceArtifactTruthFixtures` and existing `composeTenantReviewForTest()` helpers to build fresh, stale, and partial scenarios consistently.
|
||||||
|
- Add explicit assertions for fresh versus stale evidence snapshots, partial versus complete review evidence, review-pack versus review trust alignment, and canonical row truth alignment.
|
||||||
|
- Preserve existing authorization semantics: non-entitled users remain `404`, in-scope users without manage capability remain `403` for actions, and summary truth remains visible only within entitled scope.
|
||||||
|
- Keep destructive actions and operation-launch semantics unchanged; test additions should focus on trust consequences, not on unrelated lifecycle behavior.
|
||||||
|
- Focused verification targets: `EvidenceSnapshotResourceTest`, `TenantReviewLifecycleTest`, `ReviewPackResourceTest`, `TenantReviewRegisterTest`, `EvidenceOverviewPageTest`, and fixture helpers.
|
||||||
|
|
||||||
|
## Complexity Tracking
|
||||||
|
|
||||||
|
No constitution violations or justified complexity exceptions were identified.
|
||||||
|
|
||||||
|
## Proportionality Review
|
||||||
|
|
||||||
|
Not triggered beyond the already-passed spec review. The plan introduces no new enum/status family, DTO/presenter family, persisted entity, registry, resolver, taxonomy, or cross-domain UI framework.
|
||||||
141
specs/174-evidence-freshness-publication-trust/quickstart.md
Normal file
141
specs/174-evidence-freshness-publication-trust/quickstart.md
Normal file
@ -0,0 +1,141 @@
|
|||||||
|
# Quickstart: Evidence Temporal Freshness & Review Publication Trust
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
Validate that stale and partial evidence now degrade review and pack trust consistently across tenant-scoped detail pages and canonical summary pages, without changing routes, authorization semantics, or the current action inventory.
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
|
||||||
|
1. Start Sail if it is not already running.
|
||||||
|
2. Ensure the acting user is a valid workspace member and entitled to the target tenant or tenants.
|
||||||
|
3. Prepare representative fixtures for these cases:
|
||||||
|
- fresh and complete evidence snapshot
|
||||||
|
- stale evidence snapshot
|
||||||
|
- partial evidence snapshot or review with partial sections
|
||||||
|
- review with publication blockers
|
||||||
|
- ready review pack derived from a fresh review
|
||||||
|
- ready review pack derived from a stale or partial review
|
||||||
|
|
||||||
|
## Focused Automated Verification
|
||||||
|
|
||||||
|
Run the smallest existing test set that guards the affected surfaces first:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
vendor/bin/sail artisan test --compact tests/Feature/Evidence/EvidenceSnapshotResourceTest.php
|
||||||
|
vendor/bin/sail artisan test --compact tests/Feature/TenantReview/TenantReviewLifecycleTest.php
|
||||||
|
vendor/bin/sail artisan test --compact tests/Feature/ReviewPack/ReviewPackResourceTest.php
|
||||||
|
vendor/bin/sail artisan test --compact tests/Feature/TenantReview/TenantReviewRegisterTest.php
|
||||||
|
vendor/bin/sail artisan test --compact tests/Feature/Evidence/EvidenceOverviewPageTest.php
|
||||||
|
```
|
||||||
|
|
||||||
|
If implementation extends shared truth helpers or run-detail truth copy, also run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
vendor/bin/sail artisan test --compact tests/Feature/Monitoring/ArtifactTruthRunDetailTest.php
|
||||||
|
```
|
||||||
|
|
||||||
|
## Manual Validation Pass
|
||||||
|
|
||||||
|
Use one fresh tenant and one stale or partial tenant, or equivalent seeded records in the same tenant.
|
||||||
|
|
||||||
|
### 1. Evidence snapshot detail trust
|
||||||
|
|
||||||
|
Open the tenant-scoped evidence snapshot detail page for a fresh snapshot and a stale snapshot.
|
||||||
|
|
||||||
|
Confirm that:
|
||||||
|
|
||||||
|
- both snapshots can still exist structurally,
|
||||||
|
- the stale snapshot is clearly shown as stale or cautionary,
|
||||||
|
- the fresh snapshot remains current,
|
||||||
|
- next-step guidance differs appropriately,
|
||||||
|
- and no raw JSON is required to understand why the stale snapshot is less trustworthy.
|
||||||
|
|
||||||
|
### 2. Review detail trust
|
||||||
|
|
||||||
|
Open tenant reviews anchored to:
|
||||||
|
|
||||||
|
- fresh complete evidence,
|
||||||
|
- stale evidence,
|
||||||
|
- partial evidence.
|
||||||
|
|
||||||
|
Confirm that:
|
||||||
|
|
||||||
|
- review freshness and publication readiness remain separate concepts,
|
||||||
|
- stale or partial reviews can still be useful internally,
|
||||||
|
- but they do not present the same calm publishable posture as fresh complete reviews,
|
||||||
|
- and next-step guidance points toward refresh or completion work when needed.
|
||||||
|
|
||||||
|
### 3. Review pack trust
|
||||||
|
|
||||||
|
Open the review pack list and review packs linked to the reviews above.
|
||||||
|
|
||||||
|
Confirm that:
|
||||||
|
|
||||||
|
- the review-pack list row already surfaces the same internal-only or publishable caveat before the operator opens detail or clicks `Download`,
|
||||||
|
- a pack generated from stale or partial evidence no longer looks calmer than the source review,
|
||||||
|
- any internal-only or cautionary posture is visible before download,
|
||||||
|
- and the pack points back to the source review when corrective action is needed.
|
||||||
|
|
||||||
|
### 4. Canonical summary alignment
|
||||||
|
|
||||||
|
Open:
|
||||||
|
|
||||||
|
- `/admin/evidence/overview`
|
||||||
|
- `/admin/reviews`
|
||||||
|
|
||||||
|
Confirm that:
|
||||||
|
|
||||||
|
- stale or partial artifacts are visible as such in the row summaries,
|
||||||
|
- the next-step language is directionally the same as on detail pages,
|
||||||
|
- a tenant with fresh evidence but no current review shows a next step that points toward review creation rather than implying review readiness already exists,
|
||||||
|
- and drill-through links preserve tenant context and do not reveal non-entitled tenants.
|
||||||
|
|
||||||
|
### 5. Ten-second scan validation
|
||||||
|
|
||||||
|
Timebox the first visible scan of one snapshot detail page, one tenant review detail page, and one review pack detail page to 10 seconds each.
|
||||||
|
|
||||||
|
Confirm that within that time an operator can tell:
|
||||||
|
|
||||||
|
- whether the artifact is fresh enough,
|
||||||
|
- whether it is only internally useful or publishable,
|
||||||
|
- and what the next action is.
|
||||||
|
|
||||||
|
### 6. Authorization and action non-regression
|
||||||
|
|
||||||
|
Confirm that:
|
||||||
|
|
||||||
|
- view-only users can still inspect truth but not execute manage actions,
|
||||||
|
- non-entitled users still receive deny-as-not-found behavior,
|
||||||
|
- existing destructive actions still require confirmation,
|
||||||
|
- touched refresh, publish, export, regenerate, or create-next-review handlers still dispatch the existing services and current `OperationRun` types where applicable,
|
||||||
|
- and no new actions or route changes were introduced as part of the hardening.
|
||||||
|
|
||||||
|
### 7. Shared list-surface review checklist
|
||||||
|
|
||||||
|
Review `docs/product/standards/list-surface-review-checklist.md` against the touched list and report surfaces.
|
||||||
|
|
||||||
|
Confirm that:
|
||||||
|
|
||||||
|
- `/admin/evidence/overview` and `/admin/reviews` still use row click as the primary inspect model,
|
||||||
|
- the tenant-scoped review-pack list keeps its row-level trust caveat visible before drill-through or download,
|
||||||
|
- existing inline safe shortcuts and header actions remain in their established positions,
|
||||||
|
- empty and filtered states still read clearly without hiding trust truth,
|
||||||
|
- and default-visible row summaries still surface freshness, publishable posture, and next step without requiring drill-through.
|
||||||
|
|
||||||
|
### 8. Performance and render guardrails
|
||||||
|
|
||||||
|
Confirm from the implementation diff and final surface behavior that:
|
||||||
|
|
||||||
|
- the touched detail and canonical surfaces still render from existing database-backed truth inputs,
|
||||||
|
- no new render-time external calls, background dispatches, or route changes were introduced,
|
||||||
|
- and canonical row truth remains lightweight enough for a normal operator scan without adding a new per-row derivation layer.
|
||||||
|
|
||||||
|
## Formatting And Final Verification
|
||||||
|
|
||||||
|
Before finalizing implementation work:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
vendor/bin/sail bin pint --dirty --format agent
|
||||||
|
```
|
||||||
|
|
||||||
|
Then rerun the smallest affected test set. If the user wants broader confidence afterward, offer the full suite.
|
||||||
60
specs/174-evidence-freshness-publication-trust/research.md
Normal file
60
specs/174-evidence-freshness-publication-trust/research.md
Normal file
@ -0,0 +1,60 @@
|
|||||||
|
# Research: Evidence Temporal Freshness & Review Publication Trust
|
||||||
|
|
||||||
|
## Decision 1: Reuse the existing source-derived stale semantics instead of introducing a global freshness engine
|
||||||
|
|
||||||
|
- Decision: Treat provider-level `stale` evaluation and the existing `EvidenceCompletenessEvaluator` as the canonical source of temporal freshness for this feature.
|
||||||
|
- Rationale: Evidence freshness is already derived upstream in the evidence domain. `EvidenceSnapshotService` collects per-dimension payloads, providers such as `BaselineDriftPostureSource` already mark data stale using domain-appropriate recency rules, and `EvidenceCompletenessEvaluator` already escalates any required stale dimension into snapshot completeness. A second global threshold engine would duplicate truth and risk disagreement with the evidence domain.
|
||||||
|
- Alternatives considered:
|
||||||
|
- Add a new global `freshness_threshold_hours` configuration and recompute staleness on the page layer. Rejected because the existing evidence sources already encode domain-aware freshness and the page layer should not create its own recency semantics.
|
||||||
|
- Derive staleness purely from `generated_at` on snapshots and reviews. Rejected because the meaningful freshness signal already exists at the dimension level, and a snapshot can be freshly generated from stale inputs.
|
||||||
|
|
||||||
|
## Decision 2: Tighten `ArtifactTruthPresenter` instead of adding a new trust or publication resolver
|
||||||
|
|
||||||
|
- Decision: Keep `ArtifactTruthPresenter` as the single seam for evidence snapshot, tenant review, and review pack trust propagation.
|
||||||
|
- Rationale: The presenter already builds envelopes for `EvidenceSnapshot`, `TenantReview`, and `ReviewPack`, including freshness, content, publication, actionability, and next-step guidance. The current bug is not the absence of a trust layer, but that stale and partial semantics are not fully propagated through the existing one.
|
||||||
|
- Alternatives considered:
|
||||||
|
- Create a dedicated `PublicationReadinessResolver` or `FreshnessTrustGate`. Rejected because the current architecture already centralizes the needed truth and a second layer would violate the repo's proportionality and few-layers rules.
|
||||||
|
- Push the logic into page classes. Rejected because it would fragment truth across summary pages and detail resources.
|
||||||
|
|
||||||
|
## Decision 3: Tenant review publication readiness must degrade when freshness is stale
|
||||||
|
|
||||||
|
- Decision: Make stale review freshness capable of downgrading a review from publishable to an internal-only or cautionary posture even when the review status is `ready` or `published`.
|
||||||
|
- Rationale: `TenantReviewReadinessGate` already treats stale required sections as publication blockers at composition time, and `buildTenantReviewEnvelope()` already calculates a `freshnessState` of `stale`. The current contradiction is that publication readiness still becomes `publishable` from status alone, which lets stale reviews sound calmer than the evidence basis justifies.
|
||||||
|
- Rule: A stale review may remain internally useful, but it must not remain `publishable` solely because its lifecycle status is `ready` or `published`. If stronger blockers already exist, `blocked` still takes precedence.
|
||||||
|
- Alternatives considered:
|
||||||
|
- Leave publication readiness based only on status and blocker arrays. Rejected because it allows a stale review to look publishable even when the same presenter calls it stale.
|
||||||
|
- Introduce a new persisted review trust field. Rejected because the stale condition is already derivable from review completeness and section counts.
|
||||||
|
|
||||||
|
## Decision 4: Partial evidence must lower publication confidence even when it does not fully block review use
|
||||||
|
|
||||||
|
- Decision: Distinguish between reviews that are usable internally and reviews that are truly publishable when the evidence basis is partial.
|
||||||
|
- Rationale: The spec requires a meaningful difference between internal-use artifacts and publishable artifacts. The current truth model already supports separate `contentState`, `freshnessState`, `publicationReadiness`, and `actionability` dimensions, so the implementation can express this nuance without new state families.
|
||||||
|
- Rule: Partial evidence downgrades publication readiness to `internal_only` unless stronger existing blockers already make the artifact `blocked`.
|
||||||
|
- Alternatives considered:
|
||||||
|
- Treat partial evidence exactly like complete evidence whenever hard blockers are absent. Rejected because that is the current trust problem.
|
||||||
|
- Convert all partial evidence into hard publish blockers. Rejected because the spec wants internal-use versus publishable to remain distinct, not collapsed into blocked versus not blocked.
|
||||||
|
|
||||||
|
## Decision 5: Review packs must inherit stale and partial burden from the linked review and evidence basis
|
||||||
|
|
||||||
|
- Decision: Make `ReviewPack` truth inherit source review and evidence burden rather than treating a ready, non-expired file as inherently current and publishable.
|
||||||
|
- Rationale: `buildReviewPackEnvelope()` currently treats pack freshness as `current` whenever the file is not expired, even if the source review or source evidence is stale. That allows packs to appear calmer than the review they were generated from, which is exactly the contradiction this spec is meant to close.
|
||||||
|
- Rule: A pack is only `publishable` when its source review remains current and strong enough to publish. Stale or partial source burden must downgrade the pack to `internal_only`, unless stronger blockers already make it `blocked`.
|
||||||
|
- Alternatives considered:
|
||||||
|
- Keep pack freshness tied only to file expiration. Rejected because a current file can still be generated from stale governance evidence.
|
||||||
|
- Persist a separate pack trust state. Rejected because the necessary source review and evidence inputs already exist.
|
||||||
|
|
||||||
|
## Decision 6: Canonical summary pages should reuse the same truth envelope and next-step semantics
|
||||||
|
|
||||||
|
- Decision: Keep `EvidenceOverview` and `ReviewRegister` aligned by reusing the same envelope semantics that power the tenant-scoped detail pages.
|
||||||
|
- Rationale: Both canonical pages already display artifact truth and next-step or publication surfaces. The current risk is not missing UI slots, but summary rows sounding calmer than the detail pages because stale and partial burden are not fully represented in the underlying truth.
|
||||||
|
- Alternatives considered:
|
||||||
|
- Add new page-specific columns, banners, or taxonomy labels. Rejected because the repo already has canonical truth and badge primitives.
|
||||||
|
- Leave canonical summary rows untouched and rely on drill-through. Rejected because the spec explicitly targets trust propagation on summary surfaces.
|
||||||
|
|
||||||
|
## Decision 7: Expand the current Pest test surfaces and fixture helpers instead of creating a new test harness
|
||||||
|
|
||||||
|
- Decision: Extend the current evidence, review, pack, review-register, and evidence-overview tests, and strengthen shared governance-artifact fixtures.
|
||||||
|
- Rationale: The current suite already covers resource routing, basic artifact truth visibility, review publication blockers, and canonical row scoping. The missing coverage is specific: fresh versus stale propagation, partial-evidence publication trust, and review-versus-pack consistency.
|
||||||
|
- Alternatives considered:
|
||||||
|
- Rely on manual browser validation only. Rejected because this feature is about preventing semantic drift across multiple related surfaces.
|
||||||
|
- Add a separate browser suite as the primary guard. Rejected because the existing Pest feature surfaces are already well aligned with the affected code paths and will be faster and cheaper to maintain.
|
||||||
237
specs/174-evidence-freshness-publication-trust/spec.md
Normal file
237
specs/174-evidence-freshness-publication-trust/spec.md
Normal file
@ -0,0 +1,237 @@
|
|||||||
|
# Feature Specification: Evidence Temporal Freshness & Review Publication Trust
|
||||||
|
|
||||||
|
**Feature Branch**: `174-evidence-freshness-publication-trust`
|
||||||
|
**Created**: 2026-04-04
|
||||||
|
**Status**: Draft
|
||||||
|
**Input**: User description: "Spec 174 — Evidence Temporal Freshness & Review Publication Trust"
|
||||||
|
|
||||||
|
## Spec Scope Fields *(mandatory)*
|
||||||
|
|
||||||
|
- **Scope**: tenant + canonical-view
|
||||||
|
- **Primary Routes**:
|
||||||
|
- `/admin/t/{tenant}/evidence` and `/admin/t/{tenant}/evidence/{snapshot}` for tenant-scoped evidence snapshot inspection
|
||||||
|
- `/admin/t/{tenant}/reviews` and `/admin/t/{tenant}/reviews/{review}` for tenant-scoped review lifecycle and publication decisions
|
||||||
|
- `/admin/t/{tenant}/review-packs` and `/admin/t/{tenant}/review-packs/{pack}` for tenant-scoped executive pack generation, download, and trust communication
|
||||||
|
- `/admin/reviews` for the canonical review register across entitled tenants
|
||||||
|
- `route('admin.evidence.overview')` for the canonical evidence overview across entitled tenants
|
||||||
|
- **Data Ownership**:
|
||||||
|
- Tenant-owned: stored reports, evidence snapshots, evidence snapshot items, tenant reviews, and review packs remain tenant-scoped artifacts anchored to one tenant's governance history
|
||||||
|
- Workspace-owned but tenant-filtered: canonical register and overview pages aggregate only within the operator's entitled workspace and tenant set, without changing ownership of the underlying artifacts
|
||||||
|
- This feature introduces no new persisted trust record, no new freshness record, and no new export-tracking entity; truth remains derived from existing timestamps, completeness states, and artifact links
|
||||||
|
- **RBAC**:
|
||||||
|
- Workspace membership and tenant entitlement remain required for all tenant-scoped evidence, review, and pack routes
|
||||||
|
- Existing capabilities remain authoritative: `evidence.view` / `evidence.manage`, `tenant_review.view` / `tenant_review.manage`, and `review_pack.view` / `review_pack.manage`
|
||||||
|
- Canonical review and evidence surfaces must preserve deny-as-not-found semantics for non-members and non-entitled users, and must not expose cross-tenant artifact existence, trust posture, or readiness hints outside the authorized tenant set
|
||||||
|
|
||||||
|
For canonical-view specs, the spec MUST define:
|
||||||
|
|
||||||
|
- **Default filter behavior when tenant-context is active**: When an operator opens the canonical review register or canonical evidence overview from a tenant-scoped surface, the destination opens prefiltered to that tenant through the existing tenant-prefilter mechanisms so the operator stays in the same tenant world they clicked from.
|
||||||
|
- **Explicit entitlement checks preventing cross-tenant leakage**: Canonical review rows, evidence rows, artifact-truth badges, publication-readiness badges, next-step text, filters, and drill-through links must only be built after workspace membership and tenant-entitlement checks. Non-entitled users must not learn whether another tenant has a current review, a stale evidence snapshot, or an exportable pack.
|
||||||
|
|
||||||
|
## UI/UX Surface Classification *(mandatory when operator-facing surfaces are changed)*
|
||||||
|
|
||||||
|
Affected list and report surfaces in this spec MUST also be reviewed against `docs/product/standards/list-surface-review-checklist.md` before implementation sign-off so row-click behavior, inline action discipline, empty states, and summary truth remain aligned with the shared operator-surface standard.
|
||||||
|
|
||||||
|
| Surface | Surface Type | Primary Inspect/Open Model | Row Click | Secondary Actions Placement | Destructive Actions Placement | Canonical Collection Route | Canonical Detail Route | Scope Signals | Canonical Noun | Critical Truth Visible by Default | Exception Type |
|
||||||
|
|---|---|---|---|---|---|---|---|---|---|---|---|
|
||||||
|
| Evidence snapshot resource | CRUD / List-first Resource | Full-row click from list to snapshot detail | required | One `More` menu for non-primary list actions | `Expire snapshot` in `More` or detail header | `/admin/t/{tenant}/evidence` | `/admin/t/{tenant}/evidence/{snapshot}` | Tenant context and evidence completeness badges scope every row and action | Evidence / Evidence snapshot | Artifact truth, completeness, freshness pressure, and next step | none |
|
||||||
|
| Tenant review resource | CRUD / List-first Resource | Full-row click from list to tenant review detail | required | One inline safe shortcut (`Export executive pack`) plus detail-header support actions | `Archive review` in detail header `More` group | `/admin/t/{tenant}/reviews` | `/admin/t/{tenant}/reviews/{review}` | Tenant context, review status, completeness, publication readiness, and evidence basis stay explicit | Reviews / Review | Artifact truth, completeness, publication readiness, and next step | none |
|
||||||
|
| Canonical review register | Read-only Registry / Report Surface | Clickable row to the tenant-scoped review detail that matches the row | required | One inline safe shortcut (`Export executive pack`) plus header clear-filters action | none | `/admin/reviews` | Tenant-scoped review detail for the selected row | Tenant labels, publication badges, completeness badges, and tenant-prefilter state | Reviews / Review | Cross-tenant review truth, publication readiness, and next step | canonical-view registry |
|
||||||
|
| Review pack resource | CRUD / List-first Resource | Full-row click from list to pack detail | required | One inline safe shortcut (`Download`) plus detail-header support actions | `Expire` in overflow or detail header | `/admin/t/{tenant}/review-packs` | `/admin/t/{tenant}/review-packs/{pack}` | Tenant context, linked review, review status, pack status, and artifact truth remain visible | Review Packs / Review pack | Publication readiness, freshness burden, and whether export is internal-only or publishable | none |
|
||||||
|
| Evidence overview | Read-only Registry / Report Surface | Clickable row to the tenant-scoped evidence snapshot detail for the selected tenant | required | Header clear-filters action only | none | `route('admin.evidence.overview')` | `/admin/t/{tenant}/evidence/{snapshot}` for the selected row | Tenant labels, artifact truth, freshness badges, and next-step text keep the overview scoped | Evidence / Evidence snapshot | Freshness and completeness truth across entitled tenants | canonical-view registry |
|
||||||
|
|
||||||
|
## Operator Surface Contract *(mandatory when operator-facing surfaces are changed)*
|
||||||
|
|
||||||
|
| Surface | Primary Persona | Surface Type | Primary Operator Question | Default-visible Information | Diagnostics-only Information | Status Dimensions Used | Mutation Scope | Primary Actions | Dangerous Actions |
|
||||||
|
|---|---|---|---|---|---|---|---|---|---|
|
||||||
|
| Evidence snapshot detail | Tenant operator | Detail-first Operational Surface | Is this evidence basis complete enough, fresh enough, and trustworthy enough to use right now? | Artifact truth, completeness state, freshness pressure, missing or stale dimensions, and next-step guidance | Raw summary JSON, deep dimension payloads, fingerprints, source record IDs | artifact existence, content completeness, temporal freshness, actionability | TenantPilot evidence lifecycle only | Refresh evidence, view linked operation, inspect dimensions | Expire snapshot |
|
||||||
|
| Tenant review detail | Tenant operator | Detail-first Operational Surface | Is this review merely stored, ready for internal use, or strong enough to publish externally? | Artifact truth, completeness, publication readiness, review blockers or warnings, evidence basis, and next-step guidance | Full section payloads, raw summary JSON, historical audit context, operation metadata | artifact existence, completeness, temporal freshness, publication readiness, actionability | TenantPilot review lifecycle and export initiation | Refresh review, publish review, export executive pack, create next review, open evidence | Archive review |
|
||||||
|
| Canonical review register | Workspace auditor or operator | Read-only Registry / Report Surface | Which tenants currently have review artifacts that are fresh enough, publication-ready enough, or in need of follow-up? | Review status, artifact truth, completeness, publication readiness, and next-step text per tenant | Deep section content, raw evidence payloads, audit internals | review lifecycle, artifact truth, completeness, publication readiness | Read-only registry plus export initiation where already allowed | Open review, export executive pack | none |
|
||||||
|
| Review pack detail | Tenant operator | Detail-first Operational Surface | Is this pack merely downloadable, or is it publishable enough to treat as a stakeholder-facing export? | Artifact truth, pack status, linked review, evidence basis summary, publication readiness, and download caveat when needed | Fingerprints, previous fingerprint, low-level generation metadata | artifact existence, freshness pressure, publication readiness, expiration state | TenantPilot export lifecycle only | Download, regenerate pack, open linked review | Expire pack |
|
||||||
|
| Evidence overview | Workspace auditor or operator | Read-only Registry / Report Surface | Which entitled tenants currently have fresh, complete evidence and which require follow-up before review or publication? | Tenant, artifact truth, freshness state, missing or stale dimensions, and next step | Per-dimension detail payloads and raw summary JSON remain on snapshot detail | artifact truth, freshness, completeness | none | Open evidence snapshot | none |
|
||||||
|
|
||||||
|
## Proportionality Review *(mandatory when structural complexity is introduced)*
|
||||||
|
|
||||||
|
- **New source of truth?**: No. Evidence snapshots, review records, review packs, and the existing artifact-truth layer remain the authoritative truth sources.
|
||||||
|
- **New persisted entity/table/artifact?**: No. This feature explicitly avoids new persistence and must derive freshness and publication trust from existing timestamps, completeness states, and links.
|
||||||
|
- **New abstraction?**: No. The narrowest correct implementation is to harden the existing `ArtifactTruthPresenter`, readiness gates, and surface mappings rather than adding a second trust framework.
|
||||||
|
- **New enum/state/reason family?**: No. Existing freshness and publication-readiness dimensions should be tightened, not replaced with a new persisted status family.
|
||||||
|
- **New cross-domain UI framework/taxonomy?**: No. This is a surface-truth hardening slice inside the existing evidence/review/export chain.
|
||||||
|
- **Current operator problem**: Structurally complete evidence can currently read as current enough and publishable enough even when it is too old or only partial, which risks operators exporting or publishing artifacts that are not decision-grade.
|
||||||
|
- **Existing structure is insufficient because**: Completeness and publication surfaces already exist, but temporal freshness and partial-evidence burden are not strong enough in the artifact-truth and review-readiness chain to keep surfaces from sounding calmer than the underlying evidence basis.
|
||||||
|
- **Narrowest correct implementation**: Tighten the existing artifact-truth and readiness evaluation to degrade stale or partial artifacts visibly on evidence, review, register, and pack surfaces, while preserving current resources and actions.
|
||||||
|
- **Ownership cost**: The repo takes on a small amount of additional cross-surface regression coverage and ongoing maintenance for freshness-threshold and publication-caveat rules.
|
||||||
|
- **Alternative intentionally rejected**: A new StoredReport resource, a new export governance table, or a separate portfolio-trust layer was rejected because the immediate product risk can be solved by tightening the current evidence/review/export truth chain.
|
||||||
|
- **Release truth**: Current-release truth. Operators can already act on these surfaces today, so trust hardening cannot wait for a later reporting overhaul.
|
||||||
|
|
||||||
|
## User Scenarios & Testing *(mandatory)*
|
||||||
|
|
||||||
|
### User Story 1 - Detect Stale Evidence Before Reusing It (Priority: P1)
|
||||||
|
|
||||||
|
As a tenant operator, I want evidence, reviews, and packs to tell me when their underlying evidence basis is too old to rely on confidently, so that I do not make governance decisions on structurally complete but outdated artifacts.
|
||||||
|
|
||||||
|
**Why this priority**: Silent evidence aging is the highest trust risk in this domain because it can make calm-looking artifacts unsafe for real decisions.
|
||||||
|
|
||||||
|
**Independent Test**: Can be fully tested by seeding fresh and old evidence snapshots with the same structural completeness and verifying that only the fresh artifacts read as current enough for confident reuse.
|
||||||
|
|
||||||
|
**Acceptance Scenarios**:
|
||||||
|
|
||||||
|
1. **Given** an evidence snapshot is structurally complete but one or more required evidence dimensions have aged past their canonical source-defined freshness thresholds, **When** an operator opens the snapshot, linked review, or linked pack surface, **Then** the artifact is shown as stale or cautionary rather than quietly current.
|
||||||
|
2. **Given** an evidence snapshot is structurally complete and still within the freshness threshold, **When** an operator opens the snapshot, linked review, or linked pack surface, **Then** the freshness dimension allows the artifact to remain in a current or fresher state when no other trust limiter applies.
|
||||||
|
3. **Given** a canonical register or overview lists both fresh and stale artifacts, **When** the operator scans the table, **Then** freshness burden is visible without opening every row.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### User Story 2 - Distinguish Internal-Use Reviews From Publishable Reviews (Priority: P1)
|
||||||
|
|
||||||
|
As a tenant operator, I want review and pack surfaces to distinguish between internally useful artifacts and truly publishable artifacts, so that I do not treat downloadability or technical readiness as proof that a report is publishable.
|
||||||
|
|
||||||
|
**Why this priority**: Export and publication are the moments where a misleadingly calm artifact becomes an external trust problem.
|
||||||
|
|
||||||
|
**Independent Test**: Can be fully tested by rendering reviews and packs built from complete evidence, partial evidence, and stale evidence, then verifying that their readiness and publication signals diverge appropriately.
|
||||||
|
|
||||||
|
**Acceptance Scenarios**:
|
||||||
|
|
||||||
|
1. **Given** a tenant review is structurally ready but built on partial evidence, **When** the operator views the review, **Then** the surface shows a cautionary or internal-only publication posture rather than the same calm signal used for publishable reviews.
|
||||||
|
2. **Given** a review pack is downloadable but derived from stale or partial evidence, **When** the operator opens or downloads the pack, **Then** the surface discloses that the pack is better suited to internal use or review refresh rather than quiet external sharing.
|
||||||
|
3. **Given** a tenant review and its current export pack share the same stale or partial evidence burden, **When** the operator compares their surfaces, **Then** both surfaces communicate consistent trust and publication signals.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### User Story 3 - Recover The Evidence Basis Across Review Surfaces (Priority: P2)
|
||||||
|
|
||||||
|
As a workspace operator reviewing multiple tenants, I want canonical review and evidence surfaces to preserve the link between review status, evidence freshness, and next action, so that I can tell which tenants are review-ready and which need evidence refresh before publication.
|
||||||
|
|
||||||
|
**Why this priority**: Portfolio-level review work depends on summary surfaces carrying the same truth as the detail surfaces they summarize.
|
||||||
|
|
||||||
|
**Independent Test**: Can be fully tested by seeding tenants with mixed evidence freshness and review readiness and verifying that review-register and evidence-overview rows present the same trust direction as the underlying detail pages.
|
||||||
|
|
||||||
|
**Acceptance Scenarios**:
|
||||||
|
|
||||||
|
1. **Given** two tenants have reviews with different evidence freshness burdens, **When** the operator scans the review register, **Then** the table communicates which review is fresher and which needs follow-up.
|
||||||
|
2. **Given** a tenant has fresh evidence but no current review, **When** the operator scans canonical surfaces, **Then** the surfaces show a next step that points toward review creation rather than implying review readiness already exists.
|
||||||
|
3. **Given** a tenant has stale evidence and a previously generated pack, **When** the operator follows drill-through links between overview and detail pages, **Then** the trust explanation remains coherent across the journey.
|
||||||
|
|
||||||
|
### Edge Cases
|
||||||
|
|
||||||
|
- A run can complete and create an evidence snapshot, but the snapshot can later age past the acceptable freshness window without any structural completeness change.
|
||||||
|
- A review can have all sections present yet still rely on partial sections whose evidence basis should reduce publication confidence.
|
||||||
|
- A pack can remain downloadable after its linked review or evidence basis becomes stale; the surface must warn rather than silently remain calm.
|
||||||
|
- A stored report referenced by an evidence item can be pruned by retention while the evidence snapshot still exists; this feature must not require a new raw-report viewer to remain truthful.
|
||||||
|
- A user may be entitled to a tenant review but not to export or manage it; readiness and trust truth may still be visible, while actions remain capability-gated.
|
||||||
|
- Canonical register and overview pages may be prefiltered from a tenant context; they must remain semantically consistent without leaking artifacts from non-entitled tenants.
|
||||||
|
|
||||||
|
## Requirements *(mandatory)*
|
||||||
|
|
||||||
|
**Constitution alignment (required):** This feature introduces no new Microsoft Graph calls and no new long-running flow. It hardens how existing evidence, review, and pack artifacts communicate trust. Existing review and pack actions continue to use their current services and current `OperationRun` types. No new contract registry entry or queued process is added.
|
||||||
|
|
||||||
|
**Constitution alignment (PROP-001 / ABSTR-001 / PERSIST-001 / STATE-001 / BLOAT-001):** This slice adds no persistence, no new abstraction layer, and no new persisted status family. The current operator problem is that structurally complete artifacts can look newer and safer than they are. Existing structure is insufficient because completeness and publication surfaces are not yet hard enough against age and partial evidence. The narrowest correct implementation is to strengthen the existing artifact-truth and readiness logic. Ownership cost is limited to regression coverage and threshold maintenance. A separate reporting or StoredReport framework is intentionally rejected in this slice.
|
||||||
|
|
||||||
|
**Constitution alignment (OPS-UX):** No new `OperationRun` type is added. Existing evidence snapshot generation, review composition, review refresh, and review-pack generation continue to own execution lifecycle through current services and current run types. This feature only changes how resulting artifacts communicate trust and next action after those runs complete.
|
||||||
|
|
||||||
|
**Constitution alignment (RBAC-UX):** The feature affects the tenant/admin plane for tenant-scoped evidence, reviews, and review packs, plus canonical admin pages for the review register and evidence overview. Non-members and non-entitled users remain `404`. In-scope members lacking `evidence.manage`, `tenant_review.manage`, or `review_pack.manage` remain `403` for the corresponding actions. Export and publication affordances must stay capability-aware while still allowing truth signals to appear for view-only users.
|
||||||
|
|
||||||
|
**Constitution alignment (OPS-EX-AUTH-001):** Not applicable. No authentication handshake behavior is changed.
|
||||||
|
|
||||||
|
**Constitution alignment (BADGE-001):** Existing centralized badge domains for tenant review status, tenant review completeness, evidence completeness, review pack status, governance artifact freshness, and related artifact-truth labels remain the semantic source. This feature may change when those badge families show caution or downgrade states, but must not introduce local page-only badge mappings.
|
||||||
|
|
||||||
|
**Constitution alignment (UI-FIL-001):** The feature reuses native Filament resources, infolists, tables, actions, badges, sections, and existing governance artifact-truth entry views. Local replacement markup for core truth surfaces should be avoided. Any stronger stale or internal-only emphasis should still be expressed through Filament props and central badge or truth primitives rather than page-local color languages.
|
||||||
|
|
||||||
|
**Constitution alignment (UI-NAMING-001):** The target objects are evidence snapshots, reviews, and review packs. Primary operator-facing vocabulary remains `Create snapshot`, `Refresh evidence`, `Create review`, `Refresh review`, `Publish review`, `Export executive pack`, `Generate first pack`, `Download`, and `Expire`. New or revised copy must preserve the difference between `current`, `stale`, `partial`, `internal use`, `publication ready`, and `blocked`, and must avoid implementation-first wording such as `snapshot age rule`, `trust envelope`, or `derived state` in primary operator labels.
|
||||||
|
|
||||||
|
**Constitution alignment (UI-CONST-001 / UI-SURF-001 / UI-HARD-001 / UI-EX-001 / UI-REVIEW-001):** The affected surfaces consist of `CRUD / List-first Resource` lists for snapshots, reviews, and review packs, `Read-only Registry / Report Surface` pages for the canonical overview and register, and dedicated `Detail-first Operational Surface` pages for artifact inspection and action context. Each keeps one primary inspect model: row-click for snapshot, review, register, and pack tables; detail-header actions for lifecycle mutations; and diagnostic subsections for raw summaries. Critical truth visible by default must include artifact truth, freshness burden, completeness burden, publication readiness, and next step where relevant. This feature does not introduce redundant `View` affordances or move destructive actions out of their current safe placements.
|
||||||
|
|
||||||
|
**Constitution alignment (OPSURF-001):** Operator-first content on evidence, review, and pack surfaces must separate execution truth, artifact existence, completeness, freshness, and publication readiness rather than flattening them into one vague `ready` state. Diagnostics such as raw summary JSON, fingerprints, and low-level IDs remain secondary. Mutating actions continue to act on TenantPilot-managed artifacts only.
|
||||||
|
|
||||||
|
**Constitution alignment (UI-SEM-001 / LAYER-001 / TEST-TRUTH-001):** Direct mapping from persisted artifact status alone is insufficient because artifact trust depends on cross-cutting freshness and completeness context. This feature must strengthen the existing artifact-truth layer rather than adding a parallel presenter family. Tests must verify business consequences such as stale artifacts not appearing publishable, partial evidence downgrading publication trust, and review/pack surfaces staying consistent.
|
||||||
|
|
||||||
|
**Constitution alignment (Filament Action Surfaces):** The Action Surface Contract remains satisfied. Evidence, review, and pack resources keep one primary inspect model per list, no redundant row-level view actions, and confirmation-gated destructive actions where applicable. Review register and evidence overview remain read-only registry surfaces. UI-FIL-001 remains satisfied with no new exception required.
|
||||||
|
|
||||||
|
**Constitution alignment (UX-001 — Layout & Information Architecture):** Existing detail screens remain sectioned Infolist-style pages. This feature strengthens the top-level truth and caveat zones on those screens rather than converting them into forms or custom layouts. Tables remain searchable, sortable, and filterable where already supported.
|
||||||
|
|
||||||
|
### Functional Requirements
|
||||||
|
|
||||||
|
- **FR-174-001**: Evidence snapshots and every artifact derived from them MUST distinguish structural completeness from temporal freshness. A structurally complete artifact MUST NOT automatically read as current enough solely because all expected dimensions are present.
|
||||||
|
- **FR-174-002**: Temporal freshness MUST be evaluated from existing evidence timestamps using a clearly defined freshness policy for each evidence dimension. Where source-specific freshness thresholds are already canonical in the evidence domain, this feature MUST reuse them rather than inventing a second global threshold.
|
||||||
|
- **FR-174-003**: When one or more required evidence dimensions exceed their canonical freshness policy, the snapshot, any review anchored to it, and any review pack anchored to that review or snapshot MUST visibly degrade into a stale or cautionary trust state.
|
||||||
|
- **FR-174-004**: Stale evidence MUST influence the artifact-truth summary that operators see on evidence snapshot detail, tenant review detail, review register rows, review pack detail, review pack list rows, and evidence overview rows.
|
||||||
|
- **FR-174-005**: A tenant review or review pack based on stale evidence MUST NOT present the same calm `current`, `ready`, or publishable impression as a fresh artifact.
|
||||||
|
- **FR-174-006**: Partial evidence MUST materially affect review and pack trust. If a review or review pack is structurally complete but its evidence basis is partial, the surface MUST downgrade publication readiness to `internal_only`, unless existing stronger publish blockers already make it `blocked`, instead of using the same publishable posture used for stronger evidence.
|
||||||
|
- **FR-174-007**: Review readiness and publication readiness MUST remain visibly distinct. An artifact may be ready for internal inspection while still requiring caution or restriction for external sharing.
|
||||||
|
- **FR-174-008**: Download or export affordances for review packs MUST surface when a pack is safer for internal use than for external sharing. A technically downloadable pack MUST NOT silently imply publishable trust.
|
||||||
|
- **FR-174-009**: Review and pack surfaces MUST explain the principal reason for trust reduction when freshness or evidence quality lowers confidence, including stale evidence, partial sections, or missing or stale dimensions where relevant.
|
||||||
|
- **FR-174-010**: When an artifact is stale, partial, internal-only, or otherwise not publishable, the surface MUST provide a clear next step such as refreshing evidence, refreshing the review, resolving missing evidence, or generating a new pack.
|
||||||
|
- **FR-174-011**: Canonical review and evidence registry surfaces MUST stay semantically aligned with their tenant-scoped detail surfaces. A row that reads stale, partial, or internal-only on a detail page MUST not read calm or publishable in its register row.
|
||||||
|
- **FR-174-012**: Tenant review detail and review pack detail MUST not send contradictory publication or trust signals for artifacts that share the same stale or partial evidence burden.
|
||||||
|
- **FR-174-013**: This feature MUST preserve existing tenant-scoped and canonical prefilter navigation semantics so operators can follow truth from snapshot to review to pack without losing tenant context.
|
||||||
|
- **FR-174-014**: The feature MUST not require a new StoredReport surface, a new persistence model, or a new export-governance artifact in order to become truthful about stale or partial evidence.
|
||||||
|
- **FR-174-015**: Existing evidence, review, and pack actions MUST keep their current capability checks, confirmation behavior, and run-launch semantics while the surrounding truth language becomes stricter.
|
||||||
|
- **FR-174-016**: Regression coverage MUST verify fresh vs stale evidence behavior, complete vs partial evidence behavior, review vs pack truth consistency, and download or export caveat behavior without relying on a new schema artifact.
|
||||||
|
|
||||||
|
## UI Action Matrix *(mandatory when Filament is changed)*
|
||||||
|
|
||||||
|
| Surface | Location | Header Actions | Inspect Affordance (List/Table) | Row Actions (max 2 visible) | Bulk Actions (grouped) | Empty-State CTA(s) | View Header Actions | Create/Edit Save+Cancel | Audit log? | Notes / Exemptions |
|
||||||
|
|---|---|---|---|---|---|---|---|---|---|---|
|
||||||
|
| Evidence snapshot resource | `app/Filament/Resources/EvidenceSnapshotResource.php` | `Create snapshot` on list header | `recordUrl()` clickable row to snapshot detail | `More` group currently carries `Expire snapshot` | none | `Create first snapshot` | `Refresh evidence`, `Expire snapshot`, existing related links | n/a | existing evidence lifecycle audit remains | This spec changes truth presentation and next-step guidance, not the action inventory |
|
||||||
|
| Tenant review resource | `app/Filament/Resources/TenantReviewResource.php` and `Pages/ViewTenantReview.php` | `Create review` on list header | `recordUrl()` clickable row to review detail | `Export executive pack` inline shortcut on list | none | `Create first review` | `Open operation`, `View executive pack`, `View evidence snapshot`, `Refresh review`, `Publish review`, `Export executive pack`, `Create next review`, `Archive review` | n/a | existing review lifecycle audit remains | Publication-trust hardening must apply consistently across list and detail |
|
||||||
|
| Canonical review register | `app/Filament/Pages/Reviews/ReviewRegister.php` | `Clear filters` | `recordUrl()` clickable row to tenant-scoped review detail | `Export executive pack` safe row action where currently allowed | none | `Clear filters` | n/a | n/a | no new audit behavior | Canonical registry remains read-only apart from the existing export shortcut |
|
||||||
|
| Review pack resource | `app/Filament/Resources/ReviewPackResource.php` | `Generate pack` on list header | `recordUrl()` clickable row to pack detail | `Download` | none | `Generate first pack` | `Download`, `Regenerate`, `Expire`, and existing related links | n/a | existing pack lifecycle audit remains | Download remains allowed where currently allowed, but its trust caveat must become explicit |
|
||||||
|
| Evidence overview | `app/Filament/Pages/Monitoring/EvidenceOverview.php` | `Clear filters` when tenant-prefilter is active | Clickable row to tenant-scoped evidence snapshot detail | none | none | `Clear filters` in the empty state | n/a | n/a | no new audit behavior | Read-only canonical overview; this spec makes freshness truth stronger here |
|
||||||
|
|
||||||
|
### Key Entities *(include if feature involves data)*
|
||||||
|
|
||||||
|
- **StoredReport**: Raw tenant-scoped point-in-time source data such as permission posture or Entra admin roles captures. It is an input to evidence, not itself a review-grade artifact.
|
||||||
|
- **EvidenceSnapshot**: Immutable tenant-scoped evidence basis composed from multiple dimensions and evaluated for completeness and freshness.
|
||||||
|
- **EvidenceSnapshotItem**: A dimension-level evidence record linking an evidence snapshot to its source kind, source record, fingerprint, measured time, and completeness state.
|
||||||
|
- **TenantReview**: A review artifact anchored to one evidence snapshot and evaluated for completeness, publication readiness, and next action.
|
||||||
|
- **ReviewPack**: A tenant-scoped export artifact derived from a review or evidence basis, intended for download and potential sharing, with integrity and lifecycle metadata.
|
||||||
|
- **Artifact truth**: The operator-facing evaluation of artifact existence, completeness, freshness, publication readiness, and actionability that determines whether an artifact is merely stored, internally useful, or safe enough to publish.
|
||||||
|
|
||||||
|
## Success Criteria *(mandatory)*
|
||||||
|
|
||||||
|
### Measurable Outcomes
|
||||||
|
|
||||||
|
- **SC-174-001**: In seeded review exercises, operators can determine within 10 seconds whether an evidence snapshot, tenant review, or review pack is fresh enough, stale, partial, or publishable without opening raw JSON or source records.
|
||||||
|
- **SC-174-002**: In regression coverage, every scenario built on stale evidence or partial evidence shows a more cautious trust or publication state than the equivalent fresh-and-complete scenario across snapshot, review, and pack surfaces.
|
||||||
|
- **SC-174-003**: In regression coverage, review-register rows, evidence-overview rows, tenant review detail, and review-pack detail agree on the trust direction of the same underlying artifact in 100% of covered stale and partial scenarios.
|
||||||
|
- **SC-174-004**: In operator validation, downloadable but internal-only or cautionary packs are recognizably different from publishable packs before the operator clicks `Download`.
|
||||||
|
- **SC-174-005**: The feature ships without a required schema migration, a new persisted trust artifact, or a new raw StoredReport resource.
|
||||||
|
|
||||||
|
## Assumptions
|
||||||
|
|
||||||
|
- Existing evidence snapshot timestamps and item timestamps are sufficient to derive temporal freshness without creating a new persistence model.
|
||||||
|
- Existing artifact-truth and review-readiness logic are the right places to strengthen stale and partial-evidence semantics.
|
||||||
|
- Existing review-pack download behavior can remain technically available while its trust and sharing guidance becomes more explicit.
|
||||||
|
- StoredReport observability and source-liveness follow-up work may still be useful later, but are not prerequisites for this truth-hardening slice.
|
||||||
|
|
||||||
|
## Non-Goals
|
||||||
|
|
||||||
|
- Creating a dedicated StoredReport resource or raw report viewer
|
||||||
|
- Building a live-state-versus-historical-state diff engine for published reviews
|
||||||
|
- Adding stakeholder-delivery tracking or external-share audit semantics
|
||||||
|
- Rebuilding Evidence Overview or Review Register into a new portfolio application
|
||||||
|
- Introducing new tables, new status enums, or a second artifact-truth framework
|
||||||
|
|
||||||
|
## Dependencies
|
||||||
|
|
||||||
|
- Existing `EvidenceSnapshotResource`, `TenantReviewResource`, `ReviewPackResource`, `ReviewRegister`, and `EvidenceOverview` surfaces
|
||||||
|
- Existing `ArtifactTruthPresenter`, `ArtifactTruthEnvelope`, review readiness logic, and review-pack generation services
|
||||||
|
- Existing capability and tenant-entitlement enforcement for evidence, reviews, and packs
|
||||||
|
- Existing evidence-domain, tenant-review, and review-pack foundational specs already implemented in the repo
|
||||||
|
|
||||||
|
## Follow-up Spec Candidates
|
||||||
|
|
||||||
|
- **StoredReport Observability & Source Liveness** for explicit raw-report diagnostics and pruned-source visibility
|
||||||
|
- **Evidence / Review Portfolio Cross-Linking** for tighter canonical navigation between evidence overview and review register
|
||||||
|
- **Published Review Drift & Superseded Evidence Signals** for signaling when a published review no longer reflects the current evidence basis
|
||||||
|
|
||||||
|
## Definition of Done
|
||||||
|
|
||||||
|
Spec 174 is complete when:
|
||||||
|
|
||||||
|
- structurally complete artifacts no longer read as current enough when their evidence basis is too old,
|
||||||
|
- partial evidence produces a visibly more cautious review and pack trust posture,
|
||||||
|
- review and pack surfaces stay consistent about the same stale or partial burden,
|
||||||
|
- export and download actions no longer imply stronger trust than the underlying artifact supports,
|
||||||
|
- canonical register and overview surfaces reflect the same truth direction as tenant-scoped detail surfaces,
|
||||||
|
- and the hardening is achieved without new persistence or a new reporting subsystem.
|
||||||
226
specs/174-evidence-freshness-publication-trust/tasks.md
Normal file
226
specs/174-evidence-freshness-publication-trust/tasks.md
Normal file
@ -0,0 +1,226 @@
|
|||||||
|
# Tasks: Evidence Temporal Freshness & Review Publication Trust
|
||||||
|
|
||||||
|
**Input**: Design documents from `/specs/174-evidence-freshness-publication-trust/`
|
||||||
|
**Prerequisites**: `plan.md` (required), `spec.md` (required for user stories), `research.md`, `data-model.md`, `contracts/`
|
||||||
|
|
||||||
|
**Tests**: Tests are REQUIRED for this feature. Use focused Pest coverage in `tests/Feature/Evidence/EvidenceSnapshotResourceTest.php`, `tests/Feature/Evidence/EvidenceOverviewPageTest.php`, `tests/Feature/TenantReview/TenantReviewLifecycleTest.php`, `tests/Feature/TenantReview/TenantReviewRegisterTest.php`, `tests/Feature/ReviewPack/ReviewPackResourceTest.php`, and existing truth-support fixtures.
|
||||||
|
**Operations**: This feature reuses existing evidence snapshot, tenant review, and review pack generation flows and their current `OperationRun` types. No new `OperationRun` creation, lifecycle transition, notification, or `summary_counts` producer work is introduced.
|
||||||
|
**RBAC**: Existing tenant entitlement, workspace entitlement, and capability gating must remain unchanged. Tests must preserve deny-as-not-found behavior for non-entitled users and manage-action capability gating for view-only users.
|
||||||
|
**Operator Surfaces**: Evidence snapshot detail, tenant review detail, review pack detail, evidence overview, and the canonical review register must stay operator-first, with freshness, completeness, publication readiness, and next step visible by default where relevant.
|
||||||
|
**Filament UI Action Surfaces**: No new actions are introduced. Existing list inspect affordances, inline safe shortcuts, destructive action placement, and confirmation behavior must remain intact while truth semantics become stricter.
|
||||||
|
**Filament UI UX-001**: Existing Infolist and table layouts remain; this feature hardens top-level trust and caveat semantics rather than creating new screens.
|
||||||
|
**Badges**: Freshness, completeness, publication-readiness, and artifact-truth semantics must continue to use centralized badge domains and the existing governance truth partial.
|
||||||
|
|
||||||
|
**Organization**: Tasks are grouped by user story so each story can be implemented and tested as an independent increment after the shared truth scaffolding is in place.
|
||||||
|
|
||||||
|
## Phase 1: Setup (Shared Test Scaffolding)
|
||||||
|
|
||||||
|
**Purpose**: Prepare reusable stale and partial evidence fixtures that all stories can build on.
|
||||||
|
|
||||||
|
- [X] T001 Extend shared stale and partial governance artifact fixture builders in `tests/Feature/Concerns/BuildsGovernanceArtifactTruthFixtures.php`
|
||||||
|
- [X] T002 [P] Add or refine stale evidence seeding helpers used by evidence and review tests in `tests/Pest.php`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 2: Foundational (Blocking Truth Propagation Prerequisite)
|
||||||
|
|
||||||
|
**Purpose**: Tighten the shared trust-propagation seam before any surface-specific story work begins.
|
||||||
|
|
||||||
|
**⚠️ CRITICAL**: No user story work should begin until this phase is complete.
|
||||||
|
|
||||||
|
- [X] T003 Refactor shared freshness-source and evidence-burden inputs used by evidence, review, and pack truth envelopes in `app/Support/Ui/GovernanceArtifactTruth/ArtifactTruthPresenter.php` without introducing a new resolver layer
|
||||||
|
- [X] T004 [P] Preserve source-derived stale semantics and required-section blocker behavior without adding a new freshness engine in `app/Services/Evidence/EvidenceCompletenessEvaluator.php` and `app/Services/TenantReviews/TenantReviewReadinessGate.php`
|
||||||
|
|
||||||
|
**Checkpoint**: The central truth seam is ready for story-specific stale and publication hardening without introducing a second freshness framework.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 3: User Story 1 - Detect Stale Evidence Before Reusing It (Priority: P1) 🎯 MVP
|
||||||
|
|
||||||
|
**Goal**: Make stale evidence visible on snapshot, review, pack, and evidence-overview surfaces before operators reuse the artifact.
|
||||||
|
|
||||||
|
**Independent Test**: Seed fresh and stale evidence snapshots with the same structural completeness and verify that snapshot, linked review, linked pack, and evidence-overview rows downgrade only for the stale case.
|
||||||
|
|
||||||
|
### Tests for User Story 1
|
||||||
|
|
||||||
|
- [X] T005 [P] [US1] Add fresh-versus-stale snapshot detail assertions in `tests/Feature/Evidence/EvidenceSnapshotResourceTest.php`
|
||||||
|
- [X] T006 [P] [US1] Add stale-evidence overview row assertions in `tests/Feature/Evidence/EvidenceOverviewPageTest.php`
|
||||||
|
- [X] T007 [P] [US1] Add stale-evidence trust assertions for linked reviews and packs in `tests/Feature/TenantReview/TenantReviewLifecycleTest.php` and `tests/Feature/ReviewPack/ReviewPackResourceTest.php`
|
||||||
|
|
||||||
|
### Implementation for User Story 1
|
||||||
|
|
||||||
|
- [X] T008 [US1] Surface stale snapshot truth and next-step guidance on tenant evidence detail in `app/Filament/Resources/EvidenceSnapshotResource.php` and `app/Filament/Resources/EvidenceSnapshotResource/Pages/ViewEvidenceSnapshot.php`
|
||||||
|
- [X] T009 [US1] Surface stale-evidence burden on tenant review detail without changing action topology in `app/Filament/Resources/TenantReviewResource.php` and `app/Filament/Resources/TenantReviewResource/Pages/ViewTenantReview.php`
|
||||||
|
- [X] T010 [US1] Surface stale-evidence burden on review pack list and detail before download in `app/Filament/Resources/ReviewPackResource.php` and `app/Filament/Resources/ReviewPackResource/Pages/ViewReviewPack.php`
|
||||||
|
- [X] T011 [US1] Keep canonical evidence freshness rows aligned with snapshot detail in `app/Filament/Pages/Monitoring/EvidenceOverview.php` and `resources/views/filament/pages/monitoring/evidence-overview.blade.php`
|
||||||
|
- [X] T012 [US1] Run the focused stale-evidence verification pack in `tests/Feature/Evidence/EvidenceSnapshotResourceTest.php`, `tests/Feature/Evidence/EvidenceOverviewPageTest.php`, `tests/Feature/TenantReview/TenantReviewLifecycleTest.php`, and `tests/Feature/ReviewPack/ReviewPackResourceTest.php`
|
||||||
|
|
||||||
|
**Checkpoint**: Operators can now tell when evidence is stale before relying on snapshots, reviews, packs, or evidence-overview rows.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 4: User Story 2 - Distinguish Internal-Use Reviews From Publishable Reviews (Priority: P1)
|
||||||
|
|
||||||
|
**Goal**: Make partial or stale review evidence degrade review and pack publication posture into internal-only or cautionary states instead of looking publishable.
|
||||||
|
|
||||||
|
**Independent Test**: Render reviews and packs built from complete evidence, stale evidence, and partial evidence and verify that only the strongest case appears publishable.
|
||||||
|
|
||||||
|
### Tests for User Story 2
|
||||||
|
|
||||||
|
- [X] T013 [P] [US2] Add partial-evidence and stale-publication-readiness assertions for tenant reviews in `tests/Feature/TenantReview/TenantReviewLifecycleTest.php`
|
||||||
|
- [X] T014 [P] [US2] Add internal-only versus publishable pack assertions and download caveat coverage in `tests/Feature/ReviewPack/ReviewPackResourceTest.php`
|
||||||
|
|
||||||
|
### Implementation for User Story 2
|
||||||
|
|
||||||
|
- [X] T015 [US2] Tighten tenant review publication-readiness, next-step, and explanatory trust semantics for stale or partial evidence in `app/Support/Ui/GovernanceArtifactTruth/ArtifactTruthPresenter.php` and `app/Filament/Resources/TenantReviewResource.php`
|
||||||
|
- [X] T016 [US2] Tighten review pack publication-readiness and source-review caveat semantics for stale or partial evidence in `app/Support/Ui/GovernanceArtifactTruth/ArtifactTruthPresenter.php` and `app/Filament/Resources/ReviewPackResource.php`
|
||||||
|
- [X] T017 [US2] Keep the shared governance truth partial explicit about freshness versus publication readiness without adding page-local status language in `resources/views/filament/infolists/entries/governance-artifact-truth.blade.php`
|
||||||
|
- [X] T018 [US2] Verify review and pack detail surfaces stay consistent about internal-only versus publishable trust in `tests/Feature/TenantReview/TenantReviewLifecycleTest.php` and `tests/Feature/ReviewPack/ReviewPackResourceTest.php`
|
||||||
|
|
||||||
|
**Checkpoint**: Review and pack surfaces now distinguish internal-use artifacts from publishable artifacts and no longer imply publication safety from mere downloadability or status alone.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 5: User Story 3 - Recover The Evidence Basis Across Review Surfaces (Priority: P2)
|
||||||
|
|
||||||
|
**Goal**: Keep canonical review and evidence summary surfaces aligned with the tenant-scoped truth they summarize.
|
||||||
|
|
||||||
|
**Independent Test**: Seed tenants with mixed freshness and review readiness, then verify that the review register and evidence overview rows match the corresponding detail-page trust direction and preserve tenant-safe drill-through.
|
||||||
|
|
||||||
|
### Tests for User Story 3
|
||||||
|
|
||||||
|
- [X] T019 [P] [US3] Add stale and partial canonical review-register alignment assertions in `tests/Feature/TenantReview/TenantReviewRegisterTest.php`
|
||||||
|
- [X] T020 [P] [US3] Add cross-surface drill-through, no-current-review next-step, and tenant-safe evidence-overview alignment assertions in `tests/Feature/Evidence/EvidenceOverviewPageTest.php` and `tests/Feature/TenantReview/TenantReviewRegisterTest.php`
|
||||||
|
|
||||||
|
### Implementation for User Story 3
|
||||||
|
|
||||||
|
- [X] T021 [US3] Align canonical review-register artifact truth, publication, and next-step rows with tenant review detail in `app/Filament/Pages/Reviews/ReviewRegister.php`
|
||||||
|
- [X] T022 [US3] Preserve tenant-prefilter continuity, truthful drill-through, and the fresh-evidence-with-no-current-review next step between overview, register, and tenant detail surfaces in `app/Filament/Pages/Monitoring/EvidenceOverview.php` and `app/Filament/Pages/Reviews/ReviewRegister.php`
|
||||||
|
- [X] T023 [US3] Keep tenant-scoped snapshot, review, and pack resources consistent with canonical summary language in `app/Filament/Resources/EvidenceSnapshotResource.php`, `app/Filament/Resources/TenantReviewResource.php`, and `app/Filament/Resources/ReviewPackResource.php`
|
||||||
|
- [X] T024 [US3] Run the focused canonical-summary alignment verification pack in `tests/Feature/TenantReview/TenantReviewRegisterTest.php` and `tests/Feature/Evidence/EvidenceOverviewPageTest.php`
|
||||||
|
|
||||||
|
**Checkpoint**: Canonical summary surfaces now carry the same trust direction as the tenant-scoped detail pages they summarize.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 6: Polish & Cross-Cutting Concerns
|
||||||
|
|
||||||
|
**Purpose**: Final consistency, authorization non-regression, formatting, and focused verification across all stories.
|
||||||
|
|
||||||
|
- [X] T025 [P] Review and align operator-facing truth copy across the shared presenter and truth partial in `app/Support/Ui/GovernanceArtifactTruth/ArtifactTruthPresenter.php` and `resources/views/filament/infolists/entries/governance-artifact-truth.blade.php`
|
||||||
|
- [X] T026 [P] Add manage-action and tenant-entitlement non-regression coverage for touched evidence, review, and pack surfaces in `tests/Feature/Evidence/EvidenceSnapshotResourceTest.php`, `tests/Feature/TenantReview/TenantReviewLifecycleTest.php`, and `tests/Feature/ReviewPack/ReviewPackResourceTest.php`
|
||||||
|
- [X] T027 Run formatting for touched files using `vendor/bin/sail bin pint --dirty --format agent`
|
||||||
|
- [X] T028 Run the final focused verification pack from `specs/174-evidence-freshness-publication-trust/quickstart.md`, including the SC-174-001 10-second manual scan validation, the shared list-surface review checklist in `docs/product/standards/list-surface-review-checklist.md`, the DB-only render and lightweight canonical-row-derivation guardrails in `specs/174-evidence-freshness-publication-trust/plan.md`, and the existing action run-launch semantics in `app/Filament/Resources/EvidenceSnapshotResource.php`, `app/Filament/Resources/TenantReviewResource.php`, and `app/Filament/Resources/ReviewPackResource.php`, against `tests/Feature/Evidence/EvidenceSnapshotResourceTest.php`, `tests/Feature/Evidence/EvidenceOverviewPageTest.php`, `tests/Feature/TenantReview/TenantReviewLifecycleTest.php`, `tests/Feature/TenantReview/TenantReviewRegisterTest.php`, and `tests/Feature/ReviewPack/ReviewPackResourceTest.php`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Dependencies & Execution Order
|
||||||
|
|
||||||
|
### Phase Dependencies
|
||||||
|
|
||||||
|
- **Setup (Phase 1)**: Starts immediately and prepares shared stale and partial fixtures.
|
||||||
|
- **Foundational (Phase 2)**: Depends on Setup and blocks all story work until the central truth propagation seam is tightened.
|
||||||
|
- **User Story 1 (Phase 3)**: Starts after Foundational and delivers the MVP stale-evidence visibility across key surfaces.
|
||||||
|
- **User Story 2 (Phase 4)**: Starts after Foundational; it can build on User Story 1 or proceed immediately after the presenter seam is stable, but should follow US1 for the cleanest rollout.
|
||||||
|
- **User Story 3 (Phase 5)**: Starts after Foundational and depends on tightened truth semantics from earlier phases to align canonical rows.
|
||||||
|
- **Polish (Phase 6)**: Starts after the desired user stories are complete.
|
||||||
|
|
||||||
|
### User Story Dependencies
|
||||||
|
|
||||||
|
- **User Story 1 (P1)**: Depends only on the shared truth propagation seam from Phase 2.
|
||||||
|
- **User Story 2 (P1)**: Depends on the shared truth propagation seam from Phase 2 and benefits from the stale-surface work in US1.
|
||||||
|
- **User Story 3 (P2)**: Depends on the shared truth propagation seam from Phase 2 and should consume the tightened truth semantics delivered by US1 and US2.
|
||||||
|
|
||||||
|
### Within Each User Story
|
||||||
|
|
||||||
|
- Shared fixture work must land before story-specific tests rely on new stale or partial scenarios.
|
||||||
|
- Tests should be updated before or alongside the related implementation tasks and must fail before the behavior change is considered complete.
|
||||||
|
- `ArtifactTruthPresenter` changes should land before downstream Filament resource and page copy cleanup for the same story.
|
||||||
|
- Focused story-level test runs should complete before moving to the next story.
|
||||||
|
|
||||||
|
### Parallel Opportunities
|
||||||
|
|
||||||
|
- `T001` and `T002` can run in parallel once the shared stale and partial scenario matrix is agreed.
|
||||||
|
- `T005`, `T006`, and `T007` can run in parallel for User Story 1.
|
||||||
|
- `T013` and `T014` can run in parallel for User Story 2.
|
||||||
|
- `T019` and `T020` can run in parallel for User Story 3.
|
||||||
|
- `T025` and `T026` can run in parallel once implementation work is complete.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Parallel Example: User Story 1
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Story 1 tests in parallel:
|
||||||
|
Task: T005 tests/Feature/Evidence/EvidenceSnapshotResourceTest.php
|
||||||
|
Task: T006 tests/Feature/Evidence/EvidenceOverviewPageTest.php
|
||||||
|
Task: T007 tests/Feature/TenantReview/TenantReviewLifecycleTest.php and tests/Feature/ReviewPack/ReviewPackResourceTest.php
|
||||||
|
|
||||||
|
# Story 1 implementation split after truth propagation is stable:
|
||||||
|
Task: T008 app/Filament/Resources/EvidenceSnapshotResource.php and app/Filament/Resources/EvidenceSnapshotResource/Pages/ViewEvidenceSnapshot.php
|
||||||
|
Task: T009 app/Filament/Resources/TenantReviewResource.php and app/Filament/Resources/TenantReviewResource/Pages/ViewTenantReview.php
|
||||||
|
Task: T010 app/Filament/Resources/ReviewPackResource.php and app/Filament/Resources/ReviewPackResource/Pages/ViewReviewPack.php
|
||||||
|
```
|
||||||
|
|
||||||
|
## Parallel Example: User Story 2
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Story 2 tests in parallel:
|
||||||
|
Task: T013 tests/Feature/TenantReview/TenantReviewLifecycleTest.php
|
||||||
|
Task: T014 tests/Feature/ReviewPack/ReviewPackResourceTest.php
|
||||||
|
|
||||||
|
# Story 2 implementation split after failing assertions are in place:
|
||||||
|
Task: T015 app/Support/Ui/GovernanceArtifactTruth/ArtifactTruthPresenter.php and app/Filament/Resources/TenantReviewResource.php
|
||||||
|
Task: T016 app/Support/Ui/GovernanceArtifactTruth/ArtifactTruthPresenter.php and app/Filament/Resources/ReviewPackResource.php
|
||||||
|
```
|
||||||
|
|
||||||
|
## Parallel Example: User Story 3
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Story 3 tests in parallel:
|
||||||
|
Task: T019 tests/Feature/TenantReview/TenantReviewRegisterTest.php
|
||||||
|
Task: T020 tests/Feature/Evidence/EvidenceOverviewPageTest.php and tests/Feature/TenantReview/TenantReviewRegisterTest.php
|
||||||
|
|
||||||
|
# Story 3 implementation split after row-level truth expectations are clear:
|
||||||
|
Task: T021 app/Filament/Pages/Reviews/ReviewRegister.php
|
||||||
|
Task: T022 app/Filament/Pages/Monitoring/EvidenceOverview.php and app/Filament/Pages/Reviews/ReviewRegister.php
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Implementation Strategy
|
||||||
|
|
||||||
|
### MVP First (User Story 1 Only)
|
||||||
|
|
||||||
|
1. Complete Phase 1: Setup.
|
||||||
|
2. Complete Phase 2: Foundational.
|
||||||
|
3. Complete Phase 3: User Story 1.
|
||||||
|
4. **STOP and VALIDATE**: Verify stale evidence is visible across snapshot, linked review, linked pack, and evidence overview surfaces.
|
||||||
|
5. Deploy or demo if the MVP confidence level is sufficient.
|
||||||
|
|
||||||
|
### Incremental Delivery
|
||||||
|
|
||||||
|
1. Complete Setup + Foundational so the central truth seam is stable.
|
||||||
|
2. Add User Story 1 and validate stale-evidence visibility.
|
||||||
|
3. Add User Story 2 and validate internal-only versus publishable trust.
|
||||||
|
4. Add User Story 3 and validate canonical summary alignment.
|
||||||
|
5. Finish with cross-cutting copy, authorization non-regression, formatting, and focused verification.
|
||||||
|
|
||||||
|
### Parallel Team Strategy
|
||||||
|
|
||||||
|
With multiple developers:
|
||||||
|
|
||||||
|
1. Complete Setup + Foundational together.
|
||||||
|
2. Once Foundational is done:
|
||||||
|
- Developer A: User Story 1
|
||||||
|
- Developer B: User Story 2
|
||||||
|
- Developer C: User Story 3
|
||||||
|
3. Reconcile in Phase 6 with shared copy, authorization non-regression, formatting, and final focused tests.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- [P] tasks indicate different files and no dependency on incomplete predecessor tasks.
|
||||||
|
- The same touched file may appear in multiple stories because each story hardens a different user-visible outcome on the same existing surface.
|
||||||
|
- No task introduces a new persistence model, a new abstraction layer, or a new reporting subsystem.
|
||||||
@ -0,0 +1,36 @@
|
|||||||
|
# Specification Quality Checklist: Spec 175 - Workspace Governance Attention Foundation
|
||||||
|
|
||||||
|
**Purpose**: Validate specification completeness and quality before proceeding to planning
|
||||||
|
**Created**: 2026-04-04
|
||||||
|
**Feature**: [spec.md](../spec.md)
|
||||||
|
|
||||||
|
## Content Quality
|
||||||
|
|
||||||
|
- [x] No implementation details (languages, frameworks, APIs)
|
||||||
|
- [x] Focused on user value and business needs
|
||||||
|
- [x] Written for non-technical stakeholders
|
||||||
|
- [x] All mandatory sections completed
|
||||||
|
|
||||||
|
## Requirement Completeness
|
||||||
|
|
||||||
|
- [x] No [NEEDS CLARIFICATION] markers remain
|
||||||
|
- [x] Requirements are testable and unambiguous
|
||||||
|
- [x] Success criteria are measurable
|
||||||
|
- [x] Success criteria are technology-agnostic (no implementation details)
|
||||||
|
- [x] All acceptance scenarios are defined
|
||||||
|
- [x] Edge cases are identified
|
||||||
|
- [x] Scope is clearly bounded
|
||||||
|
- [x] Dependencies and assumptions identified
|
||||||
|
|
||||||
|
## Feature Readiness
|
||||||
|
|
||||||
|
- [x] All functional requirements have clear acceptance criteria
|
||||||
|
- [x] User scenarios cover primary flows
|
||||||
|
- [x] Feature meets measurable outcomes defined in Success Criteria
|
||||||
|
- [x] No implementation details leak into specification
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- Validation pass completed against the written spec on 2026-04-04.
|
||||||
|
- No clarification markers were needed because the supplied feature description already defined scope, priorities, constraints, and desired outcomes precisely.
|
||||||
|
- The spec stays intentionally narrow: it hardens existing workspace surfaces using already-available tenant truth and explicitly rejects new persistence, scores, or matrix-style redesign in this slice.
|
||||||
@ -0,0 +1,541 @@
|
|||||||
|
openapi: 3.1.0
|
||||||
|
info:
|
||||||
|
title: Workspace Governance Attention Internal Surface Contract
|
||||||
|
version: 0.1.0
|
||||||
|
summary: Internal logical contract for governance-aware workspace overview semantics
|
||||||
|
description: |
|
||||||
|
This contract is an internal planning artifact for Spec 175. It documents how
|
||||||
|
the existing workspace overview must derive governance-aware summary metrics,
|
||||||
|
attention items, calmness claims, and drill-through destinations from visible
|
||||||
|
tenant truth. The rendered routes still return HTML. The structured schemas
|
||||||
|
below describe the internal page and widget models that must be derivable
|
||||||
|
before rendering. This does not add a public HTTP API.
|
||||||
|
servers:
|
||||||
|
- url: /internal
|
||||||
|
x-overview-consumers:
|
||||||
|
- surface: workspace.overview.summary_stats
|
||||||
|
summarySource:
|
||||||
|
- workspace_overview_builder
|
||||||
|
- tenant_governance_aggregate
|
||||||
|
- operation_run_activity
|
||||||
|
- alert_delivery_activity
|
||||||
|
guardScope:
|
||||||
|
- app/Support/Workspaces/WorkspaceOverviewBuilder.php
|
||||||
|
- app/Filament/Widgets/Workspace/WorkspaceSummaryStats.php
|
||||||
|
expectedContract:
|
||||||
|
- governance_metrics_count_visible_tenants_not_raw_issue_totals
|
||||||
|
- governance_metrics_are_distinct_from_activity_metrics
|
||||||
|
- surface: workspace.overview.needs_attention
|
||||||
|
summarySource:
|
||||||
|
- workspace_overview_builder
|
||||||
|
- tenant_governance_aggregate
|
||||||
|
- existing_evidence_review_truth
|
||||||
|
- operation_run_activity
|
||||||
|
- alert_delivery_activity
|
||||||
|
guardScope:
|
||||||
|
- app/Support/Workspaces/WorkspaceOverviewBuilder.php
|
||||||
|
- app/Filament/Widgets/Workspace/WorkspaceNeedsAttention.php
|
||||||
|
- resources/views/filament/widgets/workspace/workspace-needs-attention.blade.php
|
||||||
|
expectedContract:
|
||||||
|
- each_item_identifies_the_visible_tenant_when_tenant_bound
|
||||||
|
- all_attention_items_are_tenant_bound
|
||||||
|
- governance_issues_rank_above_activity_only_items
|
||||||
|
- compare_attention_only_uses_stale_or_compare_specific_action_required_signals
|
||||||
|
- each_item_has_one_matching_destination_or_a_safe_disabled_state
|
||||||
|
- surface: workspace.overview.calmness
|
||||||
|
summarySource:
|
||||||
|
- workspace_overview_builder
|
||||||
|
- tenant_governance_aggregate
|
||||||
|
- operation_run_activity
|
||||||
|
- alert_delivery_activity
|
||||||
|
guardScope:
|
||||||
|
- app/Support/Workspaces/WorkspaceOverviewBuilder.php
|
||||||
|
- resources/views/filament/pages/workspace-overview.blade.php
|
||||||
|
expectedContract:
|
||||||
|
- operations_quiet_alone_is_not_enough_for_calmness
|
||||||
|
- zero_tenant_and_low_permission_states_do_not_masquerade_as_healthy_calm
|
||||||
|
- zero_tenant_recovery_uses_switch_workspace_and_low_permission_recovery_uses_operations_index
|
||||||
|
- surface: workspace.overview.recent_operations
|
||||||
|
summarySource:
|
||||||
|
- workspace_overview_builder
|
||||||
|
- operation_run_recency_query
|
||||||
|
guardScope:
|
||||||
|
- app/Filament/Widgets/Workspace/WorkspaceRecentOperations.php
|
||||||
|
- resources/views/filament/widgets/workspace/workspace-recent-operations.blade.php
|
||||||
|
expectedContract:
|
||||||
|
- surface_role_is_diagnostic_recency_not_primary_governance_summary
|
||||||
|
- recent_operations_remain_filtered_to_visible_tenant_scope
|
||||||
|
- recent_operations_are_bounded_to_five
|
||||||
|
paths:
|
||||||
|
/admin:
|
||||||
|
get:
|
||||||
|
summary: Render the governance-aware workspace overview bundle
|
||||||
|
operationId: viewWorkspaceGovernanceAttentionOverview
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: Workspace overview rendered with governance-aware summary, attention, calmness, and recency semantics
|
||||||
|
content:
|
||||||
|
text/html:
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
application/vnd.tenantpilot.workspace-governance-attention+json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/WorkspaceGovernanceOverviewBundle'
|
||||||
|
'302':
|
||||||
|
description: No workspace context is active yet, so the request is redirected to `/admin/choose-workspace`
|
||||||
|
'404':
|
||||||
|
description: Workspace is outside entitlement scope
|
||||||
|
/admin/choose-tenant:
|
||||||
|
get:
|
||||||
|
summary: Explicit tenant-entry destination used by workspace drill-down when the operator wants to pick a tenant deliberately
|
||||||
|
operationId: openChooseTenantFromWorkspaceOverview
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: Choose-tenant page opened inside the authenticated admin panel and may bootstrap workspace context from the selected tenant if no workspace is currently active
|
||||||
|
/admin/choose-workspace:
|
||||||
|
get:
|
||||||
|
summary: Default workspace-switch recovery destination for zero-tenant or wrong-workspace states
|
||||||
|
operationId: openChooseWorkspaceFromWorkspaceOverview
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: Choose-workspace page opened so the operator can recover to another entitled workspace context even when no workspace is currently active
|
||||||
|
/admin/t/{tenant}:
|
||||||
|
get:
|
||||||
|
summary: Tenant dashboard fallback or broad tenant drill-through from workspace attention
|
||||||
|
operationId: openTenantDashboardFromWorkspaceAttention
|
||||||
|
parameters:
|
||||||
|
- name: tenant
|
||||||
|
in: path
|
||||||
|
required: true
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: Tenant dashboard opened for the visible tenant named by the workspace item when the dashboard is the allowed fallback or primary tenant landing
|
||||||
|
'404':
|
||||||
|
description: Tenant is outside entitlement scope
|
||||||
|
/admin/t/{tenant}/findings:
|
||||||
|
get:
|
||||||
|
summary: Tenant findings destination used by workspace governance and findings attention
|
||||||
|
operationId: openTenantFindingsFromWorkspaceAttention
|
||||||
|
parameters:
|
||||||
|
- name: tenant
|
||||||
|
in: path
|
||||||
|
required: true
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
- name: tab
|
||||||
|
in: query
|
||||||
|
required: false
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/FindingsTab'
|
||||||
|
- name: high_severity
|
||||||
|
in: query
|
||||||
|
required: false
|
||||||
|
schema:
|
||||||
|
type: boolean
|
||||||
|
- name: governance_validity
|
||||||
|
in: query
|
||||||
|
required: false
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/GovernanceValidityFilter'
|
||||||
|
description: Reproduces directly filterable governance-validity subsets such as `missing_support` or `expiring`; aggregate lapsed-governance attention falls back to the tenant dashboard when the full invalid-governance family cannot be reproduced without narrowing.
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: Tenant findings list opened with a subset matching the originating workspace attention item
|
||||||
|
'403':
|
||||||
|
description: Actor is in scope but lacks findings inspection capability
|
||||||
|
'404':
|
||||||
|
description: Tenant is outside entitlement scope
|
||||||
|
/admin/t/{tenant}/baseline-compare-landing:
|
||||||
|
get:
|
||||||
|
summary: Tenant baseline compare landing used by workspace compare attention
|
||||||
|
operationId: openTenantBaselineCompareFromWorkspaceAttention
|
||||||
|
parameters:
|
||||||
|
- name: tenant
|
||||||
|
in: path
|
||||||
|
required: true
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: Tenant baseline compare landing opened with the same compare posture family summarized by the workspace item
|
||||||
|
'403':
|
||||||
|
description: Actor is in scope but lacks the current tenant-view capability required by the compare landing
|
||||||
|
'404':
|
||||||
|
description: Tenant is outside entitlement scope
|
||||||
|
/admin/t/{tenant}/evidence:
|
||||||
|
get:
|
||||||
|
summary: Tenant evidence destination used only when existing evidence truth is the most precise workspace next jump
|
||||||
|
operationId: openTenantEvidenceFromWorkspaceAttention
|
||||||
|
parameters:
|
||||||
|
- name: tenant
|
||||||
|
in: path
|
||||||
|
required: true
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: Tenant evidence surface opened for the visible tenant named by the workspace item
|
||||||
|
'403':
|
||||||
|
description: Actor is in scope but lacks evidence inspection capability
|
||||||
|
'404':
|
||||||
|
description: Tenant is outside entitlement scope
|
||||||
|
/admin/t/{tenant}/reviews:
|
||||||
|
get:
|
||||||
|
summary: Tenant reviews destination used only when existing review truth is the most precise workspace next jump
|
||||||
|
operationId: openTenantReviewsFromWorkspaceAttention
|
||||||
|
parameters:
|
||||||
|
- name: tenant
|
||||||
|
in: path
|
||||||
|
required: true
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: Tenant reviews surface opened for the visible tenant named by the workspace item
|
||||||
|
'403':
|
||||||
|
description: Actor is in scope but lacks review inspection capability
|
||||||
|
'404':
|
||||||
|
description: Tenant is outside entitlement scope
|
||||||
|
/admin/operations:
|
||||||
|
get:
|
||||||
|
summary: Canonical operations index used by workspace activity and operations-follow-up signals
|
||||||
|
operationId: openOperationsFromWorkspaceOverview
|
||||||
|
parameters:
|
||||||
|
- name: tenant_id
|
||||||
|
in: query
|
||||||
|
required: false
|
||||||
|
schema:
|
||||||
|
type:
|
||||||
|
- integer
|
||||||
|
- string
|
||||||
|
description: Optional tenant filter when the workspace item points to one tenant's operations follow-up.
|
||||||
|
- name: activeTab
|
||||||
|
in: query
|
||||||
|
required: false
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/OperationsTab'
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: Canonical operations index opened with any tenant and tab continuity required by the workspace signal; this remains the workspace-member-safe fallback for low-permission workspace states
|
||||||
|
'404':
|
||||||
|
description: Requested tenant context is outside entitlement scope
|
||||||
|
/admin/operations/{run}:
|
||||||
|
get:
|
||||||
|
summary: Canonical operation detail opened from workspace recent operations or operations attention
|
||||||
|
operationId: openOperationDetailFromWorkspaceOverview
|
||||||
|
parameters:
|
||||||
|
- name: run
|
||||||
|
in: path
|
||||||
|
required: true
|
||||||
|
schema:
|
||||||
|
type:
|
||||||
|
- integer
|
||||||
|
- string
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: Canonical operation detail opened for the visible run
|
||||||
|
'403':
|
||||||
|
description: Actor is in scope but lacks operation detail capability
|
||||||
|
'404':
|
||||||
|
description: Operation run is outside entitlement scope
|
||||||
|
/admin/alerts:
|
||||||
|
get:
|
||||||
|
summary: Canonical alerts overview used for alert-only follow-up from the workspace home
|
||||||
|
operationId: openAlertsOverviewFromWorkspaceOverview
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: Alerts overview opened for the current workspace, including delivery follow-up and alert-health context
|
||||||
|
'403':
|
||||||
|
description: Actor is in scope but lacks alerts inspection capability
|
||||||
|
'404':
|
||||||
|
description: Workspace is outside entitlement scope
|
||||||
|
components:
|
||||||
|
schemas:
|
||||||
|
MetricCategory:
|
||||||
|
type: string
|
||||||
|
enum:
|
||||||
|
- scope
|
||||||
|
- governance_risk
|
||||||
|
- activity
|
||||||
|
- alerts
|
||||||
|
AttentionFamily:
|
||||||
|
type: string
|
||||||
|
enum:
|
||||||
|
- governance
|
||||||
|
- findings
|
||||||
|
- compare
|
||||||
|
- evidence
|
||||||
|
- review
|
||||||
|
- operations
|
||||||
|
- alerts
|
||||||
|
AttentionUrgency:
|
||||||
|
type: string
|
||||||
|
enum:
|
||||||
|
- critical
|
||||||
|
- high
|
||||||
|
- medium
|
||||||
|
- supporting
|
||||||
|
DestinationKind:
|
||||||
|
type: string
|
||||||
|
enum:
|
||||||
|
- choose_tenant
|
||||||
|
- tenant_dashboard
|
||||||
|
- tenant_findings
|
||||||
|
- baseline_compare_landing
|
||||||
|
- tenant_evidence
|
||||||
|
- tenant_reviews
|
||||||
|
- operations_index
|
||||||
|
- operation_detail
|
||||||
|
- alerts_overview
|
||||||
|
- switch_workspace
|
||||||
|
- none
|
||||||
|
CheckedDomain:
|
||||||
|
type: string
|
||||||
|
enum:
|
||||||
|
- governance
|
||||||
|
- findings
|
||||||
|
- compare
|
||||||
|
- evidence
|
||||||
|
- review
|
||||||
|
- operations
|
||||||
|
- alerts
|
||||||
|
- tenant_access
|
||||||
|
FindingsTab:
|
||||||
|
type: string
|
||||||
|
enum:
|
||||||
|
- all
|
||||||
|
- needs_action
|
||||||
|
- overdue
|
||||||
|
- risk_accepted
|
||||||
|
- resolved
|
||||||
|
GovernanceValidityFilter:
|
||||||
|
type: string
|
||||||
|
enum:
|
||||||
|
- missing_support
|
||||||
|
- expiring
|
||||||
|
- valid
|
||||||
|
OperationsTab:
|
||||||
|
type: string
|
||||||
|
enum:
|
||||||
|
- all
|
||||||
|
- active
|
||||||
|
- blocked
|
||||||
|
- failed
|
||||||
|
- partial
|
||||||
|
- succeeded
|
||||||
|
DrillthroughTarget:
|
||||||
|
type: object
|
||||||
|
additionalProperties: false
|
||||||
|
required:
|
||||||
|
- kind
|
||||||
|
- disabled
|
||||||
|
properties:
|
||||||
|
kind:
|
||||||
|
$ref: '#/components/schemas/DestinationKind'
|
||||||
|
url:
|
||||||
|
type:
|
||||||
|
- string
|
||||||
|
- 'null'
|
||||||
|
tenantRouteKey:
|
||||||
|
type:
|
||||||
|
- string
|
||||||
|
- 'null'
|
||||||
|
label:
|
||||||
|
type:
|
||||||
|
- string
|
||||||
|
- 'null'
|
||||||
|
disabled:
|
||||||
|
type: boolean
|
||||||
|
helperText:
|
||||||
|
type:
|
||||||
|
- string
|
||||||
|
- 'null'
|
||||||
|
filters:
|
||||||
|
type:
|
||||||
|
- object
|
||||||
|
- 'null'
|
||||||
|
additionalProperties: true
|
||||||
|
WorkspaceSummaryMetric:
|
||||||
|
type: object
|
||||||
|
additionalProperties: false
|
||||||
|
required:
|
||||||
|
- key
|
||||||
|
- label
|
||||||
|
- value
|
||||||
|
- category
|
||||||
|
- description
|
||||||
|
- color
|
||||||
|
properties:
|
||||||
|
key:
|
||||||
|
type: string
|
||||||
|
label:
|
||||||
|
type: string
|
||||||
|
value:
|
||||||
|
type: integer
|
||||||
|
minimum: 0
|
||||||
|
category:
|
||||||
|
$ref: '#/components/schemas/MetricCategory'
|
||||||
|
description:
|
||||||
|
type: string
|
||||||
|
color:
|
||||||
|
type: string
|
||||||
|
destination:
|
||||||
|
oneOf:
|
||||||
|
- $ref: '#/components/schemas/DrillthroughTarget'
|
||||||
|
- type: 'null'
|
||||||
|
WorkspaceAttentionItem:
|
||||||
|
type: object
|
||||||
|
additionalProperties: false
|
||||||
|
required:
|
||||||
|
- key
|
||||||
|
- tenantId
|
||||||
|
- tenantLabel
|
||||||
|
- family
|
||||||
|
- urgency
|
||||||
|
- title
|
||||||
|
- body
|
||||||
|
- badge
|
||||||
|
- badgeColor
|
||||||
|
anyOf:
|
||||||
|
- required:
|
||||||
|
- destination
|
||||||
|
properties:
|
||||||
|
destination:
|
||||||
|
$ref: '#/components/schemas/DrillthroughTarget'
|
||||||
|
- required:
|
||||||
|
- actionDisabled
|
||||||
|
- helperText
|
||||||
|
properties:
|
||||||
|
actionDisabled:
|
||||||
|
const: true
|
||||||
|
helperText:
|
||||||
|
type: string
|
||||||
|
properties:
|
||||||
|
key:
|
||||||
|
type: string
|
||||||
|
tenantId:
|
||||||
|
type: integer
|
||||||
|
tenantLabel:
|
||||||
|
type: string
|
||||||
|
family:
|
||||||
|
$ref: '#/components/schemas/AttentionFamily'
|
||||||
|
urgency:
|
||||||
|
$ref: '#/components/schemas/AttentionUrgency'
|
||||||
|
title:
|
||||||
|
type: string
|
||||||
|
body:
|
||||||
|
type: string
|
||||||
|
supportingMessage:
|
||||||
|
type:
|
||||||
|
- string
|
||||||
|
- 'null'
|
||||||
|
badge:
|
||||||
|
type: string
|
||||||
|
badgeColor:
|
||||||
|
type: string
|
||||||
|
destination:
|
||||||
|
oneOf:
|
||||||
|
- $ref: '#/components/schemas/DrillthroughTarget'
|
||||||
|
- type: 'null'
|
||||||
|
actionDisabled:
|
||||||
|
type:
|
||||||
|
- boolean
|
||||||
|
- 'null'
|
||||||
|
helperText:
|
||||||
|
type:
|
||||||
|
- string
|
||||||
|
- 'null'
|
||||||
|
WorkspaceRecentOperation:
|
||||||
|
type: object
|
||||||
|
additionalProperties: false
|
||||||
|
required:
|
||||||
|
- id
|
||||||
|
- title
|
||||||
|
- statusLabel
|
||||||
|
- statusColor
|
||||||
|
- outcomeLabel
|
||||||
|
- outcomeColor
|
||||||
|
- startedAt
|
||||||
|
- destination
|
||||||
|
properties:
|
||||||
|
id:
|
||||||
|
type: integer
|
||||||
|
title:
|
||||||
|
type: string
|
||||||
|
tenantLabel:
|
||||||
|
type:
|
||||||
|
- string
|
||||||
|
- 'null'
|
||||||
|
statusLabel:
|
||||||
|
type: string
|
||||||
|
statusColor:
|
||||||
|
type: string
|
||||||
|
outcomeLabel:
|
||||||
|
type: string
|
||||||
|
outcomeColor:
|
||||||
|
type: string
|
||||||
|
guidance:
|
||||||
|
type:
|
||||||
|
- string
|
||||||
|
- 'null'
|
||||||
|
startedAt:
|
||||||
|
type: string
|
||||||
|
destination:
|
||||||
|
$ref: '#/components/schemas/DrillthroughTarget'
|
||||||
|
WorkspaceCalmnessState:
|
||||||
|
type: object
|
||||||
|
additionalProperties: false
|
||||||
|
required:
|
||||||
|
- isCalm
|
||||||
|
- checkedDomains
|
||||||
|
- title
|
||||||
|
- body
|
||||||
|
- nextAction
|
||||||
|
properties:
|
||||||
|
isCalm:
|
||||||
|
type: boolean
|
||||||
|
checkedDomains:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
$ref: '#/components/schemas/CheckedDomain'
|
||||||
|
title:
|
||||||
|
type: string
|
||||||
|
body:
|
||||||
|
type: string
|
||||||
|
nextAction:
|
||||||
|
description: Defaults to `switch_workspace` for zero-tenant recovery and `operations_index` for low-permission workspace-state recovery unless a more specific allowed action exists.
|
||||||
|
$ref: '#/components/schemas/DrillthroughTarget'
|
||||||
|
WorkspaceGovernanceOverviewBundle:
|
||||||
|
type: object
|
||||||
|
additionalProperties: false
|
||||||
|
required:
|
||||||
|
- workspaceId
|
||||||
|
- workspaceName
|
||||||
|
- summaryMetrics
|
||||||
|
- attentionItems
|
||||||
|
- recentOperations
|
||||||
|
- calmness
|
||||||
|
properties:
|
||||||
|
workspaceId:
|
||||||
|
type: integer
|
||||||
|
workspaceName:
|
||||||
|
type: string
|
||||||
|
summaryMetrics:
|
||||||
|
type: array
|
||||||
|
maxItems: 8
|
||||||
|
items:
|
||||||
|
$ref: '#/components/schemas/WorkspaceSummaryMetric'
|
||||||
|
attentionItems:
|
||||||
|
type: array
|
||||||
|
maxItems: 5
|
||||||
|
items:
|
||||||
|
$ref: '#/components/schemas/WorkspaceAttentionItem'
|
||||||
|
recentOperations:
|
||||||
|
type: array
|
||||||
|
maxItems: 5
|
||||||
|
items:
|
||||||
|
$ref: '#/components/schemas/WorkspaceRecentOperation'
|
||||||
|
calmness:
|
||||||
|
$ref: '#/components/schemas/WorkspaceCalmnessState'
|
||||||
313
specs/175-workspace-governance-attention/data-model.md
Normal file
313
specs/175-workspace-governance-attention/data-model.md
Normal file
@ -0,0 +1,313 @@
|
|||||||
|
# Phase 1 Data Model: Workspace Governance Attention Foundation
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
This feature does not add a table, persisted workspace summary, or new cross-domain runtime subsystem. It aligns the existing workspace overview surface with already-available tenant truth so the workspace home can answer which visible tenants need governance attention, why they need it, and where the operator should jump next.
|
||||||
|
|
||||||
|
## Persistent Source Truths
|
||||||
|
|
||||||
|
### Workspace
|
||||||
|
|
||||||
|
**Purpose**: Scope boundary for the workspace home and all workspace-safe aggregates.
|
||||||
|
|
||||||
|
**Key fields**:
|
||||||
|
- `id`
|
||||||
|
- `name`
|
||||||
|
- `slug`
|
||||||
|
|
||||||
|
**Validation rules**:
|
||||||
|
- Workspace overview aggregation must always resolve for one explicit workspace.
|
||||||
|
- Non-members must receive deny-as-not-found behavior before any workspace truth is rendered.
|
||||||
|
|
||||||
|
### Tenant
|
||||||
|
|
||||||
|
**Purpose**: Scope boundary and identity anchor for every governance-aware workspace attention item.
|
||||||
|
|
||||||
|
**Key fields**:
|
||||||
|
- `id`
|
||||||
|
- `workspace_id`
|
||||||
|
- `external_id`
|
||||||
|
- `name`
|
||||||
|
- `status`
|
||||||
|
|
||||||
|
**Validation rules**:
|
||||||
|
- Only active tenants inside the current workspace and inside the current user's entitled tenant slice may contribute to workspace attention or governance-risk metrics.
|
||||||
|
- Every workspace attention item must identify one visible tenant explicitly.
|
||||||
|
|
||||||
|
### Finding
|
||||||
|
|
||||||
|
**Purpose**: Source of overdue findings, high-severity active findings, and other governance workflow pressure promoted into workspace attention.
|
||||||
|
|
||||||
|
**Key fields**:
|
||||||
|
- `tenant_id`
|
||||||
|
- `workspace_id`
|
||||||
|
- `finding_type`
|
||||||
|
- `status`
|
||||||
|
- `severity`
|
||||||
|
- `due_at`
|
||||||
|
- `scope_key`
|
||||||
|
|
||||||
|
**Validation rules**:
|
||||||
|
- Canonical open and active semantics remain sourced from existing finding query helpers.
|
||||||
|
- Workspace promotion must not invent a second finding status universe.
|
||||||
|
|
||||||
|
### FindingException / Governance Validity
|
||||||
|
|
||||||
|
**Purpose**: Source of lapsed governance and expiring governance truth for risk-accepted findings.
|
||||||
|
|
||||||
|
**Key fields**:
|
||||||
|
- `tenant_id`
|
||||||
|
- `workspace_id`
|
||||||
|
- `finding_id`
|
||||||
|
- `status`
|
||||||
|
- `current_validity_state`
|
||||||
|
- `review_due_at`
|
||||||
|
- `expires_at`
|
||||||
|
|
||||||
|
**Validation rules**:
|
||||||
|
- Lapsed and expiring governance remain derived from existing validity-state rules.
|
||||||
|
- Workspace promotion must not replace existing governance-validity semantics with a new workspace-specific status family.
|
||||||
|
|
||||||
|
### OperationRun
|
||||||
|
|
||||||
|
**Purpose**: Source of workspace activity, operation failures, and canonical operation drill-through.
|
||||||
|
|
||||||
|
**Key fields**:
|
||||||
|
- `id`
|
||||||
|
- `workspace_id`
|
||||||
|
- `tenant_id`
|
||||||
|
- `type`
|
||||||
|
- `status`
|
||||||
|
- `outcome`
|
||||||
|
- `created_at`
|
||||||
|
- `completed_at`
|
||||||
|
|
||||||
|
**Validation rules**:
|
||||||
|
- Active operations and failed or warning operations remain activity truths, not governance truths.
|
||||||
|
- Workspace attention may still include operations follow-up, but those items must remain semantically distinct from governance items.
|
||||||
|
|
||||||
|
### AlertDelivery
|
||||||
|
|
||||||
|
**Purpose**: Source of workspace alert-delivery failures that remain supporting attention but no longer define workspace calmness by themselves.
|
||||||
|
|
||||||
|
**Key fields**:
|
||||||
|
- `workspace_id`
|
||||||
|
- `tenant_id`
|
||||||
|
- `status`
|
||||||
|
- `created_at`
|
||||||
|
|
||||||
|
**Validation rules**:
|
||||||
|
- Alert-delivery failures remain lower-priority supporting signals once governance-critical tenant states exist.
|
||||||
|
|
||||||
|
### EvidenceSnapshot and TenantReview
|
||||||
|
|
||||||
|
**Purpose**: Existing tenant-level evidence and review truth that may serve as a precise workspace drill-through target when already-available truth makes them the best next jump.
|
||||||
|
|
||||||
|
**Key fields**:
|
||||||
|
- `tenant_id`
|
||||||
|
- `workspace_id`
|
||||||
|
- `status` or completeness fields on the owning model
|
||||||
|
- associated detail identifiers needed by tenant evidence or review resources
|
||||||
|
|
||||||
|
**Validation rules**:
|
||||||
|
- This slice does not add a new workspace evidence or review aggregate.
|
||||||
|
- Evidence or review destinations may only be used when existing tenant truth already makes them the most precise action target.
|
||||||
|
|
||||||
|
## Existing Runtime Source Objects
|
||||||
|
|
||||||
|
### TenantGovernanceAggregate
|
||||||
|
|
||||||
|
**Purpose**: Existing derived tenant summary that already combines compare posture with overdue, expiring, lapsed, and high-severity counts.
|
||||||
|
|
||||||
|
**Key consumed fields**:
|
||||||
|
- `tenantId`
|
||||||
|
- `workspaceId`
|
||||||
|
- `compareState`
|
||||||
|
- `stateFamily`
|
||||||
|
- `tone`
|
||||||
|
- `headline`
|
||||||
|
- `supportingMessage`
|
||||||
|
- `overdueOpenFindingsCount`
|
||||||
|
- `expiringGovernanceCount`
|
||||||
|
- `lapsedGovernanceCount`
|
||||||
|
- `highSeverityActiveFindingsCount`
|
||||||
|
- `nextActionLabel`
|
||||||
|
- `nextActionTarget`
|
||||||
|
- `positiveClaimAllowed`
|
||||||
|
- `summaryAssessment`
|
||||||
|
|
||||||
|
**Validation rules**:
|
||||||
|
- Workspace promotion should consume this existing summary contract before considering lower-level recomputation.
|
||||||
|
- Any workspace calmness or ranking rule based on compare or governance must remain consistent with this aggregate.
|
||||||
|
|
||||||
|
### BaselineCompareStats
|
||||||
|
|
||||||
|
**Purpose**: Existing compare-backed statistics object underlying the tenant governance aggregate.
|
||||||
|
|
||||||
|
**Key consumed fields**:
|
||||||
|
- `overdueOpenFindingsCount`
|
||||||
|
- `expiringGovernanceCount`
|
||||||
|
- `lapsedGovernanceCount`
|
||||||
|
- `highSeverityActiveFindingsCount`
|
||||||
|
- compare posture state and evidence-gap fields needed by `summaryAssessment`
|
||||||
|
|
||||||
|
**Validation rules**:
|
||||||
|
- Workspace code should not fork a second compare-summary model while this object already provides the necessary facts.
|
||||||
|
|
||||||
|
### BaselineCompareSummaryAssessment
|
||||||
|
|
||||||
|
**Purpose**: Existing compare posture contract that maps compare stats into a state family, tone, headline, and next-action intent.
|
||||||
|
|
||||||
|
**Key consumed fields**:
|
||||||
|
- `stateFamily`
|
||||||
|
- `tone`
|
||||||
|
- `headline`
|
||||||
|
- `supportingMessage`
|
||||||
|
- `reasonCode`
|
||||||
|
- `nextActionTarget()` semantics as reflected into the aggregate
|
||||||
|
|
||||||
|
**Validation rules**:
|
||||||
|
- Workspace compare attention and calmness suppression should reuse this assessment path rather than inventing a workspace-only tone system.
|
||||||
|
|
||||||
|
### WorkspaceOverviewBuilder Payload
|
||||||
|
|
||||||
|
**Purpose**: Existing page-level payload that already carries summary metrics, attention items, recent operations, quick actions, and empty states for `/admin`.
|
||||||
|
|
||||||
|
**Validation rules**:
|
||||||
|
- Governance hardening extends this payload shape rather than replacing the page with a new surface framework.
|
||||||
|
- Existing visible-tenant filtering remains the workspace aggregation guardrail.
|
||||||
|
|
||||||
|
## Derived Workspace View Contracts
|
||||||
|
|
||||||
|
### Workspace Summary Metric
|
||||||
|
|
||||||
|
**Purpose**: Compact workspace stat that answers one scope, governance-risk, or activity question and optionally opens one matching destination.
|
||||||
|
|
||||||
|
#### Fields
|
||||||
|
|
||||||
|
| Field | Type | Required | Description |
|
||||||
|
|------|------|----------|-------------|
|
||||||
|
| `key` | string | yes | Stable metric identity such as `accessible_tenants`, `governance_attention_tenants`, `overdue_findings_tenants`, or `active_operations` |
|
||||||
|
| `label` | string | yes | Operator-facing label that must accurately describe the counted universe |
|
||||||
|
| `value` | integer | yes | Metric value |
|
||||||
|
| `category` | enum | yes | `scope`, `governance_risk`, `activity`, or `alerts` |
|
||||||
|
| `description` | string | yes | Short explanation of what the metric means |
|
||||||
|
| `color` | string | yes | Existing tone family used by the widget |
|
||||||
|
| `destination` | object nullable | no | Shared drill-through contract when the metric is actionable |
|
||||||
|
|
||||||
|
#### Validation rules
|
||||||
|
|
||||||
|
- Governance-risk metrics count affected visible tenants, not raw issue totals.
|
||||||
|
- Activity metrics remain activity-only and must not imply governance health.
|
||||||
|
- The stat strip must make the difference between `governance_risk` and `activity` categories obvious.
|
||||||
|
|
||||||
|
### Workspace Attention Item
|
||||||
|
|
||||||
|
**Purpose**: One prioritized workspace triage item that names a visible tenant problem and one next jump.
|
||||||
|
|
||||||
|
#### Fields
|
||||||
|
|
||||||
|
| Field | Type | Required | Description |
|
||||||
|
|------|------|----------|-------------|
|
||||||
|
| `key` | string | yes | Stable attention identity such as `tenant_overdue_findings`, `tenant_lapsed_governance`, `tenant_compare_attention`, or `tenant_failed_operation` |
|
||||||
|
| `tenantId` | integer | yes | Visible tenant identifier |
|
||||||
|
| `tenantLabel` | string | yes | Tenant name shown to the operator |
|
||||||
|
| `family` | enum | yes | `governance`, `findings`, `compare`, `evidence`, `review`, `operations`, or `alerts` |
|
||||||
|
| `urgency` | enum | yes | `critical`, `high`, `medium`, or `supporting` |
|
||||||
|
| `title` | string | yes | Primary operator-facing summary |
|
||||||
|
| `body` | string | yes | Short explanation of why this needs attention |
|
||||||
|
| `badge` | string | yes | Existing family label shown in the UI |
|
||||||
|
| `badgeColor` | string | yes | Existing tone family used for the item |
|
||||||
|
| `supportingMessage` | string nullable | no | Secondary explanatory text when needed |
|
||||||
|
| `destination` | object nullable | no | Shared drill-through contract |
|
||||||
|
| `actionDisabled` | boolean nullable | no | Whether the visible next step is intentionally disabled |
|
||||||
|
| `helperText` | string nullable | no | Explanation when the visible next step is disabled |
|
||||||
|
|
||||||
|
#### Validation rules
|
||||||
|
|
||||||
|
- Every workspace attention item is tenant-bound and must include both `tenantId` and `tenantLabel`.
|
||||||
|
- Governance, findings, compare, evidence or review, and operations items must remain semantically distinct.
|
||||||
|
- Compare attention promotes `BaselineCompareSummaryAssessment::STATE_STALE` directly and only treats `BaselineCompareSummaryAssessment::STATE_ACTION_REQUIRED` as materially degraded compare posture when the aggregate's next action remains compare-specific rather than findings-driven.
|
||||||
|
- `BaselineCompareSummaryAssessment::STATE_CAUTION` stays below the workspace-attention threshold unless another governance signal independently warrants promotion.
|
||||||
|
- Workspace-wide operations or alert totals remain in summary metrics or recent operations until they can be attributed to one visible tenant.
|
||||||
|
- Each item may expose one primary destination only.
|
||||||
|
- Every item must either expose a non-null destination or set `actionDisabled=true` with explanatory `helperText`; null destination without an explicit disabled state is invalid.
|
||||||
|
- If the most precise destination is not available to the current in-scope user, the item must either use an allowed fallback or expose a disabled explanatory state instead of a clickable dead end.
|
||||||
|
|
||||||
|
### Workspace Recent Operation
|
||||||
|
|
||||||
|
**Purpose**: One bounded recent-operations entry shown on the workspace overview as diagnostic recency, not governance posture.
|
||||||
|
|
||||||
|
#### Fields
|
||||||
|
|
||||||
|
| Field | Type | Required | Description |
|
||||||
|
|------|------|----------|-------------|
|
||||||
|
| `id` | integer | yes | Operation run identifier |
|
||||||
|
| `title` | string | yes | Operator-facing operation label |
|
||||||
|
| `tenantLabel` | string nullable | no | Tenant name when the run is tenant-bound |
|
||||||
|
| `statusLabel` | string | yes | Human-readable run status |
|
||||||
|
| `statusColor` | string | yes | Existing tone family for the run status |
|
||||||
|
| `outcomeLabel` | string | yes | Human-readable run outcome |
|
||||||
|
| `outcomeColor` | string | yes | Existing tone family for the run outcome |
|
||||||
|
| `guidance` | string nullable | no | Short follow-up guidance when helpful |
|
||||||
|
| `startedAt` | string | yes | Render-ready recency label |
|
||||||
|
| `destination` | object | yes | Canonical operation-detail drill-through target |
|
||||||
|
|
||||||
|
#### Validation rules
|
||||||
|
|
||||||
|
- The recent-operations collection is bounded to the five most recent visible runs.
|
||||||
|
- Recent operations remain diagnostic context and do not define calmness on their own.
|
||||||
|
- `tenantLabel` may be null only when the run is genuinely workspace-wide rather than tenant-bound.
|
||||||
|
|
||||||
|
### Workspace Drill-Through Target
|
||||||
|
|
||||||
|
**Purpose**: Shared navigation contract used by summary metrics and attention items.
|
||||||
|
|
||||||
|
#### Fields
|
||||||
|
|
||||||
|
| Field | Type | Required | Description |
|
||||||
|
|------|------|----------|-------------|
|
||||||
|
| `kind` | enum | yes | `choose_tenant`, `tenant_dashboard`, `tenant_findings`, `baseline_compare_landing`, `tenant_evidence`, `tenant_reviews`, `operations_index`, `operation_detail`, `alerts_overview`, `switch_workspace`, or `none` |
|
||||||
|
| `url` | string nullable | no | Destination URL when the target is actionable |
|
||||||
|
| `tenantRouteKey` | string nullable | no | Tenant route scope when the destination is tenant-bound |
|
||||||
|
| `filters` | object nullable | no | Query or state needed to reproduce the same subset on the destination |
|
||||||
|
| `label` | string nullable | no | Primary action label |
|
||||||
|
| `disabled` | boolean | yes | Whether the target is intentionally non-clickable |
|
||||||
|
| `helperText` | string nullable | no | Explanation shown when the target is disabled |
|
||||||
|
|
||||||
|
#### Validation rules
|
||||||
|
|
||||||
|
- `kind=none` may only be used for intentionally passive reassurance states.
|
||||||
|
- `tenant_findings` must carry the filter state needed to reproduce overdue, high-severity, expiring-governance, or other directly filterable findings subsets when applicable.
|
||||||
|
- Aggregate lapsed-governance attention uses `tenant_dashboard` when the current tenant findings filters would otherwise narrow the full invalid-governance family to a smaller subset such as `missing_support`.
|
||||||
|
- `operations_index` may carry tenant and tab filters but must remain the canonical admin operations route and the workspace-member-safe fallback for low-permission workspace states.
|
||||||
|
- `alerts_overview` targets the existing alerts overview at `/admin/alerts`, which remains the canonical alert-delivery follow-up surface for this slice.
|
||||||
|
- `switch_workspace` targets `/admin/choose-workspace` and is the default zero-tenant recovery action.
|
||||||
|
|
||||||
|
### Workspace Calmness State
|
||||||
|
|
||||||
|
**Purpose**: The derived claim that the workspace is currently calm enough for an empty or reassurance state.
|
||||||
|
|
||||||
|
#### Fields
|
||||||
|
|
||||||
|
| Field | Type | Required | Description |
|
||||||
|
|------|------|----------|-------------|
|
||||||
|
| `isCalm` | boolean | yes | Whether the workspace may currently make a calmness claim |
|
||||||
|
| `checkedDomains` | array<enum> | yes | Domains actually checked before the claim was made: `governance`, `findings`, `compare`, `evidence`, `review`, `operations`, `alerts`, `tenant_access` |
|
||||||
|
| `title` | string | yes | Empty-state or reassurance title |
|
||||||
|
| `body` | string | yes | Supporting explanation constrained to the checked domains |
|
||||||
|
| `nextAction` | object | yes | One bounded next action |
|
||||||
|
|
||||||
|
#### Validation rules
|
||||||
|
|
||||||
|
- `isCalm=true` is invalid whenever any visible tenant has governance-critical conditions inside the checked domains.
|
||||||
|
- Zero-tenant states and low-permission states must not masquerade as healthy calmness states.
|
||||||
|
- Zero-tenant states default `nextAction.kind` to `switch_workspace`, while low-permission states default `nextAction.kind` to `operations_index` unless a more specific allowed in-scope recovery action exists.
|
||||||
|
- Calm wording must not imply portfolio health beyond the `checkedDomains` list.
|
||||||
|
|
||||||
|
## Ranking Rules
|
||||||
|
|
||||||
|
1. Governance-critical tenant conditions outrank activity-only and alert-only items.
|
||||||
|
2. A single tenant may contribute multiple raw issues, but workspace attention should surface a bounded prioritized subset.
|
||||||
|
3. The stat strip answers portfolio counts by tenant, while the attention list answers which tenant to open next.
|
||||||
|
4. Recent operations remain supporting recency context and do not participate in calmness unless explicitly modeled as an operations-follow-up issue.
|
||||||
332
specs/175-workspace-governance-attention/plan.md
Normal file
332
specs/175-workspace-governance-attention/plan.md
Normal file
@ -0,0 +1,332 @@
|
|||||||
|
# Implementation Plan: Workspace Governance Attention Foundation
|
||||||
|
|
||||||
|
**Branch**: `175-workspace-governance-attention` | **Date**: 2026-04-04 | **Spec**: `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/specs/175-workspace-governance-attention/spec.md`
|
||||||
|
**Input**: Feature specification from `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/specs/175-workspace-governance-attention/spec.md`
|
||||||
|
|
||||||
|
**Note**: This plan follows the existing TenantPilot workspace and tenant-truth architecture. It hardens the current workspace overview instead of introducing a new workspace posture subsystem.
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
Promote existing tenant governance truth into the existing workspace overview so `/admin` becomes a trustworthy multi-tenant governance attention surface instead of an operations-first calm surface. The implementation will keep `WorkspaceOverviewBuilder` as the orchestration point, reuse `TenantGovernanceAggregateResolver`, `BaselineCompareStats`, existing findings and compare destinations, and optionally already-available evidence or review surfaces where they provide a more precise next jump. The first slice will harden summary metrics, attention ranking, tenant identification, compare breadth across stale, failed, and degraded states, and calmness semantics; the second slice will preserve activity versus governance separation and protect the new contract with focused workspace overview regression tests.
|
||||||
|
|
||||||
|
## Technical Context
|
||||||
|
|
||||||
|
**Language/Version**: PHP 8.4.15
|
||||||
|
**Primary Dependencies**: Laravel 12, Filament v5, Livewire v4, Pest v4, existing `WorkspaceOverviewBuilder`, `TenantGovernanceAggregateResolver`, `BaselineCompareStats`, `BaselineCompareSummaryAssessor`, `WorkspaceSummaryStats`, `WorkspaceNeedsAttention`, `WorkspaceRecentOperations`, `WorkspaceCapabilityResolver`, `CapabilityResolver`, `FindingResource`, `BaselineCompareLanding`, `EvidenceSnapshotResource`, `TenantReviewResource`, and canonical admin Operations routes
|
||||||
|
**Storage**: PostgreSQL unchanged; no new persistence, cache table, or materialized aggregate is introduced
|
||||||
|
**Testing**: Pest 4 feature and Livewire-style widget tests through Laravel Sail using existing workspace overview tests plus new governance attention and drill-through coverage
|
||||||
|
**Target Platform**: Laravel monolith web application in Sail locally and containerized Linux deployment in staging and production
|
||||||
|
**Project Type**: web application
|
||||||
|
**Performance Goals**: Keep `/admin` DB-only at render time, keep workspace attention bounded, reuse request-scoped derived-state caching for tenant governance aggregates, and avoid uncontrolled polling or unbounded cross-tenant queries
|
||||||
|
**Constraints**: No new table, no new workspace posture score, no full portfolio matrix, no new panel/provider, no cross-tenant leakage, no dead-end drill-throughs for visible states, no ad-hoc status taxonomy, and no broad workspace IA redesign
|
||||||
|
**Scale/Scope**: One workspace landing page, three existing workspace widgets, one builder, one accessible-tenant slice per workspace, six central governance signal families, and focused regression coverage for workspace calmness, ranking, drill-through continuity, and RBAC-safe omission or fallback behavior
|
||||||
|
|
||||||
|
## Constitution Check
|
||||||
|
|
||||||
|
*GATE: Passed before Phase 0 research. Re-check after Phase 1 design.*
|
||||||
|
|
||||||
|
| Principle | Pre-Research | Post-Design | Notes |
|
||||||
|
|-----------|--------------|-------------|-------|
|
||||||
|
| Inventory-first / snapshots-second | PASS | PASS | The feature adds no new inventory or snapshot truth. It only changes read-time workspace aggregation over existing records. |
|
||||||
|
| Read/write separation | PASS | PASS | The slice is read-only workspace overview hardening. No new write path, preview flow, or destructive action is introduced. |
|
||||||
|
| Graph contract path | N/A | N/A | No Graph call or `config/graph_contracts.php` change is required. |
|
||||||
|
| Deterministic capabilities | PASS | PASS | Existing workspace membership and tenant capability checks remain authoritative for aggregation and drill-through behavior. |
|
||||||
|
| RBAC-UX authorization semantics | PASS | PASS | `/admin` remains workspace-scoped, tenant destinations remain tenant-scoped, non-members stay `404`, and members missing downstream capability must not receive dead-end links. |
|
||||||
|
| Workspace and tenant isolation | PASS | PASS | Aggregation stays limited to visible tenants in the active workspace and uses existing `scopeToAuthorizedTenants()` style filtering. |
|
||||||
|
| Run observability / Ops-UX | PASS | PASS | No new `OperationRun` type, feedback surface, or lifecycle change is added. Existing operations destinations remain canonical. |
|
||||||
|
| Data minimization | PASS | PASS | The plan adds no new persistence and no broader route exposure. Only already-visible tenant truth is promoted into the workspace home. |
|
||||||
|
| Proportionality / no premature abstraction | PASS | PASS | The plan reuses `WorkspaceOverviewBuilder` and `TenantGovernanceAggregateResolver` instead of adding a new workspace posture framework or aggregate layer. |
|
||||||
|
| Persisted truth / behavioral state | PASS | PASS | No new table, enum, status family, or persisted summary artifact is planned. |
|
||||||
|
| UI semantics / few layers | PASS | PASS | The feature aligns existing widget semantics rather than introducing a new presenter or badge taxonomy. |
|
||||||
|
| Badge semantics (BADGE-001) | PASS | PASS | Existing badge and tone domains remain the source for operations and compare posture meaning. New workspace items reuse those semantics rather than inventing new colors or labels. |
|
||||||
|
| Filament-native UI / Action Surface Contract | PASS | PASS | `WorkspaceOverview` and its widgets remain navigation and inspection surfaces only. No destructive or redundant action model is added. |
|
||||||
|
| Filament UX-001 | PASS | PASS | No create or edit page is touched. The design keeps governance attention above recent operations and uses bounded empty-state wording. |
|
||||||
|
| Filament v5 / Livewire v4 compliance | PASS | PASS | The design stays within the current Filament v5 + Livewire v4 stack. |
|
||||||
|
| Provider registration location | PASS | PASS | No panel/provider registration change is required. Laravel 11+ provider registration remains in `bootstrap/providers.php`. |
|
||||||
|
| Global search hard rule | PASS | PASS | No global-searchable resource behavior is changed in this slice. |
|
||||||
|
| Destructive action safety | PASS | PASS | The feature introduces no destructive action. |
|
||||||
|
| Asset strategy | PASS | PASS | No new assets or `filament:assets` deployment change is required. |
|
||||||
|
| Testing truth (TEST-TRUTH-001) | PASS | PASS | The plan adds tests around business consequences: false calmness, tenant identification, ordering, continuity, and RBAC-safe navigation behavior. |
|
||||||
|
|
||||||
|
## Phase 0 Research
|
||||||
|
|
||||||
|
Research outcomes are captured in `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/specs/175-workspace-governance-attention/research.md`.
|
||||||
|
|
||||||
|
Key decisions:
|
||||||
|
|
||||||
|
- Reuse `WorkspaceOverviewBuilder` as the single orchestration point instead of creating a new workspace posture service.
|
||||||
|
- Reuse `TenantGovernanceAggregateResolver` and `BaselineCompareStats` per visible tenant as the primary source for lapsed governance, overdue findings, expiring governance, high-severity active findings, and stale, failed, or materially degraded compare posture.
|
||||||
|
- Count governance risk at the tenant level for workspace summary metrics so the workspace answer is “how many tenants need attention,” not “how many raw issues exist.”
|
||||||
|
- Rank governance-critical tenant states above activity-only or alert-delivery-only items, while keeping operations and alerts available as lower-priority supporting attention.
|
||||||
|
- Make every workspace attention item tenant-identifiable and map it to exactly one matching destination, with an RBAC-safe fallback or disabled/non-clickable explanatory state when the exact destination is not allowed.
|
||||||
|
- Default zero-tenant recovery to the existing choose-workspace route and keep alert-only follow-up on the existing alerts overview route.
|
||||||
|
- Keep `WorkspaceRecentOperations` as a diagnostic activity surface, not a governance surface.
|
||||||
|
- Promote evidence or review truth only when an existing tenant-level evidence or review surface already carries a clearer next jump than the tenant dashboard fallback.
|
||||||
|
- Lean on request-scoped derived-state caching to keep per-tenant aggregate resolution viable for normal workspace sizes without new persistence.
|
||||||
|
|
||||||
|
## Phase 1 Design
|
||||||
|
|
||||||
|
Design artifacts are created under `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/specs/175-workspace-governance-attention/`:
|
||||||
|
|
||||||
|
- `data-model.md`: persistent source truths and derived workspace attention contracts for this slice
|
||||||
|
- `contracts/workspace-governance-attention.openapi.yaml`: internal surface contract for workspace governance-aware overview semantics and drill-through continuity
|
||||||
|
- `quickstart.md`: focused implementation and verification workflow
|
||||||
|
|
||||||
|
Design highlights:
|
||||||
|
|
||||||
|
- `WorkspaceOverviewBuilder` remains the builder boundary and becomes responsible for deriving governance-aware summary metrics, attention candidates, and calmness claims from visible tenants only.
|
||||||
|
- `TenantGovernanceAggregateResolver` is the primary source for governance-related tenant promotion, mirroring the mature tenant dashboard semantics rather than reinterpreting the same truth a second time.
|
||||||
|
- Workspace summary metrics are split into scope, governance-risk, and activity categories so the stat strip can no longer blur portfolio activity with portfolio risk.
|
||||||
|
- Workspace attention items become structured tenant-bound records carrying tenant label, problem family, urgency, and one primary drill-through target.
|
||||||
|
- `WorkspaceNeedsAttention` remains bounded and prioritized, favoring the highest-value tenant signal per item instead of dumping every raw issue into the workspace surface.
|
||||||
|
- Compare-driven workspace attention must preserve stale, failed, and materially degraded posture families rather than collapsing them into a single degraded-only bucket.
|
||||||
|
- Compare attention promotes `STATE_STALE` directly and only treats `STATE_ACTION_REQUIRED` as materially degraded compare posture when the aggregate's next action remains compare-specific rather than findings-driven; `STATE_CAUTION` remains below the workspace-attention threshold unless another governance signal independently warrants promotion.
|
||||||
|
- Existing evidence and review surfaces stay optional targets in this slice: they are only used when already-available truth makes them the most precise next jump.
|
||||||
|
- Zero-tenant recovery defaults to `ChooseWorkspace`, and alert-only follow-up reuses the existing alerts overview at `/admin/alerts`.
|
||||||
|
- `WorkspaceRecentOperations` remains a recency surface and is intentionally prevented from defining calmness on its own.
|
||||||
|
|
||||||
|
## Phase 1 — Agent Context Update
|
||||||
|
|
||||||
|
Run after artifact generation:
|
||||||
|
|
||||||
|
- `.specify/scripts/bash/update-agent-context.sh copilot`
|
||||||
|
|
||||||
|
## Project Structure
|
||||||
|
|
||||||
|
### Documentation (this feature)
|
||||||
|
|
||||||
|
```text
|
||||||
|
specs/175-workspace-governance-attention/
|
||||||
|
├── spec.md
|
||||||
|
├── plan.md
|
||||||
|
├── research.md
|
||||||
|
├── data-model.md
|
||||||
|
├── quickstart.md
|
||||||
|
├── contracts/
|
||||||
|
│ └── workspace-governance-attention.openapi.yaml
|
||||||
|
├── checklists/
|
||||||
|
│ └── requirements.md
|
||||||
|
└── tasks.md
|
||||||
|
```
|
||||||
|
|
||||||
|
### Source Code (repository root)
|
||||||
|
|
||||||
|
```text
|
||||||
|
app/
|
||||||
|
├── Filament/
|
||||||
|
│ ├── Pages/
|
||||||
|
│ │ ├── WorkspaceOverview.php
|
||||||
|
│ │ ├── ChooseTenant.php
|
||||||
|
│ │ ├── TenantDashboard.php
|
||||||
|
│ │ └── BaselineCompareLanding.php
|
||||||
|
│ ├── Resources/
|
||||||
|
│ │ ├── FindingResource.php
|
||||||
|
│ │ ├── EvidenceSnapshotResource.php
|
||||||
|
│ │ └── TenantReviewResource.php
|
||||||
|
│ └── Widgets/
|
||||||
|
│ ├── Dashboard/
|
||||||
|
│ │ └── NeedsAttention.php
|
||||||
|
│ └── Workspace/
|
||||||
|
│ ├── WorkspaceSummaryStats.php
|
||||||
|
│ ├── WorkspaceNeedsAttention.php
|
||||||
|
│ └── WorkspaceRecentOperations.php
|
||||||
|
├── Models/
|
||||||
|
│ ├── Finding.php
|
||||||
|
│ ├── AlertDelivery.php
|
||||||
|
│ ├── OperationRun.php
|
||||||
|
│ ├── EvidenceSnapshot.php
|
||||||
|
│ └── TenantReview.php
|
||||||
|
├── Services/
|
||||||
|
│ └── Auth/
|
||||||
|
│ ├── WorkspaceCapabilityResolver.php
|
||||||
|
│ └── CapabilityResolver.php
|
||||||
|
└── Support/
|
||||||
|
├── Auth/
|
||||||
|
│ └── Capabilities.php
|
||||||
|
├── Baselines/
|
||||||
|
│ ├── BaselineCompareStats.php
|
||||||
|
│ ├── BaselineCompareSummaryAssessment.php
|
||||||
|
│ ├── BaselineCompareSummaryAssessor.php
|
||||||
|
│ ├── TenantGovernanceAggregate.php
|
||||||
|
│ └── TenantGovernanceAggregateResolver.php
|
||||||
|
├── OperationRunLinks.php
|
||||||
|
└── Workspaces/
|
||||||
|
└── WorkspaceOverviewBuilder.php
|
||||||
|
|
||||||
|
resources/
|
||||||
|
└── views/
|
||||||
|
└── filament/
|
||||||
|
├── pages/
|
||||||
|
│ └── workspace-overview.blade.php
|
||||||
|
└── widgets/
|
||||||
|
└── workspace/
|
||||||
|
├── workspace-needs-attention.blade.php
|
||||||
|
└── workspace-recent-operations.blade.php
|
||||||
|
|
||||||
|
tests/
|
||||||
|
└── Feature/
|
||||||
|
└── Filament/
|
||||||
|
├── WorkspaceOverviewAccessTest.php
|
||||||
|
├── WorkspaceOverviewAuthorizationTest.php
|
||||||
|
├── WorkspaceOverviewContentTest.php
|
||||||
|
├── WorkspaceOverviewEmptyStatesTest.php
|
||||||
|
├── WorkspaceOverviewLandingTest.php
|
||||||
|
├── WorkspaceOverviewNavigationTest.php
|
||||||
|
├── WorkspaceOverviewOperationsTest.php
|
||||||
|
├── WorkspaceOverviewPermissionVisibilityTest.php
|
||||||
|
├── WorkspaceOverviewGovernanceAttentionTest.php
|
||||||
|
├── WorkspaceOverviewDbOnlyTest.php
|
||||||
|
├── WorkspaceOverviewDrilldownContinuityTest.php
|
||||||
|
└── WorkspaceOverviewSummaryMetricsTest.php
|
||||||
|
```
|
||||||
|
|
||||||
|
**Structure Decision**: Keep the feature entirely inside the existing Laravel/Filament monolith. Extend the current workspace overview builder, workspace widgets, tenant truth helpers, and existing destination resources or pages instead of creating a new workspace domain layer.
|
||||||
|
|
||||||
|
## Complexity Tracking
|
||||||
|
|
||||||
|
> No Constitution Check violations are planned. No exceptions are currently justified.
|
||||||
|
|
||||||
|
| Violation | Why Needed | Simpler Alternative Rejected Because |
|
||||||
|
|-----------|------------|-------------------------------------|
|
||||||
|
| — | — | — |
|
||||||
|
|
||||||
|
## Proportionality Review
|
||||||
|
|
||||||
|
> No new enum/status family, persisted entity, abstraction layer, taxonomy, or cross-domain UI framework is planned in this slice.
|
||||||
|
|
||||||
|
- **Current operator problem**: The workspace home can look calm even when visible tenants already carry governance-critical problems.
|
||||||
|
- **Existing structure is insufficient because**: Workspace aggregation currently stops at operations and alerts and fails to propagate already-mature tenant governance truth.
|
||||||
|
- **Narrowest correct implementation**: Extend the existing workspace overview builder and widgets to consume existing tenant aggregates and destination contracts.
|
||||||
|
- **Ownership cost created**: Modest additional workspace-level aggregation logic and focused regression coverage.
|
||||||
|
- **Alternative intentionally rejected**: A new workspace posture subsystem, new persistence, or a matrix-style redesign.
|
||||||
|
- **Release truth**: Current-release truth.
|
||||||
|
|
||||||
|
## Implementation Strategy
|
||||||
|
|
||||||
|
### Phase A — Reuse Existing Tenant Truth In The Workspace Builder
|
||||||
|
|
||||||
|
**Goal**: Make `WorkspaceOverviewBuilder` governance-aware without creating a parallel truth layer.
|
||||||
|
|
||||||
|
| Step | File | Change |
|
||||||
|
|------|------|--------|
|
||||||
|
| A.1 | `app/Support/Workspaces/WorkspaceOverviewBuilder.php` | Add a visible-tenant governance promotion pass that resolves `TenantGovernanceAggregate` for accessible tenants and builds a bounded set of governance candidates from overdue findings, lapsed governance, expiring governance, high-severity active findings, stale, failed, or materially degraded compare posture, and optionally already-available evidence or review attention. |
|
||||||
|
| A.2 | `app/Support/Baselines/TenantGovernanceAggregateResolver.php` and existing tenant truth classes | Reuse existing aggregate outputs as-is; only add plan-level consumption, not a second interpretation layer. |
|
||||||
|
| A.3 | `tests/Feature/Filament/WorkspaceOverviewGovernanceAttentionTest.php` plus existing overview tests | Prove that visible governance-critical tenants now surface on the workspace home even when operations are quiet. |
|
||||||
|
|
||||||
|
### Phase B — Separate Governance Metrics From Activity Metrics
|
||||||
|
|
||||||
|
**Goal**: Make the stat strip clearly answer risk versus activity instead of flattening them into one line of numbers.
|
||||||
|
|
||||||
|
| Step | File | Change |
|
||||||
|
|------|------|--------|
|
||||||
|
| B.1 | `app/Support/Workspaces/WorkspaceOverviewBuilder.php` | Replace or augment the current `needs_attention` stat with one or more tenant-level governance-risk metrics such as tenants needing governance attention, tenants with overdue findings, tenants with lapsed governance, or tenants with stale, failed, or materially degraded compare posture. |
|
||||||
|
| B.2 | `app/Filament/Widgets/Workspace/WorkspaceSummaryStats.php` and `resources/views/filament/pages/workspace-overview.blade.php` | Preserve the existing stat strip but ensure risk metrics and activity metrics are semantically and visually distinguishable through grouping, wording, and destination meaning. |
|
||||||
|
| B.3 | `tests/Feature/Filament/WorkspaceOverviewSummaryMetricsTest.php` and existing content tests | Prove that governance metrics count affected visible tenants, activity metrics remain activity-only, and the two meanings are not mixed. |
|
||||||
|
|
||||||
|
### Phase C — Make Workspace Attention Tenant-Addressable And Actionable
|
||||||
|
|
||||||
|
**Goal**: Turn workspace attention into a real start surface with tenant identity, reason, priority, and next jump.
|
||||||
|
|
||||||
|
| Step | File | Change |
|
||||||
|
|------|------|--------|
|
||||||
|
| C.1 | `app/Support/Workspaces/WorkspaceOverviewBuilder.php` | Expand the attention item payload to include tenant label, tenant route key or id, problem family, urgency, and one primary target kind. Keep every attention item tenant-bound, leave workspace-wide operations or alert totals in metrics or recency surfaces, and rank governance issues above activity-only items while keeping the list bounded. |
|
||||||
|
| C.2 | `resources/views/filament/widgets/workspace/workspace-needs-attention.blade.php` | Render tenant identity, family, urgency, and one explicit primary action or a disabled explanatory state when the exact destination is not allowed. |
|
||||||
|
| C.3 | `tests/Feature/Filament/WorkspaceOverviewDrilldownContinuityTest.php` and `tests/Feature/Filament/WorkspaceOverviewGovernanceAttentionTest.php` | Prove that each central attention family leads to the correct tenant dashboard, findings, compare, evidence, review, or operation destination and that dead-end links are not exposed. |
|
||||||
|
|
||||||
|
### Phase D — Fix Workspace Calmness And Empty-State Semantics
|
||||||
|
|
||||||
|
**Goal**: Stop the workspace home from claiming calmness when only operations are quiet.
|
||||||
|
|
||||||
|
| Step | File | Change |
|
||||||
|
|------|------|--------|
|
||||||
|
| D.1 | `app/Support/Workspaces/WorkspaceOverviewBuilder.php` | Change empty-state and calmness logic so calm claims are suppressed whenever visible tenant governance or compare issues exist, even if operations and alerts are healthy. Keep zero-tenant states distinct from healthy states and default their recovery action to the existing choose-workspace route. |
|
||||||
|
| D.2 | `app/Filament/Widgets/Workspace/WorkspaceNeedsAttention.php`, `resources/views/filament/pages/workspace-overview.blade.php`, and `resources/views/filament/widgets/workspace/workspace-needs-attention.blade.php` | Tighten copy so the workspace can say “nothing urgent” only for the domains actually checked and only when no visible governance-critical tenant state exists. |
|
||||||
|
| D.3 | `tests/Feature/Filament/WorkspaceOverviewEmptyStatesTest.php` and new governance attention coverage | Prove false calmness is suppressed and low-permission or zero-tenant scenarios remain clearly distinct from healthy calm. |
|
||||||
|
|
||||||
|
### Phase E — Preserve Operations As Diagnostic Recency, Not Portfolio Posture
|
||||||
|
|
||||||
|
**Goal**: Keep `WorkspaceRecentOperations` useful without letting it dominate workspace risk semantics.
|
||||||
|
|
||||||
|
| Step | File | Change |
|
||||||
|
|------|------|--------|
|
||||||
|
| E.1 | `app/Filament/Widgets/Workspace/WorkspaceRecentOperations.php` and `resources/views/filament/widgets/workspace/workspace-recent-operations.blade.php` | Preserve existing recency behavior and row-open model, but ensure surrounding copy and page hierarchy keep it clearly subordinate to governance attention and summary metrics. |
|
||||||
|
| E.2 | `tests/Feature/Filament/WorkspaceOverviewOperationsTest.php` and content tests | Prove operations remain filtered to the visible tenant slice, remain non-polling by default, and no longer define calmness on their own. |
|
||||||
|
|
||||||
|
### Phase F — Tighten RBAC-Safe Destination Selection
|
||||||
|
|
||||||
|
**Goal**: Ensure visible truth never creates a broken or misleading navigation path.
|
||||||
|
|
||||||
|
| Step | File | Change |
|
||||||
|
|------|------|--------|
|
||||||
|
| F.1 | `app/Support/Workspaces/WorkspaceOverviewBuilder.php`, `app/Services/Auth/WorkspaceCapabilityResolver.php`, `app/Services/Auth/CapabilityResolver.php`, and tenant resource URL helpers | For each attention family, choose the most precise destination the current in-scope user may actually open; otherwise fall back to an allowed tenant dashboard or disabled explanatory state. Aggregate lapsed-governance attention stays tenant-bound but falls back to the tenant dashboard when the current findings filters would narrow the invalid-governance family. Alert-only follow-up reuses `/admin/alerts`, and zero-tenant recovery reuses `/admin/choose-workspace`. |
|
||||||
|
| F.2 | `tests/Feature/Filament/WorkspaceOverviewPermissionVisibilityTest.php`, `WorkspaceOverviewAuthorizationTest.php`, and new drilldown tests | Prove non-members still receive `404`, hidden tenants do not affect visible output, and members missing downstream capability do not receive clickable dead-end links. |
|
||||||
|
|
||||||
|
### Phase G — Verification And Formatting
|
||||||
|
|
||||||
|
**Goal**: Lock the new workspace truth and performance contract in place.
|
||||||
|
|
||||||
|
| Step | File | Change |
|
||||||
|
|------|------|--------|
|
||||||
|
| G.1 | Workspace overview focused test pack | Add or extend tests for governance promotion, stale, failed, and materially degraded compare breadth, ordering, tenant identity, summary metric separation, calmness suppression, zero-tenant next-step recovery, low-permission operations fallback, drill-through continuity, DB-only query-bounded rendering, and permission-safe fallbacks. |
|
||||||
|
| G.2 | `vendor/bin/sail bin pint --dirty --format agent` and focused Pest runs | Apply formatting and run the smallest verification pack that covers the builder, workspace view rendering, and navigation behavior. |
|
||||||
|
|
||||||
|
## Key Design Decisions
|
||||||
|
|
||||||
|
### D-001 — Keep `WorkspaceOverviewBuilder` as the single orchestration boundary
|
||||||
|
|
||||||
|
The repo already has one place that constructs the workspace overview payload. Extending that builder is narrower than inventing a separate workspace governance service or presenter layer.
|
||||||
|
|
||||||
|
### D-002 — Promote tenant truth by tenant, not by raw issue count
|
||||||
|
|
||||||
|
The workspace operator needs to know which tenants need attention first. A tenant-level risk count is a better workspace answer than raw issue totals because it preserves the MSP triage shape and avoids making one noisy tenant dominate the stat strip.
|
||||||
|
|
||||||
|
### D-003 — Reuse `TenantGovernanceAggregate` before touching lower-level logic
|
||||||
|
|
||||||
|
The tenant dashboard already uses a mature governance aggregate. Workspace attention should consume that same truth path rather than rebuilding overdue, lapsed, or compare logic with new ad-hoc queries.
|
||||||
|
|
||||||
|
### D-004 — Keep workspace attention bounded and prioritized
|
||||||
|
|
||||||
|
The spec hardens the workspace entry point, not a portfolio matrix. The workspace home should surface the top visible reasons to act, not every raw tenant issue. One high-value item per visible problem family is preferable to a noisy stream.
|
||||||
|
|
||||||
|
### D-005 — Treat evidence and review as opportunistic precision targets
|
||||||
|
|
||||||
|
Evidence and reviews are in scope only when they already represent the best existing tenant-level next jump. They are not a reason to create a new workspace evidence or review aggregate in this slice.
|
||||||
|
|
||||||
|
### D-006 — Calmness is a checked-domain claim, not a decorative empty state
|
||||||
|
|
||||||
|
The workspace may only look calm when both governance and activity signals in visible scope are calm. Operations-only quietness is never enough.
|
||||||
|
|
||||||
|
### D-007 — Disabled or fallback navigation is preferable to dead-end clicks
|
||||||
|
|
||||||
|
If the current in-scope user can see that a tenant has a problem but cannot open the most precise destination, the UI must not offer a clickable link that fails later. The surface must either choose an allowed fallback or render a disabled explanatory state.
|
||||||
|
|
||||||
|
## Risk Assessment
|
||||||
|
|
||||||
|
| Risk | Impact | Likelihood | Mitigation |
|
||||||
|
|------|--------|------------|------------|
|
||||||
|
| Workspace attention becomes noisy by promoting too many tenant signals | High | Medium | Bound the list, rank governance-critical families first, and cap per-page attention items. |
|
||||||
|
| Per-tenant aggregate resolution introduces avoidable N+1 behavior | High | Medium | Reuse `TenantGovernanceAggregateResolver` request-scoped caching and keep the visible tenant slice bounded. |
|
||||||
|
| Workspace and tenant truth diverge because workspace logic starts reinterpreting the same facts | High | Medium | Consume `TenantGovernanceAggregate` and existing destinations instead of rebuilding semantics with ad-hoc query logic. |
|
||||||
|
| Capability gaps create misleading or broken drill-throughs | High | Medium | Implement explicit destination selection with fallback or disabled states and cover it with focused tests. |
|
||||||
|
| Calmness copy still overstates health after the change | Medium | Medium | Add explicit calmness-suppression tests covering quiet-ops but risky-governance scenarios. |
|
||||||
|
|
||||||
|
## Test Strategy
|
||||||
|
|
||||||
|
- Extend existing workspace overview tests to preserve landing, navigation, authorization, permission visibility, and operations-slice behavior.
|
||||||
|
- Add focused governance attention coverage for overdue findings, lapsed governance, expiring governance, high-severity active findings, stale, failed, or materially degraded compare posture, and quiet-ops-but-risky-governance scenarios.
|
||||||
|
- Add summary-metric tests proving governance-risk metrics count affected visible tenants rather than raw issue counts and remain distinct from activity metrics.
|
||||||
|
- Add drill-through continuity tests covering tenant dashboard fallback, findings filters, baseline compare landing, evidence or review targets where applicable, and canonical operation detail or index routes.
|
||||||
|
- Add permission-sensitive tests ensuring non-members remain `404`, invisible tenants do not affect visible output, members missing a downstream capability get a safe fallback or disabled state rather than a clickable dead end, and zero-tenant members receive the choose-workspace recovery action instead of healthy calm messaging.
|
||||||
|
- Add DB-only and query-bounding verification so render-time aggregation stays inside the plan's performance constraints and benefits from existing request-scoped caching.
|
||||||
|
- Keep the verification pack Sail-first and run `vendor/bin/sail bin pint --dirty --format agent` before closing implementation.
|
||||||
|
|
||||||
|
## Constitution Check (Post-Design)
|
||||||
|
|
||||||
|
Re-check result: PASS.
|
||||||
|
|
||||||
|
- Livewire v4.0+ compliance: preserved because the design stays inside existing Filament v5 widgets and pages.
|
||||||
|
- Provider registration location: unchanged; no panel/provider registration work is needed beyond the existing `bootstrap/providers.php` setup.
|
||||||
|
- Global-searchable resources: unchanged; no resource search behavior is altered.
|
||||||
|
- Destructive actions: unchanged; the feature adds no destructive action and therefore no new confirmation flow.
|
||||||
|
- Asset strategy: unchanged; no new asset bundle or deploy-time asset step is introduced.
|
||||||
|
- Testing plan: focused Pest coverage will be added or extended for workspace overview rendering, governance promotion, calmness suppression, summary metric separation, drill-through continuity, and RBAC-safe behavior.
|
||||||
121
specs/175-workspace-governance-attention/quickstart.md
Normal file
121
specs/175-workspace-governance-attention/quickstart.md
Normal file
@ -0,0 +1,121 @@
|
|||||||
|
# Quickstart: Workspace Governance Attention Foundation
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
Validate that `/admin` no longer appears calm when visible tenants carry governance-critical conditions, that workspace summary metrics distinguish risk from activity, and that workspace attention items identify the correct tenant and open the correct next surface.
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
|
||||||
|
1. Start Sail.
|
||||||
|
2. Ensure you have one workspace with multiple visible tenants and current workspace session context.
|
||||||
|
3. Prepare seeded tenant scenarios for:
|
||||||
|
- no governance-critical conditions and no unusual activity
|
||||||
|
- overdue findings with otherwise quiet operations
|
||||||
|
- lapsed governance
|
||||||
|
- expiring governance
|
||||||
|
- high-severity active findings
|
||||||
|
- stale, failed, or materially degraded compare posture
|
||||||
|
- activity-only workspace noise with otherwise healthy governance posture
|
||||||
|
- optional existing evidence or review attention if those truth surfaces are already available
|
||||||
|
4. Prepare one workspace member who can see the workspace home but lacks at least one downstream tenant destination capability so disabled or fallback attention behavior can be verified.
|
||||||
|
5. Prepare one workspace member who belongs to the workspace but has zero accessible tenants so the choose-workspace recovery path can be verified.
|
||||||
|
|
||||||
|
## Implementation Validation Order
|
||||||
|
|
||||||
|
### 1. Run the existing workspace overview baseline pack
|
||||||
|
|
||||||
|
```bash
|
||||||
|
vendor/bin/sail artisan test --compact tests/Feature/Filament/WorkspaceOverviewAccessTest.php
|
||||||
|
vendor/bin/sail artisan test --compact tests/Feature/Filament/WorkspaceOverviewAuthorizationTest.php
|
||||||
|
vendor/bin/sail artisan test --compact tests/Feature/Filament/WorkspaceOverviewLandingTest.php
|
||||||
|
vendor/bin/sail artisan test --compact tests/Feature/Filament/WorkspaceOverviewNavigationTest.php
|
||||||
|
vendor/bin/sail artisan test --compact tests/Feature/Filament/WorkspaceOverviewContentTest.php
|
||||||
|
vendor/bin/sail artisan test --compact tests/Feature/Filament/WorkspaceOverviewEmptyStatesTest.php
|
||||||
|
vendor/bin/sail artisan test --compact tests/Feature/Filament/WorkspaceOverviewPermissionVisibilityTest.php
|
||||||
|
vendor/bin/sail artisan test --compact tests/Feature/Filament/WorkspaceOverviewOperationsTest.php
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected outcome:
|
||||||
|
- The existing workspace home still renders, remains workspace-scoped, and preserves current access and operations behavior.
|
||||||
|
|
||||||
|
### 2. Run focused governance-attention coverage
|
||||||
|
|
||||||
|
```bash
|
||||||
|
vendor/bin/sail artisan test --compact tests/Feature/Filament/WorkspaceOverviewGovernanceAttentionTest.php
|
||||||
|
vendor/bin/sail artisan test --compact tests/Feature/Filament/WorkspaceOverviewSummaryMetricsTest.php
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected outcome:
|
||||||
|
- Visible tenants with overdue findings, lapsed governance, expiring governance, high-severity active findings, or stale, failed, or materially degraded compare posture now promote governance attention into the workspace home.
|
||||||
|
- Governance-risk metrics count affected tenants and remain distinct from operations or alerts volume.
|
||||||
|
|
||||||
|
### 3. Run drill-through continuity and RBAC-safe navigation coverage
|
||||||
|
|
||||||
|
```bash
|
||||||
|
vendor/bin/sail artisan test --compact tests/Feature/Filament/WorkspaceOverviewDrilldownContinuityTest.php
|
||||||
|
vendor/bin/sail artisan test --compact tests/Feature/Filament/WorkspaceOverviewPermissionVisibilityTest.php
|
||||||
|
vendor/bin/sail artisan test --compact tests/Feature/Filament/WorkspaceOverviewAuthorizationTest.php
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected outcome:
|
||||||
|
- Workspace attention items carry tenant context and open the correct findings, compare, evidence, review, tenant dashboard, or operations destination.
|
||||||
|
- Members missing a downstream capability do not receive clickable dead-end links.
|
||||||
|
|
||||||
|
### 4. Re-run workspace empty-state and calmness coverage
|
||||||
|
|
||||||
|
```bash
|
||||||
|
vendor/bin/sail artisan test --compact tests/Feature/Filament/WorkspaceOverviewEmptyStatesTest.php
|
||||||
|
vendor/bin/sail artisan test --compact tests/Feature/Filament/WorkspaceOverviewGovernanceAttentionTest.php --filter=calm
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected outcome:
|
||||||
|
- The workspace no longer renders a calm or “nothing urgent” state when visible governance-critical tenant conditions exist.
|
||||||
|
- Zero-tenant and low-permission states remain clearly distinct from healthy calmness, zero-tenant recovery defaults to `Switch workspace`, and permission-limited recovery defaults to `Open operations` unless a more specific allowed action exists.
|
||||||
|
|
||||||
|
### 5. Format touched files
|
||||||
|
|
||||||
|
```bash
|
||||||
|
vendor/bin/sail bin pint --dirty --format agent
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected outcome:
|
||||||
|
- All changed implementation files conform to project formatting rules.
|
||||||
|
|
||||||
|
### 6. Run the final focused verification pack
|
||||||
|
|
||||||
|
```bash
|
||||||
|
vendor/bin/sail artisan test --compact tests/Feature/Filament/WorkspaceOverviewAccessTest.php
|
||||||
|
vendor/bin/sail artisan test --compact tests/Feature/Filament/WorkspaceOverviewAuthorizationTest.php
|
||||||
|
vendor/bin/sail artisan test --compact tests/Feature/Filament/WorkspaceOverviewLandingTest.php
|
||||||
|
vendor/bin/sail artisan test --compact tests/Feature/Filament/WorkspaceOverviewNavigationTest.php
|
||||||
|
vendor/bin/sail artisan test --compact tests/Feature/Filament/WorkspaceOverviewContentTest.php
|
||||||
|
vendor/bin/sail artisan test --compact tests/Feature/Filament/WorkspaceOverviewEmptyStatesTest.php
|
||||||
|
vendor/bin/sail artisan test --compact tests/Feature/Filament/WorkspaceOverviewPermissionVisibilityTest.php
|
||||||
|
vendor/bin/sail artisan test --compact tests/Feature/Filament/WorkspaceOverviewOperationsTest.php
|
||||||
|
vendor/bin/sail artisan test --compact tests/Feature/Filament/WorkspaceOverviewDbOnlyTest.php
|
||||||
|
vendor/bin/sail artisan test --compact tests/Feature/Filament/WorkspaceOverviewGovernanceAttentionTest.php
|
||||||
|
vendor/bin/sail artisan test --compact tests/Feature/Filament/WorkspaceOverviewSummaryMetricsTest.php
|
||||||
|
vendor/bin/sail artisan test --compact tests/Feature/Filament/WorkspaceOverviewDrilldownContinuityTest.php
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected outcome:
|
||||||
|
- The formatted implementation preserves landing, authorization, content, empty-state, operations, governance-attention, stat-separation, and drill-through contracts for the workspace home.
|
||||||
|
|
||||||
|
## Manual Smoke Check
|
||||||
|
|
||||||
|
1. Open `/admin` for a workspace where one visible tenant has overdue findings but operations are quiet.
|
||||||
|
2. Confirm the workspace home does not read as calm and identifies the tenant explicitly.
|
||||||
|
3. Open `/admin` for a workspace where a visible tenant has lapsed governance or stale, failed, or materially degraded compare posture and confirm that tenant is prioritized above activity-only items.
|
||||||
|
4. Click a governance attention item and confirm the destination reproduces the same tenant problem family.
|
||||||
|
5. Verify that the summary strip clearly distinguishes tenant risk from active operations.
|
||||||
|
6. Switch to a healthy workspace and confirm calm wording only appears when both governance and activity domains are truly calm within visible scope.
|
||||||
|
7. Sign in as the zero-tenant workspace member and confirm the workspace does not look healthy and instead offers `Switch workspace` as the next action.
|
||||||
|
8. Sign in as the permission-limited workspace member and confirm visible attention does not expose a clickable dead-end link and the page still presents `Open operations` as the valid workspace-safe next action.
|
||||||
|
|
||||||
|
## Non-Goals For This Slice
|
||||||
|
|
||||||
|
- No database migration.
|
||||||
|
- No new Graph contract or provider workflow.
|
||||||
|
- No full portfolio matrix or posture score.
|
||||||
|
- No new workspace evidence or review aggregate.
|
||||||
|
- No conversion of recent operations into a primary governance queue.
|
||||||
105
specs/175-workspace-governance-attention/research.md
Normal file
105
specs/175-workspace-governance-attention/research.md
Normal file
@ -0,0 +1,105 @@
|
|||||||
|
# Phase 0 Research: Workspace Governance Attention Foundation
|
||||||
|
|
||||||
|
## Decision: Reuse `WorkspaceOverviewBuilder` as the single orchestration point for workspace governance attention
|
||||||
|
|
||||||
|
**Rationale**: The current workspace home already builds one typed payload through `WorkspaceOverviewBuilder`, and it already applies visible-tenant scoping plus workspace capability checks before computing metrics, attention, and recency. The missing behavior is not a missing builder seam; it is that the builder currently stops at operations and alerts. Extending that existing orchestration point is narrower than creating a second workspace posture service.
|
||||||
|
|
||||||
|
**Alternatives considered**:
|
||||||
|
- Create a new workspace posture builder or presenter layer: rejected because it would duplicate ownership and violate the repo's bias toward few layers.
|
||||||
|
- Push governance promotion into widget-local queries: rejected because it would spread truth ownership across multiple workspace widgets.
|
||||||
|
|
||||||
|
## Decision: Reuse `TenantGovernanceAggregateResolver` and `BaselineCompareStats` per visible tenant instead of inventing workspace-local governance logic
|
||||||
|
|
||||||
|
**Rationale**: The tenant dashboard already uses `TenantGovernanceAggregate`, `BaselineCompareStats`, and `BaselineCompareSummaryAssessor` to answer exactly the governance questions Spec 175 wants to surface at workspace level: overdue findings, lapsed governance, expiring governance, high-severity active findings, and stale, failed, or materially degraded compare posture. Workspace attention should promote that existing tenant truth, not reinterpret it with a weaker query path.
|
||||||
|
|
||||||
|
**Alternatives considered**:
|
||||||
|
- Create a workspace-only aggregation query for overdue, lapsed, and compare states: rejected because it would split truth ownership away from the existing tenant governance path.
|
||||||
|
- Create a persisted workspace governance summary: rejected because the spec explicitly forbids new persistence and the problem is request-time semantics, not lifecycle truth.
|
||||||
|
|
||||||
|
## Decision: Count governance risk by affected tenant, not by raw issue total, in workspace summary metrics
|
||||||
|
|
||||||
|
**Rationale**: The workspace question is “which tenants need attention” rather than “how many findings exist.” Counting visible tenants with overdue findings, lapsed governance, or stale, failed, or materially degraded compare posture gives the operator a portfolio answer and avoids one noisy tenant dominating the workspace strip.
|
||||||
|
|
||||||
|
**Alternatives considered**:
|
||||||
|
- Show raw totals for overdue findings or lapsed exceptions across the whole workspace: rejected because it weakens triage value and makes the summary less tenant-actionable.
|
||||||
|
- Keep the existing single `needs attention` count derived from operations and alerts only: rejected because it preserves the false-calm problem.
|
||||||
|
|
||||||
|
## Decision: Rank governance-critical tenant states above activity-only and alert-delivery-only items
|
||||||
|
|
||||||
|
**Rationale**: The spec explicitly requires governance-critical states such as lapsed governance, overdue findings, high-severity active findings, and stale, failed, or materially degraded compare posture to outrank active operations and alert-delivery failures. Workspace attention should still include operations or alerts, but only after governance-critical tenant problems have had a chance to surface.
|
||||||
|
|
||||||
|
**Alternatives considered**:
|
||||||
|
- Keep current recency-first ordering with governance added lower down: rejected because it still lets busy but healthier workspaces look more urgent than risky but quiet ones.
|
||||||
|
- Remove operations and alerts entirely: rejected because they remain useful as supporting attention once governance-critical signals are represented.
|
||||||
|
|
||||||
|
## Decision: Make every workspace attention item tenant-identifiable with one primary destination
|
||||||
|
|
||||||
|
**Rationale**: The current workspace attention items are generic operations or alert items. Spec 175 requires tenant identity, problem family, relevance, and next jump. A bounded item contract with tenant label plus one primary destination is the narrowest way to satisfy this without turning the workspace home into a matrix or a second diagnostics page.
|
||||||
|
|
||||||
|
**Alternatives considered**:
|
||||||
|
- Continue using generic titles like “operations are still running”: rejected because it does not answer which tenant to open first.
|
||||||
|
- Add multiple buttons per item: rejected because it makes the attention surface noisier and less decisive.
|
||||||
|
|
||||||
|
## Decision: Use RBAC-safe fallback or disabled explanatory states instead of dead-end drill-throughs
|
||||||
|
|
||||||
|
**Rationale**: Some workspace members may be able to see that a tenant has a problem but may not hold the most precise downstream capability, such as findings inspection. The constitution permits visible disabled states for in-scope members. The safest behavior is to choose an allowed fallback such as the tenant dashboard or to render a disabled explanatory state instead of a clickable link that ends in `403`.
|
||||||
|
|
||||||
|
**Alternatives considered**:
|
||||||
|
- Hide the tenant problem entirely when the precise destination is not allowed: rejected because it can make the workspace appear calmer than reality.
|
||||||
|
- Always link to the precise destination and rely on downstream authorization failure: rejected because the spec forbids dead-end drill-throughs.
|
||||||
|
|
||||||
|
## Decision: Default zero-tenant recovery to the existing choose-workspace route
|
||||||
|
|
||||||
|
**Rationale**: The current admin workspace flow already uses `/admin/choose-workspace` as the canonical recovery surface when no usable workspace or tenant context is available. Reusing that existing route keeps the workspace overview honest for zero-tenant members without inventing a new recovery surface or asking them to infer the next step.
|
||||||
|
|
||||||
|
**Alternatives considered**:
|
||||||
|
- Treat zero-tenant state as a healthy calm empty state: rejected because it hides a scope problem as a health signal.
|
||||||
|
- Default zero-tenant recovery to a workspace-management surface: rejected because many members can switch workspace but cannot manage workspace memberships.
|
||||||
|
|
||||||
|
## Decision: Route alert-only follow-up to the existing alerts overview at `/admin/alerts`
|
||||||
|
|
||||||
|
**Rationale**: The app already has an admin alerts overview that serves as the canonical entry point for alert health, rules, and delivery follow-up. Using that route for workspace alert-only attention is narrower and more stable than inventing a new alert-delivery deep-link contract inside this slice.
|
||||||
|
|
||||||
|
**Alternatives considered**:
|
||||||
|
- Send alert-only attention directly to an alert-deliveries list route: rejected because the current operator surface is the alerts overview at `/admin/alerts`.
|
||||||
|
- Drop alert-only follow-up entirely: rejected because alert delivery remains a valid supporting attention family once governance-critical items are represented.
|
||||||
|
|
||||||
|
## Decision: Keep evidence and review promotion opportunistic, not foundational, in this slice
|
||||||
|
|
||||||
|
**Rationale**: The codebase already has tenant evidence and review resources, but the current workspace overview has no evidence or review aggregation. Spec 175 allows evidence or review issues where already available. The narrow implementation is to use those surfaces only when existing tenant truth already provides a clear problem family and a clear target, not to build a new workspace evidence or review subsystem.
|
||||||
|
|
||||||
|
**Alternatives considered**:
|
||||||
|
- Exclude evidence and review entirely: rejected because the spec explicitly leaves room for them where they already provide value.
|
||||||
|
- Build a dedicated workspace evidence or review aggregate now: rejected because it exceeds the foundation scope of the feature.
|
||||||
|
|
||||||
|
## Decision: Keep `WorkspaceRecentOperations` as diagnostic recency, not portfolio posture
|
||||||
|
|
||||||
|
**Rationale**: The current recent-operations widget already serves a clear supporting role and is covered by existing tests that verify visible-tenant filtering and the absence of polling. The spec does not require changing its query model; it requires preventing that surface from being mistaken for the workspace's main truth surface.
|
||||||
|
|
||||||
|
**Alternatives considered**:
|
||||||
|
- Expand recent operations into the primary attention owner: rejected because it would keep the workspace entry point operations-centered.
|
||||||
|
- Remove recent operations from the overview: rejected because it still provides useful context when subordinated to governance attention.
|
||||||
|
|
||||||
|
## Decision: Treat workspace calmness as a checked-domain claim instead of an operations-only empty state
|
||||||
|
|
||||||
|
**Rationale**: The current `WorkspaceNeedsAttention` empty state says “Nothing urgent in your current scope” and explains that recent operations and alert deliveries look healthy. That wording is only valid if the page is consciously limiting its claim to those domains. After governance promotion, calm messaging must be based on both governance and activity signals in visible scope, or else be narrowed explicitly.
|
||||||
|
|
||||||
|
**Alternatives considered**:
|
||||||
|
- Keep the current empty-state wording and rely on new governance items to suppress it sometimes: rejected because the wording still over-claims portfolio health.
|
||||||
|
- Remove calm messaging entirely: rejected because a truthful all-clear remains useful when the covered domains are genuinely calm.
|
||||||
|
|
||||||
|
## Decision: Lean on request-scoped derived-state caching instead of new persistence for performance
|
||||||
|
|
||||||
|
**Rationale**: `TenantGovernanceAggregateResolver` already uses request-scoped derived-state caching. That makes per-visible-tenant governance resolution viable for the workspace overview without introducing a new summary table or materialized view. This keeps the implementation within the spec's no-new-persistence constraint.
|
||||||
|
|
||||||
|
**Alternatives considered**:
|
||||||
|
- Add a cached database aggregate or background precompute job: rejected because it introduces durability and lifecycle cost the feature does not need.
|
||||||
|
- Resolve all tenant governance truth from raw ad-hoc queries without using the resolver: rejected because it ignores an existing request-scope caching seam and increases drift risk.
|
||||||
|
|
||||||
|
## Decision: Protect the feature with focused workspace overview regression tests instead of manual review alone
|
||||||
|
|
||||||
|
**Rationale**: The highest-risk failures are semantic: false calmness, wrong tenant prioritization, missing tenant labels, or broken destinations. Existing workspace overview tests already cover access, landing, navigation, permission visibility, empty states, content, and operations slice behavior. Extending that suite with governance attention and drill-through coverage is the narrowest way to keep this contract stable.
|
||||||
|
|
||||||
|
**Alternatives considered**:
|
||||||
|
- Rely on manual browser review only: rejected because semantic regressions are subtle and easy to reintroduce.
|
||||||
|
- Build a full browser-based suite first: rejected because focused Pest coverage is already the repo's normal enforcement layer for Filament truth contracts.
|
||||||
238
specs/175-workspace-governance-attention/spec.md
Normal file
238
specs/175-workspace-governance-attention/spec.md
Normal file
@ -0,0 +1,238 @@
|
|||||||
|
# Feature Specification: Spec 175 - Workspace Governance Attention Foundation
|
||||||
|
|
||||||
|
**Feature Branch**: `175-workspace-governance-attention`
|
||||||
|
**Created**: 2026-04-04
|
||||||
|
**Status**: Draft
|
||||||
|
**Input**: User description: "Spec 175 — Workspace Governance Attention Foundation"
|
||||||
|
|
||||||
|
## Spec Scope Fields *(mandatory)*
|
||||||
|
|
||||||
|
- **Scope**: workspace + canonical-view
|
||||||
|
- **Primary Routes**:
|
||||||
|
- `/admin` as the workspace-level overview where `WorkspaceOverview`, `WorkspaceSummaryStats`, `WorkspaceNeedsAttention`, and `WorkspaceRecentOperations` establish the first portfolio triage impression
|
||||||
|
- `/admin/choose-workspace` as the default workspace-switch recovery surface for zero-tenant or wrong-workspace states
|
||||||
|
- `/admin/choose-tenant` as the deliberate tenant-entry surface when the operator needs to move from workspace triage into one tenant
|
||||||
|
- `/admin/t/{tenant}` as the tenant dashboard destination for tenant-wide recovery when a workspace attention item needs a broad tenant landing point
|
||||||
|
- `/admin/t/{tenant}/findings` and tenant finding detail routes as the primary findings and governance drill-through destinations
|
||||||
|
- `/admin/t/{tenant}/baseline-compare-landing` as the compare-posture destination for stale, failed, or materially degraded compare states
|
||||||
|
- `/admin/alerts` as the canonical alert overview and delivery follow-up destination for workspace alert issues
|
||||||
|
- `/admin/operations` and canonical operation detail routes as the operations and execution follow-up destinations
|
||||||
|
- `/admin/t/{tenant}/evidence` and `/admin/t/{tenant}/reviews` only when an attention item is backed by already-visible evidence or review truth that is more precise than a generic tenant landing
|
||||||
|
- **Data Ownership**:
|
||||||
|
- Workspace-owned: workspace context, workspace home composition, workspace-scoped recent operations, and alert-delivery history already summarized on the workspace home
|
||||||
|
- Tenant-owned but workspace-filtered: visible tenant governance state, findings workflow state, evidence and review truth, and tenant-scoped compare posture that may be promoted into workspace attention
|
||||||
|
- This feature introduces no new workspace summary record; workspace attention, calmness, and governance stats remain derived over existing workspace and tenant truth
|
||||||
|
- **RBAC**:
|
||||||
|
- Workspace membership remains required to render `/admin` and all workspace-level overview aggregates
|
||||||
|
- Only tenants that are visible within the current operator's entitled workspace scope may contribute to workspace counts, calmness suppression, or attention items
|
||||||
|
- Alert-related destinations continue to require the existing alert visibility permission, while tenant drill-through destinations continue to require the current tenant-level inspection capability for that surface; baseline compare currently rides on general `tenant.view` rather than a dedicated compare-specific capability
|
||||||
|
- Non-members or out-of-scope actors remain deny-as-not-found, and the workspace home must not leak hidden tenant posture through counts, wording, or drill-through affordances
|
||||||
|
|
||||||
|
For canonical-view specs, the spec MUST define:
|
||||||
|
|
||||||
|
- **Default filter behavior when tenant-context is active**: The workspace home remains workspace-scoped even if a tenant was selected earlier in the session. The overview itself must not silently collapse into one tenant. Any drill-through launched from workspace attention or governance stats must preserve the originating tenant and problem family through tenant context or an equivalent pre-applied destination filter.
|
||||||
|
- **Explicit entitlement checks preventing cross-tenant leakage**: Workspace counts, calmness claims, priority order, and drill-through destinations must be computed only from visible tenants and visible truth surfaces. Inaccessible tenants must not contribute to `needs attention`, governance-risk stats, or empty-state suppression. If a precise destination is not authorized for the current in-scope user, the workspace surface must use an allowed fallback or remove the clickable affordance instead of hinting at hidden data.
|
||||||
|
|
||||||
|
## UI/UX Surface Classification *(mandatory when operator-facing surfaces are changed)*
|
||||||
|
|
||||||
|
| Surface | Surface Type | Primary Inspect/Open Model | Row Click | Secondary Actions Placement | Destructive Actions Placement | Canonical Collection Route | Canonical Detail Route | Scope Signals | Canonical Noun | Critical Truth Visible by Default | Exception Type |
|
||||||
|
|---|---|---|---|---|---|---|---|---|---|---|---|
|
||||||
|
| Workspace overview page | Workspace landing page | The page itself is the canonical workspace entry and hosts the embedded triage surfaces | forbidden | quick actions section only | none | `/admin` | none | Active workspace identity stays visible and embedded items must keep tenant labels explicit | Overview | Whether the workspace currently has governance attention and how that differs from raw activity | Singleton landing surface |
|
||||||
|
| Workspace `Needs Attention` | Embedded triage summary | Each attention item opens one matching working surface for the named tenant problem | required | none | none | `/admin` | Existing tenant dashboard, findings, baseline compare, evidence, review, or operation detail destination depending on item type | Tenant name, problem family, and urgency must be visible on the item before navigation | Attention / Attention item | The most important visible tenant problems and where to go next | Multi-destination triage surface |
|
||||||
|
| Workspace summary stats | Embedded status summary / drill-in surface | Each stat opens one matching destination or remains intentionally passive when no actionable set exists | forbidden | none | none | `/admin` | Matching destination for the metric, such as choose tenant, findings, compare, or operations | Workspace identity and a clear split between governance risk and activity volume | Governance attention and Operations | Portfolio risk counts are separated from portfolio activity counts | Mixed metric summary surface |
|
||||||
|
| Workspace recent operations | Embedded diagnostic recency surface | Each operation row or card opens the canonical operation detail | required | none | none | `/admin/operations` | Existing canonical operation detail route | Workspace context plus visible tenant label on each operation keeps the recency list anchored to one tenant when relevant | Operations / Operation | Recent execution context without pretending to be the main portfolio-risk summary | Diagnostic recency surface |
|
||||||
|
|
||||||
|
## Operator Surface Contract *(mandatory when operator-facing surfaces are changed)*
|
||||||
|
|
||||||
|
| Surface | Primary Persona | Surface Type | Primary Operator Question | Default-visible Information | Diagnostics-only Information | Status Dimensions Used | Mutation Scope | Primary Actions | Dangerous Actions |
|
||||||
|
|---|---|---|---|---|---|---|---|---|---|
|
||||||
|
| Workspace overview page | Workspace operator | Workspace landing page | Which tenants need attention first, and is this workspace actually calm or only operationally quiet? | Workspace identity, governance-aware summary stats, prioritized attention, and recent activity as supporting context | Deep run metadata, raw compare internals, and low-level workflow detail remain on downstream pages | governance attention, compare posture, findings pressure, evidence or review trust where already available, operations activity | none | Choose tenant, open an attention item destination, open operations | none |
|
||||||
|
| Workspace `Needs Attention` | Workspace operator | Embedded triage summary | Which tenant needs attention now, why, and where should I jump? | Tenant label, problem family, urgency, and one clear next destination | Low-level evidence payloads, verbose status histories, and raw query context remain secondary | governance issue, findings issue, compare issue, evidence or review issue, operations issue | none | Open the matching tenant dashboard, findings list, compare landing, evidence or review surface, or operation detail | none |
|
||||||
|
| Workspace summary stats | Workspace operator | Embedded status summary / drill-in surface | How much of this workspace is active, and how much of it is governance risk? | Small set of trustworthy counts with explicit governance versus activity meaning | Full tenant-by-tenant breakdown and historical trends remain outside the stat strip | governance attention volume, activity volume | none | Open the matching aggregate destination or tenant entry point | none |
|
||||||
|
| Workspace recent operations | Workspace operator | Embedded diagnostic recency surface | What has run recently if I need execution context? | Recent operations with tenant label, status, outcome, and recency | Full operation timeline, payload diagnostics, and longer history remain in operations surfaces | recency, execution status, execution outcome | none | Open the selected operation detail | none |
|
||||||
|
|
||||||
|
## Proportionality Review *(mandatory when structural complexity is introduced)*
|
||||||
|
|
||||||
|
- **New source of truth?**: No. Existing tenant governance, findings, compare, evidence, review, operations, and alert truth remain authoritative.
|
||||||
|
- **New persisted entity/table/artifact?**: No. This feature explicitly avoids a new workspace posture record or aggregate table.
|
||||||
|
- **New abstraction?**: No. The narrow change is to tighten workspace aggregation and presentation semantics on existing surfaces.
|
||||||
|
- **New enum/state/reason family?**: No. Existing governance, compare, findings, and operations state families remain authoritative.
|
||||||
|
- **New cross-domain UI framework/taxonomy?**: No. The feature aligns existing workspace and tenant surfaces without creating a new posture framework or score system.
|
||||||
|
- **Current operator problem**: The workspace home can look calm while visible tenants already have governance-critical conditions, which makes the portfolio entry point weaker than the tenant truth operators already rely on.
|
||||||
|
- **Existing structure is insufficient because**: The current workspace overview summarizes operations and alert activity well enough, but it does not promote tenant governance truth strongly enough to stop false calmness or answer which tenant to open first.
|
||||||
|
- **Narrowest correct implementation**: Reuse existing tenant governance, findings, compare, and already-available evidence or review truth to harden workspace stats, attention, and empty-state semantics on the current workspace home.
|
||||||
|
- **Ownership cost**: The repo takes on stricter workspace aggregation rules, a modest amount of copy tightening, and regression coverage for priority order, calmness suppression, and drill-through continuity.
|
||||||
|
- **Alternative intentionally rejected**: A full portfolio matrix, a workspace posture score, a major workspace redesign, or a new persisted posture model was rejected because the immediate gap is truth propagation on the existing workspace entry point.
|
||||||
|
- **Release truth**: Current-release truth. The semantic risk already exists on the shipped workspace overview.
|
||||||
|
|
||||||
|
## User Scenarios & Testing *(mandatory)*
|
||||||
|
|
||||||
|
### User Story 1 - See The Right Tenant First (Priority: P1)
|
||||||
|
|
||||||
|
As a workspace operator, I want the workspace home to surface governance-critical tenants before operational noise so that I can start triage from the riskiest visible tenant instead of from the most recent activity.
|
||||||
|
|
||||||
|
**Why this priority**: The feature only delivers value if the workspace entry point stops feeling quieter than the visible tenant truth.
|
||||||
|
|
||||||
|
**Independent Test**: Can be fully tested by seeding multiple visible tenants with mixes of overdue findings, lapsed governance, stale, failed, or materially degraded compare posture, high-severity active findings, alert failures, and active operations, then verifying that workspace attention promotes governance-critical tenants first.
|
||||||
|
|
||||||
|
**Acceptance Scenarios**:
|
||||||
|
|
||||||
|
1. **Given** one visible tenant has lapsed governance and another only has active operations, **When** the operator opens `/admin`, **Then** the governance-critical tenant is surfaced as higher-priority workspace attention.
|
||||||
|
2. **Given** a visible tenant has overdue findings while operations and alerts look healthy, **When** the workspace home loads, **Then** the page still presents governance attention instead of an all-clear impression.
|
||||||
|
3. **Given** a visible tenant has stale, failed, or materially degraded compare posture but little recent activity, **When** the workspace home loads, **Then** compare risk remains visible on the workspace entry point.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### User Story 2 - Distinguish Risk From Activity (Priority: P1)
|
||||||
|
|
||||||
|
As a workspace operator, I want the workspace home to separate governance risk from operations activity so that I can tell whether the portfolio is risky, merely busy, or genuinely calm.
|
||||||
|
|
||||||
|
**Why this priority**: A workspace overview that mixes posture and activity cannot support MSP triage with confidence.
|
||||||
|
|
||||||
|
**Independent Test**: Can be fully tested by rendering the workspace home in scenarios that contain only activity, only governance risk, both, or neither, then verifying that stats, attention, and empty states describe those cases differently.
|
||||||
|
|
||||||
|
**Acceptance Scenarios**:
|
||||||
|
|
||||||
|
1. **Given** the workspace has active operations but no visible governance-critical tenant states, **When** the overview renders, **Then** activity appears without governance-risk wording.
|
||||||
|
2. **Given** the workspace has no failed runs or alert failures but visible overdue findings exist, **When** the overview renders, **Then** the page does not present a calm empty state.
|
||||||
|
3. **Given** the workspace has no visible governance-critical states and no unusual operations issues, **When** the overview renders, **Then** calm messaging may appear and must match the covered truth domains.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### User Story 3 - Jump Into The Right Tenant Surface (Priority: P2)
|
||||||
|
|
||||||
|
As a workspace operator, I want each attention item to carry tenant identity and a trustworthy next jump so that I can recover the same problem family immediately on a tenant-level working surface.
|
||||||
|
|
||||||
|
**Why this priority**: Workspace attention is only trustworthy if the operator can click it and rediscover the same tenant problem without guesswork.
|
||||||
|
|
||||||
|
**Independent Test**: Can be fully tested by seeding representative workspace attention cases and verifying that each item names the tenant, names the problem family, and lands on the matching tenant dashboard, findings, compare, evidence, review, or operation destination.
|
||||||
|
|
||||||
|
**Acceptance Scenarios**:
|
||||||
|
|
||||||
|
1. **Given** a workspace attention item represents overdue findings for one visible tenant, **When** the operator opens it, **Then** the destination is that tenant's findings surface or a precise allowed fallback that preserves the same problem meaning.
|
||||||
|
2. **Given** a workspace attention item represents stale, failed, or materially degraded compare posture, **When** the operator opens it, **Then** the destination is the same tenant's baseline compare landing.
|
||||||
|
3. **Given** the operator can see a tenant on the workspace home but lacks a precise downstream capability, **When** the attention surface renders, **Then** it uses an allowed fallback or a disabled non-clickable state instead of a dead-end drill-through.
|
||||||
|
|
||||||
|
### Edge Cases
|
||||||
|
|
||||||
|
- A single visible tenant may have multiple governance-critical conditions at the same time; the workspace home must stay bounded while still exposing the tenant identity and the highest-priority next action.
|
||||||
|
- A workspace may be operationally quiet while one or more visible tenants still have overdue findings, lapsed governance, high-severity active findings, or stale, failed, or materially degraded compare posture; calm messaging must stay suppressed in that case.
|
||||||
|
- A workspace may be busy but healthy from a governance perspective; governance-risk stats and attention must remain calm even when recent operations are active.
|
||||||
|
- Some visible tenants may contribute only through a broader tenant dashboard drill-through because the operator lacks a more specific downstream capability; the workspace home must not create dead-end links or authorization surprises.
|
||||||
|
- A user may belong to the workspace but have zero accessible tenants; the zero-tenant state must remain distinct from a healthy calm state and must still provide one valid next step.
|
||||||
|
- Evidence or review truth may already indicate attention for a visible tenant even when operations are quiet; if that truth is already surfaced on tenant-level pages, the workspace home should be able to promote it without inventing a parallel status language.
|
||||||
|
|
||||||
|
## Requirements *(mandatory)*
|
||||||
|
|
||||||
|
**Constitution alignment (required):** This feature introduces no new Microsoft Graph call, no new write workflow, and no new queued or scheduled operation. It hardens the workspace home by promoting already-existing tenant-level truth into existing workspace surfaces.
|
||||||
|
|
||||||
|
**Constitution alignment (PROP-001 / ABSTR-001 / PERSIST-001 / STATE-001 / BLOAT-001):** This feature is intentionally narrow. It adds no new persistence, no new abstraction layer, no new status family, and no new posture framework. The required change is truthful workspace aggregation over existing tenant truth.
|
||||||
|
|
||||||
|
**Constitution alignment (OPS-UX):** No new `OperationRun` type, progress surface, or execution path is introduced. Existing operations surfaces remain the sole execution-truth surfaces. This slice only changes how the workspace home summarizes and points to those existing operations.
|
||||||
|
|
||||||
|
**Constitution alignment (RBAC-UX):** The feature lives in the admin workspace plane at `/admin` with drill-through into tenant-context or canonical admin destinations. Non-members or out-of-scope actors remain `404`. In-scope members lacking a downstream capability remain governed by existing server-side authorization on that destination. Workspace aggregation must stay capability-safe and tenant-safe: hidden tenants must not influence visible counts or wording, and attention items must not become clickable dead ends. If the semantically correct destination is not authorized, the item may only remain if an allowed fallback still preserves tenant and problem meaning. No destructive action is introduced.
|
||||||
|
|
||||||
|
**Constitution alignment (OPS-EX-AUTH-001):** Not applicable. No authentication-handshake behavior is changed.
|
||||||
|
|
||||||
|
**Constitution alignment (BADGE-001):** Existing centralized badge or tone semantics for findings severity, compare posture, and operations status or outcome remain the semantic source. This feature may expose those existing meanings at workspace level, but it must not invent page-local badge vocabularies for calmness, risk, or urgency.
|
||||||
|
|
||||||
|
**Constitution alignment (UI-FIL-001):** The feature reuses the existing Filament page and workspace widgets, existing stat cards, and existing shared status primitives. It should harden semantics through aligned copy, ordering, and drill-through meaning rather than through custom local status markup or a new widget family.
|
||||||
|
|
||||||
|
**Constitution alignment (UI-NAMING-001):** Operator-facing vocabulary must keep `Governance`, `Findings`, `Baseline Compare`, `Evidence`, `Reviews`, `Operations`, and `Needs attention` distinct. Calm wording must not imply portfolio health when the page only proves operational quietness.
|
||||||
|
|
||||||
|
**Constitution alignment (UI-CONST-001 / UI-SURF-001 / UI-HARD-001 / UI-EX-001 / UI-REVIEW-001):** The workspace overview remains the canonical workspace landing surface. `WorkspaceNeedsAttention` is the primary workspace triage surface. `WorkspaceSummaryStats` is a supporting summary strip and must separate risk from activity. `WorkspaceRecentOperations` remains diagnostic recency, not posture. Each affected surface must keep one primary inspect or open model and must not add redundant affordances.
|
||||||
|
|
||||||
|
**Constitution alignment (OPSURF-001):** Default-visible content on `/admin` must stay operator-first. Workspace identity, governance attention, and the distinction between risk and activity must remain visible before any diagnostic detail. Mutation scope remains none for all affected surfaces.
|
||||||
|
|
||||||
|
**Constitution alignment (UI-SEM-001 / LAYER-001 / TEST-TRUTH-001):** The feature must not create a new workspace posture layer, presenter framework, or persisted summary. It should instead align workspace semantics directly on top of existing tenant governance, findings, compare, evidence, review, and operations truth. Tests must protect business consequences such as false calmness, missing tenant labels, or broken drill-through continuity.
|
||||||
|
|
||||||
|
**Constitution alignment (Filament Action Surfaces):** The Action Surface Contract remains satisfied. `WorkspaceOverview` and its widgets remain inspection and drill-through surfaces with no destructive actions, no empty action groups, and no redundant `View` actions. Embedded widgets are exempt from list-action conventions where they are not tables, but each still needs one primary open model.
|
||||||
|
|
||||||
|
**Constitution alignment (UX-001 — Layout & Information Architecture):** This feature does not add create or edit screens. It refines the existing workspace landing page. Governance attention must stay above recent operations context, and empty states must use specific, bounded wording plus exactly one next step instead of broad health claims.
|
||||||
|
|
||||||
|
### Functional Requirements
|
||||||
|
|
||||||
|
- **FR-175-001**: The workspace home MUST become governance-aware rather than remaining a purely operations and alerts summary.
|
||||||
|
- **FR-175-002**: Workspace attention MUST be able to surface, when present within visible scope, at least lapsed governance, overdue findings, high-severity active findings, and stale, failed, or materially degraded compare posture.
|
||||||
|
- **FR-175-003**: Workspace attention SHOULD also be able to promote already-available evidence or review truth when that truth is more actionable than a generic tenant landing.
|
||||||
|
- **FR-175-004**: Every workspace attention item MUST clearly identify the tenant, the problem family, the urgency or relevance, and one clear next jump.
|
||||||
|
- **FR-175-005**: Workspace attention MUST semantically distinguish governance issues, findings issues, compare posture issues, evidence or review issues, and operations issues rather than flattening them into one generic alert stream.
|
||||||
|
- **FR-175-006**: Governance-critical tenant states MUST rank at least as high as, and ordinarily above, active-operations-only or alert-delivery-only items in workspace attention ordering.
|
||||||
|
- **FR-175-007**: The workspace home MUST NOT appear calmer or healthier than the worst visible tenant condition covered by its workspace attention and summary surfaces.
|
||||||
|
- **FR-175-008**: Calm or empty states on the workspace home MUST only claim calmness across the signal families that were actually checked and MUST NOT imply full portfolio health merely because operations or alerts are quiet.
|
||||||
|
- **FR-175-009**: Workspace summary stats MUST include at least one real governance-risk metric such as tenants needing governance attention, tenants with overdue findings, tenants with lapsed governance, or tenants with stale, failed, or materially degraded compare posture.
|
||||||
|
- **FR-175-010**: Workspace summary stats MUST clearly separate governance attention from activity or volume so an operator can tell whether the portfolio is risky, busy, both, or neither.
|
||||||
|
- **FR-175-011**: Every workspace attention item MUST resolve to one semantically matching destination surface such as the tenant dashboard, tenant findings, tenant baseline compare, tenant evidence, tenant reviews, or canonical operations detail.
|
||||||
|
- **FR-175-012**: When the exact matching destination is not authorized for the current in-scope user, the workspace home MUST use an allowed fallback that preserves tenant and problem meaning or suppress the clickable affordance rather than exposing a dead-end drill-through.
|
||||||
|
- **FR-175-013**: Workspace aggregation MUST reuse existing tenant governance aggregate, findings workflow and governance validity truth, compare assessment, and already-shipped evidence or review truth rather than introducing a weaker parallel workspace-only interpretation.
|
||||||
|
- **FR-175-014**: `WorkspaceRecentOperations` MUST remain an activity and recency surface and MUST NOT be treated as the workspace's primary posture or governance summary.
|
||||||
|
- **FR-175-015**: Zero-tenant and low-permission workspace states MUST remain distinct from calm or healthy portfolio states and MUST still provide one valid next action. Zero-tenant recovery MUST default to `Switch workspace`, and low-permission workspace-state recovery MUST default to `Open operations` unless a more specific allowed in-scope recovery action exists.
|
||||||
|
- **FR-175-016**: The feature MUST be achievable without a new table, new persisted workspace summary model, or heavy pre-aggregated materialized view.
|
||||||
|
- **FR-175-017**: Regression coverage MUST verify governance-attention promotion, priority order, calmness suppression, tenant identification, governance-versus-operations separation, drill-through continuity, RBAC-safe omission or fallback behavior, DB-only query-bounded render behavior, and the absence of schema requirements.
|
||||||
|
|
||||||
|
## 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 |
|
||||||
|
|---|---|---|---|---|---|---|---|---|---|---|
|
||||||
|
| Workspace overview page | `app/Filament/Pages/WorkspaceOverview.php` | Existing page chrome only; no new page-header action required | n/a | n/a | none | One bounded CTA per empty state, aligned to the specific empty condition | n/a | n/a | no new audit behavior | Singleton landing surface whose main job is truthful orientation, not mutation |
|
||||||
|
| `WorkspaceSummaryStats` | `app/Filament/Widgets/Workspace/WorkspaceSummaryStats.php` | none | Explicit stat click only when the metric has a matching actionable destination | none | none | Zero-value reassurance remains intentionally passive unless a specific next step is required | n/a | n/a | no new audit behavior | Stat meanings must stay honest about whether they represent risk or activity |
|
||||||
|
| `WorkspaceNeedsAttention` | `app/Filament/Widgets/Workspace/WorkspaceNeedsAttention.php` | none | One explicit destination or one full-item primary open model per attention item | none | none | Healthy fallback remains read-only reassurance only when no covered attention condition exists | n/a | n/a | no new audit behavior | Multi-destination triage widget; no destructive action and no redundant secondary action model |
|
||||||
|
| `WorkspaceRecentOperations` | `app/Filament/Widgets/Workspace/WorkspaceRecentOperations.php` | none | Each operation row or card opens canonical operation detail | none | none | Existing empty state remains diagnostic and must not imply broader portfolio health | n/a | n/a | no new audit behavior | Diagnostic recency surface; keeps operations context visible without redefining workspace posture |
|
||||||
|
|
||||||
|
### Key Entities *(include if feature involves data)*
|
||||||
|
|
||||||
|
- **Visible tenant governance state**: The already-derived tenant condition that tells whether a visible tenant currently carries overdue findings, lapsed or expiring governance, stale, failed, or materially degraded compare posture, high-severity active findings, or already-surfaced evidence or review attention.
|
||||||
|
- **Workspace governance attention item**: A bounded workspace-level triage item that promotes one visible tenant problem into the workspace home with tenant identity, problem family, urgency, and a next jump.
|
||||||
|
- **Workspace governance metric**: A workspace-level count that describes how many visible tenants currently require governance attention, independent from operations volume.
|
||||||
|
- **Workspace calmness claim**: Any workspace wording or empty state that implies the portfolio is calm; it is only valid when the covered governance and activity signals are genuinely calm within visible scope.
|
||||||
|
- **Workspace drill-through contract**: The semantic promise that a workspace stat or attention item can be rediscovered on the surface it opens without losing tenant identity or problem meaning.
|
||||||
|
|
||||||
|
## Success Criteria *(mandatory)*
|
||||||
|
|
||||||
|
### Measurable Outcomes
|
||||||
|
|
||||||
|
- **SC-175-001**: In regression coverage, every seeded workspace scenario containing at least one visible tenant with lapsed governance, overdue findings, high-severity active findings, or stale, failed, or materially degraded compare posture produces at least one governance attention signal on the workspace home and no calm empty state.
|
||||||
|
- **SC-175-002**: In regression coverage, 100% of covered workspace attention items display tenant context, problem-family context, and either a matching destination or an allowed disabled or fallback state when the exact destination is not authorized.
|
||||||
|
- **SC-175-003**: In seeded operator review, an operator can identify within 10 seconds which tenant to open first, why that tenant needs attention, and whether the issue is governance or operations.
|
||||||
|
- **SC-175-004**: In regression coverage, operations-only activity scenarios do not trigger governance-risk wording, and governance-critical tenant scenarios are not ranked beneath pure activity-only or alert-delivery-only items.
|
||||||
|
- **SC-175-005**: The feature ships without a required schema migration, a new persisted workspace posture model, or a required materialized aggregate view.
|
||||||
|
- **SC-175-006**: In regression coverage, a dedicated DB-only workspace overview test proves render-time aggregation stays query-bounded for representative visible-tenant scenarios and does not rely on uncontrolled polling or unbounded per-tenant query fanout.
|
||||||
|
|
||||||
|
## Assumptions
|
||||||
|
|
||||||
|
- Existing tenant governance aggregate, findings workflow state, compare assessment, and already-available evidence or review truth are sufficient to harden the workspace home without inventing a new posture system.
|
||||||
|
- Existing tenant dashboard, findings, baseline compare, evidence, review, and operations surfaces remain the correct downstream destinations for workspace drill-through.
|
||||||
|
- The current workspace overview page structure remains in place for this slice; the work changes truth ordering and meaning, not the overall workspace information architecture.
|
||||||
|
|
||||||
|
## Non-Goals
|
||||||
|
|
||||||
|
- Building a full portfolio matrix or tenant grid
|
||||||
|
- Introducing a global workspace posture score, grade, or traffic-light system
|
||||||
|
- Redesigning the entire workspace overview layout
|
||||||
|
- Adding choose-tenant posture annotations
|
||||||
|
- Creating new persistence, materialized views, or a new workspace summary artifact
|
||||||
|
- Extending the work into cross-workspace or platform-wide operator surfaces
|
||||||
|
|
||||||
|
## Dependencies
|
||||||
|
|
||||||
|
- Existing workspace overview page and workspace widgets
|
||||||
|
- Existing tenant governance truth, compare assessment, findings governance state, and evidence or review trust work
|
||||||
|
- Existing tenant dashboard, findings, compare, evidence, review, and operations destinations used as canonical drill-through surfaces
|
||||||
|
- Existing workspace and tenant RBAC plus capability-safe query scoping
|
||||||
|
|
||||||
|
## Follow-up Spec Candidates
|
||||||
|
|
||||||
|
- **Spec 176 — Workspace Portfolio Posture Surface** for per-tenant posture summaries, risk concentration, and a first true portfolio matrix
|
||||||
|
- **Spec 177 — Choose Tenant Posture Annotation** for posture-aware tenant selection and faster triage from tenant pickers
|
||||||
|
- **Spec 178 — Workspace Portfolio Stats Redesign** for a broader governance-oriented workspace stat model that goes beyond this foundation slice
|
||||||
|
|
||||||
|
## Definition of Done
|
||||||
|
|
||||||
|
Spec 175 is complete when:
|
||||||
|
|
||||||
|
- workspace attention is no longer purely operations-centered,
|
||||||
|
- governance-relevant tenant problems are visible on the workspace home,
|
||||||
|
- workspace calmness no longer reads healthier than the worst visible tenant conditions,
|
||||||
|
- attention items carry tenant identity, problem type, and a meaningful drill-through,
|
||||||
|
- workspace summary stats include at least one real governance-risk metric and clearly separate risk from activity,
|
||||||
|
- and the improvement ships without a new persistence structure or a full workspace redesign.
|
||||||
207
specs/175-workspace-governance-attention/tasks.md
Normal file
207
specs/175-workspace-governance-attention/tasks.md
Normal file
@ -0,0 +1,207 @@
|
|||||||
|
# Tasks: Workspace Governance Attention Foundation
|
||||||
|
|
||||||
|
**Input**: Design documents from `/specs/175-workspace-governance-attention/` (`spec.md`, `plan.md`, `research.md`, `data-model.md`, `contracts/`, `quickstart.md`)
|
||||||
|
**Prerequisites**: `/specs/175-workspace-governance-attention/plan.md` (required), `/specs/175-workspace-governance-attention/spec.md` (required for user stories)
|
||||||
|
|
||||||
|
**Tests**: REQUIRED (Pest) for all runtime behavior changes in this repo. Use focused workspace overview coverage in `tests/Feature/Filament/WorkspaceOverviewAccessTest.php`, `tests/Feature/Filament/WorkspaceOverviewAuthorizationTest.php`, `tests/Feature/Filament/WorkspaceOverviewLandingTest.php`, `tests/Feature/Filament/WorkspaceOverviewNavigationTest.php`, `tests/Feature/Filament/WorkspaceOverviewContentTest.php`, `tests/Feature/Filament/WorkspaceOverviewEmptyStatesTest.php`, `tests/Feature/Filament/WorkspaceOverviewPermissionVisibilityTest.php`, `tests/Feature/Filament/WorkspaceOverviewOperationsTest.php`, `tests/Feature/Filament/WorkspaceOverviewGovernanceAttentionTest.php`, `tests/Feature/Filament/WorkspaceOverviewSummaryMetricsTest.php`, `tests/Feature/Filament/WorkspaceOverviewDrilldownContinuityTest.php`, and `tests/Feature/Filament/WorkspaceOverviewDbOnlyTest.php`.
|
||||||
|
**Operations**: This feature does not create a new `OperationRun` type or change operations lifecycle ownership. Existing canonical Operations routes remain the only operations destinations involved, and the work here is limited to truthful workspace aggregation and destination continuity.
|
||||||
|
**RBAC**: Preserve workspace membership enforcement on `/admin`, deny-as-not-found `404` for non-members or out-of-scope tenants, capability-safe fallback or disabled states for drill-through items, and visible-tenant-only aggregation.
|
||||||
|
**Operator Surfaces**: `WorkspaceOverview`, `WorkspaceSummaryStats`, `WorkspaceNeedsAttention`, and `WorkspaceRecentOperations` must stay operator-first, with governance truth above recency context and no dead-end navigation.
|
||||||
|
**Filament UI Action Surfaces**: No destructive actions or redundant inspect affordances are added. `WorkspaceSummaryStats` and `WorkspaceNeedsAttention` remain drill-through summary surfaces, and `WorkspaceRecentOperations` remains a row-open diagnostic surface.
|
||||||
|
**Filament UI UX-001**: No new create, edit, or view pages are introduced. Existing workspace landing layout remains in place while semantics, ordering, and empty-state wording are hardened.
|
||||||
|
**Badges**: Existing badge semantics for findings severity, compare posture, governance validity, operations status, and operations outcome remain authoritative; no new page-local badge vocabulary is introduced.
|
||||||
|
|
||||||
|
**Organization**: Tasks are grouped by user story so each story can be implemented and verified as an independent increment.
|
||||||
|
|
||||||
|
## Phase 1: Setup (Context And Existing Surface Review)
|
||||||
|
|
||||||
|
**Purpose**: Reconfirm the exact workspace overview seams, tenant truth sources, and canonical destinations before changing `/admin` semantics.
|
||||||
|
|
||||||
|
- [X] T001 Review current workspace overview composition in `app/Support/Workspaces/WorkspaceOverviewBuilder.php`, `app/Filament/Pages/WorkspaceOverview.php`, `app/Filament/Widgets/Workspace/WorkspaceSummaryStats.php`, `app/Filament/Widgets/Workspace/WorkspaceNeedsAttention.php`, and `app/Filament/Widgets/Workspace/WorkspaceRecentOperations.php`
|
||||||
|
- [X] T002 [P] Review existing tenant governance and compare truth sources in `app/Support/Baselines/TenantGovernanceAggregateResolver.php`, `app/Support/Baselines/TenantGovernanceAggregate.php`, `app/Support/Baselines/BaselineCompareStats.php`, `app/Support/Baselines/BaselineCompareSummaryAssessor.php`, and `app/Filament/Widgets/Dashboard/NeedsAttention.php`
|
||||||
|
- [X] T003 [P] Review canonical drill-through destinations and current workspace overview regression seams in `app/Filament/Pages/TenantDashboard.php`, `app/Filament/Pages/BaselineCompareLanding.php`, `app/Filament/Resources/FindingResource/Pages/ListFindings.php`, `app/Filament/Pages/Monitoring/Operations.php`, and `tests/Feature/Filament/WorkspaceOverview*.php`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 2: Foundational (Blocking Payload And Continuity Seams)
|
||||||
|
|
||||||
|
**Purpose**: Establish the shared workspace payload and continuity helpers that every user story depends on.
|
||||||
|
|
||||||
|
**⚠️ CRITICAL**: No user story work should begin until this phase is complete.
|
||||||
|
|
||||||
|
- [X] T004 Create governance-attention and performance regression scaffolding in `tests/Feature/Filament/WorkspaceOverviewGovernanceAttentionTest.php`, `tests/Feature/Filament/WorkspaceOverviewSummaryMetricsTest.php`, `tests/Feature/Filament/WorkspaceOverviewDrilldownContinuityTest.php`, and `tests/Feature/Filament/WorkspaceOverviewDbOnlyTest.php`
|
||||||
|
- [X] T005 Extend the shared workspace overview payload to match `specs/175-workspace-governance-attention/contracts/workspace-governance-attention.openapi.yaml` for metric categories, calmness state, zero-tenant recovery, and structured attention destinations in `app/Support/Workspaces/WorkspaceOverviewBuilder.php`
|
||||||
|
- [X] T006 [P] Prepare canonical findings-subset, alerts-overview, and operations-continuity seams for workspace-originated drill-through in `app/Filament/Resources/FindingResource/Pages/ListFindings.php`, `app/Filament/Pages/Monitoring/Alerts.php`, and `app/Support/OperationRunLinks.php`
|
||||||
|
- [X] T007 [P] Add shared authorization, visibility, zero-tenant recovery, and DB-only query-bounded assertions for workspace governance destinations in `tests/Feature/Filament/WorkspaceOverviewAuthorizationTest.php`, `tests/Feature/Filament/WorkspaceOverviewPermissionVisibilityTest.php`, `tests/Feature/Filament/WorkspaceOverviewEmptyStatesTest.php`, and `tests/Feature/Filament/WorkspaceOverviewDbOnlyTest.php`
|
||||||
|
|
||||||
|
**Checkpoint**: The builder exposes the shared payload shape, the destination seams are ready, and the new regression files exist.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 3: User Story 1 - See The Right Tenant First (Priority: P1) 🎯 MVP
|
||||||
|
|
||||||
|
**Goal**: Make `/admin` surface governance-critical tenants ahead of operational noise so the riskiest visible tenant is obvious first.
|
||||||
|
|
||||||
|
**Independent Test**: Seed multiple visible tenants with overdue findings, lapsed governance, expiring governance, stale, failed, or materially degraded compare posture, high-severity active findings, alerts, and operations, then verify governance-critical tenants rank above activity-only and alert-only items and suppress false calmness.
|
||||||
|
|
||||||
|
### Tests for User Story 1
|
||||||
|
|
||||||
|
- [X] T008 [P] [US1] Add governance-ranking scenarios for overdue findings, lapsed governance, expiring governance, high-severity active findings, stale, failed, or materially degraded compare posture, and alert-only supporting items in `tests/Feature/Filament/WorkspaceOverviewGovernanceAttentionTest.php`
|
||||||
|
- [X] T009 [P] [US1] Add false-calmness and zero-tenant distinctness scenarios for quiet operations but risky governance in `tests/Feature/Filament/WorkspaceOverviewEmptyStatesTest.php` and `tests/Feature/Filament/WorkspaceOverviewContentTest.php`
|
||||||
|
|
||||||
|
### Implementation for User Story 1
|
||||||
|
|
||||||
|
- [X] T010 [US1] Promote visible-tenant governance aggregate states into bounded governance-first attention candidates, including expiring governance, stale, failed, or materially degraded compare posture, and lower-priority alert-only supporting items when they can be attributed to one visible tenant, in `app/Support/Workspaces/WorkspaceOverviewBuilder.php`
|
||||||
|
- [X] T011 [US1] Render tenant label, problem family, urgency, and governance-first ordering in `app/Filament/Widgets/Workspace/WorkspaceNeedsAttention.php` and `resources/views/filament/widgets/workspace/workspace-needs-attention.blade.php`
|
||||||
|
- [X] T012 [US1] Align workspace landing copy and calmness framing so operations-only quiet never implies portfolio health in `app/Filament/Pages/WorkspaceOverview.php` and `resources/views/filament/pages/workspace-overview.blade.php`
|
||||||
|
- [X] T013 [US1] Run focused US1 verification against `tests/Feature/Filament/WorkspaceOverviewGovernanceAttentionTest.php`, `tests/Feature/Filament/WorkspaceOverviewEmptyStatesTest.php`, and `tests/Feature/Filament/WorkspaceOverviewContentTest.php`
|
||||||
|
|
||||||
|
**Checkpoint**: The workspace home no longer looks calmer than the worst visible tenant governance state.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 4: User Story 2 - Distinguish Risk From Activity (Priority: P1)
|
||||||
|
|
||||||
|
**Goal**: Make the workspace home clearly separate governance risk from operational activity so the portfolio can be read as risky, busy, both, or calm.
|
||||||
|
|
||||||
|
**Independent Test**: Render `/admin` in governance-only, activity-only, mixed, and healthy scenarios, then verify metrics, attention, and empty states describe those cases differently and truthfully.
|
||||||
|
|
||||||
|
### Tests for User Story 2
|
||||||
|
|
||||||
|
- [X] T014 [P] [US2] Add governance-risk-versus-activity metric scenarios, including expiring governance and stale, failed, or materially degraded compare posture counts, in `tests/Feature/Filament/WorkspaceOverviewSummaryMetricsTest.php` and `tests/Feature/Filament/WorkspaceOverviewContentTest.php`
|
||||||
|
- [X] T015 [P] [US2] Add operations-only, risk-only, mixed, healthy-state, and zero-tenant recovery scenarios in `tests/Feature/Filament/WorkspaceOverviewOperationsTest.php` and `tests/Feature/Filament/WorkspaceOverviewEmptyStatesTest.php`
|
||||||
|
|
||||||
|
### Implementation for User Story 2
|
||||||
|
|
||||||
|
- [X] T016 [US2] Split workspace summary metrics into scope, governance-risk, activity, and alert categories, preserving expiring governance and stale, failed, or materially degraded compare posture as governance-risk inputs, in `app/Support/Workspaces/WorkspaceOverviewBuilder.php`
|
||||||
|
- [X] T017 [US2] Update stat-card labels, descriptions, and destination semantics for governance-risk versus activity counts in `app/Filament/Widgets/Workspace/WorkspaceSummaryStats.php` and `resources/views/filament/pages/workspace-overview.blade.php`
|
||||||
|
- [X] T018 [US2] Keep recent operations diagnostic-only and remove its ability to define calmness on its own in `app/Filament/Widgets/Workspace/WorkspaceRecentOperations.php`, `resources/views/filament/widgets/workspace/workspace-recent-operations.blade.php`, and `app/Support/Workspaces/WorkspaceOverviewBuilder.php`
|
||||||
|
- [X] T019 [US2] Run focused US2 verification against `tests/Feature/Filament/WorkspaceOverviewSummaryMetricsTest.php`, `tests/Feature/Filament/WorkspaceOverviewOperationsTest.php`, `tests/Feature/Filament/WorkspaceOverviewEmptyStatesTest.php`, and `tests/Feature/Filament/WorkspaceOverviewDbOnlyTest.php`
|
||||||
|
|
||||||
|
**Checkpoint**: The summary strip and surrounding copy now distinguish portfolio risk from portfolio activity.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 5: User Story 3 - Jump Into The Right Tenant Surface (Priority: P2)
|
||||||
|
|
||||||
|
**Goal**: Make each attention item identify the tenant and open a trustworthy next surface for the same problem family.
|
||||||
|
|
||||||
|
**Independent Test**: Seed representative findings, stale, failed, or materially degraded compare, evidence, review, alert, and operations cases, then verify each workspace attention item preserves tenant identity and reaches the correct destination or a safe fallback or disabled state.
|
||||||
|
|
||||||
|
### Tests for User Story 3
|
||||||
|
|
||||||
|
- [X] T020 [P] [US3] Add drill-through continuity coverage for tenant dashboard, findings, stale, failed, or materially degraded compare posture, evidence, review, alerts overview, and operations destinations in `tests/Feature/Filament/WorkspaceOverviewDrilldownContinuityTest.php`
|
||||||
|
- [X] T021 [P] [US3] Add capability-limited fallback, non-clickable state, zero-tenant choose-workspace recovery, and low-permission operations fallback coverage in `tests/Feature/Filament/WorkspaceOverviewPermissionVisibilityTest.php` and `tests/Feature/Filament/WorkspaceOverviewAuthorizationTest.php`
|
||||||
|
|
||||||
|
### Implementation for User Story 3
|
||||||
|
|
||||||
|
- [X] T022 [US3] Implement per-family primary destination selection with tenant-safe fallback or disabled states, explicit alerts-overview routing, `switch_workspace` as the zero-tenant default next action, `operations_index` as the low-permission workspace-state fallback, aggregate lapsed-governance fallback to the tenant dashboard when findings filters would narrow the invalid-governance family, and tenant-scope authorization checks through `app/Services/Auth/CapabilityResolver.php` alongside `app/Services/Auth/WorkspaceCapabilityResolver.php` in `app/Support/Workspaces/WorkspaceOverviewBuilder.php`
|
||||||
|
- [X] T023 [US3] Wire primary actions and helper text for workspace attention items across findings, compare, evidence, reviews, alerts, and operations, keeping every promoted item tenant-identified, in `app/Filament/Widgets/Workspace/WorkspaceNeedsAttention.php`, `resources/views/filament/widgets/workspace/workspace-needs-attention.blade.php`, and `app/Filament/Pages/WorkspaceOverview.php`
|
||||||
|
- [X] T024 [US3] Preserve canonical subset continuity for workspace-originated findings and operations drill-throughs, and route aggregate lapsed-governance attention through the tenant dashboard when a findings filter would otherwise narrow the invalid-governance family, in `app/Filament/Resources/FindingResource/Pages/ListFindings.php`, `app/Filament/Pages/Monitoring/Operations.php`, and `app/Support/Workspaces/WorkspaceOverviewBuilder.php`
|
||||||
|
- [X] T025 [US3] Run focused US3 verification against `tests/Feature/Filament/WorkspaceOverviewDrilldownContinuityTest.php`, `tests/Feature/Filament/WorkspaceOverviewPermissionVisibilityTest.php`, and `tests/Feature/Filament/WorkspaceOverviewAuthorizationTest.php`
|
||||||
|
|
||||||
|
**Checkpoint**: Every central attention family now opens the correct tenant surface or a safe, non-deceptive fallback state.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 6: Polish & Cross-Cutting Concerns
|
||||||
|
|
||||||
|
**Purpose**: Finish copy alignment, formatting, and the final focused verification pack across all stories.
|
||||||
|
|
||||||
|
- [X] T026 [P] Align final operator copy, urgency labels, disabled helper text, zero-tenant recovery wording, and low-permission `Open operations` fallback wording across `app/Filament/Pages/WorkspaceOverview.php`, `app/Filament/Widgets/Workspace/WorkspaceSummaryStats.php`, `app/Filament/Widgets/Workspace/WorkspaceNeedsAttention.php`, `resources/views/filament/pages/workspace-overview.blade.php`, and `resources/views/filament/widgets/workspace/workspace-needs-attention.blade.php`
|
||||||
|
- [X] T027 Run formatting with `vendor/bin/sail bin pint --dirty --format agent` for `app/Support/Workspaces/WorkspaceOverviewBuilder.php`, `app/Filament/Pages/WorkspaceOverview.php`, `app/Filament/Widgets/Workspace/WorkspaceSummaryStats.php`, `app/Filament/Widgets/Workspace/WorkspaceNeedsAttention.php`, `app/Filament/Widgets/Workspace/WorkspaceRecentOperations.php`, `resources/views/filament/pages/workspace-overview.blade.php`, `resources/views/filament/widgets/workspace/workspace-needs-attention.blade.php`, and `resources/views/filament/widgets/workspace/workspace-recent-operations.blade.php`
|
||||||
|
- [X] T028 Run the final quickstart verification pack from `specs/175-workspace-governance-attention/quickstart.md` against `tests/Feature/Filament/WorkspaceOverviewAccessTest.php`, `tests/Feature/Filament/WorkspaceOverviewAuthorizationTest.php`, `tests/Feature/Filament/WorkspaceOverviewLandingTest.php`, `tests/Feature/Filament/WorkspaceOverviewNavigationTest.php`, `tests/Feature/Filament/WorkspaceOverviewContentTest.php`, `tests/Feature/Filament/WorkspaceOverviewEmptyStatesTest.php`, `tests/Feature/Filament/WorkspaceOverviewPermissionVisibilityTest.php`, `tests/Feature/Filament/WorkspaceOverviewOperationsTest.php`, `tests/Feature/Filament/WorkspaceOverviewDbOnlyTest.php`, `tests/Feature/Filament/WorkspaceOverviewGovernanceAttentionTest.php`, `tests/Feature/Filament/WorkspaceOverviewSummaryMetricsTest.php`, and `tests/Feature/Filament/WorkspaceOverviewDrilldownContinuityTest.php`
|
||||||
|
- [X] T029 Run the manual smoke checks in `specs/175-workspace-governance-attention/quickstart.md` for quiet-operations-risky-governance, stale or failed compare posture, healthy workspace, zero-tenant recovery, and permission-limited member scenarios
|
||||||
|
- [X] T030 Fix the low-permission workspace operations fallback so workspace-originated drill-through clears tenant context and immediately shows workspace-wide follow-up in `app/Support/Workspaces/WorkspaceOverviewBuilder.php`, `app/Support/OperationRunLinks.php`, `app/Filament/Pages/Monitoring/Operations.php`, `tests/Feature/Monitoring/OperationsDashboardDrillthroughTest.php`, and `tests/Feature/Filament/WorkspaceOverviewAuthorizationTest.php`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Dependencies & Execution Order
|
||||||
|
|
||||||
|
### Phase Dependencies
|
||||||
|
|
||||||
|
- **Setup (Phase 1)**: No dependencies; can start immediately.
|
||||||
|
- **Foundational (Phase 2)**: Depends on Setup; blocks all user stories.
|
||||||
|
- **User Story 1 (Phase 3)**: Depends on Foundational completion.
|
||||||
|
- **User Story 2 (Phase 4)**: Depends on Foundational completion and stays independently testable, though it overlaps with the same workspace builder and page shell.
|
||||||
|
- **User Story 3 (Phase 5)**: Depends on Foundational completion and benefits from User Stories 1 and 2 because the destination contract is clearer once governance promotion and metric separation are in place.
|
||||||
|
- **Polish (Phase 6)**: Depends on all desired user stories being complete.
|
||||||
|
|
||||||
|
### User Story Dependencies
|
||||||
|
|
||||||
|
- **User Story 1 (P1)**: First deliverable and recommended MVP. No dependency on other user stories after Foundational work.
|
||||||
|
- **User Story 2 (P1)**: Can start after Foundational completion and remains independently testable, though it shares the builder and page shell with US1.
|
||||||
|
- **User Story 3 (P2)**: Can start after Foundational completion and is best delivered after US1 and US2 because it hardens the attention items already introduced there.
|
||||||
|
|
||||||
|
### Within Each User Story
|
||||||
|
|
||||||
|
- Story tests should be added before or alongside implementation and must fail before the story is considered complete.
|
||||||
|
- Builder changes should land before widget and page copy refinements that depend on the new payload.
|
||||||
|
- Destination continuity changes should land before story-level verification runs.
|
||||||
|
- Story-level verification should complete before moving on to polish.
|
||||||
|
|
||||||
|
### Parallel Opportunities
|
||||||
|
|
||||||
|
- Setup review tasks `T002` and `T003` can run in parallel.
|
||||||
|
- In Foundational work, `T006` and `T007` can run in parallel after `T005` defines the payload contract.
|
||||||
|
- In US1, `T008` and `T009` can run in parallel.
|
||||||
|
- In US2, `T014` and `T015` can run in parallel.
|
||||||
|
- In US3, `T020` and `T021` can run in parallel.
|
||||||
|
- In Phase 6, `T026` can run while the final verification command set for `T028` is being prepared.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Parallel Example: User Story 1
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Launch US1 tests in parallel:
|
||||||
|
T008 tests/Feature/Filament/WorkspaceOverviewGovernanceAttentionTest.php
|
||||||
|
T009 tests/Feature/Filament/WorkspaceOverviewEmptyStatesTest.php + tests/Feature/Filament/WorkspaceOverviewContentTest.php
|
||||||
|
```
|
||||||
|
|
||||||
|
## Parallel Example: User Story 2
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Launch US2 test work in parallel:
|
||||||
|
T014 tests/Feature/Filament/WorkspaceOverviewSummaryMetricsTest.php + tests/Feature/Filament/WorkspaceOverviewContentTest.php
|
||||||
|
T015 tests/Feature/Filament/WorkspaceOverviewOperationsTest.php + tests/Feature/Filament/WorkspaceOverviewEmptyStatesTest.php
|
||||||
|
```
|
||||||
|
|
||||||
|
## Parallel Example: User Story 3
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Launch US3 drill-through and fallback tests in parallel:
|
||||||
|
T020 tests/Feature/Filament/WorkspaceOverviewDrilldownContinuityTest.php
|
||||||
|
T021 tests/Feature/Filament/WorkspaceOverviewPermissionVisibilityTest.php + tests/Feature/Filament/WorkspaceOverviewAuthorizationTest.php
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Implementation Strategy
|
||||||
|
|
||||||
|
### MVP First (User Story 1 Only)
|
||||||
|
|
||||||
|
1. Complete Phase 1: Setup.
|
||||||
|
2. Complete Phase 2: Foundational.
|
||||||
|
3. Complete Phase 3: User Story 1.
|
||||||
|
4. Validate that `/admin` no longer emits a false calm signal when visible tenant governance issues exist.
|
||||||
|
|
||||||
|
### Incremental Delivery
|
||||||
|
|
||||||
|
1. Ship US1 to make the workspace home governance-aware and suppress false calmness.
|
||||||
|
2. Add US2 to separate governance risk from activity and protect calmness semantics.
|
||||||
|
3. Add US3 to harden drill-through continuity and capability-safe fallbacks.
|
||||||
|
4. Finish with copy alignment, formatting, the quickstart verification pack, and manual smoke checks.
|
||||||
|
|
||||||
|
### Suggested MVP Scope
|
||||||
|
|
||||||
|
- MVP = Phases 1 through 3 only.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Format Validation
|
||||||
|
|
||||||
|
- Every task follows the checklist format `- [ ] T### [P?] [US?] Description with file path`.
|
||||||
|
- Setup, Foundational, and Polish phases intentionally omit story labels.
|
||||||
|
- User story phases use `[US1]`, `[US2]`, and `[US3]` labels.
|
||||||
|
- Parallel markers are used only on tasks that can proceed independently without conflicting incomplete prerequisites.
|
||||||
@ -0,0 +1,35 @@
|
|||||||
|
# Specification Quality Checklist: Spec 177 - Inventory Coverage Truth
|
||||||
|
|
||||||
|
**Purpose**: Validate specification completeness and quality before proceeding to planning
|
||||||
|
**Created**: 2026-04-05
|
||||||
|
**Feature**: [spec.md](../spec.md)
|
||||||
|
|
||||||
|
## Content Quality
|
||||||
|
|
||||||
|
- [x] No implementation details (languages, frameworks, APIs)
|
||||||
|
- [x] Focused on user value and business needs
|
||||||
|
- [x] Written for non-technical stakeholders
|
||||||
|
- [x] All mandatory sections completed
|
||||||
|
|
||||||
|
## Requirement Completeness
|
||||||
|
|
||||||
|
- [x] No [NEEDS CLARIFICATION] markers remain
|
||||||
|
- [x] Requirements are testable and unambiguous
|
||||||
|
- [x] Success criteria are measurable
|
||||||
|
- [x] Success criteria are technology-agnostic (no implementation details)
|
||||||
|
- [x] All acceptance scenarios are defined
|
||||||
|
- [x] Edge cases are identified
|
||||||
|
- [x] Scope is clearly bounded
|
||||||
|
- [x] Dependencies and assumptions identified
|
||||||
|
|
||||||
|
## Feature Readiness
|
||||||
|
|
||||||
|
- [x] All functional requirements have clear acceptance criteria
|
||||||
|
- [x] User scenarios cover primary flows
|
||||||
|
- [x] Feature meets measurable outcomes defined in Success Criteria
|
||||||
|
- [x] No implementation details leak into specification
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- Validation pass completed after the draft was rewritten from the template scaffold.
|
||||||
|
- Follow-up inventory hardening ideas remain listed as future candidates without fixed spec numbers, since this repository already uses `179` for a different accepted spec.
|
||||||
@ -0,0 +1,235 @@
|
|||||||
|
openapi: 3.1.0
|
||||||
|
info:
|
||||||
|
title: Inventory Coverage Truth Surfaces
|
||||||
|
version: 0.1.0
|
||||||
|
description: |
|
||||||
|
Logical surface contract for Spec 177. These contracts describe the tenant-scoped
|
||||||
|
coverage truth that inventory surfaces must render, even when the delivered transport
|
||||||
|
is server-rendered Filament UI rather than a public JSON API.
|
||||||
|
paths:
|
||||||
|
/admin/inventory-items:
|
||||||
|
get:
|
||||||
|
summary: Inventory items list with truthful coverage summary
|
||||||
|
operationId: inventoryItemsCoverageSummary
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: Tenant-scoped inventory items list surface
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
additionalProperties: false
|
||||||
|
required:
|
||||||
|
- tenantId
|
||||||
|
- coverageSummary
|
||||||
|
properties:
|
||||||
|
tenantId:
|
||||||
|
type: integer
|
||||||
|
coverageSummary:
|
||||||
|
$ref: '#/components/schemas/CoverageSummary'
|
||||||
|
'403':
|
||||||
|
description: Member lacks capability for a linked follow-up action
|
||||||
|
'404':
|
||||||
|
description: User is not entitled to the tenant or workspace scope
|
||||||
|
/admin/coverage:
|
||||||
|
get:
|
||||||
|
summary: Tenant coverage truth report
|
||||||
|
operationId: inventoryCoverageReport
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: Tenant-scoped coverage report surface
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
additionalProperties: false
|
||||||
|
required:
|
||||||
|
- tenantId
|
||||||
|
- coverageSummary
|
||||||
|
- rows
|
||||||
|
properties:
|
||||||
|
tenantId:
|
||||||
|
type: integer
|
||||||
|
coverageSummary:
|
||||||
|
$ref: '#/components/schemas/CoverageSummary'
|
||||||
|
rows:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
$ref: '#/components/schemas/CoverageRow'
|
||||||
|
'403':
|
||||||
|
description: Member lacks capability for a linked follow-up action
|
||||||
|
'404':
|
||||||
|
description: User is not entitled to the tenant or workspace scope
|
||||||
|
/admin/operations/{run}:
|
||||||
|
get:
|
||||||
|
summary: Canonical inventory-sync run detail with per-type coverage section
|
||||||
|
operationId: inventorySyncRunDetail
|
||||||
|
parameters:
|
||||||
|
- in: path
|
||||||
|
name: run
|
||||||
|
required: true
|
||||||
|
schema:
|
||||||
|
type: integer
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: Canonical operation run detail surface
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
additionalProperties: false
|
||||||
|
required:
|
||||||
|
- id
|
||||||
|
- type
|
||||||
|
- status
|
||||||
|
- outcome
|
||||||
|
properties:
|
||||||
|
id:
|
||||||
|
type: integer
|
||||||
|
type:
|
||||||
|
type: string
|
||||||
|
enum:
|
||||||
|
- inventory_sync
|
||||||
|
status:
|
||||||
|
type: string
|
||||||
|
enum:
|
||||||
|
- queued
|
||||||
|
- running
|
||||||
|
- completed
|
||||||
|
outcome:
|
||||||
|
type: string
|
||||||
|
enum:
|
||||||
|
- pending
|
||||||
|
- succeeded
|
||||||
|
- partially_succeeded
|
||||||
|
- failed
|
||||||
|
- blocked
|
||||||
|
inventoryCoverageSection:
|
||||||
|
oneOf:
|
||||||
|
- $ref: '#/components/schemas/InventoryCoverageRunSection'
|
||||||
|
- type: 'null'
|
||||||
|
'403':
|
||||||
|
description: In-scope member lacks the run capability required for this operation
|
||||||
|
'404':
|
||||||
|
description: User is not entitled to the workspace or tenant scope for this run
|
||||||
|
components:
|
||||||
|
schemas:
|
||||||
|
BasisRun:
|
||||||
|
type: object
|
||||||
|
additionalProperties: false
|
||||||
|
required:
|
||||||
|
- id
|
||||||
|
- outcome
|
||||||
|
- completedAt
|
||||||
|
properties:
|
||||||
|
id:
|
||||||
|
type: integer
|
||||||
|
outcome:
|
||||||
|
type: string
|
||||||
|
enum:
|
||||||
|
- succeeded
|
||||||
|
- partially_succeeded
|
||||||
|
- failed
|
||||||
|
- blocked
|
||||||
|
completedAt:
|
||||||
|
type: string
|
||||||
|
format: date-time
|
||||||
|
CoverageSummary:
|
||||||
|
type: object
|
||||||
|
additionalProperties: false
|
||||||
|
required:
|
||||||
|
- hasCurrentCoverageResult
|
||||||
|
- supportedTypes
|
||||||
|
- succeededTypes
|
||||||
|
- failedTypes
|
||||||
|
- skippedTypes
|
||||||
|
- unknownTypes
|
||||||
|
- followUpTypes
|
||||||
|
- observedItems
|
||||||
|
properties:
|
||||||
|
hasCurrentCoverageResult:
|
||||||
|
type: boolean
|
||||||
|
basisRun:
|
||||||
|
oneOf:
|
||||||
|
- $ref: '#/components/schemas/BasisRun'
|
||||||
|
- type: 'null'
|
||||||
|
supportedTypes:
|
||||||
|
type: integer
|
||||||
|
succeededTypes:
|
||||||
|
type: integer
|
||||||
|
failedTypes:
|
||||||
|
type: integer
|
||||||
|
skippedTypes:
|
||||||
|
type: integer
|
||||||
|
unknownTypes:
|
||||||
|
type: integer
|
||||||
|
followUpTypes:
|
||||||
|
type: integer
|
||||||
|
observedItems:
|
||||||
|
type: integer
|
||||||
|
CoverageRow:
|
||||||
|
type: object
|
||||||
|
additionalProperties: false
|
||||||
|
required:
|
||||||
|
- type
|
||||||
|
- segment
|
||||||
|
- label
|
||||||
|
- category
|
||||||
|
- coverageState
|
||||||
|
- followUpRequired
|
||||||
|
- observedItemCount
|
||||||
|
- supportsDependencies
|
||||||
|
properties:
|
||||||
|
type:
|
||||||
|
type: string
|
||||||
|
segment:
|
||||||
|
type: string
|
||||||
|
enum:
|
||||||
|
- policy
|
||||||
|
- foundation
|
||||||
|
label:
|
||||||
|
type: string
|
||||||
|
category:
|
||||||
|
type: string
|
||||||
|
platform:
|
||||||
|
type:
|
||||||
|
- string
|
||||||
|
- 'null'
|
||||||
|
coverageState:
|
||||||
|
type: string
|
||||||
|
enum:
|
||||||
|
- succeeded
|
||||||
|
- failed
|
||||||
|
- skipped
|
||||||
|
- unknown
|
||||||
|
followUpRequired:
|
||||||
|
type: boolean
|
||||||
|
observedItemCount:
|
||||||
|
type: integer
|
||||||
|
basisErrorCode:
|
||||||
|
type:
|
||||||
|
- string
|
||||||
|
- 'null'
|
||||||
|
restoreMode:
|
||||||
|
type:
|
||||||
|
- string
|
||||||
|
- 'null'
|
||||||
|
riskLevel:
|
||||||
|
type:
|
||||||
|
- string
|
||||||
|
- 'null'
|
||||||
|
supportsDependencies:
|
||||||
|
type: boolean
|
||||||
|
InventoryCoverageRunSection:
|
||||||
|
type: object
|
||||||
|
additionalProperties: false
|
||||||
|
required:
|
||||||
|
- basisRun
|
||||||
|
- rows
|
||||||
|
properties:
|
||||||
|
basisRun:
|
||||||
|
$ref: '#/components/schemas/BasisRun'
|
||||||
|
rows:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
$ref: '#/components/schemas/CoverageRow'
|
||||||
@ -0,0 +1,187 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||||
|
"$id": "https://tenantpilot.local/contracts/tenant-coverage-truth.schema.json",
|
||||||
|
"title": "Tenant Coverage Truth",
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": false,
|
||||||
|
"required": [
|
||||||
|
"tenantId",
|
||||||
|
"hasCurrentCoverageResult",
|
||||||
|
"summary",
|
||||||
|
"rows"
|
||||||
|
],
|
||||||
|
"properties": {
|
||||||
|
"tenantId": {
|
||||||
|
"type": "integer"
|
||||||
|
},
|
||||||
|
"basisRun": {
|
||||||
|
"oneOf": [
|
||||||
|
{
|
||||||
|
"$ref": "#/$defs/basisRun"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "null"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"hasCurrentCoverageResult": {
|
||||||
|
"type": "boolean"
|
||||||
|
},
|
||||||
|
"summary": {
|
||||||
|
"$ref": "#/$defs/summary"
|
||||||
|
},
|
||||||
|
"rows": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/$defs/row"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"$defs": {
|
||||||
|
"basisRun": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": false,
|
||||||
|
"required": [
|
||||||
|
"id",
|
||||||
|
"outcome",
|
||||||
|
"completedAt"
|
||||||
|
],
|
||||||
|
"properties": {
|
||||||
|
"id": {
|
||||||
|
"type": "integer"
|
||||||
|
},
|
||||||
|
"outcome": {
|
||||||
|
"type": "string",
|
||||||
|
"enum": [
|
||||||
|
"succeeded",
|
||||||
|
"partially_succeeded",
|
||||||
|
"failed",
|
||||||
|
"blocked"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"completedAt": {
|
||||||
|
"type": "string",
|
||||||
|
"format": "date-time"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"summary": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": false,
|
||||||
|
"required": [
|
||||||
|
"supportedTypes",
|
||||||
|
"succeededTypes",
|
||||||
|
"failedTypes",
|
||||||
|
"skippedTypes",
|
||||||
|
"unknownTypes",
|
||||||
|
"followUpTypes",
|
||||||
|
"observedItems"
|
||||||
|
],
|
||||||
|
"properties": {
|
||||||
|
"supportedTypes": {
|
||||||
|
"type": "integer",
|
||||||
|
"minimum": 0
|
||||||
|
},
|
||||||
|
"succeededTypes": {
|
||||||
|
"type": "integer",
|
||||||
|
"minimum": 0
|
||||||
|
},
|
||||||
|
"failedTypes": {
|
||||||
|
"type": "integer",
|
||||||
|
"minimum": 0
|
||||||
|
},
|
||||||
|
"skippedTypes": {
|
||||||
|
"type": "integer",
|
||||||
|
"minimum": 0
|
||||||
|
},
|
||||||
|
"unknownTypes": {
|
||||||
|
"type": "integer",
|
||||||
|
"minimum": 0
|
||||||
|
},
|
||||||
|
"followUpTypes": {
|
||||||
|
"type": "integer",
|
||||||
|
"minimum": 0
|
||||||
|
},
|
||||||
|
"observedItems": {
|
||||||
|
"type": "integer",
|
||||||
|
"minimum": 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"row": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": false,
|
||||||
|
"required": [
|
||||||
|
"type",
|
||||||
|
"segment",
|
||||||
|
"label",
|
||||||
|
"category",
|
||||||
|
"coverageState",
|
||||||
|
"followUpRequired",
|
||||||
|
"observedItemCount",
|
||||||
|
"supportsDependencies"
|
||||||
|
],
|
||||||
|
"properties": {
|
||||||
|
"type": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"segment": {
|
||||||
|
"type": "string",
|
||||||
|
"enum": [
|
||||||
|
"policy",
|
||||||
|
"foundation"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"label": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"category": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"platform": {
|
||||||
|
"type": [
|
||||||
|
"string",
|
||||||
|
"null"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"coverageState": {
|
||||||
|
"type": "string",
|
||||||
|
"enum": [
|
||||||
|
"succeeded",
|
||||||
|
"failed",
|
||||||
|
"skipped",
|
||||||
|
"unknown"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"followUpRequired": {
|
||||||
|
"type": "boolean"
|
||||||
|
},
|
||||||
|
"observedItemCount": {
|
||||||
|
"type": "integer",
|
||||||
|
"minimum": 0
|
||||||
|
},
|
||||||
|
"basisErrorCode": {
|
||||||
|
"type": [
|
||||||
|
"string",
|
||||||
|
"null"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"restoreMode": {
|
||||||
|
"type": [
|
||||||
|
"string",
|
||||||
|
"null"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"riskLevel": {
|
||||||
|
"type": [
|
||||||
|
"string",
|
||||||
|
"null"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"supportsDependencies": {
|
||||||
|
"type": "boolean"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
163
specs/177-inventory-coverage-truth/data-model.md
Normal file
163
specs/177-inventory-coverage-truth/data-model.md
Normal file
@ -0,0 +1,163 @@
|
|||||||
|
# Phase 1 Data Model: Inventory Coverage Truth (177)
|
||||||
|
|
||||||
|
## Existing Persisted Truth
|
||||||
|
|
||||||
|
### `OperationRun` as coverage basis
|
||||||
|
Represents the canonical execution record for inventory syncs and remains the source of per-type sync truth.
|
||||||
|
|
||||||
|
**Relevant existing fields**
|
||||||
|
- `workspace_id`
|
||||||
|
- `tenant_id`
|
||||||
|
- `type`
|
||||||
|
- `status`
|
||||||
|
- `outcome`
|
||||||
|
- `summary_counts`
|
||||||
|
- `failure_summary`
|
||||||
|
- `context`
|
||||||
|
- `started_at`
|
||||||
|
- `completed_at`
|
||||||
|
|
||||||
|
**Relevant existing inventory context shape**
|
||||||
|
- `context.inventory.coverage.policy_types`
|
||||||
|
- `context.inventory.coverage.foundation_types`
|
||||||
|
- each entry stores at minimum `status`
|
||||||
|
- optional fields already supported by `InventoryCoverage` normalization:
|
||||||
|
- `item_count`
|
||||||
|
- `error_code`
|
||||||
|
|
||||||
|
**Existing invariant**
|
||||||
|
- `InventoryCoverage::fromContext()` is the canonical parser for run coverage payload.
|
||||||
|
|
||||||
|
### `InventoryItem`
|
||||||
|
Represents last observed tenant inventory rows and remains the source of current observed-item counts.
|
||||||
|
|
||||||
|
**Relevant existing fields**
|
||||||
|
- `workspace_id`
|
||||||
|
- `tenant_id`
|
||||||
|
- `policy_type`
|
||||||
|
- `external_id`
|
||||||
|
- `display_name`
|
||||||
|
- `category`
|
||||||
|
- `platform`
|
||||||
|
- `meta_jsonb`
|
||||||
|
- `last_seen_at`
|
||||||
|
- `last_seen_operation_run_id`
|
||||||
|
|
||||||
|
**Existing invariant**
|
||||||
|
- Observed rows prove last observation only. They do not by themselves prove current tenant coverage completeness.
|
||||||
|
|
||||||
|
### `InventoryPolicyTypeMeta` + capability metadata
|
||||||
|
Represents product support and capability reference for supported and foundation types.
|
||||||
|
|
||||||
|
**Relevant existing fields and derived attributes**
|
||||||
|
- `type`
|
||||||
|
- `label`
|
||||||
|
- `category`
|
||||||
|
- `platform`
|
||||||
|
- `restore`
|
||||||
|
- `risk`
|
||||||
|
- foundation flag
|
||||||
|
- dependency support from `CoverageCapabilitiesResolver`
|
||||||
|
|
||||||
|
**Existing invariant**
|
||||||
|
- Capability metadata is product support truth, not tenant coverage truth.
|
||||||
|
|
||||||
|
## New Derived Runtime Contract
|
||||||
|
|
||||||
|
### `TenantCoverageTruth`
|
||||||
|
Derived runtime contract that answers the operator question: which supported types are currently covered for this tenant, which types need follow-up, and which run establishes that statement.
|
||||||
|
|
||||||
|
**Proposed fields**
|
||||||
|
- `tenant_id`
|
||||||
|
- `basis_run_id` nullable
|
||||||
|
- `basis_run_outcome` nullable
|
||||||
|
- `basis_completed_at` nullable
|
||||||
|
- `has_current_coverage_result` boolean
|
||||||
|
- `supported_type_count`
|
||||||
|
- `succeeded_type_count`
|
||||||
|
- `failed_type_count`
|
||||||
|
- `skipped_type_count`
|
||||||
|
- `unknown_type_count`
|
||||||
|
- `follow_up_type_count`
|
||||||
|
- `observed_item_total`
|
||||||
|
- `rows` list of `TenantCoverageTypeTruth`
|
||||||
|
|
||||||
|
**Derived invariants**
|
||||||
|
- Exactly one row exists for each supported policy type and foundation type currently in the product support catalog.
|
||||||
|
- `follow_up_type_count = failed + skipped + unknown`.
|
||||||
|
- `has_current_coverage_result` is true only when a completed inventory-sync basis run with parseable payload exists.
|
||||||
|
- The basis run is chosen independently from current item counts.
|
||||||
|
|
||||||
|
### `TenantCoverageTypeTruth`
|
||||||
|
Derived row contract for one supported type.
|
||||||
|
|
||||||
|
**Proposed fields**
|
||||||
|
- `type`
|
||||||
|
- `segment` (`policy` or `foundation`)
|
||||||
|
- `label`
|
||||||
|
- `category`
|
||||||
|
- `platform` nullable
|
||||||
|
- `coverage_state` (`succeeded`, `failed`, `skipped`, `unknown`)
|
||||||
|
- `follow_up_required` boolean
|
||||||
|
- `observed_item_count`
|
||||||
|
- `basis_error_code` nullable
|
||||||
|
- `restore_mode` nullable
|
||||||
|
- `risk_level` nullable
|
||||||
|
- `supports_dependencies` boolean
|
||||||
|
|
||||||
|
**Derived invariants**
|
||||||
|
- `follow_up_required` is true for `failed`, `skipped`, and `unknown`; false only for `succeeded`.
|
||||||
|
- `observed_item_count > 0` does not change `coverage_state`.
|
||||||
|
- `coverage_state = unknown` when the type is supported but absent from the basis run payload.
|
||||||
|
- `basis_error_code` is allowed only for non-succeeded payload-backed states.
|
||||||
|
|
||||||
|
## Derived State Family
|
||||||
|
|
||||||
|
### Coverage state family
|
||||||
|
This feature introduces one derived state family for tenant coverage rows:
|
||||||
|
|
||||||
|
- `Succeeded`
|
||||||
|
- the basis run reported the type as successfully processed
|
||||||
|
- `Failed`
|
||||||
|
- the basis run reported the type as attempted and failed
|
||||||
|
- `Skipped`
|
||||||
|
- the basis run reported the type as intentionally skipped or not processed during that run
|
||||||
|
- `Unknown`
|
||||||
|
- no current coverage result exists for the supported type in the basis run
|
||||||
|
|
||||||
|
**Behavioral consequence**
|
||||||
|
- `Failed`, `Skipped`, and `Unknown` all suppress calm claims and increment follow-up counts.
|
||||||
|
- `Unknown` is derived, not persisted.
|
||||||
|
|
||||||
|
## Relationships
|
||||||
|
|
||||||
|
- One `TenantCoverageTruth` resolves for one tenant at a time.
|
||||||
|
- One `TenantCoverageTruth` may reference zero or one basis `OperationRun`.
|
||||||
|
- One `TenantCoverageTruth` contains one row per supported product type from `InventoryPolicyTypeMeta::supported()` and `InventoryPolicyTypeMeta::foundations()`.
|
||||||
|
- Each `TenantCoverageTypeTruth` joins one supported type to zero or one payload-backed status from the basis run and zero or more `InventoryItem` rows from the current tenant observation set.
|
||||||
|
|
||||||
|
## Selection Rules
|
||||||
|
|
||||||
|
### Basis run selection
|
||||||
|
- candidate runs are `OperationRun` rows where:
|
||||||
|
- `tenant_id` matches the selected tenant
|
||||||
|
- `type = inventory_sync`
|
||||||
|
- `status = completed`
|
||||||
|
- candidates are ordered by:
|
||||||
|
- `completed_at DESC`
|
||||||
|
- `id DESC`
|
||||||
|
- the selected basis run is the first candidate whose `context.inventory.coverage` payload can be parsed by `InventoryCoverage::fromContext()`
|
||||||
|
- if no candidate qualifies, the tenant has no current coverage basis run
|
||||||
|
|
||||||
|
### Unknown derivation
|
||||||
|
- if a supported type is absent from both `policy_types` and `foundation_types` in the selected basis payload, the type is `Unknown`
|
||||||
|
- absence from the basis payload is not converted into `Skipped`
|
||||||
|
- item presence from older runs does not upgrade `Unknown`
|
||||||
|
|
||||||
|
## Validation Rules
|
||||||
|
|
||||||
|
- No schema migration is required.
|
||||||
|
- No new persisted state is introduced.
|
||||||
|
- Coverage rows must remain tenant-scoped.
|
||||||
|
- Capability metadata must not alter the derived coverage state.
|
||||||
|
- Surfaces may cite the basis run only when they can do so without violating authorization.
|
||||||
285
specs/177-inventory-coverage-truth/plan.md
Normal file
285
specs/177-inventory-coverage-truth/plan.md
Normal file
@ -0,0 +1,285 @@
|
|||||||
|
# Implementation Plan: Inventory Coverage Truth
|
||||||
|
|
||||||
|
**Branch**: `feat/177-inventory-coverage-truth` | **Date**: 2026-04-05 | **Spec**: `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/specs/177-inventory-coverage-truth/spec.md`
|
||||||
|
**Input**: Feature specification from `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/specs/177-inventory-coverage-truth/spec.md`
|
||||||
|
|
||||||
|
**Note**: This template is filled in by the `/speckit.plan` command. See `.specify/scripts/` for helper scripts.
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
Correct inventory coverage semantics by deriving tenant coverage truth from the latest completed inventory-sync run that contains usable per-type coverage payload, joining that run truth with current inventory-item counts and existing supported-type metadata, replacing the misleading coverage percentage in the inventory KPI header with operator-readable counts, refocusing the coverage page on tenant follow-up, and exposing a human-readable per-type coverage section on inventory-sync run detail. The implementation stays fully derived, introduces no new persistence, keeps capability and support metadata secondary, and preserves existing sync execution, authorization, and Ops-UX behavior.
|
||||||
|
|
||||||
|
## Technical Context
|
||||||
|
|
||||||
|
**Language/Version**: PHP 8.4.15
|
||||||
|
**Primary Dependencies**: Laravel 12, Filament v5, Livewire v4, Pest v4, existing `InventoryItem`, `OperationRun`, `InventoryCoverage`, `InventoryPolicyTypeMeta`, `CoverageCapabilitiesResolver`, `InventoryKpiHeader`, `InventoryCoverage` page, and `OperationRunResource` enterprise-detail stack
|
||||||
|
**Storage**: PostgreSQL; existing `inventory_items` rows and `operation_runs.context` / `operation_runs.summary_counts` JSONB are reused with no schema change
|
||||||
|
**Testing**: Pest 4 unit and feature tests, including Filament or Livewire page coverage, run through Laravel Sail
|
||||||
|
**Target Platform**: Laravel monolith web application in Sail locally and containerized Linux deployment for staging and production
|
||||||
|
**Project Type**: web application
|
||||||
|
**Performance Goals**: DB-only render path for inventory summary and coverage surfaces; no render-time Graph calls; one tenant-scoped basis-run lookup plus grouped item counts per render; default-visible inventory surfaces expose covered versus follow-up types, basis-run context, and next-step guidance without raw JSON inspection
|
||||||
|
**Constraints**: Derived-only implementation; no new coverage table; no inventory-sync backend rewrite; no new Graph calls; no new destructive actions; capability metadata stays secondary; unauthorized run drill-through must degrade safely
|
||||||
|
**Scale/Scope**: One tenant at a time across three primary surfaces: inventory KPI header on the items list, the inventory coverage page, and canonical inventory-sync run detail; dozens of supported and foundation types rather than unbounded row counts
|
||||||
|
|
||||||
|
## Constitution Check
|
||||||
|
|
||||||
|
*GATE: Passed before Phase 0 research. Re-checked after Phase 1 design and still passing.*
|
||||||
|
|
||||||
|
| Principle | Pre-Research | Post-Design | Notes |
|
||||||
|
|-----------|--------------|-------------|-------|
|
||||||
|
| Inventory-first / snapshots-second | PASS | PASS | Coverage remains derived from current inventory observation and canonical inventory-sync runs; no snapshot or backup truth is introduced. |
|
||||||
|
| Read/write separation | PASS | PASS | The feature changes read-time surfaces only. Existing `Run Inventory Sync` semantics remain unchanged. |
|
||||||
|
| Graph contract path | PASS | PASS | No new Graph call path or contract registry entry is introduced. |
|
||||||
|
| Deterministic capabilities | PASS | PASS | Capability and support metadata remain derived from existing policy-type meta and capability resolvers. |
|
||||||
|
| Workspace + tenant isolation | PASS | PASS | All coverage truth stays tenant-scoped; canonical operations drill-through remains tenant-safe and entitlement-checked. |
|
||||||
|
| RBAC-UX authorization semantics | PASS | PASS | Non-members remain `404`; members lacking the run capability remain `403` on run detail; coverage surfaces must degrade safely instead of exposing broken links. |
|
||||||
|
| Run observability / Ops-UX | PASS | PASS | Existing `inventory_sync` `OperationRun` remains canonical. No new run type or new feedback surface is introduced. |
|
||||||
|
| Ops-UX lifecycle / summary counts | PASS | PASS | `OperationRunService` remains the only transition path; `summary_counts` remain canonical and numeric-only. The plan only adds read-time rendering. |
|
||||||
|
| Data minimization | PASS | PASS | Existing whitelisted inventory metadata and sanitized run context are reused; no new persisted payload surface is added. |
|
||||||
|
| Proportionality / no premature abstraction | PASS WITH JUSTIFIED DERIVED CONTRACT | PASS WITH JUSTIFIED DERIVED CONTRACT | One narrow runtime contract plus resolver is justified because current truth is split across run context, item counts, and supported-type metadata, and three operator surfaces already need the same synthesis. |
|
||||||
|
| Persisted truth / behavioral state | PASS | PASS | `Unknown` is derived presentation truth, not persisted domain state. No new tables or durable artifacts are introduced. |
|
||||||
|
| UI semantics / few layers | PASS | PASS | The design replaces a misleading KPI and capability-first report with one direct tenant-coverage read model instead of adding a wider semantic framework. |
|
||||||
|
| Badge semantics (BADGE-001) | PASS | PASS | Coverage state badges stay centralized and test-covered. No page-local badge language is introduced. |
|
||||||
|
| Filament-native UI / Action Surface Contract | PASS | PASS | Existing Filament tables, stats, sections, and enterprise-detail views are reused. The existing derived-row exception on `InventoryCoverage` remains the only UI exception. |
|
||||||
|
| Filament UX-001 | PASS | PASS | The coverage page remains a searchable, filterable table surface with one clear empty-state CTA; run detail remains a view-style operational page; no create or edit layout changes are needed. |
|
||||||
|
| List-surface review checklist reference | PASS | PASS | The inventory items list and inventory coverage page are governed by `docs/product/standards/list-surface-review-checklist.md`, which must be applied before sign-off. |
|
||||||
|
| Filament v5 / Livewire v4 compliance | PASS | PASS | The plan stays inside the existing Filament v5 + Livewire v4 stack. No legacy APIs are introduced. |
|
||||||
|
| Provider registration location | PASS | PASS | No panel or provider registration change is involved; Laravel 11+ provider registration remains in `bootstrap/providers.php`. |
|
||||||
|
| Global search hard rule | PASS | PASS | No globally searchable resource is added or modified. |
|
||||||
|
| Destructive action safety | PASS | PASS | No new destructive action is introduced. Existing sync start action remains non-destructive and capability-gated. |
|
||||||
|
| Asset strategy | PASS | PASS | No asset registration or `filament:assets` deployment change is required. |
|
||||||
|
| Testing truth (TEST-TRUTH-001) | PASS | PASS | The design adds focused resolver, surface, and RBAC-safe continuity tests that protect operator-visible business truth. |
|
||||||
|
|
||||||
|
## Phase 0 Research
|
||||||
|
|
||||||
|
Research outcomes are captured in `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/specs/177-inventory-coverage-truth/research.md`.
|
||||||
|
|
||||||
|
Key decisions:
|
||||||
|
|
||||||
|
- Define the relevant coverage basis as the latest completed `inventory_sync` run for the tenant that contains a parseable `context.inventory.coverage` payload, regardless of overall run outcome, so `Failed` and `Skipped` remain visible when the run produced per-type truth.
|
||||||
|
- Keep tenant coverage fully derived from three existing sources: `OperationRun.context.inventory.coverage`, current `InventoryItem` counts, and existing supported-type metadata plus capability metadata.
|
||||||
|
- Introduce one narrow runtime contract and resolver in `App\Support\Inventory` rather than extending the low-level `InventoryCoverage` parser or adding request-scoped caching infrastructure.
|
||||||
|
- Replace the KPI percentage with count-based summary facts, since the spec explicitly prioritizes semantically clear counts over percentage language.
|
||||||
|
- Keep the coverage page as one truth-first report with summary + per-type table, and move capability metadata into secondary columns or reference treatment rather than preserving the current capability-first matrix.
|
||||||
|
- Add a dedicated human-readable per-type coverage section to the existing enterprise-detail run page instead of creating a new operations screen.
|
||||||
|
- Defer any first-class `stale` or freshness state family to later health hardening work; this slice surfaces the basis timestamp clearly without adding another semantic layer.
|
||||||
|
|
||||||
|
## Phase 1 Design
|
||||||
|
|
||||||
|
Design artifacts are created under `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/specs/177-inventory-coverage-truth/`:
|
||||||
|
|
||||||
|
- `data-model.md`: existing persisted truths plus the new derived tenant coverage contract
|
||||||
|
- `contracts/inventory-coverage-truth.openapi.yaml`: logical surface contract for the inventory items summary, coverage page, and run detail continuity
|
||||||
|
- `contracts/tenant-coverage-truth.schema.json`: schema for the derived tenant coverage truth contract consumed by the UI
|
||||||
|
- `quickstart.md`: focused implementation and verification workflow
|
||||||
|
|
||||||
|
Design decisions:
|
||||||
|
|
||||||
|
- The new runtime contract is summary-first and derived; it does not become a new persisted truth source.
|
||||||
|
- `InventoryCoverage` remains the low-level parser for `OperationRun` context; a sibling resolver assembles tenant coverage truth by joining the parsed payload with item counts and metadata.
|
||||||
|
- The inventory KPI header will shift from `Coverage %` to count-based coverage signals and explicit basis-run context.
|
||||||
|
- The `InventoryCoverage` page will reuse its existing table surface but reorder the page around tenant coverage truth and follow-up, with capability metadata kept clearly secondary.
|
||||||
|
- Inventory-sync run detail will gain one enterprise-detail section backed by a custom Blade view under the existing `filament.infolists.entries.*` convention.
|
||||||
|
- No new request-scoped caching or cross-surface aggregate infrastructure is introduced in this slice; one resolver is sufficient.
|
||||||
|
|
||||||
|
## Project Structure
|
||||||
|
|
||||||
|
### Documentation (this feature)
|
||||||
|
|
||||||
|
```text
|
||||||
|
specs/177-inventory-coverage-truth/
|
||||||
|
├── spec.md
|
||||||
|
├── plan.md
|
||||||
|
├── research.md
|
||||||
|
├── data-model.md
|
||||||
|
├── quickstart.md
|
||||||
|
├── contracts/
|
||||||
|
│ ├── inventory-coverage-truth.openapi.yaml
|
||||||
|
│ └── tenant-coverage-truth.schema.json
|
||||||
|
├── checklists/
|
||||||
|
│ └── requirements.md
|
||||||
|
└── tasks.md
|
||||||
|
```
|
||||||
|
|
||||||
|
### Source Code (repository root)
|
||||||
|
|
||||||
|
```text
|
||||||
|
app/
|
||||||
|
├── Filament/
|
||||||
|
│ ├── Pages/
|
||||||
|
│ │ └── InventoryCoverage.php
|
||||||
|
│ ├── Resources/
|
||||||
|
│ │ ├── InventoryItemResource/
|
||||||
|
│ │ │ └── Pages/
|
||||||
|
│ │ │ └── ListInventoryItems.php
|
||||||
|
│ │ └── OperationRunResource.php
|
||||||
|
│ ├── Pages/
|
||||||
|
│ │ └── Operations/
|
||||||
|
│ │ └── TenantlessOperationRunViewer.php
|
||||||
|
│ └── Widgets/
|
||||||
|
│ └── Inventory/
|
||||||
|
│ └── InventoryKpiHeader.php
|
||||||
|
├── Models/
|
||||||
|
│ ├── InventoryItem.php
|
||||||
|
│ └── OperationRun.php
|
||||||
|
└── Support/
|
||||||
|
└── Inventory/
|
||||||
|
├── CoverageCapabilitiesResolver.php
|
||||||
|
├── InventoryCoverage.php
|
||||||
|
├── InventoryPolicyTypeMeta.php
|
||||||
|
├── TenantCoverageTruth.php
|
||||||
|
└── TenantCoverageTruthResolver.php
|
||||||
|
|
||||||
|
resources/
|
||||||
|
└── views/
|
||||||
|
└── filament/
|
||||||
|
├── pages/
|
||||||
|
│ └── inventory-coverage.blade.php
|
||||||
|
└── infolists/
|
||||||
|
└── entries/
|
||||||
|
└── inventory-coverage-truth.blade.php
|
||||||
|
|
||||||
|
tests/
|
||||||
|
├── Feature/
|
||||||
|
│ ├── Filament/
|
||||||
|
│ │ ├── InventoryCoverageAdminTenantParityTest.php
|
||||||
|
│ │ ├── InventoryCoverageTableTest.php
|
||||||
|
│ │ ├── InventoryItemResourceTest.php
|
||||||
|
│ │ ├── InventoryPagesTest.php
|
||||||
|
│ │ └── OperationRunEnterpriseDetailPageTest.php
|
||||||
|
│ ├── Inventory/
|
||||||
|
│ │ ├── InventorySyncServiceTest.php
|
||||||
|
│ │ ├── InventorySyncStartSurfaceTest.php
|
||||||
|
│ │ └── RunInventorySyncJobTest.php
|
||||||
|
│ ├── Operations/
|
||||||
|
│ │ └── TenantlessOperationRunViewerTest.php
|
||||||
|
│ └── Rbac/
|
||||||
|
│ └── InventoryItemResourceAuthorizationTest.php
|
||||||
|
└── Unit/
|
||||||
|
└── Support/
|
||||||
|
└── Inventory/
|
||||||
|
└── TenantCoverageTruthResolverTest.php
|
||||||
|
```
|
||||||
|
|
||||||
|
**Structure Decision**: Keep the existing Laravel monolith layout. Add one narrow derived coverage contract plus resolver under `app/Support/Inventory`, update the three existing operator surfaces, add one custom enterprise-detail view, and extend the current feature and unit tests instead of creating new base directories or a broader presentation framework.
|
||||||
|
|
||||||
|
## Implementation Strategy
|
||||||
|
|
||||||
|
### Phase A — Introduce the Derived Coverage Contract
|
||||||
|
|
||||||
|
**Goal**: Add one explicit runtime contract that represents tenant coverage truth without changing persistence.
|
||||||
|
|
||||||
|
| Step | File | Change |
|
||||||
|
|------|------|--------|
|
||||||
|
| A.1 | `app/Support/Inventory/TenantCoverageTruth.php` | Add a readonly runtime contract representing basis-run metadata, summary counts, and per-type tenant coverage rows |
|
||||||
|
| A.2 | `app/Support/Inventory/TenantCoverageTruthResolver.php` | Add the resolver that selects the latest completed coverage-bearing inventory-sync run, parses `InventoryCoverage`, joins current item counts, and synthesizes follow-up classification |
|
||||||
|
| A.3 | `app/Support/Inventory/InventoryCoverage.php` | Keep the low-level parser focused on run payload normalization; extend only if a small helper is needed for row access, not for tenant-level joining |
|
||||||
|
|
||||||
|
### Phase B — Refactor the Inventory KPI Header
|
||||||
|
|
||||||
|
**Goal**: Remove the misleading percentage and replace it with count-based tenant coverage truth.
|
||||||
|
|
||||||
|
| Step | File | Change |
|
||||||
|
|------|------|--------|
|
||||||
|
| B.1 | `app/Filament/Widgets/Inventory/InventoryKpiHeader.php` | Replace `Coverage %` with count-based coverage stats such as succeeded types and types needing follow-up, plus explicit basis-run or no-sync context while keeping any restore or compare metadata clearly separate from coverage truth |
|
||||||
|
| B.2 | `tests/Feature/Filament/InventoryPagesTest.php` and `tests/Feature/Filament/InventoryItemResourceTest.php` | Preserve the existing inventory list inspect and sync-start affordances while asserting the summary surface no longer implies completeness from restorable-item share |
|
||||||
|
|
||||||
|
### Phase C — Recenter the Coverage Page Around Tenant Truth
|
||||||
|
|
||||||
|
**Goal**: Turn the current capability-first page into a tenant coverage report without removing support metadata entirely.
|
||||||
|
|
||||||
|
| Step | File | Change |
|
||||||
|
|------|------|--------|
|
||||||
|
| C.1 | `app/Filament/Pages/InventoryCoverage.php` | Replace the current static capability row builder with rows sourced from `TenantCoverageTruthResolver`; lead with coverage-state, basis-run, observed-item, follow-up columns, and deterministic follow-up priority ordering |
|
||||||
|
| C.2 | `resources/views/filament/pages/inventory-coverage.blade.php` | Add or adjust the summary zone so the page cites the basis run, last sync time, explicit no-sync fallback, provider or permission follow-up guidance, and follow-up summary before the table |
|
||||||
|
| C.3 | `tests/Feature/Filament/InventoryCoverageTableTest.php` and `tests/Feature/Filament/InventoryCoverageAdminTenantParityTest.php` | Prove the page is tenant-coverage-first, keeps admin and tenant context parity, covers deterministic follow-up priority and no-basis-run messaging, and retains support, restore, and compare metadata only as secondary treatment |
|
||||||
|
|
||||||
|
### Phase D — Add Human-Readable Per-Type Results To Run Detail
|
||||||
|
|
||||||
|
**Goal**: Make inventory-sync per-type truth readable without raw JSON inspection.
|
||||||
|
|
||||||
|
| Step | File | Change |
|
||||||
|
|------|------|--------|
|
||||||
|
| D.1 | `app/Filament/Resources/OperationRunResource.php` | For `inventory_sync` runs, add a dedicated enterprise-detail section that renders per-type coverage truth from `InventoryCoverage::fromContext()` |
|
||||||
|
| D.2 | `resources/views/filament/infolists/entries/inventory-coverage-truth.blade.php` | Add the custom section view used by the enterprise-detail builder for per-type status, item counts, follow-up priority, and provider or permission follow-up cues |
|
||||||
|
| D.3 | `tests/Feature/Operations/TenantlessOperationRunViewerTest.php` and `tests/Feature/Filament/OperationRunEnterpriseDetailPageTest.php` | Assert that inventory-sync runs render the new coverage section and that execution outcome stays distinct from per-type coverage truth |
|
||||||
|
|
||||||
|
### Phase E — Enforce RBAC-Safe Coverage Continuity
|
||||||
|
|
||||||
|
**Goal**: Ensure coverage surfaces can cite the backing run without leaking inaccessible operations.
|
||||||
|
|
||||||
|
| Step | File | Change |
|
||||||
|
|------|------|--------|
|
||||||
|
| E.1 | `app/Support/Inventory/TenantCoverageTruthResolver.php` and calling surfaces | Include basis-run identity and safe continuity metadata without assuming the current user can always open the run |
|
||||||
|
| E.2 | `app/Filament/Widgets/Inventory/InventoryKpiHeader.php` and `app/Filament/Pages/InventoryCoverage.php` | Show direct links only when the current user can open the run; otherwise show explanatory guidance, explicit no-sync copy, and provider or permission follow-up guidance |
|
||||||
|
| E.3 | `tests/Feature/Rbac/InventoryItemResourceAuthorizationTest.php` and a new resolver or feature test for run continuity | Assert 404 or 403 behavior remains unchanged and the coverage UI degrades safely for users who cannot open the run |
|
||||||
|
|
||||||
|
### Phase F — Regression Protection and Verification
|
||||||
|
|
||||||
|
**Goal**: Lock the corrected semantics in place with focused tests and formatting.
|
||||||
|
|
||||||
|
| Step | File | Change |
|
||||||
|
|------|------|--------|
|
||||||
|
| F.1 | `tests/Unit/Support/Inventory/TenantCoverageTruthResolverTest.php` | Cover basis-run selection, `Unknown` derivation, follow-up classification, and item-count joining |
|
||||||
|
| F.2 | Existing feature tests across Inventory, Filament, Operations, and RBAC | Cover KPI wording, truth-first coverage page behavior, run-detail readability, and safe continuity |
|
||||||
|
| F.3 | `vendor/bin/sail bin pint --dirty --format agent` and focused Pest runs | Apply formatting and run the smallest verification pack covering resolver logic, inventory surfaces, run detail, and RBAC continuity |
|
||||||
|
|
||||||
|
## Key Design Decisions
|
||||||
|
|
||||||
|
### D-001 — The basis run is the latest completed inventory-sync run with usable per-type coverage payload
|
||||||
|
|
||||||
|
This feature needs `Failed` and `Skipped` to remain visible when a run produced real per-type truth, so the basis selector must key off payload presence rather than optimistic run outcome alone.
|
||||||
|
|
||||||
|
### D-002 — `InventoryCoverage` stays a low-level parser; tenant synthesis lives in a sibling contract and resolver
|
||||||
|
|
||||||
|
`InventoryCoverage` already models the canonical `context.inventory.coverage` payload. Extending it to select runs, join item counts, and apply UI-facing follow-up logic would blur responsibilities and make the low-level parser less reusable.
|
||||||
|
|
||||||
|
### D-003 — Count-based KPI signals are the narrowest correction
|
||||||
|
|
||||||
|
The spec explicitly identifies the unqualified percentage as the misleading element. Replacing it with succeeded and follow-up type counts corrects the operator semantics without inventing a new score.
|
||||||
|
|
||||||
|
### D-004 — Capability metadata stays visible but subordinate
|
||||||
|
|
||||||
|
The product support matrix is still useful, but it must stop being the primary answer to a tenant coverage question. The plan keeps the metadata in secondary table columns or reference treatment instead of removing it completely.
|
||||||
|
|
||||||
|
### D-005 — No new stale state family in this slice
|
||||||
|
|
||||||
|
The spec allows stale semantics as optional. Adding them now would create another interpretation layer and broaden the scope beyond the immediate truth correction. This slice uses explicit timestamps and leaves broader freshness posture to later health work.
|
||||||
|
|
||||||
|
### D-006 — Follow-up priority is deterministic and severity-first
|
||||||
|
|
||||||
|
Coverage surfaces must not invent their own urgency rules. Follow-up ordering is `Failed` before `Unknown` before `Skipped`, then observed item count descending, then type label ascending, so the summary and the table can highlight the same first-review candidates without presentation drift.
|
||||||
|
|
||||||
|
## Risk Assessment
|
||||||
|
|
||||||
|
| Risk | Impact | Likelihood | Mitigation |
|
||||||
|
|------|--------|------------|------------|
|
||||||
|
| The resolver selects a run that should not be the coverage basis | High | Medium | Unit-test the basis selector across succeeded, partial, failed-with-payload, skipped-with-payload, and no-payload scenarios |
|
||||||
|
| Observed item counts are read as proof of coverage | High | Medium | Separate item counts from state columns and summary language, and add regression tests for types with items but `Unknown` coverage |
|
||||||
|
| Capability metadata still dominates the coverage page | Medium | Medium | Lead with coverage-state columns and summary facts, demote support metadata to secondary columns, and add page-level assertions |
|
||||||
|
| Run drill-through leaks inaccessible operations or creates dead links | High | Medium | Compute safe continuity metadata in the resolver or calling surface and test authorized, forbidden, and not-found paths |
|
||||||
|
| Run-detail coverage rendering conflicts with the existing enterprise-detail hierarchy | Medium | Low | Use one enterprise-detail view section under existing conventions and keep raw context JSON in the technical section |
|
||||||
|
|
||||||
|
## Test Strategy
|
||||||
|
|
||||||
|
- Add focused unit coverage for `TenantCoverageTruthResolver` so basis-run selection, `Unknown` derivation, and follow-up classification are verified without UI noise.
|
||||||
|
- Extend `InventoryCoverageTableTest` and `InventoryCoverageAdminTenantParityTest` to prove the page now answers tenant coverage truth first, applies deterministic follow-up priority, handles the no-basis-run case plainly, and keeps support, restore, and compare metadata secondary.
|
||||||
|
- Extend inventory list and page regressions so the KPI summary no longer exposes a misleading coverage percentage, plainly reports when no basis run exists, and preserves existing sync-start affordances plus canonical run links.
|
||||||
|
- Extend `TenantlessOperationRunViewerTest` and `OperationRunEnterpriseDetailPageTest` to verify inventory-sync runs render a readable per-type coverage section, provider or permission follow-up guidance, and keep execution outcome separate from coverage truth.
|
||||||
|
- Add RBAC coverage for safe continuity so users who can see inventory truth but cannot open the run receive non-clickable or explanatory guidance rather than broken drill-throughs.
|
||||||
|
- Run the smallest focused Sail test pack plus Pint before implementation completion.
|
||||||
|
|
||||||
|
## Complexity Tracking
|
||||||
|
|
||||||
|
| Violation | Why Needed | Simpler Alternative Rejected Because |
|
||||||
|
|-----------|------------|-------------------------------------|
|
||||||
|
| New derived runtime contract plus resolver | Three existing surfaces need the same tenant coverage synthesis from run payload, item counts, and capability metadata | Recomputing the join inside each widget or page would duplicate business truth and make regression drift likely |
|
||||||
|
|
||||||
|
## Proportionality Review
|
||||||
|
|
||||||
|
- **Current operator problem**: Operators currently read a `Coverage` KPI that actually measures restorable-item share and a coverage page that primarily presents product support metadata, while the true per-type sync result lives in run context and is difficult to read.
|
||||||
|
- **Existing structure is insufficient because**: No existing object combines the relevant run basis, per-type run status, item counts, and support metadata into one tenant-coverage answer, and the current surfaces each infer their own incomplete version of coverage.
|
||||||
|
- **Narrowest correct implementation**: Add one derived runtime contract plus one resolver, then refit the existing KPI header, coverage page, and run-detail page around that contract without adding persistence or a broader framework.
|
||||||
|
- **Ownership cost created**: One new runtime contract, one resolver, one custom enterprise-detail view, and a focused set of unit and feature regressions.
|
||||||
|
- **Alternative intentionally rejected**: A persisted coverage table, a new percentage score, a request-scoped aggregate framework, or a broader inventory health layer were rejected because they exceed the scope of correcting already-available truth.
|
||||||
|
- **Release truth**: Current-release truth correction.
|
||||||
64
specs/177-inventory-coverage-truth/quickstart.md
Normal file
64
specs/177-inventory-coverage-truth/quickstart.md
Normal file
@ -0,0 +1,64 @@
|
|||||||
|
# Quickstart: Inventory Coverage Truth (177)
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
Implement the Spec 177 truth correction without changing inventory-sync execution semantics or adding persistence.
|
||||||
|
|
||||||
|
The implementation is complete when:
|
||||||
|
- the inventory KPI header no longer shows a misleading unqualified coverage percentage,
|
||||||
|
- the coverage page answers tenant coverage truth first,
|
||||||
|
- inventory-sync run detail shows per-type results in human-readable form,
|
||||||
|
- and run continuity is RBAC-safe.
|
||||||
|
|
||||||
|
## Suggested Implementation Order
|
||||||
|
|
||||||
|
1. Add the derived runtime contract and resolver under `app/Support/Inventory`.
|
||||||
|
2. Add unit tests for basis-run selection, `Unknown` derivation, and follow-up classification.
|
||||||
|
3. Refactor `InventoryKpiHeader` to consume the new resolver and switch to count-based summary facts.
|
||||||
|
4. Refactor `InventoryCoverage` to consume the same resolver and move capability metadata into secondary treatment.
|
||||||
|
5. Add the inventory-sync run detail section and its custom Blade view.
|
||||||
|
6. Extend RBAC and feature tests for safe continuity and truthful rendering.
|
||||||
|
7. Run Pint and the focused Sail test pack.
|
||||||
|
|
||||||
|
## Verification Workflow
|
||||||
|
|
||||||
|
### Unit and focused feature tests
|
||||||
|
|
||||||
|
Run the smallest focused set first:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
export PATH="/bin:/usr/bin:/usr/local/bin:$PATH"
|
||||||
|
cd /Users/ahmeddarrazi/Documents/projects/TenantAtlas
|
||||||
|
vendor/bin/sail artisan test --compact tests/Unit/Support/Inventory/TenantCoverageTruthResolverTest.php
|
||||||
|
vendor/bin/sail artisan test --compact tests/Feature/Filament/InventoryCoverageTableTest.php
|
||||||
|
vendor/bin/sail artisan test --compact tests/Feature/Filament/InventoryCoverageAdminTenantParityTest.php
|
||||||
|
vendor/bin/sail artisan test --compact tests/Feature/Operations/TenantlessOperationRunViewerTest.php
|
||||||
|
vendor/bin/sail artisan test --compact tests/Feature/Inventory/RunInventorySyncJobTest.php
|
||||||
|
vendor/bin/sail bin pint --dirty --format agent
|
||||||
|
```
|
||||||
|
|
||||||
|
### Manual operator walkthrough
|
||||||
|
|
||||||
|
1. Open the inventory items list for a tenant with a recent inventory-sync run.
|
||||||
|
2. Confirm the header shows count-based coverage truth, not `Coverage %`.
|
||||||
|
3. Open the coverage page and verify the summary cites the basis run and the per-type table leads with coverage state and follow-up.
|
||||||
|
4. Open the cited inventory-sync run and verify the new per-type coverage section renders without opening raw JSON.
|
||||||
|
5. Verify a user without access to the run sees safe explanatory guidance instead of a dead-end link.
|
||||||
|
|
||||||
|
## Out-of-Scope Guardrails
|
||||||
|
|
||||||
|
Do not do any of the following in this slice:
|
||||||
|
- add a new coverage table or materialized summary artifact
|
||||||
|
- rewrite `InventorySyncService` or `RunInventorySyncJob`
|
||||||
|
- introduce restore-readiness or compare-readiness semantics into coverage
|
||||||
|
- add a first-class stale coverage state family
|
||||||
|
- add page-local badge mappings or local semantic color logic
|
||||||
|
|
||||||
|
## Completion Checklist
|
||||||
|
|
||||||
|
- Count-based KPI summary shipped
|
||||||
|
- Coverage page is tenant-truth-first
|
||||||
|
- Capability metadata is visibly secondary
|
||||||
|
- Run detail has human-readable per-type coverage
|
||||||
|
- Safe continuity works for both authorized and unauthorized viewers
|
||||||
|
- Focused tests and Pint pass
|
||||||
66
specs/177-inventory-coverage-truth/research.md
Normal file
66
specs/177-inventory-coverage-truth/research.md
Normal file
@ -0,0 +1,66 @@
|
|||||||
|
# Phase 0 Research: Inventory Coverage Truth (177)
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
Spec 177 corrects a semantic trust problem on the existing inventory surfaces.
|
||||||
|
|
||||||
|
The current inventory KPI widget computes `Coverage %` from restorable-item share inside `InventoryKpiHeader`, while real per-type sync truth is already persisted in canonical `OperationRun.context['inventory']['coverage']` by `InventorySyncService` and `RunInventorySyncJob`. The current `InventoryCoverage` page is config-driven and capability-first, and inventory-sync run detail still leaves the per-type result largely hidden behind generic run outcome and raw JSON.
|
||||||
|
|
||||||
|
The feature must stay derived, tenant-scoped, and operator-first.
|
||||||
|
|
||||||
|
## Decisions
|
||||||
|
|
||||||
|
### Decision: The coverage basis is the latest completed inventory-sync run with parseable per-type coverage payload
|
||||||
|
- **Rationale**: The spec needs `Succeeded`, `Failed`, `Skipped`, and `Unknown` to be visible as tenant coverage truth. The job currently writes a normalized `context.inventory.coverage` payload before terminalizing the run, including skipped and failed cases that still carry real per-type truth. The narrowest deterministic rule is therefore to select the latest completed `inventory_sync` run for the tenant whose payload can be parsed by `InventoryCoverage::fromContext()`.
|
||||||
|
- **Alternatives considered**:
|
||||||
|
- Latest succeeded or partially succeeded run only: rejected because it would hide relevant skipped or failed per-type truth that the spec explicitly wants operators to see.
|
||||||
|
- Latest attempted run regardless of payload: rejected because a run without parseable coverage payload cannot support per-type coverage truth and would collapse all rows into guesswork.
|
||||||
|
|
||||||
|
### Decision: Coverage remains fully derived from existing truth sources
|
||||||
|
- **Rationale**: The feature can answer the tenant coverage question by combining three already-existing sources: canonical per-type sync truth from `OperationRun.context.inventory.coverage`, observed-item counts from `InventoryItem`, and product capability metadata from `InventoryPolicyTypeMeta` plus `CoverageCapabilitiesResolver`. This satisfies the constitution bias toward deriving before persisting.
|
||||||
|
- **Alternatives considered**:
|
||||||
|
- New coverage table or materialized snapshot: rejected because it would duplicate current-release truth and add lifecycle overhead without new operator value.
|
||||||
|
- Writeback summary JSON to `Tenant`: rejected because the truth already belongs to the latest inventory-sync run and current observed items.
|
||||||
|
|
||||||
|
### Decision: Introduce one narrow runtime contract and resolver as siblings to `InventoryCoverage`
|
||||||
|
- **Rationale**: `InventoryCoverage` is already the canonical parser for the stored run payload. Extending it to perform tenant-scoped run lookup, item-count joins, and follow-up classification would blur responsibilities. A sibling runtime contract such as `TenantCoverageTruth` and a resolver such as `TenantCoverageTruthResolver` keep the low-level parser small while giving the UI one stable read model.
|
||||||
|
- **Alternatives considered**:
|
||||||
|
- Add more behavior directly to `InventoryCoverage`: rejected because it would mix raw payload normalization with tenant-level query and presentation concerns.
|
||||||
|
- Compute the join independently inside each page or widget: rejected because three surfaces would re-own the same truth and regress independently.
|
||||||
|
- Add request-scoped aggregate caching: rejected as unnecessary complexity for this slice.
|
||||||
|
|
||||||
|
### Decision: Replace the KPI percentage with count-based coverage facts
|
||||||
|
- **Rationale**: The spec explicitly says absolute counts are preferred over a percentage unless the percentage is narrowly qualified. Count-based facts such as succeeded types, types needing follow-up, last sync, and items observed answer the operator question directly and avoid false completeness signals.
|
||||||
|
- **Alternatives considered**:
|
||||||
|
- Keep a relabeled percentage such as `Latest sync type coverage`: rejected for the first slice because counts are clearer and avoid another interpretation layer.
|
||||||
|
- Keep the current restorable-item share with different wording: rejected because it still answers the wrong question.
|
||||||
|
|
||||||
|
### Decision: The coverage page becomes one tenant-coverage-first report with capability metadata demoted to secondary treatment
|
||||||
|
- **Rationale**: The current page already has a searchable and filterable table surface. The narrowest correction is to reuse that surface, rebuild the row model around tenant coverage truth, lead with summary + state + follow-up columns, and keep capability metadata in secondary columns or labeled reference treatment.
|
||||||
|
- **Alternatives considered**:
|
||||||
|
- Preserve the current capability-first matrix and add a separate banner: rejected because the primary semantic center would remain wrong.
|
||||||
|
- Split the page into two separate tables for tenant truth and product support: rejected as broader than needed for the first correction slice.
|
||||||
|
|
||||||
|
### Decision: Inventory-sync run detail gets one human-readable per-type coverage section under the existing enterprise-detail stack
|
||||||
|
- **Rationale**: `OperationRunResource` already uses `EnterpriseDetailBuilder` with custom view sections. Adding one `inventory_sync`-specific section under the same pattern is the narrowest way to expose per-type results without inventing a new operational page.
|
||||||
|
- **Alternatives considered**:
|
||||||
|
- Continue relying on raw context JSON: rejected because the spec explicitly forbids leaving this truth buried in JSON.
|
||||||
|
- Build a standalone inventory-sync detail page: rejected because the canonical run viewer already exists.
|
||||||
|
|
||||||
|
### Decision: Do not introduce a first-class stale or freshness coverage state in Spec 177
|
||||||
|
- **Rationale**: The spec lists stale semantics as optional secondary behavior. The current trust defect is the wrong meaning of coverage, not missing freshness taxonomy. Showing the basis timestamp is enough for this slice and avoids broadening the state family.
|
||||||
|
- **Alternatives considered**:
|
||||||
|
- Add `Stale` now as a fifth primary coverage state: rejected because it would expand scope into inventory health and freshness semantics better handled by a later follow-up spec.
|
||||||
|
|
||||||
|
### Decision: Run continuity must be RBAC-safe and explanatory when drill-through is unavailable
|
||||||
|
- **Rationale**: The spec requires that coverage surfaces never emit broken or implicitly inaccessible next actions. The UI must only link to the basis run when the user is entitled to open it; otherwise it must show clear non-clickable guidance.
|
||||||
|
- **Alternatives considered**:
|
||||||
|
- Always show the run link and let authorization fail after navigation: rejected because it creates dead-end operator flows and can leak existence.
|
||||||
|
- Hide all run references unless the user can open them: rejected because the spec still requires clear explanation of what the coverage statement is based on.
|
||||||
|
|
||||||
|
## Clarifications Resolved
|
||||||
|
|
||||||
|
- **Relevant inventory sync**: The basis run is payload-bearing and completed; outcome alone is not sufficient.
|
||||||
|
- **Unknown semantics**: `Unknown` means there is no current tenant coverage result for that supported type in the chosen basis run, even if items still exist from older observation.
|
||||||
|
- **Capability separation**: Restore mode, risk, dependency support, and similar metadata remain visible only as secondary support reference, not as coverage truth.
|
||||||
|
- **Scope limit**: No new persistence, no backend rewrite, and no freshness-state expansion are included in this slice.
|
||||||
235
specs/177-inventory-coverage-truth/spec.md
Normal file
235
specs/177-inventory-coverage-truth/spec.md
Normal file
@ -0,0 +1,235 @@
|
|||||||
|
# Feature Specification: Spec 177 - Inventory Coverage Truth
|
||||||
|
|
||||||
|
**Feature Branch**: `feat/177-inventory-coverage-truth`
|
||||||
|
**Created**: 2026-04-05
|
||||||
|
**Status**: Draft
|
||||||
|
**Input**: User description: "Spec 177 - Inventory Coverage Truth"
|
||||||
|
|
||||||
|
## Spec Scope Fields *(mandatory)*
|
||||||
|
|
||||||
|
- **Scope**: tenant + canonical-view
|
||||||
|
- **Primary Routes**:
|
||||||
|
- `/admin/inventory-items` and inventory item detail routes while a tenant context is active
|
||||||
|
- `/admin/coverage` while a tenant context is active
|
||||||
|
- `/admin/operations` and `/admin/operations/{run}` for canonical inventory-sync drill-through
|
||||||
|
- **Data Ownership**:
|
||||||
|
- Tenant-owned `InventoryItem` rows and per-type observed item counts remain the tenant inventory observation truth.
|
||||||
|
- Workspace-owned `OperationRun` rows with a tenant reference remain the canonical execution truth for `inventory_sync`, including the existing per-type payload stored under `context.inventory.coverage`.
|
||||||
|
- Product capability and support metadata remain derived from the supported-type catalog and capability metadata already exposed through inventory policy-type meta and related capability resolvers.
|
||||||
|
- This feature introduces no new persisted coverage table, no materialized coverage snapshot, and no writeback artifact.
|
||||||
|
- **RBAC**:
|
||||||
|
- Workspace membership, tenant entitlement, and tenant inventory view capability remain required for the inventory items list and the coverage page.
|
||||||
|
- Starting an inventory sync from the inventory items list remains gated by the canonical tenant inventory sync capability.
|
||||||
|
- Inventory-sync run drill-through remains governed by existing operation-run authorization: workspace membership, tenant entitlement when a run is tenant-bound, and any run-specific required capability already attached to that run.
|
||||||
|
- Coverage surfaces must never expose broken or unauthorized next-action links; when a user can see coverage truth but cannot open the backing run, the UI must degrade safely.
|
||||||
|
|
||||||
|
For canonical-view specs, the spec MUST define:
|
||||||
|
|
||||||
|
- **Default filter behavior when tenant-context is active**: Coverage-to-operations navigation opens the exact latest relevant inventory-sync run when one is available. If the operator needs the broader operations list instead, the canonical destination opens prefiltered to the active tenant and the inventory-sync operation family.
|
||||||
|
- **Explicit entitlement checks preventing cross-tenant leakage**: Coverage summary counts, per-type rows, observed-item counts, run references, and follow-up actions must be derived only from the active tenant scope and from operation runs the current user is entitled to inspect. Non-members remain deny-as-not-found, and inaccessible runs must not leak existence through clickable dead ends.
|
||||||
|
|
||||||
|
## UI/UX Surface Classification *(mandatory when operator-facing surfaces are changed)*
|
||||||
|
|
||||||
|
| Surface | Surface Type | Primary Inspect/Open Model | Row Click | Secondary Actions Placement | Destructive Actions Placement | Canonical Collection Route | Canonical Detail Route | Scope Signals | Canonical Noun | Critical Truth Visible by Default | Exception Type |
|
||||||
|
|---|---|---|---|---|---|---|---|---|---|---|---|
|
||||||
|
| Inventory items list with KPI summary | Read-only Registry / Report Surface | Full-row click to inventory item detail remains the one inspect model for item records | required | `Run Inventory Sync` stays in the header; coverage and run continuity links stay in the KPI summary or adjacent summary content | none | `/admin/inventory-items` | Inventory item detail route for the selected tenant item | Active tenant context, current filters, and the last relevant sync reference anchor the list to one tenant | Inventory Items / Inventory Item | Observed items plus truthful tenant coverage summary, including latest sync reference and follow-up counts rather than a misleading restorable-item percentage | Embedded summary surface inside a read-only resource |
|
||||||
|
| Inventory coverage page | Read-only Registry / Report Surface | The page itself is the canonical tenant coverage report; diagnostics drill through via explicit summary links to the relevant sync run | forbidden | Summary-level diagnostic and retry actions live above or beside the per-type table; capability reference remains secondary on the page | none | `/admin/coverage` | `/admin/operations/{run}` for the cited relevant inventory-sync run | Active tenant context, cited run timestamp, cited run identity, and visible succeeded or failed or skipped or unknown counts | Inventory Coverage | Per-type tenant coverage truth, follow-up need, and the run the truth is based on, clearly separated from product capability reference | Derived per-type rows have no standalone record detail |
|
||||||
|
| Inventory sync run detail | Detail-first Operational Surface | Dedicated run detail page | forbidden | Existing back, refresh, related-link, and safe diagnostic actions remain in the detail header | none | `/admin/operations` | `/admin/operations/{run}` | Workspace context, referenced tenant, run timing, run outcome, and inventory-sync identity | Inventory Sync Run / Operation Run | Execution outcome plus human-readable per-type coverage results instead of raw JSON alone | none |
|
||||||
|
|
||||||
|
## Operator Surface Contract *(mandatory when operator-facing surfaces are changed)*
|
||||||
|
|
||||||
|
| Surface | Primary Persona | Surface Type | Primary Operator Question | Default-visible Information | Diagnostics-only Information | Status Dimensions Used | Mutation Scope | Primary Actions | Dangerous Actions |
|
||||||
|
|---|---|---|---|---|---|---|---|---|---|
|
||||||
|
| Inventory items list with KPI summary | Tenant operator | List with embedded summary | What has this tenant inventory observed, and does current coverage need follow-up before I trust that view? | Total observed items, last relevant sync reference, counts of succeeded types and types needing follow-up, and the inventory item list itself | Raw run context, low-level provider failure details, and secondary capability metadata | coverage truth, execution recency, item presence | `Run Inventory Sync` continues to affect the Microsoft tenant and TenantPilot inventory observation state; the list itself is read-only | Open inventory item, Run Inventory Sync, Open coverage truth | none |
|
||||||
|
| Inventory coverage page | Tenant operator | Derived report | Which supported types are currently covered for this tenant, which are not, and what should I do next? | Coverage summary, follow-up summary, cited relevant sync, and a per-type table showing state, timestamp or run reference context, observed item count, and follow-up need | Secondary support or capability reference, dependency capability, raw reason codes, and deep diagnostics | coverage truth, execution truth, item presence, capability reference as a separate domain | none on the report itself; any sync retry action keeps the existing inventory-sync mutation scope | View latest sync run, Run inventory sync again when authorized, Review follow-up types | none |
|
||||||
|
| Inventory sync run detail | Tenant operator or workspace operator with tenant entitlement | Detail | What did this inventory sync actually do per type, and how does that explain the tenant's current coverage truth? | Run status and outcome, human-readable per-type results, counts, related tenant context, and next-step guidance | Full context JSON, raw failure payloads, and deeper technical fragments | execution outcome, per-type coverage result, item counts, next-step guidance | none on the detail page itself | Refresh, open related coverage context, open related records | none |
|
||||||
|
|
||||||
|
## Proportionality Review *(mandatory when structural complexity is introduced)*
|
||||||
|
|
||||||
|
- **New source of truth?**: No.
|
||||||
|
- **New persisted entity/table/artifact?**: No.
|
||||||
|
- **New abstraction?**: Yes, one narrow derived coverage-truth assembler or read model may be required to combine supported types, the latest relevant inventory-sync result, observed item counts, and follow-up classification on existing surfaces.
|
||||||
|
- **New enum/state/reason family?**: Yes, a narrow derived tenant-coverage state family must include `Unknown` or `Not synced yet` alongside the existing successful, failed, and skipped outcomes. It remains derived, not persisted.
|
||||||
|
- **New cross-domain UI framework/taxonomy?**: No.
|
||||||
|
- **Current operator problem**: Operators currently see a `Coverage` KPI that is really a restorable-item share, while real per-type sync truth lives in inventory-sync run context and is almost invisible on the operator-facing surfaces that should answer coverage questions.
|
||||||
|
- **Existing structure is insufficient because**: The KPI widget currently compresses restorable-item share into `Coverage %`, the coverage page renders a capability or support matrix rather than tenant sync truth, and inventory-sync run detail does not expose per-type results in an operator-first format.
|
||||||
|
- **Narrowest correct implementation**: Derive one tenant coverage view from the existing latest relevant inventory-sync run, the supported-type catalog, and current inventory-item counts; surface it on the existing inventory pages and inventory-sync run detail; keep product capability reference secondary; add no new persistence.
|
||||||
|
- **Ownership cost**: Focused derived-read-model logic, presentation cleanup on three existing surfaces, and regression coverage across tenant coverage, operations drill-through, and RBAC-safe degradation.
|
||||||
|
- **Alternative intentionally rejected**: A new coverage table, a restore-readiness score, a compare-readiness layer, a dashboard-wide inventory health program, or a broader content-depth taxonomy were rejected because the immediate defect is misleading operator truth on already-shipped inventory surfaces.
|
||||||
|
- **Release truth**: Current-release truth correction that prepares later usefulness, missing-item, and dashboard follow-up work.
|
||||||
|
|
||||||
|
## User Scenarios & Testing *(mandatory)*
|
||||||
|
|
||||||
|
### User Story 1 - Read truthful coverage at a glance (Priority: P1)
|
||||||
|
|
||||||
|
As a tenant operator, I can open inventory surfaces and immediately understand which supported inventory types are currently covered for my tenant and which types still need follow-up.
|
||||||
|
|
||||||
|
**Why this priority**: The current trust failure starts at first glance. If the leading KPI and coverage page are semantically wrong, every later decision is biased by false calm.
|
||||||
|
|
||||||
|
**Independent Test**: Can be fully tested by seeding one tenant with a latest relevant inventory-sync run that contains a mix of succeeded, failed, skipped, and omitted types plus current inventory items, then rendering the inventory items list and the coverage page.
|
||||||
|
|
||||||
|
**Acceptance Scenarios**:
|
||||||
|
|
||||||
|
1. **Given** a tenant has a latest relevant inventory-sync run with mixed per-type outcomes, **When** the operator opens the coverage page, **Then** every supported type appears with a truthful state of succeeded, failed, skipped, or unknown and the follow-up summary highlights the non-succeeded types.
|
||||||
|
2. **Given** a supported type has observed items but no current coverage result in the relevant sync basis, **When** the operator opens the coverage surface, **Then** the type appears as unknown or not synced yet rather than implicitly covered.
|
||||||
|
3. **Given** no relevant inventory-sync run exists for the tenant, **When** the operator opens the inventory summary surfaces, **Then** the UI makes clear that there is no current coverage result and points the operator toward starting inventory sync instead of presenting a positive coverage claim.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### User Story 2 - Move cleanly from coverage truth to run truth (Priority: P1)
|
||||||
|
|
||||||
|
As a tenant operator, I can see which inventory-sync run the current coverage statement is based on and open that run or a safe equivalent diagnostic path without losing tenant context.
|
||||||
|
|
||||||
|
**Why this priority**: Coverage claims must be recoverable. If the UI states that a tenant is covered or not covered but cannot show the exact run that established that claim, the truth is not auditable.
|
||||||
|
|
||||||
|
**Independent Test**: Can be fully tested by seeding a tenant with a relevant inventory-sync run and verifying that the coverage page and inventory summary surfaces cite the run, the timestamp, and the correct drill-through behavior for both authorized and unauthorized viewers.
|
||||||
|
|
||||||
|
**Acceptance Scenarios**:
|
||||||
|
|
||||||
|
1. **Given** current coverage is derived from a specific inventory-sync run, **When** the operator opens the coverage page, **Then** the page shows the cited run and timestamp and provides a direct path to that run.
|
||||||
|
2. **Given** the current user can see inventory truth but cannot open the backing run, **When** the coverage page renders, **Then** the page shows safe explanatory guidance instead of a broken or unauthorized run link.
|
||||||
|
3. **Given** the operator needs the broader operations history, **When** the operator follows the diagnostic path from inventory coverage, **Then** the operations destination stays scoped to the originating tenant and the inventory-sync operation family.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### User Story 3 - Diagnose per-type inventory-sync results without raw JSON (Priority: P2)
|
||||||
|
|
||||||
|
As a tenant operator, I can open an inventory-sync run detail page and read the per-type results in human terms, so I understand how execution outcome and coverage follow-up relate.
|
||||||
|
|
||||||
|
**Why this priority**: The backend already knows this truth. The missing value is readable diagnosis, not a new execution system.
|
||||||
|
|
||||||
|
**Independent Test**: Can be fully tested by seeding an inventory-sync run whose per-type payload contains mixed outcomes and verifying that the run detail page renders a readable per-type breakdown and next-step guidance without requiring raw JSON inspection.
|
||||||
|
|
||||||
|
**Acceptance Scenarios**:
|
||||||
|
|
||||||
|
1. **Given** an inventory-sync run has per-type coverage payload data, **When** the operator opens the run detail page, **Then** the page shows a human-readable per-type result section rather than burying the truth only in raw context JSON.
|
||||||
|
2. **Given** the run outcome is partially successful, **When** the operator views run detail, **Then** the page separates overall execution outcome from the specific types that still need follow-up.
|
||||||
|
3. **Given** one or more types failed or were skipped, **When** the operator views run detail, **Then** the next-step guidance points to the relevant follow-up path such as reviewing provider or permission problems or running inventory sync again.
|
||||||
|
|
||||||
|
### Edge Cases
|
||||||
|
|
||||||
|
- The tenant has never completed a relevant inventory-sync run, so all supported types must remain unknown instead of reading as covered by default.
|
||||||
|
- The latest relevant run omitted one or more supported types entirely, which must result in unknown rather than silent success.
|
||||||
|
- Observed items exist from an older run, but the latest relevant run did not establish current coverage truth for that type.
|
||||||
|
- The latest relevant run failed or was blocked before processing most or all selected types.
|
||||||
|
- A type was intentionally skipped because of selection or foundation toggles, and the UI must show that truth without implying success.
|
||||||
|
- The current user may view inventory surfaces but may not satisfy the required capability for the cited inventory-sync run.
|
||||||
|
- The supported-type catalog may change after the latest sync, so the coverage surface must distinguish current support reference from tenant sync truth.
|
||||||
|
|
||||||
|
## Requirements *(mandatory)*
|
||||||
|
|
||||||
|
**Constitution alignment (required):** This feature introduces no new Microsoft Graph contract, no new change operation, and no new long-running workflow. It reuses the existing `inventory_sync` operation truth already written into canonical `OperationRun` records and corrects how that truth is surfaced on inventory pages.
|
||||||
|
|
||||||
|
**Constitution alignment (PROP-001 / ABSTR-001 / PERSIST-001 / STATE-001 / BLOAT-001):** The feature is intentionally narrow. It introduces no new persistence and no generic framework. A single derived coverage-truth assembler and one derived `Unknown` coverage state are justified because the existing surfaces currently misrepresent three different truths as one. The feature follows the default bias of deriving before persisting, replacing misleading semantics before layering new ones, and being explicit instead of generic.
|
||||||
|
|
||||||
|
**Constitution alignment (OPS-UX):** The existing `inventory_sync` `OperationRun` remains canonical. This feature does not change queued-toast, progress-surface, or terminal-notification behavior. `OperationRun.status` and `OperationRun.outcome` remain service-owned through `OperationRunService`, `summary_counts` remain numeric-only and canonical, scheduled or system-run behavior is unchanged, and regression coverage must verify run-detail rendering and coverage-to-run continuity without inventing new operation feedback surfaces.
|
||||||
|
|
||||||
|
**Constitution alignment (RBAC-UX):** This feature spans tenant inventory surfaces on `/admin` with active tenant context and canonical operations surfaces on `/admin/operations`. Non-members or actors outside the current tenant scope remain `404`. In-scope members missing the required run capability remain `403` on the run detail itself. Server-side authorization remains the source of truth through the existing capability resolver on inventory surfaces and `OperationRunPolicy` plus run-capability resolution for run drill-through. No raw capability strings or role-string checks are introduced. No new destructive action is added.
|
||||||
|
|
||||||
|
**Constitution alignment (OPS-EX-AUTH-001):** Not applicable. No authentication handshake behavior is changed.
|
||||||
|
|
||||||
|
**Constitution alignment (BADGE-001):** Any added or updated coverage-state badges must stay centralized. `Succeeded`, `Failed`, `Skipped`, and `Unknown` must use shared badge semantics rather than page-local color language, and regression tests must cover the new or revised mappings.
|
||||||
|
|
||||||
|
**Constitution alignment (UI-FIL-001):** The feature reuses Filament stats widgets, tables, sections, badges, infolists, and actions already present on the affected inventory and operations surfaces. It avoids local replacement markup for status language and does not require publishing internal Filament views. No exception is expected.
|
||||||
|
|
||||||
|
**Constitution alignment (UI-STD-001):** The modified inventory items list and inventory coverage page are governed by `docs/product/standards/list-surface-review-checklist.md`, and implementation must review both surfaces against that checklist before sign-off.
|
||||||
|
|
||||||
|
**Constitution alignment (UI-NAMING-001):** Operator-facing vocabulary must distinguish `Coverage`, `Support`, `Capability`, `Restore mode`, `Compare`, `Last sync`, and `Needs follow-up`. `Coverage` means tenant-specific sync coverage for supported types. `Coverage %` is forbidden unless explicitly qualified as the latest sync's type coverage. `Restore ready` and `Compare ready` must not be used as synonyms for coverage.
|
||||||
|
|
||||||
|
**Constitution alignment (UI-CONST-001 / UI-SURF-001 / UI-HARD-001 / UI-EX-001 / UI-REVIEW-001):** The inventory items list keeps row click for real inventory records. The inventory coverage page remains a derived report with an approved exception: its per-type rows do not open a standalone detail record. Diagnostics drill through through explicit links to the relevant run. The canonical collection and detail routes are stated above, and critical truth visible by default must be tenant coverage truth rather than support metadata.
|
||||||
|
|
||||||
|
**Constitution alignment (OPSURF-001):** Default-visible content must stay operator-first. Coverage truth, execution truth, and item presence must be shown as separate dimensions. Capability and support metadata remain diagnostics or secondary reference. The existing `Run Inventory Sync` action continues to communicate a tenant-affecting sync over the external tenant plus TenantPilot observation state and follows the existing safe execution pattern.
|
||||||
|
|
||||||
|
**Constitution alignment (UI-SEM-001 / LAYER-001 / TEST-TRUTH-001):** Direct mapping from one existing source is insufficient because current truth is split across operation-run context, inventory-item counts, and supported-type metadata. This feature may introduce one narrow derived coverage view, but it must replace the misleading KPI and capability-centric interpretation rather than add a new general semantic framework. Tests must focus on operator consequences: truthful summaries, safe follow-up, and auditable continuity.
|
||||||
|
|
||||||
|
**Constitution alignment (Filament Action Surfaces):** The Action Surface Contract remains satisfied with one approved exception on `InventoryCoverage`: derived per-type rows do not have a standalone record detail. Redundant `View` actions remain absent, empty action groups remain absent, and no new destructive action is introduced. UI-FIL-001 is satisfied with no approved exception beyond the existing derived-row detail exemption.
|
||||||
|
|
||||||
|
**Constitution alignment (UX-001 - Layout & Information Architecture):** The inventory coverage page remains a search and filter driven table surface with a truthful summary above the table and a clear single empty-state CTA. The inventory items list remains a read-only list and detail flow. Inventory-sync run detail remains a view-style operational page. Any new summary panels or diagnostic sections must use shared sections or cards and keep raw JSON secondary.
|
||||||
|
|
||||||
|
### Functional Requirements
|
||||||
|
|
||||||
|
- **FR-177-001**: Default-visible inventory summary surfaces MUST NOT present a KPI or label that can be read as tenant inventory completeness when it actually represents restorable-item share, capability coverage, or any other secondary truth. The current unqualified `Coverage %` must be removed or replaced with an explicitly qualified type-coverage statement.
|
||||||
|
- **FR-177-002**: The tenant coverage report MUST show every supported inventory type for the selected tenant with, at minimum, the type, current coverage state, the relevant sync reference or time basis, observed item count, and whether follow-up is needed.
|
||||||
|
- **FR-177-003**: Coverage state MUST be derived from real inventory-sync truth and MUST include at least `Succeeded`, `Failed`, `Skipped`, and `Unknown` or `Not synced yet`.
|
||||||
|
- **FR-177-004**: Coverage surfaces MUST make clear which relevant inventory-sync run they are based on, including a visible timestamp and a direct or safely degraded path to the canonical run detail. If no relevant sync exists, the surface must say so plainly.
|
||||||
|
- **FR-177-005**: Run execution truth and coverage truth MUST remain separate. A partial or failed run outcome must not replace the per-type statement of which supported types are currently covered and which still need follow-up.
|
||||||
|
- **FR-177-006**: The inventory coverage page MUST primarily answer the tenant question `Which supported types are currently inventoried successfully for this tenant, and where are the gaps?` It must not remain centered on a product support matrix.
|
||||||
|
- **FR-177-007**: Product support and capability metadata MAY remain available only as a clearly secondary reference layer with labels such as `Support`, `Capability`, or `Restore mode`. It must not be confused with tenant coverage truth.
|
||||||
|
- **FR-177-008**: When one or more supported types are failed, skipped, or unknown, the coverage surfaces MUST prominently signal that follow-up is required, how many types are affected, and which types are highest priority to review first. Follow-up priority MUST use a deterministic severity-first order of `Failed` before `Unknown` before `Skipped`, then observed item count descending, then inventory type label ascending for stable ties.
|
||||||
|
- **FR-177-009**: A supported type without a current tenant coverage result MUST appear as `Unknown` or `Not synced yet`. It must not appear positively because items exist, because the type is supported, or because the product has richer capability metadata for it.
|
||||||
|
- **FR-177-010**: Observed item counts MUST remain clearly separate from coverage truth and MUST NOT be styled or worded as proof of completeness, restore usefulness, or compare usefulness.
|
||||||
|
- **FR-177-011**: Coverage surfaces MUST provide real next actions such as viewing the latest relevant run, running inventory sync again, reviewing failed or skipped types, or reviewing provider or permission issues. When a next action cannot be opened by the current user, the surface MUST show safe explanatory guidance instead of a dead-end link.
|
||||||
|
- **FR-177-012**: Inventory-sync run detail MUST expose per-type results in a human-readable section and MUST NOT rely on raw JSON alone to communicate which supported types succeeded, failed, or were skipped.
|
||||||
|
- **FR-177-013**: All coverage signals, per-type states, counts, and run drill-throughs MUST remain tenant-scoped and RBAC-conformant.
|
||||||
|
- **FR-177-014**: Coverage MUST NOT imply restore readiness. Any restore metadata shown on the same surface must remain explicitly separate from the coverage statement.
|
||||||
|
- **FR-177-015**: Coverage MUST NOT imply compare readiness. Any compare-related context shown on the same surface must remain secondary and explicitly distinct from coverage truth.
|
||||||
|
- **FR-177-016**: The feature MUST be derived from existing inventory items, supported-type metadata, and canonical inventory-sync run truth without adding a new coverage persistence model or rewriting the inventory-sync backend.
|
||||||
|
|
||||||
|
## UI Action Matrix *(mandatory when Filament is changed)*
|
||||||
|
|
||||||
|
| Surface | Location | Header Actions | Inspect Affordance (List/Table) | Row Actions (max 2 visible) | Bulk Actions (grouped) | Empty-State CTA(s) | View Header Actions | Create/Edit Save+Cancel | Audit log? | Notes / Exemptions |
|
||||||
|
|---|---|---|---|---|---|---|---|---|---|---|
|
||||||
|
| Inventory items list with KPI summary | `app/Filament/Resources/InventoryItemResource.php`, `app/Filament/Resources/InventoryItemResource/Pages/ListInventoryItems.php`, `app/Filament/Widgets/Inventory/InventoryKpiHeader.php` | `Run Inventory Sync` | Existing one-click open path to inventory item detail remains the only inspect model for item rows | none | none | Existing inventory list empty-state CTA remains unchanged | n/a | n/a | existing only for sync dispatch | KPI labels, summary facts, and coverage or run continuity links are changed by this spec. The sync action remains capability-gated and non-destructive. |
|
||||||
|
| Inventory coverage page | `app/Filament/Pages/InventoryCoverage.php` | none; summary-level diagnostic or retry CTAs may appear above the table | Approved exception: derived per-type rows do not have a standalone detail record | none | none | `Clear filters` | n/a | n/a | no new audit behavior | Action Surface Contract remains satisfied through the approved derived-row exception. Run continuity is provided through explicit summary links rather than row inspect actions. |
|
||||||
|
| Inventory sync run detail | `app/Filament/Pages/Operations/TenantlessOperationRunViewer.php` and the existing operation-run detail rendering stack | Existing back, refresh, and related-link header actions remain | Direct page | n/a | none | n/a | Existing back, refresh, and related-link header actions remain | n/a | no new audit behavior | This spec adds human-readable per-type inventory coverage results to the existing detail page and does not introduce new mutations. |
|
||||||
|
|
||||||
|
### Key Entities *(include if feature involves data)*
|
||||||
|
|
||||||
|
- **Tenant coverage truth**: The tenant-specific statement of whether each supported inventory type is currently covered successfully, failed, skipped, or still unknown.
|
||||||
|
- **Relevant inventory-sync run**: The latest completed `inventory_sync` execution for the active tenant that contains parseable `context.inventory.coverage` truth. Overall run outcome does not disqualify it when usable per-type payload exists, because `Failed` and `Skipped` remain meaningful operator truth.
|
||||||
|
- **Coverage state**: The per-type result family used for operator decisions: `Succeeded`, `Failed`, `Skipped`, and `Unknown` or `Not synced yet`.
|
||||||
|
- **Observed item count**: The current number of inventory items observed for one supported type in the selected tenant, kept separate from coverage truth.
|
||||||
|
- **Capability reference**: Static product support metadata such as support mode, restore mode, dependency capability, risk, or similar type metadata that must remain distinct from tenant coverage truth.
|
||||||
|
|
||||||
|
## Success Criteria *(mandatory)*
|
||||||
|
|
||||||
|
### Measurable Outcomes
|
||||||
|
|
||||||
|
- **SC-177-001**: In seeded regression scenarios, the default-visible inventory items summary, inventory coverage page, and inventory-sync run detail together expose covered versus follow-up types, basis-run context, and next-step guidance without requiring raw JSON inspection.
|
||||||
|
- **SC-177-002**: In regression coverage, 100% of default-visible inventory summary surfaces stop showing an unqualified `Coverage %` or any equivalent metric that can be read as tenant inventory completeness.
|
||||||
|
- **SC-177-003**: In regression coverage, every supported type shown on the tenant coverage report resolves to exactly one of `Succeeded`, `Failed`, `Skipped`, or `Unknown`, and the summary follow-up count matches the number of non-succeeded types.
|
||||||
|
- **SC-177-004**: In tested authorization scenarios, operators can either open the cited relevant inventory-sync run from coverage surfaces or receive a safe non-clickable explanation in 100% of cases.
|
||||||
|
- **SC-177-005**: The feature ships without a required schema migration, without a new coverage table, and without a rewrite of the inventory-sync backend.
|
||||||
|
|
||||||
|
## Assumptions
|
||||||
|
|
||||||
|
- Existing inventory-sync runs already persist per-type successful or failed or skipped truth for attempted types inside canonical `OperationRun` context.
|
||||||
|
- The supported and foundation type catalog remains the authoritative denominator for tenant coverage reporting.
|
||||||
|
- `Unknown` is a derived tenant state used when current coverage truth for a supported type cannot be established from the relevant sync basis, even if older items still exist.
|
||||||
|
- Inventory item list and detail pages remain the correct places for item-level observation; this spec does not promote them into restore-readiness or compare-readiness surfaces.
|
||||||
|
|
||||||
|
## Non-Goals
|
||||||
|
|
||||||
|
- Redesigning the backup or restore domain
|
||||||
|
- Introducing a restore-readiness algorithm or item-level readiness score
|
||||||
|
- Introducing a compare-readiness domain for inventory coverage
|
||||||
|
- Building a missing or vanished item workflow
|
||||||
|
- Launching a dashboard-wide inventory health program
|
||||||
|
- Adding a new persistence table or materialized coverage artifact
|
||||||
|
- Rewriting the inventory-sync backend
|
||||||
|
|
||||||
|
## Dependencies
|
||||||
|
|
||||||
|
- Spec 039 - Inventory Program
|
||||||
|
- Spec 040 - Inventory Core
|
||||||
|
- Spec 041 - Inventory UI
|
||||||
|
- Spec 042 - Inventory Dependencies Graph
|
||||||
|
- Existing inventory surfaces, inventory-sync execution, supported-type metadata, and canonical operations drill-through behavior already present in the repo
|
||||||
|
|
||||||
|
## Follow-up Spec Candidates
|
||||||
|
|
||||||
|
- **Inventory Content Depth & Usefulness**: Separate coverage truth from content depth, restore usefulness, and compare usefulness.
|
||||||
|
- **Inventory Missing / Vanished Surface**: Add explicit follow-up for missing or vanished items once coverage truth is no longer overloaded.
|
||||||
|
- **Dashboard Inventory Health**: Propagate truthful coverage and freshness signals onto broader tenant overview surfaces after the core coverage semantics are corrected.
|
||||||
|
|
||||||
|
## Definition of Done
|
||||||
|
|
||||||
|
Spec 177 is complete when:
|
||||||
|
|
||||||
|
- no operator can read a default-visible coverage metric as tenant inventory completeness when it is actually describing capability or restorable-item share,
|
||||||
|
- the inventory coverage page shows truthful per-type tenant coverage and makes follow-up obvious,
|
||||||
|
- the inventory items list and its KPI summary cite truthful coverage and last-sync context rather than a misleading percentage,
|
||||||
|
- inventory-sync run detail exposes per-type results in human-readable form,
|
||||||
|
- capability and support metadata remain available but secondary and explicitly labeled,
|
||||||
|
- coverage-to-run continuity is auditable and RBAC-safe,
|
||||||
|
- and the improvement ships without a new persisted coverage model or an inventory-sync backend rewrite.
|
||||||
224
specs/177-inventory-coverage-truth/tasks.md
Normal file
224
specs/177-inventory-coverage-truth/tasks.md
Normal file
@ -0,0 +1,224 @@
|
|||||||
|
# Tasks: Inventory Coverage Truth
|
||||||
|
|
||||||
|
**Input**: Design documents from `/specs/177-inventory-coverage-truth/`
|
||||||
|
**Prerequisites**: `plan.md` (required), `spec.md` (required for user stories), `research.md`, `data-model.md`, `contracts/`, `quickstart.md`
|
||||||
|
|
||||||
|
**Tests**: Required. Use Pest coverage in `tests/Unit/Support/Inventory/TenantCoverageTruthResolverTest.php`, `tests/Unit/Badges/BadgeCatalogTest.php`, `tests/Unit/Badges/InventoryCoverageStateBadgesTest.php`, `tests/Feature/Filament/InventoryCoverageTableTest.php`, `tests/Feature/Filament/InventoryCoverageAdminTenantParityTest.php`, `tests/Feature/Filament/InventoryPagesTest.php`, `tests/Feature/Filament/InventoryItemResourceTest.php`, `tests/Feature/Filament/InventoryCoverageRunContinuityTest.php`, `tests/Feature/Filament/OperationRunEnterpriseDetailPageTest.php`, `tests/Feature/Inventory/InventorySyncServiceTest.php`, `tests/Feature/Inventory/InventorySyncStartSurfaceTest.php`, `tests/Feature/Inventory/RunInventorySyncJobTest.php`, `tests/Feature/Operations/TenantlessOperationRunViewerTest.php`, and `tests/Feature/Rbac/InventoryItemResourceAuthorizationTest.php`.
|
||||||
|
**Operations**: This feature reuses the existing `inventory_sync` `OperationRun` and does not introduce a new run type or change lifecycle ownership. Tasks must keep canonical run links and read-time rendering aligned without changing queued execution semantics.
|
||||||
|
**RBAC**: Existing workspace membership, tenant entitlement, and 404 vs 403 semantics remain authoritative. Tasks must preserve tenant-safe rendering and ensure basis-run links degrade safely when the current user cannot open the run.
|
||||||
|
**Operator Surfaces**: The inventory items list KPI header, the inventory coverage page, and canonical inventory-sync run detail must become tenant-coverage-first while keeping low-level diagnostics secondary.
|
||||||
|
**Filament UI Action Surfaces**: No new destructive actions or new action inventories are added. The existing `Run Inventory Sync` header action remains capability-gated and non-destructive, and the `InventoryCoverage` page keeps its derived-row no-detail exception.
|
||||||
|
**Filament UI UX-001**: No new create or edit screens are introduced. The inventory coverage page remains a searchable, filterable table with a single clear empty-state CTA, and run detail remains an enterprise-detail view surface.
|
||||||
|
**Badges**: Coverage-state semantics must stay centralized through `BadgeDomain`, `BadgeCatalog`, and `BadgeRenderer`; no page-local badge mappings are allowed.
|
||||||
|
|
||||||
|
**Organization**: Tasks are grouped by user story so each story can be implemented and validated as an independent increment once the shared tenant coverage contract is in place.
|
||||||
|
|
||||||
|
## Phase 1: Setup (Shared Coverage Truth Scaffolding)
|
||||||
|
|
||||||
|
**Purpose**: Create the narrow runtime and regression entry points required for tenant coverage truth.
|
||||||
|
|
||||||
|
- [X] T001 [P] Create the derived tenant coverage truth scaffolding in `app/Support/Inventory/TenantCoverageTruth.php` and `app/Support/Inventory/TenantCoverageTruthResolver.php`
|
||||||
|
- [X] T002 [P] Create the focused regression scaffolding in `tests/Unit/Support/Inventory/TenantCoverageTruthResolverTest.php`, `tests/Unit/Badges/InventoryCoverageStateBadgesTest.php`, and `tests/Feature/Filament/InventoryCoverageRunContinuityTest.php`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 2: Foundational (Blocking Coverage Contract)
|
||||||
|
|
||||||
|
**Purpose**: Build the shared derived coverage contract, basis-run selection, and centralized badge semantics that all user stories depend on.
|
||||||
|
|
||||||
|
**⚠️ CRITICAL**: No user story work should begin until this phase is complete.
|
||||||
|
|
||||||
|
- [X] T003 [P] Extend basis-run and mixed-outcome payload coverage assertions in `tests/Unit/Support/Inventory/TenantCoverageTruthResolverTest.php` and `tests/Feature/Inventory/RunInventorySyncJobTest.php`
|
||||||
|
- [X] T004 [P] Add centralized inventory coverage badge mapping assertions in `tests/Unit/Badges/BadgeCatalogTest.php` and `tests/Unit/Badges/InventoryCoverageStateBadgesTest.php`
|
||||||
|
- [X] T005 [P] Implement the latest completed coverage-bearing inventory-sync selection helper in `app/Models/OperationRun.php`
|
||||||
|
- [X] T006 [P] Implement the derived runtime fields and row contract in `app/Support/Inventory/TenantCoverageTruth.php`
|
||||||
|
- [X] T007 Implement tenant coverage resolution, item-count joins, follow-up classification, and basis-run metadata in `app/Support/Inventory/TenantCoverageTruthResolver.php`
|
||||||
|
- [X] T008 Implement the centralized inventory coverage state badge domain and mapper in `app/Support/Badges/BadgeDomain.php`, `app/Support/Badges/BadgeCatalog.php`, and `app/Support/Badges/Domains/InventoryCoverageStateBadge.php`
|
||||||
|
|
||||||
|
**Checkpoint**: A single derived tenant coverage contract now exists for all supported types, with centralized badge semantics and deterministic basis-run selection.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 3: User Story 1 - Read Truthful Coverage At A Glance (Priority: P1) 🎯 MVP
|
||||||
|
|
||||||
|
**Goal**: Make the inventory KPI header and coverage page answer the tenant coverage question directly instead of implying completeness from restorable-item share or support metadata.
|
||||||
|
|
||||||
|
**Independent Test**: Seed a tenant with a coverage-bearing inventory-sync run that includes succeeded, failed, skipped, and omitted types plus current inventory items, then verify the inventory items list and coverage page show truthful counts, `Unknown` rows, and follow-up emphasis.
|
||||||
|
|
||||||
|
### Tests for User Story 1
|
||||||
|
|
||||||
|
- [X] T009 [P] [US1] Rewrite summary-surface expectations for truthful count-based coverage on the inventory items list in `tests/Feature/Filament/InventoryPagesTest.php` and `tests/Feature/Filament/InventoryItemResourceTest.php`
|
||||||
|
- [X] T010 [P] [US1] Add coverage-table assertions for `Succeeded`, `Failed`, `Skipped`, `Unknown`, deterministic follow-up priority, no-basis-run messaging, and secondary support, restore, and compare metadata in `tests/Feature/Filament/InventoryCoverageTableTest.php` and `tests/Feature/Filament/InventoryCoverageAdminTenantParityTest.php`
|
||||||
|
|
||||||
|
### Implementation for User Story 1
|
||||||
|
|
||||||
|
- [X] T011 [P] [US1] Replace the misleading coverage percentage with count-based tenant coverage facts, explicit no-basis-run messaging, and coverage-only terminology in `app/Filament/Widgets/Inventory/InventoryKpiHeader.php`
|
||||||
|
- [X] T012 [P] [US1] Rewrite KPI supporting badge copy around follow-up counts, top-priority follow-up types, and observed-item counts without implying restore or compare readiness in `app/Support/Inventory/InventoryKpiBadges.php`
|
||||||
|
- [X] T013 [US1] Refactor tenant coverage row assembly, deterministic follow-up ranking, and default-visible columns while keeping support, restore, and compare metadata secondary in `app/Filament/Pages/InventoryCoverage.php`
|
||||||
|
- [X] T014 [US1] Rewrite the coverage page intro and summary copy to tenant-coverage-first language with explicit no-sync fallback guidance in `resources/views/filament/pages/inventory-coverage.blade.php`
|
||||||
|
- [X] T015 [US1] Run the focused truthful-at-a-glance pack in `tests/Feature/Filament/InventoryCoverageTableTest.php`, `tests/Feature/Filament/InventoryCoverageAdminTenantParityTest.php`, `tests/Feature/Filament/InventoryPagesTest.php`, and `tests/Feature/Filament/InventoryItemResourceTest.php`
|
||||||
|
|
||||||
|
**Checkpoint**: The inventory items list and coverage page now show tenant coverage truth first, with capability metadata clearly secondary.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 4: User Story 2 - Move Cleanly From Coverage Truth To Run Truth (Priority: P1)
|
||||||
|
|
||||||
|
**Goal**: Make coverage surfaces cite the exact basis run, provide safe drill-through when authorized, and degrade cleanly when the run cannot be opened.
|
||||||
|
|
||||||
|
**Independent Test**: Seed a tenant with a coverage-bearing basis run and verify the inventory summary surfaces show the basis timestamp and run continuity, then verify a user without run access receives explanatory guidance instead of a dead-end link.
|
||||||
|
|
||||||
|
### Tests for User Story 2
|
||||||
|
|
||||||
|
- [X] T016 [P] [US2] Add basis-run continuity assertions for linked coverage summaries, explicit no-basis-run fallback, and tenant-scoped operations fallback in `tests/Feature/Filament/InventoryPagesTest.php` and `tests/Feature/Filament/InventoryCoverageRunContinuityTest.php`
|
||||||
|
- [X] T017 [P] [US2] Add safe degradation assertions for viewers who can see inventory truth but cannot open the basis run in `tests/Feature/Rbac/InventoryItemResourceAuthorizationTest.php` and `tests/Feature/Filament/InventoryCoverageRunContinuityTest.php`
|
||||||
|
|
||||||
|
### Implementation for User Story 2
|
||||||
|
|
||||||
|
- [X] T018 [P] [US2] Surface basis-run summary, continuity actions, no-sync fallback, and provider or permission follow-up guidance on the coverage report in `app/Filament/Pages/InventoryCoverage.php` and `resources/views/filament/pages/inventory-coverage.blade.php`
|
||||||
|
- [X] T019 [P] [US2] Surface basis-run continuity and safe link rendering in the inventory KPI summary using `app/Filament/Widgets/Inventory/InventoryKpiHeader.php` and `app/Support/Inventory/TenantCoverageTruthResolver.php`
|
||||||
|
- [X] T020 [US2] Run the focused continuity pack in `tests/Feature/Filament/InventoryCoverageRunContinuityTest.php`, `tests/Feature/Filament/InventoryPagesTest.php`, and `tests/Feature/Rbac/InventoryItemResourceAuthorizationTest.php`
|
||||||
|
|
||||||
|
**Checkpoint**: Coverage claims are now auditable through the basis run and remain safe when the current user cannot open the run detail.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 5: User Story 3 - Diagnose Per-Type Inventory Sync Results Without Raw JSON (Priority: P2)
|
||||||
|
|
||||||
|
**Goal**: Make canonical inventory-sync run detail render human-readable per-type results and keep execution outcome separate from tenant coverage follow-up.
|
||||||
|
|
||||||
|
**Independent Test**: Seed an inventory-sync run with mixed per-type outcomes and verify the canonical run viewer shows a readable coverage section without relying on raw JSON.
|
||||||
|
|
||||||
|
### Tests for User Story 3
|
||||||
|
|
||||||
|
- [X] T021 [P] [US3] Add human-readable inventory-sync section assertions, including provider or permission follow-up guidance, in `tests/Feature/Operations/TenantlessOperationRunViewerTest.php` and `tests/Feature/Filament/OperationRunEnterpriseDetailPageTest.php`
|
||||||
|
- [X] T022 [P] [US3] Extend mixed-outcome payload expectations for per-type coverage rows in `tests/Feature/Inventory/InventorySyncServiceTest.php` and `tests/Feature/Inventory/RunInventorySyncJobTest.php`
|
||||||
|
|
||||||
|
### Implementation for User Story 3
|
||||||
|
|
||||||
|
- [X] T023 [US3] Add the inventory-sync per-type coverage enterprise-detail section with explicit next-step guidance in `app/Filament/Resources/OperationRunResource.php`
|
||||||
|
- [X] T024 [P] [US3] Create the human-readable per-type run coverage view with provider or permission follow-up cues in `resources/views/filament/infolists/entries/inventory-coverage-truth.blade.php`
|
||||||
|
- [X] T025 [US3] Run the focused inventory-sync run-detail pack in `tests/Feature/Operations/TenantlessOperationRunViewerTest.php`, `tests/Feature/Filament/OperationRunEnterpriseDetailPageTest.php`, `tests/Feature/Inventory/InventorySyncServiceTest.php`, and `tests/Feature/Inventory/RunInventorySyncJobTest.php`
|
||||||
|
|
||||||
|
**Checkpoint**: Inventory-sync run detail now explains per-type outcomes directly and no longer forces operators into raw JSON for core coverage truth.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 6: Polish & Cross-Cutting Concerns
|
||||||
|
|
||||||
|
**Purpose**: Align copy, run focused verification, and confirm the feature stayed within the intended no-new-persistence boundary.
|
||||||
|
|
||||||
|
- [X] T026 [P] Align operator-facing coverage labels, restore or compare separation, and helper copy across `app/Filament/Widgets/Inventory/InventoryKpiHeader.php`, `app/Support/Inventory/InventoryKpiBadges.php`, `app/Filament/Pages/InventoryCoverage.php`, and `app/Filament/Resources/OperationRunResource.php`
|
||||||
|
- [X] T027 Run `vendor/bin/sail bin pint --dirty --format agent` for touched files under `app/`, `resources/views/`, and `tests/`
|
||||||
|
- [X] T028 Run the focused Sail verification pack from `specs/177-inventory-coverage-truth/quickstart.md` against `tests/Unit/Support/Inventory/TenantCoverageTruthResolverTest.php`, `tests/Unit/Badges/BadgeCatalogTest.php`, `tests/Unit/Badges/InventoryCoverageStateBadgesTest.php`, `tests/Feature/Filament/InventoryCoverageTableTest.php`, `tests/Feature/Filament/InventoryCoverageAdminTenantParityTest.php`, `tests/Feature/Filament/InventoryPagesTest.php`, `tests/Feature/Filament/InventoryItemResourceTest.php`, `tests/Feature/Filament/InventoryCoverageRunContinuityTest.php`, `tests/Feature/Filament/OperationRunEnterpriseDetailPageTest.php`, `tests/Feature/Inventory/InventorySyncServiceTest.php`, `tests/Feature/Inventory/InventorySyncStartSurfaceTest.php`, `tests/Feature/Inventory/RunInventorySyncJobTest.php`, `tests/Feature/Operations/TenantlessOperationRunViewerTest.php`, and `tests/Feature/Rbac/InventoryItemResourceAuthorizationTest.php`
|
||||||
|
- [X] T029 Validate that the final implementation introduces no schema migration, no inventory-sync backend rewrite, and no new persisted truth by reviewing `database/migrations/`, `app/Services/Inventory/InventorySyncService.php`, `app/Jobs/RunInventorySyncJob.php`, and `specs/177-inventory-coverage-truth/plan.md` against the final diff
|
||||||
|
- [X] T030 [P] Review `app/Filament/Resources/InventoryItemResource/Pages/ListInventoryItems.php` and `app/Filament/Pages/InventoryCoverage.php` against `docs/product/standards/list-surface-review-checklist.md` before final sign-off
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Dependencies & Execution Order
|
||||||
|
|
||||||
|
### Phase Dependencies
|
||||||
|
|
||||||
|
- **Setup (Phase 1)**: No dependencies; start immediately.
|
||||||
|
- **Foundational (Phase 2)**: Depends on Setup completion and blocks all user story work.
|
||||||
|
- **User Story 1 (Phase 3)**: Depends on Foundational completion and delivers the MVP truth-correction slice.
|
||||||
|
- **User Story 2 (Phase 4)**: Depends on User Story 1 because it extends the same summary surfaces with basis-run continuity and safe drill-through.
|
||||||
|
- **User Story 3 (Phase 5)**: Depends on Foundational completion and can proceed in parallel with User Story 2 if staffed, since it focuses on canonical run detail.
|
||||||
|
- **Polish (Phase 6)**: Depends on all desired user stories being complete.
|
||||||
|
|
||||||
|
### User Story Dependencies
|
||||||
|
|
||||||
|
- **User Story 1 (P1)**: Depends only on the shared derived coverage contract from Phase 2 and is the recommended MVP.
|
||||||
|
- **User Story 2 (P1)**: Depends on User Story 1 because the same inventory summary surfaces must first speak truthful coverage before adding run continuity.
|
||||||
|
- **User Story 3 (P2)**: Depends only on the foundational contract and existing canonical run detail infrastructure; it can be delivered after User Story 1 or in parallel with User Story 2, but is lower priority.
|
||||||
|
|
||||||
|
### Within Each User Story
|
||||||
|
|
||||||
|
- Story tests should be written before or alongside implementation and should fail for the intended reason before the story is considered complete.
|
||||||
|
- Shared resolver or badge changes should land before surface refactors that consume them.
|
||||||
|
- Surface refactors should land before the focused story-level verification run.
|
||||||
|
|
||||||
|
### Parallel Opportunities
|
||||||
|
|
||||||
|
- `T001` and `T002` can run in parallel during Setup.
|
||||||
|
- `T003`, `T004`, `T005`, and `T006` can run in parallel during Foundational work.
|
||||||
|
- `T009` and `T010` can run in parallel for User Story 1.
|
||||||
|
- `T011` and `T012` can run in parallel for User Story 1 after the foundational resolver is ready.
|
||||||
|
- `T016` and `T017` can run in parallel for User Story 2.
|
||||||
|
- `T018` and `T019` can run in parallel for User Story 2 after basis-run continuity metadata is available.
|
||||||
|
- `T021` and `T022` can run in parallel for User Story 3.
|
||||||
|
- `T023` and `T024` can run in parallel for User Story 3.
|
||||||
|
- `T026` and `T030` can run in parallel during Polish.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Parallel Example: User Story 1
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Launch the inventory truth regressions together before changing summary surfaces:
|
||||||
|
Task: T009 tests/Feature/Filament/InventoryPagesTest.php and tests/Feature/Filament/InventoryItemResourceTest.php
|
||||||
|
Task: T010 tests/Feature/Filament/InventoryCoverageTableTest.php and tests/Feature/Filament/InventoryCoverageAdminTenantParityTest.php
|
||||||
|
|
||||||
|
# Split KPI and helper refactors once the shared resolver is ready:
|
||||||
|
Task: T011 app/Filament/Widgets/Inventory/InventoryKpiHeader.php
|
||||||
|
Task: T012 app/Support/Inventory/InventoryKpiBadges.php
|
||||||
|
```
|
||||||
|
|
||||||
|
## Parallel Example: User Story 2
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Write the continuity and degradation assertions together before adding links:
|
||||||
|
Task: T016 tests/Feature/Filament/InventoryCoverageRunContinuityTest.php and tests/Feature/Filament/InventoryPagesTest.php
|
||||||
|
Task: T017 tests/Feature/Rbac/InventoryItemResourceAuthorizationTest.php and tests/Feature/Filament/InventoryCoverageRunContinuityTest.php
|
||||||
|
|
||||||
|
# Split page and widget continuity work after the tests exist:
|
||||||
|
Task: T018 app/Filament/Pages/InventoryCoverage.php and resources/views/filament/pages/inventory-coverage.blade.php
|
||||||
|
Task: T019 app/Filament/Widgets/Inventory/InventoryKpiHeader.php and app/Support/Inventory/TenantCoverageTruthResolver.php
|
||||||
|
```
|
||||||
|
|
||||||
|
## Parallel Example: User Story 3
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Lock the run-detail expectations and payload assertions together:
|
||||||
|
Task: T021 tests/Feature/Operations/TenantlessOperationRunViewerTest.php and tests/Feature/Filament/OperationRunEnterpriseDetailPageTest.php
|
||||||
|
Task: T022 tests/Feature/Inventory/InventorySyncServiceTest.php and tests/Feature/Inventory/RunInventorySyncJobTest.php
|
||||||
|
|
||||||
|
# Build the section view and enterprise-detail wiring in parallel:
|
||||||
|
Task: T023 app/Filament/Resources/OperationRunResource.php
|
||||||
|
Task: T024 resources/views/filament/infolists/entries/inventory-coverage-truth.blade.php
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Implementation Strategy
|
||||||
|
|
||||||
|
### MVP First (User Story 1 Only)
|
||||||
|
|
||||||
|
1. Complete Phase 1: Setup.
|
||||||
|
2. Complete Phase 2: Foundational.
|
||||||
|
3. Complete Phase 3: User Story 1.
|
||||||
|
4. Validate the inventory items list and coverage page with the User Story 1-focused subset of `specs/177-inventory-coverage-truth/quickstart.md`.
|
||||||
|
|
||||||
|
### Incremental Delivery
|
||||||
|
|
||||||
|
1. Finish Setup and Foundational work.
|
||||||
|
2. Deliver User Story 1 and validate truthful tenant coverage at a glance.
|
||||||
|
3. Deliver User Story 2 and validate run continuity plus safe degradation.
|
||||||
|
4. Deliver User Story 3 and validate human-readable inventory-sync run detail.
|
||||||
|
5. Finish with formatting, the focused Sail pack, and the no-new-persistence review.
|
||||||
|
|
||||||
|
### Parallel Team Strategy
|
||||||
|
|
||||||
|
1. One developer can complete Phase 1 and the model or resolver side of Phase 2 while another prepares the badge and feature regressions.
|
||||||
|
2. After Phase 2, one developer can take User Story 1 while another prepares User Story 3 run-detail tests.
|
||||||
|
3. After User Story 1 stabilizes, one developer can handle User Story 2 continuity while another completes User Story 3 UI wiring.
|
||||||
|
4. Rejoin for Phase 6 formatting and verification.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- Every task follows the required checklist format: checkbox, task ID, optional parallel marker, required story label for story phases, and exact file paths.
|
||||||
|
- The suggested MVP scope is Phase 1 through Phase 3 only.
|
||||||
|
- No task in this plan introduces new persistence, a new Graph contract, a new Filament panel or provider registration change, or a new destructive action.
|
||||||
36
specs/179-provider-truth-cleanup/checklists/requirements.md
Normal file
36
specs/179-provider-truth-cleanup/checklists/requirements.md
Normal file
@ -0,0 +1,36 @@
|
|||||||
|
# Specification Quality Checklist: Spec 179 - Provider Readiness Source-of-Truth Cleanup
|
||||||
|
|
||||||
|
**Purpose**: Validate specification completeness and quality before proceeding to planning
|
||||||
|
**Created**: 2026-04-04
|
||||||
|
**Feature**: [spec.md](../spec.md)
|
||||||
|
|
||||||
|
## Content Quality
|
||||||
|
|
||||||
|
- [x] No implementation details (languages, frameworks, APIs)
|
||||||
|
- [x] Focused on user value and business needs
|
||||||
|
- [x] Written for non-technical stakeholders
|
||||||
|
- [x] All mandatory sections completed
|
||||||
|
|
||||||
|
## Requirement Completeness
|
||||||
|
|
||||||
|
- [x] No [NEEDS CLARIFICATION] markers remain
|
||||||
|
- [x] Requirements are testable and unambiguous
|
||||||
|
- [x] Success criteria are measurable
|
||||||
|
- [x] Success criteria are technology-agnostic (no implementation details)
|
||||||
|
- [x] All acceptance scenarios are defined
|
||||||
|
- [x] Edge cases are identified
|
||||||
|
- [x] Scope is clearly bounded
|
||||||
|
- [x] Dependencies and assumptions identified
|
||||||
|
|
||||||
|
## Feature Readiness
|
||||||
|
|
||||||
|
- [x] All functional requirements have clear acceptance criteria
|
||||||
|
- [x] User scenarios cover primary flows
|
||||||
|
- [x] Feature meets measurable outcomes defined in Success Criteria
|
||||||
|
- [x] No implementation details leak into specification
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- Validation pass completed on 2026-04-04.
|
||||||
|
- Constitution-required route and action-surface references are intentionally confined to the surface contract and action matrix.
|
||||||
|
- No clarification questions were required because the requested truth rules, scope boundaries, and follow-up exclusions were explicit.
|
||||||
@ -0,0 +1,390 @@
|
|||||||
|
openapi: 3.1.0
|
||||||
|
info:
|
||||||
|
title: Provider Truth Cleanup Internal Surface Contract
|
||||||
|
version: 0.1.0
|
||||||
|
summary: Internal planning contract for tenant and provider status-truth cleanup
|
||||||
|
description: |
|
||||||
|
This contract is an internal planning artifact for Spec 179. The affected
|
||||||
|
routes continue to render HTML. The schemas below describe the structured
|
||||||
|
truth that must be derivable before rendering so tenant and provider
|
||||||
|
surfaces stop elevating legacy status projections over lifecycle, consent,
|
||||||
|
and verification.
|
||||||
|
servers:
|
||||||
|
- url: /internal
|
||||||
|
x-surface-consumers:
|
||||||
|
- surface: tenant.list
|
||||||
|
summarySource:
|
||||||
|
- tenant.lifecycle
|
||||||
|
- optional_bounded_provider_signal
|
||||||
|
guardScope:
|
||||||
|
- app/Filament/Resources/TenantResource.php
|
||||||
|
expectedContract:
|
||||||
|
- lifecycle_is_primary_tenant_truth
|
||||||
|
- legacy_app_status_is_not_default_visible
|
||||||
|
- provider_signal_may_be_omitted_if_no_truthful_bounded_summary_exists
|
||||||
|
- surface: tenant.detail.provider_summary
|
||||||
|
summarySource:
|
||||||
|
- tenant.lifecycle
|
||||||
|
- tenant.provider_connection_state_helper
|
||||||
|
- provider_connection.consent_status
|
||||||
|
- provider_connection.verification_status
|
||||||
|
- latest_provider_connection_check_run
|
||||||
|
guardScope:
|
||||||
|
- app/Filament/Resources/TenantResource.php
|
||||||
|
- resources/views/filament/infolists/entries/provider-connection-state.blade.php
|
||||||
|
- app/Filament/Widgets/Tenant/TenantVerificationReport.php
|
||||||
|
expectedContract:
|
||||||
|
- lifecycle_is_separate_from_provider_truth
|
||||||
|
- provider_summary_leads_with_consent_and_verification
|
||||||
|
- legacy_status_and_health_are_diagnostic_only
|
||||||
|
- missing_default_connection_never_reads_as_ready
|
||||||
|
- surface: provider_connections.list
|
||||||
|
summarySource:
|
||||||
|
- provider_connection.consent_status
|
||||||
|
- provider_connection.verification_status
|
||||||
|
- provider_connection.connection_type
|
||||||
|
- provider_connection.is_default
|
||||||
|
- provider_connection.legacy_diagnostics
|
||||||
|
guardScope:
|
||||||
|
- app/Filament/Resources/ProviderConnectionResource.php
|
||||||
|
- app/Filament/Resources/ProviderConnectionResource/Pages/ListProviderConnections.php
|
||||||
|
expectedContract:
|
||||||
|
- consent_and_verification_are_default_visible_axes
|
||||||
|
- legacy_status_and_health_are_secondary
|
||||||
|
- core_filters_follow_leading_truth
|
||||||
|
- surface: provider_connections.detail
|
||||||
|
summarySource:
|
||||||
|
- provider_connection.consent_status
|
||||||
|
- provider_connection.verification_status
|
||||||
|
- provider_connection.legacy_diagnostics
|
||||||
|
guardScope:
|
||||||
|
- app/Filament/Resources/ProviderConnectionResource.php
|
||||||
|
- app/Filament/Resources/ProviderConnectionResource/Pages/ViewProviderConnection.php
|
||||||
|
- app/Filament/Resources/ProviderConnectionResource/Pages/EditProviderConnection.php
|
||||||
|
expectedContract:
|
||||||
|
- current_state_and_diagnostics_are_visually_separate
|
||||||
|
- configured_or_consented_is_not_equated_with_verified
|
||||||
|
- legacy_diagnostics_do_not_dominate
|
||||||
|
paths:
|
||||||
|
/admin/tenants:
|
||||||
|
get:
|
||||||
|
summary: Render the tenant list with lifecycle-led truth and no legacy app-status prominence
|
||||||
|
operationId: viewTenantListTruthCleanup
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: Tenant list rendered with lifecycle as the primary tenant truth and no default-visible app-status truth
|
||||||
|
content:
|
||||||
|
text/html:
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
application/vnd.tenantpilot.tenant-list-truth+json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/TenantListTruthBundle'
|
||||||
|
'404':
|
||||||
|
description: Workspace or tenant scope is outside entitlement
|
||||||
|
/admin/tenants/{tenant}:
|
||||||
|
get:
|
||||||
|
summary: Render tenant detail with lifecycle and provider truth separated
|
||||||
|
operationId: viewTenantTruthDetail
|
||||||
|
parameters:
|
||||||
|
- name: tenant
|
||||||
|
in: path
|
||||||
|
required: true
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: Tenant detail rendered with lifecycle separate from provider consent and provider verification
|
||||||
|
content:
|
||||||
|
text/html:
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
application/vnd.tenantpilot.tenant-detail-truth+json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/TenantDetailTruthModel'
|
||||||
|
'404':
|
||||||
|
description: Tenant is outside entitlement scope
|
||||||
|
/admin/provider-connections:
|
||||||
|
get:
|
||||||
|
summary: Render the canonical provider-connections list with consent and verification as primary axes
|
||||||
|
operationId: viewProviderConnectionsTruthList
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: Provider-connection list rendered with current-state truth leading and legacy diagnostics secondary
|
||||||
|
content:
|
||||||
|
text/html:
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
application/vnd.tenantpilot.provider-connections-list-truth+json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/ProviderConnectionListTruthBundle'
|
||||||
|
'302':
|
||||||
|
description: Workspace members without an active tenant filter or equivalent context may be redirected according to existing canonical-admin rules
|
||||||
|
'404':
|
||||||
|
description: Workspace or tenant scope is outside entitlement
|
||||||
|
/admin/provider-connections/{record}:
|
||||||
|
get:
|
||||||
|
summary: Render provider connection detail with current state and diagnostics separated
|
||||||
|
operationId: viewProviderConnectionTruthDetail
|
||||||
|
parameters:
|
||||||
|
- name: record
|
||||||
|
in: path
|
||||||
|
required: true
|
||||||
|
schema:
|
||||||
|
type:
|
||||||
|
- integer
|
||||||
|
- string
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: Provider-connection detail rendered with consent and verification as the leading state contract
|
||||||
|
content:
|
||||||
|
text/html:
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
application/vnd.tenantpilot.provider-connection-detail-truth+json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/ProviderConnectionDetailTruthModel'
|
||||||
|
'403':
|
||||||
|
description: Actor is in scope but lacks the capability required for a protected action on the page
|
||||||
|
'404':
|
||||||
|
description: Provider connection is outside entitlement scope
|
||||||
|
/admin/provider-connections/{record}/edit:
|
||||||
|
get:
|
||||||
|
summary: Render provider connection edit with current state context before mutations
|
||||||
|
operationId: editProviderConnectionTruthDetail
|
||||||
|
parameters:
|
||||||
|
- name: record
|
||||||
|
in: path
|
||||||
|
required: true
|
||||||
|
schema:
|
||||||
|
type:
|
||||||
|
- integer
|
||||||
|
- string
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: Provider-connection edit page rendered with current consent and verification context and separate diagnostics
|
||||||
|
content:
|
||||||
|
text/html:
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
application/vnd.tenantpilot.provider-connection-edit-truth+json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/ProviderConnectionDetailTruthModel'
|
||||||
|
'403':
|
||||||
|
description: Actor is in scope but lacks manage capability
|
||||||
|
'404':
|
||||||
|
description: Provider connection is outside entitlement scope
|
||||||
|
components:
|
||||||
|
schemas:
|
||||||
|
VisibilityMode:
|
||||||
|
type: string
|
||||||
|
enum:
|
||||||
|
- hidden
|
||||||
|
- diagnostic
|
||||||
|
- primary
|
||||||
|
ProviderSignal:
|
||||||
|
type: object
|
||||||
|
required:
|
||||||
|
- mode
|
||||||
|
- explanation
|
||||||
|
properties:
|
||||||
|
mode:
|
||||||
|
type: string
|
||||||
|
enum:
|
||||||
|
- omitted
|
||||||
|
- missing_default_connection
|
||||||
|
- consent_verification_summary
|
||||||
|
consentStatus:
|
||||||
|
type:
|
||||||
|
- string
|
||||||
|
- 'null'
|
||||||
|
verificationStatus:
|
||||||
|
type:
|
||||||
|
- string
|
||||||
|
- 'null'
|
||||||
|
explanation:
|
||||||
|
type: string
|
||||||
|
TenantListRow:
|
||||||
|
type: object
|
||||||
|
required:
|
||||||
|
- tenantId
|
||||||
|
- tenantLabel
|
||||||
|
- lifecycle
|
||||||
|
- legacyAppStatusVisibility
|
||||||
|
- primaryInspectUrl
|
||||||
|
properties:
|
||||||
|
tenantId:
|
||||||
|
type: integer
|
||||||
|
tenantLabel:
|
||||||
|
type: string
|
||||||
|
lifecycle:
|
||||||
|
type: string
|
||||||
|
providerSignal:
|
||||||
|
oneOf:
|
||||||
|
- $ref: '#/components/schemas/ProviderSignal'
|
||||||
|
- type: 'null'
|
||||||
|
legacyAppStatusVisibility:
|
||||||
|
$ref: '#/components/schemas/VisibilityMode'
|
||||||
|
primaryInspectUrl:
|
||||||
|
type: string
|
||||||
|
TenantListTruthBundle:
|
||||||
|
type: object
|
||||||
|
required:
|
||||||
|
- rows
|
||||||
|
properties:
|
||||||
|
rows:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
$ref: '#/components/schemas/TenantListRow'
|
||||||
|
TenantProviderSummary:
|
||||||
|
type: object
|
||||||
|
required:
|
||||||
|
- connectionPresence
|
||||||
|
- ctaUrl
|
||||||
|
- legacyStatusVisibility
|
||||||
|
- legacyHealthVisibility
|
||||||
|
properties:
|
||||||
|
connectionPresence:
|
||||||
|
type: string
|
||||||
|
enum:
|
||||||
|
- missing
|
||||||
|
- configured
|
||||||
|
- default_configured
|
||||||
|
ctaUrl:
|
||||||
|
type: string
|
||||||
|
displayName:
|
||||||
|
type:
|
||||||
|
- string
|
||||||
|
- 'null'
|
||||||
|
provider:
|
||||||
|
type:
|
||||||
|
- string
|
||||||
|
- 'null'
|
||||||
|
consentStatus:
|
||||||
|
type:
|
||||||
|
- string
|
||||||
|
- 'null'
|
||||||
|
verificationStatus:
|
||||||
|
type:
|
||||||
|
- string
|
||||||
|
- 'null'
|
||||||
|
lastCheckedAt:
|
||||||
|
type:
|
||||||
|
- string
|
||||||
|
- 'null'
|
||||||
|
lastErrorReasonCode:
|
||||||
|
type:
|
||||||
|
- string
|
||||||
|
- 'null'
|
||||||
|
legacyStatusVisibility:
|
||||||
|
$ref: '#/components/schemas/VisibilityMode'
|
||||||
|
legacyHealthVisibility:
|
||||||
|
$ref: '#/components/schemas/VisibilityMode'
|
||||||
|
TenantDetailTruthModel:
|
||||||
|
type: object
|
||||||
|
required:
|
||||||
|
- tenantId
|
||||||
|
- lifecycle
|
||||||
|
- providerSummary
|
||||||
|
properties:
|
||||||
|
tenantId:
|
||||||
|
type: integer
|
||||||
|
lifecycle:
|
||||||
|
type: string
|
||||||
|
providerSummary:
|
||||||
|
$ref: '#/components/schemas/TenantProviderSummary'
|
||||||
|
verificationReportSurface:
|
||||||
|
type: string
|
||||||
|
description: Existing tenant verification widget remains the deep-dive verification surface
|
||||||
|
ProviderConnectionListItem:
|
||||||
|
type: object
|
||||||
|
required:
|
||||||
|
- connectionId
|
||||||
|
- displayName
|
||||||
|
- provider
|
||||||
|
- connectionType
|
||||||
|
- isDefault
|
||||||
|
- consentStatus
|
||||||
|
- verificationStatus
|
||||||
|
- legacyStatusVisibility
|
||||||
|
- legacyHealthVisibility
|
||||||
|
- primaryInspectUrl
|
||||||
|
properties:
|
||||||
|
connectionId:
|
||||||
|
type: integer
|
||||||
|
tenantLabel:
|
||||||
|
type:
|
||||||
|
- string
|
||||||
|
- 'null'
|
||||||
|
displayName:
|
||||||
|
type: string
|
||||||
|
provider:
|
||||||
|
type: string
|
||||||
|
connectionType:
|
||||||
|
type: string
|
||||||
|
isDefault:
|
||||||
|
type: boolean
|
||||||
|
consentStatus:
|
||||||
|
type: string
|
||||||
|
verificationStatus:
|
||||||
|
type: string
|
||||||
|
legacyStatusVisibility:
|
||||||
|
$ref: '#/components/schemas/VisibilityMode'
|
||||||
|
legacyHealthVisibility:
|
||||||
|
$ref: '#/components/schemas/VisibilityMode'
|
||||||
|
primaryInspectUrl:
|
||||||
|
type: string
|
||||||
|
ProviderConnectionListTruthBundle:
|
||||||
|
type: object
|
||||||
|
required:
|
||||||
|
- rows
|
||||||
|
properties:
|
||||||
|
rows:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
$ref: '#/components/schemas/ProviderConnectionListItem'
|
||||||
|
ProviderConnectionDetailTruthModel:
|
||||||
|
type: object
|
||||||
|
required:
|
||||||
|
- connectionId
|
||||||
|
- displayName
|
||||||
|
- provider
|
||||||
|
- connectionType
|
||||||
|
- isDefault
|
||||||
|
- consentStatus
|
||||||
|
- verificationStatus
|
||||||
|
- legacyStatusVisibility
|
||||||
|
- legacyHealthVisibility
|
||||||
|
properties:
|
||||||
|
connectionId:
|
||||||
|
type: integer
|
||||||
|
displayName:
|
||||||
|
type: string
|
||||||
|
provider:
|
||||||
|
type: string
|
||||||
|
connectionType:
|
||||||
|
type: string
|
||||||
|
isDefault:
|
||||||
|
type: boolean
|
||||||
|
consentStatus:
|
||||||
|
type: string
|
||||||
|
verificationStatus:
|
||||||
|
type: string
|
||||||
|
lastCheckedAt:
|
||||||
|
type:
|
||||||
|
- string
|
||||||
|
- 'null'
|
||||||
|
lastErrorReasonCode:
|
||||||
|
type:
|
||||||
|
- string
|
||||||
|
- 'null'
|
||||||
|
lastErrorMessage:
|
||||||
|
type:
|
||||||
|
- string
|
||||||
|
- 'null'
|
||||||
|
migrationReviewRequired:
|
||||||
|
type: boolean
|
||||||
|
legacyStatusVisibility:
|
||||||
|
$ref: '#/components/schemas/VisibilityMode'
|
||||||
|
legacyHealthVisibility:
|
||||||
|
$ref: '#/components/schemas/VisibilityMode'
|
||||||
222
specs/179-provider-truth-cleanup/data-model.md
Normal file
222
specs/179-provider-truth-cleanup/data-model.md
Normal file
@ -0,0 +1,222 @@
|
|||||||
|
# Phase 1 Data Model: Provider Readiness Source-of-Truth Cleanup
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
This feature adds no table, no new enum, and no persisted readiness artifact. It reclassifies which already-stored fields are allowed to act as leading operator truth on tenant and provider connection surfaces.
|
||||||
|
|
||||||
|
## Persistent Source Truths
|
||||||
|
|
||||||
|
### Tenant
|
||||||
|
|
||||||
|
**Purpose**: Tenant identity and lifecycle boundary for all tenant-facing operator surfaces.
|
||||||
|
|
||||||
|
**Key fields**:
|
||||||
|
- `id`
|
||||||
|
- `workspace_id`
|
||||||
|
- `external_id`
|
||||||
|
- `name`
|
||||||
|
- `status`
|
||||||
|
- `app_status` (legacy tenant-level projection)
|
||||||
|
- `rbac_status`
|
||||||
|
|
||||||
|
**Relationships**:
|
||||||
|
- `Tenant` has many `ProviderConnection`
|
||||||
|
|
||||||
|
**Validation rules**:
|
||||||
|
- `status` remains the authoritative tenant lifecycle input and maps through `TenantLifecycle`.
|
||||||
|
- `app_status` remains persisted but is no longer allowed to act as leading operator truth on targeted tenant surfaces.
|
||||||
|
- `rbac_status` remains a separate domain and must not substitute for provider readiness.
|
||||||
|
|
||||||
|
### ProviderConnection
|
||||||
|
|
||||||
|
**Purpose**: Canonical stored provider-connection record for tenant-scoped provider integration state.
|
||||||
|
|
||||||
|
**Key fields**:
|
||||||
|
- `id`
|
||||||
|
- `tenant_id`
|
||||||
|
- `workspace_id`
|
||||||
|
- `provider`
|
||||||
|
- `display_name`
|
||||||
|
- `is_default`
|
||||||
|
- `connection_type`
|
||||||
|
- `consent_status`
|
||||||
|
- `verification_status`
|
||||||
|
- `status` (legacy connection-state projection)
|
||||||
|
- `health_status` (legacy health projection)
|
||||||
|
- `last_health_check_at`
|
||||||
|
- `last_error_reason_code`
|
||||||
|
- `last_error_message`
|
||||||
|
- `migration_review_required`
|
||||||
|
|
||||||
|
**Relationships**:
|
||||||
|
- `ProviderConnection` belongs to `Tenant`
|
||||||
|
- `ProviderConnection` belongs to `Workspace`
|
||||||
|
- `ProviderConnection` has one `ProviderCredential`
|
||||||
|
|
||||||
|
**Validation rules**:
|
||||||
|
- `consent_status` is the primary stored truth for consent progression.
|
||||||
|
- `verification_status` is the primary stored truth for current provider verification outcome.
|
||||||
|
- `status` and `health_status` may remain persisted and updated by existing projections, but targeted operator surfaces must treat them as secondary diagnostics only.
|
||||||
|
- `is_default` remains the routing anchor for tenant-facing provider summaries where a single connection must be chosen.
|
||||||
|
|
||||||
|
### ProviderCredential
|
||||||
|
|
||||||
|
**Purpose**: Encrypted credential storage associated with a provider connection.
|
||||||
|
|
||||||
|
**Key fields**:
|
||||||
|
- `provider_connection_id`
|
||||||
|
- encrypted credential payload fields
|
||||||
|
|
||||||
|
**Relationships**:
|
||||||
|
- `ProviderCredential` belongs to `ProviderConnection`
|
||||||
|
|
||||||
|
**Validation rules**:
|
||||||
|
- This spec does not change credential persistence, mutation rules, or encryption handling.
|
||||||
|
- Credential presence may continue to influence diagnostic projections, but it is not itself rendered as a leading readiness label on the targeted surfaces.
|
||||||
|
|
||||||
|
### OperationRun / Verification Report
|
||||||
|
|
||||||
|
**Purpose**: Stored verification evidence used by tenant verification and provider check surfaces.
|
||||||
|
|
||||||
|
**Key fields**:
|
||||||
|
- `tenant_id`
|
||||||
|
- `type` with `provider.connection.check`
|
||||||
|
- `status`
|
||||||
|
- `outcome`
|
||||||
|
- `context`
|
||||||
|
- stored verification report payload derived from the run
|
||||||
|
|
||||||
|
**Relationships**:
|
||||||
|
- `OperationRun` belongs to `Tenant` when tenant-bound
|
||||||
|
|
||||||
|
**Validation rules**:
|
||||||
|
- The latest stored verification report remains the deep-dive verification surface for tenant detail.
|
||||||
|
- This spec does not change run lifecycle, queueing, or verification report persistence.
|
||||||
|
|
||||||
|
## Existing Domain State Families
|
||||||
|
|
||||||
|
### TenantLifecycle
|
||||||
|
|
||||||
|
**Values**:
|
||||||
|
- `draft`
|
||||||
|
- `onboarding`
|
||||||
|
- `active`
|
||||||
|
- `archived`
|
||||||
|
|
||||||
|
**Rule**:
|
||||||
|
- Lifecycle answers whether the tenant is in the normal lifecycle. It does not answer provider consent, provider verification, or provider readiness.
|
||||||
|
|
||||||
|
### ProviderConsentStatus
|
||||||
|
|
||||||
|
**Values**:
|
||||||
|
- `unknown`
|
||||||
|
- `required`
|
||||||
|
- `granted`
|
||||||
|
- `failed`
|
||||||
|
- `revoked`
|
||||||
|
|
||||||
|
**Rule**:
|
||||||
|
- Consent answers whether the permission or consent step has been completed. It does not prove provider verification health.
|
||||||
|
|
||||||
|
### ProviderVerificationStatus
|
||||||
|
|
||||||
|
**Values**:
|
||||||
|
- `unknown`
|
||||||
|
- `pending`
|
||||||
|
- `healthy`
|
||||||
|
- `degraded`
|
||||||
|
- `blocked`
|
||||||
|
- `error`
|
||||||
|
|
||||||
|
**Rule**:
|
||||||
|
- Verification is the primary current provider-state axis for whether a connection has been checked and what that check currently proves.
|
||||||
|
|
||||||
|
### Legacy Diagnostic Projections
|
||||||
|
|
||||||
|
**Known provider projection values in current surfaces**:
|
||||||
|
- `status`: commonly `connected`, `needs_consent`, `error`, `disabled`
|
||||||
|
- `health_status`: commonly `ok`, `degraded`, `down`, `unknown`
|
||||||
|
|
||||||
|
**Rule**:
|
||||||
|
- These values remain diagnostics or projections. They must not compete with consent and verification as primary operator truth on targeted surfaces.
|
||||||
|
|
||||||
|
## Derived Surface Contracts
|
||||||
|
|
||||||
|
### Tenant List Row Truth (derived, non-persisted)
|
||||||
|
|
||||||
|
**Purpose**: Minimal row-level truth for `/admin/tenants`.
|
||||||
|
|
||||||
|
| Field | Type | Required | Description |
|
||||||
|
|------|------|----------|-------------|
|
||||||
|
| `tenantId` | integer | yes | Tenant identifier |
|
||||||
|
| `tenantLabel` | string | yes | Tenant name |
|
||||||
|
| `lifecycle` | string | yes | Lifecycle label derived from `TenantLifecycle` |
|
||||||
|
| `providerSignal` | object nullable | no | Optional bounded provider summary derived from current truth; may be absent in this slice |
|
||||||
|
| `legacyAppStatusVisible` | boolean | yes | Must be `false` on default-visible tenant list surfaces |
|
||||||
|
| `primaryInspectUrl` | string | yes | Canonical tenant detail destination |
|
||||||
|
|
||||||
|
**Validation rules**:
|
||||||
|
- `providerSignal` may be omitted when a truthful single-row summary cannot be stated without inventing a readiness model.
|
||||||
|
- `legacyAppStatusVisible` must be `false` for the default tenant list configuration.
|
||||||
|
|
||||||
|
### Tenant Detail Provider Summary (derived, non-persisted)
|
||||||
|
|
||||||
|
**Purpose**: Compact provider-state summary rendered inside the tenant detail `Provider` section.
|
||||||
|
|
||||||
|
| Field | Type | Required | Description |
|
||||||
|
|------|------|----------|-------------|
|
||||||
|
| `connectionPresence` | enum | yes | `missing`, `configured`, or `default_configured` |
|
||||||
|
| `ctaUrl` | string | yes | Canonical provider-connections destination |
|
||||||
|
| `displayName` | string nullable | no | Default or chosen connection display name |
|
||||||
|
| `provider` | string nullable | no | Provider label |
|
||||||
|
| `consentStatus` | string nullable | no | Current consent state from the chosen connection |
|
||||||
|
| `verificationStatus` | string nullable | no | Current verification state from the chosen connection |
|
||||||
|
| `lastCheckedAt` | string nullable | no | Stored last-check timestamp |
|
||||||
|
| `lastErrorReasonCode` | string nullable | no | Diagnostic reason code when present |
|
||||||
|
| `legacyStatus` | string nullable | no | Legacy diagnostic projection |
|
||||||
|
| `legacyHealthStatus` | string nullable | no | Legacy health projection |
|
||||||
|
|
||||||
|
**Validation rules**:
|
||||||
|
- Consent and verification are the leading fields in this summary.
|
||||||
|
- Legacy status and health may remain available only as diagnostics.
|
||||||
|
- When no default Microsoft provider connection exists, the summary must explicitly say action is needed and must not invent a healthy or ready label.
|
||||||
|
|
||||||
|
### Provider Connection Surface Truth (derived, non-persisted)
|
||||||
|
|
||||||
|
**Purpose**: Shared truth hierarchy for provider connection list, view, and edit surfaces.
|
||||||
|
|
||||||
|
| Field | Type | Required | Description |
|
||||||
|
|------|------|----------|-------------|
|
||||||
|
| `connectionId` | integer | yes | Provider connection identifier |
|
||||||
|
| `tenantLabel` | string nullable | no | Tenant label when rendered on tenantless canonical surfaces |
|
||||||
|
| `displayName` | string | yes | Connection display name |
|
||||||
|
| `provider` | string | yes | Provider key or label |
|
||||||
|
| `connectionType` | string | yes | Platform or dedicated |
|
||||||
|
| `isDefault` | boolean | yes | Default designation |
|
||||||
|
| `consentStatus` | string | yes | Leading consent truth |
|
||||||
|
| `verificationStatus` | string | yes | Leading verification truth |
|
||||||
|
| `legacyStatus` | string nullable | no | Secondary diagnostic projection |
|
||||||
|
| `legacyHealthStatus` | string nullable | no | Secondary diagnostic projection |
|
||||||
|
| `migrationReviewRequired` | boolean | yes | Secondary diagnostic flag |
|
||||||
|
| `lastCheckedAt` | string nullable | no | Last stored check timestamp |
|
||||||
|
| `lastErrorReasonCode` | string nullable | no | Stored diagnostic reason code |
|
||||||
|
|
||||||
|
**Validation rules**:
|
||||||
|
- List, view, and edit surfaces must display `consentStatus` and `verificationStatus` before any legacy projection fields.
|
||||||
|
- Legacy fields may remain filterable or visible only when clearly marked and positioned as diagnostics.
|
||||||
|
- `connectionType` and `isDefault` remain supporting facts, not substitutes for readiness.
|
||||||
|
|
||||||
|
## Surface Truth Hierarchy Rules
|
||||||
|
|
||||||
|
1. Tenant lifecycle is the primary tenant-state axis.
|
||||||
|
2. Provider consent and provider verification are the primary provider-state axes.
|
||||||
|
3. RBAC remains a separate tenant-management domain.
|
||||||
|
4. Legacy tenant app status, provider status, and provider health are diagnostic-only on targeted surfaces.
|
||||||
|
5. No derived surface contract in this feature may collapse `active`, `connected`, or `consented` into a synthetic `ready` value.
|
||||||
|
|
||||||
|
## No New Persistence
|
||||||
|
|
||||||
|
- No new table is introduced.
|
||||||
|
- No new enum or reason family is introduced.
|
||||||
|
- No new persisted readiness summary is introduced.
|
||||||
|
- Existing persisted legacy fields remain for compatibility, but their surface role is reduced.
|
||||||
322
specs/179-provider-truth-cleanup/plan.md
Normal file
322
specs/179-provider-truth-cleanup/plan.md
Normal file
@ -0,0 +1,322 @@
|
|||||||
|
# Implementation Plan: Provider Readiness Source-of-Truth Cleanup
|
||||||
|
|
||||||
|
**Branch**: `179-provider-truth-cleanup` | **Date**: 2026-04-04 | **Spec**: `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/specs/179-provider-truth-cleanup/spec.md`
|
||||||
|
**Input**: Feature specification from `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/specs/179-provider-truth-cleanup/spec.md`
|
||||||
|
|
||||||
|
**Note**: This plan keeps the current tenant and provider connection model intact. It cleans up which already-stored fields are allowed to act as leading operator truth and explicitly avoids introducing a new readiness model, new persistence, or a new semantic framework.
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
Harden the existing tenant and provider operator surfaces so lifecycle, consent, and verification become the only leading truth axes on the targeted pages. The implementation will remove `Tenant.app_status` from primary tenant surfaces, repoint the tenant-detail Provider section from legacy connection `status` and `health_status` to current consent and verification, and reorganize provider connection list, view, and edit surfaces so legacy projections become secondary diagnostics. The narrowest safe tenant-list choice in this slice is to omit a new row-level provider readiness signal rather than invent one. The provider list will instead promote consent and verification to the default-visible connection-state axes, while existing legacy fields remain persisted and available only as secondary diagnostics.
|
||||||
|
|
||||||
|
## Technical Context
|
||||||
|
|
||||||
|
**Language/Version**: PHP 8.4.15
|
||||||
|
**Primary Dependencies**: Laravel 12, Filament v5, Livewire v4, Pest v4, existing `TenantResource`, `ProviderConnectionResource`, `TenantVerificationReport`, `BadgeCatalog`, `BadgeRenderer`, `TenantOperabilityService`, `ProviderConsentStatus`, `ProviderVerificationStatus`, and shared provider-state Blade partials
|
||||||
|
**Storage**: PostgreSQL unchanged; no new table, column, or persisted artifact is introduced
|
||||||
|
**Testing**: Pest 4 feature tests and Livewire-style Filament surface tests through Laravel Sail, reusing existing tenant and provider surface tests plus focused Spec 179 truth-cleanup coverage
|
||||||
|
**Target Platform**: Laravel monolith web application running in Sail locally and containerized Linux environments in staging and production
|
||||||
|
**Project Type**: web application
|
||||||
|
**Performance Goals**: Keep existing DB-only render guarantees, avoid adding uncontrolled per-row provider queries to the tenant list, and keep provider and tenant detail rendering bounded to already-loaded or single-record derived state
|
||||||
|
**Constraints**: No new readiness enum or score, no new persisted truth, no new presenter or taxonomy framework, no authorization widening, no cross-tenant leakage, no new asset pipeline work, and no false equivalence between `active`, `connected`, `consented`, and `ready`
|
||||||
|
**Scale/Scope**: Two existing Filament resources, five operator-facing surfaces, one existing tenant provider-summary partial, one tenant verification widget, three legacy badge domains under review, and a focused regression pack around truth hierarchy and filter semantics
|
||||||
|
|
||||||
|
## Constitution Check
|
||||||
|
|
||||||
|
*GATE: Passed before Phase 0 research. Re-check after Phase 1 design.*
|
||||||
|
|
||||||
|
| Principle | Pre-Research | Post-Design | Notes |
|
||||||
|
|-----------|--------------|-------------|-------|
|
||||||
|
| Inventory-first / snapshots-second | PASS | PASS | The feature changes only read-time presentation of already-stored tenant and provider truth. No snapshot or inventory contract changes are introduced. |
|
||||||
|
| Read/write separation | PASS | PASS | No new mutation path, preview flow, or destructive action is added. Existing provider and tenant mutations remain unchanged. |
|
||||||
|
| Graph contract path | N/A | N/A | No Graph contract or `config/graph_contracts.php` change is required. |
|
||||||
|
| Deterministic capabilities | PASS | PASS | Existing provider view/manage and tenant membership rules remain authoritative. |
|
||||||
|
| RBAC-UX authorization semantics | PASS | PASS | All touched surfaces remain in `/admin`, preserve tenant and workspace scoping, keep non-members at `404`, and keep member-without-capability behavior unchanged. |
|
||||||
|
| Workspace and tenant isolation | PASS | PASS | No new route or query broadens tenant visibility. Canonical tenantless provider routes remain tenant-aware and scoped. |
|
||||||
|
| Run observability / Ops-UX | PASS | PASS | No new `OperationRun` type or queueing behavior is introduced. Existing verification and provider-run actions keep their current semantics. |
|
||||||
|
| Data minimization | PASS | PASS | No new persisted data, logs, or surface payloads are introduced. |
|
||||||
|
| Proportionality / no premature abstraction | PASS | PASS | The plan reuses existing resources, helpers, and partials instead of adding a new readiness service, presenter, or DTO layer. |
|
||||||
|
| Persisted truth / behavioral state | PASS | PASS | No new state family or table is planned. Existing legacy fields remain persisted for compatibility but lose leading surface prominence. |
|
||||||
|
| UI semantics / few layers | PASS | PASS | The plan uses direct lifecycle, consent, and verification truth instead of introducing a new readiness interpreter. |
|
||||||
|
| Badge semantics (BADGE-001) | PASS | PASS | Existing badge semantics remain centralized. Where provider consent or verification need status-like badges, the plan extends `BadgeCatalog` and `BadgeRenderer` through narrow centralized mappings instead of page-local labels or a synthetic readiness domain. |
|
||||||
|
| Filament-native UI / Action Surface Contract | PASS | PASS | Existing Filament resources, sections, tables, infolists, view entries, and action groups are reused. No redundant inspect action or empty group is introduced. |
|
||||||
|
| Filament UX-001 | PASS | PASS | No new screen type is introduced. Existing list, view, and edit surfaces remain within current Filament layout patterns while truth hierarchy changes inside those sections. |
|
||||||
|
| Filament v5 / Livewire v4 compliance | PASS | PASS | The design stays within the current Filament v5 and Livewire v4 stack. |
|
||||||
|
| Provider registration location | PASS | PASS | No provider or panel registration change is required; Laravel 11+ provider registration remains in `bootstrap/providers.php`. |
|
||||||
|
| Global search hard rule | PASS | PASS | `TenantResource` remains globally searchable and already has view and edit pages. `ProviderConnectionResource` remains non-globally-searchable. |
|
||||||
|
| Destructive action safety | PASS | PASS | No new destructive action is introduced. Existing archive, restore, force-delete, credential deletion, and disable flows remain confirmed and capability-gated. |
|
||||||
|
| Asset strategy | PASS | PASS | No new asset bundle or deploy-time `filament:assets` change is required. |
|
||||||
|
| Testing truth (TEST-TRUTH-001) | PASS | PASS | The plan adds focused regression coverage for default-visible truth hierarchy, filter semantics, and non-readiness interpretation. |
|
||||||
|
|
||||||
|
## Phase 0 Research
|
||||||
|
|
||||||
|
Research outcomes are captured in `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/specs/179-provider-truth-cleanup/research.md`.
|
||||||
|
|
||||||
|
Key decisions:
|
||||||
|
|
||||||
|
- Remove tenant-list `app_status` prominence entirely instead of replacing it with a premature readiness badge.
|
||||||
|
- Reuse the existing tenant-detail Provider section and `providerConnectionState()` helper, but make consent and verification the leading provider summary fields.
|
||||||
|
- Keep `TenantVerificationReport` as the verification deep-dive and do not repurpose tenant operability gating as provider readiness truth.
|
||||||
|
- Promote provider connection `consent_status` and `verification_status` to the list’s default-visible current-state axes.
|
||||||
|
- Split provider connection detail and edit pages into current-state truth versus diagnostics rather than one mixed `Status` section.
|
||||||
|
- Keep legacy persisted fields and existing projection writers intact; this slice is presentation cleanup only.
|
||||||
|
- Update provider filters so the main operator filter language follows consent and verification rather than legacy status projections.
|
||||||
|
- Extend existing surface-truth tests and add one focused provider-surface truth test instead of introducing a broader test framework.
|
||||||
|
|
||||||
|
## Phase 1 Design
|
||||||
|
|
||||||
|
Design artifacts are created under `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/specs/179-provider-truth-cleanup/`:
|
||||||
|
|
||||||
|
- `research.md`: planning decisions and rejected alternatives
|
||||||
|
- `data-model.md`: persistent source truths and derived surface truth contracts
|
||||||
|
- `contracts/provider-truth-cleanup.openapi.yaml`: internal route and surface contract for tenant and provider truth cleanup
|
||||||
|
- `quickstart.md`: focused implementation and validation workflow
|
||||||
|
|
||||||
|
Design highlights:
|
||||||
|
|
||||||
|
- The tenant list will remain lifecycle-led and will not gain a new speculative readiness badge in this slice.
|
||||||
|
- `TenantResource::providerConnectionState()` and the shared Provider infolist entry will be repointed to current consent and verification truth, with legacy `status` and `health_status` relegated to diagnostics.
|
||||||
|
- `Tenant.app_status` will be removed from the leading tenant-list and tenant-detail operator contract rather than simply hidden behind a toggle.
|
||||||
|
- `ProviderConnectionResource` will make consent and verification the default-visible list columns and primary detail/edit state section, while legacy status and health become secondary diagnostics.
|
||||||
|
- No new readiness framework, readiness enum, or provider-state presenter will be introduced. Existing badge infrastructure will be extended only as needed with narrow centralized mappings for provider consent and provider verification, while legacy lifecycle and diagnostic mappings remain centralized.
|
||||||
|
- Existing provider mutation, verification, and authorization behavior stays intact; only the truth hierarchy on touched surfaces changes.
|
||||||
|
|
||||||
|
## Phase 1 — Agent Context Update
|
||||||
|
|
||||||
|
Run after artifact generation:
|
||||||
|
|
||||||
|
- `.specify/scripts/bash/update-agent-context.sh copilot`
|
||||||
|
|
||||||
|
## Project Structure
|
||||||
|
|
||||||
|
### Documentation (this feature)
|
||||||
|
|
||||||
|
```text
|
||||||
|
specs/179-provider-truth-cleanup/
|
||||||
|
├── spec.md
|
||||||
|
├── plan.md
|
||||||
|
├── research.md
|
||||||
|
├── data-model.md
|
||||||
|
├── quickstart.md
|
||||||
|
├── contracts/
|
||||||
|
│ └── provider-truth-cleanup.openapi.yaml
|
||||||
|
├── checklists/
|
||||||
|
│ └── requirements.md
|
||||||
|
└── tasks.md
|
||||||
|
```
|
||||||
|
|
||||||
|
### Source Code (repository root)
|
||||||
|
|
||||||
|
```text
|
||||||
|
app/
|
||||||
|
├── Filament/
|
||||||
|
│ ├── Resources/
|
||||||
|
│ │ ├── TenantResource.php
|
||||||
|
│ │ ├── TenantResource/
|
||||||
|
│ │ │ └── Pages/
|
||||||
|
│ │ │ ├── ListTenants.php
|
||||||
|
│ │ │ └── ViewTenant.php
|
||||||
|
│ │ ├── ProviderConnectionResource.php
|
||||||
|
│ │ └── ProviderConnectionResource/
|
||||||
|
│ │ └── Pages/
|
||||||
|
│ │ ├── ListProviderConnections.php
|
||||||
|
│ │ ├── ViewProviderConnection.php
|
||||||
|
│ │ └── EditProviderConnection.php
|
||||||
|
│ └── Widgets/
|
||||||
|
│ └── Tenant/
|
||||||
|
│ └── TenantVerificationReport.php
|
||||||
|
├── Models/
|
||||||
|
│ ├── Tenant.php
|
||||||
|
│ ├── ProviderConnection.php
|
||||||
|
│ ├── ProviderCredential.php
|
||||||
|
│ └── OperationRun.php
|
||||||
|
├── Services/
|
||||||
|
│ └── Tenants/
|
||||||
|
│ └── TenantOperabilityService.php
|
||||||
|
└── Support/
|
||||||
|
├── Badges/
|
||||||
|
│ ├── BadgeCatalog.php
|
||||||
|
│ ├── BadgeDomain.php
|
||||||
|
│ └── Domains/
|
||||||
|
│ ├── TenantAppStatusBadge.php
|
||||||
|
│ ├── ProviderConnectionStatusBadge.php
|
||||||
|
│ └── ProviderConnectionHealthBadge.php
|
||||||
|
├── Providers/
|
||||||
|
│ ├── ProviderConsentStatus.php
|
||||||
|
│ └── ProviderVerificationStatus.php
|
||||||
|
└── Tenants/
|
||||||
|
└── TenantLifecycle.php
|
||||||
|
|
||||||
|
resources/
|
||||||
|
└── views/
|
||||||
|
└── filament/
|
||||||
|
├── infolists/
|
||||||
|
│ └── entries/
|
||||||
|
│ └── provider-connection-state.blade.php
|
||||||
|
└── widgets/
|
||||||
|
└── tenant/
|
||||||
|
└── tenant-verification-report.blade.php
|
||||||
|
|
||||||
|
tests/
|
||||||
|
├── Feature/
|
||||||
|
│ ├── Filament/
|
||||||
|
│ │ ├── TenantLifecycleStatusDomainSeparationTest.php
|
||||||
|
│ │ ├── TenantTruthCleanupSpec179Test.php
|
||||||
|
│ │ └── ProviderConnectionsDbOnlyTest.php
|
||||||
|
│ ├── ProviderConnections/
|
||||||
|
│ │ ├── ProviderConnectionTruthCleanupSpec179Test.php
|
||||||
|
│ │ ├── ProviderConnectionListAuthorizationTest.php
|
||||||
|
│ │ ├── ProviderConnectionAuthorizationTest.php
|
||||||
|
│ │ └── RequiredFiltersTest.php
|
||||||
|
│ ├── Tenants/
|
||||||
|
│ │ └── TenantProviderConnectionsCtaTest.php
|
||||||
|
│ └── Rbac/
|
||||||
|
│ └── TenantResourceAuthorizationTest.php
|
||||||
|
└── Unit/
|
||||||
|
└── Badges/
|
||||||
|
├── TenantBadgesTest.php
|
||||||
|
└── ProviderConnectionBadgesTest.php
|
||||||
|
```
|
||||||
|
|
||||||
|
**Structure Decision**: Keep the feature entirely inside the existing Laravel/Filament monolith. Update the current resources, shared provider-summary partial, tenant verification widget contract, and focused test files instead of creating a new provider-readiness subsystem.
|
||||||
|
|
||||||
|
## Complexity Tracking
|
||||||
|
|
||||||
|
> No Constitution Check violations are planned. No exception or bloat trigger is currently justified.
|
||||||
|
|
||||||
|
| Violation | Why Needed | Simpler Alternative Rejected Because |
|
||||||
|
|-----------|------------|-------------------------------------|
|
||||||
|
| — | — | — |
|
||||||
|
|
||||||
|
## Proportionality Review
|
||||||
|
|
||||||
|
> No new enum, persisted entity, abstraction layer, taxonomy, or cross-domain UI framework is planned in this slice.
|
||||||
|
|
||||||
|
- **Current operator problem**: Current tenant and provider surfaces let legacy status fields sound more authoritative than current lifecycle, consent, and verification truth.
|
||||||
|
- **Existing structure is insufficient because**: The model already stores better truth, but the resources and shared provider-summary partial still foreground the wrong fields.
|
||||||
|
- **Narrowest correct implementation**: Rewire the existing tenant and provider surfaces to consume the current truth hierarchy without inventing a new readiness model or dropping persisted compatibility fields.
|
||||||
|
- **Ownership cost created**: Focused resource updates, one shared partial rewrite, and a small regression suite for surface truth hierarchy.
|
||||||
|
- **Alternative intentionally rejected**: A new readiness enum, new presenter layer, or schema cleanup.
|
||||||
|
- **Release truth**: Current-release truth cleanup.
|
||||||
|
|
||||||
|
## Implementation Strategy
|
||||||
|
|
||||||
|
### Phase A — Remove Legacy Tenant App Status From Primary Tenant Surfaces
|
||||||
|
|
||||||
|
**Goal**: Make tenant lifecycle the only default-visible tenant status axis on the tenant list and tenant detail identity block.
|
||||||
|
|
||||||
|
| Step | File | Change |
|
||||||
|
|------|------|--------|
|
||||||
|
| A.1 | `app/Filament/Resources/TenantResource.php` | Remove `app_status` from the default-visible tenant list column set and stop treating it as a primary list filter. Keep lifecycle and other tenant identity fields intact. |
|
||||||
|
| A.2 | `app/Filament/Resources/TenantResource.php` | Remove `app_status` from the tenant-detail `Identity` section’s leading status contract rather than simply hiding it behind a toggle. |
|
||||||
|
| A.3 | `tests/Feature/Filament/TenantLifecycleStatusDomainSeparationTest.php` and `tests/Feature/Filament/TenantTruthCleanupSpec179Test.php` | Prove tenant list and tenant detail no longer use `app_status` as primary operator truth. |
|
||||||
|
|
||||||
|
### Phase B — Repoint Tenant Detail Provider Summary To Current Provider Truth
|
||||||
|
|
||||||
|
**Goal**: Make tenant detail show provider consent and verification rather than legacy provider connection status and health.
|
||||||
|
|
||||||
|
| Step | File | Change |
|
||||||
|
|------|------|--------|
|
||||||
|
| B.1 | `app/Filament/Resources/TenantResource.php` | Refactor `providerConnectionState()` to derive provider summary fields from the chosen Microsoft connection’s `consent_status`, `verification_status`, `is_default`, `last_health_check_at`, and `last_error_reason_code`, while keeping missing-connection handling explicit. |
|
||||||
|
| B.2 | `resources/views/filament/infolists/entries/provider-connection-state.blade.php` | Rewrite the Provider summary entry so consent and verification are the leading displayed values and legacy `status` and `health_status` move to optional diagnostics. |
|
||||||
|
| B.3 | `app/Filament/Widgets/Tenant/TenantVerificationReport.php` and its Blade view | Keep the existing verification widget as the deep-dive stored-report surface and ensure the surrounding tenant-detail semantics do not duplicate or contradict it. |
|
||||||
|
| B.4 | `tests/Feature/Tenants/TenantProviderConnectionsCtaTest.php` and `tests/Feature/Filament/TenantTruthCleanupSpec179Test.php` | Prove the tenant-detail provider CTA remains correct and the Provider section no longer implies readiness from legacy fields. |
|
||||||
|
|
||||||
|
### Phase C — Promote Consent And Verification On Provider Connection List Surfaces
|
||||||
|
|
||||||
|
**Goal**: Make the provider connections list answer the current provider-state questions from consent and verification first.
|
||||||
|
|
||||||
|
| Step | File | Change |
|
||||||
|
|------|------|--------|
|
||||||
|
| C.1 | `app/Filament/Resources/ProviderConnectionResource.php` | Add default-visible consent and verification columns using existing label helpers and keep connection type and default designation as supporting facts. |
|
||||||
|
| C.2 | `app/Filament/Resources/ProviderConnectionResource.php` | Move legacy `status` and `health_status` columns to toggleable or explicitly diagnostic visibility and adjust visible-column defaults accordingly. |
|
||||||
|
| C.3 | `app/Filament/Resources/ProviderConnectionResource.php` | Replace or demote core `status` and `health_status` filters so the leading filter language becomes consent and verification, with legacy filters retained only if clearly diagnostic. |
|
||||||
|
| C.4 | `tests/Feature/ProviderConnections/RequiredFiltersTest.php`, `tests/Feature/Filament/ProviderConnectionsDbOnlyTest.php`, and `tests/Feature/ProviderConnections/ProviderConnectionTruthCleanupSpec179Test.php` | Prove default-visible columns and filters follow the new hierarchy without breaking DB-only rendering. |
|
||||||
|
|
||||||
|
### Phase D — Split Provider Connection View And Edit Into Current State Versus Diagnostics
|
||||||
|
|
||||||
|
**Goal**: Prevent provider connection detail and edit pages from showing current and legacy status axes as peers.
|
||||||
|
|
||||||
|
| Step | File | Change |
|
||||||
|
|------|------|--------|
|
||||||
|
| D.1 | `app/Filament/Resources/ProviderConnectionResource.php` | Replace the mixed `Status` infolist section with a primary current-state section for consent and verification and a secondary diagnostics section for legacy status, legacy health, migration review, and last-error fields. |
|
||||||
|
| D.2 | `app/Filament/Resources/ProviderConnectionResource.php` | Apply the same separation to the edit form’s read-only state context so mutations happen in the presence of current consent and verification truth, with legacy fields shown only as diagnostics if retained. |
|
||||||
|
| D.3 | `tests/Feature/ProviderConnections/ProviderConnectionTruthCleanupSpec179Test.php` | Prove current state and diagnostics are visually and semantically separate on provider connection view and edit surfaces. |
|
||||||
|
|
||||||
|
### Phase E — Preserve Central Truth Mapping Without Adding A New Badge Framework
|
||||||
|
|
||||||
|
**Goal**: Keep badge and presentation semantics centralized while avoiding a new readiness taxonomy.
|
||||||
|
|
||||||
|
| Step | File | Change |
|
||||||
|
|------|------|--------|
|
||||||
|
| E.1 | `app/Support/Badges/BadgeCatalog.php`, `app/Support/Badges/BadgeDomain.php`, `app/Support/Badges/Domains/ProviderConsentStatusBadge.php`, `app/Support/Badges/Domains/ProviderVerificationStatusBadge.php`, and existing legacy badge-domain classes | Add narrow centralized badge mappings for `consent_status` and `verification_status`, keep legacy app-status or connection-status mappings diagnostic-only, and do not introduce a synthetic readiness badge domain in this slice. |
|
||||||
|
| E.2 | `tests/Unit/Badges/TenantBadgesTest.php` and `tests/Unit/Badges/ProviderConnectionBadgesTest.php` | Update unit badge coverage for the centralized provider consent and provider verification mappings and keep legacy diagnostic mapping expectations explicit. |
|
||||||
|
|
||||||
|
### Phase F — Protect Authorization, DB-only Rendering, And Formatting
|
||||||
|
|
||||||
|
**Goal**: Keep the cleanup semantically tight and operationally safe.
|
||||||
|
|
||||||
|
| Step | File | Change |
|
||||||
|
|------|------|--------|
|
||||||
|
| F.1 | Existing tenant and provider authorization tests | Re-run and extend current authorization coverage only where surface visibility or filter behavior changes could affect scope boundaries. |
|
||||||
|
| F.2 | Focused Pest verification pack | Add or update tests proving legacy-status suppression, provider current-state promotion, filter truth alignment, DB-only rendering, and denial semantics. |
|
||||||
|
| F.3 | `vendor/bin/sail bin pint --dirty --format agent` and focused Pest runs | Apply formatting and run the minimum Sail-first verification pack before implementation is considered complete. |
|
||||||
|
|
||||||
|
## Key Design Decisions
|
||||||
|
|
||||||
|
### D-001 — Do not create a new readiness model in a truth-cleanup spec
|
||||||
|
|
||||||
|
The current feature exists to stop misleading primary surfaces, not to coin a new readiness taxonomy. Lifecycle, consent, and verification already provide the necessary authoritative inputs.
|
||||||
|
|
||||||
|
### D-002 — The tenant list should consciously omit provider readiness rather than compress it unsafely
|
||||||
|
|
||||||
|
Any row-level provider readiness badge would require non-trivial aggregation and semantic compression across provider connections. Omitting that signal is safer and matches the spec’s fallback allowance.
|
||||||
|
|
||||||
|
### D-003 — Reuse the existing tenant-detail Provider section instead of adding another summary layer
|
||||||
|
|
||||||
|
`providerConnectionState()` and the shared infolist entry already own the tenant-detail provider summary. Repointing that contract is narrower than building a new widget or presenter.
|
||||||
|
|
||||||
|
### D-004 — Provider detail pages need one current-state section and one diagnostics section
|
||||||
|
|
||||||
|
Equal-weight status groupings are the core current UI problem. Separating current state from diagnostics is the smallest structural change that makes the leading truth obvious.
|
||||||
|
|
||||||
|
### D-005 — Keep legacy persisted fields, but remove their authority on targeted surfaces
|
||||||
|
|
||||||
|
The model and some jobs still project `status` and `health_status`, and tenant `app_status` still exists in storage. This slice preserves compatibility while eliminating misleading prominence.
|
||||||
|
|
||||||
|
### D-006 — Do not use tenant operability gating as provider readiness truth
|
||||||
|
|
||||||
|
`TenantOperabilityService` answers lifecycle and capability eligibility questions. It is not a substitute for consent or verification health and must stay separate in both implementation and messaging.
|
||||||
|
|
||||||
|
## Risk Assessment
|
||||||
|
|
||||||
|
| Risk | Impact | Likelihood | Mitigation |
|
||||||
|
|------|--------|------------|------------|
|
||||||
|
| Removing `app_status` from the tenant list may reduce scanability before a later readiness spec lands | Medium | Medium | Keep the tenant list lifecycle-led, keep the provider CTA obvious on tenant detail, and treat any future list signal as follow-up work rather than a rushed addition now. |
|
||||||
|
| Tenant detail may still show the wrong provider connection when multiple connections exist | High | Medium | Continue to prefer the default Microsoft connection when present and render an explicit missing or configured state rather than an optimistic readiness label. |
|
||||||
|
| Provider list could retain legacy semantics if filters change more slowly than columns | High | Medium | Update both default-visible columns and core filter set together and protect them with `RequiredFiltersTest` plus a focused truth test. |
|
||||||
|
| Existing tests currently asserting `app_status` visibility will fail noisily | Medium | High | Update the intentional tenant truth-separation test first and add a spec-specific replacement that encodes the new contract. |
|
||||||
|
| New tenant-list provider queries could create unnecessary DB cost | Medium | Low | Avoid adding a new per-row provider signal on the tenant list in this slice. |
|
||||||
|
|
||||||
|
## Test Strategy
|
||||||
|
|
||||||
|
- Update `tests/Feature/Filament/TenantLifecycleStatusDomainSeparationTest.php` so it protects the new tenant truth hierarchy instead of asserting legacy `app_status` prominence.
|
||||||
|
- Add `tests/Feature/Filament/TenantTruthCleanupSpec179Test.php` to cover tenant list default-visible truth and tenant detail Provider section semantics.
|
||||||
|
- Add `tests/Feature/ProviderConnections/ProviderConnectionTruthCleanupSpec179Test.php` to cover provider connection list, view, and edit truth hierarchy.
|
||||||
|
- Update `tests/Feature/ProviderConnections/RequiredFiltersTest.php` so the core filter contract follows consent and verification instead of legacy status or health.
|
||||||
|
- Update `tests/Feature/Filament/ProviderConnectionsDbOnlyTest.php` so visible-column expectations and DB-only rendering stay valid after the list contract changes.
|
||||||
|
- Keep `tests/Feature/Tenants/TenantProviderConnectionsCtaTest.php` and existing authorization tests in the focused pack so route and scope semantics do not regress.
|
||||||
|
- Keep tenant global-search safety and provider-connection global-search exclusion in the focused pack so discovery behavior does not regress.
|
||||||
|
- Finish the feature with an explicit no-migration and no-new-persistence diff validation gate in addition to runtime regression coverage.
|
||||||
|
- Run the focused Sail-first pack defined in `quickstart.md`, then run `vendor/bin/sail bin pint --dirty --format agent` before closing implementation.
|
||||||
|
|
||||||
|
## Constitution Check (Post-Design)
|
||||||
|
|
||||||
|
Re-check result: PASS.
|
||||||
|
|
||||||
|
- Livewire v4.0+ compliance: preserved because the plan stays within the current Filament v5 + Livewire v4 resources, widgets, and pages.
|
||||||
|
- Provider registration location: unchanged; no panel or provider registration work is needed beyond the existing `bootstrap/providers.php` setup.
|
||||||
|
- Global-searchable resources: `TenantResource` remains globally searchable and already has both view and edit pages; `ProviderConnectionResource` remains non-globally-searchable, so no global-search conflict is introduced.
|
||||||
|
- Destructive actions: no new destructive action is added. Existing tenant archive, restore, and force-delete flows and existing provider credential or connection-state mutations remain confirmation-protected and capability-gated.
|
||||||
|
- Asset strategy: unchanged; no new assets are introduced and the deploy-time `php artisan filament:assets` process remains unaffected.
|
||||||
|
- Testing plan: focused Pest coverage will protect tenant legacy-status suppression, tenant-detail provider summary truth, provider current-state promotion, filter hierarchy, DB-only rendering, and unchanged authorization boundaries.
|
||||||
147
specs/179-provider-truth-cleanup/quickstart.md
Normal file
147
specs/179-provider-truth-cleanup/quickstart.md
Normal file
@ -0,0 +1,147 @@
|
|||||||
|
# Quickstart: Provider Readiness Source-of-Truth Cleanup
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
Validate that tenant and provider operator surfaces no longer elevate `Tenant.app_status`, `ProviderConnection.status`, or `ProviderConnection.health_status` as leading truth, and that lifecycle, consent, and verification now answer the primary operator questions.
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
|
||||||
|
1. Start Sail.
|
||||||
|
2. Prepare one workspace member with at least one visible tenant and provider connection management access.
|
||||||
|
3. Seed or create tenant scenarios for:
|
||||||
|
- active tenant with `app_status` populated but provider verification `unknown`
|
||||||
|
- onboarding tenant with granted consent and blocked verification
|
||||||
|
- tenant with no default Microsoft provider connection
|
||||||
|
4. Seed or create provider connection scenarios for:
|
||||||
|
- consent `granted`, verification `degraded`, legacy `status=connected`, legacy `health_status=ok`
|
||||||
|
- consent `required` or `revoked`, verification `blocked`, legacy `status` still optimistic
|
||||||
|
- configured connection that has never been verified
|
||||||
|
- disabled connection with retained legacy status or health values
|
||||||
|
5. Prepare one non-member or cross-workspace actor for deny-as-not-found checks.
|
||||||
|
|
||||||
|
## Implementation Validation Order
|
||||||
|
|
||||||
|
### 1. Run the current baseline tenant and provider surface tests
|
||||||
|
|
||||||
|
```bash
|
||||||
|
vendor/bin/sail artisan test --compact tests/Feature/Filament/TenantLifecycleStatusDomainSeparationTest.php
|
||||||
|
vendor/bin/sail artisan test --compact tests/Feature/Filament/ProviderConnectionsDbOnlyTest.php
|
||||||
|
vendor/bin/sail artisan test --compact tests/Feature/ProviderConnections/RequiredFiltersTest.php
|
||||||
|
vendor/bin/sail artisan test --compact tests/Feature/Tenants/TenantProviderConnectionsCtaTest.php
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected outcome:
|
||||||
|
- Existing tenant detail, provider connection pages, canonical tenantless provider route, and provider CTA behavior still render correctly before the cleanup changes are applied.
|
||||||
|
|
||||||
|
### 2. Run focused tenant truth-cleanup coverage
|
||||||
|
|
||||||
|
```bash
|
||||||
|
vendor/bin/sail artisan test --compact tests/Feature/Filament/TenantLifecycleStatusDomainSeparationTest.php
|
||||||
|
vendor/bin/sail artisan test --compact tests/Feature/Filament/TenantTruthCleanupSpec179Test.php
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected outcome:
|
||||||
|
- Tenant list no longer shows `app_status` as default-visible truth.
|
||||||
|
- Tenant detail keeps lifecycle separate from provider consent and verification.
|
||||||
|
- Tenant detail Provider section stops leading with legacy `status` and `health_status`.
|
||||||
|
|
||||||
|
### 3. Run focused provider truth-cleanup coverage
|
||||||
|
|
||||||
|
```bash
|
||||||
|
vendor/bin/sail artisan test --compact tests/Feature/ProviderConnections/ProviderConnectionTruthCleanupSpec179Test.php
|
||||||
|
vendor/bin/sail artisan test --compact tests/Feature/ProviderConnections/RequiredFiltersTest.php
|
||||||
|
vendor/bin/sail artisan test --compact tests/Feature/Filament/ProviderConnectionsDbOnlyTest.php
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected outcome:
|
||||||
|
- Provider connection list promotes consent and verification to the default-visible columns.
|
||||||
|
- Provider connection view and edit pages show current state separately from diagnostics.
|
||||||
|
- Legacy `status` and `health_status` remain secondary or hidden by default.
|
||||||
|
|
||||||
|
### 4. Re-run authorization and discovery-safety coverage on touched resources
|
||||||
|
|
||||||
|
```bash
|
||||||
|
vendor/bin/sail artisan test --compact tests/Feature/Rbac/TenantResourceAuthorizationTest.php
|
||||||
|
vendor/bin/sail artisan test --compact tests/Feature/ProviderConnections/ProviderConnectionListAuthorizationTest.php
|
||||||
|
vendor/bin/sail artisan test --compact tests/Feature/ProviderConnections/ProviderConnectionAuthorizationTest.php
|
||||||
|
vendor/bin/sail artisan test --compact tests/Feature/Rbac/AdminGlobalSearchContextSafetyTest.php
|
||||||
|
vendor/bin/sail artisan test --compact tests/Feature/Filament/TenantGlobalSearchLifecycleScopeTest.php
|
||||||
|
vendor/bin/sail artisan test --compact tests/Feature/Filament/TenantScopingTest.php
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected outcome:
|
||||||
|
- Workspace and tenant scoping remain unchanged.
|
||||||
|
- Non-members still receive deny-as-not-found behavior.
|
||||||
|
- Members without capability do not gain new visibility or mutation access.
|
||||||
|
- Tenant global search remains workspace-safe.
|
||||||
|
- Provider connections remain excluded from global search.
|
||||||
|
|
||||||
|
### 5. Run badge-mapping coverage
|
||||||
|
|
||||||
|
```bash
|
||||||
|
vendor/bin/sail artisan test --compact tests/Unit/Badges/TenantBadgesTest.php
|
||||||
|
vendor/bin/sail artisan test --compact tests/Unit/Badges/ProviderConnectionBadgesTest.php
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected outcome:
|
||||||
|
- Lifecycle, provider consent, provider verification, and retained legacy diagnostic badges resolve through centralized badge mappings only.
|
||||||
|
|
||||||
|
### 6. Format touched files
|
||||||
|
|
||||||
|
```bash
|
||||||
|
vendor/bin/sail bin pint --dirty --format agent
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected outcome:
|
||||||
|
- All touched implementation files conform to project formatting rules.
|
||||||
|
|
||||||
|
### 7. Run the final focused verification pack
|
||||||
|
|
||||||
|
```bash
|
||||||
|
vendor/bin/sail artisan test --compact tests/Feature/Filament/TenantLifecycleStatusDomainSeparationTest.php
|
||||||
|
vendor/bin/sail artisan test --compact tests/Feature/Filament/TenantTruthCleanupSpec179Test.php
|
||||||
|
vendor/bin/sail artisan test --compact tests/Feature/Filament/ProviderConnectionsDbOnlyTest.php
|
||||||
|
vendor/bin/sail artisan test --compact tests/Feature/ProviderConnections/ProviderConnectionTruthCleanupSpec179Test.php
|
||||||
|
vendor/bin/sail artisan test --compact tests/Feature/ProviderConnections/RequiredFiltersTest.php
|
||||||
|
vendor/bin/sail artisan test --compact tests/Feature/Tenants/TenantProviderConnectionsCtaTest.php
|
||||||
|
vendor/bin/sail artisan test --compact tests/Feature/Rbac/TenantResourceAuthorizationTest.php
|
||||||
|
vendor/bin/sail artisan test --compact tests/Feature/ProviderConnections/ProviderConnectionListAuthorizationTest.php
|
||||||
|
vendor/bin/sail artisan test --compact tests/Feature/ProviderConnections/ProviderConnectionAuthorizationTest.php
|
||||||
|
vendor/bin/sail artisan test --compact tests/Feature/Rbac/AdminGlobalSearchContextSafetyTest.php
|
||||||
|
vendor/bin/sail artisan test --compact tests/Feature/Filament/TenantGlobalSearchLifecycleScopeTest.php
|
||||||
|
vendor/bin/sail artisan test --compact tests/Feature/Filament/TenantScopingTest.php
|
||||||
|
vendor/bin/sail artisan test --compact tests/Unit/Badges/TenantBadgesTest.php
|
||||||
|
vendor/bin/sail artisan test --compact tests/Unit/Badges/ProviderConnectionBadgesTest.php
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected outcome:
|
||||||
|
- The targeted tenant and provider surfaces keep truthful status hierarchy, keep DB-only rendering where already promised, and preserve existing authorization boundaries.
|
||||||
|
|
||||||
|
### 8. Validate no migration and no new persisted truth were introduced
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git diff --name-only -- database/migrations app/Models app/Support/Providers app/Support/Badges/BadgeDomain.php app/Support/Badges/BadgeCatalog.php app/Support/Badges/Domains/ProviderConsentStatusBadge.php app/Support/Badges/Domains/ProviderVerificationStatusBadge.php app/Support/Badges/Domains/TenantAppStatusBadge.php app/Support/Badges/Domains/ProviderConnectionStatusBadge.php app/Support/Badges/Domains/ProviderConnectionHealthBadge.php specs/179-provider-truth-cleanup
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected outcome:
|
||||||
|
- No new migration file is introduced for this feature.
|
||||||
|
- No new persisted readiness artifact or new provider-status family appears outside the approved centralized badge mapping work.
|
||||||
|
- Central badge changes are limited to the approved consent, verification, and retained diagnostic badge mappers.
|
||||||
|
|
||||||
|
## Manual Smoke Check
|
||||||
|
|
||||||
|
1. Open `/admin/tenants` and confirm lifecycle remains visible while `app_status` is no longer a default-visible status badge.
|
||||||
|
2. Open one tenant detail page and confirm the `Provider` section now leads with consent and verification, not connection `status` and `health`.
|
||||||
|
3. Confirm the `Verification report` widget still provides the deeper stored verification surface and does not depend on outbound HTTP at render time.
|
||||||
|
4. Open `/admin/provider-connections` and confirm consent and verification are the primary default-visible state columns.
|
||||||
|
5. Confirm any retained legacy `status` or `health` values are secondary diagnostics rather than peer badges.
|
||||||
|
6. Open a provider connection view page and confirm `configured`, `connected`, or `consented` no longer read as equivalent to verified or ready.
|
||||||
|
7. Open a provider connection edit page and confirm current consent and verification context are visible before any mutation, while diagnostics remain secondary.
|
||||||
|
8. Repeat one tenant and one provider URL as a non-member or out-of-scope actor and confirm deny-as-not-found behavior still holds.
|
||||||
|
|
||||||
|
## Non-Goals For This Slice
|
||||||
|
|
||||||
|
- No database migration.
|
||||||
|
- No new readiness enum, score, or persisted summary.
|
||||||
|
- No change to verification queueing, `OperationRun` semantics, or provider mutation workflows.
|
||||||
|
- No removal of legacy database fields or projection writers.
|
||||||
74
specs/179-provider-truth-cleanup/research.md
Normal file
74
specs/179-provider-truth-cleanup/research.md
Normal file
@ -0,0 +1,74 @@
|
|||||||
|
# Phase 0 Research: Provider Readiness Source-of-Truth Cleanup
|
||||||
|
|
||||||
|
## Decision: Remove legacy `app_status` from tenant list primary truth and do not replace it with a new row-level readiness badge in this slice
|
||||||
|
|
||||||
|
**Rationale**: The current tenant list exposes `app_status` as a default-visible badge and filter even though the feature goal is to stop projecting frozen or semantically stale provider truth from tenant-level legacy fields. A new per-row provider readiness badge would require choosing one provider connection, compressing multi-connection state, and implicitly inventing a readiness model. The narrowest safe move is to let the tenant list remain lifecycle-led and to consciously omit provider readiness from the row when it cannot be stated without semantic overreach.
|
||||||
|
|
||||||
|
**Alternatives considered**:
|
||||||
|
- Introduce a new tenant readiness badge on the list: rejected because it would create a new semantic layer before the later readiness spec.
|
||||||
|
- Keep `app_status` but toggle it off by default: rejected because the spec explicitly warns that hidden-by-default legacy truth still leaks back into operator interpretation.
|
||||||
|
|
||||||
|
## Decision: Reuse the existing tenant Provider section, but repoint it from legacy `status` and `health_status` to consent and verification
|
||||||
|
|
||||||
|
**Rationale**: Tenant detail already has a dedicated `Provider` section backed by `TenantResource::providerConnectionState()` and the shared Blade entry `provider-connection-state.blade.php`. That section is the narrowest place to present current provider truth without inventing a new widget or presenter. The helper should continue to resolve the default Microsoft provider connection or a missing-connection state, but its leading fields should become `consent_status` and `verification_status`, with legacy status and health demoted to optional diagnostics.
|
||||||
|
|
||||||
|
**Alternatives considered**:
|
||||||
|
- Build a new tenant provider summary widget: rejected because the current section already exists and the spec does not justify another surface layer.
|
||||||
|
- Remove the Provider section entirely and rely only on the verification widget: rejected because the spec requires clear consent and verification semantics, and the verification widget is a deep-dive report, not the only summary contract.
|
||||||
|
|
||||||
|
## Decision: Keep `TenantVerificationReport` as the verification deep-dive and do not turn `TenantOperability` into provider truth
|
||||||
|
|
||||||
|
**Rationale**: `TenantVerificationReport` already gives tenant detail a DB-only verification deep-dive using the latest `provider.connection.check` run. `TenantOperabilityService` and `verificationReadinessOutcome()` answer whether a verification action may be started in a given lifecycle and capability context, not whether the provider is operationally healthy. The cleanup should continue to use the verification report for stored verification detail and must not promote operability gating into readiness truth.
|
||||||
|
|
||||||
|
**Alternatives considered**:
|
||||||
|
- Use `TenantOperabilityDecision` as a tenant provider readiness signal: rejected because it mixes lifecycle and permission gating with provider-state truth.
|
||||||
|
- Collapse verification detail into the Provider section and remove the verification widget: rejected because the widget already provides the deeper stored-report surface and no new readiness model is needed.
|
||||||
|
|
||||||
|
## Decision: Promote `consent_status` and `verification_status` to the default-visible provider connection list axes
|
||||||
|
|
||||||
|
**Rationale**: The provider connections list currently defaults to `status` and `health_status`, even though the model already stores `consent_status` and `verification_status` and the resource already has label helpers for them. Surfacing consent and verification in the table is the clearest way to answer whether a connection is consented and whether it has been checked. Legacy `status` and `health_status` may remain available as hidden diagnostics for compatibility, but they should stop being the primary scan path.
|
||||||
|
|
||||||
|
**Alternatives considered**:
|
||||||
|
- Keep `status` and `health_status` visible because they are shorter to scan: rejected because that preserves the competing-truth problem the spec exists to remove.
|
||||||
|
- Remove legacy columns completely from the list: rejected because the fields may still help diagnostics and internal compatibility during the transition.
|
||||||
|
|
||||||
|
## Decision: Split provider connection detail and edit surfaces into current state versus diagnostics instead of one equal-weight Status section
|
||||||
|
|
||||||
|
**Rationale**: The current provider connection infolist and form both render consent, verification, legacy status, and health at the same hierarchy level in one `Status` section. The cleanup should create a primary current-state section for consent and verification, and a separate diagnostics section for legacy status, health, migration review, and last-error metadata. This keeps the existing resource and existing fields while making the leading truth explicit.
|
||||||
|
|
||||||
|
**Alternatives considered**:
|
||||||
|
- Keep one Status section and merely reorder the fields: rejected because equal visual grouping would still overstate the legacy fields.
|
||||||
|
- Delete legacy fields from detail and edit immediately: rejected because the spec allows the fields to remain as long as they stop acting like leading truth.
|
||||||
|
|
||||||
|
## Decision: Extend the existing badge catalog with narrow provider consent and provider verification mappings while keeping legacy badge domains diagnostic-only
|
||||||
|
|
||||||
|
**Rationale**: BADGE-001 requires status-like values to render through `BadgeCatalog` and `BadgeRenderer`. `BadgeCatalog` already centralizes legacy `TenantAppStatus`, `ProviderConnectionStatus`, and `ProviderConnectionHealth` mappings, so the narrowest compliant move is to add centralized provider consent and provider verification mappings inside the existing badge system. This keeps lifecycle, consent, verification, and legacy diagnostics on one central semantic path without creating a synthetic `Ready` or `ProviderReadiness` domain.
|
||||||
|
|
||||||
|
**Alternatives considered**:
|
||||||
|
- Continue using plain labels or local helper output for consent and verification: rejected because BADGE-001 requires centralized status-like badge semantics.
|
||||||
|
- Add a new `ProviderReadiness` badge domain: rejected because it would front-run the later readiness spec and violate proportionality.
|
||||||
|
- Remove legacy badge mappers entirely: rejected because diagnostic surfaces and internal compatibility still reference them.
|
||||||
|
|
||||||
|
## Decision: Keep legacy persisted fields and current projection writers intact in this slice
|
||||||
|
|
||||||
|
**Rationale**: `ProviderConnection` still projects `status` and `health_status` during classification and enable or disable flows, and tenant records still persist `app_status`. The spec explicitly forbids a schema change or aggressive technical removal. The correct scope is presentation cleanup first, not domain-field deletion.
|
||||||
|
|
||||||
|
**Alternatives considered**:
|
||||||
|
- Drop legacy columns now: rejected because existing jobs, projections, audit metadata, and compatibility paths still write or reference them.
|
||||||
|
- Stop writing the fields immediately as part of this spec: rejected because it would turn a surface-truth cleanup into a broader behavioral change.
|
||||||
|
|
||||||
|
## Decision: Update provider list filters to follow the leading truth hierarchy
|
||||||
|
|
||||||
|
**Rationale**: The current provider list filter set centers `status` and `health_status`. If the table’s leading truth becomes consent and verification, the list’s core filters should follow the same hierarchy. Legacy filters may remain only as explicitly diagnostic filters, not as the primary filter language for current provider state.
|
||||||
|
|
||||||
|
**Alternatives considered**:
|
||||||
|
- Leave legacy filters untouched while changing only columns: rejected because that keeps the old semantics in the main operator interaction path.
|
||||||
|
- Remove all legacy filters outright: rejected because diagnostic filtering may still be useful while the fields remain persisted.
|
||||||
|
|
||||||
|
## Decision: Extend existing truth-separation tests and add one focused provider-surface truth test instead of inventing a large new test harness
|
||||||
|
|
||||||
|
**Rationale**: The repo already has `TenantLifecycleStatusDomainSeparationTest`, `ProviderConnectionsDbOnlyTest`, `RequiredFiltersTest`, and badge tests. The spec’s main risk is business-truth regression on a few surfaces. Focused feature tests that assert default-visible fields, section hierarchy, and filter names are enough to protect this contract without creating a broad new testing abstraction.
|
||||||
|
|
||||||
|
**Alternatives considered**:
|
||||||
|
- Rely on manual browser inspection only: rejected because truth regressions on default-visible surfaces are easy to reintroduce.
|
||||||
|
- Create a large generic presenter test framework first: rejected because the cleanup intentionally avoids adding a new presenter layer.
|
||||||
226
specs/179-provider-truth-cleanup/spec.md
Normal file
226
specs/179-provider-truth-cleanup/spec.md
Normal file
@ -0,0 +1,226 @@
|
|||||||
|
# Feature Specification: Spec 179 - Provider Readiness Source-of-Truth Cleanup
|
||||||
|
|
||||||
|
**Feature Branch**: `179-provider-truth-cleanup`
|
||||||
|
**Created**: 2026-04-04
|
||||||
|
**Status**: Draft
|
||||||
|
**Input**: User description: "Spec 179 — Provider Readiness Source-of-Truth Cleanup"
|
||||||
|
|
||||||
|
## Spec Scope Fields *(mandatory)*
|
||||||
|
|
||||||
|
- **Scope**: workspace + tenant
|
||||||
|
- **Primary Routes**:
|
||||||
|
- `/admin/tenants`
|
||||||
|
- `/admin/tenants/{tenant}`
|
||||||
|
- `/admin/provider-connections`
|
||||||
|
- `/admin/provider-connections/{record}`
|
||||||
|
- `/admin/provider-connections/{record}/edit`
|
||||||
|
- **Data Ownership**:
|
||||||
|
- Tenant-owned truth already exists in tenant lifecycle state, provider connection consent state, provider connection verification state, provider connection metadata, and stored verification outputs tied to a tenant.
|
||||||
|
- Workspace-owned context remains the selected workspace and its membership filter, which determines which tenants and provider connections are visible in `/admin`.
|
||||||
|
- This feature introduces no new persisted truth. It only removes misleading prominence from existing legacy status fields and reorders presentation of already-stored truth.
|
||||||
|
- **RBAC**:
|
||||||
|
- Workspace membership remains required for the tenant and provider resources in `/admin`.
|
||||||
|
- Tenant membership remains the isolation boundary for tenant and provider records.
|
||||||
|
- Existing provider view and provider manage capability checks remain authoritative for view and mutation surfaces.
|
||||||
|
- No authorization broadening is allowed; non-members remain deny-as-not-found and members without capability remain forbidden for protected actions.
|
||||||
|
|
||||||
|
## UI/UX Surface Classification *(mandatory when operator-facing surfaces are changed)*
|
||||||
|
|
||||||
|
| Surface | Surface Type | Primary Inspect/Open Model | Row Click | Secondary Actions Placement | Destructive Actions Placement | Canonical Collection Route | Canonical Detail Route | Scope Signals | Canonical Noun | Critical Truth Visible by Default | Exception Type |
|
||||||
|
|---|---|---|---|---|---|---|---|---|---|---|---|
|
||||||
|
| Tenant list | CRUD / List-first Resource | Full-row click into tenant view | required | Header `Add tenant`; non-inspect row actions stay in `More` | Existing archive, restore, and force-delete actions remain secondary in `More`; unchanged by this spec | `/admin/tenants` | `/admin/tenants/{tenant}` | Active workspace, tenant name, environment, and existing tenant scope | Tenants / Tenant | Tenant lifecycle, plus an optional bounded provider signal only when it is derived from current provider truth | none |
|
||||||
|
| Tenant view | Detail / view-first resource | Tenant view page itself | forbidden | Header `ActionGroup` remains the secondary action container | Existing destructive lifecycle actions stay grouped and confirmed; unchanged by this spec | `/admin/tenants` | `/admin/tenants/{tenant}` | Tenant identity, workspace context, and existing tenant-scoped widgets | Tenant | Tenant lifecycle first; provider consent and provider verification separate from lifecycle and RBAC | none |
|
||||||
|
| Provider connections list | CRUD / List-first Resource | Full-row click into provider connection view | required | Header `New connection`; operational and management actions stay in row `More` | Existing set-default, enable or disable, and credential mutations stay secondary and confirmed where already required | `/admin/provider-connections` | `/admin/provider-connections/{record}` | Active workspace plus tenant filter or tenant query context, tenant name, provider name, and default marker | Provider Connections / Provider Connection | Consent state and verification state, not legacy connection status or health | Tenantless canonical route with tenant-aware filtering |
|
||||||
|
| Provider connection view | Detail / view-first resource | Provider connection view page itself | forbidden | Header actions and grouped header mutations remain the secondary action area | Existing credential and connection-state mutations remain grouped, confirmed, and capability-gated | `/admin/provider-connections` | `/admin/provider-connections/{record}` | Tenant identity, provider identity, connection type, and default marker | Provider Connection | Consent state and verification state as the leading diagnosis | none |
|
||||||
|
| Provider connection edit | Edit form | Provider connection edit page itself | forbidden | Save and cancel remain the form actions; grouped header actions remain secondary | Existing credential and connection-state mutations remain grouped, confirmed, and capability-gated | `/admin/provider-connections` | `/admin/provider-connections/{record}/edit` | Tenant identity, provider identity, connection type, and default marker | Provider Connection | Current consent and verification context before any configuration mutation; legacy fields are not allowed to dominate the form | none |
|
||||||
|
|
||||||
|
## Operator Surface Contract *(mandatory when operator-facing surfaces are changed)*
|
||||||
|
|
||||||
|
| Surface | Primary Persona | Surface Type | Primary Operator Question | Default-visible Information | Diagnostics-only Information | Status Dimensions Used | Mutation Scope | Primary Actions | Dangerous Actions |
|
||||||
|
|---|---|---|---|---|---|---|---|---|---|
|
||||||
|
| Tenant list | Workspace or tenant operator | List | Which tenants are in a normal lifecycle state, and is there any trustworthy provider concern I should notice without assuming readiness? | Tenant name, lifecycle, environment, and either a bounded provider signal from current provider truth or no provider signal at all | Legacy app-status projections and frozen provider surrogates are not default-visible | lifecycle, optional provider consent or verification summary | none on the list itself; existing row actions remain unchanged | Open tenant, Add tenant | Existing archive, restore, and force-delete actions remain secondary and are not reinterpreted as provider readiness signals |
|
||||||
|
| Tenant view | Tenant operator | Detail | What is this tenant's lifecycle, and what does the current provider base actually prove? | Lifecycle summary, provider consent, provider verification, verification summary, and RBAC shown as a separate domain when present | Legacy app status, historical provider projections, and raw verification internals stay diagnostic-only if retained at all | lifecycle, provider consent, provider verification, RBAC status as a separate domain | Existing verification and tenant management actions remain unchanged | Provider connections, Verify configuration, Edit, Grant admin consent, Open in Entra | Existing archive, restore, and force-delete actions remain grouped, confirmed, and clearly separate from provider truth |
|
||||||
|
| Provider connections list | Tenant operator or tenant admin | List | Which connections are consented, which are verified, and which still need attention? | Tenant, provider, default marker, consent state, verification state, and connection type when useful | Legacy connection status, legacy health, migration-review metadata, and raw error text stay secondary | consent, verification, default designation | Existing provider operations and management actions remain unchanged | Open provider connection, New connection | Set as default, enable or disable connection, dedicated-credential actions, and provider-run actions remain secondary and capability-gated |
|
||||||
|
| Provider connection view | Tenant operator or tenant admin | Detail | Is this connection only configured, or has it actually been checked and verified? | Consent state, verification state, provider identity, default designation, and next-step links | Legacy status and health, raw identifiers, and low-level technical fields stay secondary | consent, verification, connection type | Existing connection-check and provider-management actions remain unchanged | Check connection, Grant admin consent, Edit, View last check run | Enable dedicated override, rotate or delete dedicated credential, revert to platform, enable or disable connection, and set default remain grouped, confirmed, and authorized |
|
||||||
|
| Provider connection edit | Tenant admin | Edit | What can I safely change, and what do the current consent and verification states tell me before I mutate this connection? | Editable configuration, consent state, verification state, save and cancel, and the same tenant scope cues as the view page | Legacy status and health, raw technical metadata, and historical projections stay diagnostic-only if retained | consent, verification, connection type | Existing provider configuration mutation scope remains unchanged | Save changes, Cancel, Check connection, View last check run | Existing dedicated-credential and enable or disable actions remain grouped, confirmed, and authorized |
|
||||||
|
|
||||||
|
## Proportionality Review *(mandatory when structural complexity is introduced)*
|
||||||
|
|
||||||
|
- **New source of truth?**: No.
|
||||||
|
- **New persisted entity/table/artifact?**: No.
|
||||||
|
- **New abstraction?**: No.
|
||||||
|
- **New enum/state/reason family?**: No.
|
||||||
|
- **New cross-domain UI framework/taxonomy?**: No.
|
||||||
|
- **Current operator problem**: A tenant or provider connection can currently look active, connected, or healthy on a leading surface even when the provider basis is unclear, stale, blocked, or semantically contradicted by newer status fields.
|
||||||
|
- **Existing structure is insufficient because**: The current model already has clearer truth axes for lifecycle, consent, and verification, but primary tenant and provider surfaces still elevate legacy fields such as tenant app status, provider connection status, and provider health as if they were current truth.
|
||||||
|
- **Narrowest correct implementation**: Rework only the existing tenant and provider presentation layers, default-visible fields, filters, and badge mappings so that lifecycle, consent, and verification become the primary operator-facing truth without inventing a new readiness model.
|
||||||
|
- **Ownership cost**: The repo takes on focused UI cleanup, badge or filter adjustments, and regression coverage for truth ordering, but no new persistence, taxonomy, or cross-cutting semantic framework.
|
||||||
|
- **Alternative intentionally rejected**: A new tenant readiness enum, a full readiness assessment, or a new persisted summary artifact is rejected for this slice because the immediate risk is conflicting surface truth, not the absence of another status model.
|
||||||
|
- **Release truth**: Current-release truth cleanup that also prepares the codebase for later readiness and gating work.
|
||||||
|
|
||||||
|
## User Scenarios & Testing *(mandatory)*
|
||||||
|
|
||||||
|
### User Story 1 - Read truthful tenant surfaces (Priority: P1)
|
||||||
|
|
||||||
|
As an operator, I can open the tenant list and tenant view and understand tenant lifecycle separately from provider consent and provider verification, so an `active` tenant does not silently read as provider-ready.
|
||||||
|
|
||||||
|
**Why this priority**: The biggest current risk is false confidence on the main tenant-facing surfaces.
|
||||||
|
|
||||||
|
**Independent Test**: Can be fully tested by seeding tenants whose lifecycle, legacy app status, consent, and verification states disagree, then rendering the tenant list and tenant view to verify that lifecycle remains separate and legacy app status no longer drives the leading operator truth.
|
||||||
|
|
||||||
|
**Acceptance Scenarios**:
|
||||||
|
|
||||||
|
1. **Given** a tenant is `active` and its provider verification is `unknown`, **When** the operator opens the tenant list or tenant view, **Then** the surface shows lifecycle without implying the tenant is provider-ready.
|
||||||
|
2. **Given** a tenant is onboarding, consent is granted, and verification is blocked, **When** the operator opens the tenant view, **Then** onboarding remains the tenant lifecycle truth and the provider issue remains separately visible.
|
||||||
|
3. **Given** a tenant still has a populated legacy app-status value, **When** the operator opens a primary tenant surface, **Then** that legacy field is not presented as the current leading status.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### User Story 2 - Diagnose provider connections from current axes (Priority: P1)
|
||||||
|
|
||||||
|
As an operator, I can use provider connection list, view, and edit surfaces to see consent and verification as the leading provider state axes, even when legacy connection status fields still contain data.
|
||||||
|
|
||||||
|
**Why this priority**: Provider connection pages are the canonical diagnostic surfaces for this domain and must stop showing parallel state systems as peers.
|
||||||
|
|
||||||
|
**Independent Test**: Can be fully tested by seeding provider connections whose legacy `status` and `health_status` disagree with `consent_status` and `verification_status`, then verifying that list, view, and edit surfaces elevate consent and verification while demoting or hiding the legacy fields.
|
||||||
|
|
||||||
|
**Acceptance Scenarios**:
|
||||||
|
|
||||||
|
1. **Given** a provider connection has consent granted, verification degraded, legacy status `connected`, and legacy health `ok`, **When** the operator opens the provider connections list or detail pages, **Then** consent and degraded verification are the leading truths and the legacy fields do not appear at equal prominence.
|
||||||
|
2. **Given** a provider connection is configured but never verified, **When** the operator opens its view or edit page, **Then** the page makes clear that the connection is not yet proven healthy.
|
||||||
|
3. **Given** a provider connection is disabled, **When** the operator opens the list or detail page, **Then** connection-state actions remain secondary and do not override consent or verification semantics.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### User Story 3 - Avoid false readiness language across surfaces (Priority: P2)
|
||||||
|
|
||||||
|
As an operator, I can move between tenant and provider surfaces without seeing `active`, `connected`, `consented`, or similar labels treated as if they automatically mean `ready`.
|
||||||
|
|
||||||
|
**Why this priority**: This slice is a truth cleanup, not a final readiness model, so it must reduce semantic overreach before any follow-up readiness work starts.
|
||||||
|
|
||||||
|
**Independent Test**: Can be fully tested by rendering primary tenant and provider surfaces for records that look favorable in one status family but unfavorable in another and verifying that no default-visible wording or badge composition collapses them into one readiness conclusion.
|
||||||
|
|
||||||
|
**Acceptance Scenarios**:
|
||||||
|
|
||||||
|
1. **Given** a tenant is active and the default provider connection is consented but verification is error or blocked, **When** the operator inspects the tenant and provider surfaces, **Then** neither surface implies that the tenant is operationally ready.
|
||||||
|
2. **Given** the tenant list does not have enough trustworthy provider truth to summarize one tenant safely, **When** the list renders, **Then** it omits that provider readiness signal instead of inventing a legacy or optimistic substitute.
|
||||||
|
3. **Given** RBAC status is also shown on the tenant view, **When** the page renders, **Then** RBAC remains a separate domain and does not masquerade as provider readiness.
|
||||||
|
|
||||||
|
### Edge Cases
|
||||||
|
|
||||||
|
- Legacy app status or legacy provider status fields still contain optimistic values that contradict current verification truth.
|
||||||
|
- A tenant has no provider connection or has multiple provider connections, so the tenant list cannot safely compress provider truth into one scan-time signal.
|
||||||
|
- Consent is granted but verification has never been run, so the system must distinguish configured or consented from verified.
|
||||||
|
- Verification is stale, blocked, degraded, or error while the tenant lifecycle remains active.
|
||||||
|
- RBAC health is present on the tenant view at the same time as provider truth and must remain visibly separate.
|
||||||
|
- A non-member or cross-workspace actor attempts to reach tenant or provider surfaces and must continue to see deny-as-not-found behavior without new hints.
|
||||||
|
|
||||||
|
## Requirements *(mandatory)*
|
||||||
|
|
||||||
|
**Constitution alignment (required):** This feature introduces no new Microsoft Graph contract, no new long-running workflow, and no new write path. Existing verification and provider-run actions remain as they are today. The work is limited to truth cleanup on existing operator-facing surfaces.
|
||||||
|
|
||||||
|
**Constitution alignment (PROP-001 / ABSTR-001 / PERSIST-001 / STATE-001 / BLOAT-001):** This feature is intentionally narrow. It adds no new persistence, abstraction, state family, presenter framework, or readiness taxonomy. It cleans up leading surface truth using already-existing lifecycle, consent, verification, and verification-report data.
|
||||||
|
|
||||||
|
**Constitution alignment (OPS-UX):** No new `OperationRun` type, execution surface, or feedback path is introduced. Existing verification and provider-operation flows keep their current run semantics.
|
||||||
|
|
||||||
|
**Constitution alignment (RBAC-UX):** The work stays in the admin plane at `/admin` on existing tenant and provider resources. Non-members and out-of-scope users remain deny-as-not-found. In-scope members without capability remain forbidden for protected actions. Server-side authorization remains the source of truth. Tenant global search stays safe because the tenant resource already has view and edit pages; provider connections remain non-globally-searchable for this slice. Existing destructive-like actions keep their current confirmation requirements.
|
||||||
|
|
||||||
|
**Constitution alignment (OPS-EX-AUTH-001):** Not applicable. No auth handshake behavior is changed.
|
||||||
|
|
||||||
|
**Constitution alignment (BADGE-001):** Status badge semantics remain centralized. This feature updates leading badge usage so tenant lifecycle, provider consent, and provider verification are the primary operator-facing status hierarchy on targeted surfaces. If provider consent or provider verification require new mappings, they MUST be added through `BadgeCatalog` and `BadgeRenderer` inside the existing badge system rather than through page-local labels or a synthetic readiness framework.
|
||||||
|
|
||||||
|
**Constitution alignment (UI-FIL-001):** The feature reuses existing Filament tables, infolists, widgets, form sections, action groups, and centralized badge helpers. No local replacement markup or page-local status language is needed. No exception is expected.
|
||||||
|
|
||||||
|
**Constitution alignment (UI-NAMING-001):** Operator-facing copy must keep `Lifecycle`, `Consent`, and `Verification` as distinct questions. This slice must not introduce `Ready` as a new leading label. If any legacy field remains visible, it must be named as diagnostic or legacy rather than as primary status.
|
||||||
|
|
||||||
|
**Constitution alignment (UI-CONST-001 / UI-SURF-001 / UI-HARD-001 / UI-EX-001 / UI-REVIEW-001):** Each affected surface keeps one primary inspect or open model, and the surface tables above define collection routes, detail routes, action placement, scope signals, and the default-visible truth. The targeted surfaces must stop treating `app_status`, provider `status`, or provider `health_status` as peer signals to lifecycle, consent, or verification.
|
||||||
|
|
||||||
|
**Constitution alignment (OPSURF-001):** Default-visible content must stay operator-first. Tenant surfaces show lifecycle and, when warranted, provider truth. Provider surfaces show consent and verification first. Diagnostics such as legacy fields, raw identifiers, or historical projections must remain secondary. Existing mutation scope and safe-execution patterns remain unchanged.
|
||||||
|
|
||||||
|
**Constitution alignment (UI-SEM-001 / LAYER-001 / TEST-TRUTH-001):** Direct mapping from existing lifecycle, consent, and verification truth is sufficient once legacy prominence is removed. This feature must not add a new readiness interpreter or semantic wrapper. Tests must protect business truth by proving the absence of false readiness and the demotion of conflicting legacy signals.
|
||||||
|
|
||||||
|
**Constitution alignment (Filament Action Surfaces):** The Action Surface Contract remains satisfied. Affected surfaces keep one primary inspect model, redundant `View` actions remain absent, empty action groups are not introduced, and destructive actions keep their current placement and confirmation rules. UI-FIL-001 is satisfied with no approved exception.
|
||||||
|
|
||||||
|
**Constitution alignment (UX-001 — Layout & Information Architecture):** No new screen type is introduced. Tenant view continues to use a view page with widgets and infolist content. Provider connection view continues to use a view page. Provider connection edit remains a section-based edit form. List pages keep search, sort, and filters, but the default-visible status dimensions and core filters must align to leading truth rather than legacy projections.
|
||||||
|
|
||||||
|
### Functional Requirements
|
||||||
|
|
||||||
|
- **FR-179-001 (Lifecycle remains its own truth)**: Targeted tenant surfaces MUST present tenant lifecycle as a distinct domain and MUST NOT use lifecycle labels to imply provider readiness.
|
||||||
|
- **FR-179-002 (Tenant app status removed from leading truth)**: Targeted tenant surfaces MUST NOT present `app_status` as a default-visible primary badge, summary field, or equivalent current-status signal.
|
||||||
|
- **FR-179-003 (Tenant list uses only trustworthy provider signal)**: The tenant list MUST either show a bounded provider signal derived from current provider consent and verification truth or omit provider status for that row. It MUST NOT rely on `app_status` or another legacy projection as the scan-time provider indicator.
|
||||||
|
- **FR-179-004 (Tenant detail separates provider axes)**: When tenant detail surfaces provider state, they MUST show provider consent and provider verification as separate axes.
|
||||||
|
- **FR-179-005 (No false readiness on tenant detail)**: Tenant detail MUST NOT allow `active`, `connected`, `configured`, or `consented` signals to read as equivalent to verified, healthy, or ready when verification is unknown, blocked, degraded, error, or absent.
|
||||||
|
- **FR-179-006 (RBAC remains separate)**: If RBAC status appears on tenant detail, it MUST remain visibly separate from provider consent and provider verification and MUST NOT substitute for provider readiness.
|
||||||
|
- **FR-179-007 (Provider list leading axes)**: Provider connection list MUST use consent state and verification state as the primary default-visible connection-state axes.
|
||||||
|
- **FR-179-008 (Legacy provider status demoted)**: Provider connection list, view, and edit surfaces MUST NOT present legacy `status` or `health_status` as equal-priority peer truth next to consent and verification.
|
||||||
|
- **FR-179-009 (Legacy diagnostics handling)**: If legacy provider `status` or `health_status` remains visible anywhere on the targeted provider surfaces, it MUST be clearly labeled and positioned as secondary diagnostics, historical projection, or technical metadata.
|
||||||
|
- **FR-179-010 (Provider detail distinguishes configured from proven)**: Provider connection view and edit surfaces MUST make it clear whether a connection is merely configured or consented versus operationally checked and verified.
|
||||||
|
- **FR-179-011 (Badge truth alignment)**: Centralized badge and presentation mappings used by the targeted tenant and provider surfaces MUST elevate lifecycle, consent, and verification over tenant app status, provider connection status, and provider connection health.
|
||||||
|
- **FR-179-012 (No conflicting equal-prominence status mosaic)**: Targeted tenant and provider surfaces MUST NOT render multiple semantically overlapping status badges or fields at the same visual level when only one is authoritative for that operator question.
|
||||||
|
- **FR-179-013 (List filters follow leading truth)**: Default-visible list filters and core list columns on affected surfaces MUST align with the leading truth hierarchy. Legacy status filters may remain only if they are explicitly secondary and do not present themselves as the primary way to judge current provider readiness.
|
||||||
|
- **FR-179-014 (No new persisted truth)**: The feature MUST ship without a new table, without a new persisted readiness artifact, and without a new status family.
|
||||||
|
- **FR-179-015 (Authorization boundaries unchanged)**: Workspace scoping, tenant isolation, and capability checks on all touched surfaces MUST remain unchanged. This cleanup MUST NOT widen visibility or bypass existing server-side authorization.
|
||||||
|
- **FR-179-016 (Global search and discovery stay safe)**: Tenant search behavior may remain in place because the tenant resource already has view and edit pages. Provider connections remain non-globally-searchable in this slice, and no touched surface may introduce new search-based status leakage.
|
||||||
|
- **FR-179-017 (Regression coverage is mandatory)**: Regression coverage MUST verify tenant legacy-status suppression, provider primary-status promotion, contradictory multi-status demotion, badge truth alignment, and global-search safety. Final implementation validation MUST verify the absence of schema requirements and new persisted truth.
|
||||||
|
|
||||||
|
## UI Action Matrix *(mandatory when Filament is changed)*
|
||||||
|
|
||||||
|
| Surface | Location | Header Actions | Inspect Affordance (List/Table) | Row Actions (max 2 visible) | Bulk Actions (grouped) | Empty-State CTA(s) | View Header Actions | Create/Edit Save+Cancel | Audit log? | Notes / Exemptions |
|
||||||
|
|---|---|---|---|---|---|---|---|---|---|---|
|
||||||
|
| Tenant list | `app/Filament/Resources/TenantResource.php` and `app/Filament/Resources/TenantResource/Pages/ListTenants.php` | `Add tenant` | `recordUrl()` opens the tenant view; one row-primary action slot is preserved | Primary overflow includes `Resume onboarding` or `View related onboarding`; `More` contains `Open`, `Edit`, `Grant admin consent`, `Open in Entra`, `Sync`, `Verify configuration`, `Restore`, `Force delete`, `Archive`, and RBAC helper actions | `Sync selected` stays grouped under `More` | `Add tenant` | n/a | n/a | existing only | Action Surface Contract remains satisfied. This spec changes status truth, not the action inventory. |
|
||||||
|
| Tenant view | `app/Filament/Resources/TenantResource/Pages/ViewTenant.php` | `Provider connections`, `Edit`, `View related onboarding`, `Grant admin consent`, `Open in Entra`, `Verify configuration` inside the existing header `ActionGroup` | direct page | n/a | none | n/a | Existing grouped lifecycle and verification actions remain | n/a | existing only | No new header mutations are introduced. The change is in default-visible status semantics only. |
|
||||||
|
| Provider connections list | `app/Filament/Resources/ProviderConnectionResource.php` and `app/Filament/Resources/ProviderConnectionResource/Pages/ListProviderConnections.php` | `New connection` | `recordUrl()` opens the provider connection view | `More` contains `Edit`, `Check connection`, `Inventory sync`, `Compliance snapshot`, `Set as default`, `Enable dedicated override`, `Rotate dedicated credential`, `Delete dedicated credential`, `Revert to platform`, `Enable connection`, and `Disable connection` | none | `New connection` | n/a | n/a | existing only | Canonical tenantless route stays `/admin/provider-connections`; this spec changes which status fields are treated as primary truth. |
|
||||||
|
| Provider connection view | `app/Filament/Resources/ProviderConnectionResource/Pages/ViewProviderConnection.php` | `Grant admin consent`, `Edit`, plus grouped connection-management actions | direct page | n/a | none | n/a | `Check connection`, `View last check run`, and existing grouped dedicated-credential and connection-state actions remain | n/a | existing only | Existing confirmed and audited mutations remain unchanged. This spec only changes which state is read as primary. |
|
||||||
|
| Provider connection edit | `app/Filament/Resources/ProviderConnectionResource/Pages/EditProviderConnection.php` | Existing grouped connection-management actions remain | direct page | n/a | none | n/a | Existing grouped connection-management actions remain | Default save and cancel actions remain | existing only | UX-001 remains satisfied. The edit surface must show consent and verification as current context without letting legacy fields dominate. |
|
||||||
|
|
||||||
|
### Key Entities *(include if feature involves data)*
|
||||||
|
|
||||||
|
- **Tenant lifecycle**: The tenant's own lifecycle truth, such as onboarding, active, archived, or equivalent states that describe whether the tenant is in the normal lifecycle.
|
||||||
|
- **Provider consent state**: The stored state that answers whether the provider permission or consent step has been completed.
|
||||||
|
- **Provider verification state**: The stored state that answers whether the provider connection has been checked and what that check currently proves.
|
||||||
|
- **Legacy tenant app status**: An older projected tenant-level field that may still exist in storage but is no longer allowed to act as leading operator truth on targeted surfaces.
|
||||||
|
- **Legacy provider connection status and health**: Older connection-level projections that may remain in storage for internal compatibility but are no longer allowed to compete with consent and verification on targeted surfaces.
|
||||||
|
- **Bounded provider signal**: A tightly-scoped tenant-level summary derived from current provider truth that may appear on the tenant list only when it can be expressed without inventing a broader readiness model.
|
||||||
|
|
||||||
|
## Success Criteria *(mandatory)*
|
||||||
|
|
||||||
|
### Measurable Outcomes
|
||||||
|
|
||||||
|
- **SC-179-001**: In regression coverage, 100% of targeted tenant surfaces stop showing legacy tenant app status as a default-visible primary operator truth.
|
||||||
|
- **SC-179-002**: In regression coverage, 100% of targeted provider surfaces show consent state and verification state before any legacy provider status or health field.
|
||||||
|
- **SC-179-003**: In seeded scenarios where a tenant is active but provider verification is unknown, blocked, degraded, or error, 100% of targeted surfaces avoid implying that the tenant is provider-ready by default.
|
||||||
|
- **SC-179-004**: In seeded scenarios where legacy fields contradict current consent or verification truth, 100% of targeted surfaces render the current truth hierarchy consistently.
|
||||||
|
- **SC-179-005**: The feature ships without a required schema migration, a new persisted readiness artifact, or a new cross-surface status family.
|
||||||
|
|
||||||
|
## Assumptions
|
||||||
|
|
||||||
|
- Existing consent and verification fields are sufficiently trustworthy to become the leading provider truth for this cleanup slice.
|
||||||
|
- Existing verification reports and provider next-step guidance remain the correct supporting diagnostics for provider truth.
|
||||||
|
- Existing RBAC status surfaces remain valid as their own domain and do not need redesign in this spec beyond clearer separation from provider truth.
|
||||||
|
- The tenant list may omit a provider signal for some tenants if no bounded, trustworthy summary can be expressed without inventing a readiness model.
|
||||||
|
|
||||||
|
## Non-Goals
|
||||||
|
|
||||||
|
- Introducing a full tenant readiness assessment
|
||||||
|
- Adding a new readiness enum or score
|
||||||
|
- Building cross-dashboard or needs-attention propagation for every provider problem
|
||||||
|
- Adding operation pre-flight gating or blocking rules beyond the current behavior
|
||||||
|
- Introducing verification freshness or scheduled re-verification lifecycle rules
|
||||||
|
- Removing legacy database columns or internal compatibility paths in this slice
|
||||||
|
|
||||||
|
## Dependencies
|
||||||
|
|
||||||
|
- Existing tenant lifecycle model and tenant operator surfaces
|
||||||
|
- Existing provider connection model, consent state, verification state, and verification-report semantics
|
||||||
|
- Existing centralized badge and presentation mappings used on tenant and provider surfaces
|
||||||
|
- Existing workspace and tenant scoping, capability enforcement, and deny-as-not-found boundaries
|
||||||
|
- Existing tenant and provider Filament resources, pages, and regression tests covering truth and authorization behavior
|
||||||
|
|
||||||
|
## Definition of Done
|
||||||
|
|
||||||
|
- Tenant app status no longer appears as leading operator truth on the targeted tenant surfaces.
|
||||||
|
- Provider legacy status and health no longer compete with consent and verification on the targeted provider surfaces.
|
||||||
|
- Tenant lifecycle, provider consent, provider verification, and RBAC remain visibly separate where they coexist.
|
||||||
|
- No targeted tenant or provider surface allows `active`, `connected`, or `consented` to read as equivalent to ready.
|
||||||
|
- Badge and presentation usage reflects the cleaned truth hierarchy without inventing a new readiness model.
|
||||||
|
- The change requires no new persistence structure and is covered by targeted regression tests.
|
||||||
203
specs/179-provider-truth-cleanup/tasks.md
Normal file
203
specs/179-provider-truth-cleanup/tasks.md
Normal file
@ -0,0 +1,203 @@
|
|||||||
|
# Tasks: Provider Readiness Source-of-Truth Cleanup
|
||||||
|
|
||||||
|
**Input**: Design documents from `/specs/179-provider-truth-cleanup/`
|
||||||
|
**Prerequisites**: `plan.md`, `spec.md`, `research.md`, `data-model.md`, `contracts/provider-truth-cleanup.openapi.yaml`, `quickstart.md`
|
||||||
|
|
||||||
|
**Tests**: Required. Write or update Pest coverage before each behavior change and keep Sail-first verification focused.
|
||||||
|
|
||||||
|
**Organization**: Tasks are grouped by user story so each story can be implemented and validated independently.
|
||||||
|
|
||||||
|
## Phase 1: Setup (Shared Regression Scaffolding)
|
||||||
|
|
||||||
|
**Purpose**: Create the focused regression entry points for Spec 179 before changing operator-facing surfaces.
|
||||||
|
|
||||||
|
- [X] T001 [P] Create the tenant truth-cleanup Pest scaffold in `tests/Feature/Filament/TenantTruthCleanupSpec179Test.php`
|
||||||
|
- [X] T002 [P] Create the provider truth-cleanup Pest scaffold in `tests/Feature/ProviderConnections/ProviderConnectionTruthCleanupSpec179Test.php`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 2: Foundational (Blocking Prerequisites)
|
||||||
|
|
||||||
|
**Purpose**: Lock route, scope, and discovery invariants before changing any tenant or provider truth surfaces.
|
||||||
|
|
||||||
|
**⚠️ CRITICAL**: No user story work should begin until this phase is complete.
|
||||||
|
|
||||||
|
- [X] T003 Preserve canonical provider CTA and deny-as-not-found invariants in `tests/Feature/Tenants/TenantProviderConnectionsCtaTest.php`, `tests/Feature/Rbac/TenantResourceAuthorizationTest.php`, `tests/Feature/ProviderConnections/ProviderConnectionListAuthorizationTest.php`, and `tests/Feature/ProviderConnections/ProviderConnectionAuthorizationTest.php`
|
||||||
|
- [X] T004 [P] Preserve tenant global-search scope and provider-connection global-search exclusion in `tests/Feature/Rbac/AdminGlobalSearchContextSafetyTest.php`, `tests/Feature/Filament/TenantGlobalSearchLifecycleScopeTest.php`, and `tests/Feature/Filament/TenantScopingTest.php`
|
||||||
|
|
||||||
|
**Checkpoint**: Scope guards and discovery invariants are ready; tenant and provider truth cleanup can now proceed.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 3: User Story 1 - Read truthful tenant surfaces (Priority: P1) 🎯 MVP
|
||||||
|
|
||||||
|
**Goal**: Make tenant list and tenant detail lifecycle-led, remove `app_status` as leading truth, and show provider consent and verification separately from lifecycle.
|
||||||
|
|
||||||
|
**Independent Test**: Seed tenants whose lifecycle, legacy `app_status`, consent, and verification disagree, then verify tenant list and tenant detail show lifecycle separately and no longer treat `app_status` as current truth.
|
||||||
|
|
||||||
|
### Tests for User Story 1
|
||||||
|
|
||||||
|
- [X] T005 [P] [US1] Add tenant list truth regression cases in `tests/Feature/Filament/TenantTruthCleanupSpec179Test.php` for active-plus-unknown, onboarding-plus-blocked, missing-default-connection, and multi-connection unsafe-summary omission scenarios
|
||||||
|
- [X] T006 [P] [US1] Rewrite lifecycle-separation expectations in `tests/Feature/Filament/TenantLifecycleStatusDomainSeparationTest.php` so lifecycle and RBAC remain visible while `app_status` stops acting as primary truth
|
||||||
|
|
||||||
|
### Implementation for User Story 1
|
||||||
|
|
||||||
|
- [X] T007 [US1] Remove default-visible `app_status` columns and primary `app_status` filter usage from `app/Filament/Resources/TenantResource.php`, and keep any tenant-list provider signal omitted when current provider truth cannot be compressed safely
|
||||||
|
- [X] T008 [US1] Remove leading `app_status` detail output and repoint `providerConnectionState()` to `consent_status` and `verification_status` in `app/Filament/Resources/TenantResource.php`
|
||||||
|
- [X] T009 [US1] Rewrite the tenant Provider summary in `resources/views/filament/infolists/entries/provider-connection-state.blade.php` to lead with consent and verification and demote legacy status and health to diagnostics
|
||||||
|
- [X] T010 [US1] Align verification deep-dive wording with the new tenant summary contract in `app/Filament/Widgets/Tenant/TenantVerificationReport.php` and `resources/views/filament/widgets/tenant/tenant-verification-report.blade.php`
|
||||||
|
- [X] T011 [US1] Update canonical provider-connections CTA assertions after the tenant detail summary change in `tests/Feature/Tenants/TenantProviderConnectionsCtaTest.php`
|
||||||
|
|
||||||
|
**Checkpoint**: Tenant list and tenant detail now answer lifecycle and provider questions without implying readiness from legacy fields.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 4: User Story 2 - Diagnose provider connections from current axes (Priority: P1)
|
||||||
|
|
||||||
|
**Goal**: Make provider connection list, view, and edit surfaces lead with consent and verification while demoting legacy connection status and health to diagnostics.
|
||||||
|
|
||||||
|
**Independent Test**: Seed provider connections whose legacy `status` and `health_status` conflict with `consent_status` and `verification_status`, then verify list, view, and edit surfaces elevate the current axes and keep DB-only rendering intact.
|
||||||
|
|
||||||
|
### Tests for User Story 2
|
||||||
|
|
||||||
|
- [X] T012 [P] [US2] Add provider list, view, and edit truth regression cases in `tests/Feature/ProviderConnections/ProviderConnectionTruthCleanupSpec179Test.php` for consent and verification versus legacy status and health conflicts
|
||||||
|
- [X] T013 [P] [US2] Update provider filter expectations in `tests/Feature/ProviderConnections/RequiredFiltersTest.php` to require consent-led and verification-led filters plus `default_only`
|
||||||
|
- [X] T014 [P] [US2] Update DB-only rendering expectations in `tests/Feature/Filament/ProviderConnectionsDbOnlyTest.php` for the new default-visible provider state columns
|
||||||
|
|
||||||
|
### Implementation for User Story 2
|
||||||
|
|
||||||
|
- [X] T015 [US2] Promote `consent_status` and `verification_status` to default-visible list columns and demote legacy `status` and `health_status` columns in `app/Filament/Resources/ProviderConnectionResource.php`
|
||||||
|
- [X] T016 [US2] Replace primary `status` and `health_status` filters with consent-led and verification-led filters and mark any retained legacy filters as diagnostic in `app/Filament/Resources/ProviderConnectionResource.php`
|
||||||
|
- [X] T017 [US2] Split the provider connection view infolist into Current state and Diagnostics sections in `app/Filament/Resources/ProviderConnectionResource.php`
|
||||||
|
- [X] T018 [US2] Split the provider connection edit form context into Current state and Diagnostics sections in `app/Filament/Resources/ProviderConnectionResource.php`
|
||||||
|
|
||||||
|
**Checkpoint**: Provider connection pages now answer whether a connection is consented and verified before showing any legacy projections.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 5: User Story 3 - Avoid false readiness language across surfaces (Priority: P2)
|
||||||
|
|
||||||
|
**Goal**: Keep tenant and provider surfaces from collapsing `active`, `connected`, or `consented` into `ready`, and keep RBAC separate from provider truth.
|
||||||
|
|
||||||
|
**Independent Test**: Render tenant and provider surfaces for records that look favorable in one status family but unfavorable in another and verify no default-visible wording, badge, or section title implies readiness.
|
||||||
|
|
||||||
|
### Tests for User Story 3
|
||||||
|
|
||||||
|
- [X] T019 [P] [US3] Add cross-surface false-readiness assertions in `tests/Feature/Filament/TenantTruthCleanupSpec179Test.php` and `tests/Feature/ProviderConnections/ProviderConnectionTruthCleanupSpec179Test.php` for active-plus-blocked or error, consented-plus-unknown, and RBAC-separated scenarios
|
||||||
|
- [X] T020 [P] [US3] Re-run scope-leak and capability regression coverage in `tests/Feature/Rbac/TenantResourceAuthorizationTest.php`, `tests/Feature/ProviderConnections/ProviderConnectionListAuthorizationTest.php`, and `tests/Feature/ProviderConnections/ProviderConnectionAuthorizationTest.php` against the cleaned surfaces
|
||||||
|
|
||||||
|
### Implementation for User Story 3
|
||||||
|
|
||||||
|
- [X] T021 [US3] Normalize operator-facing labels and section headings to Lifecycle, Consent, Verification, and Diagnostics wording in `app/Filament/Resources/TenantResource.php` and `app/Filament/Resources/ProviderConnectionResource.php`
|
||||||
|
- [X] T022 [US3] Add centralized badge mappings for provider consent and provider verification, keep legacy app-status or connection-status badges diagnostic-only, and avoid any synthetic readiness domain in `app/Support/Badges/BadgeCatalog.php`, `app/Support/Badges/BadgeDomain.php`, `app/Support/Badges/Domains/ProviderConsentStatusBadge.php`, `app/Support/Badges/Domains/ProviderVerificationStatusBadge.php`, `app/Support/Badges/Domains/TenantAppStatusBadge.php`, `app/Support/Badges/Domains/ProviderConnectionStatusBadge.php`, and `app/Support/Badges/Domains/ProviderConnectionHealthBadge.php`
|
||||||
|
- [X] T023 [US3] Update unit badge regression coverage for centralized lifecycle, provider consent, provider verification, and legacy diagnostic mappings in `tests/Unit/Badges/TenantBadgesTest.php` and `tests/Unit/Badges/ProviderConnectionBadgesTest.php`
|
||||||
|
|
||||||
|
**Checkpoint**: No targeted tenant or provider surface uses favorable legacy language to imply provider readiness.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 6: Polish & Cross-Cutting Concerns
|
||||||
|
|
||||||
|
**Purpose**: Format, verify, and manually confirm the cleaned truth hierarchy across all affected surfaces.
|
||||||
|
|
||||||
|
- [X] T024 Run `vendor/bin/sail bin pint --dirty --format agent` for touched files under `app/`, `resources/views/`, and `tests/` as governed by `composer.json`
|
||||||
|
- [X] T025 Run the focused Sail verification pack from `specs/179-provider-truth-cleanup/quickstart.md` against `tests/Feature/Filament/TenantLifecycleStatusDomainSeparationTest.php`, `tests/Feature/Filament/TenantTruthCleanupSpec179Test.php`, `tests/Feature/Filament/ProviderConnectionsDbOnlyTest.php`, `tests/Feature/ProviderConnections/ProviderConnectionTruthCleanupSpec179Test.php`, `tests/Feature/ProviderConnections/RequiredFiltersTest.php`, `tests/Feature/Tenants/TenantProviderConnectionsCtaTest.php`, `tests/Feature/Rbac/TenantResourceAuthorizationTest.php`, `tests/Feature/ProviderConnections/ProviderConnectionListAuthorizationTest.php`, `tests/Feature/ProviderConnections/ProviderConnectionAuthorizationTest.php`, `tests/Feature/Rbac/AdminGlobalSearchContextSafetyTest.php`, `tests/Feature/Filament/TenantGlobalSearchLifecycleScopeTest.php`, `tests/Feature/Filament/TenantScopingTest.php`, `tests/Unit/Badges/TenantBadgesTest.php`, and `tests/Unit/Badges/ProviderConnectionBadgesTest.php`
|
||||||
|
- [X] T026 Execute the manual smoke checklist in `specs/179-provider-truth-cleanup/quickstart.md` against `app/Filament/Resources/TenantResource.php` and `app/Filament/Resources/ProviderConnectionResource.php` on `/admin/tenants` and `/admin/provider-connections`
|
||||||
|
- [X] T027 Validate that the final implementation introduces no schema migration, no new persisted truth, and no unplanned status-family expansion by reviewing `database/migrations/`, `app/Models/Tenant.php`, `app/Models/ProviderConnection.php`, `app/Support/Providers/ProviderConsentStatus.php`, `app/Support/Providers/ProviderVerificationStatus.php`, `app/Support/Badges/BadgeDomain.php`, `app/Support/Badges/BadgeCatalog.php`, `app/Support/Badges/Domains/ProviderConsentStatusBadge.php`, `app/Support/Badges/Domains/ProviderVerificationStatusBadge.php`, `app/Support/Badges/Domains/TenantAppStatusBadge.php`, `app/Support/Badges/Domains/ProviderConnectionStatusBadge.php`, `app/Support/Badges/Domains/ProviderConnectionHealthBadge.php`, and `specs/179-provider-truth-cleanup/plan.md` against the final diff
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Dependencies & Execution Order
|
||||||
|
|
||||||
|
### Phase Dependencies
|
||||||
|
|
||||||
|
- **Setup (Phase 1)**: No dependencies; start immediately.
|
||||||
|
- **Foundational (Phase 2)**: Depends on Setup completion; blocks all user story work.
|
||||||
|
- **User Story 1 (Phase 3)**: Depends on Foundational completion.
|
||||||
|
- **User Story 2 (Phase 4)**: Depends on Foundational completion.
|
||||||
|
- **User Story 3 (Phase 5)**: Depends on User Story 1 and User Story 2 completion because it harmonizes cross-surface wording and diagnostic semantics.
|
||||||
|
- **Polish (Phase 6)**: Depends on all desired user stories being complete.
|
||||||
|
|
||||||
|
### User Story Dependencies
|
||||||
|
|
||||||
|
- **US1**: Independent after Phase 2 and is the recommended MVP slice.
|
||||||
|
- **US2**: Independent after Phase 2 and can run in parallel with US1.
|
||||||
|
- **US3**: Depends on the finished tenant and provider surface hierarchy from US1 and US2.
|
||||||
|
|
||||||
|
### Within Each User Story
|
||||||
|
|
||||||
|
- Write or update the story tests first and confirm they fail for the intended reason.
|
||||||
|
- Update the primary resource or shared surface contract before adjusting dependent Blade or widget output.
|
||||||
|
- Finish story-specific assertions after the implementation lands.
|
||||||
|
- Keep authorization regressions green before advancing to the next story.
|
||||||
|
|
||||||
|
### Parallel Opportunities
|
||||||
|
|
||||||
|
- `T001` and `T002` can run in parallel.
|
||||||
|
- `T003` and `T004` can run in parallel.
|
||||||
|
- `T005` and `T006` can run in parallel.
|
||||||
|
- `T012`, `T013`, and `T014` can run in parallel.
|
||||||
|
- `T019` and `T020` can run in parallel.
|
||||||
|
- Phase 3 and Phase 4 can run in parallel after Phase 2 completes.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Parallel Example: User Story 1
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Launch the tenant truth regressions together before changing tenant surfaces:
|
||||||
|
Task: T005 Add tenant list truth regression cases in tests/Feature/Filament/TenantTruthCleanupSpec179Test.php
|
||||||
|
Task: T006 Rewrite lifecycle-separation expectations in tests/Feature/Filament/TenantLifecycleStatusDomainSeparationTest.php
|
||||||
|
```
|
||||||
|
|
||||||
|
## Parallel Example: User Story 2
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Launch the provider list and rendering guards together before changing ProviderConnectionResource:
|
||||||
|
Task: T012 Add provider list, view, and edit truth regression cases in tests/Feature/ProviderConnections/ProviderConnectionTruthCleanupSpec179Test.php
|
||||||
|
Task: T013 Update provider filter expectations in tests/Feature/ProviderConnections/RequiredFiltersTest.php
|
||||||
|
Task: T014 Update DB-only rendering expectations in tests/Feature/Filament/ProviderConnectionsDbOnlyTest.php
|
||||||
|
```
|
||||||
|
|
||||||
|
## Parallel Example: User Story 3
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Lock the cross-surface wording and scope guards together once US1 and US2 are complete:
|
||||||
|
Task: T019 Add cross-surface false-readiness assertions in tests/Feature/Filament/TenantTruthCleanupSpec179Test.php and tests/Feature/ProviderConnections/ProviderConnectionTruthCleanupSpec179Test.php
|
||||||
|
Task: T020 Re-run scope-leak and capability regression coverage in tests/Feature/Rbac/TenantResourceAuthorizationTest.php, tests/Feature/ProviderConnections/ProviderConnectionListAuthorizationTest.php, and tests/Feature/ProviderConnections/ProviderConnectionAuthorizationTest.php
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Implementation Strategy
|
||||||
|
|
||||||
|
### MVP First (User Story 1 Only)
|
||||||
|
|
||||||
|
1. Complete Phase 1: Setup.
|
||||||
|
2. Complete Phase 2: Foundational.
|
||||||
|
3. Complete Phase 3: User Story 1.
|
||||||
|
4. Validate tenant surfaces with the US1-focused subset of `specs/179-provider-truth-cleanup/quickstart.md`.
|
||||||
|
5. Demo or review the tenant truth cleanup before expanding to provider surfaces.
|
||||||
|
|
||||||
|
### Incremental Delivery
|
||||||
|
|
||||||
|
1. Finish Setup and Foundational work.
|
||||||
|
2. Deliver US1 and validate tenant truth cleanup.
|
||||||
|
3. Deliver US2 and validate provider truth cleanup.
|
||||||
|
4. Deliver US3 and validate cross-surface wording and diagnostic consistency.
|
||||||
|
5. Finish Phase 6 verification and manual smoke checks.
|
||||||
|
|
||||||
|
### Parallel Team Strategy
|
||||||
|
|
||||||
|
1. One developer completes Phase 1 and Phase 2.
|
||||||
|
2. After Phase 2, one developer takes US1 while another takes US2.
|
||||||
|
3. Rejoin on US3 once both surface hierarchies are stable.
|
||||||
|
4. Finish with shared formatting, focused Sail tests, and manual smoke validation.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- Every task follows the required checklist format: checkbox, task ID, optional parallel marker, required story label for story phases, and exact file paths.
|
||||||
|
- The task list preserves the plan decision not to invent a new tenant-list readiness badge in this slice.
|
||||||
|
- No task introduces new persistence, a new readiness enum, or a new presenter layer.
|
||||||
@ -0,0 +1,194 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use App\Filament\Resources\EvidenceSnapshotResource;
|
||||||
|
use App\Filament\Resources\ReviewPackResource;
|
||||||
|
use App\Filament\Resources\TenantReviewResource;
|
||||||
|
use App\Models\Tenant;
|
||||||
|
use App\Support\TenantReviewCompletenessState;
|
||||||
|
use App\Support\TenantReviewStatus;
|
||||||
|
use App\Support\Workspaces\WorkspaceContext;
|
||||||
|
use Tests\Feature\Concerns\BuildsGovernanceArtifactTruthFixtures;
|
||||||
|
|
||||||
|
uses(BuildsGovernanceArtifactTruthFixtures::class);
|
||||||
|
|
||||||
|
pest()->browser()->timeout(15_000);
|
||||||
|
|
||||||
|
it('smokes tenant-scoped evidence freshness and publication trust surfaces', function (): void {
|
||||||
|
$staleTenant = Tenant::factory()->create(['name' => 'Browser Smoke Stale Tenant']);
|
||||||
|
[$user, $staleTenant] = createUserWithTenant(tenant: $staleTenant, role: 'owner');
|
||||||
|
|
||||||
|
$partialTenant = Tenant::factory()->create([
|
||||||
|
'workspace_id' => (int) $staleTenant->workspace_id,
|
||||||
|
'name' => 'Browser Smoke Partial Tenant',
|
||||||
|
]);
|
||||||
|
createUserWithTenant(tenant: $partialTenant, user: $user, role: 'owner');
|
||||||
|
|
||||||
|
$staleSnapshot = seedStaleTenantReviewEvidence($staleTenant);
|
||||||
|
$partialSnapshot = seedPartialTenantReviewEvidence($partialTenant);
|
||||||
|
|
||||||
|
$partialReview = $this->makeArtifactTruthReview(
|
||||||
|
tenant: $partialTenant,
|
||||||
|
user: $user,
|
||||||
|
snapshot: $partialSnapshot,
|
||||||
|
reviewOverrides: [
|
||||||
|
'status' => TenantReviewStatus::Ready->value,
|
||||||
|
'completeness_state' => TenantReviewCompletenessState::Complete->value,
|
||||||
|
],
|
||||||
|
summaryOverrides: [
|
||||||
|
'publish_blockers' => [],
|
||||||
|
'section_state_counts' => [
|
||||||
|
'complete' => 6,
|
||||||
|
'partial' => 0,
|
||||||
|
'missing' => 0,
|
||||||
|
'stale' => 0,
|
||||||
|
],
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
$staleReview = $this->makeArtifactTruthReview(
|
||||||
|
tenant: $staleTenant,
|
||||||
|
user: $user,
|
||||||
|
snapshot: $staleSnapshot,
|
||||||
|
reviewOverrides: [
|
||||||
|
'status' => TenantReviewStatus::Published->value,
|
||||||
|
'published_at' => now(),
|
||||||
|
'published_by_user_id' => (int) $user->getKey(),
|
||||||
|
'completeness_state' => TenantReviewCompletenessState::Complete->value,
|
||||||
|
],
|
||||||
|
summaryOverrides: [
|
||||||
|
'publish_blockers' => [],
|
||||||
|
'section_state_counts' => [
|
||||||
|
'complete' => 6,
|
||||||
|
'partial' => 0,
|
||||||
|
'missing' => 0,
|
||||||
|
'stale' => 0,
|
||||||
|
],
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
$stalePack = $this->makeArtifactTruthReviewPack(
|
||||||
|
tenant: $staleTenant,
|
||||||
|
user: $user,
|
||||||
|
snapshot: $staleSnapshot,
|
||||||
|
review: $staleReview,
|
||||||
|
summaryOverrides: [
|
||||||
|
'review_status' => TenantReviewStatus::Published->value,
|
||||||
|
'review_completeness_state' => TenantReviewCompletenessState::Complete->value,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->actingAs($user)->withSession([
|
||||||
|
WorkspaceContext::SESSION_KEY => (int) $staleTenant->workspace_id,
|
||||||
|
]);
|
||||||
|
session()->put(WorkspaceContext::SESSION_KEY, (int) $staleTenant->workspace_id);
|
||||||
|
|
||||||
|
visit(EvidenceSnapshotResource::getUrl('view', ['record' => $staleSnapshot], tenant: $staleTenant))
|
||||||
|
->waitForText('Artifact truth')
|
||||||
|
->assertNoJavaScriptErrors()
|
||||||
|
->assertSee('Stale')
|
||||||
|
->assertSee('Refresh the stale evidence before relying on this snapshot');
|
||||||
|
|
||||||
|
visit(TenantReviewResource::tenantScopedUrl('view', ['record' => $partialReview], $partialTenant))
|
||||||
|
->waitForText('Artifact truth')
|
||||||
|
->assertNoJavaScriptErrors()
|
||||||
|
->assertSee('Internal only')
|
||||||
|
->assertSee('Complete the evidence basis before publishing this review');
|
||||||
|
|
||||||
|
visit(ReviewPackResource::getUrl('index', tenant: $staleTenant))
|
||||||
|
->waitForText('Artifact truth')
|
||||||
|
->assertNoJavaScriptErrors()
|
||||||
|
->assertSee('Internal only')
|
||||||
|
->assertSee('Refresh the source review before sharing this pack')
|
||||||
|
->assertSee('Download');
|
||||||
|
|
||||||
|
visit(ReviewPackResource::getUrl('view', ['record' => $stalePack], tenant: $staleTenant))
|
||||||
|
->waitForText('Artifact truth')
|
||||||
|
->assertNoJavaScriptErrors()
|
||||||
|
->assertSee('Internal only')
|
||||||
|
->assertSee('Refresh the source review before sharing this pack')
|
||||||
|
->assertSee('Download');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('smokes canonical evidence and review trust surfaces', function (): void {
|
||||||
|
$staleTenant = Tenant::factory()->create(['name' => 'Browser Canonical Stale Tenant']);
|
||||||
|
[$user, $staleTenant] = createUserWithTenant(tenant: $staleTenant, role: 'owner');
|
||||||
|
|
||||||
|
$partialTenant = Tenant::factory()->create([
|
||||||
|
'workspace_id' => (int) $staleTenant->workspace_id,
|
||||||
|
'name' => 'Browser Canonical Partial Tenant',
|
||||||
|
]);
|
||||||
|
createUserWithTenant(tenant: $partialTenant, user: $user, role: 'owner');
|
||||||
|
|
||||||
|
$freshTenant = Tenant::factory()->create([
|
||||||
|
'workspace_id' => (int) $staleTenant->workspace_id,
|
||||||
|
'name' => 'Browser Canonical Fresh Tenant',
|
||||||
|
]);
|
||||||
|
createUserWithTenant(tenant: $freshTenant, user: $user, role: 'owner');
|
||||||
|
|
||||||
|
$staleSnapshot = $this->makeStaleArtifactTruthEvidenceSnapshot($staleTenant);
|
||||||
|
$freshSnapshot = $this->makeArtifactTruthEvidenceSnapshot($freshTenant);
|
||||||
|
$partialSnapshot = seedPartialTenantReviewEvidence($partialTenant);
|
||||||
|
|
||||||
|
$this->makeArtifactTruthReview(
|
||||||
|
tenant: $staleTenant,
|
||||||
|
user: $user,
|
||||||
|
snapshot: $staleSnapshot,
|
||||||
|
reviewOverrides: [
|
||||||
|
'status' => TenantReviewStatus::Published->value,
|
||||||
|
'published_at' => now(),
|
||||||
|
'published_by_user_id' => (int) $user->getKey(),
|
||||||
|
'completeness_state' => TenantReviewCompletenessState::Complete->value,
|
||||||
|
],
|
||||||
|
summaryOverrides: [
|
||||||
|
'publish_blockers' => [],
|
||||||
|
'section_state_counts' => [
|
||||||
|
'complete' => 6,
|
||||||
|
'partial' => 0,
|
||||||
|
'missing' => 0,
|
||||||
|
'stale' => 0,
|
||||||
|
],
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->makeArtifactTruthReview(
|
||||||
|
tenant: $partialTenant,
|
||||||
|
user: $user,
|
||||||
|
snapshot: $partialSnapshot,
|
||||||
|
reviewOverrides: [
|
||||||
|
'status' => TenantReviewStatus::Ready->value,
|
||||||
|
'completeness_state' => TenantReviewCompletenessState::Complete->value,
|
||||||
|
],
|
||||||
|
summaryOverrides: [
|
||||||
|
'publish_blockers' => [],
|
||||||
|
'section_state_counts' => [
|
||||||
|
'complete' => 6,
|
||||||
|
'partial' => 0,
|
||||||
|
'missing' => 0,
|
||||||
|
'stale' => 0,
|
||||||
|
],
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->actingAs($user)->withSession([
|
||||||
|
WorkspaceContext::SESSION_KEY => (int) $staleTenant->workspace_id,
|
||||||
|
]);
|
||||||
|
session()->put(WorkspaceContext::SESSION_KEY, (int) $staleTenant->workspace_id);
|
||||||
|
|
||||||
|
visit(route('admin.evidence.overview'))
|
||||||
|
->waitForText('Browser Canonical Stale Tenant')
|
||||||
|
->assertNoJavaScriptErrors()
|
||||||
|
->assertSee('Browser Canonical Fresh Tenant')
|
||||||
|
->assertSee('Refresh the stale evidence before relying on this snapshot')
|
||||||
|
->assertSee('Create a current review from this evidence snapshot');
|
||||||
|
|
||||||
|
visit('/admin/reviews')
|
||||||
|
->waitForText('Browser Canonical Stale Tenant')
|
||||||
|
->assertNoJavaScriptErrors()
|
||||||
|
->assertSee('Browser Canonical Partial Tenant')
|
||||||
|
->assertSee('Internal only')
|
||||||
|
->assertSee('Refresh the evidence basis before publishing this review')
|
||||||
|
->assertSee('Complete the evidence basis before publishing this review')
|
||||||
|
->assertDontSee('Publishable');
|
||||||
|
});
|
||||||
268
tests/Browser/Spec177InventoryCoverageTruthSmokeTest.php
Normal file
268
tests/Browser/Spec177InventoryCoverageTruthSmokeTest.php
Normal file
@ -0,0 +1,268 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use App\Filament\Pages\InventoryCoverage;
|
||||||
|
use App\Filament\Resources\InventoryItemResource;
|
||||||
|
use App\Models\InventoryItem;
|
||||||
|
use App\Models\OperationRun;
|
||||||
|
use App\Models\Tenant;
|
||||||
|
use App\Support\OperationRunOutcome;
|
||||||
|
use App\Support\Workspaces\WorkspaceContext;
|
||||||
|
|
||||||
|
pest()->browser()->timeout(15_000);
|
||||||
|
|
||||||
|
function inventoryItemListLivewireScript(string $body): string
|
||||||
|
{
|
||||||
|
return <<<JS
|
||||||
|
const component = window.Livewire?.all().find((entry) => {
|
||||||
|
if (entry.name === 'App\\Filament\\Resources\\InventoryItemResource\\Pages\\ListInventoryItems') {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const state = entry.canonical ?? {};
|
||||||
|
|
||||||
|
return Object.prototype.hasOwnProperty.call(state, 'tableFilters')
|
||||||
|
&& Object.prototype.hasOwnProperty.call(state, 'tableDeferredFilters')
|
||||||
|
&& Object.prototype.hasOwnProperty.call(state, 'tableRecordsPerPage')
|
||||||
|
&& Object.prototype.hasOwnProperty.call(state, 'tableSearch');
|
||||||
|
});
|
||||||
|
|
||||||
|
if (! component) {
|
||||||
|
throw new Error('ListInventoryItems Livewire component not found.');
|
||||||
|
}
|
||||||
|
|
||||||
|
{$body}
|
||||||
|
JS;
|
||||||
|
}
|
||||||
|
|
||||||
|
function seedSpec177InventoryCoverageTruthFixtures(Tenant $tenant): OperationRun
|
||||||
|
{
|
||||||
|
foreach (range(1, 130) as $index) {
|
||||||
|
InventoryItem::factory()->create([
|
||||||
|
'tenant_id' => (int) $tenant->getKey(),
|
||||||
|
'display_name' => sprintf('Browser Inventory %02d', $index),
|
||||||
|
'policy_type' => 'deviceConfiguration',
|
||||||
|
'external_id' => sprintf('browser-inventory-%02d', $index),
|
||||||
|
'platform' => 'windows',
|
||||||
|
'last_seen_at' => now()->subMinutes($index),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
InventoryItem::factory()->create([
|
||||||
|
'tenant_id' => (int) $tenant->getKey(),
|
||||||
|
'display_name' => 'Browser Conditional Access',
|
||||||
|
'policy_type' => 'conditionalAccessPolicy',
|
||||||
|
'external_id' => 'browser-conditional-access',
|
||||||
|
'platform' => 'windows',
|
||||||
|
'last_seen_at' => now()->subMinutes(31),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return createInventorySyncOperationRunWithCoverage(
|
||||||
|
$tenant,
|
||||||
|
[
|
||||||
|
'conditionalAccessPolicy' => 'succeeded',
|
||||||
|
'deviceConfiguration' => 'failed',
|
||||||
|
'roleScopeTag' => 'skipped',
|
||||||
|
],
|
||||||
|
['roleScopeTag'],
|
||||||
|
[
|
||||||
|
'outcome' => OperationRunOutcome::PartiallySucceeded->value,
|
||||||
|
'completed_at' => now()->subMinute(),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function seedSpec177InventoryItemFilterPaginationFixtures(Tenant $tenant): void
|
||||||
|
{
|
||||||
|
foreach (range(1, 45) as $index) {
|
||||||
|
InventoryItem::factory()->create([
|
||||||
|
'tenant_id' => (int) $tenant->getKey(),
|
||||||
|
'display_name' => sprintf('Windows Fresh Device %02d', $index),
|
||||||
|
'policy_type' => 'deviceConfiguration',
|
||||||
|
'external_id' => sprintf('windows-fresh-device-%02d', $index),
|
||||||
|
'platform' => 'windows',
|
||||||
|
'last_seen_at' => now()->subMinutes($index),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (range(1, 3) as $index) {
|
||||||
|
InventoryItem::factory()->create([
|
||||||
|
'tenant_id' => (int) $tenant->getKey(),
|
||||||
|
'display_name' => sprintf('Mac Fresh Device %02d', $index),
|
||||||
|
'policy_type' => 'deviceConfiguration',
|
||||||
|
'external_id' => sprintf('mac-fresh-device-%02d', $index),
|
||||||
|
'platform' => 'macOS',
|
||||||
|
'last_seen_at' => now()->subMinutes(45 + $index),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (range(1, 3) as $index) {
|
||||||
|
InventoryItem::factory()->create([
|
||||||
|
'tenant_id' => (int) $tenant->getKey(),
|
||||||
|
'display_name' => sprintf('Conditional Access Fresh %02d', $index),
|
||||||
|
'policy_type' => 'conditionalAccessPolicy',
|
||||||
|
'external_id' => sprintf('conditional-access-fresh-%02d', $index),
|
||||||
|
'platform' => 'windows',
|
||||||
|
'last_seen_at' => now()->subMinutes(48 + $index),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (range(46, 55) as $index) {
|
||||||
|
InventoryItem::factory()->create([
|
||||||
|
'tenant_id' => (int) $tenant->getKey(),
|
||||||
|
'display_name' => sprintf('Windows Fresh Device %02d', $index),
|
||||||
|
'policy_type' => 'deviceConfiguration',
|
||||||
|
'external_id' => sprintf('windows-fresh-device-%02d', $index),
|
||||||
|
'platform' => 'windows',
|
||||||
|
'last_seen_at' => now()->subMinutes(6 + $index),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (range(1, 3) as $index) {
|
||||||
|
InventoryItem::factory()->create([
|
||||||
|
'tenant_id' => (int) $tenant->getKey(),
|
||||||
|
'display_name' => sprintf('Windows Stale Device %02d', $index),
|
||||||
|
'policy_type' => 'deviceConfiguration',
|
||||||
|
'external_id' => sprintf('windows-stale-device-%02d', $index),
|
||||||
|
'platform' => 'windows',
|
||||||
|
'last_seen_at' => now()->subDays(3)->subMinutes($index),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
it('smokes inventory coverage truth surfaces with filters, pagination, and run drill-through', function (): void {
|
||||||
|
$tenant = Tenant::factory()->create([
|
||||||
|
'name' => 'Spec177 Browser Tenant',
|
||||||
|
'external_id' => 'spec177-browser-tenant',
|
||||||
|
]);
|
||||||
|
|
||||||
|
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner');
|
||||||
|
|
||||||
|
$run = seedSpec177InventoryCoverageTruthFixtures($tenant);
|
||||||
|
|
||||||
|
$this->actingAs($user)->withSession([
|
||||||
|
WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id,
|
||||||
|
]);
|
||||||
|
session()->put(WorkspaceContext::SESSION_KEY, (int) $tenant->workspace_id);
|
||||||
|
|
||||||
|
$searchPage = visit(InventoryItemResource::getUrl('index', tenant: $tenant));
|
||||||
|
|
||||||
|
$searchPage
|
||||||
|
->waitForText('Inventory Items')
|
||||||
|
->assertNoJavaScriptErrors()
|
||||||
|
->assertSee('Covered types')
|
||||||
|
->assertSee('Need follow-up')
|
||||||
|
->assertSee('Coverage basis')
|
||||||
|
->assertSee('Open basis run')
|
||||||
|
->assertSee('Run Inventory Sync')
|
||||||
|
->assertSee('Browser Inventory 01')
|
||||||
|
->assertDontSee('Browser Inventory 130')
|
||||||
|
->fill('main input[placeholder="Search"]', 'Browser Inventory 01')
|
||||||
|
->waitForText('Browser Inventory 01')
|
||||||
|
->assertNoJavaScriptErrors()
|
||||||
|
->assertSee('Browser Inventory 01')
|
||||||
|
->assertDontSee('Browser Inventory 02');
|
||||||
|
|
||||||
|
$page = visit(InventoryCoverage::getUrl(tenant: $tenant));
|
||||||
|
|
||||||
|
$page
|
||||||
|
->waitForText('Tenant coverage truth')
|
||||||
|
->assertNoJavaScriptErrors()
|
||||||
|
->assertSee('Latest coverage-bearing sync completed')
|
||||||
|
->assertSee('Open basis run')
|
||||||
|
->assertSee('Open inventory items')
|
||||||
|
->fill('input[placeholder="Search by type or label"]', 'Conditional Access')
|
||||||
|
->waitForText('Conditional Access')
|
||||||
|
->assertNoJavaScriptErrors()
|
||||||
|
->assertSee('Conditional Access')
|
||||||
|
->assertScript("document.querySelector('input[placeholder=\"Search by type or label\"]')?.value === 'Conditional Access'", true)
|
||||||
|
->click('Open basis run')
|
||||||
|
->waitForText('Operation #'.(int) $run->getKey())
|
||||||
|
->assertNoJavaScriptErrors()
|
||||||
|
->assertRoute('admin.operations.view', ['run' => (int) $run->getKey()])
|
||||||
|
->assertSee('Inventory sync coverage')
|
||||||
|
->assertSee('Need follow-up');
|
||||||
|
|
||||||
|
$page->script(<<<'JS'
|
||||||
|
history.back();
|
||||||
|
JS);
|
||||||
|
|
||||||
|
$page
|
||||||
|
->waitForText('Tenant coverage truth')
|
||||||
|
->assertNoJavaScriptErrors()
|
||||||
|
->click('Open inventory items')
|
||||||
|
->waitForText('Inventory Items')
|
||||||
|
->assertNoJavaScriptErrors()
|
||||||
|
->assertSee('Browser Inventory 01');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('smokes inventory item pagination with stable filter combinations', function (): void {
|
||||||
|
$tenant = Tenant::factory()->create([
|
||||||
|
'name' => 'Spec177 Browser Filter Tenant',
|
||||||
|
'external_id' => 'spec177-browser-filter-tenant',
|
||||||
|
]);
|
||||||
|
|
||||||
|
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner');
|
||||||
|
|
||||||
|
seedSpec177InventoryItemFilterPaginationFixtures($tenant);
|
||||||
|
|
||||||
|
$this->actingAs($user)->withSession([
|
||||||
|
WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id,
|
||||||
|
]);
|
||||||
|
session()->put(WorkspaceContext::SESSION_KEY, (int) $tenant->workspace_id);
|
||||||
|
|
||||||
|
$page = visit(InventoryItemResource::getUrl('index', tenant: $tenant));
|
||||||
|
|
||||||
|
$page
|
||||||
|
->waitForText('Inventory Items')
|
||||||
|
->assertNoJavaScriptErrors()
|
||||||
|
->assertSee('Windows Fresh Device 01')
|
||||||
|
->assertDontSee('Windows Fresh Device 30')
|
||||||
|
->assertDontSee('Mac Fresh Device 01')
|
||||||
|
->wait(1);
|
||||||
|
|
||||||
|
$page->script(inventoryItemListLivewireScript(<<<'JS'
|
||||||
|
component.$wire.set('tableRecordsPerPage', 50);
|
||||||
|
JS));
|
||||||
|
|
||||||
|
$page
|
||||||
|
->wait(1)
|
||||||
|
->waitForText('Mac Fresh Device 01')
|
||||||
|
->assertNoJavaScriptErrors()
|
||||||
|
->assertSee('Mac Fresh Device 01')
|
||||||
|
->assertSee('Conditional Access Fresh 01')
|
||||||
|
->assertDontSee('Windows Fresh Device 46');
|
||||||
|
|
||||||
|
$page->script(inventoryItemListLivewireScript(<<<'JS'
|
||||||
|
component.$wire.set('tableDeferredFilters.policy_type.value', 'deviceConfiguration');
|
||||||
|
component.$wire.set('tableDeferredFilters.platform.value', 'windows');
|
||||||
|
component.$wire.set('tableDeferredFilters.stale.value', '0');
|
||||||
|
component.$wire.call('applyTableFilters');
|
||||||
|
JS));
|
||||||
|
|
||||||
|
$page
|
||||||
|
->wait(1)
|
||||||
|
->waitForText('Active filters')
|
||||||
|
->waitForText('Windows Fresh Device 46')
|
||||||
|
->assertNoJavaScriptErrors()
|
||||||
|
->assertSee('Policy type: Device Configuration')
|
||||||
|
->assertSee('Platform: Windows')
|
||||||
|
->assertSee('Freshness: Fresh')
|
||||||
|
->assertSee('Windows Fresh Device 46')
|
||||||
|
->assertDontSee('Mac Fresh Device 01')
|
||||||
|
->assertDontSee('Conditional Access Fresh 01')
|
||||||
|
->assertDontSee('Windows Stale Device 01');
|
||||||
|
|
||||||
|
$page->script(inventoryItemListLivewireScript(<<<'JS'
|
||||||
|
component.$wire.call('gotoPage', 2);
|
||||||
|
JS));
|
||||||
|
|
||||||
|
$page
|
||||||
|
->wait(1)
|
||||||
|
->waitForText('Windows Fresh Device 51')
|
||||||
|
->assertNoJavaScriptErrors()
|
||||||
|
->assertSee('Windows Fresh Device 51')
|
||||||
|
->assertDontSee('Windows Fresh Device 01')
|
||||||
|
->assertDontSee('Mac Fresh Device 01');
|
||||||
|
});
|
||||||
@ -57,7 +57,7 @@ public function test_renders_verification_report_on_canonical_detail_without_fil
|
|||||||
->assertOk()
|
->assertOk()
|
||||||
->assertSee('Verification report')
|
->assertSee('Verification report')
|
||||||
->assertDontSee('Verification report unavailable')
|
->assertDontSee('Verification report unavailable')
|
||||||
->assertSee('Open previous verification')
|
->assertSee('Open previous operation')
|
||||||
->assertSee('/admin/operations/'.((int) $previousRun->getKey()), false)
|
->assertSee('/admin/operations/'.((int) $previousRun->getKey()), false)
|
||||||
->assertSee('Token acquisition works');
|
->assertSee('Token acquisition works');
|
||||||
}
|
}
|
||||||
|
|||||||
@ -44,9 +44,9 @@ public function test_shows_non_blocking_mismatch_context_when_the_selected_tenan
|
|||||||
->withSession([WorkspaceContext::SESSION_KEY => (int) $runTenant->workspace_id])
|
->withSession([WorkspaceContext::SESSION_KEY => (int) $runTenant->workspace_id])
|
||||||
->get(route('admin.operations.view', ['run' => (int) $run->getKey()]))
|
->get(route('admin.operations.view', ['run' => (int) $run->getKey()]))
|
||||||
->assertOk()
|
->assertOk()
|
||||||
->assertSee('Current tenant context differs from this run')
|
->assertSee('Current tenant context differs from this operation')
|
||||||
->assertSee('Current tenant context: Current Tenant.')
|
->assertSee('Current tenant context: Current Tenant.')
|
||||||
->assertSee('Run tenant: Run Tenant.')
|
->assertSee('Operation tenant: Run Tenant.')
|
||||||
->assertSee('canonical workspace view');
|
->assertSee('canonical workspace view');
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -71,7 +71,7 @@ public function test_frames_tenantless_runs_as_workspace_level_even_when_tenant_
|
|||||||
->withSession([WorkspaceContext::SESSION_KEY => (int) $selectedTenant->workspace_id])
|
->withSession([WorkspaceContext::SESSION_KEY => (int) $selectedTenant->workspace_id])
|
||||||
->get(route('admin.operations.view', ['run' => (int) $run->getKey()]))
|
->get(route('admin.operations.view', ['run' => (int) $run->getKey()]))
|
||||||
->assertOk()
|
->assertOk()
|
||||||
->assertSee('Workspace-level run')
|
->assertSee('Workspace-level operation')
|
||||||
->assertSee('This canonical workspace view is not tied to the current tenant context (Selected Tenant).');
|
->assertSee('This canonical workspace view is not tied to the current tenant context (Selected Tenant).');
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -97,8 +97,8 @@ public function test_keeps_onboarding_tenant_runs_viewable_with_lifecycle_aware_
|
|||||||
->withSession([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id])
|
->withSession([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id])
|
||||||
->get(route('admin.operations.view', ['run' => (int) $run->getKey()]))
|
->get(route('admin.operations.view', ['run' => (int) $run->getKey()]))
|
||||||
->assertOk()
|
->assertOk()
|
||||||
->assertSee('Run tenant is not available in the current tenant selector')
|
->assertSee('Operation tenant is not available in the current tenant selector')
|
||||||
->assertSee('Run tenant: Onboarding Tenant.')
|
->assertSee('Operation tenant: Onboarding Tenant.')
|
||||||
->assertSee('This tenant is currently onboarding')
|
->assertSee('This tenant is currently onboarding')
|
||||||
->assertSee('Back to Operations')
|
->assertSee('Back to Operations')
|
||||||
->assertDontSee('This tenant is currently active')
|
->assertDontSee('This tenant is currently active')
|
||||||
@ -135,8 +135,8 @@ public function test_keeps_archived_tenant_runs_viewable_with_lifecycle_aware_co
|
|||||||
->withSession([WorkspaceContext::SESSION_KEY => (int) $activeTenant->workspace_id])
|
->withSession([WorkspaceContext::SESSION_KEY => (int) $activeTenant->workspace_id])
|
||||||
->get(route('admin.operations.view', ['run' => (int) $run->getKey()]))
|
->get(route('admin.operations.view', ['run' => (int) $run->getKey()]))
|
||||||
->assertOk()
|
->assertOk()
|
||||||
->assertSee('Run tenant is not available in the current tenant selector')
|
->assertSee('Operation tenant is not available in the current tenant selector')
|
||||||
->assertSee('Run tenant: Archived Tenant.')
|
->assertSee('Operation tenant: Archived Tenant.')
|
||||||
->assertSee('This tenant is currently archived')
|
->assertSee('This tenant is currently archived')
|
||||||
->assertSee('Back to Operations')
|
->assertSee('Back to Operations')
|
||||||
->assertDontSee('deactivated')
|
->assertDontSee('deactivated')
|
||||||
@ -165,8 +165,8 @@ public function test_keeps_selector_excluded_draft_tenant_runs_viewable_with_lif
|
|||||||
->withSession([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id])
|
->withSession([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id])
|
||||||
->get(route('admin.operations.view', ['run' => (int) $run->getKey()]))
|
->get(route('admin.operations.view', ['run' => (int) $run->getKey()]))
|
||||||
->assertOk()
|
->assertOk()
|
||||||
->assertSee('Run tenant is not available in the current tenant selector')
|
->assertSee('Operation tenant is not available in the current tenant selector')
|
||||||
->assertSee('Run tenant: Draft Tenant.')
|
->assertSee('Operation tenant: Draft Tenant.')
|
||||||
->assertSee('This tenant is currently draft')
|
->assertSee('This tenant is currently draft')
|
||||||
->assertDontSee('Resume onboarding');
|
->assertDontSee('Resume onboarding');
|
||||||
}
|
}
|
||||||
|
|||||||
@ -125,7 +125,7 @@ function createAssignedBaselineTenant(): array
|
|||||||
|
|
||||||
expect($assessment->stateFamily)->toBe(BaselineCompareSummaryAssessment::STATE_ACTION_REQUIRED)
|
expect($assessment->stateFamily)->toBe(BaselineCompareSummaryAssessment::STATE_ACTION_REQUIRED)
|
||||||
->and($assessment->evaluationResult)->toBe('failed_result')
|
->and($assessment->evaluationResult)->toBe('failed_result')
|
||||||
->and($assessment->nextActionLabel())->toBe('Review the failed run');
|
->and($assessment->nextActionLabel())->toBe('Review the failed operation');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('treats stale compare history as a stale summary state instead of positive', function (): void {
|
it('treats stale compare history as a stale summary state instead of positive', function (): void {
|
||||||
|
|||||||
@ -157,7 +157,7 @@ function createTenantGovernanceException(Tenant $tenant, Finding $finding, User
|
|||||||
expect($aggregate)->not->toBeNull()
|
expect($aggregate)->not->toBeNull()
|
||||||
->and($aggregate?->stateFamily)->toBe(BaselineCompareSummaryAssessment::STATE_ACTION_REQUIRED)
|
->and($aggregate?->stateFamily)->toBe(BaselineCompareSummaryAssessment::STATE_ACTION_REQUIRED)
|
||||||
->and($aggregate?->nextActionTarget)->toBe(BaselineCompareSummaryAssessment::NEXT_TARGET_RUN)
|
->and($aggregate?->nextActionTarget)->toBe(BaselineCompareSummaryAssessment::NEXT_TARGET_RUN)
|
||||||
->and($aggregate?->nextActionLabel)->toBe('Review the failed run')
|
->and($aggregate?->nextActionLabel)->toBe('Review the failed operation')
|
||||||
->and($aggregate?->headline)->toBe('The latest baseline compare failed before it produced a usable result.');
|
->and($aggregate?->headline)->toBe('The latest baseline compare failed before it produced a usable result.');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@ -45,6 +45,40 @@ protected function makeArtifactTruthEvidenceSnapshot(
|
|||||||
return EvidenceSnapshot::query()->create(array_replace($defaults, $snapshotOverrides));
|
return EvidenceSnapshot::query()->create(array_replace($defaults, $snapshotOverrides));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
protected function makeStaleArtifactTruthEvidenceSnapshot(
|
||||||
|
Tenant $tenant,
|
||||||
|
array $snapshotOverrides = [],
|
||||||
|
array $summaryOverrides = [],
|
||||||
|
): EvidenceSnapshot {
|
||||||
|
$snapshot = $this->makeArtifactTruthEvidenceSnapshot($tenant, $snapshotOverrides, $summaryOverrides);
|
||||||
|
|
||||||
|
return $this->restateArtifactTruthEvidenceSnapshot(
|
||||||
|
$snapshot,
|
||||||
|
EvidenceCompletenessState::Stale,
|
||||||
|
array_replace([
|
||||||
|
'missing_dimensions' => 0,
|
||||||
|
'stale_dimensions' => 2,
|
||||||
|
], $summaryOverrides),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function makePartialArtifactTruthEvidenceSnapshot(
|
||||||
|
Tenant $tenant,
|
||||||
|
array $snapshotOverrides = [],
|
||||||
|
array $summaryOverrides = [],
|
||||||
|
): EvidenceSnapshot {
|
||||||
|
$snapshot = $this->makeArtifactTruthEvidenceSnapshot($tenant, $snapshotOverrides, $summaryOverrides);
|
||||||
|
|
||||||
|
return $this->restateArtifactTruthEvidenceSnapshot(
|
||||||
|
$snapshot,
|
||||||
|
EvidenceCompletenessState::Partial,
|
||||||
|
array_replace([
|
||||||
|
'missing_dimensions' => 1,
|
||||||
|
'stale_dimensions' => 0,
|
||||||
|
], $summaryOverrides),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
protected function makeArtifactTruthReview(
|
protected function makeArtifactTruthReview(
|
||||||
Tenant $tenant,
|
Tenant $tenant,
|
||||||
User $user,
|
User $user,
|
||||||
@ -146,4 +180,47 @@ protected function makeArtifactTruthRun(
|
|||||||
|
|
||||||
return OperationRun::factory()->create(array_replace($defaults, $attributes));
|
return OperationRun::factory()->create(array_replace($defaults, $attributes));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $summaryOverrides
|
||||||
|
*/
|
||||||
|
private function restateArtifactTruthEvidenceSnapshot(
|
||||||
|
EvidenceSnapshot $snapshot,
|
||||||
|
EvidenceCompletenessState $completenessState,
|
||||||
|
array $summaryOverrides = [],
|
||||||
|
): EvidenceSnapshot {
|
||||||
|
$summary = is_array($snapshot->summary) ? $snapshot->summary : [];
|
||||||
|
|
||||||
|
$summary = array_replace($summary, match ($completenessState) {
|
||||||
|
EvidenceCompletenessState::Stale => [
|
||||||
|
'dimension_count' => max(1, (int) ($summary['dimension_count'] ?? 5)),
|
||||||
|
'missing_dimensions' => 0,
|
||||||
|
'stale_dimensions' => max(1, (int) ($summary['stale_dimensions'] ?? 1)),
|
||||||
|
],
|
||||||
|
EvidenceCompletenessState::Partial => [
|
||||||
|
'dimension_count' => max(1, (int) ($summary['dimension_count'] ?? 5)),
|
||||||
|
'missing_dimensions' => max(1, (int) ($summary['missing_dimensions'] ?? 1)),
|
||||||
|
'stale_dimensions' => 0,
|
||||||
|
],
|
||||||
|
EvidenceCompletenessState::Missing => [
|
||||||
|
'dimension_count' => (int) ($summary['dimension_count'] ?? 0),
|
||||||
|
'missing_dimensions' => max(1, (int) ($summary['missing_dimensions'] ?? 1)),
|
||||||
|
'stale_dimensions' => 0,
|
||||||
|
],
|
||||||
|
EvidenceCompletenessState::Complete => [
|
||||||
|
'dimension_count' => max(1, (int) ($summary['dimension_count'] ?? 5)),
|
||||||
|
'missing_dimensions' => 0,
|
||||||
|
'stale_dimensions' => 0,
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
$summary = array_replace_recursive($summary, $summaryOverrides);
|
||||||
|
|
||||||
|
$snapshot->forceFill([
|
||||||
|
'completeness_state' => $completenessState->value,
|
||||||
|
'summary' => $summary,
|
||||||
|
])->save();
|
||||||
|
|
||||||
|
return $snapshot->fresh();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -10,8 +10,9 @@
|
|||||||
use App\Support\Workspaces\WorkspaceContext;
|
use App\Support\Workspaces\WorkspaceContext;
|
||||||
use Filament\Facades\Filament;
|
use Filament\Facades\Filament;
|
||||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
use Tests\Feature\Concerns\BuildsGovernanceArtifactTruthFixtures;
|
||||||
|
|
||||||
uses(RefreshDatabase::class);
|
uses(RefreshDatabase::class, BuildsGovernanceArtifactTruthFixtures::class);
|
||||||
|
|
||||||
it('shows only authorized tenant rows on the workspace evidence overview', function (): void {
|
it('shows only authorized tenant rows on the workspace evidence overview', function (): void {
|
||||||
$tenantA = Tenant::factory()->create();
|
$tenantA = Tenant::factory()->create();
|
||||||
@ -91,3 +92,30 @@
|
|||||||
->assertSee(EvidenceSnapshotResource::getUrl('view', ['record' => $snapshots[(int) $tenantB->getKey()]], tenant: $tenantB), false)
|
->assertSee(EvidenceSnapshotResource::getUrl('view', ['record' => $snapshots[(int) $tenantB->getKey()]], tenant: $tenantB), false)
|
||||||
->assertDontSee(EvidenceSnapshotResource::getUrl('view', ['record' => $snapshots[(int) $tenantA->getKey()]], tenant: $tenantA), false);
|
->assertDontSee(EvidenceSnapshotResource::getUrl('view', ['record' => $snapshots[(int) $tenantA->getKey()]], tenant: $tenantA), false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('shows stale evidence burden and a create-review next step on the overview', function (): void {
|
||||||
|
$staleTenant = Tenant::factory()->create(['name' => 'Stale Tenant']);
|
||||||
|
[$user, $staleTenant] = createUserWithTenant(tenant: $staleTenant, role: 'owner');
|
||||||
|
|
||||||
|
$freshTenant = Tenant::factory()->create([
|
||||||
|
'workspace_id' => (int) $staleTenant->workspace_id,
|
||||||
|
'name' => 'Fresh Tenant',
|
||||||
|
]);
|
||||||
|
createUserWithTenant(tenant: $freshTenant, user: $user, role: 'owner');
|
||||||
|
|
||||||
|
$staleSnapshot = $this->makeStaleArtifactTruthEvidenceSnapshot($staleTenant);
|
||||||
|
$freshSnapshot = $this->makeArtifactTruthEvidenceSnapshot($freshTenant);
|
||||||
|
|
||||||
|
Filament::setTenant(null, true);
|
||||||
|
|
||||||
|
$this->actingAs($user)
|
||||||
|
->withSession([WorkspaceContext::SESSION_KEY => (int) $staleTenant->workspace_id])
|
||||||
|
->get(route('admin.evidence.overview'))
|
||||||
|
->assertOk()
|
||||||
|
->assertSee($staleTenant->name)
|
||||||
|
->assertSee($freshTenant->name)
|
||||||
|
->assertSee('Refresh the stale evidence before relying on this snapshot')
|
||||||
|
->assertSee('Create a current review from this evidence snapshot')
|
||||||
|
->assertSee(EvidenceSnapshotResource::getUrl('view', ['record' => $staleSnapshot], tenant: $staleTenant), false)
|
||||||
|
->assertSee(EvidenceSnapshotResource::getUrl('view', ['record' => $freshSnapshot], tenant: $freshTenant), false);
|
||||||
|
});
|
||||||
|
|||||||
@ -21,8 +21,9 @@
|
|||||||
use Illuminate\Support\Facades\Gate;
|
use Illuminate\Support\Facades\Gate;
|
||||||
use Illuminate\Support\Facades\Queue;
|
use Illuminate\Support\Facades\Queue;
|
||||||
use Livewire\Livewire;
|
use Livewire\Livewire;
|
||||||
|
use Tests\Feature\Concerns\BuildsGovernanceArtifactTruthFixtures;
|
||||||
|
|
||||||
uses(RefreshDatabase::class);
|
uses(RefreshDatabase::class, BuildsGovernanceArtifactTruthFixtures::class);
|
||||||
|
|
||||||
function seedEvidenceDomain(Tenant $tenant): void
|
function seedEvidenceDomain(Tenant $tenant): void
|
||||||
{
|
{
|
||||||
@ -172,6 +173,30 @@ function seedEvidenceDomain(Tenant $tenant): void
|
|||||||
->assertSee('Refresh evidence before using this snapshot');
|
->assertSee('Refresh evidence before using this snapshot');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('shows stale evidence snapshots as cautionary while fresh snapshots remain current', function (): void {
|
||||||
|
$freshTenant = Tenant::factory()->create();
|
||||||
|
[$user, $freshTenant] = createUserWithTenant(tenant: $freshTenant, role: 'owner');
|
||||||
|
$staleTenant = Tenant::factory()->create([
|
||||||
|
'workspace_id' => (int) $freshTenant->workspace_id,
|
||||||
|
]);
|
||||||
|
createUserWithTenant(tenant: $staleTenant, user: $user, role: 'owner');
|
||||||
|
|
||||||
|
$freshSnapshot = $this->makeArtifactTruthEvidenceSnapshot($freshTenant);
|
||||||
|
$staleSnapshot = $this->makeStaleArtifactTruthEvidenceSnapshot($staleTenant);
|
||||||
|
|
||||||
|
$this->actingAs($user)
|
||||||
|
->get(EvidenceSnapshotResource::getUrl('view', ['record' => $freshSnapshot], tenant: $freshTenant))
|
||||||
|
->assertOk()
|
||||||
|
->assertSee('No action needed')
|
||||||
|
->assertDontSee('Refresh the stale evidence before relying on this snapshot');
|
||||||
|
|
||||||
|
$this->actingAs($user)
|
||||||
|
->get(EvidenceSnapshotResource::getUrl('view', ['record' => $staleSnapshot], tenant: $staleTenant))
|
||||||
|
->assertOk()
|
||||||
|
->assertSee('Stale')
|
||||||
|
->assertSee('Refresh the stale evidence before relying on this snapshot');
|
||||||
|
});
|
||||||
|
|
||||||
it('renders readable evidence dimension summaries and keeps raw json available', function (): void {
|
it('renders readable evidence dimension summaries and keeps raw json available', function (): void {
|
||||||
$tenant = Tenant::factory()->create();
|
$tenant = Tenant::factory()->create();
|
||||||
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner');
|
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner');
|
||||||
|
|||||||
169
tests/Feature/Filament/DashboardKpisWidgetTest.php
Normal file
169
tests/Feature/Filament/DashboardKpisWidgetTest.php
Normal file
@ -0,0 +1,169 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use App\Filament\Resources\FindingResource;
|
||||||
|
use App\Filament\Widgets\Dashboard\DashboardKpis;
|
||||||
|
use App\Models\Finding;
|
||||||
|
use App\Models\OperationRun;
|
||||||
|
use App\Support\Auth\Capabilities;
|
||||||
|
use App\Support\OperationRunLinks;
|
||||||
|
use App\Support\OperationRunOutcome;
|
||||||
|
use App\Support\OperationRunStatus;
|
||||||
|
use App\Support\Rbac\UiTooltips;
|
||||||
|
use Filament\Facades\Filament;
|
||||||
|
use Filament\Widgets\StatsOverviewWidget\Stat;
|
||||||
|
use Illuminate\Support\Facades\Gate;
|
||||||
|
use Livewire\Livewire;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, array{value:string,description:string|null,url:string|null}>
|
||||||
|
*/
|
||||||
|
function dashboardKpiStatPayloads($component): array
|
||||||
|
{
|
||||||
|
$method = new ReflectionMethod(DashboardKpis::class, 'getStats');
|
||||||
|
$method->setAccessible(true);
|
||||||
|
|
||||||
|
return collect($method->invoke($component->instance()))
|
||||||
|
->mapWithKeys(fn (Stat $stat): array => [
|
||||||
|
(string) $stat->getLabel() => [
|
||||||
|
'value' => (string) $stat->getValue(),
|
||||||
|
'description' => $stat->getDescription(),
|
||||||
|
'url' => $stat->getUrl(),
|
||||||
|
],
|
||||||
|
])
|
||||||
|
->all();
|
||||||
|
}
|
||||||
|
|
||||||
|
it('aligns dashboard KPI counts and drill-throughs to canonical findings and operations semantics', function (): void {
|
||||||
|
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||||
|
$this->actingAs($user);
|
||||||
|
|
||||||
|
Finding::factory()->for($tenant)->create([
|
||||||
|
'finding_type' => Finding::FINDING_TYPE_DRIFT,
|
||||||
|
'status' => Finding::STATUS_NEW,
|
||||||
|
'severity' => Finding::SEVERITY_LOW,
|
||||||
|
]);
|
||||||
|
|
||||||
|
Finding::factory()->for($tenant)->create([
|
||||||
|
'finding_type' => Finding::FINDING_TYPE_DRIFT,
|
||||||
|
'status' => Finding::STATUS_TRIAGED,
|
||||||
|
'severity' => Finding::SEVERITY_MEDIUM,
|
||||||
|
]);
|
||||||
|
|
||||||
|
Finding::factory()->for($tenant)->create([
|
||||||
|
'finding_type' => Finding::FINDING_TYPE_DRIFT,
|
||||||
|
'status' => Finding::STATUS_REOPENED,
|
||||||
|
'severity' => Finding::SEVERITY_CRITICAL,
|
||||||
|
]);
|
||||||
|
|
||||||
|
Finding::factory()->for($tenant)->create([
|
||||||
|
'finding_type' => Finding::FINDING_TYPE_PERMISSION_POSTURE,
|
||||||
|
'status' => Finding::STATUS_IN_PROGRESS,
|
||||||
|
'severity' => Finding::SEVERITY_HIGH,
|
||||||
|
]);
|
||||||
|
|
||||||
|
Finding::factory()->for($tenant)->create([
|
||||||
|
'finding_type' => Finding::FINDING_TYPE_DRIFT,
|
||||||
|
'status' => Finding::STATUS_RESOLVED,
|
||||||
|
'severity' => Finding::SEVERITY_HIGH,
|
||||||
|
]);
|
||||||
|
|
||||||
|
OperationRun::factory()->create([
|
||||||
|
'tenant_id' => (int) $tenant->getKey(),
|
||||||
|
'workspace_id' => (int) $tenant->workspace_id,
|
||||||
|
'type' => 'inventory_sync',
|
||||||
|
'status' => OperationRunStatus::Queued->value,
|
||||||
|
'outcome' => OperationRunOutcome::Pending->value,
|
||||||
|
'created_at' => now()->subMinute(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
OperationRun::factory()->create([
|
||||||
|
'tenant_id' => (int) $tenant->getKey(),
|
||||||
|
'workspace_id' => (int) $tenant->workspace_id,
|
||||||
|
'type' => 'inventory_sync',
|
||||||
|
'status' => OperationRunStatus::Queued->value,
|
||||||
|
'outcome' => OperationRunOutcome::Pending->value,
|
||||||
|
'created_at' => now()->subHour(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
OperationRun::factory()->create([
|
||||||
|
'tenant_id' => (int) $tenant->getKey(),
|
||||||
|
'workspace_id' => (int) $tenant->workspace_id,
|
||||||
|
'type' => 'policy.sync',
|
||||||
|
'status' => OperationRunStatus::Completed->value,
|
||||||
|
'outcome' => OperationRunOutcome::PartiallySucceeded->value,
|
||||||
|
]);
|
||||||
|
|
||||||
|
OperationRun::factory()->create([
|
||||||
|
'tenant_id' => (int) $tenant->getKey(),
|
||||||
|
'workspace_id' => (int) $tenant->workspace_id,
|
||||||
|
'type' => 'policy.sync',
|
||||||
|
'status' => OperationRunStatus::Completed->value,
|
||||||
|
'outcome' => OperationRunOutcome::Failed->value,
|
||||||
|
]);
|
||||||
|
|
||||||
|
Filament::setCurrentPanel(Filament::getPanel('tenant'));
|
||||||
|
Filament::setTenant($tenant, true);
|
||||||
|
|
||||||
|
$stats = dashboardKpiStatPayloads(Livewire::test(DashboardKpis::class));
|
||||||
|
|
||||||
|
expect($stats)->toMatchArray([
|
||||||
|
'Open drift findings' => [
|
||||||
|
'value' => '3',
|
||||||
|
'description' => 'active drift workflow items',
|
||||||
|
'url' => FindingResource::getUrl('index', [
|
||||||
|
'tab' => 'needs_action',
|
||||||
|
'finding_type' => Finding::FINDING_TYPE_DRIFT,
|
||||||
|
], panel: 'tenant', tenant: $tenant),
|
||||||
|
],
|
||||||
|
'High severity active findings' => [
|
||||||
|
'value' => '2',
|
||||||
|
'description' => 'high or critical findings needing review',
|
||||||
|
'url' => FindingResource::getUrl('index', [
|
||||||
|
'tab' => 'needs_action',
|
||||||
|
'high_severity' => 1,
|
||||||
|
], panel: 'tenant', tenant: $tenant),
|
||||||
|
],
|
||||||
|
'Active operations' => [
|
||||||
|
'value' => '1',
|
||||||
|
'description' => 'healthy queued or running tenant work',
|
||||||
|
'url' => OperationRunLinks::index($tenant, activeTab: 'active'),
|
||||||
|
],
|
||||||
|
'Operations needing follow-up' => [
|
||||||
|
'value' => '3',
|
||||||
|
'description' => 'failed, warning, or stalled runs',
|
||||||
|
'url' => OperationRunLinks::index($tenant, activeTab: 'blocked'),
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps findings KPI truth visible while disabling dead-end drill-throughs for members without findings access', function (): void {
|
||||||
|
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||||
|
$this->actingAs($user);
|
||||||
|
|
||||||
|
Finding::factory()->for($tenant)->create([
|
||||||
|
'finding_type' => Finding::FINDING_TYPE_DRIFT,
|
||||||
|
'status' => Finding::STATUS_NEW,
|
||||||
|
'severity' => Finding::SEVERITY_CRITICAL,
|
||||||
|
]);
|
||||||
|
|
||||||
|
Gate::define(Capabilities::TENANT_FINDINGS_VIEW, fn (): bool => false);
|
||||||
|
|
||||||
|
Filament::setCurrentPanel(Filament::getPanel('tenant'));
|
||||||
|
Filament::setTenant($tenant, true);
|
||||||
|
|
||||||
|
$stats = dashboardKpiStatPayloads(Livewire::test(DashboardKpis::class));
|
||||||
|
|
||||||
|
expect($stats['Open drift findings'])->toMatchArray([
|
||||||
|
'value' => '1',
|
||||||
|
'description' => UiTooltips::INSUFFICIENT_PERMISSION,
|
||||||
|
'url' => null,
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect($stats['High severity active findings'])->toMatchArray([
|
||||||
|
'value' => '1',
|
||||||
|
'description' => UiTooltips::INSUFFICIENT_PERMISSION,
|
||||||
|
'url' => null,
|
||||||
|
]);
|
||||||
|
});
|
||||||
@ -3,7 +3,11 @@
|
|||||||
declare(strict_types=1);
|
declare(strict_types=1);
|
||||||
|
|
||||||
use App\Filament\Pages\InventoryCoverage;
|
use App\Filament\Pages\InventoryCoverage;
|
||||||
|
use App\Models\OperationRun;
|
||||||
use App\Models\Tenant;
|
use App\Models\Tenant;
|
||||||
|
use App\Support\Badges\BadgeCatalog;
|
||||||
|
use App\Support\Badges\BadgeDomain;
|
||||||
|
use App\Support\Inventory\InventoryCoverage as InventoryCoveragePayload;
|
||||||
use App\Support\Workspaces\WorkspaceContext;
|
use App\Support\Workspaces\WorkspaceContext;
|
||||||
use Filament\Facades\Filament;
|
use Filament\Facades\Filament;
|
||||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
@ -17,6 +21,45 @@
|
|||||||
$tenantB = Tenant::factory()->create(['workspace_id' => (int) $tenantA->workspace_id]);
|
$tenantB = Tenant::factory()->create(['workspace_id' => (int) $tenantA->workspace_id]);
|
||||||
createUserWithTenant(tenant: $tenantB, user: $user, role: 'owner');
|
createUserWithTenant(tenant: $tenantB, user: $user, role: 'owner');
|
||||||
|
|
||||||
|
OperationRun::factory()->create([
|
||||||
|
'tenant_id' => (int) $tenantA->getKey(),
|
||||||
|
'workspace_id' => (int) $tenantA->workspace_id,
|
||||||
|
'type' => 'inventory_sync',
|
||||||
|
'status' => 'completed',
|
||||||
|
'outcome' => 'failed',
|
||||||
|
'context' => [
|
||||||
|
'inventory' => [
|
||||||
|
'coverage' => InventoryCoveragePayload::buildPayload([
|
||||||
|
'deviceConfiguration' => [
|
||||||
|
'status' => InventoryCoveragePayload::StatusFailed,
|
||||||
|
'item_count' => 0,
|
||||||
|
'error_code' => 'graph_forbidden',
|
||||||
|
],
|
||||||
|
], []),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
'completed_at' => now(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
OperationRun::factory()->create([
|
||||||
|
'tenant_id' => (int) $tenantB->getKey(),
|
||||||
|
'workspace_id' => (int) $tenantB->workspace_id,
|
||||||
|
'type' => 'inventory_sync',
|
||||||
|
'status' => 'completed',
|
||||||
|
'outcome' => 'succeeded',
|
||||||
|
'context' => [
|
||||||
|
'inventory' => [
|
||||||
|
'coverage' => InventoryCoveragePayload::buildPayload([
|
||||||
|
'deviceConfiguration' => [
|
||||||
|
'status' => InventoryCoveragePayload::StatusSucceeded,
|
||||||
|
'item_count' => 1,
|
||||||
|
],
|
||||||
|
], []),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
'completed_at' => now()->subMinute(),
|
||||||
|
]);
|
||||||
|
|
||||||
$this->actingAs($user);
|
$this->actingAs($user);
|
||||||
Filament::setCurrentPanel('admin');
|
Filament::setCurrentPanel('admin');
|
||||||
Filament::setTenant(null, true);
|
Filament::setTenant(null, true);
|
||||||
@ -29,5 +72,10 @@
|
|||||||
|
|
||||||
Livewire::actingAs($user)->test(InventoryCoverage::class)
|
Livewire::actingAs($user)->test(InventoryCoverage::class)
|
||||||
->assertOk()
|
->assertOk()
|
||||||
->assertSee('Coverage');
|
->assertSee('Tenant coverage truth')
|
||||||
|
->assertTableColumnFormattedStateSet(
|
||||||
|
'coverage_state',
|
||||||
|
BadgeCatalog::spec(BadgeDomain::InventoryCoverageState, 'failed')->label,
|
||||||
|
'policy:deviceConfiguration',
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|||||||
@ -0,0 +1,90 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use App\Filament\Pages\InventoryCoverage;
|
||||||
|
use App\Filament\Resources\InventoryItemResource;
|
||||||
|
use App\Models\OperationRun;
|
||||||
|
use App\Models\Tenant;
|
||||||
|
use App\Support\Inventory\InventoryCoverage as InventoryCoveragePayload;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
|
||||||
|
uses(RefreshDatabase::class);
|
||||||
|
|
||||||
|
function seedCoverageBasisRun(Tenant $tenant): OperationRun
|
||||||
|
{
|
||||||
|
return OperationRun::factory()->create([
|
||||||
|
'tenant_id' => (int) $tenant->getKey(),
|
||||||
|
'workspace_id' => (int) $tenant->workspace_id,
|
||||||
|
'type' => 'inventory_sync',
|
||||||
|
'status' => 'completed',
|
||||||
|
'outcome' => 'failed',
|
||||||
|
'context' => [
|
||||||
|
'inventory' => [
|
||||||
|
'coverage' => InventoryCoveragePayload::buildPayload([
|
||||||
|
'deviceConfiguration' => [
|
||||||
|
'status' => InventoryCoveragePayload::StatusFailed,
|
||||||
|
'item_count' => 0,
|
||||||
|
'error_code' => 'graph_forbidden',
|
||||||
|
],
|
||||||
|
], []),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
'completed_at' => now(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
it('shows the basis run and tenant-scoped history path on the coverage report for authorized viewers', function (): void {
|
||||||
|
$tenant = Tenant::factory()->create();
|
||||||
|
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner');
|
||||||
|
|
||||||
|
$run = seedCoverageBasisRun($tenant);
|
||||||
|
|
||||||
|
$historyUrl = route('admin.operations.index', [
|
||||||
|
'tenant_id' => (int) $tenant->getKey(),
|
||||||
|
'tableFilters' => [
|
||||||
|
'type' => [
|
||||||
|
'value' => 'inventory_sync',
|
||||||
|
],
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->actingAs($user)
|
||||||
|
->get(InventoryCoverage::getUrl(tenant: $tenant))
|
||||||
|
->assertOk()
|
||||||
|
->assertSee('Latest coverage-bearing sync completed')
|
||||||
|
->assertSee('Open basis run')
|
||||||
|
->assertSee(route('admin.operations.view', ['run' => (int) $run->getKey()]), false)
|
||||||
|
->assertSee($historyUrl, false)
|
||||||
|
->assertSee('Review the cited inventory sync to inspect provider or permission issues in detail.');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('degrades basis-run links safely for viewers who cannot open inventory-sync runs', function (): void {
|
||||||
|
$tenant = Tenant::factory()->create();
|
||||||
|
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'readonly');
|
||||||
|
|
||||||
|
seedCoverageBasisRun($tenant);
|
||||||
|
|
||||||
|
$this->actingAs($user)
|
||||||
|
->get(InventoryCoverage::getUrl(tenant: $tenant))
|
||||||
|
->assertOk()
|
||||||
|
->assertSee('The coverage basis is current, but your role cannot open the cited run detail.')
|
||||||
|
->assertDontSee('Open basis run');
|
||||||
|
|
||||||
|
$this->actingAs($user)
|
||||||
|
->get(InventoryItemResource::getUrl('index', tenant: $tenant))
|
||||||
|
->assertOk()
|
||||||
|
->assertSee('Latest run detail is not available with your current role.')
|
||||||
|
->assertDontSee('Open basis run');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps the no-basis fallback explicit on the inventory items list', function (): void {
|
||||||
|
$tenant = Tenant::factory()->create();
|
||||||
|
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner');
|
||||||
|
|
||||||
|
$this->actingAs($user)
|
||||||
|
->get(InventoryItemResource::getUrl('index', tenant: $tenant))
|
||||||
|
->assertOk()
|
||||||
|
->assertSee('No current result')
|
||||||
|
->assertSee('Run Inventory Sync from Inventory Items to establish current coverage truth.');
|
||||||
|
});
|
||||||
@ -3,19 +3,21 @@
|
|||||||
declare(strict_types=1);
|
declare(strict_types=1);
|
||||||
|
|
||||||
use App\Filament\Pages\InventoryCoverage;
|
use App\Filament\Pages\InventoryCoverage;
|
||||||
|
use App\Models\InventoryItem;
|
||||||
|
use App\Models\OperationRun;
|
||||||
use App\Models\Tenant;
|
use App\Models\Tenant;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use App\Models\WorkspaceMembership;
|
use App\Models\WorkspaceMembership;
|
||||||
use App\Support\Badges\BadgeCatalog;
|
use App\Support\Badges\BadgeCatalog;
|
||||||
use App\Support\Badges\BadgeDomain;
|
use App\Support\Badges\BadgeDomain;
|
||||||
use App\Support\Badges\TagBadgeCatalog;
|
use App\Support\Inventory\InventoryCoverage as InventoryCoveragePayload;
|
||||||
use App\Support\Badges\TagBadgeDomain;
|
|
||||||
use Filament\Facades\Filament;
|
use Filament\Facades\Filament;
|
||||||
use Filament\Tables\Columns\IconColumn;
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
use Filament\Tables\Columns\TextColumn;
|
|
||||||
use Livewire\Features\SupportTesting\Testable;
|
use Livewire\Features\SupportTesting\Testable;
|
||||||
use Livewire\Livewire;
|
use Livewire\Livewire;
|
||||||
|
|
||||||
|
uses(RefreshDatabase::class);
|
||||||
|
|
||||||
function inventoryCoverageRecordKey(string $segment, string $type): string
|
function inventoryCoverageRecordKey(string $segment, string $type): string
|
||||||
{
|
{
|
||||||
return "{$segment}:{$type}";
|
return "{$segment}:{$type}";
|
||||||
@ -31,198 +33,147 @@ function inventoryCoverageComponent(User $user, Tenant $tenant): Testable
|
|||||||
return Livewire::actingAs($user)->test(InventoryCoverage::class);
|
return Livewire::actingAs($user)->test(InventoryCoverage::class);
|
||||||
}
|
}
|
||||||
|
|
||||||
function removeInventoryCoverageRestoreMetadata(): void
|
function seedTruthfulCoverageRun(Tenant $tenant): OperationRun
|
||||||
{
|
{
|
||||||
config()->set(
|
InventoryItem::factory()->create([
|
||||||
'tenantpilot.supported_policy_types',
|
'tenant_id' => (int) $tenant->getKey(),
|
||||||
collect(config('tenantpilot.supported_policy_types', []))
|
'display_name' => 'Conditional Access Prod',
|
||||||
->map(function (array $row): array {
|
'policy_type' => 'conditionalAccessPolicy',
|
||||||
unset($row['restore']);
|
'external_id' => 'ca-1',
|
||||||
|
'platform' => 'windows',
|
||||||
|
]);
|
||||||
|
|
||||||
return $row;
|
InventoryItem::factory()->create([
|
||||||
})
|
'tenant_id' => (int) $tenant->getKey(),
|
||||||
->all(),
|
'display_name' => 'Compliance Legacy',
|
||||||
);
|
'policy_type' => 'deviceCompliancePolicy',
|
||||||
|
'external_id' => 'dc-1',
|
||||||
|
'platform' => 'windows',
|
||||||
|
]);
|
||||||
|
|
||||||
config()->set(
|
return OperationRun::factory()->create([
|
||||||
'tenantpilot.foundation_types',
|
'tenant_id' => (int) $tenant->getKey(),
|
||||||
collect(config('tenantpilot.foundation_types', []))
|
'workspace_id' => (int) $tenant->workspace_id,
|
||||||
->map(function (array $row): array {
|
'type' => 'inventory_sync',
|
||||||
unset($row['restore']);
|
'status' => 'completed',
|
||||||
|
'outcome' => 'partially_succeeded',
|
||||||
return $row;
|
'context' => [
|
||||||
})
|
'inventory' => [
|
||||||
->all(),
|
'coverage' => InventoryCoveragePayload::buildPayload([
|
||||||
);
|
'conditionalAccessPolicy' => [
|
||||||
|
'status' => InventoryCoveragePayload::StatusSucceeded,
|
||||||
|
'item_count' => 1,
|
||||||
|
],
|
||||||
|
'deviceConfiguration' => [
|
||||||
|
'status' => InventoryCoveragePayload::StatusFailed,
|
||||||
|
'item_count' => 0,
|
||||||
|
'error_code' => 'graph_forbidden',
|
||||||
|
],
|
||||||
|
'roleScopeTag' => [
|
||||||
|
'status' => InventoryCoveragePayload::StatusSkipped,
|
||||||
|
'item_count' => 0,
|
||||||
|
],
|
||||||
|
], ['roleScopeTag']),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
'completed_at' => now(),
|
||||||
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
it('renders searchable coverage rows for policy and foundation metadata', function (): void {
|
it('renders truthful coverage states and deterministic follow-up order', function (): void {
|
||||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||||
|
seedTruthfulCoverageRun($tenant);
|
||||||
|
|
||||||
$conditionalAccessKey = inventoryCoverageRecordKey('policy', 'conditionalAccessPolicy');
|
$failedKey = inventoryCoverageRecordKey('policy', 'deviceConfiguration');
|
||||||
$deviceConfigurationKey = inventoryCoverageRecordKey('policy', 'deviceConfiguration');
|
$unknownKey = inventoryCoverageRecordKey('policy', 'deviceCompliancePolicy');
|
||||||
$scopeTagKey = inventoryCoverageRecordKey('foundation', 'roleScopeTag');
|
$skippedKey = inventoryCoverageRecordKey('foundation', 'roleScopeTag');
|
||||||
|
$succeededKey = inventoryCoverageRecordKey('policy', 'conditionalAccessPolicy');
|
||||||
|
|
||||||
inventoryCoverageComponent($user, $tenant)
|
inventoryCoverageComponent($user, $tenant)
|
||||||
->assertOk()
|
->assertOk()
|
||||||
->assertTableColumnExists('type')
|
->assertTableColumnExists('coverage_state')
|
||||||
->assertTableColumnExists('label')
|
->assertTableColumnExists('label')
|
||||||
|
->assertTableColumnExists('follow_up_guidance')
|
||||||
|
->assertTableColumnExists('observed_item_count')
|
||||||
->assertTableColumnExists('category')
|
->assertTableColumnExists('category')
|
||||||
->assertTableColumnExists('dependencies')
|
->assertCanSeeTableRecords([$failedKey, $unknownKey, $skippedKey], inOrder: true)
|
||||||
->assertCountTableRecords(
|
->assertTableColumnFormattedStateSet(
|
||||||
count(config('tenantpilot.supported_policy_types', [])) + count(config('tenantpilot.foundation_types', [])),
|
'coverage_state',
|
||||||
|
BadgeCatalog::spec(BadgeDomain::InventoryCoverageState, 'failed')->label,
|
||||||
|
$failedKey,
|
||||||
)
|
)
|
||||||
->assertCanSeeTableRecords([$conditionalAccessKey, $scopeTagKey])
|
->assertTableColumnFormattedStateSet(
|
||||||
->searchTable('conditional')
|
'coverage_state',
|
||||||
->assertCanSeeTableRecords([$conditionalAccessKey])
|
BadgeCatalog::spec(BadgeDomain::InventoryCoverageState, 'unknown')->label,
|
||||||
->assertCanNotSeeTableRecords([$deviceConfigurationKey, $scopeTagKey])
|
$unknownKey,
|
||||||
->searchTable('Scope Tag')
|
)
|
||||||
->assertCanSeeTableRecords([$scopeTagKey])
|
->assertTableColumnFormattedStateSet(
|
||||||
->assertCanNotSeeTableRecords([$conditionalAccessKey])
|
'coverage_state',
|
||||||
->searchTable(null)
|
BadgeCatalog::spec(BadgeDomain::InventoryCoverageState, 'skipped')->label,
|
||||||
->assertCanSeeTableRecords([$conditionalAccessKey, $deviceConfigurationKey, $scopeTagKey]);
|
$skippedKey,
|
||||||
|
)
|
||||||
|
->assertTableColumnFormattedStateSet(
|
||||||
|
'coverage_state',
|
||||||
|
BadgeCatalog::spec(BadgeDomain::InventoryCoverageState, 'succeeded')->label,
|
||||||
|
$succeededKey,
|
||||||
|
)
|
||||||
|
->assertTableColumnFormattedStateSet(
|
||||||
|
'follow_up_guidance',
|
||||||
|
'Review provider consent or permissions, then rerun inventory sync.',
|
||||||
|
$failedKey,
|
||||||
|
)
|
||||||
|
->assertTableColumnFormattedStateSet(
|
||||||
|
'follow_up_guidance',
|
||||||
|
'No current basis result exists for this type. Run inventory sync to confirm coverage.',
|
||||||
|
$unknownKey,
|
||||||
|
)
|
||||||
|
->assertTableColumnStateSet('observed_item_count', 1, $unknownKey)
|
||||||
|
->searchTable('Compliance')
|
||||||
|
->assertCanSeeTableRecords([$unknownKey])
|
||||||
|
->assertCanNotSeeTableRecords([$failedKey, $skippedKey, $succeededKey]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('sorts coverage rows by type and label deterministically', function (): void {
|
it('filters truthful coverage rows by state, category, and restore metadata', function (): void {
|
||||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||||
|
seedTruthfulCoverageRun($tenant);
|
||||||
|
|
||||||
$appProtectionKey = inventoryCoverageRecordKey('policy', 'appProtectionPolicy');
|
$failedKey = inventoryCoverageRecordKey('policy', 'deviceConfiguration');
|
||||||
$conditionalAccessKey = inventoryCoverageRecordKey('policy', 'conditionalAccessPolicy');
|
$unknownKey = inventoryCoverageRecordKey('policy', 'deviceCompliancePolicy');
|
||||||
$deviceComplianceKey = inventoryCoverageRecordKey('policy', 'deviceCompliancePolicy');
|
$skippedKey = inventoryCoverageRecordKey('foundation', 'roleScopeTag');
|
||||||
$adminTemplatesKey = inventoryCoverageRecordKey('policy', 'groupPolicyConfiguration');
|
$succeededKey = inventoryCoverageRecordKey('policy', 'conditionalAccessPolicy');
|
||||||
$appConfigDeviceKey = inventoryCoverageRecordKey('policy', 'managedDeviceAppConfiguration');
|
|
||||||
$appConfigMamKey = inventoryCoverageRecordKey('policy', 'mamAppConfiguration');
|
|
||||||
|
|
||||||
inventoryCoverageComponent($user, $tenant)
|
|
||||||
->sortTable('type')
|
|
||||||
->assertCanSeeTableRecords([$appProtectionKey, $conditionalAccessKey, $deviceComplianceKey], inOrder: true)
|
|
||||||
->sortTable('label')
|
|
||||||
->assertCanSeeTableRecords([$adminTemplatesKey, $appConfigDeviceKey, $appConfigMamKey], inOrder: true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('filters coverage rows by category and restore mode when restore metadata exists', function (): void {
|
|
||||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
|
||||||
|
|
||||||
$assignmentFilterKey = inventoryCoverageRecordKey('foundation', 'assignmentFilter');
|
|
||||||
$scopeTagKey = inventoryCoverageRecordKey('foundation', 'roleScopeTag');
|
|
||||||
$roleDefinitionKey = inventoryCoverageRecordKey('foundation', 'intuneRoleDefinition');
|
|
||||||
$roleAssignmentKey = inventoryCoverageRecordKey('foundation', 'intuneRoleAssignment');
|
|
||||||
$deviceConfigurationKey = inventoryCoverageRecordKey('policy', 'deviceConfiguration');
|
|
||||||
$conditionalAccessKey = inventoryCoverageRecordKey('policy', 'conditionalAccessPolicy');
|
|
||||||
$securityBaselineKey = inventoryCoverageRecordKey('policy', 'securityBaselinePolicy');
|
|
||||||
|
|
||||||
inventoryCoverageComponent($user, $tenant)
|
inventoryCoverageComponent($user, $tenant)
|
||||||
|
->assertTableFilterExists('coverage_state')
|
||||||
->assertTableFilterExists('category')
|
->assertTableFilterExists('category')
|
||||||
->assertTableFilterExists('restore')
|
->assertTableFilterExists('restore')
|
||||||
->filterTable('category', 'Foundations')
|
->filterTable('coverage_state', 'failed')
|
||||||
->assertCanSeeTableRecords([$assignmentFilterKey, $scopeTagKey])
|
->assertCanSeeTableRecords([$failedKey])
|
||||||
->assertCanNotSeeTableRecords([$deviceConfigurationKey, $conditionalAccessKey, $roleDefinitionKey, $roleAssignmentKey])
|
->assertCanNotSeeTableRecords([$unknownKey, $skippedKey, $succeededKey])
|
||||||
->removeTableFilters()
|
->removeTableFilters()
|
||||||
->filterTable('category', 'RBAC')
|
->filterTable('category', 'Foundations')
|
||||||
->assertCanSeeTableRecords([$roleDefinitionKey, $roleAssignmentKey])
|
->assertCanSeeTableRecords([$skippedKey])
|
||||||
->assertCanNotSeeTableRecords([$assignmentFilterKey, $scopeTagKey, $deviceConfigurationKey])
|
->assertCanNotSeeTableRecords([$failedKey, $unknownKey, $succeededKey])
|
||||||
->removeTableFilters()
|
->removeTableFilters()
|
||||||
->filterTable('restore', 'preview-only')
|
->filterTable('restore', 'preview-only')
|
||||||
->assertCanSeeTableRecords([$conditionalAccessKey, $securityBaselineKey, $roleDefinitionKey, $roleAssignmentKey])
|
->assertCanSeeTableRecords([$succeededKey])
|
||||||
->assertCanNotSeeTableRecords([$deviceConfigurationKey, $assignmentFilterKey]);
|
->assertCanNotSeeTableRecords([$failedKey, $skippedKey]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('omits the restore filter when the runtime dataset has no restore metadata', function (): void {
|
it('shows a clear-filters empty state for the derived truth table', function (): void {
|
||||||
removeInventoryCoverageRestoreMetadata();
|
|
||||||
|
|
||||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||||
|
seedTruthfulCoverageRun($tenant);
|
||||||
$component = inventoryCoverageComponent($user, $tenant)
|
|
||||||
->assertTableFilterExists('category');
|
|
||||||
|
|
||||||
expect($component->instance()->getTable()->getFilter('restore'))->toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('shows a single clear-filters empty state action and can reset back to a populated dataset', function (): void {
|
|
||||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
|
||||||
|
|
||||||
$conditionalAccessKey = inventoryCoverageRecordKey('policy', 'conditionalAccessPolicy');
|
|
||||||
|
|
||||||
inventoryCoverageComponent($user, $tenant)
|
inventoryCoverageComponent($user, $tenant)
|
||||||
->assertTableEmptyStateActionsExistInOrder(['clear_filters'])
|
->assertTableEmptyStateActionsExistInOrder(['clear_filters'])
|
||||||
->searchTable('no-such-coverage-entry')
|
->searchTable('no-such-coverage-entry')
|
||||||
->assertCountTableRecords(0)
|
->assertCountTableRecords(0)
|
||||||
->assertSee('No coverage entries match this view')
|
->assertSee('No coverage rows match this report')
|
||||||
->assertSee('Clear filters')
|
->assertSee('Clear filters');
|
||||||
->searchTable(null)
|
|
||||||
->assertCountTableRecords(
|
|
||||||
count(config('tenantpilot.supported_policy_types', [])) + count(config('tenantpilot.foundation_types', [])),
|
|
||||||
)
|
|
||||||
->assertCanSeeTableRecords([$conditionalAccessKey]);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('preserves badge semantics and dependency indicators in the interactive table columns', function (): void {
|
it('returns 404 for non-members on the inventory coverage page even when basis truth exists', function (): void {
|
||||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
|
||||||
|
|
||||||
$conditionalAccessKey = inventoryCoverageRecordKey('policy', 'conditionalAccessPolicy');
|
|
||||||
$deviceConfigurationKey = inventoryCoverageRecordKey('policy', 'deviceConfiguration');
|
|
||||||
$assignmentFilterKey = inventoryCoverageRecordKey('foundation', 'assignmentFilter');
|
|
||||||
|
|
||||||
$typeSpec = TagBadgeCatalog::spec(TagBadgeDomain::PolicyType, 'conditionalAccessPolicy');
|
|
||||||
$categorySpec = TagBadgeCatalog::spec(TagBadgeDomain::PolicyCategory, 'Conditional Access');
|
|
||||||
$restoreSpec = BadgeCatalog::spec(BadgeDomain::PolicyRestoreMode, 'preview-only');
|
|
||||||
$riskSpec = BadgeCatalog::spec(BadgeDomain::PolicyRisk, 'high');
|
|
||||||
|
|
||||||
inventoryCoverageComponent($user, $tenant)
|
|
||||||
->assertTableColumnFormattedStateSet('label', $typeSpec->label, $conditionalAccessKey)
|
|
||||||
->assertTableColumnFormattedStateSet('category', $categorySpec->label, $conditionalAccessKey)
|
|
||||||
->assertTableColumnFormattedStateSet('restore', $restoreSpec->label, $conditionalAccessKey)
|
|
||||||
->assertTableColumnFormattedStateSet('risk', $riskSpec->label, $conditionalAccessKey)
|
|
||||||
->assertTableColumnFormattedStateSet('segment', 'Policy', $conditionalAccessKey)
|
|
||||||
->assertTableColumnFormattedStateSet('segment', 'Foundation', $assignmentFilterKey)
|
|
||||||
->assertTableColumnStateSet('dependencies', true, $deviceConfigurationKey)
|
|
||||||
->assertTableColumnStateSet('dependencies', false, $assignmentFilterKey)
|
|
||||||
->assertTableColumnExists('label', function (TextColumn $column) use ($typeSpec): bool {
|
|
||||||
$state = $column->getState();
|
|
||||||
|
|
||||||
return $column->isBadge()
|
|
||||||
&& $column->getColor($state) === $typeSpec->color
|
|
||||||
&& $column->getIcon($state) === $typeSpec->icon;
|
|
||||||
}, $conditionalAccessKey)
|
|
||||||
->assertTableColumnExists('category', function (TextColumn $column) use ($categorySpec): bool {
|
|
||||||
$state = $column->getState();
|
|
||||||
|
|
||||||
return $column->isBadge()
|
|
||||||
&& $column->getColor($state) === $categorySpec->color
|
|
||||||
&& $column->getIcon($state) === $categorySpec->icon;
|
|
||||||
}, $conditionalAccessKey)
|
|
||||||
->assertTableColumnExists('restore', function (TextColumn $column) use ($restoreSpec): bool {
|
|
||||||
$state = $column->getState();
|
|
||||||
|
|
||||||
return $column->isBadge()
|
|
||||||
&& $column->getColor($state) === $restoreSpec->color
|
|
||||||
&& $column->getIcon($state) === $restoreSpec->icon;
|
|
||||||
}, $conditionalAccessKey)
|
|
||||||
->assertTableColumnExists('risk', function (TextColumn $column) use ($riskSpec): bool {
|
|
||||||
$state = $column->getState();
|
|
||||||
|
|
||||||
return $column->isBadge()
|
|
||||||
&& $column->getColor($state) === $riskSpec->color
|
|
||||||
&& $column->getIcon($state) === $riskSpec->icon;
|
|
||||||
}, $conditionalAccessKey)
|
|
||||||
->assertTableColumnExists('dependencies', function (IconColumn $column): bool {
|
|
||||||
$state = $column->getState();
|
|
||||||
|
|
||||||
return $state === true
|
|
||||||
&& $column->getColor($state) === 'success'
|
|
||||||
&& (string) $column->getIcon($state) === 'heroicon-m-check-circle';
|
|
||||||
}, $deviceConfigurationKey)
|
|
||||||
->assertTableColumnExists('dependencies', function (IconColumn $column): bool {
|
|
||||||
$state = $column->getState();
|
|
||||||
|
|
||||||
return $state === false
|
|
||||||
&& $column->getColor($state) === 'gray'
|
|
||||||
&& (string) $column->getIcon($state) === 'heroicon-m-minus-circle';
|
|
||||||
}, $assignmentFilterKey);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns 404 for non-members on the inventory coverage page even when RBAC foundations exist', function (): void {
|
|
||||||
[$owner, $tenant] = createUserWithTenant(role: 'owner');
|
[$owner, $tenant] = createUserWithTenant(role: 'owner');
|
||||||
|
seedTruthfulCoverageRun($tenant);
|
||||||
|
|
||||||
$this->actingAs($owner);
|
$this->actingAs($owner);
|
||||||
$tenant->makeCurrent();
|
$tenant->makeCurrent();
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user