Compare commits

...

2 Commits

Author SHA1 Message Date
9fbd3e5ec7 Spec 186: implement tenant registry recovery triage (#217)
## Summary
- turn the tenant registry into a workspace-scoped recovery triage surface with backup posture and recovery evidence columns
- preserve workspace overview backup and recovery drilldown intent by routing multi-tenant cases into filtered tenant registry slices
- add the Spec 186 planning artifacts, focused regression coverage, and shared triage presentation helpers

## Testing
- `cd apps/platform && ./vendor/bin/sail bin pint --dirty --format agent`
- `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Filament/TenantRegistryRecoveryTriageTest.php tests/Feature/Filament/WorkspaceOverviewSummaryMetricsTest.php tests/Feature/Filament/WorkspaceOverviewDrilldownContinuityTest.php tests/Feature/Filament/TenantResourceIndexIsWorkspaceScopedTest.php tests/Feature/Filament/WorkspaceOverviewAuthorizationTest.php tests/Feature/Guards/ActionSurfaceContractTest.php tests/Feature/Guards/FilamentTableStandardsGuardTest.php`

## Notes
- no schema change
- no new persisted recovery truth
- branch includes the full Spec 186 spec, plan, research, data model, contract, quickstart, and tasks artifacts

Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de>
Reviewed-on: #217
2026-04-09 19:20:48 +00:00
53e799fea7 Spec 185: workspace recovery posture visibility (#216)
## Summary
- add Spec 185 workspace recovery posture visibility artifacts under `specs/185-workspace-recovery-posture-visibility`
- promote tenant backup health and recovery evidence onto the workspace overview with separate metrics, attention ordering, calmness coverage, and tenant-dashboard drill-throughs
- batch visible-tenant backup/recovery derivation to keep the workspace overview query-bounded
- align follow-up fixes from the authoritative suite rerun, including dashboard truth-alignment fixtures, canonical backup schedule tenant context, guard-path cleanup, smoke-fixture credential removal, and robust theme asset manifest handling

## Testing
- `cd apps/platform && ./vendor/bin/sail bin pint --dirty --format agent`
- `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Unit/Filament/PanelThemeAssetTest.php tests/Feature/Guards/DerivedStateConsumerAdoptionGuardTest.php`
- focused regression pack for the previously failing cases passed
- full suite JUnit run passed: `3401` tests, `18849` assertions, `0` failures, `0` errors, `8` skips

## Notes
- no new schema or persisted workspace recovery model
- no provider-registration changes; Filament/Livewire stack remains on Filament v5 and Livewire v4
- no new destructive actions or global search changes

Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de>
Reviewed-on: #216
2026-04-09 12:57:19 +00:00
48 changed files with 5677 additions and 261 deletions

View File

@ -155,6 +155,10 @@ ## Active Technologies
- Existing PostgreSQL, Redis, and filesystem storage for `apps/platform`; static build artifacts for `apps/website`; repository-managed workspace manifests and docs; no new database persistence (183-website-workspace-foundation)
- PHP 8.4, Laravel 12, Blade, Filament v5, Livewire v4 + Filament v5 widgets and resources, Livewire v4, Pest v4, existing `TenantDashboard`, `DashboardKpis`, `NeedsAttention`, `TenantBackupHealthResolver`, `TenantBackupHealthAssessment`, `RestoreRunResource`, `RestoreSafetyResolver`, `RestoreResultAttention`, `OperationRunLinks`, and existing RBAC helpers (184-dashboard-recovery-honesty)
- PostgreSQL with existing tenant-owned `backup_sets`, `restore_runs`, and linked `operation_runs`; no schema change planned (184-dashboard-recovery-honesty)
- PHP 8.4.15 + Laravel 12, Filament v5, Livewire v4, Pest v4, existing `WorkspaceOverviewBuilder`, `WorkspaceSummaryStats`, `WorkspaceNeedsAttention`, `TenantBackupHealthResolver`, `TenantBackupHealthAssessment`, `RestoreSafetyResolver`, tenant dashboard widgets, `WorkspaceCapabilityResolver`, `CapabilityResolver`, and the current workspace overview Blade surfaces (185-workspace-recovery-posture-visibility)
- PostgreSQL unchanged; no schema change, new cache table, or persisted workspace recovery artifact is planned (185-workspace-recovery-posture-visibility)
- PHP 8.4, Laravel 12, Blade, Filament v5, Livewire v4 + Filament v5 resources and table filters, Livewire v4 `ListRecords`, Pest v4, Laravel Sail, existing `TenantResource`, `ListTenants`, `WorkspaceOverviewBuilder`, `TenantBackupHealthResolver`, `TenantBackupHealthAssessment`, `RestoreSafetyResolver`, `RecoveryReadiness`, and shared badge infrastructure (186-tenant-registry-recovery-triage)
- PostgreSQL with existing tenant-owned `tenants`, `backup_sets`, `backup_items`, `restore_runs`, `policies`, and membership records; no schema change planned (186-tenant-registry-recovery-triage)
- PHP 8.4.15 (feat/005-bulk-operations)
@ -189,8 +193,8 @@ ## Code Style
PHP 8.4.15: Follow standard conventions
## Recent Changes
- 186-tenant-registry-recovery-triage: Added PHP 8.4, Laravel 12, Blade, Filament v5, Livewire v4 + Filament v5 resources and table filters, Livewire v4 `ListRecords`, Pest v4, Laravel Sail, existing `TenantResource`, `ListTenants`, `WorkspaceOverviewBuilder`, `TenantBackupHealthResolver`, `TenantBackupHealthAssessment`, `RestoreSafetyResolver`, `RecoveryReadiness`, and shared badge infrastructure
- 185-workspace-recovery-posture-visibility: Added PHP 8.4.15 + Laravel 12, Filament v5, Livewire v4, Pest v4, existing `WorkspaceOverviewBuilder`, `WorkspaceSummaryStats`, `WorkspaceNeedsAttention`, `TenantBackupHealthResolver`, `TenantBackupHealthAssessment`, `RestoreSafetyResolver`, tenant dashboard widgets, `WorkspaceCapabilityResolver`, `CapabilityResolver`, and the current workspace overview Blade surfaces
- 184-dashboard-recovery-honesty: Added PHP 8.4, Laravel 12, Blade, Filament v5, Livewire v4 + Filament v5 widgets and resources, Livewire v4, Pest v4, existing `TenantDashboard`, `DashboardKpis`, `NeedsAttention`, `TenantBackupHealthResolver`, `TenantBackupHealthAssessment`, `RestoreRunResource`, `RestoreSafetyResolver`, `RestoreResultAttention`, `OperationRunLinks`, and existing RBAC helpers
- 183-website-workspace-foundation: Added PHP 8.4.15 and Laravel 12 for `apps/platform`; Node.js 20+ with pnpm 10 workspace tooling; Astro v6 for `apps/website`; Bash and Docker Compose for root orchestration + `laravel/framework`, `filament/filament`, `livewire/livewire`, `laravel/sail`, `vite`, `tailwindcss`, `pnpm` workspaces, Astro, existing `./scripts/platform-sail` wrapper, repo-root Docker Compose
- 182-platform-relocation: Added PHP 8.4.15, Laravel 12, Blade, Livewire v4, Filament v5.2.x, Tailwind CSS v4, Vite 7 + `laravel/framework`, `filament/filament`, `livewire/livewire`, `laravel/sail`, `laravel-vite-plugin`, `tailwindcss`, `vite`, `pestphp/pest`, `drizzle-kit`, PostgreSQL, Redis, Docker Compose
<!-- MANUAL ADDITIONS START -->
<!-- MANUAL ADDITIONS END -->

View File

@ -66,8 +66,6 @@ public function handle(): int
'workspace_id' => (int) $workspace->getKey(),
'name' => (string) ($scenarioConfig['tenant_name'] ?? 'Spec 180 Blocked Backup Tenant'),
'tenant_id' => $tenantRouteKey,
'app_client_id' => (string) ($scenarioConfig['app_client_id'] ?? '18000000-0000-4000-8000-000000000182'),
'app_client_secret' => null,
'app_certificate_thumbprint' => null,
'app_status' => 'ok',
'app_notes' => null,

View File

@ -75,9 +75,9 @@ public function getSubheading(): ?string
return null;
}
$tenant = Filament::getTenant();
$tenant = BackupScheduleResource::panelTenantContext();
if (! $tenant instanceof Tenant) {
if ($tenant === null) {
return 'One or more enabled schedules need follow-up.';
}

View File

@ -2,6 +2,7 @@
namespace App\Filament\Resources;
use App\Filament\Pages\TenantDashboard;
use App\Filament\Resources\TenantResource\Pages;
use App\Filament\Resources\TenantResource\RelationManagers;
use App\Http\Controllers\RbacDelegatedAuthController;
@ -30,6 +31,8 @@
use App\Support\Audit\AuditActionId;
use App\Support\Auth\Capabilities;
use App\Support\Auth\UiTooltips;
use App\Support\BackupHealth\TenantBackupHealthAssessment;
use App\Support\BackupHealth\TenantBackupHealthResolver;
use App\Support\Badges\BadgeDomain;
use App\Support\Badges\BadgeRenderer;
use App\Support\Badges\TagBadgeDomain;
@ -39,12 +42,14 @@
use App\Support\OpsUx\OperationUxPresenter;
use App\Support\OpsUx\OpsUxBrowserEvents;
use App\Support\Rbac\UiEnforcement;
use App\Support\RestoreSafety\RestoreSafetyResolver;
use App\Support\Tenants\TenantActionDescriptor;
use App\Support\Tenants\TenantActionSurface;
use App\Support\Tenants\TenantInteractionLane;
use App\Support\Tenants\TenantLifecyclePresentation;
use App\Support\Tenants\TenantOperabilityOutcome;
use App\Support\Tenants\TenantOperabilityQuestion;
use App\Support\Tenants\TenantRecoveryTriagePresentation;
use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration;
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceInspectAffordance;
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile;
@ -94,6 +99,16 @@ class TenantResource extends Resource
*/
protected static array $tenantActionCatalogCache = [];
private const string POSTURE_SNAPSHOT_REQUEST_KEY = 'tenant_resource.posture_snapshot';
/**
* @var array<string, true>
*/
private const array TRIAGE_SORT_VALUES = [
TenantRecoveryTriagePresentation::TRIAGE_SORT_DEFAULT => true,
TenantRecoveryTriagePresentation::TRIAGE_SORT_WORST_FIRST => true,
];
/**
* Tenant creation is handled exclusively by the onboarding wizard.
* The CRUD create page has been removed.
@ -241,7 +256,19 @@ public static function getGlobalSearchEloquentQuery(): Builder
public static function table(Table $table): Table
{
return $table
->defaultSort('name')
->defaultSort(function (Builder $query, string $direction, mixed $livewire): Builder {
if (static::triageSortIsWorstFirst(static::triageSortState($livewire))) {
return static::applyWorstFirstTriageOrdering($query);
}
$nameColumn = (new Tenant)->qualifyColumn('name');
if (! method_exists($livewire, 'getTableSortColumn') || $livewire->getTableSortColumn() !== 'name') {
$query->orderBy($nameColumn, $direction);
}
return $query;
})
->paginated(TablePaginationProfiles::resource())
->persistFiltersInSession()
->persistSearchInSession()
@ -251,6 +278,28 @@ public static function table(Table $table): Table
Tables\Columns\TextColumn::make('name')
->searchable()
->sortable(),
Tables\Columns\TextColumn::make('backup_posture')
->label('Backup posture')
->badge()
->state(fn (Tenant $record): ?string => static::backupHealthAssessmentForTenant($record)?->posture)
->formatStateUsing(fn (mixed $state, Tenant $record): string => TenantRecoveryTriagePresentation::backupPostureLabel(
static::backupHealthAssessmentForTenant($record),
))
->color(fn (Tenant $record): string => TenantRecoveryTriagePresentation::backupPostureTone(
static::backupHealthAssessmentForTenant($record),
)),
Tables\Columns\TextColumn::make('recovery_evidence')
->label('Recovery evidence')
->badge()
->state(fn (Tenant $record): ?string => TenantRecoveryTriagePresentation::recoveryEvidenceState(
static::recoveryEvidenceForTenant($record),
))
->formatStateUsing(fn (mixed $state, Tenant $record): string => TenantRecoveryTriagePresentation::recoveryEvidenceLabel(
static::recoveryEvidenceForTenant($record),
))
->color(fn (Tenant $record): string => TenantRecoveryTriagePresentation::recoveryEvidenceTone(
static::recoveryEvidenceForTenant($record),
)),
Tables\Columns\TextColumn::make('tenant_id')
->label('Tenant ID')
->copyable()
@ -303,6 +352,32 @@ public static function table(Table $table): Table
'staging' => 'STAGING',
'other' => 'Other',
]),
Tables\Filters\SelectFilter::make('backup_posture')
->label('Backup posture')
->multiple()
->options(TenantRecoveryTriagePresentation::backupPostureOptions())
->query(function (Builder $query, array $data): Builder {
return static::applySnapshotTenantSubsetFilter(
$query,
static::sanitizeBackupPostures($data['values'] ?? []),
snapshotKey: 'backup_posture_ids',
);
}),
Tables\Filters\SelectFilter::make('recovery_evidence')
->label('Recovery evidence')
->multiple()
->options(TenantRecoveryTriagePresentation::recoveryEvidenceOptions())
->query(function (Builder $query, array $data): Builder {
return static::applyRecoveryEvidenceFilter(
$query,
static::sanitizeRecoveryEvidenceStates($data['values'] ?? []),
);
}),
Tables\Filters\SelectFilter::make('triage_sort')
->label('Sort order')
->options(TenantRecoveryTriagePresentation::triageSortOptions())
->placeholder('Default order')
->query(static fn (Builder $query): Builder => $query),
])
->actions([
Actions\Action::make('related_onboarding')
@ -310,6 +385,12 @@ public static function table(Table $table): Table
->icon(fn (Tenant $record): string => static::relatedOnboardingDraftAction($record, TenantActionSurface::TenantIndexRow)?->icon ?? 'heroicon-o-arrow-path')
->url(fn (Tenant $record): string => static::relatedOnboardingDraftUrl($record) ?? route('admin.onboarding'))
->visible(fn (Tenant $record): bool => static::tenantIndexPrimaryAction($record)?->key === 'related_onboarding'),
Actions\Action::make('openTenant')
->label('Open')
->icon('heroicon-o-arrow-right')
->color('primary')
->url(fn (Tenant $record): string => TenantDashboard::getUrl(panel: 'tenant', tenant: $record))
->visible(fn (Tenant $record): bool => $record->isActive() && static::tenantIndexPrimaryAction($record)?->key !== 'related_onboarding'),
ActionGroup::make([
Actions\Action::make('related_onboarding_overflow')
->label(fn (Tenant $record): string => static::relatedOnboardingDraftActionLabel($record, TenantActionSurface::TenantIndexRow) ?? 'View related onboarding')
@ -317,12 +398,6 @@ public static function table(Table $table): Table
->url(fn (Tenant $record): string => static::relatedOnboardingDraftUrl($record) ?? route('admin.onboarding'))
->visible(fn (Tenant $record): bool => static::relatedOnboardingDraftAction($record, TenantActionSurface::TenantIndexRow) instanceof TenantActionDescriptor
&& static::tenantIndexPrimaryAction($record)?->key !== 'related_onboarding'),
Actions\Action::make('openTenant')
->label('Open')
->icon('heroicon-o-arrow-right')
->color('primary')
->url(fn (Tenant $record) => \App\Filament\Resources\PolicyResource::getUrl('index', panel: 'tenant', tenant: $record))
->visible(fn (Tenant $record) => $record->isActive()),
UiEnforcement::forAction(
Actions\Action::make('edit')
->label('Edit')
@ -808,6 +883,311 @@ public static function table(Table $table): Table
->emptyStateIcon('heroicon-o-building-office-2');
}
/**
* @return list<string>
*/
public static function sanitizeBackupPostures(mixed $value): array
{
return static::sanitizeRequestedValues(
$value,
array_keys(TenantRecoveryTriagePresentation::backupPostureOptions()),
);
}
/**
* @return list<string>
*/
public static function sanitizeRecoveryEvidenceStates(mixed $value): array
{
return static::sanitizeRequestedValues(
$value,
array_keys(TenantRecoveryTriagePresentation::recoveryEvidenceOptions()),
);
}
public static function sanitizeTriageSort(mixed $value): ?string
{
if (! is_string($value) || ! isset(self::TRIAGE_SORT_VALUES[$value])) {
return null;
}
return $value;
}
/**
* @param list<string> $allowedValues
* @return list<string>
*/
private static function sanitizeRequestedValues(mixed $value, array $allowedValues): array
{
$allowed = array_fill_keys($allowedValues, true);
return array_values(array_filter(
array_unique(array_filter(static::normalizeRequestedValues($value), static fn (string $item): bool => isset($allowed[$item]))),
static fn (string $item): bool => isset($allowed[$item]),
));
}
/**
* @return list<string>
*/
private static function normalizeRequestedValues(mixed $value): array
{
if (is_string($value) && $value !== '') {
return [$value];
}
if (! is_array($value)) {
return [];
}
return array_values(array_filter($value, static fn (mixed $item): bool => is_string($item) && $item !== ''));
}
private static function triageSortState(mixed $livewire): ?string
{
if (! is_object($livewire) || ! method_exists($livewire, 'getTableFilterState')) {
return null;
}
$state = $livewire->getTableFilterState('triage_sort');
$value = is_array($state) ? ($state['value'] ?? null) : null;
return static::sanitizeTriageSort($value);
}
private static function triageSortIsWorstFirst(?string $value): bool
{
return $value === TenantRecoveryTriagePresentation::TRIAGE_SORT_WORST_FIRST;
}
private static function backupHealthAssessmentForTenant(Tenant $tenant): ?TenantBackupHealthAssessment
{
return static::postureSnapshot()['backup_health'][(int) $tenant->getKey()] ?? null;
}
/**
* @return array<string, mixed>|null
*/
private static function recoveryEvidenceForTenant(Tenant $tenant): ?array
{
return static::postureSnapshot()['recovery_evidence'][(int) $tenant->getKey()] ?? null;
}
private static function applySnapshotTenantSubsetFilter(
Builder $query,
array $selectedValues,
string $snapshotKey,
): Builder {
if ($selectedValues === []) {
return $query;
}
$snapshot = static::postureSnapshot();
$tenantIds = collect($selectedValues)
->flatMap(static fn (string $value): array => $snapshot[$snapshotKey][$value] ?? [])
->map(static fn (mixed $tenantId): int => (int) $tenantId)
->unique()
->values()
->all();
if ($tenantIds === []) {
return $query->whereRaw('1 = 0');
}
return $query->whereIn((new Tenant)->qualifyColumn('id'), $tenantIds);
}
private static function applyRecoveryEvidenceFilter(Builder $query, array $selectedValues): Builder
{
if ($selectedValues === []) {
return $query;
}
$tenantIds = collect(static::postureSnapshot()['recovery_evidence'])
->filter(static fn (array $evidence): bool => in_array(
TenantRecoveryTriagePresentation::recoveryEvidenceState($evidence),
$selectedValues,
true,
))
->keys()
->map(static fn (mixed $tenantId): int => (int) $tenantId)
->values()
->all();
if ($tenantIds === []) {
return $query->whereRaw('1 = 0');
}
return $query->whereIn((new Tenant)->qualifyColumn('id'), $tenantIds);
}
private static function applyWorstFirstTriageOrdering(Builder $query): Builder
{
$tiers = static::postureSnapshot()['triage_tiers'];
if ($tiers === []) {
return $query;
}
$qualifiedId = (new Tenant)->qualifyColumn('id');
$qualifiedName = (new Tenant)->qualifyColumn('name');
$clauses = [];
$bindings = [];
foreach ([1, 2, 3, 4, 5] as $tier) {
$tenantIds = array_keys(array_filter($tiers, static fn (int $value): bool => $value === $tier));
if ($tenantIds === []) {
continue;
}
$placeholders = implode(', ', array_fill(0, count($tenantIds), '?'));
$clauses[] = "when {$qualifiedId} in ({$placeholders}) then {$tier}";
array_push($bindings, ...$tenantIds);
}
if ($clauses === []) {
return $query->orderBy($qualifiedName);
}
return $query
->orderByRaw('case '.implode(' ', $clauses).' else 6 end', $bindings)
->orderBy($qualifiedName);
}
/**
* @return array{
* tenant_ids: list<int>,
* backup_health: array<int, TenantBackupHealthAssessment>,
* recovery_evidence: array<int, array<string, mixed>>,
* backup_posture_ids: array<string, list<int>>,
* recovery_evidence_ids: array<string, list<int>>,
* triage_tiers: array<int, int>
* }
*/
private static function postureSnapshot(): array
{
$request = request();
$snapshot = $request->attributes->get(self::POSTURE_SNAPSHOT_REQUEST_KEY);
if (is_array($snapshot)) {
return $snapshot;
}
$snapshot = static::buildPostureSnapshot();
$request->attributes->set(self::POSTURE_SNAPSHOT_REQUEST_KEY, $snapshot);
return $snapshot;
}
/**
* @return array{
* tenant_ids: list<int>,
* backup_health: array<int, TenantBackupHealthAssessment>,
* recovery_evidence: array<int, array<string, mixed>>,
* backup_posture_ids: array<string, list<int>>,
* recovery_evidence_ids: array<string, list<int>>,
* triage_tiers: array<int, int>
* }
*/
private static function buildPostureSnapshot(): array
{
$tenantIds = static::visibleTenantIdsForPostureSnapshot();
if ($tenantIds === []) {
return [
'tenant_ids' => [],
'backup_health' => [],
'recovery_evidence' => [],
'backup_posture_ids' => [],
'recovery_evidence_ids' => [],
'triage_tiers' => [],
];
}
/** @var TenantBackupHealthResolver $backupHealthResolver */
$backupHealthResolver = app(TenantBackupHealthResolver::class);
$backupHealth = $backupHealthResolver->assessMany($tenantIds);
/** @var RestoreSafetyResolver $restoreSafetyResolver */
$restoreSafetyResolver = app(RestoreSafetyResolver::class);
$recoveryEvidence = $restoreSafetyResolver->dashboardRecoveryEvidenceForTenants($tenantIds, $backupHealth);
$triageTiers = [];
foreach ($tenantIds as $tenantId) {
$triageTiers[$tenantId] = TenantRecoveryTriagePresentation::triageTier(
$backupHealth[$tenantId] ?? null,
$recoveryEvidence[$tenantId] ?? null,
);
}
return [
'tenant_ids' => $tenantIds,
'backup_health' => $backupHealth,
'recovery_evidence' => $recoveryEvidence,
'backup_posture_ids' => static::snapshotTenantIdsByBackupPosture($backupHealth),
'recovery_evidence_ids' => static::snapshotTenantIdsByRecoveryEvidence($recoveryEvidence),
'triage_tiers' => $triageTiers,
];
}
/**
* @return list<int>
*/
private static function visibleTenantIdsForPostureSnapshot(): array
{
/** @var Builder $query */
$query = clone static::getEloquentQuery();
return $query
->reorder()
->orderBy((new Tenant)->qualifyColumn('name'))
->orderBy((new Tenant)->qualifyColumn('id'))
->pluck((new Tenant)->qualifyColumn('id'))
->map(static fn (mixed $tenantId): int => (int) $tenantId)
->all();
}
/**
* @param array<int, TenantBackupHealthAssessment> $assessments
* @return array<string, list<int>>
*/
private static function snapshotTenantIdsByBackupPosture(array $assessments): array
{
$tenantIdsByPosture = [];
foreach ($assessments as $tenantId => $assessment) {
$tenantIdsByPosture[$assessment->posture] ??= [];
$tenantIdsByPosture[$assessment->posture][] = (int) $tenantId;
}
return $tenantIdsByPosture;
}
/**
* @param array<int, array<string, mixed>> $recoveryEvidence
* @return array<string, list<int>>
*/
private static function snapshotTenantIdsByRecoveryEvidence(array $recoveryEvidence): array
{
$tenantIdsByState = [];
foreach ($recoveryEvidence as $tenantId => $evidence) {
$state = TenantRecoveryTriagePresentation::recoveryEvidenceState($evidence);
if ($state === null) {
continue;
}
$tenantIdsByState[$state] ??= [];
$tenantIdsByState[$state][] = (int) $tenantId;
}
return $tenantIdsByState;
}
public static function infolist(Schema $schema): Schema
{
// ... [Infolist Omitted - No Change] ...

View File

@ -8,6 +8,7 @@
use App\Models\User;
use App\Models\Workspace;
use App\Services\Onboarding\OnboardingDraftResolver;
use App\Support\Tenants\TenantRecoveryTriagePresentation;
use App\Support\Workspaces\WorkspaceContext;
use Filament\Actions;
use Filament\Resources\Pages\ListRecords;
@ -16,6 +17,12 @@ class ListTenants extends ListRecords
{
protected static string $resource = TenantResource::class;
public function mount(): void
{
parent::mount();
$this->applyRequestedTriageIntent();
}
protected function getHeaderActions(): array
{
return [
@ -26,9 +33,33 @@ protected function getHeaderActions(): array
protected function getTableEmptyStateActions(): array
{
return [
$actions = [
$this->makeOnboardingEntryAction(),
];
if ($this->hasActiveTriageEmptyState()) {
array_unshift($actions, $this->clearTriageFiltersAction());
}
return $actions;
}
protected function getTableEmptyStateHeading(): ?string
{
if ($this->hasActiveTriageEmptyState()) {
return 'No visible tenants match this triage slice';
}
return parent::getTableEmptyStateHeading();
}
protected function getTableEmptyStateDescription(): ?string
{
if ($this->hasActiveTriageEmptyState()) {
return 'Try a different backup posture or recovery evidence filter, or return to the default calm-browsing order.';
}
return parent::getTableEmptyStateDescription();
}
private function makeOnboardingEntryAction(): Actions\Action
@ -41,6 +72,61 @@ private function makeOnboardingEntryAction(): Actions\Action
->url(route('admin.onboarding'));
}
private function clearTriageFiltersAction(): Actions\Action
{
return Actions\Action::make('clear_triage_filters')
->label('Clear filters')
->icon('heroicon-o-funnel')
->color('gray')
->action('resetTableFiltersForm');
}
private function applyRequestedTriageIntent(): void
{
$hasIntent = request()->query->has('backup_posture')
|| request()->query->has('recovery_evidence')
|| request()->query->has('triage_sort');
if (! $hasIntent) {
return;
}
$backupPostures = TenantResource::sanitizeBackupPostures(request()->query('backup_posture'));
$recoveryEvidence = TenantResource::sanitizeRecoveryEvidenceStates(request()->query('recovery_evidence'));
$triageSort = TenantResource::sanitizeTriageSort(request()->query('triage_sort'));
foreach (['backup_posture', 'recovery_evidence', 'triage_sort'] as $filterName) {
data_forget($this->tableFilters, $filterName);
data_forget($this->tableDeferredFilters, $filterName);
}
if ($backupPostures !== []) {
$this->tableFilters['backup_posture']['values'] = $backupPostures;
$this->tableDeferredFilters['backup_posture']['values'] = $backupPostures;
}
if ($recoveryEvidence !== []) {
$this->tableFilters['recovery_evidence']['values'] = $recoveryEvidence;
$this->tableDeferredFilters['recovery_evidence']['values'] = $recoveryEvidence;
}
if ($triageSort !== null) {
$this->tableFilters['triage_sort']['value'] = $triageSort;
$this->tableDeferredFilters['triage_sort']['value'] = $triageSort;
}
}
private function hasActiveTriageEmptyState(): bool
{
$backupPostures = TenantResource::sanitizeBackupPostures(data_get($this->tableFilters, 'backup_posture.values', []));
$recoveryEvidence = TenantResource::sanitizeRecoveryEvidenceStates(data_get($this->tableFilters, 'recovery_evidence.values', []));
$triageSort = TenantResource::sanitizeTriageSort(data_get($this->tableFilters, 'triage_sort.value'));
return $backupPostures !== []
|| $recoveryEvidence !== []
|| $triageSort === TenantRecoveryTriagePresentation::TRIAGE_SORT_WORST_FIRST;
}
private function accessibleResumableDraftCount(): int
{
$user = auth()->user();

View File

@ -18,10 +18,10 @@
use App\Support\Rbac\UiTooltips;
use App\Support\RestoreSafety\RestoreResultAttention;
use App\Support\RestoreSafety\RestoreSafetyResolver;
use App\Support\Tenants\TenantRecoveryTriagePresentation;
use Filament\Facades\Filament;
use Filament\Widgets\Widget;
use Illuminate\Database\Eloquent\ModelNotFoundException;
use Illuminate\Support\Str;
class RecoveryReadiness extends Widget
{
@ -58,16 +58,16 @@ protected function getViewData(): array
'pollingInterval' => ActiveRuns::pollingIntervalForTenant($tenant),
'backupPosture' => [
'label' => 'Backup posture',
'value' => Str::headline($backupHealth->posture),
'description' => $this->backupHealthDescription($backupHealth, $backupHealthAction['helperText']),
'color' => $backupHealth->tone(),
'value' => TenantRecoveryTriagePresentation::backupPostureLabel($backupHealth),
'description' => TenantRecoveryTriagePresentation::backupPostureDescription($backupHealth, $backupHealthAction['helperText']),
'color' => TenantRecoveryTriagePresentation::backupPostureTone($backupHealth),
'url' => $backupHealthAction['actionUrl'],
],
'recoveryEvidence' => [
'label' => 'Recovery evidence',
'value' => $this->recoveryEvidenceValue($recoveryEvidence['overview_state']),
'description' => $this->recoveryEvidenceDescription($recoveryEvidence, $recoveryAction['helperText']),
'color' => $this->recoveryEvidenceTone($recoveryEvidence),
'value' => TenantRecoveryTriagePresentation::recoveryEvidenceLabel($recoveryEvidence),
'description' => TenantRecoveryTriagePresentation::recoveryEvidenceDescription($recoveryEvidence, $recoveryAction['helperText']),
'color' => TenantRecoveryTriagePresentation::recoveryEvidenceTone($recoveryEvidence),
'url' => $recoveryAction['actionUrl'],
],
];
@ -211,64 +211,6 @@ private function resolveRecoveryAction(Tenant $tenant, array $recoveryEvidence):
}
}
private function recoveryEvidenceValue(string $overviewState): string
{
return match ($overviewState) {
'unvalidated' => 'Unvalidated',
'weakened' => 'Weakened',
'no_recent_issues_visible' => 'No recent issues visible',
default => Str::headline($overviewState),
};
}
/**
* @param array<string, mixed> $recoveryEvidence
*/
private function recoveryEvidenceDescription(array $recoveryEvidence, ?string $helperText): string
{
$parts = [
is_string($recoveryEvidence['summary'] ?? null) ? $recoveryEvidence['summary'] : null,
is_string($recoveryEvidence['claim_boundary'] ?? null) ? $recoveryEvidence['claim_boundary'] : null,
$helperText,
];
return trim(implode(' ', array_filter($parts, static fn (?string $part): bool => filled($part))));
}
/**
* @param array<string, mixed> $recoveryEvidence
*/
private function recoveryEvidenceTone(array $recoveryEvidence): string
{
$attentionState = is_string($recoveryEvidence['latest_relevant_attention_state'] ?? null)
? $recoveryEvidence['latest_relevant_attention_state']
: null;
return match ($recoveryEvidence['overview_state'] ?? null) {
'unvalidated' => 'warning',
'weakened' => $attentionState === RestoreResultAttention::STATE_FAILED ? 'danger' : 'warning',
'no_recent_issues_visible' => 'success',
default => 'gray',
};
}
private function backupHealthDescription(TenantBackupHealthAssessment $assessment, ?string $helperText): string
{
$parts = [
$assessment->supportingMessage ?? $assessment->headline,
];
if ($assessment->posture === TenantBackupHealthAssessment::POSTURE_HEALTHY) {
$parts[] = $assessment->positiveClaimBoundary;
}
if ($helperText !== null) {
$parts[] = $helperText;
}
return trim(implode(' ', array_filter($parts, static fn (?string $part): bool => filled($part))));
}
private function canOpenBackupSurfaces(Tenant $tenant): bool
{
$user = auth()->user();

View File

@ -27,6 +27,7 @@ class WorkspaceNeedsAttention extends Widget
* supporting_message: ?string,
* badge: string,
* badge_color: string,
* reason_context: ?array<string, mixed>,
* destination: array<string, mixed>,
* action_disabled: bool,
* helper_text: ?string,
@ -58,6 +59,7 @@ class WorkspaceNeedsAttention extends Widget
* supporting_message: ?string,
* badge: string,
* badge_color: string,
* reason_context: ?array<string, mixed>,
* destination: array<string, mixed>,
* action_disabled: bool,
* helper_text: ?string,

View File

@ -26,13 +26,233 @@ public function assess(Tenant|int $tenant): TenantBackupHealthAssessment
? (int) $tenant->getKey()
: (int) $tenant;
return $this->assessMany([$tenantId])[$tenantId];
}
/**
* @param iterable<int, Tenant|int> $tenants
* @return array<int, TenantBackupHealthAssessment>
*/
public function assessMany(iterable $tenants): array
{
$tenantIds = $this->normalizeTenantIds($tenants);
if ($tenantIds === []) {
return [];
}
$now = CarbonImmutable::now('UTC');
$latestBackupSet = $this->latestRelevantBackupSet($tenantId);
$latestBackupSets = $this->latestRelevantBackupSets($tenantIds);
$scheduleFollowUps = $this->scheduleFollowUpEvaluations($tenantIds, $now);
$assessments = [];
foreach ($tenantIds as $tenantId) {
$assessments[$tenantId] = $this->assessmentForResolvedInputs(
tenantId: $tenantId,
latestBackupSet: $latestBackupSets[$tenantId] ?? null,
scheduleFollowUp: $scheduleFollowUps[$tenantId] ?? $this->emptyScheduleFollowUpEvaluation(),
now: $now,
);
}
return $assessments;
}
private function latestRelevantBackupSet(int $tenantId): ?BackupSet
{
return $this->latestRelevantBackupSets([$tenantId])[$tenantId] ?? null;
}
/**
* @param array<int, int> $tenantIds
* @return array<int, BackupSet>
*/
private function latestRelevantBackupSets(array $tenantIds): array
{
if ($tenantIds === []) {
return [];
}
$latestBackupSetIds = BackupSet::query()
->withTrashed()
->whereIn('tenant_id', $tenantIds)
->whereNotNull('completed_at')
->orderBy('tenant_id')
->orderByDesc('completed_at')
->orderByDesc('id')
->get([
'id',
'tenant_id',
])
->unique('tenant_id')
->pluck('id')
->all();
if ($latestBackupSetIds === []) {
return [];
}
return BackupSet::query()
->withTrashed()
->whereIn('id', $latestBackupSetIds)
->with([
'items' => fn ($query) => $query->select([
'id',
'tenant_id',
'backup_set_id',
'payload',
'metadata',
'assignments',
]),
])
->get([
'id',
'tenant_id',
'workspace_id',
'name',
'status',
'item_count',
'created_by',
'completed_at',
'created_at',
'metadata',
'deleted_at',
])
->keyBy(static fn (BackupSet $backupSet): int => (int) $backupSet->tenant_id)
->all();
}
private function freshnessEvaluation(?CarbonInterface $latestCompletedAt, CarbonImmutable $now): BackupFreshnessEvaluation
{
$cutoffAt = $now->subHours($this->freshnessHours());
return new BackupFreshnessEvaluation(
latestCompletedAt: $latestCompletedAt,
cutoffAt: $cutoffAt,
isFresh: $latestCompletedAt?->greaterThanOrEqualTo($cutoffAt) ?? false,
);
}
private function scheduleFollowUpEvaluation(int $tenantId, CarbonImmutable $now): BackupScheduleFollowUpEvaluation
{
return $this->scheduleFollowUpEvaluations([$tenantId], $now)[$tenantId] ?? $this->emptyScheduleFollowUpEvaluation();
}
/**
* @param array<int, int> $tenantIds
* @return array<int, BackupScheduleFollowUpEvaluation>
*/
private function scheduleFollowUpEvaluations(array $tenantIds, CarbonImmutable $now): array
{
if ($tenantIds === []) {
return [];
}
$schedulesByTenant = BackupSchedule::query()
->whereIn('tenant_id', $tenantIds)
->where('is_enabled', true)
->orderBy('tenant_id')
->orderBy('next_run_at')
->orderBy('id')
->get([
'id',
'tenant_id',
'last_run_status',
'last_run_at',
'next_run_at',
'created_at',
])
->groupBy('tenant_id');
$evaluations = [];
foreach ($tenantIds as $tenantId) {
$evaluations[$tenantId] = $this->evaluateScheduleFollowUpCollection(
schedules: $schedulesByTenant->get($tenantId, collect()),
now: $now,
);
}
return $evaluations;
}
private function evaluateScheduleFollowUpCollection(iterable $schedules, CarbonImmutable $now): BackupScheduleFollowUpEvaluation
{
$graceCutoff = $now->subMinutes($this->scheduleOverdueGraceMinutes());
$enabledScheduleCount = 0;
$overdueScheduleCount = 0;
$failedRecentRunCount = 0;
$neverSuccessfulCount = 0;
$primaryScheduleId = null;
foreach ($schedules as $schedule) {
$enabledScheduleCount++;
$isOverdue = $schedule->next_run_at?->lessThan($graceCutoff) ?? false;
$lastRunStatus = strtolower(trim((string) $schedule->last_run_status));
$needsFollowUpAfterRun = in_array($lastRunStatus, ['failed', 'partial', 'skipped', 'canceled'], true);
$neverSuccessful = $schedule->last_run_at === null
&& ($isOverdue || ($schedule->created_at?->lessThan($graceCutoff) ?? false));
if ($isOverdue) {
$overdueScheduleCount++;
}
if ($needsFollowUpAfterRun) {
$failedRecentRunCount++;
}
if ($neverSuccessful) {
$neverSuccessfulCount++;
}
if ($primaryScheduleId === null && ($neverSuccessful || $isOverdue || $needsFollowUpAfterRun)) {
$primaryScheduleId = (int) $schedule->getKey();
}
}
return new BackupScheduleFollowUpEvaluation(
hasEnabledSchedules: $enabledScheduleCount > 0,
enabledScheduleCount: $enabledScheduleCount,
overdueScheduleCount: $overdueScheduleCount,
failedRecentRunCount: $failedRecentRunCount,
neverSuccessfulCount: $neverSuccessfulCount,
needsFollowUp: $overdueScheduleCount > 0 || $failedRecentRunCount > 0 || $neverSuccessfulCount > 0,
primaryScheduleId: $primaryScheduleId,
summaryMessage: $this->scheduleSummaryMessage(
enabledScheduleCount: $enabledScheduleCount,
overdueScheduleCount: $overdueScheduleCount,
failedRecentRunCount: $failedRecentRunCount,
neverSuccessfulCount: $neverSuccessfulCount,
),
);
}
private function emptyScheduleFollowUpEvaluation(): BackupScheduleFollowUpEvaluation
{
return new BackupScheduleFollowUpEvaluation(
hasEnabledSchedules: false,
enabledScheduleCount: 0,
overdueScheduleCount: 0,
failedRecentRunCount: 0,
neverSuccessfulCount: 0,
needsFollowUp: false,
primaryScheduleId: null,
summaryMessage: null,
);
}
private function assessmentForResolvedInputs(
int $tenantId,
?BackupSet $latestBackupSet,
BackupScheduleFollowUpEvaluation $scheduleFollowUp,
CarbonImmutable $now,
): TenantBackupHealthAssessment {
$qualitySummary = $latestBackupSet instanceof BackupSet
? $this->backupQualityResolver->summarizeBackupSet($latestBackupSet)
: null;
$freshnessEvaluation = $this->freshnessEvaluation($latestBackupSet?->completed_at, $now);
$scheduleFollowUp = $this->scheduleFollowUpEvaluation($tenantId, $now);
if (! $latestBackupSet instanceof BackupSet) {
return new TenantBackupHealthAssessment(
@ -133,115 +353,25 @@ public function assess(Tenant|int $tenant): TenantBackupHealthAssessment
);
}
private function latestRelevantBackupSet(int $tenantId): ?BackupSet
/**
* @param iterable<int, Tenant|int> $tenants
* @return array<int, int>
*/
private function normalizeTenantIds(iterable $tenants): array
{
/** @var BackupSet|null $backupSet */
$backupSet = BackupSet::query()
->withTrashed()
->where('tenant_id', $tenantId)
->whereNotNull('completed_at')
->orderByDesc('completed_at')
->orderByDesc('id')
->with([
'items' => fn ($query) => $query->select([
'id',
'tenant_id',
'backup_set_id',
'payload',
'metadata',
'assignments',
]),
])
->first([
'id',
'tenant_id',
'workspace_id',
'name',
'status',
'item_count',
'created_by',
'completed_at',
'created_at',
'metadata',
'deleted_at',
]);
$tenantIds = [];
return $backupSet;
}
foreach ($tenants as $tenant) {
$tenantId = $tenant instanceof Tenant
? (int) $tenant->getKey()
: (int) $tenant;
private function freshnessEvaluation(?CarbonInterface $latestCompletedAt, CarbonImmutable $now): BackupFreshnessEvaluation
{
$cutoffAt = $now->subHours($this->freshnessHours());
return new BackupFreshnessEvaluation(
latestCompletedAt: $latestCompletedAt,
cutoffAt: $cutoffAt,
isFresh: $latestCompletedAt?->greaterThanOrEqualTo($cutoffAt) ?? false,
);
}
private function scheduleFollowUpEvaluation(int $tenantId, CarbonImmutable $now): BackupScheduleFollowUpEvaluation
{
$graceCutoff = $now->subMinutes($this->scheduleOverdueGraceMinutes());
$schedules = BackupSchedule::query()
->where('tenant_id', $tenantId)
->where('is_enabled', true)
->orderBy('next_run_at')
->orderBy('id')
->get([
'id',
'tenant_id',
'last_run_status',
'last_run_at',
'next_run_at',
'created_at',
]);
$enabledScheduleCount = $schedules->count();
$overdueScheduleCount = 0;
$failedRecentRunCount = 0;
$neverSuccessfulCount = 0;
$primaryScheduleId = null;
foreach ($schedules as $schedule) {
$isOverdue = $schedule->next_run_at?->lessThan($graceCutoff) ?? false;
$lastRunStatus = strtolower(trim((string) $schedule->last_run_status));
$needsFollowUpAfterRun = in_array($lastRunStatus, ['failed', 'partial', 'skipped', 'canceled'], true);
$neverSuccessful = $schedule->last_run_at === null
&& ($isOverdue || ($schedule->created_at?->lessThan($graceCutoff) ?? false));
if ($isOverdue) {
$overdueScheduleCount++;
}
if ($needsFollowUpAfterRun) {
$failedRecentRunCount++;
}
if ($neverSuccessful) {
$neverSuccessfulCount++;
}
if ($primaryScheduleId === null && ($neverSuccessful || $isOverdue || $needsFollowUpAfterRun)) {
$primaryScheduleId = (int) $schedule->getKey();
if ($tenantId > 0) {
$tenantIds[] = $tenantId;
}
}
return new BackupScheduleFollowUpEvaluation(
hasEnabledSchedules: $enabledScheduleCount > 0,
enabledScheduleCount: $enabledScheduleCount,
overdueScheduleCount: $overdueScheduleCount,
failedRecentRunCount: $failedRecentRunCount,
neverSuccessfulCount: $neverSuccessfulCount,
needsFollowUp: $overdueScheduleCount > 0 || $failedRecentRunCount > 0 || $neverSuccessfulCount > 0,
primaryScheduleId: $primaryScheduleId,
summaryMessage: $this->scheduleSummaryMessage(
enabledScheduleCount: $enabledScheduleCount,
overdueScheduleCount: $overdueScheduleCount,
failedRecentRunCount: $failedRecentRunCount,
neverSuccessfulCount: $neverSuccessfulCount,
),
);
return array_values(array_unique($tenantIds));
}
private function latestBackupAgeMessage(?CarbonInterface $completedAt, CarbonImmutable $now): ?string

View File

@ -8,10 +8,19 @@ class PanelThemeAsset
{
public static function resolve(string $entry): ?string
{
if (app()->runningUnitTests()) {
return static::resolveFromManifest($entry);
}
if (is_file(public_path('hot'))) {
return Vite::asset($entry);
}
return static::resolveFromManifest($entry);
}
private static function resolveFromManifest(string $entry): ?string
{
$manifest = public_path('build/manifest.json');
if (! is_file($manifest)) {
@ -20,6 +29,11 @@ public static function resolve(string $entry): ?string
/** @var array<string, array{file?: string}>|null $decoded */
$decoded = json_decode((string) file_get_contents($manifest), true);
if (! is_array($decoded)) {
return null;
}
$file = $decoded[$entry]['file'] ?? null;
if (! is_string($file) || $file === '') {

View File

@ -10,6 +10,7 @@
use App\Models\Tenant;
use App\Models\User;
use App\Services\Auth\CapabilityResolver;
use App\Support\BackupHealth\TenantBackupHealthAssessment;
use App\Support\BackupHealth\TenantBackupHealthResolver;
use App\Support\Auth\Capabilities;
@ -20,6 +21,7 @@
public function __construct(
private CapabilityResolver $capabilityResolver,
private WriteGateInterface $writeGate,
private TenantBackupHealthResolver $backupHealthResolver,
) {}
/**
@ -496,57 +498,59 @@ public function resultAttentionForRun(RestoreRun $restoreRun): RestoreResultAtte
*/
public function dashboardRecoveryEvidence(Tenant $tenant): array
{
$backupHealth = app(TenantBackupHealthResolver::class)->assess($tenant);
$backupHealth = $this->backupHealthResolver->assess($tenant);
$relevantRestoreHistory = $this->latestRelevantRestoreHistory($tenant);
$relevantRun = $relevantRestoreHistory['run'];
$relevantAttention = $relevantRestoreHistory['attention'];
return $this->dashboardRecoveryEvidencePayload(
backupHealth: $backupHealth,
relevantRun: $relevantRestoreHistory['run'],
relevantAttention: $relevantRestoreHistory['attention'],
);
}
if (! $relevantRun instanceof RestoreRun || ! $relevantAttention instanceof RestoreResultAttention) {
return [
'backup_posture' => $backupHealth->posture,
'overview_state' => 'unvalidated',
'headline' => 'Recovery evidence is unvalidated',
'summary' => 'No executed restore history is visible in the latest tenant restore records.',
'claim_boundary' => RestoreSafetyCopy::recoveryBoundary('run_completed_not_recovery_proven'),
'latest_relevant_restore_run_id' => null,
'latest_relevant_attention_state' => null,
'latest_relevant_restore_run' => null,
'latest_relevant_attention' => null,
'reason' => 'no_history',
];
/**
* @param iterable<int, Tenant|int> $tenants
* @param array<int, TenantBackupHealthAssessment>|null $backupHealthAssessments
* @return array<int, array{
* backup_posture: string,
* overview_state: string,
* headline: string,
* summary: string,
* claim_boundary: string,
* latest_relevant_restore_run_id: ?int,
* latest_relevant_attention_state: ?string,
* latest_relevant_restore_run: ?RestoreRun,
* latest_relevant_attention: ?RestoreResultAttention,
* reason: string
* }>
*/
public function dashboardRecoveryEvidenceForTenants(
iterable $tenants,
?array $backupHealthAssessments = null,
): array {
$tenantIds = $this->normalizeTenantIds($tenants);
if ($tenantIds === []) {
return [];
}
if (in_array($relevantAttention->state, [
RestoreResultAttention::STATE_FAILED,
RestoreResultAttention::STATE_PARTIAL,
RestoreResultAttention::STATE_COMPLETED_WITH_FOLLOW_UP,
], true)) {
return [
'backup_posture' => $backupHealth->posture,
'overview_state' => 'weakened',
'headline' => 'Recent restore history weakens confidence',
'summary' => $relevantAttention->summary,
'claim_boundary' => RestoreSafetyCopy::recoveryBoundary($relevantAttention->recoveryClaimBoundary),
'latest_relevant_restore_run_id' => (int) $relevantRun->getKey(),
'latest_relevant_attention_state' => $relevantAttention->state,
'latest_relevant_restore_run' => $relevantRun,
'latest_relevant_attention' => $relevantAttention,
'reason' => $relevantAttention->state,
];
$resolvedBackupHealthAssessments = $backupHealthAssessments ?? $this->backupHealthResolver->assessMany($tenantIds);
$candidatesByTenant = $this->dashboardRecoveryCandidatesForTenants($tenantIds)->groupBy('tenant_id');
$evidence = [];
foreach ($tenantIds as $tenantId) {
$backupHealth = $resolvedBackupHealthAssessments[$tenantId] ?? $this->backupHealthResolver->assess($tenantId);
$relevantRestoreHistory = $this->latestRelevantRestoreHistoryFromCandidates(
$candidatesByTenant->get($tenantId, collect()),
);
$evidence[$tenantId] = $this->dashboardRecoveryEvidencePayload(
backupHealth: $backupHealth,
relevantRun: $relevantRestoreHistory['run'],
relevantAttention: $relevantRestoreHistory['attention'],
);
}
return [
'backup_posture' => $backupHealth->posture,
'overview_state' => 'no_recent_issues_visible',
'headline' => 'No recent restore issues visible',
'summary' => 'Recent executed restore history exists without a current follow-up signal.',
'claim_boundary' => RestoreSafetyCopy::recoveryBoundary($relevantAttention->recoveryClaimBoundary),
'latest_relevant_restore_run_id' => (int) $relevantRun->getKey(),
'latest_relevant_attention_state' => $relevantAttention->state,
'latest_relevant_restore_run' => $relevantRun,
'latest_relevant_attention' => $relevantAttention,
'reason' => 'no_recent_issues_visible',
];
return $evidence;
}
/**
@ -611,7 +615,16 @@ public function invalidationReasonsForBasis(
*/
private function latestRelevantRestoreHistory(Tenant $tenant): array
{
foreach ($this->dashboardRecoveryCandidates($tenant) as $candidate) {
return $this->latestRelevantRestoreHistoryFromCandidates($this->dashboardRecoveryCandidates($tenant));
}
/**
* @param iterable<int, RestoreRun> $candidates
* @return array{run: ?RestoreRun, attention: ?RestoreResultAttention}
*/
private function latestRelevantRestoreHistoryFromCandidates(iterable $candidates): array
{
foreach ($candidates as $candidate) {
$attention = $this->resultAttentionForRun($candidate);
if ($attention->state === RestoreResultAttention::STATE_NOT_EXECUTED) {
@ -630,6 +643,40 @@ private function latestRelevantRestoreHistory(Tenant $tenant): array
];
}
/**
* @param array<int, int> $tenantIds
* @return \Illuminate\Database\Eloquent\Collection<int, RestoreRun>
*/
private function dashboardRecoveryCandidatesForTenants(array $tenantIds)
{
if ($tenantIds === []) {
return RestoreRun::query()->whereRaw('1 = 0')->get();
}
$candidateIds = RestoreRun::query()
->whereIn('tenant_id', $tenantIds)
->orderBy('tenant_id')
->orderByRaw('COALESCE(completed_at, started_at, created_at) DESC')
->orderByDesc('id')
->get(['id', 'tenant_id'])
->groupBy('tenant_id')
->flatMap(static fn ($runs): array => $runs->take(self::DASHBOARD_RECOVERY_CANDIDATE_LIMIT)->pluck('id')->all())
->values()
->all();
if ($candidateIds === []) {
return RestoreRun::query()->whereRaw('1 = 0')->get();
}
return RestoreRun::query()
->whereIn('id', $candidateIds)
->with('operationRun:id,outcome,context')
->orderBy('tenant_id')
->orderByRaw('COALESCE(completed_at, started_at, created_at) DESC')
->orderByDesc('id')
->get();
}
/**
* @return \Illuminate\Database\Eloquent\Collection<int, RestoreRun>
*/
@ -644,6 +691,73 @@ private function dashboardRecoveryCandidates(Tenant $tenant)
->get();
}
/**
* @return array{
* backup_posture: string,
* overview_state: string,
* headline: string,
* summary: string,
* claim_boundary: string,
* latest_relevant_restore_run_id: ?int,
* latest_relevant_attention_state: ?string,
* latest_relevant_restore_run: ?RestoreRun,
* latest_relevant_attention: ?RestoreResultAttention,
* reason: string
* }
*/
private function dashboardRecoveryEvidencePayload(
TenantBackupHealthAssessment $backupHealth,
?RestoreRun $relevantRun,
?RestoreResultAttention $relevantAttention,
): array {
if (! $relevantRun instanceof RestoreRun || ! $relevantAttention instanceof RestoreResultAttention) {
return [
'backup_posture' => $backupHealth->posture,
'overview_state' => 'unvalidated',
'headline' => 'Recovery evidence is unvalidated',
'summary' => 'No executed restore history is visible in the latest tenant restore records.',
'claim_boundary' => RestoreSafetyCopy::recoveryBoundary('run_completed_not_recovery_proven'),
'latest_relevant_restore_run_id' => null,
'latest_relevant_attention_state' => null,
'latest_relevant_restore_run' => null,
'latest_relevant_attention' => null,
'reason' => 'no_history',
];
}
if (in_array($relevantAttention->state, [
RestoreResultAttention::STATE_FAILED,
RestoreResultAttention::STATE_PARTIAL,
RestoreResultAttention::STATE_COMPLETED_WITH_FOLLOW_UP,
], true)) {
return [
'backup_posture' => $backupHealth->posture,
'overview_state' => 'weakened',
'headline' => 'Recent restore history weakens confidence',
'summary' => $relevantAttention->summary,
'claim_boundary' => RestoreSafetyCopy::recoveryBoundary($relevantAttention->recoveryClaimBoundary),
'latest_relevant_restore_run_id' => (int) $relevantRun->getKey(),
'latest_relevant_attention_state' => $relevantAttention->state,
'latest_relevant_restore_run' => $relevantRun,
'latest_relevant_attention' => $relevantAttention,
'reason' => $relevantAttention->state,
];
}
return [
'backup_posture' => $backupHealth->posture,
'overview_state' => 'no_recent_issues_visible',
'headline' => 'No recent restore issues visible',
'summary' => 'Recent executed restore history exists without a current follow-up signal.',
'claim_boundary' => RestoreSafetyCopy::recoveryBoundary($relevantAttention->recoveryClaimBoundary),
'latest_relevant_restore_run_id' => (int) $relevantRun->getKey(),
'latest_relevant_attention_state' => $relevantAttention->state,
'latest_relevant_restore_run' => $relevantRun,
'latest_relevant_attention' => $relevantAttention,
'reason' => 'no_recent_issues_visible',
];
}
private function primaryCauseFamilyForRun(RestoreRun $restoreRun): string
{
$operationContext = is_array($restoreRun->operationRun?->context) ? $restoreRun->operationRun->context : [];
@ -726,4 +840,25 @@ private function normalizeIds(array $ids): array
return $normalized;
}
/**
* @param iterable<int, Tenant|int> $tenants
* @return array<int, int>
*/
private function normalizeTenantIds(iterable $tenants): array
{
$tenantIds = [];
foreach ($tenants as $tenant) {
$tenantId = $tenant instanceof Tenant
? (int) $tenant->getKey()
: (int) $tenant;
if ($tenantId > 0) {
$tenantIds[] = $tenantId;
}
}
return array_values(array_unique($tenantIds));
}
}

View File

@ -0,0 +1,183 @@
<?php
declare(strict_types=1);
namespace App\Support\Tenants;
use App\Support\BackupHealth\TenantBackupHealthAssessment;
use App\Support\RestoreSafety\RestoreResultAttention;
use Illuminate\Support\Str;
final class TenantRecoveryTriagePresentation
{
public const RECOVERY_EVIDENCE_WEAKENED = 'weakened';
public const RECOVERY_EVIDENCE_UNVALIDATED = 'unvalidated';
public const RECOVERY_EVIDENCE_NO_RECENT_ISSUES_VISIBLE = 'no_recent_issues_visible';
public const TRIAGE_SORT_DEFAULT = 'default';
public const TRIAGE_SORT_WORST_FIRST = 'worst_first';
/**
* @return array<string, string>
*/
public static function backupPostureOptions(): array
{
return [
TenantBackupHealthAssessment::POSTURE_ABSENT => 'Absent',
TenantBackupHealthAssessment::POSTURE_STALE => 'Stale',
TenantBackupHealthAssessment::POSTURE_DEGRADED => 'Degraded',
TenantBackupHealthAssessment::POSTURE_HEALTHY => 'Healthy',
];
}
/**
* @return array<string, string>
*/
public static function recoveryEvidenceOptions(): array
{
return [
self::RECOVERY_EVIDENCE_WEAKENED => 'Weakened',
self::RECOVERY_EVIDENCE_UNVALIDATED => 'Unvalidated',
self::RECOVERY_EVIDENCE_NO_RECENT_ISSUES_VISIBLE => 'No recent issues visible',
];
}
/**
* @return array<string, string>
*/
public static function triageSortOptions(): array
{
return [
self::TRIAGE_SORT_DEFAULT => 'Default order',
self::TRIAGE_SORT_WORST_FIRST => 'Worst first',
];
}
public static function backupPostureState(?TenantBackupHealthAssessment $assessment): ?string
{
return $assessment?->posture;
}
public static function backupPostureLabel(?TenantBackupHealthAssessment $assessment): string
{
$state = self::backupPostureState($assessment);
if ($state === null) {
return 'Unknown';
}
return self::backupPostureOptions()[$state] ?? Str::headline($state);
}
public static function backupPostureTone(?TenantBackupHealthAssessment $assessment): string
{
return $assessment?->tone() ?? 'gray';
}
public static function backupPostureDescription(?TenantBackupHealthAssessment $assessment, ?string $helperText = null): ?string
{
if (! $assessment instanceof TenantBackupHealthAssessment) {
return $helperText;
}
$parts = [
$assessment->supportingMessage ?? $assessment->headline,
$assessment->posture === TenantBackupHealthAssessment::POSTURE_HEALTHY
? $assessment->positiveClaimBoundary
: null,
$helperText,
];
$description = trim(implode(' ', array_filter($parts, static fn (?string $part): bool => filled($part))));
return $description !== '' ? $description : null;
}
/**
* @param array<string, mixed>|null $recoveryEvidence
*/
public static function recoveryEvidenceState(?array $recoveryEvidence): ?string
{
$state = $recoveryEvidence['overview_state'] ?? null;
return is_string($state) && $state !== '' ? $state : null;
}
/**
* @param array<string, mixed>|null $recoveryEvidence
*/
public static function recoveryEvidenceLabel(?array $recoveryEvidence): string
{
$state = self::recoveryEvidenceState($recoveryEvidence);
if ($state === null) {
return 'Unknown';
}
return self::recoveryEvidenceOptions()[$state] ?? Str::headline($state);
}
/**
* @param array<string, mixed>|null $recoveryEvidence
*/
public static function recoveryEvidenceDescription(?array $recoveryEvidence, ?string $helperText = null): ?string
{
if (! is_array($recoveryEvidence)) {
return $helperText;
}
$parts = [
is_string($recoveryEvidence['summary'] ?? null) ? $recoveryEvidence['summary'] : null,
is_string($recoveryEvidence['claim_boundary'] ?? null) ? $recoveryEvidence['claim_boundary'] : null,
$helperText,
];
$description = trim(implode(' ', array_filter($parts, static fn (?string $part): bool => filled($part))));
return $description !== '' ? $description : null;
}
/**
* @param array<string, mixed>|null $recoveryEvidence
*/
public static function recoveryEvidenceTone(?array $recoveryEvidence): string
{
if (! is_array($recoveryEvidence)) {
return 'gray';
}
$attentionState = is_string($recoveryEvidence['latest_relevant_attention_state'] ?? null)
? $recoveryEvidence['latest_relevant_attention_state']
: null;
return match (self::recoveryEvidenceState($recoveryEvidence)) {
self::RECOVERY_EVIDENCE_UNVALIDATED => 'warning',
self::RECOVERY_EVIDENCE_WEAKENED => $attentionState === RestoreResultAttention::STATE_FAILED ? 'danger' : 'warning',
self::RECOVERY_EVIDENCE_NO_RECENT_ISSUES_VISIBLE => 'success',
default => 'gray',
};
}
/**
* @param array<string, mixed>|null $recoveryEvidence
*/
public static function triageTier(
?TenantBackupHealthAssessment $backupHealth,
?array $recoveryEvidence,
): int {
$backupPosture = self::backupPostureState($backupHealth);
$recoveryState = self::recoveryEvidenceState($recoveryEvidence);
return match (true) {
$backupPosture === TenantBackupHealthAssessment::POSTURE_ABSENT => 1,
$recoveryState === self::RECOVERY_EVIDENCE_WEAKENED => 2,
$backupPosture === TenantBackupHealthAssessment::POSTURE_STALE => 3,
$recoveryState === self::RECOVERY_EVIDENCE_UNVALIDATED => 4,
$backupPosture === TenantBackupHealthAssessment::POSTURE_DEGRADED => 5,
default => 6,
};
}
}

View File

@ -8,6 +8,7 @@
use App\Filament\Pages\ChooseTenant;
use App\Filament\Pages\TenantDashboard;
use App\Filament\Resources\FindingResource;
use App\Filament\Resources\TenantResource;
use App\Models\AlertDelivery;
use App\Models\FindingException;
use App\Models\OperationRun;
@ -18,6 +19,8 @@
use App\Services\Auth\WorkspaceCapabilityResolver;
use App\Services\Auth\WorkspaceRoleCapabilityMap;
use App\Support\Auth\Capabilities;
use App\Support\BackupHealth\TenantBackupHealthAssessment;
use App\Support\BackupHealth\TenantBackupHealthResolver;
use App\Support\Badges\BadgeDomain;
use App\Support\Badges\BadgeRenderer;
use App\Support\Baselines\BaselineCompareSummaryAssessment;
@ -28,6 +31,10 @@
use App\Support\OperationRunLinks;
use App\Support\OpsUx\OperationUxPresenter;
use App\Support\Rbac\UiTooltips;
use App\Support\RestoreSafety\RestoreResultAttention;
use App\Support\RestoreSafety\RestoreSafetyCopy;
use App\Support\RestoreSafety\RestoreSafetyResolver;
use App\Support\Tenants\TenantRecoveryTriagePresentation;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Collection;
@ -37,6 +44,8 @@ public function __construct(
private WorkspaceCapabilityResolver $workspaceCapabilityResolver,
private CapabilityResolver $capabilityResolver,
private TenantGovernanceAggregateResolver $tenantGovernanceAggregateResolver,
private TenantBackupHealthResolver $tenantBackupHealthResolver,
private RestoreSafetyResolver $restoreSafetyResolver,
) {}
/**
@ -62,6 +71,14 @@ public function build(Workspace $workspace, User $user): array
$tenantContexts,
static fn (array $context): bool => (bool) ($context['has_governance_attention'] ?? false),
));
$backupAttentionTenantCount = count(array_filter(
$tenantContexts,
static fn (array $context): bool => (bool) ($context['has_backup_attention'] ?? false),
));
$recoveryAttentionTenantCount = count(array_filter(
$tenantContexts,
static fn (array $context): bool => (bool) ($context['has_recovery_attention'] ?? false),
));
$totalProblemOperationsCount = array_sum(array_map(
static fn (array $context): int => (int) ($context['terminal_follow_up_operations_count'] ?? 0)
@ -92,6 +109,8 @@ public function build(Workspace $workspace, User $user): array
accessibleTenantCount: $accessibleTenants->count(),
attentionItems: $attentionItems,
governanceAttentionTenantCount: $governanceAttentionTenantCount,
backupAttentionTenantCount: $backupAttentionTenantCount,
recoveryAttentionTenantCount: $recoveryAttentionTenantCount,
totalProblemOperationsCount: $totalProblemOperationsCount,
totalActiveOperationsCount: $totalActiveOperationsCount,
totalAlertFailuresCount: $totalAlertFailuresCount,
@ -115,9 +134,13 @@ public function build(Workspace $workspace, User $user): array
$summaryMetrics = $this->summaryMetrics(
accessibleTenantCount: $accessibleTenants->count(),
governanceAttentionTenantCount: $governanceAttentionTenantCount,
backupAttentionTenantCount: $backupAttentionTenantCount,
recoveryAttentionTenantCount: $recoveryAttentionTenantCount,
totalActiveOperationsCount: $totalActiveOperationsCount,
totalAlertFailuresCount: $totalAlertFailuresCount,
canViewAlerts: $canViewAlerts,
tenantContexts: $tenantContexts,
user: $user,
navigationContext: $navigationContext,
);
@ -183,6 +206,12 @@ private function tenantContexts(Collection $accessibleTenants, int $workspaceId,
return [];
}
$backupHealthByTenant = $this->tenantBackupHealthResolver->assessMany($accessibleTenantIds);
$recoveryEvidenceByTenant = $this->restoreSafetyResolver->dashboardRecoveryEvidenceForTenants(
$accessibleTenantIds,
$backupHealthByTenant,
);
$terminalFollowUpCounts = $this->scopeToVisibleTenants(
OperationRun::query(),
$workspaceId,
@ -235,13 +264,66 @@ private function tenantContexts(Collection $accessibleTenants, int $workspaceId,
: [];
return $accessibleTenants
->map(function (Tenant $tenant) use ($terminalFollowUpCounts, $staleAttentionCounts, $activeOperationCounts, $alertFailureCounts): array {
->map(function (Tenant $tenant) use (
$terminalFollowUpCounts,
$staleAttentionCounts,
$activeOperationCounts,
$alertFailureCounts,
$backupHealthByTenant,
$recoveryEvidenceByTenant,
): array {
$tenantId = (int) $tenant->getKey();
$aggregate = $this->governanceAggregate($tenant);
$backupHealth = $backupHealthByTenant[$tenantId] ?? null;
$recoveryEvidence = $recoveryEvidenceByTenant[$tenantId] ?? null;
$recoveryOverviewState = is_array($recoveryEvidence)
? ($recoveryEvidence['overview_state'] ?? null)
: null;
$hasBackupAttention = $backupHealth instanceof TenantBackupHealthAssessment
&& in_array($backupHealth->posture, [
TenantBackupHealthAssessment::POSTURE_ABSENT,
TenantBackupHealthAssessment::POSTURE_STALE,
TenantBackupHealthAssessment::POSTURE_DEGRADED,
], true);
return [
'tenant' => $tenant,
'aggregate' => $aggregate,
'backup_health_assessment' => $backupHealth,
'backup_health_posture' => $backupHealth instanceof TenantBackupHealthAssessment
? $backupHealth->posture
: null,
'backup_health_reason' => $backupHealth instanceof TenantBackupHealthAssessment
? $backupHealth->primaryReason
: null,
'backup_health_headline' => $backupHealth instanceof TenantBackupHealthAssessment
? $backupHealth->headline
: null,
'backup_health_summary' => $backupHealth instanceof TenantBackupHealthAssessment
? $backupHealth->supportingMessage
: null,
'backup_health_boundary' => $backupHealth instanceof TenantBackupHealthAssessment
? $backupHealth->positiveClaimBoundary
: null,
'has_backup_attention' => $hasBackupAttention,
'recovery_evidence' => $recoveryEvidence,
'recovery_evidence_state' => is_string($recoveryOverviewState) ? $recoveryOverviewState : null,
'recovery_evidence_reason' => is_array($recoveryEvidence) && is_string($recoveryEvidence['reason'] ?? null)
? $recoveryEvidence['reason']
: null,
'recovery_evidence_headline' => is_array($recoveryEvidence) && is_string($recoveryEvidence['headline'] ?? null)
? $recoveryEvidence['headline']
: null,
'recovery_evidence_summary' => is_array($recoveryEvidence) && is_string($recoveryEvidence['summary'] ?? null)
? $recoveryEvidence['summary']
: null,
'recovery_evidence_boundary' => is_array($recoveryEvidence) && is_string($recoveryEvidence['claim_boundary'] ?? null)
? $recoveryEvidence['claim_boundary']
: null,
'latest_relevant_restore_run_id' => is_array($recoveryEvidence) && is_numeric($recoveryEvidence['latest_relevant_restore_run_id'] ?? null)
? (int) $recoveryEvidence['latest_relevant_restore_run_id']
: null,
'has_recovery_attention' => in_array($recoveryOverviewState, ['weakened', 'unvalidated'], true),
'has_governance_attention' => $this->hasGovernanceAttention($aggregate),
'terminal_follow_up_operations_count' => (int) ($terminalFollowUpCounts[$tenantId] ?? 0),
'stale_attention_operations_count' => (int) ($staleAttentionCounts[$tenantId] ?? 0),
@ -409,6 +491,14 @@ private function attentionItems(
)];
}
if (($backupHealthItem = $this->backupHealthAttentionItem($tenant, $context, $user)) !== null) {
return [$backupHealthItem];
}
if (($recoveryEvidenceItem = $this->recoveryEvidenceAttentionItem($tenant, $context, $user)) !== null) {
return [$recoveryEvidenceItem];
}
$items = [];
$terminalFollowUpOperationsCount = (int) ($context['terminal_follow_up_operations_count'] ?? 0);
@ -534,6 +624,11 @@ private function attentionPriority(array $item): int
'tenant_compare_attention' => 90,
'tenant_high_severity_findings' => 80,
'tenant_expiring_governance' => 70,
'tenant_backup_absent' => 65,
'tenant_recovery_weakened' => 60,
'tenant_backup_stale' => 58,
'tenant_recovery_unvalidated' => 55,
'tenant_backup_degraded' => 52,
'tenant_operations_stale_attention' => 45,
'tenant_operations_terminal_follow_up' => 40,
'tenant_active_operations' => 20,
@ -542,6 +637,133 @@ private function attentionPriority(array $item): int
};
}
/**
* @param array<string, mixed> $context
* @return array<string, mixed>|null
*/
private function backupHealthAttentionItem(Tenant $tenant, array $context, User $user): ?array
{
$assessment = $context['backup_health_assessment'] ?? null;
if (! $assessment instanceof TenantBackupHealthAssessment) {
return null;
}
if (! in_array($assessment->posture, [
TenantBackupHealthAssessment::POSTURE_ABSENT,
TenantBackupHealthAssessment::POSTURE_STALE,
TenantBackupHealthAssessment::POSTURE_DEGRADED,
], true)) {
return null;
}
$reasonContext = [
'family' => 'backup_health',
'state' => $assessment->posture,
'reason' => $assessment->primaryReason,
];
return $this->makeAttentionItem(
tenant: $tenant,
key: match ($assessment->posture) {
TenantBackupHealthAssessment::POSTURE_ABSENT => 'tenant_backup_absent',
TenantBackupHealthAssessment::POSTURE_STALE => 'tenant_backup_stale',
default => 'tenant_backup_degraded',
},
family: 'backup_health',
urgency: match ($assessment->posture) {
TenantBackupHealthAssessment::POSTURE_ABSENT => 'critical',
TenantBackupHealthAssessment::POSTURE_STALE => 'high',
default => 'medium',
},
title: $this->backupHealthAttentionTitle($assessment),
body: $assessment->supportingMessage ?? $assessment->headline,
badge: 'Backup health',
badgeColor: $assessment->tone(),
destination: $this->tenantDashboardTarget($tenant, $user),
supportingMessage: $assessment->positiveClaimBoundary,
reasonContext: $reasonContext,
);
}
/**
* @param array<string, mixed> $context
* @return array<string, mixed>|null
*/
private function recoveryEvidenceAttentionItem(Tenant $tenant, array $context, User $user): ?array
{
$recoveryEvidence = $context['recovery_evidence'] ?? null;
if (! is_array($recoveryEvidence)) {
return null;
}
$overviewState = is_string($recoveryEvidence['overview_state'] ?? null)
? $recoveryEvidence['overview_state']
: null;
if (! in_array($overviewState, ['weakened', 'unvalidated'], true)) {
return null;
}
$latestRelevantAttention = $recoveryEvidence['latest_relevant_attention'] ?? null;
$attentionState = $latestRelevantAttention instanceof RestoreResultAttention
? $latestRelevantAttention->state
: (is_string($recoveryEvidence['latest_relevant_attention_state'] ?? null)
? $recoveryEvidence['latest_relevant_attention_state']
: null);
$supportingParts = [
$latestRelevantAttention instanceof RestoreResultAttention
? RestoreSafetyCopy::primaryNextAction($latestRelevantAttention->primaryNextAction)
: null,
is_string($recoveryEvidence['claim_boundary'] ?? null) ? $recoveryEvidence['claim_boundary'] : null,
];
return $this->makeAttentionItem(
tenant: $tenant,
key: $overviewState === 'weakened'
? 'tenant_recovery_weakened'
: 'tenant_recovery_unvalidated',
family: 'recovery_evidence',
urgency: $overviewState === 'weakened' ? 'high' : 'medium',
title: $overviewState === 'unvalidated'
? 'Recovery evidence is unvalidated'
: $this->recoveryAttentionTitle($attentionState),
body: (string) ($recoveryEvidence['summary'] ?? 'Recent restore history weakens confidence.'),
badge: 'Recovery evidence',
badgeColor: $overviewState === 'weakened' && $attentionState === RestoreResultAttention::STATE_FAILED
? 'danger'
: 'warning',
destination: $this->tenantDashboardTarget($tenant, $user),
supportingMessage: trim(implode(' ', array_filter($supportingParts, static fn (?string $part): bool => filled($part)))),
reasonContext: [
'family' => 'recovery_evidence',
'state' => $overviewState,
'reason' => is_string($recoveryEvidence['reason'] ?? null) ? $recoveryEvidence['reason'] : null,
],
);
}
private function backupHealthAttentionTitle(TenantBackupHealthAssessment $assessment): string
{
return match ($assessment->primaryReason) {
TenantBackupHealthAssessment::REASON_NO_BACKUP_BASIS => 'No usable backup basis',
TenantBackupHealthAssessment::REASON_LATEST_BACKUP_STALE => 'Latest backup is stale',
TenantBackupHealthAssessment::REASON_LATEST_BACKUP_DEGRADED => 'Latest backup is degraded',
default => $assessment->headline,
};
}
private function recoveryAttentionTitle(?string $attentionState): string
{
return match ($attentionState) {
RestoreResultAttention::STATE_FAILED => 'Recent restore failed',
RestoreResultAttention::STATE_PARTIAL => 'Recent restore is partial',
RestoreResultAttention::STATE_COMPLETED_WITH_FOLLOW_UP => 'Recent restore needs follow-up',
default => 'Recent restore history weakens confidence',
};
}
/**
* @return array<string, mixed>
*/
@ -556,6 +778,7 @@ private function makeAttentionItem(
string $badgeColor,
array $destination,
?string $supportingMessage = null,
?array $reasonContext = null,
): array {
$item = [
'key' => $key,
@ -569,6 +792,7 @@ private function makeAttentionItem(
'supporting_message' => $supportingMessage,
'badge' => $badge,
'badge_color' => $badgeColor,
'reason_context' => $reasonContext,
'destination' => $destination,
'action_disabled' => (bool) ($destination['disabled'] ?? false),
'helper_text' => $destination['helper_text'] ?? null,
@ -585,9 +809,13 @@ private function makeAttentionItem(
private function summaryMetrics(
int $accessibleTenantCount,
int $governanceAttentionTenantCount,
int $backupAttentionTenantCount,
int $recoveryAttentionTenantCount,
int $totalActiveOperationsCount,
int $totalAlertFailuresCount,
bool $canViewAlerts,
array $tenantContexts,
User $user,
CanonicalNavigationContext $navigationContext,
): array {
$metrics = [
@ -615,6 +843,32 @@ private function summaryMetrics(
? $this->chooseTenantTarget('Choose tenant')
: null,
),
$this->makeSummaryMetric(
key: 'backup_attention_tenants',
label: 'Backup attention',
value: $backupAttentionTenantCount,
category: 'backup_health',
description: 'Visible tenants with non-healthy backup posture.',
color: $backupAttentionTenantCount > 0 ? 'danger' : 'gray',
destination: $this->attentionMetricDestination(
tenantContexts: $tenantContexts,
user: $user,
stateKey: 'has_backup_attention',
),
),
$this->makeSummaryMetric(
key: 'recovery_attention_tenants',
label: 'Recovery attention',
value: $recoveryAttentionTenantCount,
category: 'recovery_evidence',
description: 'Visible tenants with weakened or unvalidated recovery evidence.',
color: $recoveryAttentionTenantCount > 0 ? 'warning' : 'gray',
destination: $this->attentionMetricDestination(
tenantContexts: $tenantContexts,
user: $user,
stateKey: 'has_recovery_attention',
),
),
$this->makeSummaryMetric(
key: 'active_operations',
label: 'Active operations',
@ -671,6 +925,50 @@ private function makeSummaryMetric(
];
}
/**
* @param list<array<string, mixed>> $tenantContexts
*/
private function attentionMetricDestination(array $tenantContexts, User $user, string $stateKey): ?array
{
$affectedContexts = array_values(array_filter(
$tenantContexts,
static fn (array $context): bool => (bool) ($context[$stateKey] ?? false),
));
if ($affectedContexts === []) {
return null;
}
if (count($affectedContexts) > 1) {
return match ($stateKey) {
'has_backup_attention' => $this->filteredTenantRegistryTarget([
'backup_posture' => [
TenantBackupHealthAssessment::POSTURE_ABSENT,
TenantBackupHealthAssessment::POSTURE_STALE,
TenantBackupHealthAssessment::POSTURE_DEGRADED,
],
'triage_sort' => TenantRecoveryTriagePresentation::TRIAGE_SORT_WORST_FIRST,
], 'Choose tenant'),
'has_recovery_attention' => $this->filteredTenantRegistryTarget([
'recovery_evidence' => [
TenantRecoveryTriagePresentation::RECOVERY_EVIDENCE_WEAKENED,
TenantRecoveryTriagePresentation::RECOVERY_EVIDENCE_UNVALIDATED,
],
'triage_sort' => TenantRecoveryTriagePresentation::TRIAGE_SORT_WORST_FIRST,
], 'Choose tenant'),
default => $this->chooseTenantTarget('Choose tenant'),
};
}
$tenant = $affectedContexts[0]['tenant'] ?? null;
if (! $tenant instanceof Tenant) {
return null;
}
return $this->tenantDashboardTarget($tenant, $user);
}
/**
* @param array<int, int> $accessibleTenantIds
* @return list<array<string, mixed>>
@ -725,13 +1023,15 @@ private function calmnessState(
int $accessibleTenantCount,
array $attentionItems,
int $governanceAttentionTenantCount,
int $backupAttentionTenantCount,
int $recoveryAttentionTenantCount,
int $totalProblemOperationsCount,
int $totalActiveOperationsCount,
int $totalAlertFailuresCount,
bool $canViewAlerts,
CanonicalNavigationContext $navigationContext,
): array {
$checkedDomains = ['tenant_access', 'governance', 'findings', 'compare', 'operations'];
$checkedDomains = ['tenant_access', 'governance', 'findings', 'compare', 'backup_health', 'recovery_evidence', 'operations'];
if ($canViewAlerts) {
$checkedDomains[] = 'alerts';
@ -747,17 +1047,20 @@ private function calmnessState(
];
}
$hasPortfolioRecoveryAttention = $backupAttentionTenantCount > 0 || $recoveryAttentionTenantCount > 0;
$hasActivityAttention = $totalActiveOperationsCount > 0
|| $totalProblemOperationsCount > 0
|| ($canViewAlerts && $totalAlertFailuresCount > 0);
$isCalm = $governanceAttentionTenantCount === 0 && ! $hasActivityAttention;
$isCalm = $governanceAttentionTenantCount === 0
&& ! $hasPortfolioRecoveryAttention
&& ! $hasActivityAttention;
if ($isCalm) {
return [
'is_calm' => true,
'checked_domains' => $checkedDomains,
'title' => 'Nothing urgent in your visible workspace slice',
'body' => 'Visible governance, findings, compare posture, and activity currently look calm. Choose a tenant deliberately if you want to inspect one in more detail.',
'body' => 'Visible governance, backup health, recovery evidence, compare posture, and activity currently look calm. Backup health and recovery evidence are included in this visible workspace calmness check.',
'next_action' => $this->chooseTenantTarget(),
];
}
@ -767,7 +1070,7 @@ private function calmnessState(
'is_calm' => false,
'checked_domains' => $checkedDomains,
'title' => 'Workspace activity still needs review',
'body' => 'This workspace is not calm in the domains it can check right now, but your current scope does not expose a more specific tenant drill-through here. Review operations first.',
'body' => 'Backup health or recovery evidence still needs follow-up, or activity remains open in the visible workspace, but your current scope does not expose a more specific tenant drill-through here. Review operations first.',
'next_action' => $this->operationsIndexTarget(null, $navigationContext, 'active'),
];
}
@ -776,7 +1079,7 @@ private function calmnessState(
'is_calm' => false,
'checked_domains' => $checkedDomains,
'title' => 'Visible tenants still need attention',
'body' => 'Governance risk or execution follow-up is still present in this workspace.',
'body' => 'Backup health or recovery evidence still needs follow-up, or governance and activity remain open in this workspace.',
'next_action' => $attentionItems[0]['destination'] ?? $this->chooseTenantTarget(),
];
}
@ -908,6 +1211,20 @@ private function chooseTenantTarget(string $label = 'Choose tenant'): array
);
}
/**
* @param array<string, mixed> $filters
* @return array<string, mixed>
*/
private function filteredTenantRegistryTarget(array $filters, string $label = 'Choose tenant'): array
{
return $this->destination(
kind: 'choose_tenant',
url: $this->appendQuery(TenantResource::getUrl(panel: 'admin'), $filters),
label: $label,
filters: $filters,
);
}
/**
* @return array<string, mixed>
*/
@ -925,7 +1242,7 @@ private function switchWorkspaceTarget(string $label = 'Switch workspace'): arra
*/
private function tenantDashboardTarget(Tenant $tenant, User $user, string $label = 'Open tenant dashboard'): array
{
if (! $this->canTenantView($user, $tenant)) {
if (! $this->canAccessTenantDashboard($user, $tenant)) {
return $this->disabledDestination(
kind: 'tenant_dashboard',
label: $label,
@ -1058,6 +1375,11 @@ private function canTenantView(User $user, Tenant $tenant): bool
return $this->capabilityResolver->can($user, $tenant, Capabilities::TENANT_VIEW);
}
private function canAccessTenantDashboard(User $user, Tenant $tenant): bool
{
return $this->capabilityResolver->isMember($user, $tenant);
}
private function canOpenFindings(User $user, Tenant $tenant): bool
{
return $this->capabilityResolver->can($user, $tenant, Capabilities::TENANT_FINDINGS_VIEW);

View File

@ -26,7 +26,7 @@
</h1>
<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. Governance risk is ranked ahead of execution noise, and calm wording only appears when the checked workspace domains are genuinely quiet.
This home stays workspace-scoped even when you were previously working in a tenant. Governance risk is still ranked ahead of execution noise, backup health stays separate from recovery evidence, and calm wording only appears when visible tenants are genuinely quiet across the checked domains.
</p>
</div>
</x-filament::section>
@ -87,7 +87,10 @@ class="rounded-xl border border-gray-200 bg-white px-4 py-3 text-left transition
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
Backup health stays separate from recovery evidence
</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">
Calm wording stays bounded to visible tenants and checked domains
</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

View File

@ -7,6 +7,7 @@
use App\Models\BackupItem;
use App\Models\BackupSet;
use App\Models\RestoreRun;
use App\Support\RestoreSafety\RestoreSafetyResolver;
use Filament\Facades\Filament;
use Illuminate\Support\Facades\DB;
use Livewire\Livewire;
@ -68,6 +69,66 @@ function makeHealthyBackupForRecoveryPerformance(\App\Models\Tenant $tenant): Ba
->assertDontSee('Weakened');
});
it('keeps the latest-10 restore-history cap when recovery evidence is derived for multiple tenants in batch', function (): void {
[$user, $tenantA] = createUserWithTenant(role: 'owner', ensureDefaultMicrosoftProviderConnection: false);
$tenantB = \App\Models\Tenant::factory()->create([
'status' => 'active',
'workspace_id' => (int) $tenantA->workspace_id,
'name' => 'Second batch tenant',
]);
createUserWithTenant($tenantB, $user, role: 'owner', workspaceRole: 'owner', ensureDefaultMicrosoftProviderConnection: false);
makeHealthyBackupForRecoveryPerformance($tenantA);
makeHealthyBackupForRecoveryPerformance($tenantB);
$tenantABackup = BackupSet::factory()->for($tenantA)->create([
'name' => 'Tenant A candidate cap backup',
]);
foreach (range(1, 10) as $minutesAgo) {
RestoreRun::factory()
->for($tenantA)
->for($tenantABackup)
->previewOnly()
->create([
'created_at' => now()->subMinutes($minutesAgo),
'started_at' => now()->subMinutes($minutesAgo),
'completed_at' => null,
]);
}
RestoreRun::factory()
->for($tenantA)
->for($tenantABackup)
->failedOutcome()
->create([
'created_at' => now()->subMinutes(11),
'started_at' => now()->subMinutes(11),
'completed_at' => now()->subMinutes(11),
]);
$tenantBBackup = BackupSet::factory()->for($tenantB)->create([
'name' => 'Tenant B weakened backup',
]);
RestoreRun::factory()
->for($tenantB)
->for($tenantBBackup)
->completedWithFollowUp()
->create([
'completed_at' => now()->subMinutes(5),
]);
$evidence = app(RestoreSafetyResolver::class)->dashboardRecoveryEvidenceForTenants([
(int) $tenantA->getKey(),
(int) $tenantB->getKey(),
]);
expect($evidence[(int) $tenantA->getKey()]['overview_state'])->toBe('unvalidated')
->and($evidence[(int) $tenantB->getKey()]['overview_state'])->toBe('weakened');
});
it('renders dashboard recovery posture and restore-history list with bounded query volume', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'owner', ensureDefaultMicrosoftProviderConnection: false);
$this->actingAs($user);

View File

@ -4,8 +4,8 @@
use App\Filament\Pages\TenantDashboard;
use App\Filament\Resources\BackupSetResource;
use App\Filament\Widgets\Dashboard\DashboardKpis;
use App\Filament\Widgets\Dashboard\NeedsAttention;
use App\Filament\Widgets\Dashboard\RecoveryReadiness;
use App\Models\BackupItem;
use App\Models\BackupSet;
use App\Models\Finding;
@ -93,7 +93,7 @@
->assertSee(UiTooltips::INSUFFICIENT_PERMISSION)
->assertSee('Open latest backup');
Livewire::test(DashboardKpis::class)
Livewire::test(RecoveryReadiness::class)
->assertSee('Backup posture')
->assertSee('Stale');

View File

@ -13,6 +13,7 @@
use App\Models\Finding;
use App\Models\FindingException;
use App\Models\OperationRun;
use App\Models\RestoreRun;
use App\Support\Baselines\BaselineCompareReasonCode;
use App\Support\OperationRunOutcome;
use App\Support\OperationRunStatus;
@ -165,6 +166,14 @@ function seedTrustworthyCompare(array $tenantContext): void
'assignments' => [],
]);
RestoreRun::factory()
->for($tenant)
->for($healthyBackup)
->completedOutcome()
->create([
'completed_at' => now()->subMinutes(10),
]);
OperationRun::factory()->create([
'tenant_id' => (int) $tenant->getKey(),
'workspace_id' => (int) $tenant->workspace_id,
@ -303,6 +312,14 @@ function seedTrustworthyCompare(array $tenantContext): void
'assignments' => [],
]);
RestoreRun::factory()
->for($tenant)
->for($healthyBackup)
->completedOutcome()
->create([
'completed_at' => now()->subMinutes(10),
]);
Filament::setCurrentPanel(Filament::getPanel('tenant'));
Filament::setTenant($tenant, true);

View File

@ -0,0 +1,397 @@
<?php
declare(strict_types=1);
use App\Filament\Pages\TenantDashboard;
use App\Filament\Resources\TenantResource;
use App\Filament\Resources\TenantResource\Pages\ListTenants;
use App\Models\Policy;
use App\Models\Tenant;
use App\Models\User;
use App\Support\BackupHealth\BackupFreshnessEvaluation;
use App\Support\BackupHealth\BackupScheduleFollowUpEvaluation;
use App\Support\BackupHealth\TenantBackupHealthAssessment;
use App\Support\BackupHealth\TenantBackupHealthResolver;
use App\Support\RestoreSafety\RestoreSafetyResolver;
use App\Support\Workspaces\WorkspaceContext;
use Carbon\CarbonImmutable;
use Filament\Facades\Filament;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Livewire\Livewire;
use function Pest\Laravel\mock;
uses(RefreshDatabase::class);
afterEach(function (): void {
CarbonImmutable::setTestNow();
});
function tenantRegistryBaseContext(string $anchorName = 'Anchor Tenant'): array
{
$tenant = Tenant::factory()->create([
'status' => 'active',
'name' => $anchorName,
]);
[$user, $tenant] = createUserWithTenant(
tenant: $tenant,
role: 'owner',
workspaceRole: 'readonly',
);
return [$user, $tenant];
}
function tenantRegistryPeer(User $user, Tenant $workspaceTenant, string $name): Tenant
{
$tenant = Tenant::factory()->create([
'status' => 'active',
'workspace_id' => (int) $workspaceTenant->workspace_id,
'name' => $name,
]);
createUserWithTenant(
tenant: $tenant,
user: $user,
role: 'owner',
workspaceRole: 'readonly',
);
return $tenant;
}
function tenantRegistryList(Tenant $workspaceTenant, User $user, array $query = [])
{
test()->actingAs($user);
session()->put(WorkspaceContext::SESSION_KEY, (int) $workspaceTenant->workspace_id);
Filament::setTenant(null, true);
request()->attributes->remove('tenant_resource.posture_snapshot');
session()->forget('tables.'.md5(ListTenants::class).'_filters');
session()->forget('tables.'.md5(ListTenants::class).'_search');
session()->forget('tables.'.md5(ListTenants::class).'_sort');
$factory = $query !== []
? Livewire::withQueryParams($query)->actingAs($user)
: Livewire::actingAs($user);
return $factory->test(ListTenants::class);
}
function tenantRegistryBackupAssessment(
int $tenantId,
string $posture,
?string $reason = null,
?string $supportingMessage = null,
): TenantBackupHealthAssessment {
return new TenantBackupHealthAssessment(
tenantId: $tenantId,
posture: $posture,
primaryReason: $reason,
headline: str($posture)->headline()->toString(),
supportingMessage: $supportingMessage,
latestRelevantBackupSetId: null,
latestRelevantCompletedAt: now()->subMinutes(10),
qualitySummary: null,
freshnessEvaluation: new BackupFreshnessEvaluation(
latestCompletedAt: now()->subMinutes(10),
cutoffAt: now()->subHour(),
isFresh: true,
),
scheduleFollowUp: new BackupScheduleFollowUpEvaluation(
hasEnabledSchedules: false,
enabledScheduleCount: 0,
overdueScheduleCount: 0,
failedRecentRunCount: 0,
neverSuccessfulCount: 0,
needsFollowUp: false,
primaryScheduleId: null,
summaryMessage: null,
),
healthyClaimAllowed: $posture === TenantBackupHealthAssessment::POSTURE_HEALTHY,
primaryActionTarget: null,
positiveClaimBoundary: 'Recent backup history does not prove tenant recovery.',
);
}
function tenantRegistryRecoveryEvidence(
string $overviewState,
string $summary = 'Bounded recovery evidence summary.',
string $reason = 'no_recent_issues_visible',
): array {
return [
'overview_state' => $overviewState,
'summary' => $summary,
'claim_boundary' => 'Tenant-wide recovery is not proven.',
'reason' => $reason,
'latest_relevant_restore_run' => null,
'latest_relevant_attention' => null,
'latest_relevant_attention_state' => null,
];
}
it('shows separate backup posture and recovery evidence signals without turning metadata into recovery truth', function (): void {
CarbonImmutable::setTestNow(CarbonImmutable::create(2026, 4, 9, 9, 0, 0, 'UTC'));
[$user, $absentTenant] = tenantRegistryBaseContext('Absent Backup Tenant');
workspaceOverviewSeedQuietTenantTruth($absentTenant);
$weakenedTenant = tenantRegistryPeer($user, $absentTenant, 'Weakened Recovery Tenant');
workspaceOverviewSeedQuietTenantTruth($weakenedTenant);
$weakenedBackup = workspaceOverviewSeedHealthyBackup($weakenedTenant, [
'completed_at' => now()->subMinutes(20),
]);
workspaceOverviewSeedRestoreHistory($weakenedTenant, $weakenedBackup, 'follow_up');
$metadataTenant = tenantRegistryPeer($user, $absentTenant, 'Metadata Drift Tenant');
workspaceOverviewSeedQuietTenantTruth($metadataTenant);
$metadataBackup = workspaceOverviewSeedHealthyBackup($metadataTenant, [
'completed_at' => now()->subMinutes(15),
]);
workspaceOverviewSeedRestoreHistory($metadataTenant, $metadataBackup, 'completed');
Policy::factory()->for($metadataTenant)->create([
'display_name' => 'Stale sync policy',
'last_synced_at' => now()->subDays(14),
]);
$component = tenantRegistryList($absentTenant, $user)
->assertTableColumnExists('backup_posture')
->assertTableColumnExists('recovery_evidence')
->assertTableColumnVisible('backup_posture')
->assertTableColumnVisible('recovery_evidence')
->assertTableColumnFormattedStateSet('backup_posture', 'Absent', $absentTenant)
->assertTableColumnFormattedStateSet('recovery_evidence', 'Weakened', $weakenedTenant)
->assertTableColumnFormattedStateSet('backup_posture', 'Healthy', $metadataTenant)
->assertTableColumnFormattedStateSet('recovery_evidence', 'No recent issues visible', $metadataTenant)
->assertTableActionVisible('openTenant', $weakenedTenant)
->assertTableActionHasUrl('openTenant', TenantDashboard::getUrl(panel: 'tenant', tenant: $weakenedTenant), $weakenedTenant)
->assertDontSee('recoverable')
->assertDontSee('recovery proven')
->assertDontSee('validated overall');
expect($component->instance()->getTable()->getRecordUrl($weakenedTenant))
->toBe(TenantResource::getUrl('view', ['record' => $weakenedTenant], panel: 'admin'));
});
it('filters the registry to exact backup and recovery posture slices', function (): void {
CarbonImmutable::setTestNow(CarbonImmutable::create(2026, 4, 9, 9, 0, 0, 'UTC'));
[$user, $calmTenant] = tenantRegistryBaseContext('Calm Tenant');
workspaceOverviewSeedQuietTenantTruth($calmTenant);
$calmBackup = workspaceOverviewSeedHealthyBackup($calmTenant, [
'completed_at' => now()->subMinutes(10),
]);
workspaceOverviewSeedRestoreHistory($calmTenant, $calmBackup, 'completed');
$degradedTenant = tenantRegistryPeer($user, $calmTenant, 'Degraded Backup Tenant');
workspaceOverviewSeedQuietTenantTruth($degradedTenant);
$degradedBackup = workspaceOverviewSeedHealthyBackup($degradedTenant, [
'completed_at' => now()->subMinutes(12),
'item_count' => 2,
], [
'payload' => [],
'metadata' => [
'source' => 'metadata_only',
'assignments_fetch_failed' => true,
],
'assignments' => [],
]);
workspaceOverviewSeedRestoreHistory($degradedTenant, $degradedBackup, 'completed');
$unvalidatedTenant = tenantRegistryPeer($user, $calmTenant, 'Unvalidated Recovery Tenant');
workspaceOverviewSeedQuietTenantTruth($unvalidatedTenant);
workspaceOverviewSeedHealthyBackup($unvalidatedTenant, [
'completed_at' => now()->subMinutes(11),
]);
tenantRegistryList($calmTenant, $user)
->assertTableColumnFormattedStateSet('recovery_evidence', 'Unvalidated', $unvalidatedTenant);
$tenantResourceReflection = new ReflectionClass(TenantResource::class);
$postureSnapshot = $tenantResourceReflection->getMethod('postureSnapshot');
$postureSnapshot->setAccessible(true);
expect($postureSnapshot->invoke(null)['recovery_evidence_ids']['unvalidated'] ?? [])
->toBe([(int) $unvalidatedTenant->getKey()]);
$backupFiltered = tenantRegistryList($calmTenant, $user)
->filterTable('backup_posture', [TenantBackupHealthAssessment::POSTURE_DEGRADED]);
expect($backupFiltered->instance()->getFilteredTableQuery()?->pluck('tenants.name')->all())
->toBe(['Degraded Backup Tenant']);
$recoveryFiltered = tenantRegistryList($calmTenant, $user)
->filterTable('recovery_evidence', ['unvalidated'])
->assertSet('tableFilters.recovery_evidence.values', ['unvalidated']);
expect($recoveryFiltered->instance()->getFilteredTableQuery()?->pluck('tenants.name')->all())
->toBe(['Unvalidated Recovery Tenant']);
});
it('orders the visible tenant registry worst-first with stable tenant-name tie breaks when triage sorting is requested', function (): void {
CarbonImmutable::setTestNow(CarbonImmutable::create(2026, 4, 9, 9, 0, 0, 'UTC'));
[$user, $absentTenant] = tenantRegistryBaseContext('Absent Backup Tenant');
workspaceOverviewSeedQuietTenantTruth($absentTenant);
$alphaWeakenedTenant = tenantRegistryPeer($user, $absentTenant, 'Alpha Weakened Tenant');
workspaceOverviewSeedQuietTenantTruth($alphaWeakenedTenant);
$alphaWeakenedBackup = workspaceOverviewSeedHealthyBackup($alphaWeakenedTenant, [
'completed_at' => now()->subMinutes(20),
]);
workspaceOverviewSeedRestoreHistory($alphaWeakenedTenant, $alphaWeakenedBackup, 'follow_up');
$zetaWeakenedTenant = tenantRegistryPeer($user, $absentTenant, 'Zeta Weakened Tenant');
workspaceOverviewSeedQuietTenantTruth($zetaWeakenedTenant);
$zetaWeakenedBackup = workspaceOverviewSeedHealthyBackup($zetaWeakenedTenant, [
'completed_at' => now()->subMinutes(21),
]);
workspaceOverviewSeedRestoreHistory($zetaWeakenedTenant, $zetaWeakenedBackup, 'failed');
$staleTenant = tenantRegistryPeer($user, $absentTenant, 'Stale Backup Tenant');
workspaceOverviewSeedQuietTenantTruth($staleTenant);
$staleBackup = workspaceOverviewSeedHealthyBackup($staleTenant, [
'completed_at' => now()->subDays(2),
]);
workspaceOverviewSeedRestoreHistory($staleTenant, $staleBackup, 'completed');
$unvalidatedTenant = tenantRegistryPeer($user, $absentTenant, 'Unvalidated Recovery Tenant');
workspaceOverviewSeedQuietTenantTruth($unvalidatedTenant);
workspaceOverviewSeedHealthyBackup($unvalidatedTenant, [
'completed_at' => now()->subMinutes(18),
]);
$degradedTenant = tenantRegistryPeer($user, $absentTenant, 'Degraded Backup Tenant');
workspaceOverviewSeedQuietTenantTruth($degradedTenant);
$degradedBackup = workspaceOverviewSeedHealthyBackup($degradedTenant, [
'completed_at' => now()->subMinutes(17),
'item_count' => 2,
], [
'payload' => [],
'metadata' => [
'source' => 'metadata_only',
'assignments_fetch_failed' => true,
],
'assignments' => [],
]);
workspaceOverviewSeedRestoreHistory($degradedTenant, $degradedBackup, 'completed');
$calmTenant = tenantRegistryPeer($user, $absentTenant, 'Calm Tenant');
workspaceOverviewSeedQuietTenantTruth($calmTenant);
$calmBackup = workspaceOverviewSeedHealthyBackup($calmTenant, [
'completed_at' => now()->subMinutes(14),
]);
workspaceOverviewSeedRestoreHistory($calmTenant, $calmBackup, 'completed');
tenantRegistryList($absentTenant, $user, [
'triage_sort' => 'worst_first',
])
->assertSet('tableFilters.triage_sort.value', 'worst_first')
->assertCanSeeTableRecords([
$absentTenant,
$alphaWeakenedTenant,
$zetaWeakenedTenant,
$staleTenant,
$unvalidatedTenant,
$degradedTenant,
$calmTenant,
], inOrder: true);
});
it('loads backup posture and recovery evidence with one batch per registry render instead of per-row fanout', function (): void {
[$user, $firstTenant] = tenantRegistryBaseContext('Batch Tenant Alpha');
$secondTenant = tenantRegistryPeer($user, $firstTenant, 'Batch Tenant Beta');
$backupAssessments = [
(int) $firstTenant->getKey() => tenantRegistryBackupAssessment(
tenantId: (int) $firstTenant->getKey(),
posture: TenantBackupHealthAssessment::POSTURE_STALE,
reason: TenantBackupHealthAssessment::REASON_LATEST_BACKUP_STALE,
supportingMessage: 'The latest backup is outside the configured freshness window.',
),
(int) $secondTenant->getKey() => tenantRegistryBackupAssessment(
tenantId: (int) $secondTenant->getKey(),
posture: TenantBackupHealthAssessment::POSTURE_HEALTHY,
),
];
$expectedTenantIds = [
(int) $firstTenant->getKey(),
(int) $secondTenant->getKey(),
];
$backupResolver = new class($expectedTenantIds, $backupAssessments)
{
public int $assessManyCalls = 0;
/**
* @param list<int> $expectedTenantIds
* @param array<int, TenantBackupHealthAssessment> $assessments
*/
public function __construct(
private array $expectedTenantIds,
private array $assessments,
) {}
/**
* @return array<int, TenantBackupHealthAssessment>
*/
public function assessMany(iterable $tenantIds): array
{
$this->assessManyCalls++;
expect(array_values(is_array($tenantIds) ? $tenantIds : iterator_to_array($tenantIds, false)))
->toBe($this->expectedTenantIds);
return $this->assessments;
}
};
$restoreSafetyResolver = new class($expectedTenantIds, $backupAssessments)
{
public int $dashboardEvidenceCalls = 0;
/**
* @param list<int> $expectedTenantIds
* @param array<int, TenantBackupHealthAssessment> $expectedAssessments
*/
public function __construct(
private array $expectedTenantIds,
private array $expectedAssessments,
) {}
/**
* @param array<int, TenantBackupHealthAssessment> $resolvedAssessments
* @return array<int, array<string, mixed>>
*/
public function dashboardRecoveryEvidenceForTenants(array $tenantIds, array $resolvedAssessments): array
{
$this->dashboardEvidenceCalls++;
expect($tenantIds)->toBe($this->expectedTenantIds)
->and($resolvedAssessments)->toBe($this->expectedAssessments);
return [
$tenantIds[0] => tenantRegistryRecoveryEvidence(
overviewState: 'unvalidated',
summary: 'No recent restore history is available for this tenant.',
reason: 'no_history',
),
$tenantIds[1] => tenantRegistryRecoveryEvidence(
overviewState: 'no_recent_issues_visible',
),
];
}
};
app()->instance(TenantBackupHealthResolver::class, $backupResolver);
app()->instance(RestoreSafetyResolver::class, $restoreSafetyResolver);
tenantRegistryList($firstTenant, $user)
->assertCanSeeTableRecords([$firstTenant, $secondTenant])
->assertTableColumnFormattedStateSet('backup_posture', 'Stale', $firstTenant)
->assertTableColumnFormattedStateSet('recovery_evidence', 'No recent issues visible', $secondTenant);
expect($backupResolver->assessManyCalls)->toBe(1)
->and($restoreSafetyResolver->dashboardEvidenceCalls)->toBe(1);
});

View File

@ -2,11 +2,13 @@
declare(strict_types=1);
use App\Filament\Resources\TenantResource\Pages\ListTenants;
use App\Models\Tenant;
use App\Models\TenantMembership;
use App\Models\User;
use App\Models\Workspace;
use App\Models\WorkspaceMembership;
use App\Support\BackupHealth\TenantBackupHealthAssessment;
use App\Support\Workspaces\WorkspaceContext;
use Filament\Facades\Filament;
use Illuminate\Foundation\Testing\RefreshDatabase;
@ -105,3 +107,57 @@
->assertSet('tableSort', 'name:desc')
->assertSet('tableFilters.environment.value', 'prod');
});
it('keeps posture filters scoped to visible workspace tenants only', function (): void {
$visibleTenant = Tenant::factory()->create(['status' => 'active']);
[$user, $visibleTenant] = createUserWithTenant($visibleTenant, role: 'owner', workspaceRole: 'readonly');
workspaceOverviewSeedQuietTenantTruth($visibleTenant);
workspaceOverviewSeedHealthyBackup($visibleTenant, [
'completed_at' => now()->subMinutes(10),
]);
$hiddenRecoveryTenant = Tenant::factory()->create([
'status' => 'active',
'workspace_id' => (int) $visibleTenant->workspace_id,
'name' => 'Hidden Recovery Tenant',
]);
workspaceOverviewSeedQuietTenantTruth($hiddenRecoveryTenant);
workspaceOverviewSeedHealthyBackup($hiddenRecoveryTenant, [
'completed_at' => now()->subMinutes(9),
]);
$hiddenBackupTenant = Tenant::factory()->create([
'status' => 'active',
'workspace_id' => (int) $visibleTenant->workspace_id,
'name' => 'Hidden Degraded Tenant',
]);
workspaceOverviewSeedQuietTenantTruth($hiddenBackupTenant);
workspaceOverviewSeedHealthyBackup($hiddenBackupTenant, [
'completed_at' => now()->subMinutes(8),
], [
'payload' => [],
'metadata' => [
'source' => 'metadata_only',
'assignments_fetch_failed' => true,
],
'assignments' => [],
]);
$this->actingAs($user);
session()->put(WorkspaceContext::SESSION_KEY, (int) $visibleTenant->workspace_id);
Filament::setTenant(null, true);
$recoveryFiltered = Livewire::actingAs($user)
->test(ListTenants::class)
->filterTable('recovery_evidence', ['unvalidated']);
expect($recoveryFiltered->instance()->getFilteredTableQuery()?->pluck('tenants.name')->all())
->toBe([(string) $visibleTenant->name]);
$backupFiltered = Livewire::actingAs($user)
->test(ListTenants::class)
->filterTable('backup_posture', [TenantBackupHealthAssessment::POSTURE_DEGRADED]);
expect($backupFiltered->instance()->getFilteredTableQuery()?->pluck('tenants.name')->all())
->toBe([]);
});

View File

@ -3,9 +3,19 @@
declare(strict_types=1);
use App\Models\User;
use App\Models\Tenant;
use App\Models\Workspace;
use App\Models\WorkspaceMembership;
use App\Services\Auth\CapabilityResolver;
use App\Support\Auth\Capabilities;
use App\Support\Workspaces\WorkspaceContext;
use Carbon\CarbonImmutable;
use function Pest\Laravel\mock;
afterEach(function (): void {
CarbonImmutable::setTestNow();
});
it('returns 404 when the active workspace is outside the users membership scope', function (): void {
$user = User::factory()->create();
@ -28,6 +38,10 @@
[$user, $tenant] = createUserWithTenant($tenant, role: 'readonly', workspaceRole: 'readonly');
[$profile, $snapshot] = seedActiveBaselineForTenant($tenant);
seedBaselineCompareRun($tenant, $profile, $snapshot, workspaceOverviewCompareCoverage());
$backupSet = workspaceOverviewSeedHealthyBackup($tenant, [
'completed_at' => now()->subMinutes(10),
]);
workspaceOverviewSeedRestoreHistory($tenant, $backupSet, 'completed');
\App\Models\OperationRun::factory()->tenantlessForWorkspace($tenant->workspace()->firstOrFail())->create([
'status' => \App\Support\OperationRunStatus::Running->value,
@ -60,3 +74,112 @@
->and($overview['calmness']['next_action']['kind'])->toBe('switch_workspace')
->and($overview['attention_empty_state']['action_label'])->toBe('Switch workspace');
});
it('keeps single-tenant backup and recovery metric drill-through available when the tenant dashboard stays membership-accessible', function (): void {
CarbonImmutable::setTestNow(CarbonImmutable::create(2026, 4, 9, 9, 0, 0, 'UTC'));
$backupTenant = Tenant::factory()->create(['status' => 'active']);
[$user, $backupTenant] = createUserWithTenant($backupTenant, role: 'readonly', workspaceRole: 'readonly');
workspaceOverviewSeedQuietTenantTruth($backupTenant);
$backupTenantSet = workspaceOverviewSeedHealthyBackup($backupTenant, [
'completed_at' => now()->subDays(2),
]);
workspaceOverviewSeedRestoreHistory($backupTenant, $backupTenantSet, 'completed');
$recoveryTenant = Tenant::factory()->create([
'status' => 'active',
'workspace_id' => (int) $backupTenant->workspace_id,
'name' => 'Recovery Tenant',
]);
createUserWithTenant($recoveryTenant, $user, role: 'readonly', workspaceRole: 'readonly');
workspaceOverviewSeedQuietTenantTruth($recoveryTenant);
$recoveryBackup = workspaceOverviewSeedHealthyBackup($recoveryTenant, [
'completed_at' => now()->subMinutes(20),
]);
workspaceOverviewSeedRestoreHistory($recoveryTenant, $recoveryBackup, 'follow_up');
mock(CapabilityResolver::class, function ($mock): void {
$mock->shouldReceive('primeMemberships')->once();
$mock->shouldReceive('isMember')->andReturnTrue();
$mock->shouldReceive('can')
->andReturnUsing(static function ($user, Tenant $tenant, string $capability): bool {
return match ($capability) {
Capabilities::TENANT_VIEW => false,
default => false,
};
});
});
$overview = app(\App\Support\Workspaces\WorkspaceOverviewBuilder::class)
->build($backupTenant->workspace()->firstOrFail(), $user);
$metrics = collect($overview['summary_metrics'])->keyBy('key');
expect($metrics->get('backup_attention_tenants')['value'])->toBe(1)
->and($metrics->get('backup_attention_tenants')['destination']['kind'])->toBe('tenant_dashboard')
->and($metrics->get('backup_attention_tenants')['destination']['disabled'])->toBeFalse()
->and($metrics->get('backup_attention_tenants')['destination_url'])->toContain('/admin/t/')
->and($metrics->get('recovery_attention_tenants')['value'])->toBe(1)
->and($metrics->get('recovery_attention_tenants')['destination']['kind'])->toBe('tenant_dashboard')
->and($metrics->get('recovery_attention_tenants')['destination']['disabled'])->toBeFalse()
->and($metrics->get('recovery_attention_tenants')['destination_url'])->toContain('/admin/t/');
});
it('falls back to the visible tenant dashboard when hidden peers are excluded from backup and recovery metric drill-through', function (): void {
CarbonImmutable::setTestNow(CarbonImmutable::create(2026, 4, 9, 9, 0, 0, 'UTC'));
$visibleBackupTenant = Tenant::factory()->create(['status' => 'active']);
[$user, $visibleBackupTenant] = createUserWithTenant($visibleBackupTenant, role: 'readonly', workspaceRole: 'readonly');
workspaceOverviewSeedQuietTenantTruth($visibleBackupTenant);
$visibleBackupSet = workspaceOverviewSeedHealthyBackup($visibleBackupTenant, [
'completed_at' => now()->subDays(2),
]);
workspaceOverviewSeedRestoreHistory($visibleBackupTenant, $visibleBackupSet, 'completed');
$visibleRecoveryTenant = Tenant::factory()->create([
'status' => 'active',
'workspace_id' => (int) $visibleBackupTenant->workspace_id,
'name' => 'Visible Recovery Tenant',
]);
createUserWithTenant($visibleRecoveryTenant, $user, role: 'readonly', workspaceRole: 'readonly');
workspaceOverviewSeedQuietTenantTruth($visibleRecoveryTenant);
$visibleRecoveryBackup = workspaceOverviewSeedHealthyBackup($visibleRecoveryTenant, [
'completed_at' => now()->subMinutes(20),
]);
workspaceOverviewSeedRestoreHistory($visibleRecoveryTenant, $visibleRecoveryBackup, 'follow_up');
$hiddenBackupTenant = Tenant::factory()->create([
'status' => 'active',
'workspace_id' => (int) $visibleBackupTenant->workspace_id,
'name' => 'Hidden Backup Tenant',
]);
workspaceOverviewSeedQuietTenantTruth($hiddenBackupTenant);
$hiddenRecoveryTenant = Tenant::factory()->create([
'status' => 'active',
'workspace_id' => (int) $visibleBackupTenant->workspace_id,
'name' => 'Hidden Recovery Tenant',
]);
workspaceOverviewSeedQuietTenantTruth($hiddenRecoveryTenant);
$hiddenRecoveryBackup = workspaceOverviewSeedHealthyBackup($hiddenRecoveryTenant, [
'completed_at' => now()->subMinutes(18),
]);
workspaceOverviewSeedRestoreHistory($hiddenRecoveryTenant, $hiddenRecoveryBackup, 'follow_up');
mock(CapabilityResolver::class, function ($mock): void {
$mock->shouldReceive('primeMemberships')->once();
$mock->shouldReceive('isMember')->andReturnTrue();
$mock->shouldReceive('can')->andReturnFalse();
});
$metrics = collect(
app(\App\Support\Workspaces\WorkspaceOverviewBuilder::class)
->build($visibleBackupTenant->workspace()->firstOrFail(), $user)['summary_metrics'],
)->keyBy('key');
expect($metrics->get('backup_attention_tenants')['value'])->toBe(1)
->and($metrics->get('backup_attention_tenants')['destination']['kind'])->toBe('tenant_dashboard')
->and($metrics->get('backup_attention_tenants')['destination_url'])->toContain('/admin/t/')
->and($metrics->get('recovery_attention_tenants')['value'])->toBe(1)
->and($metrics->get('recovery_attention_tenants')['destination']['kind'])->toBe('tenant_dashboard')
->and($metrics->get('recovery_attention_tenants')['destination_url'])->toContain('/admin/t/');
});

View File

@ -8,8 +8,11 @@
it('shows workspace identity, summary cards, recent operations, and quick actions', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'owner');
[$profile, $snapshot] = seedActiveBaselineForTenant($tenant);
seedBaselineCompareRun($tenant, $profile, $snapshot, workspaceOverviewCompareCoverage());
workspaceOverviewSeedQuietTenantTruth($tenant);
$backupSet = workspaceOverviewSeedHealthyBackup($tenant, [
'completed_at' => now()->subMinutes(10),
]);
workspaceOverviewSeedRestoreHistory($tenant, $backupSet, 'completed');
OperationRun::factory()->create([
'tenant_id' => (int) $tenant->getKey(),
@ -26,6 +29,8 @@
->assertSee('Workspace overview')
->assertSee('Accessible tenants')
->assertSee('Governance attention')
->assertSee('Backup attention')
->assertSee('Recovery attention')
->assertSee('Active operations')
->assertSee('Needs attention')
->assertSee('Recent operations')
@ -34,6 +39,10 @@
->assertSee('Open alerts')
->assertSee('Review current and recent workspace-wide operations.')
->assertSee('Activity only. Active execution does not imply governance health.')
->assertSee('Visible tenants with non-healthy backup posture.')
->assertSee('Visible tenants with weakened or unvalidated recovery evidence.')
->assertSee('Governance risk counts affected tenants')
->assertSee('Backup health stays separate from recovery evidence')
->assertSee('Calm wording stays bounded to visible tenants and checked domains')
->assertSee('Inventory sync');
});

View File

@ -6,13 +6,19 @@
use App\Models\OperationRun;
use App\Models\Tenant;
use App\Support\Workspaces\WorkspaceContext;
use Carbon\CarbonImmutable;
use Illuminate\Support\Facades\DB;
afterEach(function (): void {
CarbonImmutable::setTestNow();
});
it('renders the workspace overview DB-only with bounded query volume for representative visible-tenant scenarios', function (): void {
CarbonImmutable::setTestNow(CarbonImmutable::create(2026, 4, 9, 9, 0, 0, 'UTC'));
$tenantA = Tenant::factory()->create(['status' => 'active']);
[$user, $tenantA] = createUserWithTenant($tenantA, role: 'owner', workspaceRole: 'readonly');
[$profileA, $snapshotA] = seedActiveBaselineForTenant($tenantA);
seedBaselineCompareRun($tenantA, $profileA, $snapshotA, workspaceOverviewCompareCoverage());
workspaceOverviewSeedQuietTenantTruth($tenantA);
Finding::factory()->for($tenantA)->create([
'workspace_id' => (int) $tenantA->workspace_id,
@ -26,14 +32,10 @@
'name' => 'Second Tenant',
]);
createUserWithTenant($tenantB, $user, role: 'owner', workspaceRole: 'readonly');
[$profileB, $snapshotB] = seedActiveBaselineForTenant($tenantB);
seedBaselineCompareRun(
$tenantB,
$profileB,
$snapshotB,
workspaceOverviewCompareCoverage(),
completedAt: now()->subDays(10),
);
workspaceOverviewSeedQuietTenantTruth($tenantB);
workspaceOverviewSeedHealthyBackup($tenantB, [
'completed_at' => now()->subDays(2),
]);
$tenantC = Tenant::factory()->create([
'status' => 'active',
@ -41,8 +43,11 @@
'name' => 'Third Tenant',
]);
createUserWithTenant($tenantC, $user, role: 'owner', workspaceRole: 'readonly');
[$profileC, $snapshotC] = seedActiveBaselineForTenant($tenantC);
seedBaselineCompareRun($tenantC, $profileC, $snapshotC, workspaceOverviewCompareCoverage());
workspaceOverviewSeedQuietTenantTruth($tenantC);
$tenantCBackup = workspaceOverviewSeedHealthyBackup($tenantC, [
'completed_at' => now()->subMinutes(20),
]);
workspaceOverviewSeedRestoreHistory($tenantC, $tenantCBackup, 'follow_up');
OperationRun::factory()->create([
'tenant_id' => (int) $tenantC->getKey(),
@ -61,6 +66,8 @@
->get('/admin')
->assertOk()
->assertSee('Governance attention')
->assertSee('Backup attention')
->assertSee('Recovery attention')
->assertSee('Recent operations');
});

View File

@ -2,8 +2,10 @@
declare(strict_types=1);
use App\Filament\Resources\TenantResource\Pages\ListTenants;
use App\Filament\Resources\EvidenceSnapshotResource;
use App\Filament\Resources\TenantReviewResource;
use App\Filament\Widgets\Dashboard\NeedsAttention as TenantNeedsAttention;
use App\Filament\Widgets\Workspace\WorkspaceNeedsAttention;
use App\Models\AlertDelivery;
use App\Models\Finding;
@ -12,14 +14,26 @@
use App\Support\OperationRunOutcome;
use App\Support\OperationRunStatus;
use App\Support\OperationRunType;
use App\Support\Tenants\TenantRecoveryTriagePresentation;
use App\Support\Workspaces\WorkspaceOverviewBuilder;
use App\Support\Workspaces\WorkspaceContext;
use Carbon\CarbonImmutable;
use Filament\Facades\Filament;
use Livewire\Livewire;
afterEach(function (): void {
CarbonImmutable::setTestNow();
});
it('preserves canonical findings, compare, alerts, and operations drill-through continuity from the workspace overview', function (): void {
$tenantDashboard = Tenant::factory()->create(['status' => 'active']);
[$user, $tenantDashboard] = createUserWithTenant($tenantDashboard, role: 'owner', workspaceRole: 'readonly');
[$dashboardProfile, $dashboardSnapshot] = seedActiveBaselineForTenant($tenantDashboard);
seedBaselineCompareRun($tenantDashboard, $dashboardProfile, $dashboardSnapshot, workspaceOverviewCompareCoverage());
$tenantDashboardBackup = workspaceOverviewSeedHealthyBackup($tenantDashboard, [
'completed_at' => now()->subMinutes(15),
]);
workspaceOverviewSeedRestoreHistory($tenantDashboard, $tenantDashboardBackup, 'completed');
Finding::factory()->riskAccepted()->create([
'workspace_id' => (int) $tenantDashboard->workspace_id,
@ -33,6 +47,10 @@
createUserWithTenant($tenantFindings, $user, role: 'owner', workspaceRole: 'readonly');
[$findingsProfile, $findingsSnapshot] = seedActiveBaselineForTenant($tenantFindings);
seedBaselineCompareRun($tenantFindings, $findingsProfile, $findingsSnapshot, workspaceOverviewCompareCoverage());
$tenantFindingsBackup = workspaceOverviewSeedHealthyBackup($tenantFindings, [
'completed_at' => now()->subMinutes(14),
]);
workspaceOverviewSeedRestoreHistory($tenantFindings, $tenantFindingsBackup, 'completed');
Finding::factory()->for($tenantFindings)->create([
'workspace_id' => (int) $tenantFindings->workspace_id,
@ -54,6 +72,10 @@
workspaceOverviewCompareCoverage(),
completedAt: now()->subDays(10),
);
$tenantCompareBackup = workspaceOverviewSeedHealthyBackup($tenantCompare, [
'completed_at' => now()->subMinutes(13),
]);
workspaceOverviewSeedRestoreHistory($tenantCompare, $tenantCompareBackup, 'completed');
$tenantOperations = Tenant::factory()->create([
'status' => 'active',
@ -63,6 +85,10 @@
createUserWithTenant($tenantOperations, $user, role: 'owner', workspaceRole: 'readonly');
[$operationsProfile, $operationsSnapshot] = seedActiveBaselineForTenant($tenantOperations);
seedBaselineCompareRun($tenantOperations, $operationsProfile, $operationsSnapshot, workspaceOverviewCompareCoverage());
$tenantOperationsBackup = workspaceOverviewSeedHealthyBackup($tenantOperations, [
'completed_at' => now()->subMinutes(12),
]);
workspaceOverviewSeedRestoreHistory($tenantOperations, $tenantOperationsBackup, 'completed');
OperationRun::factory()->create([
'tenant_id' => (int) $tenantOperations->getKey(),
@ -80,6 +106,10 @@
createUserWithTenant($tenantAlerts, $user, role: 'owner', workspaceRole: 'readonly');
[$alertsProfile, $alertsSnapshot] = seedActiveBaselineForTenant($tenantAlerts);
seedBaselineCompareRun($tenantAlerts, $alertsProfile, $alertsSnapshot, workspaceOverviewCompareCoverage());
$tenantAlertsBackup = workspaceOverviewSeedHealthyBackup($tenantAlerts, [
'completed_at' => now()->subMinutes(11),
]);
workspaceOverviewSeedRestoreHistory($tenantAlerts, $tenantAlertsBackup, 'completed');
AlertDelivery::factory()->create([
'tenant_id' => (int) $tenantAlerts->getKey(),
@ -177,3 +207,167 @@
->toContain($evidenceUrl)
->toContain($reviewUrl);
});
it('hydrates filtered tenant-registry triage state from multi-tenant workspace backup and recovery metrics', function (): void {
CarbonImmutable::setTestNow(CarbonImmutable::create(2026, 4, 9, 9, 0, 0, 'UTC'));
$absentTenant = Tenant::factory()->create([
'status' => 'active',
'name' => 'Absent Backup Tenant',
]);
[$user, $absentTenant] = createUserWithTenant($absentTenant, role: 'owner', workspaceRole: 'readonly');
workspaceOverviewSeedQuietTenantTruth($absentTenant);
$staleTenant = Tenant::factory()->create([
'status' => 'active',
'workspace_id' => (int) $absentTenant->workspace_id,
'name' => 'Stale Backup Tenant',
]);
createUserWithTenant($staleTenant, $user, role: 'owner', workspaceRole: 'readonly');
workspaceOverviewSeedQuietTenantTruth($staleTenant);
$staleBackup = workspaceOverviewSeedHealthyBackup($staleTenant, [
'completed_at' => now()->subDays(2),
]);
workspaceOverviewSeedRestoreHistory($staleTenant, $staleBackup, 'completed');
$degradedTenant = Tenant::factory()->create([
'status' => 'active',
'workspace_id' => (int) $absentTenant->workspace_id,
'name' => 'Degraded Backup Tenant',
]);
createUserWithTenant($degradedTenant, $user, role: 'owner', workspaceRole: 'readonly');
workspaceOverviewSeedQuietTenantTruth($degradedTenant);
$degradedBackup = workspaceOverviewSeedHealthyBackup($degradedTenant, [
'completed_at' => now()->subMinutes(20),
], [
'payload' => [],
'metadata' => [
'source' => 'metadata_only',
'assignments_fetch_failed' => true,
],
'assignments' => [],
]);
workspaceOverviewSeedRestoreHistory($degradedTenant, $degradedBackup, 'completed');
$weakenedTenant = Tenant::factory()->create([
'status' => 'active',
'workspace_id' => (int) $absentTenant->workspace_id,
'name' => 'Weakened Recovery Tenant',
]);
createUserWithTenant($weakenedTenant, $user, role: 'owner', workspaceRole: 'readonly');
workspaceOverviewSeedQuietTenantTruth($weakenedTenant);
$weakenedBackup = workspaceOverviewSeedHealthyBackup($weakenedTenant, [
'completed_at' => now()->subMinutes(18),
]);
workspaceOverviewSeedRestoreHistory($weakenedTenant, $weakenedBackup, 'follow_up');
$unvalidatedTenant = Tenant::factory()->create([
'status' => 'active',
'workspace_id' => (int) $absentTenant->workspace_id,
'name' => 'Unvalidated Recovery Tenant',
]);
createUserWithTenant($unvalidatedTenant, $user, role: 'owner', workspaceRole: 'readonly');
workspaceOverviewSeedQuietTenantTruth($unvalidatedTenant);
workspaceOverviewSeedHealthyBackup($unvalidatedTenant, [
'completed_at' => now()->subMinutes(16),
]);
$calmTenant = Tenant::factory()->create([
'status' => 'active',
'workspace_id' => (int) $absentTenant->workspace_id,
'name' => 'Calm Tenant',
]);
createUserWithTenant($calmTenant, $user, role: 'owner', workspaceRole: 'readonly');
workspaceOverviewSeedQuietTenantTruth($calmTenant);
$calmBackup = workspaceOverviewSeedHealthyBackup($calmTenant, [
'completed_at' => now()->subMinutes(14),
]);
workspaceOverviewSeedRestoreHistory($calmTenant, $calmBackup, 'completed');
$workspace = $absentTenant->workspace()->firstOrFail();
$metrics = collect(app(WorkspaceOverviewBuilder::class)->build($workspace, $user)['summary_metrics'])->keyBy('key');
$this->actingAs($user);
session()->put(WorkspaceContext::SESSION_KEY, (int) $workspace->getKey());
Filament::setTenant(null, true);
parse_str((string) parse_url((string) $metrics->get('backup_attention_tenants')['destination_url'], PHP_URL_QUERY), $backupQuery);
$backupRegistry = Livewire::withQueryParams($backupQuery)
->actingAs($user)
->test(ListTenants::class)
->assertSet('tableFilters.backup_posture.values', [
'absent',
'stale',
'degraded',
])
->assertSet('tableFilters.triage_sort.value', TenantRecoveryTriagePresentation::TRIAGE_SORT_WORST_FIRST);
expect($backupRegistry->instance()->getFilteredSortedTableQuery()?->pluck('tenants.name')->all())
->toBe([
'Absent Backup Tenant',
'Stale Backup Tenant',
'Degraded Backup Tenant',
]);
parse_str((string) parse_url((string) $metrics->get('recovery_attention_tenants')['destination_url'], PHP_URL_QUERY), $recoveryQuery);
$recoveryRegistry = Livewire::withQueryParams($recoveryQuery)
->actingAs($user)
->test(ListTenants::class)
->assertSet('tableFilters.recovery_evidence.values', [
'weakened',
'unvalidated',
])
->assertSet('tableFilters.triage_sort.value', TenantRecoveryTriagePresentation::TRIAGE_SORT_WORST_FIRST);
expect($recoveryRegistry->instance()->getFilteredSortedTableQuery()?->pluck('tenants.name')->all())
->toBe([
'Absent Backup Tenant',
'Weakened Recovery Tenant',
'Unvalidated Recovery Tenant',
]);
});
it('routes backup and recovery workspace attention into tenant dashboards that still show the same weakness', function (): void {
$backupTenant = Tenant::factory()->create([
'status' => 'active',
'name' => 'Backup Weak Tenant',
]);
[$user, $backupTenant] = createUserWithTenant($backupTenant, role: 'owner', workspaceRole: 'readonly');
workspaceOverviewSeedQuietTenantTruth($backupTenant);
$recoveryTenant = Tenant::factory()->create([
'status' => 'active',
'workspace_id' => (int) $backupTenant->workspace_id,
'name' => 'Recovery Weak Tenant',
]);
createUserWithTenant($recoveryTenant, $user, role: 'owner', workspaceRole: 'readonly');
workspaceOverviewSeedQuietTenantTruth($recoveryTenant);
$recoveryBackup = workspaceOverviewSeedHealthyBackup($recoveryTenant, [
'completed_at' => now()->subMinutes(20),
]);
workspaceOverviewSeedRestoreHistory($recoveryTenant, $recoveryBackup, 'follow_up');
$workspace = $backupTenant->workspace()->firstOrFail();
$overview = app(WorkspaceOverviewBuilder::class)->build($workspace, $user);
$items = collect($overview['attention_items'])->keyBy('key');
$backupDestination = $items->get('tenant_backup_absent')['destination'];
$recoveryDestination = $items->get('tenant_recovery_weakened')['destination'];
expect($backupDestination['kind'])->toBe('tenant_dashboard')
->and($recoveryDestination['kind'])->toBe('tenant_dashboard');
$this->actingAs($user);
Filament::setCurrentPanel(Filament::getPanel('tenant'));
Filament::setTenant($backupTenant, true);
Livewire::test(TenantNeedsAttention::class)
->assertSee('No usable backup basis');
Filament::setCurrentPanel(Filament::getPanel('tenant'));
Filament::setTenant($recoveryTenant, true);
Livewire::test(TenantNeedsAttention::class)
->assertSee('Recent restore needs follow-up');
});

View File

@ -8,6 +8,11 @@
use App\Models\Workspace;
use App\Models\WorkspaceMembership;
use App\Support\Workspaces\WorkspaceContext;
use Carbon\CarbonImmutable;
afterEach(function (): void {
CarbonImmutable::setTestNow();
});
it('renders intentional empty states when the workspace has no accessible tenant data', function (): void {
$user = User::factory()->create();
@ -52,15 +57,48 @@
});
it('renders the healthy calm state only when visible governance and activity are genuinely quiet', function (): void {
CarbonImmutable::setTestNow(CarbonImmutable::create(2026, 4, 9, 9, 0, 0, 'UTC'));
$tenant = Tenant::factory()->create(['status' => 'active']);
[$user, $tenant] = createUserWithTenant($tenant, role: 'owner', workspaceRole: 'readonly');
[$profile, $snapshot] = seedActiveBaselineForTenant($tenant);
seedBaselineCompareRun($tenant, $profile, $snapshot, workspaceOverviewCompareCoverage());
workspaceOverviewSeedQuietTenantTruth($tenant);
$backupSet = workspaceOverviewSeedHealthyBackup($tenant, [
'completed_at' => now()->subMinutes(10),
]);
workspaceOverviewSeedRestoreHistory($tenant, $backupSet, 'completed');
$this->actingAs($user)
->withSession([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id])
->get('/admin')
->assertOk()
->assertSee('Nothing urgent in your visible workspace slice')
->assertSee('Visible governance, findings, compare posture, and activity currently look calm.');
->assertSee('Visible governance, backup health, recovery evidence, compare posture, and activity currently look calm.')
->assertSee('Backup health and recovery evidence are included in this visible workspace calmness check.');
});
it('suppresses calmness when backup or recovery attention exists and keeps the checked domains explicit', function (): void {
CarbonImmutable::setTestNow(CarbonImmutable::create(2026, 4, 9, 9, 0, 0, 'UTC'));
$backupTenant = Tenant::factory()->create(['status' => 'active']);
[$user, $backupTenant] = createUserWithTenant($backupTenant, role: 'owner', workspaceRole: 'readonly');
workspaceOverviewSeedQuietTenantTruth($backupTenant);
$recoveryTenant = Tenant::factory()->create([
'status' => 'active',
'workspace_id' => (int) $backupTenant->workspace_id,
'name' => 'Recovery Tenant',
]);
createUserWithTenant($recoveryTenant, $user, role: 'owner', workspaceRole: 'readonly');
workspaceOverviewSeedQuietTenantTruth($recoveryTenant);
$recoveryBackup = workspaceOverviewSeedHealthyBackup($recoveryTenant, [
'completed_at' => now()->subMinutes(20),
]);
workspaceOverviewSeedRestoreHistory($recoveryTenant, $recoveryBackup, 'follow_up');
$overview = app(\App\Support\Workspaces\WorkspaceOverviewBuilder::class)
->build($backupTenant->workspace()->firstOrFail(), $user);
expect($overview['calmness']['is_calm'])->toBeFalse()
->and($overview['calmness']['checked_domains'])->toContain('backup_health', 'recovery_evidence')
->and($overview['calmness']['body'])->toContain('Backup health or recovery evidence still needs follow-up');
});

View File

@ -17,6 +17,10 @@
[$user, $tenantGovernance] = createUserWithTenant($tenantGovernance, role: 'owner', workspaceRole: 'readonly');
[$profile, $snapshot] = seedActiveBaselineForTenant($tenantGovernance);
seedBaselineCompareRun($tenantGovernance, $profile, $snapshot, workspaceOverviewCompareCoverage());
$tenantGovernanceBackup = workspaceOverviewSeedHealthyBackup($tenantGovernance, [
'completed_at' => now()->subMinutes(15),
]);
workspaceOverviewSeedRestoreHistory($tenantGovernance, $tenantGovernanceBackup, 'completed');
Finding::factory()->for($tenantGovernance)->create([
'workspace_id' => (int) $tenantGovernance->workspace_id,
@ -32,6 +36,10 @@
createUserWithTenant($tenantActivity, $user, role: 'owner', workspaceRole: 'readonly');
[$activityProfile, $activitySnapshot] = seedActiveBaselineForTenant($tenantActivity);
seedBaselineCompareRun($tenantActivity, $activityProfile, $activitySnapshot, workspaceOverviewCompareCoverage());
$tenantActivityBackup = workspaceOverviewSeedHealthyBackup($tenantActivity, [
'completed_at' => now()->subMinutes(14),
]);
workspaceOverviewSeedRestoreHistory($tenantActivity, $tenantActivityBackup, 'completed');
OperationRun::factory()->create([
'tenant_id' => (int) $tenantActivity->getKey(),
@ -49,6 +57,10 @@
createUserWithTenant($tenantAlerts, $user, role: 'owner', workspaceRole: 'readonly');
[$alertsProfile, $alertsSnapshot] = seedActiveBaselineForTenant($tenantAlerts);
seedBaselineCompareRun($tenantAlerts, $alertsProfile, $alertsSnapshot, workspaceOverviewCompareCoverage());
$tenantAlertsBackup = workspaceOverviewSeedHealthyBackup($tenantAlerts, [
'completed_at' => now()->subMinutes(13),
]);
workspaceOverviewSeedRestoreHistory($tenantAlerts, $tenantAlertsBackup, 'completed');
AlertDelivery::factory()->create([
'tenant_id' => (int) $tenantAlerts->getKey(),
@ -73,6 +85,10 @@
[$user, $tenantExpiring] = createUserWithTenant($tenantExpiring, role: 'owner', workspaceRole: 'readonly');
[$expiringProfile, $expiringSnapshot] = seedActiveBaselineForTenant($tenantExpiring);
seedBaselineCompareRun($tenantExpiring, $expiringProfile, $expiringSnapshot, workspaceOverviewCompareCoverage());
$tenantExpiringBackup = workspaceOverviewSeedHealthyBackup($tenantExpiring, [
'completed_at' => now()->subMinutes(15),
]);
workspaceOverviewSeedRestoreHistory($tenantExpiring, $tenantExpiringBackup, 'completed');
$finding = Finding::factory()->riskAccepted()->create([
'workspace_id' => (int) $tenantExpiring->workspace_id,
@ -112,6 +128,10 @@
workspaceOverviewCompareCoverage(),
completedAt: now()->subDays(10),
);
$tenantStaleBackup = workspaceOverviewSeedHealthyBackup($tenantStale, [
'completed_at' => now()->subMinutes(14),
]);
workspaceOverviewSeedRestoreHistory($tenantStale, $tenantStaleBackup, 'completed');
$workspace = $tenantExpiring->workspace()->firstOrFail();
$overview = app(WorkspaceOverviewBuilder::class)->build($workspace, $user);
@ -128,6 +148,10 @@
[$user, $tenantLapsed] = createUserWithTenant($tenantLapsed, role: 'owner', workspaceRole: 'readonly');
[$lapsedProfile, $lapsedSnapshot] = seedActiveBaselineForTenant($tenantLapsed);
seedBaselineCompareRun($tenantLapsed, $lapsedProfile, $lapsedSnapshot, workspaceOverviewCompareCoverage());
$tenantLapsedBackup = workspaceOverviewSeedHealthyBackup($tenantLapsed, [
'completed_at' => now()->subMinutes(15),
]);
workspaceOverviewSeedRestoreHistory($tenantLapsed, $tenantLapsedBackup, 'completed');
Finding::factory()->riskAccepted()->create([
'workspace_id' => (int) $tenantLapsed->workspace_id,
@ -148,6 +172,10 @@
workspaceOverviewCompareCoverage(),
outcome: OperationRunOutcome::Failed->value,
);
$tenantFailedBackup = workspaceOverviewSeedHealthyBackup($tenantFailedCompare, [
'completed_at' => now()->subMinutes(14),
]);
workspaceOverviewSeedRestoreHistory($tenantFailedCompare, $tenantFailedBackup, 'completed');
$tenantHighSeverity = Tenant::factory()->create([
'status' => 'active',
@ -157,6 +185,10 @@
createUserWithTenant($tenantHighSeverity, $user, role: 'owner', workspaceRole: 'readonly');
[$highProfile, $highSnapshot] = seedActiveBaselineForTenant($tenantHighSeverity);
seedBaselineCompareRun($tenantHighSeverity, $highProfile, $highSnapshot, workspaceOverviewCompareCoverage());
$tenantHighBackup = workspaceOverviewSeedHealthyBackup($tenantHighSeverity, [
'completed_at' => now()->subMinutes(13),
]);
workspaceOverviewSeedRestoreHistory($tenantHighSeverity, $tenantHighBackup, 'completed');
Finding::factory()->for($tenantHighSeverity)->create([
'workspace_id' => (int) $tenantHighSeverity->workspace_id,
@ -174,3 +206,66 @@
'tenant_high_severity_findings',
]);
});
it('keeps governance-first ordering while inserting backup and recovery attention ahead of activity-only items', function (): void {
$governanceTenant = Tenant::factory()->create(['status' => 'active']);
[$user, $governanceTenant] = createUserWithTenant($governanceTenant, role: 'owner', workspaceRole: 'readonly');
workspaceOverviewSeedQuietTenantTruth($governanceTenant);
$governanceBackup = workspaceOverviewSeedHealthyBackup($governanceTenant, [
'completed_at' => now()->subMinutes(15),
]);
workspaceOverviewSeedRestoreHistory($governanceTenant, $governanceBackup, 'completed');
Finding::factory()->for($governanceTenant)->create([
'workspace_id' => (int) $governanceTenant->workspace_id,
'status' => Finding::STATUS_TRIAGED,
'due_at' => now()->subDay(),
]);
$backupTenant = Tenant::factory()->create([
'status' => 'active',
'workspace_id' => (int) $governanceTenant->workspace_id,
'name' => 'Backup Tenant',
]);
createUserWithTenant($backupTenant, $user, role: 'owner', workspaceRole: 'readonly');
workspaceOverviewSeedQuietTenantTruth($backupTenant);
$recoveryTenant = Tenant::factory()->create([
'status' => 'active',
'workspace_id' => (int) $governanceTenant->workspace_id,
'name' => 'Recovery Tenant',
]);
createUserWithTenant($recoveryTenant, $user, role: 'owner', workspaceRole: 'readonly');
workspaceOverviewSeedQuietTenantTruth($recoveryTenant);
$recoveryBackup = workspaceOverviewSeedHealthyBackup($recoveryTenant, [
'completed_at' => now()->subMinutes(20),
]);
workspaceOverviewSeedRestoreHistory($recoveryTenant, $recoveryBackup, 'follow_up');
$operationsTenant = Tenant::factory()->create([
'status' => 'active',
'workspace_id' => (int) $governanceTenant->workspace_id,
'name' => 'Operations Tenant',
]);
createUserWithTenant($operationsTenant, $user, role: 'owner', workspaceRole: 'readonly');
workspaceOverviewSeedQuietTenantTruth($operationsTenant);
$operationsBackup = workspaceOverviewSeedHealthyBackup($operationsTenant, [
'completed_at' => now()->subMinutes(14),
]);
workspaceOverviewSeedRestoreHistory($operationsTenant, $operationsBackup, 'completed');
OperationRun::factory()->create([
'tenant_id' => (int) $operationsTenant->getKey(),
'workspace_id' => (int) $operationsTenant->workspace_id,
'type' => OperationRunType::InventorySync->value,
'status' => OperationRunStatus::Running->value,
'outcome' => OperationRunOutcome::Pending->value,
]);
$overview = app(WorkspaceOverviewBuilder::class)->build($governanceTenant->workspace()->firstOrFail(), $user);
$keys = collect($overview['attention_items'])->pluck('key')->all();
expect($keys[0])->toBe('tenant_overdue_findings')
->and(array_search('tenant_active_operations', $keys, true))->toBeGreaterThan(array_search('tenant_recovery_weakened', $keys, true))
->and(array_search('tenant_active_operations', $keys, true))->toBeGreaterThan(array_search('tenant_backup_absent', $keys, true));
});

View File

@ -6,9 +6,14 @@
use App\Services\Auth\CapabilityResolver;
use App\Support\Auth\Capabilities;
use App\Support\Workspaces\WorkspaceOverviewBuilder;
use Carbon\CarbonImmutable;
use function Pest\Laravel\mock;
afterEach(function (): void {
CarbonImmutable::setTestNow();
});
it('keeps switch workspace visible while hiding manage workspaces and unauthorized tenant counts for readonly members', function (): void {
$tenantA = Tenant::factory()->create(['status' => 'active']);
[$user, $tenantA] = createUserWithTenant($tenantA, role: 'owner', workspaceRole: 'readonly');
@ -32,8 +37,11 @@
it('keeps governance attention visible but non-clickable when the tenant membership does not grant drill-through capability', function (): void {
$tenant = Tenant::factory()->create(['status' => 'active']);
[$user, $tenant] = createUserWithTenant($tenant, role: 'readonly', workspaceRole: 'readonly');
[$profile, $snapshot] = seedActiveBaselineForTenant($tenant);
seedBaselineCompareRun($tenant, $profile, $snapshot, workspaceOverviewCompareCoverage());
workspaceOverviewSeedQuietTenantTruth($tenant);
$backupSet = workspaceOverviewSeedHealthyBackup($tenant, [
'completed_at' => now()->subMinutes(10),
]);
workspaceOverviewSeedRestoreHistory($tenant, $backupSet, 'completed');
\App\Models\Finding::factory()->for($tenant)->create([
'workspace_id' => (int) $tenant->workspace_id,
@ -62,3 +70,101 @@
->and($item['destination']['kind'])->toBe('tenant_findings')
->and($item['helper_text'])->not->toBeNull();
});
it('omits hidden-tenant backup and recovery issues from workspace counts and calmness claims', function (): void {
CarbonImmutable::setTestNow(CarbonImmutable::create(2026, 4, 9, 9, 0, 0, 'UTC'));
$visibleTenant = Tenant::factory()->create(['status' => 'active']);
[$user, $visibleTenant] = createUserWithTenant($visibleTenant, role: 'owner', workspaceRole: 'readonly');
workspaceOverviewSeedQuietTenantTruth($visibleTenant);
$visibleBackup = workspaceOverviewSeedHealthyBackup($visibleTenant, [
'completed_at' => now()->subMinutes(10),
]);
workspaceOverviewSeedRestoreHistory($visibleTenant, $visibleBackup, 'completed');
$hiddenBackupTenant = Tenant::factory()->create([
'status' => 'active',
'workspace_id' => (int) $visibleTenant->workspace_id,
'name' => 'Hidden Backup Tenant',
]);
workspaceOverviewSeedQuietTenantTruth($hiddenBackupTenant);
$hiddenRecoveryTenant = Tenant::factory()->create([
'status' => 'active',
'workspace_id' => (int) $visibleTenant->workspace_id,
'name' => 'Hidden Recovery Tenant',
]);
workspaceOverviewSeedQuietTenantTruth($hiddenRecoveryTenant);
$hiddenRecoveryBackup = workspaceOverviewSeedHealthyBackup($hiddenRecoveryTenant, [
'completed_at' => now()->subMinutes(20),
]);
workspaceOverviewSeedRestoreHistory($hiddenRecoveryTenant, $hiddenRecoveryBackup, 'follow_up');
$workspace = $visibleTenant->workspace()->firstOrFail();
$overview = app(WorkspaceOverviewBuilder::class)->build($workspace, $user);
$metrics = collect($overview['summary_metrics'])->keyBy('key');
expect($metrics->get('backup_attention_tenants')['value'])->toBe(0)
->and($metrics->get('recovery_attention_tenants')['value'])->toBe(0)
->and($overview['attention_items'])->toBe([])
->and($overview['calmness']['is_calm'])->toBeTrue()
->and($overview['calmness']['body'])->toContain('visible workspace')
->and(collect($overview['attention_items'])->pluck('tenant_label')->all())
->not->toContain('Hidden Backup Tenant', 'Hidden Recovery Tenant');
});
it('keeps backup and recovery items tenant-safe when the tenant dashboard remains membership-accessible', function (): void {
CarbonImmutable::setTestNow(CarbonImmutable::create(2026, 4, 9, 9, 0, 0, 'UTC'));
$backupTenant = Tenant::factory()->create(['status' => 'active']);
[$user, $backupTenant] = createUserWithTenant($backupTenant, role: 'readonly', workspaceRole: 'readonly');
workspaceOverviewSeedQuietTenantTruth($backupTenant);
$recoveryTenant = Tenant::factory()->create([
'status' => 'active',
'workspace_id' => (int) $backupTenant->workspace_id,
'name' => 'Recovery Tenant',
]);
createUserWithTenant($recoveryTenant, $user, role: 'readonly', workspaceRole: 'readonly');
workspaceOverviewSeedQuietTenantTruth($recoveryTenant);
$recoveryBackup = workspaceOverviewSeedHealthyBackup($recoveryTenant, [
'completed_at' => now()->subMinutes(20),
]);
workspaceOverviewSeedRestoreHistory($recoveryTenant, $recoveryBackup, 'follow_up');
mock(CapabilityResolver::class, function ($mock) use ($backupTenant, $recoveryTenant): void {
$mock->shouldReceive('primeMemberships')->once();
$mock->shouldReceive('isMember')
->andReturnUsing(static function ($user, Tenant $tenant) use ($backupTenant, $recoveryTenant): bool {
expect([(int) $backupTenant->getKey(), (int) $recoveryTenant->getKey()])
->toContain((int) $tenant->getKey());
return true;
});
$mock->shouldReceive('can')
->andReturnUsing(static function ($user, Tenant $tenant, string $capability) use ($backupTenant, $recoveryTenant): bool {
expect([(int) $backupTenant->getKey(), (int) $recoveryTenant->getKey()])
->toContain((int) $tenant->getKey());
return match ($capability) {
Capabilities::TENANT_VIEW => false,
default => false,
};
});
});
$workspace = $backupTenant->workspace()->firstOrFail();
$overview = app(WorkspaceOverviewBuilder::class)->build($workspace, $user);
$items = collect($overview['attention_items'])->keyBy('family');
expect($items->get('backup_health')['action_disabled'])->toBeFalse()
->and($items->get('backup_health')['destination']['kind'])->toBe('tenant_dashboard')
->and($items->get('backup_health')['destination']['disabled'])->toBeFalse()
->and($items->get('backup_health')['helper_text'])->toBeNull()
->and($items->get('backup_health')['url'])->toContain('/admin/t/')
->and($items->get('recovery_evidence')['action_disabled'])->toBeFalse()
->and($items->get('recovery_evidence')['destination']['kind'])->toBe('tenant_dashboard')
->and($items->get('recovery_evidence')['destination']['disabled'])->toBeFalse()
->and($items->get('recovery_evidence')['helper_text'])->toBeNull()
->and($items->get('recovery_evidence')['url'])->toContain('/admin/t/');
});

View File

@ -0,0 +1,103 @@
<?php
declare(strict_types=1);
use App\Models\Tenant;
use App\Support\Workspaces\WorkspaceOverviewBuilder;
use Carbon\CarbonImmutable;
afterEach(function (): void {
CarbonImmutable::setTestNow();
});
it('orders backup-health and recovery-evidence attention by severity and suppresses calm recovery history', function (): void {
CarbonImmutable::setTestNow(CarbonImmutable::create(2026, 4, 9, 9, 0, 0, 'UTC'));
$absentTenant = Tenant::factory()->create([
'status' => 'active',
'name' => 'Absent Backup Tenant',
]);
[$user, $absentTenant] = createUserWithTenant($absentTenant, role: 'owner', workspaceRole: 'readonly');
workspaceOverviewSeedQuietTenantTruth($absentTenant);
$staleTenant = Tenant::factory()->create([
'status' => 'active',
'workspace_id' => (int) $absentTenant->workspace_id,
'name' => 'Stale Backup Tenant',
]);
createUserWithTenant($staleTenant, $user, role: 'owner', workspaceRole: 'readonly');
workspaceOverviewSeedQuietTenantTruth($staleTenant);
workspaceOverviewSeedHealthyBackup($staleTenant, [
'name' => 'Stale backup',
'completed_at' => now()->subDays(2),
]);
$degradedTenant = Tenant::factory()->create([
'status' => 'active',
'workspace_id' => (int) $absentTenant->workspace_id,
'name' => 'Degraded Backup Tenant',
]);
createUserWithTenant($degradedTenant, $user, role: 'owner', workspaceRole: 'readonly');
workspaceOverviewSeedQuietTenantTruth($degradedTenant);
workspaceOverviewSeedHealthyBackup($degradedTenant, [
'name' => 'Degraded backup',
'completed_at' => now()->subMinutes(30),
], [
'payload' => [],
'metadata' => [
'source' => 'metadata_only',
'assignments_fetch_failed' => true,
],
'assignments' => [],
]);
$weakenedTenant = Tenant::factory()->create([
'status' => 'active',
'workspace_id' => (int) $absentTenant->workspace_id,
'name' => 'Weakened Recovery Tenant',
]);
createUserWithTenant($weakenedTenant, $user, role: 'owner', workspaceRole: 'readonly');
workspaceOverviewSeedQuietTenantTruth($weakenedTenant);
$weakenedBackup = workspaceOverviewSeedHealthyBackup($weakenedTenant, [
'name' => 'Weakened backup',
'completed_at' => now()->subMinutes(25),
]);
workspaceOverviewSeedRestoreHistory($weakenedTenant, $weakenedBackup, 'follow_up');
$unvalidatedTenant = Tenant::factory()->create([
'status' => 'active',
'workspace_id' => (int) $absentTenant->workspace_id,
'name' => 'Unvalidated Recovery Tenant',
]);
createUserWithTenant($unvalidatedTenant, $user, role: 'owner', workspaceRole: 'readonly');
workspaceOverviewSeedQuietTenantTruth($unvalidatedTenant);
workspaceOverviewSeedHealthyBackup($unvalidatedTenant, [
'name' => 'Unvalidated backup',
'completed_at' => now()->subMinutes(20),
]);
$calmTenant = Tenant::factory()->create([
'status' => 'active',
'workspace_id' => (int) $absentTenant->workspace_id,
'name' => 'Calm Tenant',
]);
createUserWithTenant($calmTenant, $user, role: 'owner', workspaceRole: 'readonly');
workspaceOverviewSeedQuietTenantTruth($calmTenant);
$calmBackup = workspaceOverviewSeedHealthyBackup($calmTenant, [
'name' => 'Calm backup',
'completed_at' => now()->subMinutes(15),
]);
workspaceOverviewSeedRestoreHistory($calmTenant, $calmBackup, 'completed');
$workspace = $absentTenant->workspace()->firstOrFail();
$overview = app(WorkspaceOverviewBuilder::class)->build($workspace, $user);
$items = collect($overview['attention_items']);
expect($items)->toHaveCount(5)
->and($items->where('family', 'backup_health')->pluck('reason_context.state')->values()->all())
->toBe(['absent', 'stale', 'degraded'])
->and($items->where('family', 'recovery_evidence')->pluck('reason_context.state')->values()->all())
->toBe(['weakened', 'unvalidated'])
->and($items->pluck('tenant_label')->all())->not->toContain((string) $calmTenant->name)
->and($items->pluck('reason_context.reason')->filter()->all())->not->toContain('no_recent_issues_visible');
});

View File

@ -8,6 +8,11 @@
use App\Models\OperationRun;
use App\Models\Tenant;
use App\Support\Workspaces\WorkspaceOverviewBuilder;
use Carbon\CarbonImmutable;
afterEach(function (): void {
CarbonImmutable::setTestNow();
});
it('counts governance attention by affected tenant instead of raw issue totals', function (): void {
$tenantOverdue = Tenant::factory()->create(['status' => 'active']);
@ -124,3 +129,116 @@
->and($metrics->get('alert_failures')['value'])->toBe(1)
->and($metrics->get('alert_failures')['category'])->toBe('alerts');
});
it('counts backup and recovery attention tenants separately and chooses precise or fallback destinations', function (): void {
CarbonImmutable::setTestNow(CarbonImmutable::create(2026, 4, 9, 9, 0, 0, 'UTC'));
$recoveryTenantA = Tenant::factory()->create([
'status' => 'active',
'name' => 'Recovery Tenant A',
]);
[$user, $recoveryTenantA] = createUserWithTenant($recoveryTenantA, role: 'owner', workspaceRole: 'readonly');
workspaceOverviewSeedQuietTenantTruth($recoveryTenantA);
$recoveryTenantABackup = workspaceOverviewSeedHealthyBackup($recoveryTenantA, [
'completed_at' => now()->subMinutes(20),
]);
workspaceOverviewSeedRestoreHistory($recoveryTenantA, $recoveryTenantABackup, 'follow_up');
$recoveryTenantB = Tenant::factory()->create([
'status' => 'active',
'workspace_id' => (int) $recoveryTenantA->workspace_id,
'name' => 'Recovery Tenant B',
]);
createUserWithTenant($recoveryTenantB, $user, role: 'owner', workspaceRole: 'readonly');
workspaceOverviewSeedQuietTenantTruth($recoveryTenantB);
$recoveryTenantBBackup = workspaceOverviewSeedHealthyBackup($recoveryTenantB, [
'completed_at' => now()->subMinutes(22),
]);
workspaceOverviewSeedRestoreHistory($recoveryTenantB, $recoveryTenantBBackup, 'failed');
$backupTenantA = Tenant::factory()->create([
'status' => 'active',
'workspace_id' => (int) $recoveryTenantA->workspace_id,
'name' => 'Backup Tenant A',
]);
createUserWithTenant($backupTenantA, $user, role: 'owner', workspaceRole: 'readonly');
workspaceOverviewSeedQuietTenantTruth($backupTenantA);
$backupTenantABackup = workspaceOverviewSeedHealthyBackup($backupTenantA, [
'completed_at' => now()->subDays(2),
]);
workspaceOverviewSeedRestoreHistory($backupTenantA, $backupTenantABackup, 'completed');
$backupTenantB = Tenant::factory()->create([
'status' => 'active',
'workspace_id' => (int) $recoveryTenantA->workspace_id,
'name' => 'Backup Tenant B',
]);
createUserWithTenant($backupTenantB, $user, role: 'owner', workspaceRole: 'readonly');
workspaceOverviewSeedQuietTenantTruth($backupTenantB);
$backupTenantBBackup = workspaceOverviewSeedHealthyBackup($backupTenantB, [
'completed_at' => now()->subMinutes(18),
], [
'payload' => [],
'metadata' => [
'source' => 'metadata_only',
'assignments_fetch_failed' => true,
],
'assignments' => [],
]);
workspaceOverviewSeedRestoreHistory($backupTenantB, $backupTenantBBackup, 'completed');
$calmTenant = Tenant::factory()->create([
'status' => 'active',
'workspace_id' => (int) $recoveryTenantA->workspace_id,
'name' => 'Calm Tenant',
]);
createUserWithTenant($calmTenant, $user, role: 'owner', workspaceRole: 'readonly');
workspaceOverviewSeedQuietTenantTruth($calmTenant);
$calmBackup = workspaceOverviewSeedHealthyBackup($calmTenant, [
'completed_at' => now()->subMinutes(15),
]);
workspaceOverviewSeedRestoreHistory($calmTenant, $calmBackup, 'completed');
$workspace = $recoveryTenantA->workspace()->firstOrFail();
$overview = app(WorkspaceOverviewBuilder::class)->build($workspace, $user);
$metrics = collect($overview['summary_metrics'])->keyBy('key');
$backupDestination = $metrics->get('backup_attention_tenants')['destination'];
$recoveryDestination = $metrics->get('recovery_attention_tenants')['destination'];
expect($metrics->get('backup_attention_tenants')['value'])->toBe(2)
->and($metrics->get('backup_attention_tenants')['category'])->toBe('backup_health')
->and($metrics->get('backup_attention_tenants')['destination']['kind'])->toBe('choose_tenant')
->and($metrics->get('backup_attention_tenants')['destination_url'])->toStartWith(\App\Filament\Resources\TenantResource::getUrl(panel: 'admin'))
->and($backupDestination['filters'])->toBe([
'backup_posture' => ['absent', 'stale', 'degraded'],
'triage_sort' => 'worst_first',
])
->and($metrics->get('recovery_attention_tenants')['value'])->toBe(2)
->and($metrics->get('recovery_attention_tenants')['category'])->toBe('recovery_evidence')
->and($recoveryDestination['kind'])->toBe('choose_tenant')
->and($metrics->get('recovery_attention_tenants')['destination_url'])->toStartWith(\App\Filament\Resources\TenantResource::getUrl(panel: 'admin'))
->and($recoveryDestination['filters'])->toBe([
'recovery_evidence' => ['weakened', 'unvalidated'],
'triage_sort' => 'worst_first',
]);
});
it('keeps backup and recovery attention counts at zero for calm visible tenants', function (): void {
CarbonImmutable::setTestNow(CarbonImmutable::create(2026, 4, 9, 9, 0, 0, 'UTC'));
$tenant = Tenant::factory()->create(['status' => 'active']);
[$user, $tenant] = createUserWithTenant($tenant, role: 'owner', workspaceRole: 'readonly');
workspaceOverviewSeedQuietTenantTruth($tenant);
$backupSet = workspaceOverviewSeedHealthyBackup($tenant, [
'completed_at' => now()->subMinutes(10),
]);
workspaceOverviewSeedRestoreHistory($tenant, $backupSet, 'completed');
$workspace = $tenant->workspace()->firstOrFail();
$overview = app(WorkspaceOverviewBuilder::class)->build($workspace, $user);
$metrics = collect($overview['summary_metrics'])->keyBy('key');
expect($metrics->get('backup_attention_tenants')['value'])->toBe(0)
->and($metrics->get('recovery_attention_tenants')['value'])->toBe(0)
->and($overview['calmness']['checked_domains'])->toContain('backup_health', 'recovery_evidence');
});

View File

@ -10,6 +10,7 @@
use App\Filament\Pages\Monitoring\Operations;
use App\Filament\Pages\NoAccess;
use App\Filament\Pages\Operations\TenantlessOperationRunViewer;
use App\Filament\Pages\TenantDashboard;
use App\Filament\Pages\Reviews\ReviewRegister;
use App\Filament\Pages\TenantDiagnostics;
use App\Filament\Pages\TenantRequiredPermissions;
@ -53,6 +54,7 @@
use App\Filament\Resources\ReviewPackResource\Pages\ListReviewPacks;
use App\Filament\Resources\TenantResource;
use App\Filament\Resources\TenantResource\Pages\ManageTenantMemberships;
use App\Filament\Resources\TenantResource\Pages\ListTenants;
use App\Filament\Resources\TenantResource\Pages\ViewTenant;
use App\Filament\Resources\TenantResource\RelationManagers\TenantMembershipsRelationManager;
use App\Filament\Resources\TenantReviewResource;
@ -564,6 +566,25 @@ function actionSurfaceSystemPanelContext(array $capabilities): PlatformUser
->assertSeeLivewire(TenantMembershipsRelationManager::class);
});
it('keeps the tenant registry action surface on row inspect plus one safe dashboard shortcut for active tenants', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'owner');
$this->actingAs($user);
Filament::setCurrentPanel('admin');
Filament::setTenant(null, true);
Filament::bootCurrentPanel();
session([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id]);
$list = Livewire::actingAs($user)
->test(ListTenants::class)
->assertTableActionVisible('openTenant', $tenant)
->assertTableActionHidden('related_onboarding', $tenant)
->assertTableActionHasUrl('openTenant', TenantDashboard::getUrl(panel: 'tenant', tenant: $tenant), $tenant);
expect($list->instance()->getTable()->getRecordUrl($tenant))
->toBe(TenantResource::getUrl('view', ['record' => $tenant], panel: 'admin'));
});
it('renders the backup items relation manager on the backup set detail page', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'owner');

View File

@ -7,7 +7,7 @@
it('keeps covered derived-state consumers on declared access paths without ad hoc caches', function (): void {
$root = SourceFileScanner::projectRoot();
$contractPath = $root.'/specs/167-derived-state-memoization/contracts/request-scoped-derived-state.logical.openapi.yaml';
$contractPath = repo_path('specs/167-derived-state-memoization/contracts/request-scoped-derived-state.logical.openapi.yaml');
/** @var array<string, mixed> $contract */
$contract = Yaml::parseFile($contractPath);

View File

@ -336,3 +336,42 @@
expect($missing)->toBeEmpty('Missing pagination profile helper usage: '.implode(', ', $missing));
});
it('keeps tenant-registry recovery triage columns, filters, and query hydration explicit', function (): void {
$patternByPath = [
'app/Filament/Resources/TenantResource.php' => [
"TextColumn::make('backup_posture')",
"TextColumn::make('recovery_evidence')",
"SelectFilter::make('backup_posture')",
"SelectFilter::make('recovery_evidence')",
"SelectFilter::make('triage_sort')",
'applyWorstFirstTriageOrdering',
'postureSnapshot',
],
'app/Filament/Resources/TenantResource/Pages/ListTenants.php' => [
'applyRequestedTriageIntent',
'getTableEmptyStateHeading',
'getTableEmptyStateDescription',
],
];
$missing = [];
foreach ($patternByPath as $relativePath => $patterns) {
$contents = file_get_contents(base_path($relativePath));
if (! is_string($contents)) {
$missing[] = $relativePath;
continue;
}
foreach ($patterns as $pattern) {
if (! str_contains($contents, $pattern)) {
$missing[] = "{$relativePath} ({$pattern})";
}
}
}
expect($missing)->toBeEmpty('Missing tenant registry triage table contracts: '.implode(', ', $missing));
});

View File

@ -3,11 +3,14 @@
use App\Models\BaselineProfile;
use App\Models\BaselineSnapshot;
use App\Models\BaselineTenantAssignment;
use App\Models\BackupItem;
use App\Models\BackupSet;
use App\Models\EvidenceSnapshot;
use App\Models\Finding;
use App\Models\OperationRun;
use App\Models\ProviderConnection;
use App\Models\ProviderCredential;
use App\Models\RestoreRun;
use App\Models\StoredReport;
use App\Models\Tenant;
use App\Models\TenantOnboardingSession;
@ -463,6 +466,66 @@ function workspaceOverviewCompareCoverage(): array
];
}
function workspaceOverviewSeedQuietTenantTruth(Tenant $tenant): void
{
[$profile, $snapshot] = seedActiveBaselineForTenant($tenant);
seedBaselineCompareRun($tenant, $profile, $snapshot, workspaceOverviewCompareCoverage());
}
/**
* @param array<string, mixed> $backupSetAttributes
* @param array<string, mixed> $itemAttributes
*/
function workspaceOverviewSeedHealthyBackup(
Tenant $tenant,
array $backupSetAttributes = [],
array $itemAttributes = [],
): BackupSet {
$backupSet = BackupSet::factory()
->for($tenant)
->create(array_merge([
'name' => 'Workspace overview healthy backup',
'item_count' => 1,
'completed_at' => now()->subMinutes(20),
], $backupSetAttributes));
BackupItem::factory()
->for($tenant)
->for($backupSet)
->create(array_merge([
'payload' => ['id' => 'workspace-overview-policy'],
'metadata' => [],
'assignments' => [],
], $itemAttributes));
return $backupSet;
}
/**
* @param array<string, mixed> $attributes
*/
function workspaceOverviewSeedRestoreHistory(
Tenant $tenant,
BackupSet $backupSet,
string $state = 'completed',
array $attributes = [],
): RestoreRun {
$factory = match ($state) {
'failed' => RestoreRun::factory()->failedOutcome(),
'partial' => RestoreRun::factory()->partialOutcome(),
'follow_up' => RestoreRun::factory()->completedWithFollowUp(),
default => RestoreRun::factory()->completedOutcome(),
};
return $factory
->for($tenant)
->for($backupSet)
->create(array_merge([
'completed_at' => now()->subMinutes(10),
], $attributes));
}
/**
* @return array{tenant: string}
*/

View File

@ -49,3 +49,31 @@ function useTemporaryPublicPath(): string
expect(PanelThemeAsset::resolve('resources/css/filament/admin/theme.css'))
->toEndWith('/build/assets/theme-test.css');
});
it('ignores the Vite hot file during tests and still resolves the built manifest asset', function (): void {
$publicPath = useTemporaryPublicPath();
File::ensureDirectoryExists($publicPath.'/build');
File::put($publicPath.'/hot', 'http://localhost:5173');
File::put(
$publicPath.'/build/manifest.json',
json_encode([
'resources/css/filament/admin/theme.css' => [
'file' => 'assets/theme-test.css',
],
], JSON_THROW_ON_ERROR),
);
expect(PanelThemeAsset::resolve('resources/css/filament/admin/theme.css'))
->toEndWith('/build/assets/theme-test.css')
->not->toContain(':5173');
});
it('returns null when the build manifest contains invalid json', function (): void {
$publicPath = useTemporaryPublicPath();
File::ensureDirectoryExists($publicPath.'/build');
File::put($publicPath.'/build/manifest.json', '{invalid-json');
expect(PanelThemeAsset::resolve('resources/css/filament/admin/theme.css'))->toBeNull();
});

View File

@ -0,0 +1,36 @@
# Specification Quality Checklist: Workspace Recovery Posture Visibility
**Purpose**: Validate specification completeness and quality before proceeding to planning
**Created**: 2026-04-09
**Feature**: [spec.md](../spec.md)
## Content Quality
- [x] No implementation details (languages, frameworks, APIs)
- [x] Focused on user value and business needs
- [x] Written for non-technical stakeholders
- [x] All mandatory sections completed
## Requirement Completeness
- [x] No [NEEDS CLARIFICATION] markers remain
- [x] Requirements are testable and unambiguous
- [x] Success criteria are measurable
- [x] Success criteria are technology-agnostic (no implementation details)
- [x] All acceptance scenarios are defined
- [x] Edge cases are identified
- [x] Scope is clearly bounded
- [x] Dependencies and assumptions identified
## Feature Readiness
- [x] All functional requirements have clear acceptance criteria
- [x] User scenarios cover primary flows
- [x] Feature meets measurable outcomes defined in Success Criteria
- [x] No implementation details leak into specification
## Notes
- Validation completed on 2026-04-09.
- The spec keeps workspace claims explicitly bounded to visible tenants and existing tenant-level backup and recovery truth.
- No clarification round is required before `/speckit.plan`.

View File

@ -0,0 +1,306 @@
openapi: 3.1.0
info:
title: Workspace Recovery Posture Visibility Internal Surface Contract
version: 0.1.0
summary: Internal logical contract for backup-health and recovery-evidence visibility on the workspace overview
description: |
This contract is an internal planning artifact for Spec 185. It documents how
the existing workspace overview must derive backup-health and recovery-evidence
metrics, attention items, calmness, and tenant-dashboard drillthroughs 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_backup_health_resolver
- restore_safety_resolver_dashboard_recovery_evidence
- existing_governance_metrics
- existing_activity_metrics
guardScope:
- app/Support/Workspaces/WorkspaceOverviewBuilder.php
- app/Filament/Widgets/Workspace/WorkspaceSummaryStats.php
expectedContract:
- backup_attention_and_recovery_attention_are_separate_metrics
- counts_are_visible_tenant_counts_not_raw_issue_totals
- single_tenant_metric_destinations_may_open_that_tenant_dashboard
- multi_tenant_metric_destinations_fall_back_to_choose_tenant
- surface: workspace.overview.needs_attention
summarySource:
- workspace_overview_builder
- tenant_backup_health_resolver
- restore_safety_resolver_dashboard_recovery_evidence
- existing_governance_attention
- existing_operations_attention
guardScope:
- app/Support/Workspaces/WorkspaceOverviewBuilder.php
- app/Filament/Widgets/Workspace/WorkspaceNeedsAttention.php
- resources/views/filament/widgets/workspace/workspace-needs-attention.blade.php
expectedContract:
- backup_health_and_recovery_evidence_items_are_tenant_bound
- new_items_use_tenant_dashboard_as_primary_destination
- backup_and_recovery_items_rank_above_activity_only_signals
- no_item_claims_workspace_recovery_is_proven
- hidden_tenants_never_leak_through_labels_or_reason_text
- surface: workspace.overview.calmness
summarySource:
- workspace_overview_builder
- tenant_backup_health_resolver
- restore_safety_resolver_dashboard_recovery_evidence
- existing_workspace_calmness_inputs
guardScope:
- app/Support/Workspaces/WorkspaceOverviewBuilder.php
- resources/views/filament/pages/workspace-overview.blade.php
expectedContract:
- checked_domains_include_backup_health_and_recovery_evidence
- calmness_is_false_when_any_visible_backup_or_recovery_issue_exists
- calmness_is_explicitly_bounded_to_visible_tenants
paths:
/admin:
get:
summary: Render the workspace overview with backup-health and recovery-evidence visibility
operationId: viewWorkspaceRecoveryPostureOverview
responses:
'200':
description: Workspace overview rendered with visible-tenant backup and recovery metrics, attention, and calmness semantics
content:
text/html:
schema:
type: string
application/vnd.tenantpilot.workspace-recovery-posture-visibility+json:
schema:
$ref: '#/components/schemas/WorkspaceRecoveryOverviewBundle'
'302':
description: No workspace context is active yet, so the request redirects to `/admin/choose-workspace`
'404':
description: Workspace is outside entitlement scope
/admin/choose-tenant:
get:
summary: Deliberate tenant-entry destination used by multi-tenant workspace backup or recovery metrics
operationId: openChooseTenantFromWorkspaceRecoveryOverview
responses:
'200':
description: Choose-tenant page opened inside the current workspace context so the operator can pick a tenant deliberately
/admin/t/{tenant}:
get:
summary: Canonical tenant-dashboard drillthrough for workspace backup or recovery posture items
operationId: openTenantDashboardFromWorkspaceRecoveryOverview
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
'404':
description: Tenant is outside entitlement scope
components:
schemas:
MetricCategory:
type: string
enum:
- scope
- governance_risk
- backup_health
- recovery_evidence
- activity
- alerts
WorkspaceMetricKey:
type: string
enum:
- accessible_tenants
- governance_attention_tenants
- backup_attention_tenants
- recovery_attention_tenants
- active_operations
- alert_failures
AttentionFamily:
type: string
enum:
- governance
- findings
- compare
- backup_health
- recovery_evidence
- operations
- alerts
- evidence
- review
AttentionUrgency:
type: string
enum:
- critical
- high
- medium
- supporting
DestinationKind:
type: string
enum:
- choose_tenant
- switch_workspace
- tenant_dashboard
- tenant_findings
- baseline_compare_landing
- operations_index
- alerts_overview
- tenant_evidence
- tenant_reviews
CalmnessCheckedDomain:
type: string
enum:
- tenant_access
- governance
- findings
- compare
- backup_health
- recovery_evidence
- operations
- alerts
WorkspaceRecoveryOverviewBundle:
type: object
required:
- accessible_tenant_count
- summary_metrics
- attention_items
- calmness
properties:
accessible_tenant_count:
type: integer
minimum: 0
summary_metrics:
type: array
items:
$ref: '#/components/schemas/WorkspaceSummaryMetric'
attention_items:
type: array
items:
$ref: '#/components/schemas/WorkspaceAttentionItem'
calmness:
$ref: '#/components/schemas/WorkspaceCalmness'
WorkspaceSummaryMetric:
type: object
required:
- key
- label
- value
- category
- description
properties:
key:
$ref: '#/components/schemas/WorkspaceMetricKey'
label:
type: string
value:
type: integer
minimum: 0
category:
$ref: '#/components/schemas/MetricCategory'
description:
type: string
destination:
anyOf:
- $ref: '#/components/schemas/Destination'
- type: 'null'
WorkspaceAttentionItem:
type: object
required:
- key
- tenant_id
- tenant_label
- family
- urgency
- title
- body
- destination
properties:
key:
type: string
tenant_id:
type: integer
tenant_label:
type: string
family:
$ref: '#/components/schemas/AttentionFamily'
urgency:
$ref: '#/components/schemas/AttentionUrgency'
title:
type: string
body:
type: string
supporting_message:
anyOf:
- type: string
- type: 'null'
reason_context:
anyOf:
- $ref: '#/components/schemas/ReasonContext'
- type: 'null'
destination:
$ref: '#/components/schemas/Destination'
ReasonContext:
type: object
required:
- family
- state
properties:
family:
$ref: '#/components/schemas/AttentionFamily'
state:
type: string
enum:
- absent
- stale
- degraded
- weakened
- unvalidated
reason:
anyOf:
- type: string
- type: 'null'
Destination:
type: object
required:
- kind
- label
- disabled
properties:
kind:
$ref: '#/components/schemas/DestinationKind'
label:
type: string
url:
anyOf:
- type: string
- type: 'null'
disabled:
type: boolean
helper_text:
anyOf:
- type: string
- type: 'null'
WorkspaceCalmness:
type: object
required:
- is_calm
- checked_domains
- title
- body
- next_action
properties:
is_calm:
type: boolean
checked_domains:
type: array
items:
$ref: '#/components/schemas/CalmnessCheckedDomain'
title:
type: string
body:
type: string
next_action:
$ref: '#/components/schemas/Destination'

View File

@ -0,0 +1,167 @@
# Data Model: Workspace Recovery Posture Visibility
## Existing Source Truth Models
### TenantBackupHealthAssessment
Existing derived tenant-level backup-input assessment from `TenantBackupHealthResolver`.
| Field | Type | Meaning |
|------|------|---------|
| `tenantId` | integer | Tenant scope for the assessment |
| `posture` | string | `absent`, `stale`, `degraded`, or `healthy` backup-input posture |
| `primaryReason` | string nullable | Why the current backup posture is not calmly healthy |
| `headline` | string | Operator-facing headline for the backup truth |
| `supportingMessage` | string nullable | Supporting backup-health explanation |
| `healthyClaimAllowed` | boolean | Whether a positive backup-health statement is allowed |
| `primaryActionTarget` | action target nullable | Canonical tenant-local backup follow-up target |
| `positiveClaimBoundary` | string | Canonical statement that backup health reflects backup inputs only and does not prove restore success |
### Dashboard Recovery Evidence Projection
Existing tenant-level recovery-evidence projection from `RestoreSafetyResolver::dashboardRecoveryEvidence()`.
| Field | Type | Meaning |
|------|------|---------|
| `backup_posture` | string | Current tenant backup posture carried through for bounded recovery copy |
| `overview_state` | string | `unvalidated`, `weakened`, or `no_recent_issues_visible` |
| `headline` | string | Operator-facing headline for tenant recovery evidence |
| `summary` | string | Supporting explanation of the current evidence state |
| `claim_boundary` | string | Text that prevents the summary from becoming a recovery-proof claim |
| `latest_relevant_restore_run_id` | integer nullable | Most relevant executed restore run for continuity |
| `latest_relevant_attention_state` | string nullable | Existing `RestoreResultAttention` state for the relevant run |
| `reason` | string | Reason key such as `no_history`, `failed`, `partial`, `completed_with_follow_up`, or `no_recent_issues_visible` |
### Existing Workspace Overview Output Contracts
`WorkspaceOverviewBuilder` already emits derived `summary_metrics`, `attention_items`, and `calmness` for `/admin`.
| Projection | Existing role |
|-----------|---------------|
| `summary_metrics` | Workspace scan strip for scope, governance, activity, and alerts |
| `attention_items` | Bounded, prioritized tenant- or workspace-bound triage list |
| `calmness` | Honest empty-state and “nothing urgent” contract for the visible workspace slice |
## New Derived Projections For Spec 185
Spec 185 adds **derived** workspace projections only. No migration or persisted model is introduced.
### VisibleWorkspaceTenantRecoveryContext
Per-visible-tenant context carried inside `WorkspaceOverviewBuilder` before widget rendering.
| Field | Type | Persisted | Meaning |
|------|------|-----------|---------|
| `tenantId` | integer | no | Visible tenant identity |
| `tenantLabel` | string | no | Tenant name shown on workspace surfaces |
| `tenantRouteKey` | string | no | Tenant route identity for drillthrough |
| `backupHealthPosture` | string | no | Existing tenant backup-health posture |
| `backupHealthReason` | string nullable | no | Existing tenant backup-health primary reason |
| `backupHealthHeadline` | string | no | Backup-health headline for workspace reason text |
| `backupHealthSummary` | string nullable | no | Supporting backup-health explanation |
| `backupHealthBoundary` | string | no | Backup-input claim boundary inherited from tenant truth |
| `recoveryEvidenceState` | string | no | Existing tenant recovery-evidence overview state |
| `recoveryEvidenceReason` | string | no | Existing tenant recovery-evidence reason key |
| `recoveryEvidenceHeadline` | string | no | Recovery-evidence headline for workspace reason text |
| `recoveryEvidenceSummary` | string | no | Supporting recovery-evidence explanation |
| `recoveryEvidenceBoundary` | string | no | Recovery claim boundary inherited from tenant truth |
| `latestRelevantRestoreRunId` | integer nullable | no | Latest relevant restore run for continuity if needed later |
| `hasBackupAttention` | boolean | no | True when backup posture is `absent`, `stale`, or `degraded` |
| `hasRecoveryAttention` | boolean | no | True when recovery evidence is `weakened` or `unvalidated` |
| `workspacePrimaryDestination` | destination | no | Primary tenant-dashboard drillthrough payload |
### WorkspaceRecoverySummaryMetric
Derived stat-strip metric for cross-tenant recovery and backup visibility.
| Field | Type | Persisted | Meaning |
|------|------|-----------|---------|
| `key` | string | no | `backup_attention_tenants` or `recovery_attention_tenants` |
| `label` | string | no | Operator-facing metric label |
| `value` | integer | no | Count of visible tenants needing follow-up in that family |
| `category` | string | no | Distinct metric category such as `backup_health` or `recovery_evidence` |
| `description` | string | no | Bounded explanation of what the count means |
| `destination` | destination nullable | no | `tenant_dashboard` when exactly one visible tenant is affected, otherwise `choose_tenant` |
### WorkspaceRecoveryAttentionItem
Derived workspace triage item for one visible tenant backup or recovery weakness.
| Field | Type | Persisted | Meaning |
|------|------|-----------|---------|
| `key` | string | no | Stable item key such as `tenant_backup_absent` or `tenant_recovery_weakened` |
| `family` | string | no | `backup_health` or `recovery_evidence` |
| `urgency` | string | no | Relative severity tier used inside workspace ordering |
| `tenant_id` | integer | no | Visible tenant identity |
| `tenant_label` | string | no | Visible tenant label |
| `title` | string | no | Bounded item title |
| `body` | string | no | Short reason text explaining the weakness |
| `supporting_message` | string nullable | no | Optional claim boundary or supplemental follow-up explanation |
| `badge` | string | no | Family badge label |
| `badge_color` | string | no | Existing shared tone mapping |
| `reason_context` | object | no | `{ family, state, reason }` payload for tests and future continuity |
| `destination` | destination | no | Primary tenant-dashboard drillthrough or safe disabled state |
### WorkspaceRecoveryCalmnessContract
Derived calmness state with explicit domain coverage.
| Field | Type | Persisted | Meaning |
|------|------|-----------|---------|
| `is_calm` | boolean | no | True only when covered domains are quiet for visible tenants |
| `checked_domains` | list<string> | no | Must now include `backup_health` and `recovery_evidence` |
| `title` | string | no | Calm or non-calm summary title |
| `body` | string | no | Bounded explanation that names the covered domains |
| `next_action` | destination | no | Tenant dashboard, choose-tenant, switch-workspace, or existing workspace action target |
## Derived State Rules
### Backup Attention Eligibility
| Backup posture | Workspace backup attention? | Notes |
|---------------|-----------------------------|-------|
| `absent` | yes | Highest backup-health severity |
| `stale` | yes | Middle backup-health severity |
| `degraded` | yes | Lowest backup-health severity that still needs attention |
| `healthy` | no | Schedule follow-up may still exist at tenant level, but Spec 185 counts only non-healthy backup posture for workspace backup attention |
### Recovery Attention Eligibility
| Recovery state | Workspace recovery attention? | Notes |
|---------------|-------------------------------|-------|
| `weakened` | yes | Highest recovery-evidence severity |
| `unvalidated` | yes | Lower than `weakened`, but still attention-worthy |
| `no_recent_issues_visible` | no | Calm recovery evidence still carries a non-proof boundary and must not become a problem item |
### Attention Ordering Consequences
| Derived family | Internal order |
|---------------|----------------|
| `backup_health` | `absent``stale``degraded` |
| `recovery_evidence` | `weakened``unvalidated` |
Cross-family ordering remains integrated into the existing workspace priority model. The new families must rank above activity-only operations and alerts while preserving the current governance-first intent of the queue.
## Invariants
- All new workspace recovery and backup projections are derived at render time and are not persisted.
- All counts and items are computed only from visible tenants in the active workspace scope.
- Backup health and recovery evidence remain separate fields and separate families in every derived workspace structure.
- Any calm workspace statement is bounded to visible tenants and covered domains only.
- The tenant dashboard is the canonical destination for new workspace backup or recovery items; deeper tenant backup-set or restore-run pages remain secondary follow-up surfaces.
- No derived workspace projection may claim recovery proof or restore guarantee.
## Relationships
| Source | Relationship | Target | Use in this spec |
|------|--------------|--------|------------------|
| Tenant | has one derived | `TenantBackupHealthAssessment` | Source of backup posture for workspace aggregation |
| Tenant | has one derived | dashboard recovery evidence projection | Source of recovery-evidence state for workspace aggregation |
| Workspace overview | derives many | `VisibleWorkspaceTenantRecoveryContext` | Per-tenant visible context used by stats, attention, and calmness |
| Workspace overview | derives many | `WorkspaceRecoverySummaryMetric` | Separate backup and recovery portfolio counts |
| Workspace overview | derives many | `WorkspaceRecoveryAttentionItem` | Prioritized tenant-level triage items |
| Workspace overview | derives one | `WorkspaceRecoveryCalmnessContract` | Honest calmness and checked-domain statement |
## No Persistence Changes
Spec 185 introduces no new table, no new column, no new materialized view, no new cache artifact, and no migration. All new structures are transient builder- or widget-level projections over existing tenant truth.

View File

@ -0,0 +1,288 @@
# Implementation Plan: Workspace Recovery Posture Visibility
**Branch**: `185-workspace-recovery-posture-visibility` | **Date**: 2026-04-09 | **Spec**: `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/specs/185-workspace-recovery-posture-visibility/spec.md`
**Input**: Feature specification from `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/specs/185-workspace-recovery-posture-visibility/spec.md`
**Note**: This plan keeps workspace recovery posture derived from existing tenant backup-health and recovery-evidence truth. It hardens `/admin` for workspace-first triage without introducing a new recovery-confidence engine, a new persisted workspace posture model, or a broad IA rewrite.
## Summary
Promote the existing tenant-level backup-health and recovery-evidence truth onto `/admin` so a workspace operator can answer, in one scan, how many visible tenants need backup follow-up, how many need recovery-evidence follow-up, which tenant should be opened first, and whether workspace calmness includes those domains. The implementation will keep `WorkspaceOverviewBuilder` as the orchestration boundary, reuse `TenantBackupHealthResolver` and `RestoreSafetyResolver::dashboardRecoveryEvidence()` as the source-of-truth seams, add batch-friendly visible-tenant derivation to avoid uncontrolled per-tenant query fanout, extend `WorkspaceSummaryStats` and `WorkspaceNeedsAttention` with separate backup and recovery signals, and route new workspace drillthroughs to the affected tenant dashboard as the canonical first landing. Focused Pest coverage will protect aggregation, priority ordering, checked domains, calmness boundaries, drillthrough continuity, RBAC-safe omission, and representative DB-only render costs.
## Technical Context
**Language/Version**: PHP 8.4.15
**Primary Dependencies**: Laravel 12, Filament v5, Livewire v4, Pest v4, existing `WorkspaceOverviewBuilder`, `WorkspaceSummaryStats`, `WorkspaceNeedsAttention`, `TenantBackupHealthResolver`, `TenantBackupHealthAssessment`, `RestoreSafetyResolver`, tenant dashboard widgets, `WorkspaceCapabilityResolver`, `CapabilityResolver`, and the current workspace overview Blade surfaces
**Storage**: PostgreSQL unchanged; no schema change, new cache table, or persisted workspace recovery artifact is planned
**Testing**: Pest feature tests and Livewire-style widget tests through Laravel Sail, reusing existing workspace overview tests plus focused upstream backup and recovery truth guards
**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, preserve bounded query behavior for representative visible-tenant workspaces, avoid naive per-tenant resolver fanout, and keep restore-history candidate evaluation aligned with the existing latest-10 tenant recovery posture rule
**Constraints**: No new recovery-confidence engine, no new persisted workspace posture model, no new portfolio matrix page, no new Graph calls, no new panel/provider changes, no over-strong recovery claims, no hidden-tenant leakage, and no drift away from tenant dashboard as the canonical triage destination
**Scale/Scope**: One workspace overview page, one builder, two existing workspace widgets, one calmness contract, visible-tenant aggregation for moderate tenant counts, and focused regression coverage for metrics, attention, calmness, drillthroughs, RBAC, and query-bounded rendering
## 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 | Existing tenant backup sets, schedules, restore runs, and restore-result attention remain the only source truths. No new inventory or snapshot ownership is introduced. |
| Read/write separation | PASS | PASS | The slice is read-only workspace overview hardening. No new write path, preview flow, or remote mutation is added. |
| Graph contract path | N/A | N/A | No Graph calls or contract-registry changes are required. |
| Deterministic capabilities | PASS | PASS | Existing workspace and tenant capability registries remain authoritative. Aggregation uses visible tenants only and does not invent new permission semantics. |
| RBAC-UX authorization semantics | PASS | PASS | `/admin` remains workspace-scoped; drillthrough stays in the tenant plane; non-members remain `404`; members missing deeper capability keep safe disabled or fallback navigation without hidden-tenant leakage. |
| Workspace and tenant isolation | PASS | PASS | The plan keeps visible-tenant filtering as the aggregation boundary and keeps tenant dashboard drillthrough explicit. |
| Run observability / Ops-UX | PASS | PASS | No new `OperationRun`, notification surface, or lifecycle change is introduced. Existing operations remain diagnostic context only. |
| Data minimization | PASS | PASS | No new persistence or broader route exposure is planned. Only already-visible tenant truth is promoted onto the workspace home. |
| Proportionality / no premature abstraction | PASS | PASS | The plan extends `WorkspaceOverviewBuilder` and existing truth helpers instead of introducing a workspace recovery subsystem, presenter framework, or persisted score. |
| Persisted truth / behavioral state | PASS | PASS | New attention families and checked domains are derived presentation semantics with operator consequences on ordering and calmness only; no new persisted domain state is introduced. |
| UI semantics / few layers | PASS | PASS | Backup health and recovery evidence are rendered directly from existing truth on existing widgets; no new badge framework or interpretation pipeline is added. |
| Badge semantics (BADGE-001) | PASS | PASS | Existing badge and tone semantics remain authoritative. The workspace overview will not invent a new portfolio recovery badge language. |
| Filament-native UI / Action Surface Contract | PASS | PASS | `WorkspaceOverview`, `WorkspaceSummaryStats`, and `WorkspaceNeedsAttention` remain read-only navigation surfaces. No destructive or redundant action model is added. |
| Filament UX-001 / HDR-001 | PASS | PASS | No create/edit flows or new record headers are introduced. The landing page keeps the existing layout and makes recovery triage more visible within that structure. |
| Filament v5 / Livewire v4 compliance | PASS | PASS | The design stays inside Filament v5 and Livewire v4. No Livewire v3-era patterns are introduced. |
| Provider registration location | PASS | PASS | No panel or provider 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. Existing destructive actions remain unchanged and confirmed where already required. |
| Asset strategy | PASS | PASS | No new assets are planned. Deployment continues to use `cd apps/platform && php artisan filament:assets` unchanged if assets are built elsewhere in the product. |
| Testing truth (TEST-TRUTH-001) | PASS | PASS | The plan adds tests for business consequences: false calmness, wrong counts, wrong priority order, hidden-tenant leakage, lost drillthrough context, and query regressions. |
## Phase 0 Research
Research outcomes are captured in `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/specs/185-workspace-recovery-posture-visibility/research.md`.
Key decisions:
- Keep `WorkspaceOverviewBuilder` as the only workspace orchestration boundary rather than creating a new workspace recovery posture service.
- Reuse `TenantBackupHealthResolver` and `RestoreSafetyResolver::dashboardRecoveryEvidence()` as the source-of-truth seams; do not duplicate tenant posture mapping inside a second workspace-only interpretation layer.
- Add narrow visible-tenant bulk derivation support around the existing tenant truth helpers so workspace rendering can stay query-bounded instead of calling single-tenant resolvers naively for every visible tenant.
- Count backup and recovery attention by affected visible tenant, not by raw backup errors, raw restore runs, or a blended workspace score.
- Keep backup health and recovery evidence separate on summary metrics, attention items, and calmness copy; reject any single blended workspace recovery label.
- Use the tenant dashboard as the canonical drillthrough for new workspace backup-health and recovery-evidence items, with choose-tenant as the multi-tenant metric fallback and existing deeper backup or restore surfaces left as secondary tenant-local navigation.
- Extend calmness and checked domains explicitly with `backup_health` and `recovery_evidence` instead of silently implying those domains were checked.
- Insert new attention families above activity-only operations and alerts while preserving the current governance-first intent of workspace attention ordering.
- Reuse existing workspace overview test seams and DB-only query guard patterns rather than creating a new browser harness or portfolio cache layer.
## Phase 1 Design
Design artifacts are created under `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/specs/185-workspace-recovery-posture-visibility/`:
- `research.md`: implementation decisions, constraints, and rejected alternatives for workspace recovery posture visibility
- `data-model.md`: existing tenant truth models plus the derived visible-tenant and workspace-overview projections used by this slice
- `contracts/workspace-recovery-posture-visibility.openapi.yaml`: internal surface contract for workspace summary metrics, attention items, calmness, and tenant-dashboard drillthrough
- `quickstart.md`: focused implementation and verification workflow for workspace recovery posture visibility
Design decisions:
- `WorkspaceOverviewBuilder` remains the builder boundary and becomes responsible for deriving visible-tenant backup and recovery context, workspace metrics, new attention candidates, and honest calmness from visible tenants only.
- Existing tenant backup-health truth remains authoritative; the workspace layer only consumes the posture, reason, claim boundary, and tenant-safe action contract already implied by that truth.
- Existing tenant recovery-evidence truth remains authoritative; the workspace layer only consumes `overview_state`, reason, summary, claim boundary, and latest relevant run continuity.
- The implementation keeps summary metrics separate: one metric for backup-attention tenants and one metric for recovery-attention tenants, in addition to the existing governance, activity, and alert metrics.
- New workspace attention items are tenant-bound records carrying tenant label, family, severity, bounded reason text, and a primary tenant-dashboard destination. They do not introduce a new workspace recovery page.
- Workspace calmness remains bounded to visible tenants and now explicitly names backup health and recovery evidence in the checked-domain contract and calm-state copy.
- Existing deeper tenant backup-set or restore-run surfaces remain downstream from the tenant dashboard instead of becoming the primary workspace landing, which keeps drillthrough predictable and aligned with the specs workspace-first triage goal.
- Query-bounded implementation is handled through visible-tenant batch derivation over latest backup basis, schedule follow-up, and capped restore-history candidates; no persisted workspace cache or materialized posture artifact is introduced.
## Project Structure
### Documentation (this feature)
```text
specs/185-workspace-recovery-posture-visibility/
├── spec.md
├── plan.md
├── research.md
├── data-model.md
├── quickstart.md
├── contracts/
│ └── workspace-recovery-posture-visibility.openapi.yaml
├── checklists/
│ └── requirements.md
└── tasks.md
```
### Source Code (repository root)
```text
apps/platform/
├── app/
│ ├── Filament/
│ │ ├── Pages/
│ │ │ ├── WorkspaceOverview.php
│ │ │ ├── ChooseTenant.php
│ │ │ └── TenantDashboard.php
│ │ └── Widgets/
│ │ ├── Dashboard/
│ │ │ ├── DashboardKpis.php
│ │ │ ├── NeedsAttention.php
│ │ │ └── RecoveryReadiness.php
│ │ └── Workspace/
│ │ ├── WorkspaceSummaryStats.php
│ │ ├── WorkspaceNeedsAttention.php
│ │ └── WorkspaceRecentOperations.php
│ ├── Models/
│ │ ├── BackupSchedule.php
│ │ ├── BackupSet.php
│ │ ├── RestoreRun.php
│ │ ├── OperationRun.php
│ │ ├── AlertDelivery.php
│ │ └── Tenant.php
│ └── Support/
│ ├── BackupHealth/
│ │ ├── TenantBackupHealthAssessment.php
│ │ └── TenantBackupHealthResolver.php
│ ├── RestoreSafety/
│ │ ├── RestoreResultAttention.php
│ │ └── RestoreSafetyResolver.php
│ └── Workspaces/
│ └── WorkspaceOverviewBuilder.php
├── resources/
│ └── views/
│ └── filament/
│ ├── pages/
│ │ └── workspace-overview.blade.php
│ └── widgets/
│ └── workspace/
│ └── workspace-needs-attention.blade.php
└── tests/
└── Feature/
└── Filament/
├── WorkspaceOverviewAccessTest.php
├── WorkspaceOverviewAuthorizationTest.php
├── WorkspaceOverviewContentTest.php
├── WorkspaceOverviewDbOnlyTest.php
├── WorkspaceOverviewDrilldownContinuityTest.php
├── WorkspaceOverviewEmptyStatesTest.php
├── WorkspaceOverviewGovernanceAttentionTest.php
├── WorkspaceOverviewRecoveryAttentionTest.php
├── WorkspaceOverviewLandingTest.php
├── WorkspaceOverviewNavigationTest.php
├── WorkspaceOverviewOperationsTest.php
├── WorkspaceOverviewPermissionVisibilityTest.php
├── WorkspaceOverviewSummaryMetricsTest.php
├── DashboardRecoveryPosturePerformanceTest.php
├── DashboardKpisWidgetTest.php
└── NeedsAttentionWidgetTest.php
```
**Structure Decision**: Keep the work entirely inside the existing Laravel and Filament monolith. Extend the current workspace overview builder, workspace widgets, and existing tenant truth helpers instead of creating a new workspace recovery domain or a second portfolio-truth layer.
## Complexity Tracking
> No Constitution Check violations are planned. No exception beyond the specs approved derived attention-family addition is currently justified.
| Violation | Why Needed | Simpler Alternative Rejected Because |
|-----------|------------|-------------------------------------|
| — | — | — |
## Proportionality Review
> The feature adds derived workspace attention families and checked-domain markers, but no new persistence, no new abstraction layer, and no new cross-domain framework.
- **Current operator problem**: Workspace operators cannot quickly see which visible tenants are backup-weak or recovery-evidence-weak, so portfolio triage remains tenant-by-tenant.
- **Existing structure is insufficient because**: Tenant truth exists, but the workspace home does not yet aggregate or prioritize it.
- **Narrowest correct implementation**: Extend the existing workspace overview builder and widgets to consume existing tenant truth and present it as separate backup and recovery signals.
- **Ownership cost created**: Additional workspace aggregation logic, a small amount of ordering and copy logic, and focused regression coverage for counts, calmness, drillthrough, RBAC, and performance.
- **Alternative intentionally rejected**: A dedicated portfolio matrix, a persisted workspace recovery score, or a new recovery-confidence engine.
- **Release truth**: Current-release truth. The slice surfaces already-existing tenant truth on the existing workspace landing page.
## Implementation Strategy
### Phase A — Derive Visible-Tenant Backup And Recovery Contexts Without A New Workspace Engine
**Goal**: Reuse existing tenant-level truth while keeping workspace rendering query-bounded.
| Step | File | Change |
|------|------|--------|
| A.1 | `apps/platform/app/Support/Workspaces/WorkspaceOverviewBuilder.php` | Extend the visible-tenant context build step to carry backup-health posture, backup-health reason metadata, recovery-evidence overview state, recovery-evidence reason metadata, and primary tenant-dashboard drillthrough context for every visible tenant. |
| A.2 | `apps/platform/app/Support/BackupHealth/TenantBackupHealthResolver.php` and `apps/platform/app/Support/RestoreSafety/RestoreSafetyResolver.php` | Add narrow bulk or prefetch-friendly derivation seams that let the workspace builder compute visible-tenant backup and recovery posture without naively invoking the current single-tenant resolvers in an uncontrolled loop. Keep the logic in the existing source-of-truth helpers rather than duplicating it in a workspace-only mapper. |
| A.3 | `apps/platform/tests/Feature/Filament/WorkspaceOverviewDbOnlyTest.php` and focused new or updated workspace builder coverage | Prove that the expanded workspace overview remains DB-only and query-bounded for representative multi-tenant scenarios once backup and recovery signals are enabled. |
### Phase B — Add Separate Workspace Summary Metrics For Backup And Recovery Attention
**Goal**: Let the workspace stat strip answer how many visible tenants need backup follow-up versus recovery-evidence follow-up.
| Step | File | Change |
|------|------|--------|
| B.1 | `apps/platform/app/Support/Workspaces/WorkspaceOverviewBuilder.php` | Add `backup_attention_tenants` and `recovery_attention_tenants` summary metrics that count visible tenants with non-calm backup or recovery posture. Keep these counts tenant-based, not raw-issue-based. |
| B.2 | `apps/platform/app/Filament/Widgets/Workspace/WorkspaceSummaryStats.php` and `apps/platform/resources/views/filament/pages/workspace-overview.blade.php` | Render the new metrics using the existing stats strip, with descriptions that keep backup health and recovery evidence separate and bounded. |
| B.3 | `apps/platform/tests/Feature/Filament/WorkspaceOverviewSummaryMetricsTest.php` and `apps/platform/tests/Feature/Filament/WorkspaceOverviewContentTest.php` | Cover separate metrics, honest descriptions, zero-problem states, and metric destinations for one-tenant and multi-tenant affected sets. |
### Phase C — Introduce Backup-Health And Recovery-Evidence Workspace Attention Families
**Goal**: Make workspace attention tell the operator which tenant to open first and why.
| Step | File | Change |
|------|------|--------|
| C.1 | `apps/platform/app/Support/Workspaces/WorkspaceOverviewBuilder.php` | Add `backup_health` attention items for visible tenants with `absent`, `stale`, or `degraded` backup posture and `recovery_evidence` attention items for visible tenants with `weakened` or `unvalidated` recovery evidence. |
| C.2 | `apps/platform/app/Support/Workspaces/WorkspaceOverviewBuilder.php` | Integrate the new items into the existing priority ordering so recovery and backup items rank above activity-only operations and alerts while preserving the current governance-first intent of the workspace queue. |
| C.3 | `apps/platform/resources/views/filament/widgets/workspace/workspace-needs-attention.blade.php` and `apps/platform/app/Filament/Widgets/Workspace/WorkspaceNeedsAttention.php` | Reuse the existing workspace attention rendering contract so each new item shows tenant label, family, bounded reason, and one clear tenant-dashboard action or a safe disabled state. |
| C.4 | `apps/platform/tests/Feature/Filament/WorkspaceOverviewRecoveryAttentionTest.php` and `apps/platform/tests/Feature/Filament/WorkspaceOverviewGovernanceAttentionTest.php` | Cover absent, stale, degraded, weakened, and unvalidated ordering, ensure `no_recent_issues_visible` never becomes a workspace problem item, and prove the new families preserve existing governance and operations queue intent. |
### Phase D — Make Calmness Honest About Backup Health And Recovery Evidence
**Goal**: Remove false calmness by omission and make checked domains explicit.
| Step | File | Change |
|------|------|--------|
| D.1 | `apps/platform/app/Support/Workspaces/WorkspaceOverviewBuilder.php` | Extend `checked_domains` with `backup_health` and `recovery_evidence`, suppress calmness whenever visible tenants trigger either family, and keep calmness bounded to visible tenants. |
| D.2 | `apps/platform/resources/views/filament/pages/workspace-overview.blade.php` and any calmness-copy consumers | Update calmness copy so calm states explicitly say that backup health and recovery evidence were included, and non-calm states explain that visible tenants still need attention in those domains. |
| D.3 | `apps/platform/tests/Feature/Filament/WorkspaceOverviewEmptyStatesTest.php` and `apps/platform/tests/Feature/Filament/WorkspaceOverviewContentTest.php` | Verify that calmness is only true when backup and recovery are both checked and quiet, and that zero-tenant or low-permission states do not masquerade as healthy calmness. |
### Phase E — Preserve Tenant-Safe Drillthrough Continuity
**Goal**: Ensure workspace recovery or backup items land on a tenant surface that still explains the same weakness.
| Step | File | Change |
|------|------|--------|
| E.1 | `apps/platform/app/Support/Workspaces/WorkspaceOverviewBuilder.php` | Make the tenant dashboard the primary destination for new backup-health and recovery-evidence items, and use choose-tenant as the metric fallback when more than one visible tenant is affected. |
| E.2 | Existing tenant dashboard widgets and continuity seams only if needed | Pass lightweight reason context only where an existing tenant surface already supports it; otherwise rely on the tenant dashboards existing backup-health and recovery-evidence presentation to preserve the reason without adding new routing shells. |
| E.3 | `apps/platform/tests/Feature/Filament/WorkspaceOverviewDrilldownContinuityTest.php` and `apps/platform/tests/Feature/Filament/WorkspaceOverviewPermissionVisibilityTest.php` | Prove that workspace drillthrough opens the correct tenant, stays tenant-safe, and degrades to a safe disabled or fallback state when a more specific downstream route is unavailable. |
### Phase F — Lock In RBAC And Performance Guardrails
**Goal**: Prevent hidden-tenant leakage and render-path regressions once recovery and backup signals are live on `/admin`.
| Step | File | Change |
|------|------|--------|
| F.1 | `apps/platform/tests/Feature/Filament/WorkspaceOverviewPermissionVisibilityTest.php` | Verify that hidden tenants do not appear in counts, item labels, or reason text, and that calmness remains bounded to visible tenants. |
| F.2 | `apps/platform/tests/Feature/Filament/WorkspaceOverviewAuthorizationTest.php` | Verify that read-only workspace members still receive truthful summary visibility while downstream capability limits stay enforced with the existing 404/403 semantics. |
| F.3 | `apps/platform/tests/Feature/Filament/WorkspaceOverviewDbOnlyTest.php` and upstream dashboard recovery performance tests | Verify that the workspace path remains query-bounded and that the underlying tenant recovery-evidence derivation still respects the existing latest-10 candidate cap. |
| F.4 | `cd apps/platform && ./vendor/bin/sail bin pint --dirty --format agent` and focused Pest runs | Required formatting and targeted verification before the implementation is considered complete. |
## Key Design Decisions
### D-001 — Workspace Truth Remains Derived From Tenant Truth
The workspace overview does not define new recovery confidence. It only aggregates tenant backup-health and tenant recovery-evidence states that already exist.
### D-002 — Batch Derivation Lives At Existing Truth Seams
Performance hardening belongs in the existing `TenantBackupHealthResolver` and `RestoreSafetyResolver` seams or in tightly scoped builder prefetch support, not in a new workspace cache layer or second mapping engine.
### D-003 — Backup And Recovery Stay Separate Everywhere
Metrics, attention items, calmness copy, and contracts must distinguish backup weakness from recovery-evidence weakness so the operator knows what kind of problem exists.
### D-004 — Tenant Dashboard Is The Primary Workspace Drillthrough
The workspace overview should land the operator on the affected tenant dashboard first. That keeps the flow workspace-first and lets the tenant truth explain itself before the operator decides to dive into backup sets or restore runs.
### D-005 — Calmness Must Name The Domains It Actually Checked
The workspace can only say it is calm if backup health and recovery evidence were actually checked and were quiet for visible tenants. Silent inclusion is not enough.
## Risk Assessment
- If bulk derivation duplicates tenant truth instead of reusing existing resolver semantics, workspace and tenant views could drift.
- If the tenant dashboard drillthrough is bypassed too aggressively in favor of deep links, the workspace flow will become harder to scan and harder to reason about under RBAC.
- If new recovery and backup items are inserted above everything else, the existing governance-first workspace triage could regress; if they are inserted too low, the new slice will not solve the operator workflow gap.
- If calmness wording is updated without checked-domain expansion, the UI could still sound honest while remaining semantically incomplete.
- If metrics count raw backup sets or raw restore runs instead of affected tenants, the workspace answer will be noisy instead of actionable.
## Phase 1 — Agent Context Update
Run after artifact generation:
- `.specify/scripts/bash/update-agent-context.sh copilot`

View File

@ -0,0 +1,106 @@
# Quickstart: Workspace Recovery Posture Visibility
## Prerequisites
1. Start the application services if they are not already running:
```bash
cd apps/platform && ./vendor/bin/sail up -d
```
2. Use a workspace member account with access to at least one visible tenant.
3. Keep the current workspace overview route as the primary verification entry point:
```text
/admin
```
## Focused Automated Verification
Run the existing workspace overview pack first:
```bash
cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Filament/WorkspaceOverviewSummaryMetricsTest.php
cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Filament/WorkspaceOverviewDrilldownContinuityTest.php
cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Filament/WorkspaceOverviewPermissionVisibilityTest.php
cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Filament/WorkspaceOverviewDbOnlyTest.php
cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Filament/WorkspaceOverviewContentTest.php
cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Filament/WorkspaceOverviewEmptyStatesTest.php
```
If the new attention-family coverage is split into a dedicated file, run it as part of the pack. Otherwise extend and rerun the existing governance-attention file:
```bash
cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Filament/WorkspaceOverviewGovernanceAttentionTest.php
```
Run the upstream tenant-truth guards that Spec 185 depends on:
```bash
cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Filament/DashboardRecoveryPosturePerformanceTest.php
cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Filament/DashboardKpisWidgetTest.php
cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Filament/NeedsAttentionWidgetTest.php
```
Format after code changes:
```bash
cd apps/platform && ./vendor/bin/sail bin pint --dirty --format agent
```
## Manual Validation Scenarios
### Scenario 1: Mixed workspace with backup and recovery issues
1. Open `/admin` for a workspace with multiple visible tenants.
2. Verify one visible tenant is `absent` on backup health, one is `stale`, one is `unvalidated`, one is `weakened`, and one is calm.
3. Verify the stat strip shows separate backup-attention and recovery-attention counts.
4. Verify `Needs attention` shows the expected tenant order and keeps backup health and recovery evidence separate.
### Scenario 2: Single affected tenant metric drillthrough
1. Seed a workspace where exactly one visible tenant has backup attention or recovery attention.
2. Open `/admin`.
3. Verify the matching summary metric links directly to that tenants dashboard.
4. Confirm the tenant dashboard still shows the same backup or recovery weakness.
### Scenario 3: Multi-tenant metric fallback
1. Seed a workspace where multiple visible tenants are affected.
2. Open `/admin`.
3. Verify the matching summary metric uses the deliberate tenant-entry fallback instead of pretending there is a single canonical tenant.
4. Confirm the operator can still choose the correct tenant from there.
### Scenario 4: Calm workspace with explicit checked domains
1. Seed a workspace where all visible tenants are healthy on backup health and `no_recent_issues_visible` on recovery evidence.
2. Open `/admin`.
3. Verify the workspace may render a calm state.
4. Verify the calmness statement explicitly includes backup health and recovery evidence.
5. Verify the wording stays bounded to visible tenants and does not claim recovery proof.
### Scenario 5: RBAC-limited member
1. Sign in as a workspace member who cannot see some tenants in the workspace.
2. Open `/admin`.
3. Verify hidden tenants do not appear in counts, item labels, or reason text.
4. Verify any calmness claim is explicitly about the visible tenant slice.
5. If a downstream capability is missing, verify the workspace item degrades to a safe disabled or fallback state rather than a dead-end link.
## Non-goals Check
Before considering the slice complete, verify that no workspace surface introduces any of the following:
- `workspace recovery is proven`
- `portfolio restore readiness is guaranteed`
- `healthy backups guarantee successful recovery`
- Any equivalent blended score or proof language stronger than the visible tenant truth supports
## Deployment Note
No new Filament assets are planned for this slice. Deployment keeps the existing asset step unchanged:
```bash
cd apps/platform && php artisan filament:assets
```

View File

@ -0,0 +1,111 @@
# Research: Workspace Recovery Posture Visibility
## Decision 1: Keep the slice inside the existing workspace overview instead of creating a new portfolio recovery surface
**Decision**: Implement Spec 185 inside `WorkspaceOverviewBuilder`, `WorkspaceSummaryStats`, `WorkspaceNeedsAttention`, and the existing workspace overview Blade surface. Do not create a dedicated workspace recovery matrix page or a second portfolio posture shell.
**Rationale**: The operator gap is on `/admin`, where the current workspace landing page does not expose backup or recovery-evidence weakness across visible tenants. Extending the existing landing surface closes that gap with the smallest IA change and keeps the operators first scan in one place.
**Alternatives considered**:
- Add a dedicated workspace recovery matrix page. Rejected because the spec explicitly rules that out and because it would split first-scan triage across two pages.
- Add recovery posture to the tenant list instead of `/admin`. Rejected because the workflow problem is workspace-first triage, not tenant-directory enrichment.
## Decision 2: Reuse the existing tenant truth seams rather than invent a workspace recovery mapper
**Decision**: Reuse `TenantBackupHealthResolver` for backup posture and `RestoreSafetyResolver::dashboardRecoveryEvidence()` for recovery evidence as the authoritative truth seams for the workspace overview.
**Rationale**: Both tenant-level truths already exist and already carry the bounded operator language required by the spec. Reinterpreting those states inside a new workspace-only mapper would create a second truth path and make tenant and workspace semantics drift.
**Alternatives considered**:
- Recompute backup posture and recovery evidence inside `WorkspaceOverviewBuilder` from raw tables only. Rejected because it would duplicate tenant logic and increase drift risk.
- Persist a workspace recovery summary table. Rejected because the feature is explicitly derived-first and does not need independent lifecycle truth.
## Decision 3: Add narrow batch-friendly derivation to the existing truth helpers to avoid N+1 behavior
**Decision**: Add narrow visible-tenant batch or prefetch support around the existing backup-health and recovery-evidence derivation seams instead of calling the current single-tenant resolver methods naively in a loop.
**Rationale**: `TenantBackupHealthResolver::assess()` currently loads the latest relevant backup set and schedules for one tenant, and `RestoreSafetyResolver::dashboardRecoveryEvidence()` also resolves backup health and capped restore history for one tenant. Calling both seams per visible tenant inside the workspace overview would create avoidable fanout. A narrow batch-friendly seam preserves the source-of-truth logic while keeping `/admin` query-bounded.
**Alternatives considered**:
- Call the existing single-tenant resolver methods once per visible tenant. Rejected because representative workspace render paths already have a DB-only query budget, and this approach would grow linearly in an uncontrolled way.
- Add a new generic workspace caching layer or persisted aggregate. Rejected because it introduces architecture and persistence the slice does not need.
## Decision 4: Count affected visible tenants, not raw issues or raw restore runs
**Decision**: The new summary metrics count visible tenants with backup attention and visible tenants with recovery-evidence attention.
**Rationale**: The workspace operator question is “how many tenants need attention,” not “how many backup items failed” or “how many restore runs exist.” Tenant counts preserve triage value and align with how the existing governance metric already works.
**Alternatives considered**:
- Count raw backup degradations, schedules, or restore runs. Rejected because the counts become noisy and do not answer which tenant to open first.
- Build a single blended posture score. Rejected because the spec explicitly requires backup health and recovery evidence to stay separate.
## Decision 5: Keep backup health and recovery evidence separate on metrics, attention, and calmness copy
**Decision**: Use one backup-attention metric, one recovery-attention metric, one `backup_health` attention family, and one `recovery_evidence` attention family. Calmness copy explicitly mentions both domains.
**Rationale**: Backup health and recovery evidence answer different operator questions. The product already treats them as separate truths at tenant level, and the workspace overview must preserve that distinction.
**Alternatives considered**:
- Show one blended “recovery posture” metric. Rejected because it hides whether the weakness is missing backup basis or weak restore evidence.
- Keep only attention items and no new metrics. Rejected because the operator also needs a quick portfolio count before drilling into items.
## Decision 6: Use the tenant dashboard as the primary workspace drillthrough for new recovery and backup items
**Decision**: New workspace backup-health and recovery-evidence items drill into `/admin/t/{tenant}` as the primary landing. Summary metrics fall back to `ChooseTenant` when multiple visible tenants are affected and may link directly to the tenant dashboard only when exactly one visible tenant is affected.
**Rationale**: The tenant dashboard already surfaces backup health and recovery evidence side by side, so it preserves the flagged weakness without forcing the operator immediately into deep backup-set or restore-run pages. This matches the specs workspace-first triage flow and keeps drillthrough predictable under RBAC.
**Alternatives considered**:
- Link new workspace items directly to backup-set or restore-run surfaces. Rejected as the primary contract because it makes the workspace flow more brittle and less consistent, especially when permissions differ across downstream surfaces.
- Use only `ChooseTenant` for all new items. Rejected because the operator would lose the “open this tenant now” flow that the spec explicitly requires.
## Decision 7: Keep deeper tenant backup or restore pages as secondary follow-up only
**Decision**: The workspace overview does not need to invent new direct reason-specific routes. Existing tenant backup-set and restore-run surfaces remain tenant-local follow-up after the operator lands on the tenant dashboard.
**Rationale**: The tenant dashboard already preserves why the tenant was flagged. That makes new direct workspace-to-detail routing unnecessary for this slice and avoids adding a second continuity scheme for workspace triage.
**Alternatives considered**:
- Add workspace-specific reason query parameters to deeper routes immediately. Rejected for now because the tenant dashboard already preserves the context and this slice does not need a new routing contract to be truthful.
- Add a new workspace recovery drawer or modal instead of navigating. Rejected because it would add a new interaction model on the landing page.
## Decision 8: Extend calmness by explicit checked domains, not silent implication
**Decision**: Add `backup_health` and `recovery_evidence` to the workspace `checked_domains` contract and make calmness copy say that those domains were included.
**Rationale**: The main trust failure is calmness by omission. Simply changing the boolean without naming the checked domains would not let the operator distinguish “calm because checked” from “calm because ignored.”
**Alternatives considered**:
- Reuse the current calmness boolean with no domain changes. Rejected because it would leave the semantic gap intact.
- Mention backup and recovery in the body copy only. Rejected because the contract also needs machine-readable domain coverage for tests and future slices.
## Decision 9: Insert new recovery and backup items above activity-only signals while preserving current governance-first intent
**Decision**: Add backup-health and recovery-evidence items above operations and alerts in workspace attention ordering, but do not let them unintentionally erase the existing governance-first priority scheme.
**Rationale**: Recovery and backup are now triage-relevant workspace domains, but the current workspace overview already uses governance as a high-signal priority family. The narrowest safe change is to raise backup and recovery above activity-only signals while keeping the current governance-critical ordering semantics intact.
**Alternatives considered**:
- Put all new backup or recovery items at the very top of the queue. Rejected because it could unintentionally demote stronger existing governance blockers.
- Put all new items below operations and alerts. Rejected because the slice would not solve the portfolio-triage gap.
## Decision 10: Reuse existing workspace overview tests and DB-only guards instead of introducing a new harness
**Decision**: Extend the existing workspace overview test pack and DB-only render guard, and keep the upstream tenant recovery performance guard in scope to protect the source truths that workspace aggregation consumes.
**Rationale**: The repo already has focused tests for summary metrics, drillthrough continuity, permission visibility, calmness, and DB-only rendering. Extending those seams keeps coverage close to the business consequences and avoids building a second test framework for the same landing page.
**Alternatives considered**:
- Add only manual smoke checks. Rejected because the repo requires programmatic coverage and the slice changes multiple derived contracts.
- Build a browser-only suite for the workspace overview. Rejected because the current workspace overview behavior is already well-covered with server-side and Livewire-style tests.
## Decision 11: No new assets, provider registration, or global-search changes
**Decision**: Keep the slice inside existing Filament pages, widgets, and views with no new assets, no provider changes, and no new global-search behavior.
**Rationale**: The feature is about visibility and triage semantics, not new frontend infrastructure or discovery surfaces. Existing deployment and panel registration rules remain unchanged.
**Alternatives considered**:
- Add custom assets or a new panel widget type. Rejected because existing Filament widgets already cover the required UI.
- Expand global search to expose workspace recovery posture. Rejected because the spec does not require it and because search is not the first-scan triage surface.

View File

@ -0,0 +1,267 @@
# Feature Specification: Workspace Recovery Posture Visibility
**Feature Branch**: `185-workspace-recovery-posture-visibility`
**Created**: 2026-04-09
**Status**: Draft
**Input**: User description: "Spec 185 — Workspace Recovery Posture Visibility"
## Spec Scope Fields *(mandatory)*
- **Scope**: workspace
- **Primary Routes**:
- `/admin` as the workspace-level overview where `WorkspaceOverview`, `WorkspaceSummaryStats`, and `WorkspaceNeedsAttention` answer the first portfolio triage question
- `/admin/choose-tenant` as the bounded tenant-entry fallback when the operator needs to move from workspace triage into one tenant without a more precise allowed destination
- `/admin/t/{tenant}` as the canonical tenant dashboard drill-through for backup-health and recovery-evidence follow-up
- `/admin/t/{tenant}/backup-sets` and `/admin/t/{tenant}/restore-runs` only when an existing reason-specific destination can preserve the same weakness context without weakening claim boundaries or violating permissions
- **Data Ownership**:
- Tenant-owned backup truth remains authoritative for backup posture, including the derived tenant backup-health assessment built from existing backup and schedule records
- Tenant-owned restore truth remains authoritative for recovery evidence, including the existing dashboard-level recovery-evidence overview derived from restore history and restore-result attention
- Workspace overview metrics, attention items, calmness, and drill-through hints remain derived workspace-level views over visible tenant truth; this feature introduces no new persisted workspace recovery posture, score, or confidence ledger
- **RBAC**:
- Workspace membership remains required to render `/admin` and all workspace-level aggregation shown by this feature
- Only tenants visible inside the current workspace and capability scope may contribute to backup metrics, recovery metrics, calmness suppression, or attention items
- Tenant dashboard and any deeper backup or restore destinations continue to enforce existing tenant-scoped read and mutation permissions; workspace-level visibility never upgrades access
- Non-members or out-of-scope actors remain deny-as-not-found, and members who cannot open a deeper destination must still receive truthful workspace summary language without hidden-tenant leakage or dead-end drill-throughs
## 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 embedded recovery and backup triage surfaces | forbidden | Quick actions only | none | `/admin` | none | Active workspace identity stays visible and every promoted issue keeps tenant identity explicit | Workspace overview | Which visible tenants need backup or recovery follow-up first | Singleton landing surface |
| Workspace summary stats | Embedded status summary / drill-in surface | Explicit stat click when a metric has a precise next destination; otherwise intentionally passive | forbidden | none | none | `/admin` | Matching tenant dashboard or choose-tenant fallback when no single precise tenant target exists | Workspace identity plus visible-tenant scope | Backup attention / Recovery attention | Separate cross-tenant counts for backup weakness and recovery-evidence weakness | Mixed metric summary surface |
| Workspace `Needs Attention` | Embedded triage summary | Each attention item opens one allowed tenant destination that preserves the same weakness reason | forbidden | none | none | `/admin` | `/admin/t/{tenant}` by default, with deeper existing tenant backup or restore surfaces only when context continuity is preserved | Tenant name, issue family, and visible scope remain explicit before navigation | Attention / Attention item | The highest-priority visible tenant backup or recovery weakness and the next click | Multi-destination triage surface |
| Tenant dashboard recovery landing | Tenant detail-first landing | The tenant dashboard is the canonical recovery and backup landing when workspace triage needs one tenant-wide destination | forbidden | Existing dashboard actions only | none added by this feature | `/admin/t/{tenant}` | Existing tenant backup or restore follow-up surfaces remain secondary destinations | Active workspace plus tenant context | Tenant dashboard | Backup-health and recovery-evidence truth remain visible without losing why the workspace flagged the tenant | Existing tenant dashboard pattern |
## 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 visible tenants are weak on backups or recovery evidence, and where should I go first? | Backup-attention count, recovery-attention count, prioritized attention, calmness boundary, and tenant labels | Raw backup metadata, full restore histories, and low-level diagnostics remain downstream | backup health, recovery evidence, existing governance, existing operations | none | Open a priority tenant from attention or choose a tenant deliberately | none |
| Workspace summary stats | Workspace operator | Embedded summary | How many visible tenants need backup follow-up, and how many need recovery-evidence follow-up? | Separate backup and recovery counts with bounded labels | Tenant-by-tenant breakdown remains secondary | backup attention volume, recovery attention volume | none | Open the affected tenant or tenant-entry surface | none |
| Workspace `Needs Attention` | Workspace operator | Embedded triage summary | Which tenant needs attention now, why, and what should I click next? | Tenant name, issue family, bounded reason, clear action label | Full restore-result detail, backup-set history, and deep schedule context remain downstream | backup severity, recovery-evidence severity, existing workspace priority ordering | none | Open the tenant dashboard or an allowed reason-specific tenant surface | none |
| Tenant dashboard recovery landing | Workspace operator after drill-through | Tenant landing page | Why did the workspace flag this tenant, and what backup or recovery truth confirms it? | Tenant backup health, tenant recovery evidence, and the same bounded weakness context | Full backup-set and restore-run diagnostics remain deeper drill-through surfaces | backup posture, recovery-evidence posture, tenant-local follow-up | none added by this feature | Continue to backup sets or restore runs when needed | none added by this feature |
## Proportionality Review *(mandatory when structural complexity is introduced)*
- **New source of truth?**: No. Tenant backup-health truth and tenant recovery-evidence truth remain authoritative.
- **New persisted entity/table/artifact?**: No. The feature explicitly forbids a new workspace recovery posture record, workspace recovery score, or persisted confidence model.
- **New abstraction?**: No. The narrow change is to extend the existing workspace overview builder and existing workspace surfaces with more truthful derived aggregation.
- **New enum/state/reason family?**: Yes, but derived only. The feature adds workspace attention families such as `backup_health` and `recovery_evidence`, plus checked-domain markers for those families. Tenant posture states remain defined by existing tenant-level truth.
- **New cross-domain UI framework/taxonomy?**: No. This slice strengthens existing workspace triage and explicitly avoids a new portfolio matrix or recovery-confidence framework.
- **Current operator problem**: Workspace operators can currently see honest tenant-level backup and recovery signals, but they cannot tell from the workspace overview which visible tenants are weakest, which tenants need recovery-evidence follow-up, or whether workspace calmness is real versus omission-driven.
- **Existing structure is insufficient because**: The current workspace overview already summarizes governance, findings, compare, and operations, but it does not yet promote tenant backup-health and tenant recovery-evidence truth into workspace-first triage. Operators are forced into tenant-by-tenant inspection.
- **Narrowest correct implementation**: Extend visible tenant contexts, workspace summary metrics, workspace attention families, calmness checked domains, and workspace-to-tenant drill-through continuity so that existing tenant truth becomes triage-ready at `/admin` without introducing new persistence or a stronger recovery-claim engine.
- **Ownership cost**: The repo takes on additional workspace aggregation logic, severity ordering, bounded claim copy, query-bounded data loading, and targeted regression coverage for metrics, attention, calmness, drill-through continuity, and RBAC-safe omission.
- **Alternative intentionally rejected**: A dedicated portfolio recovery matrix, a persisted workspace recovery score, tenant-list columns for recovery posture, or a new recovery-confidence engine were rejected because they introduce new truth and broader IA change before the current workspace overview tells the existing truth honestly.
- **Release truth**: Current-release truth hardening. Tenant truth already exists; this slice makes workspace triage usable.
## User Scenarios & Testing *(mandatory)*
### User Story 1 - See Backup And Recovery Hotspots Fast (Priority: P1)
As a workspace operator, I want the workspace overview to show separate backup and recovery-evidence attention counts so that I can tell within seconds how many visible tenants need follow-up in each domain.
**Why this priority**: This is the core workspace-first workflow gap. Without a truthful cross-tenant count, operators still have to inspect tenants one by one.
**Independent Test**: Can be fully tested by seeding visible tenants with mixes of `absent`, `stale`, `degraded`, `unvalidated`, `weakened`, and calm states, then verifying that `/admin` shows separate backup and recovery counts without overclaiming workspace confidence.
**Acceptance Scenarios**:
1. **Given** visible tenants include backup-weak and recovery-weak cases, **When** the operator opens `/admin`, **Then** the summary shows at least one backup-attention metric and one recovery-attention metric with separate counts.
2. **Given** visible tenants are mixed across backup and recovery weakness families, **When** the workspace overview renders, **Then** the counts reflect only the visible tenants that need follow-up in each domain.
3. **Given** all visible tenants are healthy on backups and have `no_recent_issues_visible` recovery evidence, **When** the overview renders, **Then** backup and recovery attention metrics do not invent problem counts.
---
### User Story 2 - Open The Right Tenant First (Priority: P1)
As a workspace operator, I want workspace attention to rank the weakest visible tenants and give me one clear next click so that I can start triage from the highest-value tenant rather than from raw activity.
**Why this priority**: Counts alone do not solve the workflow gap. The overview must also answer which tenant should be opened first.
**Independent Test**: Can be fully tested by seeding mixed workspaces and verifying that attention items rank `absent` above `stale` above `degraded`, rank `weakened` above `unvalidated`, and carry tenant-safe drill-through destinations.
**Acceptance Scenarios**:
1. **Given** one visible tenant has `absent` backup health and another has `degraded` backup health, **When** the attention list renders, **Then** the absent-backup tenant appears first within the backup-health family.
2. **Given** one visible tenant has `weakened` recovery evidence and another has `unvalidated` recovery evidence, **When** the attention list renders, **Then** the weakened-recovery tenant appears first within the recovery-evidence family.
3. **Given** an operator opens a workspace attention item, **When** the destination loads, **Then** the tenant identity and the same weakness reason remain recoverable on the target surface.
---
### User Story 3 - Trust Calmness Boundaries (Priority: P2)
As a workspace operator, I want calmness on the workspace overview to explicitly include backup health and recovery evidence so that I know a calm workspace is calm because those domains were checked, not because they were ignored.
**Why this priority**: False calmness by omission is the main trust failure this slice is intended to remove.
**Independent Test**: Can be fully tested by rendering the workspace overview for calm and non-calm portfolios and verifying that calmness is suppressed whenever backup or recovery attention exists and that calm wording explicitly names the covered domains.
**Acceptance Scenarios**:
1. **Given** a visible tenant has backup or recovery attention, **When** calmness is derived for `/admin`, **Then** the workspace overview is not calm and the checked-domain contract includes backup health and recovery evidence.
2. **Given** all visible tenants are calm across covered domains, **When** the overview renders, **Then** the calmness statement explicitly says that backup health and recovery evidence were included.
3. **Given** hidden tenants may exist outside the current visible scope, **When** calmness is shown, **Then** the calm statement remains bounded to visible tenants rather than making a stronger portfolio-wide claim.
---
### User Story 4 - Preserve Permission-Safe Portfolio Truth (Priority: P3)
As a workspace operator with partial tenant visibility, I want workspace-level backup and recovery triage to remain truthful without leaking hidden tenants so that the overview stays safe under RBAC boundaries.
**Why this priority**: Workspace aggregation is only acceptable if it remains tenant-safe and permission-safe under partial visibility.
**Independent Test**: Can be fully tested by mixing visible and hidden tenants with backup and recovery issues, then verifying that `/admin` counts only visible tenants, leaks no hidden tenant names or reason text, and still stays bounded in its calmness claims.
**Acceptance Scenarios**:
1. **Given** a user cannot see some tenants with backup or recovery issues, **When** the workspace overview renders, **Then** hidden tenants do not appear in counts, attention items, or reason text.
2. **Given** only hidden tenants have backup or recovery issues, **When** the workspace overview renders, **Then** any calmness claim stays explicitly scoped to visible tenants.
3. **Given** a user can see the workspace overview but cannot open a more precise backup or restore destination, **When** a workspace item renders, **Then** it uses an allowed tenant-dashboard fallback or a safe non-clickable state instead of a dead-end link.
### Edge Cases
- All visible tenants may be `healthy` on backups and `no_recent_issues_visible` on recovery evidence; the workspace overview must allow calmness only when both domains are included in checked domains.
- A workspace may contain many `unvalidated` tenants at once; recovery-evidence counts and attention must stay explicit without turning the absence of proof into a stronger negative or positive claim.
- One visible tenant may have `absent` backup health while all other visible tenants are calm; the absent-backup tenant must still prevent workspace calmness and appear as the first backup attention item.
- A mixed workspace may contain `weakened`, `unvalidated`, `stale`, `degraded`, and healthy tenants simultaneously; ordering must stay consistent and bounded rather than collapsing everything into one generic urgency bucket.
- Hidden tenants may still have backup or recovery issues; the workspace overview must neither leak those tenants nor imply that hidden tenants are calm.
- A tenant may have `no_recent_issues_visible` recovery evidence but still have stale or absent backup posture; backup attention must remain visible and must not be canceled out by calm recovery evidence.
- A tenant may be the only actionable backup or recovery problem while the rest of the workspace is quiet; metrics, attention, and calmness must still point clearly to that one tenant.
## Requirements *(mandatory)*
**Constitution alignment (required):** This feature introduces no new Microsoft Graph calls, no new write workflow, and no new queued or scheduled operation. It is a read-first workspace triage slice that promotes existing tenant backup-health and recovery-evidence truth onto the existing workspace overview.
**Constitution alignment (PROP-001 / ABSTR-001 / PERSIST-001 / STATE-001 / BLOAT-001):** The slice remains derived-first. It adds no new persistence, no portfolio confidence engine, and no new broad abstraction layer. The only new semantic additions are derived workspace attention families and checked-domain markers that sit on top of already-shipped tenant truth.
**Constitution alignment (OPS-UX):** No new `OperationRun`, progress surface, or execution path is introduced. Existing operations remain execution truth only. This feature changes how the workspace overview summarizes and prioritizes existing tenant backup and recovery signals.
**Constitution alignment (RBAC-UX):** The feature lives primarily in the workspace plane at `/admin` with read-only drill-through into the tenant plane at `/admin/t/{tenant}` and, only when authorized, deeper existing tenant backup or restore surfaces. Non-members or actors outside workspace or tenant scope remain `404`. Existing deeper read and mutation permissions remain server-side and unchanged. Workspace aggregation must use only visible tenants, must not leak hidden tenant problems, and must use safe destination fallbacks when a precise downstream surface is not allowed.
**Constitution alignment (OPS-EX-AUTH-001):** Not applicable. No authentication-handshake behavior changes.
**Constitution alignment (BADGE-001):** Existing centralized status semantics for backup health, restore-result attention, and recovery-evidence language remain authoritative. The workspace overview may elevate those semantics, but it must not invent a page-local badge or color system for portfolio recovery posture.
**Constitution alignment (UI-FIL-001):** The feature reuses the existing Filament workspace overview page and existing workspace widgets. Semantic emphasis must come from aligned copy, ordering, and existing shared primitives rather than custom local markup or a new widget family.
**Constitution alignment (UI-NAMING-001):** Operator-facing language must keep `Backup health` and `Recovery evidence` separate. Primary labels may use terms such as `Backup attention`, `Recovery attention`, `Needs attention`, `Visible tenants need attention`, `Backup health`, and `Recovery evidence`, but must not introduce `recovery proven`, `restore guaranteed`, `workspace ready`, or equivalent overclaims.
**Constitution alignment (UI-CONST-001 / UI-SURF-001 / UI-HARD-001 / UI-EX-001 / UI-REVIEW-001):** `WorkspaceOverview` remains the canonical workspace landing surface. `WorkspaceSummaryStats` remains an embedded summary strip. `WorkspaceNeedsAttention` remains the workspace triage surface. The tenant dashboard remains the canonical fallback drill-through. No redundant `View` affordances or new destructive controls are introduced.
**Constitution alignment (OPSURF-001):** Default-visible content on `/admin` must answer the operator's first triage question within 5 to 10 seconds: which visible tenants are weak on backups, which are weak on recovery evidence, and where the next click should go. Diagnostics remain downstream on the tenant dashboard and deeper tenant pages.
**Constitution alignment (UI-SEM-001 / LAYER-001 / TEST-TRUTH-001):** The feature must not create a second recovery-confidence truth. It may only derive workspace-level visibility and prioritization from existing tenant truth, and tests must focus on business consequences such as false calmness, hidden-tenant leakage, wrong priority order, and lost drill-through context.
**Constitution alignment (Filament Action Surfaces):** The Action Surface Contract remains satisfied. `WorkspaceOverview`, `WorkspaceSummaryStats`, and `WorkspaceNeedsAttention` remain read-only drill-through surfaces with no destructive actions, no empty action groups, and no redundant view actions. UI-FIL-001 remains satisfied and no new exception is required.
**Constitution alignment (UX-001 — Layout & Information Architecture):** The feature does not add new create or edit forms. It refines the existing workspace landing page. Backup and recovery posture must appear as first-scan portfolio triage signals rather than as hidden downstream detail, and calmness wording must remain explicit about which domains were checked.
### Functional Requirements
- **FR-185-001**: The system MUST extend each visible tenant context used by the workspace overview with the current tenant backup-health posture from the existing tenant backup-health source of truth.
- **FR-185-002**: The workspace overview MUST recognize at least `absent`, `stale`, `degraded`, and `healthy` as backup-health posture classes without redefining how those states are determined.
- **FR-185-003**: The system MUST extend each visible tenant context used by the workspace overview with the current tenant recovery-evidence overview state from the existing tenant recovery-evidence source of truth.
- **FR-185-004**: The workspace overview MUST recognize at least `unvalidated`, `weakened`, and `no_recent_issues_visible` as recovery-evidence states without redefining how those states are determined.
- **FR-185-005**: Workspace summary metrics MUST expose at least one backup-attention metric and one recovery-attention metric and MUST keep those portfolio signals separate.
- **FR-185-006**: Workspace `Needs Attention` MUST support a `backup_health` family that surfaces every visible tenant whose backup posture is not `healthy`.
- **FR-185-007**: The `backup_health` family MUST prioritize `absent` above `stale` above `degraded`.
- **FR-185-008**: Workspace `Needs Attention` MUST support a `recovery_evidence` family that surfaces every visible tenant whose recovery evidence is `weakened` or `unvalidated`, and it MUST NOT surface `no_recent_issues_visible` as a problem item.
- **FR-185-009**: Every backup-health or recovery-evidence attention item MUST include the affected tenant, bounded reason text, one clear action, and one tenant-safe destination.
- **FR-185-010**: Workspace metrics, attention, and calmness copy MUST keep backup health and recovery evidence as separate signals and MUST NOT merge them into a single ambiguous portfolio posture score or label.
- **FR-185-011**: Workspace calmness MUST evaluate backup health and recovery evidence alongside the existing checked domains and MUST add `backup_health` and `recovery_evidence`, or functionally equivalent markers, to the checked-domain contract.
- **FR-185-012**: Workspace calmness MUST NOT render a calm state when any visible tenant triggers backup-health or recovery-evidence attention.
- **FR-185-013**: Calmness copy MUST explicitly state that backup health and recovery evidence were included and MUST keep any calm claim bounded to visible tenants.
- **FR-185-014**: Workspace-level wording MUST NOT claim or imply that the workspace is recovery proven, that portfolio restore readiness is guaranteed, or that healthy backups prove successful recovery.
- **FR-185-015**: Drill-through from workspace backup or recovery metrics and attention items MUST preserve tenant identity and the originating weakness reason; the tenant dashboard is the canonical fallback when no more precise allowed destination can preserve that context.
- **FR-185-016**: Workspace aggregation MUST count and surface only tenants visible within the current workspace and capability scope and MUST NOT leak hidden tenant names, counts, or problem detail.
- **FR-185-017**: When hidden tenants exist or no more precise downstream capability is available, workspace copy and navigation MUST remain bounded to visible evidence and use safe fallbacks or non-clickable states instead of stronger claims or dead-end links.
- **FR-185-018**: Global workspace attention ordering MUST integrate backup-health and recovery-evidence families without unintentionally breaking existing governance or operations priorities outside the intended severity rules.
- **FR-185-019**: Workspace backup and recovery aggregation MUST remain practical for moderate visible-tenant counts, SHOULD use batch or preloaded truth where available, and MUST avoid uncontrolled per-tenant N+1 patterns.
- **FR-185-020**: The feature MUST ship without a new persisted workspace recovery posture model, workspace recovery score, tenant-list posture column set, dedicated portfolio matrix page, or recovery-confidence engine.
- **FR-185-021**: Regression coverage MUST prove builder aggregation, separate summary metrics, backup-health attention, recovery-evidence attention, checked-domain expansion, honest calmness copy, drill-through continuity, RBAC-safe visibility, representative mixed-workspace ordering, and query-bounded render behavior.
## Derived State Semantics
- **Tenant backup-health source**: The workspace overview consumes tenant backup-health posture exactly as already derived at tenant level. This slice does not redefine backup-health rules.
- **Tenant recovery-evidence source**: The workspace overview consumes tenant recovery-evidence overview state exactly as already derived at tenant level. This slice does not redefine recovery-evidence rules.
- **Workspace backup attention set**: The set of visible tenants whose tenant backup-health posture is `absent`, `stale`, or `degraded`, ordered by `absent`, then `stale`, then `degraded`.
- **Workspace recovery attention set**: The set of visible tenants whose tenant recovery-evidence state is `weakened` or `unvalidated`, ordered by `weakened`, then `unvalidated`.
- **Checked domains contract**: Workspace calmness is only allowed when existing workspace domains plus `backup_health` and `recovery_evidence` were actually checked and no visible tenant triggers attention in those domains.
- **Calmness boundary**: Any calm workspace statement produced by this feature is a statement about visible tenants and covered domains only. It is never a proof of full portfolio recovery readiness.
## Attention Priority Rules
- **Backup health family order**: `absent` before `stale` before `degraded`.
- **Recovery evidence family order**: `weakened` before `unvalidated`.
- **Cross-family integration rule**: Backup-health and recovery-evidence items must enter the existing workspace attention ordering in a way that preserves the current governance and operations intent of the workspace overview rather than silently replacing it with a backup-only or recovery-only queue.
- **Bounded claim rule**: An item may say that a tenant needs backup follow-up or recovery-evidence follow-up, but it may not say that the tenant or the workspace is recovery proven or guaranteed.
## Smoke-Test Goals
- Seed one workspace with multiple visible tenants where one tenant is `absent` on backup health, one is `stale` on backup health, one is `unvalidated` on recovery evidence, one is `weakened` on recovery evidence, and one is calm.
- Verify that `/admin` shows separate backup and recovery summary metrics and that both metrics remain truthful under mixed visible tenants.
- Verify that `Needs Attention` shows backup-health and recovery-evidence items with the expected priority order and one clear next click per item.
- Verify that calmness is shown only in the fully calm visible-workspace case and that calm wording explicitly includes backup health and recovery evidence.
- Verify that drill-through opens the correct tenant and preserves why that tenant was flagged.
- Verify that hidden tenants do not appear in counts or attention items when the operator lacks visibility.
## Success Criteria *(mandatory)*
### Measurable Outcomes
- **SC-185-001**: In acceptance review, a workspace operator can determine within 10 seconds from `/admin` how many visible tenants need backup follow-up, how many need recovery-evidence follow-up, and which tenant to open first.
- **SC-185-002**: In 100% of covered regression scenarios, visible tenants with `absent`, `stale`, or `degraded` backup posture appear in the correct backup metric and backup attention flow, and visible tenants with `weakened` or `unvalidated` recovery evidence appear in the correct recovery metric and recovery attention flow.
- **SC-185-003**: In 100% of covered calmness scenarios, the workspace overview shows calmness only when backup health and recovery evidence are included in checked domains and no visible tenant triggers either family.
- **SC-185-004**: In 100% of covered drill-through scenarios, workspace backup or recovery attention preserves tenant identity and the same weakness reason at the destination or allowed fallback.
- **SC-185-005**: In 100% of covered RBAC scenarios, hidden tenants do not leak through counts, item labels, or reason text, and any calmness claim stays explicitly bounded to visible tenants.
- **SC-185-006**: Targeted DB-only or query-bounded regression coverage proves that representative moderate-tenant workspace rendering does not degrade into uncontrolled per-tenant query fanout when backup and recovery signals are enabled.
- **SC-185-007**: The feature ships without a schema migration, a new persisted workspace recovery model, a new workspace recovery score, or a new recovery-confidence engine.
## Assumptions
- Existing tenant dashboard work already surfaces honest backup-health and recovery-evidence truth that can be reused as workspace-level drill-through context.
- The current workspace overview composition remains in place; this slice extends tenant contexts, summary metrics, attention families, and calmness semantics rather than redesigning the page.
- The tenant dashboard remains the correct fallback destination whenever a deeper backup or restore surface would lose context or violate the operator's permissions.
- Existing tenant backup and restore truth can be loaded in a batched or eager-loaded way that keeps workspace rendering practical for moderate tenant counts.
## Non-Goals
- Building a dedicated portfolio recovery matrix or a new workspace recovery page
- Introducing a persisted workspace recovery score, workspace posture model, or recovery-confidence ledger
- Redefining tenant backup-health logic or tenant recovery-evidence logic
- Adding backup or recovery posture columns to the tenant resource list
- Redesigning the entire workspace overview information architecture
- Adding automatic restore validation, scheduled recovery probes, or a broader recovery-confidence engine
- Claiming that the workspace or portfolio is recovery proven or restore guaranteed
## Dependencies
- Existing `WorkspaceOverview`, `WorkspaceSummaryStats`, `WorkspaceNeedsAttention`, and `WorkspaceOverviewBuilder` workspace surfaces
- Existing tenant backup-health truth and the tenant-level backup-health source of truth
- Existing tenant recovery-evidence truth and the tenant-level recovery-evidence source of truth
- Existing tenant dashboard and, when allowed, deeper tenant backup-set and restore-run surfaces used for drill-through continuity
- Existing workspace and tenant RBAC scoping and safe destination fallback patterns
## Risks
- If workspace copy collapses backup health and recovery evidence into one blended posture, operators will lose the distinction this slice is meant to restore.
- If calmness does not explicitly include the new checked domains, the workspace overview may remain falsely calm by omission.
- If aggregation ignores visible-scope boundaries, hidden tenant recovery or backup problems could leak through counts, wording, or navigation.
- If the implementation relies on naive per-tenant resolver calls, workspace rendering could regress under moderate tenant counts.
- If drill-through destinations cannot recover the same weakness reason, the workspace overview will feel more precise than the tenant truth it opens.
## Definition of Done
Spec 185 is complete when:
- the workspace overview makes backup-health weakness visible across visible tenants,
- the workspace overview makes recovery-evidence weakness visible across visible tenants,
- workspace summary metrics show separate backup and recovery attention counts,
- `WorkspaceNeedsAttention` cleanly integrates `backup_health` and `recovery_evidence` families,
- workspace calmness includes backup health and recovery evidence in its checked-domain and claim-boundary logic,
- no over-strong recovery or restore-confidence claim is introduced,
- drill-through remains triage-ready and preserves tenant context,
- RBAC-safe aggregation and omission behavior are covered,
- query-bounded tests cover representative multi-tenant render behavior,
- and the workspace overview becomes a usable workspace-first triage surface without adding a new recovery-confidence engine.

View File

@ -0,0 +1,238 @@
# Tasks: Workspace Recovery Posture Visibility
**Input**: Design documents from `/specs/185-workspace-recovery-posture-visibility/` (`spec.md`, `plan.md`, `research.md`, `data-model.md`, `contracts/`, `quickstart.md`)
**Prerequisites**: `/specs/185-workspace-recovery-posture-visibility/plan.md` (required), `/specs/185-workspace-recovery-posture-visibility/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/WorkspaceOverviewSummaryMetricsTest.php`, `tests/Feature/Filament/WorkspaceOverviewRecoveryAttentionTest.php`, `tests/Feature/Filament/WorkspaceOverviewDrilldownContinuityTest.php`, `tests/Feature/Filament/WorkspaceOverviewPermissionVisibilityTest.php`, `tests/Feature/Filament/WorkspaceOverviewAuthorizationTest.php`, `tests/Feature/Filament/WorkspaceOverviewEmptyStatesTest.php`, `tests/Feature/Filament/WorkspaceOverviewContentTest.php`, `tests/Feature/Filament/WorkspaceOverviewDbOnlyTest.php`, `tests/Feature/Filament/WorkspaceOverviewGovernanceAttentionTest.php`, and existing upstream tenant-truth guards in `tests/Feature/Filament/DashboardRecoveryPosturePerformanceTest.php`, `tests/Feature/Filament/DashboardKpisWidgetTest.php`, and `tests/Feature/Filament/NeedsAttentionWidgetTest.php`.
**Operations**: This feature does not create a new `OperationRun` type or change operation lifecycle ownership. Existing Operations surfaces remain diagnostic-only and are not expanded as part of this slice.
**RBAC**: Preserve workspace membership enforcement on `/admin`, deny-as-not-found `404` semantics for non-members or out-of-scope tenants, existing `403` semantics for in-scope actors lacking deeper capabilities, visible-tenant-only aggregation, and safe tenant-dashboard or choose-tenant fallbacks for new workspace signals.
**Operator Surfaces**: `WorkspaceOverview`, `WorkspaceSummaryStats`, and `WorkspaceNeedsAttention` must stay operator-first, keep backup health and recovery evidence separate, and make tenant identity explicit on every new workspace attention item.
**Filament UI Action Surfaces**: No destructive actions or redundant inspect affordances are added. `WorkspaceSummaryStats` remains a stat drill-through surface, `WorkspaceNeedsAttention` remains an item-based triage surface, and `WorkspaceOverview` remains the singleton landing page.
**Filament UI UX-001**: No new create, edit, or view pages are introduced. Existing workspace landing layout remains in place while metrics, calmness, and attention semantics are hardened.
**Badges**: Existing badge and tone semantics remain authoritative; no new page-local portfolio recovery badge language may be 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 Seam Review)
**Purpose**: Reconfirm the exact workspace overview seams, tenant truth sources, and regression surfaces before changing `/admin` semantics.
- [X] T001 Review the current workspace overview composition in `apps/platform/app/Support/Workspaces/WorkspaceOverviewBuilder.php`, `apps/platform/app/Filament/Pages/WorkspaceOverview.php`, `apps/platform/app/Filament/Widgets/Workspace/WorkspaceSummaryStats.php`, `apps/platform/app/Filament/Widgets/Workspace/WorkspaceNeedsAttention.php`, and `apps/platform/resources/views/filament/pages/workspace-overview.blade.php`
- [X] T002 [P] Review the existing tenant backup-health and recovery-evidence source truths in `apps/platform/app/Support/BackupHealth/TenantBackupHealthResolver.php`, `apps/platform/app/Support/BackupHealth/TenantBackupHealthAssessment.php`, `apps/platform/app/Support/RestoreSafety/RestoreSafetyResolver.php`, `apps/platform/app/Filament/Widgets/Dashboard/DashboardKpis.php`, and `apps/platform/app/Filament/Widgets/Dashboard/NeedsAttention.php`
- [X] T003 [P] Review the existing workspace overview regression seams and contract expectations in `specs/185-workspace-recovery-posture-visibility/contracts/workspace-recovery-posture-visibility.openapi.yaml`, `apps/platform/tests/Feature/Filament/WorkspaceOverviewSummaryMetricsTest.php`, `apps/platform/tests/Feature/Filament/WorkspaceOverviewDrilldownContinuityTest.php`, `apps/platform/tests/Feature/Filament/WorkspaceOverviewPermissionVisibilityTest.php`, and `apps/platform/tests/Feature/Filament/WorkspaceOverviewDbOnlyTest.php`
---
## Phase 2: Foundational (Blocking Payload And Derivation Seams)
**Purpose**: Establish the shared workspace payload, visible-tenant derivation seams, and regression scaffolding that every user story depends on.
**⚠️ CRITICAL**: No user story work should begin until this phase is complete.
- [X] T004 Create the initial recovery-visibility test scaffolding in `apps/platform/tests/Feature/Filament/WorkspaceOverviewSummaryMetricsTest.php`, `apps/platform/tests/Feature/Filament/WorkspaceOverviewDrilldownContinuityTest.php`, `apps/platform/tests/Feature/Filament/WorkspaceOverviewPermissionVisibilityTest.php`, `apps/platform/tests/Feature/Filament/WorkspaceOverviewDbOnlyTest.php`, and `apps/platform/tests/Feature/Filament/WorkspaceOverviewRecoveryAttentionTest.php`
- [X] T005 Extend the shared workspace overview payload to align with `specs/185-workspace-recovery-posture-visibility/contracts/workspace-recovery-posture-visibility.openapi.yaml` for new metric keys, attention families, reason-context payloads, destination kinds, and checked domains in `apps/platform/app/Support/Workspaces/WorkspaceOverviewBuilder.php`
- [X] T006 [P] Add batch-friendly visible-tenant backup-health derivation support in `apps/platform/app/Support/BackupHealth/TenantBackupHealthResolver.php` and `apps/platform/app/Support/BackupHealth/TenantBackupHealthAssessment.php`
- [X] T007 [P] Add batch-friendly visible-tenant recovery-evidence derivation support while preserving the latest-10 restore-history cap in `apps/platform/app/Support/RestoreSafety/RestoreSafetyResolver.php` and `apps/platform/tests/Feature/Filament/DashboardRecoveryPosturePerformanceTest.php`
**Checkpoint**: The builder exposes the shared backup-health and recovery-evidence workspace payload shape, and the visible-tenant derivation seams are ready for story work.
---
## Phase 3: User Story 1 - See Backup And Recovery Hotspots Fast (Priority: P1) 🎯 MVP
**Goal**: Make `/admin` show separate backup-attention and recovery-attention counts for visible tenants.
**Independent Test**: Seed visible tenants with `absent`, `stale`, `degraded`, `unvalidated`, `weakened`, and calm states, then verify that `/admin` shows separate backup and recovery summary metrics without overclaiming workspace confidence.
### Tests for User Story 1
- [X] T008 [P] [US1] Add mixed, calm, single-tenant, and multi-tenant backup and recovery metric scenarios in `apps/platform/tests/Feature/Filament/WorkspaceOverviewSummaryMetricsTest.php`
- [X] T009 [P] [US1] Add content assertions for separate backup-attention and recovery-attention labels, descriptions, and destination semantics in `apps/platform/tests/Feature/Filament/WorkspaceOverviewContentTest.php`
### Implementation for User Story 1
- [X] T010 [US1] Compute `backup_attention_tenants` and `recovery_attention_tenants` from visible-tenant backup and recovery contexts in `apps/platform/app/Support/Workspaces/WorkspaceOverviewBuilder.php`
- [X] T011 [US1] Render the new workspace backup-attention and recovery-attention metrics plus stat-card destination behavior in `apps/platform/app/Filament/Widgets/Workspace/WorkspaceSummaryStats.php` and `apps/platform/resources/views/filament/pages/workspace-overview.blade.php`
- [X] T012 [US1] Run focused US1 verification against `apps/platform/tests/Feature/Filament/WorkspaceOverviewSummaryMetricsTest.php`, `apps/platform/tests/Feature/Filament/WorkspaceOverviewContentTest.php`, and `apps/platform/tests/Feature/Filament/WorkspaceOverviewDbOnlyTest.php`
**Checkpoint**: The workspace overview now answers how many visible tenants need backup follow-up and how many need recovery-evidence follow-up.
---
## Phase 4: User Story 2 - Open The Right Tenant First (Priority: P1)
**Goal**: Make workspace attention rank backup and recovery weakness by severity and send the operator to the correct tenant first.
**Independent Test**: Seed mixed visible tenants and verify that `absent` ranks above `stale` above `degraded`, `weakened` ranks above `unvalidated`, and each new attention item opens the affected tenant dashboard with the same weakness still visible there.
### Tests for User Story 2
- [X] T013 [P] [US2] Add backup-health and recovery-evidence family ordering, `no_recent_issues_visible` suppression, and cross-family queue-preservation scenarios in `apps/platform/tests/Feature/Filament/WorkspaceOverviewRecoveryAttentionTest.php` and `apps/platform/tests/Feature/Filament/WorkspaceOverviewGovernanceAttentionTest.php`
- [X] T014 [P] [US2] Add backup-health and recovery-evidence drill-through continuity plus rendered attention-item contract assertions in `apps/platform/tests/Feature/Filament/WorkspaceOverviewDrilldownContinuityTest.php` and `apps/platform/tests/Feature/Filament/WorkspaceOverviewContentTest.php`
### Implementation for User Story 2
- [X] T015 [US2] Add `backup_health` and `recovery_evidence` attention candidate building, tenant-bound reason context, severity ordering, and cross-family insertion that preserves existing governance and operations priorities in `apps/platform/app/Support/Workspaces/WorkspaceOverviewBuilder.php`
- [X] T016 [US2] Render tenant-bound backup-health and recovery-evidence items with one clear tenant-dashboard action in `apps/platform/app/Filament/Widgets/Workspace/WorkspaceNeedsAttention.php` and `apps/platform/resources/views/filament/widgets/workspace/workspace-needs-attention.blade.php`
- [X] T017 [US2] Wire single-tenant metric drill-through and multi-tenant choose-tenant fallback semantics for the new backup-attention and recovery-attention metrics plus attention items in `apps/platform/app/Support/Workspaces/WorkspaceOverviewBuilder.php` and `apps/platform/app/Filament/Pages/WorkspaceOverview.php`
- [X] T018 [US2] Run focused US2 verification against `apps/platform/tests/Feature/Filament/WorkspaceOverviewRecoveryAttentionTest.php`, `apps/platform/tests/Feature/Filament/WorkspaceOverviewGovernanceAttentionTest.php`, `apps/platform/tests/Feature/Filament/WorkspaceOverviewDrilldownContinuityTest.php`, and `apps/platform/tests/Feature/Filament/WorkspaceOverviewContentTest.php`
**Checkpoint**: The workspace overview now tells the operator which tenant to open first and why.
---
## Phase 5: User Story 3 - Trust Calmness Boundaries (Priority: P2)
**Goal**: Make workspace calmness explicitly include backup health and recovery evidence instead of hiding blind spots.
**Independent Test**: Render calm and non-calm visible-workspace scenarios and verify that calmness is suppressed whenever backup-health attention or recovery-evidence attention exists, that `checked_domains` includes both new domains, and that calm copy explicitly names those domains.
### Tests for User Story 3
- [X] T019 [P] [US3] Add calmness and checked-domain scenarios for backup-health and recovery-evidence coverage in `apps/platform/tests/Feature/Filament/WorkspaceOverviewEmptyStatesTest.php` and `apps/platform/tests/Feature/Filament/WorkspaceOverviewContentTest.php`
- [X] T020 [P] [US3] Add builder-level calmness suppression coverage for mixed backup and recovery portfolios in `apps/platform/tests/Feature/Filament/WorkspaceOverviewGovernanceAttentionTest.php`
### Implementation for User Story 3
- [X] T021 [US3] Extend `checked_domains`, calmness suppression, and calm next-action selection for `backup_health` and `recovery_evidence` in `apps/platform/app/Support/Workspaces/WorkspaceOverviewBuilder.php`
- [X] T022 [US3] Update calmness and empty-state copy to state explicitly that backup health and recovery evidence were checked in `apps/platform/resources/views/filament/pages/workspace-overview.blade.php` and `apps/platform/app/Filament/Pages/WorkspaceOverview.php`
- [X] T023 [US3] Run focused US3 verification against `apps/platform/tests/Feature/Filament/WorkspaceOverviewEmptyStatesTest.php`, `apps/platform/tests/Feature/Filament/WorkspaceOverviewContentTest.php`, and `apps/platform/tests/Feature/Filament/WorkspaceOverviewGovernanceAttentionTest.php`
**Checkpoint**: Calmness can no longer read as honest if backup-health weakness or recovery-evidence weakness is still present in the visible tenant slice.
---
## Phase 6: User Story 4 - Preserve Permission-Safe Portfolio Truth (Priority: P3)
**Goal**: Keep the new workspace backup-health and recovery-evidence signals truthful under partial tenant visibility and limited downstream capability.
**Independent Test**: Mix visible and hidden tenants with backup and recovery issues, then verify that `/admin` counts only visible tenants, leaks no hidden tenant labels or reason text, stays bounded in calmness claims, and degrades safely when a deeper destination is unavailable.
### Tests for User Story 4
- [X] T024 [P] [US4] Add hidden-tenant omission and bounded-calmness visibility scenarios in `apps/platform/tests/Feature/Filament/WorkspaceOverviewPermissionVisibilityTest.php`
- [X] T025 [P] [US4] Add positive and negative authorization plus safe fallback scenarios for new metric and item destinations in `apps/platform/tests/Feature/Filament/WorkspaceOverviewAuthorizationTest.php` and `apps/platform/tests/Feature/Filament/WorkspaceOverviewDrilldownContinuityTest.php`
### Implementation for User Story 4
- [X] T026 [US4] Enforce visible-tenant-only aggregation for backup-health and recovery-evidence signals plus safe single-tenant versus choose-tenant destination selection in `apps/platform/app/Support/Workspaces/WorkspaceOverviewBuilder.php`
- [X] T027 [US4] Keep capability-limited backup-health and recovery-evidence item rendering tenant-safe with disabled states and helper text in `apps/platform/app/Filament/Widgets/Workspace/WorkspaceNeedsAttention.php` and `apps/platform/resources/views/filament/widgets/workspace/workspace-needs-attention.blade.php`
- [X] T028 [US4] Run focused US4 verification against `apps/platform/tests/Feature/Filament/WorkspaceOverviewPermissionVisibilityTest.php`, `apps/platform/tests/Feature/Filament/WorkspaceOverviewAuthorizationTest.php`, and `apps/platform/tests/Feature/Filament/WorkspaceOverviewDrilldownContinuityTest.php`
**Checkpoint**: The new workspace backup-health and recovery-evidence signals are now tenant-safe, bounded, and authorization-aware.
---
## Phase 7: Polish & Cross-Cutting Concerns
**Purpose**: Finish copy alignment, cleanup, formatting, and the final focused verification pack.
- [X] T029 [P] Align final operator copy, claim-boundary wording, and family labels across `apps/platform/app/Support/Workspaces/WorkspaceOverviewBuilder.php`, `apps/platform/app/Filament/Widgets/Workspace/WorkspaceSummaryStats.php`, `apps/platform/app/Filament/Widgets/Workspace/WorkspaceNeedsAttention.php`, `apps/platform/resources/views/filament/pages/workspace-overview.blade.php`, and `apps/platform/resources/views/filament/widgets/workspace/workspace-needs-attention.blade.php`
- [X] T030 [P] Collapse any temporary workspace-only posture mapping back into the existing truth seams in `apps/platform/app/Support/Workspaces/WorkspaceOverviewBuilder.php`, `apps/platform/app/Support/BackupHealth/TenantBackupHealthResolver.php`, and `apps/platform/app/Support/RestoreSafety/RestoreSafetyResolver.php`
- [X] T031 Run formatting with `cd apps/platform && ./vendor/bin/sail bin pint --dirty --format agent` for the affected `app/`, `resources/views/`, and `tests/Feature/Filament/` files
- [X] T032 Run the final quickstart verification pack from `specs/185-workspace-recovery-posture-visibility/quickstart.md` against `apps/platform/tests/Feature/Filament/WorkspaceOverviewSummaryMetricsTest.php`, `apps/platform/tests/Feature/Filament/WorkspaceOverviewRecoveryAttentionTest.php`, `apps/platform/tests/Feature/Filament/WorkspaceOverviewDrilldownContinuityTest.php`, `apps/platform/tests/Feature/Filament/WorkspaceOverviewPermissionVisibilityTest.php`, `apps/platform/tests/Feature/Filament/WorkspaceOverviewAuthorizationTest.php`, `apps/platform/tests/Feature/Filament/WorkspaceOverviewDbOnlyTest.php`, `apps/platform/tests/Feature/Filament/WorkspaceOverviewContentTest.php`, `apps/platform/tests/Feature/Filament/WorkspaceOverviewEmptyStatesTest.php`, `apps/platform/tests/Feature/Filament/WorkspaceOverviewGovernanceAttentionTest.php`, `apps/platform/tests/Feature/Filament/DashboardRecoveryPosturePerformanceTest.php`, `apps/platform/tests/Feature/Filament/DashboardKpisWidgetTest.php`, and `apps/platform/tests/Feature/Filament/NeedsAttentionWidgetTest.php`
- [X] T033 Run the manual smoke checks from `specs/185-workspace-recovery-posture-visibility/quickstart.md` for mixed workspace, single-tenant metric drill-through, multi-tenant fallback, calm workspace, and RBAC-limited member scenarios
---
## Dependencies & Execution Order
### Phase Dependencies
- **Setup (Phase 1)**: No dependencies; can start immediately.
- **Foundational (Phase 2)**: Depends on Setup; blocks all user-story work.
- **User Story 1 (Phase 3)**: Depends on Foundational completion.
- **User Story 2 (Phase 4)**: Depends on Foundational completion and reuses the shared visible-tenant payload from Phase 2.
- **User Story 3 (Phase 5)**: Depends on Foundational completion and is best delivered after the new backup and recovery families exist.
- **User Story 4 (Phase 6)**: Depends on Foundational completion and is best delivered after the new metric, attention, and calmness paths exist.
- **Polish (Phase 7)**: Depends on all desired user stories being complete.
### User Story Dependencies
- **User Story 1 (P1)**: Can start after Foundational work and is the recommended MVP.
- **User Story 2 (P1)**: Can start after Foundational work and remains independently testable, though it shares the same visible-tenant payload with US1.
- **User Story 3 (P2)**: Can start after Foundational work, but is clearest once US1 and US2 have introduced the new metrics and attention families it must govern.
- **User Story 4 (P3)**: Can start after Foundational work, but is most effective once the new signals from US1 through US3 already exist.
### Within Each User Story
- Tests should be added before or alongside implementation and must fail before the story is considered complete.
- Builder and resolver changes should land before widget or page rendering tasks that depend on the new payload.
- Rendering changes should land before focused story verification runs.
- Focused story verification should complete before moving on to the next story.
### Parallel Opportunities
- Setup tasks `T002` and `T003` can run in parallel.
- Foundational tasks `T006` and `T007` can run in parallel after `T005` defines the shared workspace payload shape.
- In US1, `T008` and `T009` can run in parallel.
- In US2, `T013` and `T014` can run in parallel.
- In US3, `T019` and `T020` can run in parallel.
- In US4, `T024` and `T025` can run in parallel.
- In Phase 7, `T029` and `T030` can run in parallel before the final verification pack.
---
## Parallel Example: User Story 1
```bash
# Launch US1 test work in parallel:
T008 apps/platform/tests/Feature/Filament/WorkspaceOverviewSummaryMetricsTest.php
T009 apps/platform/tests/Feature/Filament/WorkspaceOverviewContentTest.php
```
## Parallel Example: User Story 2
```bash
# Launch US2 ordering and continuity coverage in parallel:
T013 apps/platform/tests/Feature/Filament/WorkspaceOverviewRecoveryAttentionTest.php
T014 apps/platform/tests/Feature/Filament/WorkspaceOverviewDrilldownContinuityTest.php
```
## Parallel Example: User Story 3
```bash
# Launch US3 calmness coverage in parallel:
T019 apps/platform/tests/Feature/Filament/WorkspaceOverviewEmptyStatesTest.php + apps/platform/tests/Feature/Filament/WorkspaceOverviewContentTest.php
T020 apps/platform/tests/Feature/Filament/WorkspaceOverviewGovernanceAttentionTest.php
```
## Parallel Example: User Story 4
```bash
# Launch US4 visibility and authorization coverage in parallel:
T024 apps/platform/tests/Feature/Filament/WorkspaceOverviewPermissionVisibilityTest.php
T025 apps/platform/tests/Feature/Filament/WorkspaceOverviewAuthorizationTest.php + apps/platform/tests/Feature/Filament/WorkspaceOverviewDrilldownContinuityTest.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` now answers how many visible tenants need backup follow-up and how many need recovery-evidence follow-up.
### Incremental Delivery
1. Ship US1 to make the workspace home count backup-health and recovery-evidence hotspots honestly.
2. Add US2 to prioritize the right tenant and preserve tenant-dashboard drill-through continuity.
3. Add US3 to make calmness explicit and remove blind-spot calmness.
4. Add US4 to harden RBAC-safe omission, fallback behavior, and bounded claims.
5. Finish with copy alignment, cleanup, 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]`, `[US3]`, and `[US4]` labels.
- Parallel markers are used only on tasks that can proceed independently without conflicting incomplete prerequisites.

View File

@ -0,0 +1,35 @@
# Specification Quality Checklist: Tenant Registry Recovery Triage
**Purpose**: Validate specification completeness and quality before proceeding to planning
**Created**: 2026-04-09
**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
- Validated on 2026-04-09 after correcting the feature number to 186.
- No clarification markers remain; the spec is ready for `/speckit.plan`.

View File

@ -0,0 +1,119 @@
openapi: 3.1.0
info:
title: Tenant Registry Recovery Triage Route Contract
version: 0.1.0
summary: Internal navigation contract for workspace backup and recovery drilldowns into the tenant registry.
servers:
- url: /
paths:
/admin/tenants:
get:
operationId: openTenantRegistryTriage
summary: Open the tenant registry with exact triage intent preserved.
description: |
Multi-tenant workspace backup and recovery drilldowns open this route with exact posture filter semantics.
The route renders HTML, stays scoped to the current visible workspace slice, and must not reveal out-of-scope tenants.
parameters:
- name: backup_posture
in: query
required: false
description: Exact backup-posture filter values to apply on initial registry load.
style: form
explode: true
schema:
type: array
items:
type: string
enum:
- absent
- stale
- degraded
- healthy
- name: recovery_evidence
in: query
required: false
description: Exact recovery-evidence filter values to apply on initial registry load.
style: form
explode: true
schema:
type: array
items:
type: string
enum:
- weakened
- unvalidated
- no_recent_issues_visible
- name: triage_sort
in: query
required: false
description: Optional registry ordering mode. Workspace drilldowns default to worst-first.
schema:
type: string
enum:
- default
- worst_first
responses:
'200':
description: Tenant registry HTML rendered with the requested exact triage intent over the visible tenant set.
content:
text/html:
schema:
type: string
'404':
description: Workspace scope or tenant visibility is not established.
/admin/t/{tenant}:
get:
operationId: openSingleTenantDashboardTriage
summary: Open the tenant dashboard as the single-tenant triage destination.
description: |
Workspace backup or recovery drilldowns may resolve directly here when exactly one visible tenant is affected.
This route remains the canonical safe fallback when deeper backup or restore surfaces are not appropriate.
parameters:
- name: tenant
in: path
required: true
description: Tenant external route key.
schema:
type: string
responses:
'200':
description: Tenant dashboard HTML rendered inside the current workspace and tenant scope.
content:
text/html:
schema:
type: string
'404':
description: Tenant is not visible in the current workspace or the actor is not a member.
components:
schemas:
TenantRegistryTriageIntent:
type: object
additionalProperties: false
properties:
backup_posture:
type: array
items:
type: string
enum:
- absent
- stale
- degraded
- healthy
recovery_evidence:
type: array
items:
type: string
enum:
- weakened
- unvalidated
- no_recent_issues_visible
triage_sort:
type: string
enum:
- default
- worst_first
description: Exact filter and ordering intent used to open the tenant registry in recovery-triage mode.

View File

@ -0,0 +1,167 @@
# Data Model: Tenant Registry Recovery Triage
## Overview
Spec 186 adds no new persisted entity. The feature is a derived registry-view slice over existing tenant, backup-health, and recovery-evidence truth.
## Existing Persisted Inputs
### Tenant
- **Purpose**: Canonical tenant identity and lifecycle record for the workspace-scoped registry.
- **Key fields**:
- `id`
- `workspace_id`
- `external_id`
- `name`
- `tenant_id`
- `environment`
- `status`
- `domain`
- `created_at`
- **Derived fields already used by the registry**:
- `policies_count`
- `last_policy_sync_at`
- **Relationships**:
- belongs to one workspace
- has many memberships
- has many policies
- has many backup sets
- has many restore runs
### TenantBackupHealthAssessment
- **Purpose**: Existing derived tenant backup-health truth used by dashboard and workspace overview.
- **Persistence**: Not persisted.
- **Key fields**:
- `tenantId`
- `posture` = `absent | stale | degraded | healthy`
- `primaryReason` = `no_backup_basis | latest_backup_stale | latest_backup_degraded | schedule_follow_up | null`
- `headline`
- `supportingMessage`
- `latestRelevantBackupSetId`
- `latestRelevantCompletedAt`
- `positiveClaimBoundary`
- `primaryActionTarget`
- **Behavioral role in Spec 186**:
- provides the registrys `Backup posture` value
- provides the backup-attention set for workspace drilldown intent
- contributes to triage rank
### Dashboard Recovery Evidence Payload
- **Purpose**: Existing derived tenant recovery-evidence truth built from restore history.
- **Persistence**: Not persisted.
- **Key fields**:
- `backup_posture`
- `overview_state` = `unvalidated | weakened | no_recent_issues_visible`
- `headline`
- `summary`
- `claim_boundary`
- `latest_relevant_restore_run_id`
- `latest_relevant_attention_state`
- `reason` = `no_history | failed | partial | completed_with_follow_up | no_recent_issues_visible`
- **Behavioral role in Spec 186**:
- provides the registrys `Recovery evidence` value
- provides the recovery-attention set for workspace drilldown intent
- contributes to triage rank
## New Derived Read Models
### TenantRegistryTriageRow
- **Purpose**: Request-scoped row projection for the tenant registry.
- **Persistence**: Not persisted.
- **Fields**:
- `tenant_id`
- `tenant_route_key`
- `tenant_name`
- `environment`
- `lifecycle_status`
- `policies_count`
- `last_policy_sync_at`
- `backup_posture`
- `backup_reason`
- `recovery_evidence`
- `recovery_reason`
- `triage_rank`
- `next_action_url`
- **Validation rules**:
- `backup_posture` must come directly from `TenantBackupHealthAssessment::posture`
- `recovery_evidence` must come directly from `dashboardRecoveryEvidenceForTenants(...)[tenant]['overview_state']`
- `triage_rank` must be derived from the shared registry priority table, not hard-coded independently per column or filter
- `next_action_url` must resolve to an allowed destination in the current workspace and tenant scope
### TenantRegistryTriageIntent
- **Purpose**: Query-string driven list intent for opening the registry in a prefiltered triage state.
- **Persistence**: Not persisted.
- **Fields**:
- `backup_posture[]` optional array of `absent | stale | degraded | healthy`
- `recovery_evidence[]` optional array of `weakened | unvalidated | no_recent_issues_visible`
- `triage_sort` optional enum `default | worst_first`
- **Validation rules**:
- every `backup_posture[]` entry must be one of the canonical backup posture values
- every `recovery_evidence[]` entry must be one of the canonical recovery-evidence values
- if `backup_posture[]` is present for workspace backup attention, its intended set is `absent`, `stale`, and `degraded`
- if `recovery_evidence[]` is present for workspace recovery attention, its intended set is `weakened` and `unvalidated`
- workspace drilldowns that need weak-first behavior must set `triage_sort=worst_first` explicitly; manual registry filtering alone does not change the default calm-browsing sort
### WorkspaceAttentionDestination
- **Purpose**: Derived navigation target from workspace overview metrics or attention items.
- **Persistence**: Not persisted.
- **Fields**:
- `kind` = existing workspace-drilldown contract value already used by the current widgets; unchanged by this feature
- `url`
- `tenant_route_key` optional
- `filters` optional exact registry intent payload
- **Validation rules**:
- one affected visible tenant resolves to the existing single-tenant dashboard kind and opens `/admin/t/{tenant}`
- more than one affected visible tenant preserves the existing multi-tenant admin-plane kind while `url` now opens filtered `/admin/tenants`
- any registry destination must carry exact posture filter semantics, not a vague `needs attention` label
## Relationships
- **Tenant** has one derived `TenantBackupHealthAssessment` in registry context.
- **Tenant** has one derived dashboard recovery-evidence payload in registry context.
- **TenantRegistryTriageRow** combines one `Tenant` with one backup-health assessment and one recovery-evidence payload.
- **WorkspaceAttentionDestination** references either one specific tenant or one registry triage intent, but never both at once.
## Triage Ranking Model
### Canonical rank tiers
1. `backup_posture = absent`
2. `recovery_evidence = weakened`
3. `backup_posture = stale`
4. `recovery_evidence = unvalidated`
5. `backup_posture = degraded`
6. calm rows: `backup_posture = healthy` and `recovery_evidence = no_recent_issues_visible`
### Tie-breaker
- Rows inside the same tier are secondarily ordered by `tenant_name` ascending.
### Rule for dual-signal tenants
- If a tenant is weak in both domains, the highest active tier governs its `triage_rank`, but both posture signals remain visible in the row.
## State Transitions
There are no persisted state transitions in this feature.
### Derived transition rules
- A registry row moves between triage tiers only when the underlying backup-health or recovery-evidence truth changes.
- Registry intent transitions are URL-driven:
- unfiltered registry
- backup-filtered registry
- recovery-filtered registry
- manually refined registry
- cleared back to the default registry view
## Notes
- The registry continues to be the source of truth for tenant identity and lifecycle metadata in workspace context.
- The registry does not become the source of truth for full backup diagnostics, restore proof, or overall recoverability.

View File

@ -0,0 +1,257 @@
# Implementation Plan: Tenant Registry Recovery Triage
**Branch**: `186-tenant-registry-recovery-triage` | **Date**: 2026-04-09 | **Spec**: `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/specs/186-tenant-registry-recovery-triage/spec.md`
**Input**: Feature specification from `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/specs/186-tenant-registry-recovery-triage/spec.md`
## Summary
Turn the existing tenant registry into the operators portfolio recovery-triage surface without creating a second recovery-truth system. The implementation will keep `TenantBackupHealthResolver` and `RestoreSafetyResolver` as the only truth sources for tenant backup posture and tenant recovery evidence, project those states onto `TenantResource` as bounded list columns, add exact posture filters and deterministic worst-first ordering, and change workspace backup and recovery multi-tenant drilldowns from `ChooseTenant` to the filtered tenant registry while preserving single-tenant direct-to-dashboard behavior. The slice stays read-only, introduces no schema change, no new persistence, no new resolver truth, no new panel or asset registration, and no broader dashboard or chooser redesign.
Key approach: work inside the existing `TenantResource`, `ListTenants`, `WorkspaceOverviewBuilder`, and workspace widget seams; batch-load posture state via `assessMany()` and `dashboardRecoveryEvidenceForTenants()` across the current visible workspace tenant set; preserve intent through exact query-string filter parameters on `/admin/tenants`; keep the Filament list action-surface contract intact; and validate the result with focused Pest, Livewire, truthfulness, and RBAC coverage.
## Technical Context
**Language/Version**: PHP 8.4, Laravel 12, Blade, Filament v5, Livewire v4
**Primary Dependencies**: Filament v5 resources and table filters, Livewire v4 `ListRecords`, Pest v4, Laravel Sail, existing `TenantResource`, `ListTenants`, `WorkspaceOverviewBuilder`, `TenantBackupHealthResolver`, `TenantBackupHealthAssessment`, `RestoreSafetyResolver`, `RecoveryReadiness`, and shared badge infrastructure
**Storage**: PostgreSQL with existing tenant-owned `tenants`, `backup_sets`, `backup_items`, `restore_runs`, `policies`, and membership records; no schema change planned
**Testing**: Pest feature tests, Livewire resource-page tests, and focused unit coverage only if a narrow shared presentation or ranking seam is extracted, all run through Sail
**Target Platform**: Laravel web application in Sail locally and containerized Linux deployment in staging and production
**Project Type**: Laravel monolith web application rooted at `apps/platform`
**Performance Goals**: Keep registry rendering DB-only and query-bounded, avoid per-row resolver N+1 behavior, preserve current table pagination and session-persisted filter or sort behavior, and keep the operators first-scan triage answer within 5 to 10 seconds
**Constraints**: No new persisted portfolio posture table, no new recovery score, no chooser redesign, no tenant-level resolver truth change, no cross-tenant leakage, no new dashboard widgets, no extra registry inspect action, no page-local badge language, and no new Filament assets
**Scale/Scope**: One workspace overview builder, one tenant registry resource and page pair, one existing workspace summary or attention destination contract, optional shared badge or copy extension for existing posture states, and focused regression coverage across filters, ordering, drilldowns, truthfulness, and RBAC
## Constitution Check
*GATE: Passed before Phase 0 research. Re-checked after Phase 1 design and still passing.*
| Principle | Status | Notes |
|-----------|--------|-------|
| Inventory-first | Pass | The feature reads existing tenant backup and restore evidence only; no new source of truth or snapshot behavior is introduced |
| Read/write separation | Pass | This is a read-first list and drilldown hardening slice with no new write path |
| Graph contract path | Pass | No Microsoft Graph call or `config/graph_contracts.php` change is required |
| Deterministic capabilities | Pass | Existing capability registry and current tenant or workspace scope rules remain authoritative |
| RBAC-UX planes and 404 vs 403 | Pass | Workspace surfaces stay under `/admin`, tenant follow-up stays under `/admin/t/{tenant}`, non-members remain `404`, and deeper capability checks remain server-side |
| Workspace isolation | Pass | Only current-workspace and in-scope tenants may appear in the registry or drilldowns |
| Tenant isolation | Pass | Registry posture is still derived per tenant and filtered to visible memberships only |
| Destructive confirmation standard | Pass | No new destructive action is introduced; existing destructive tenant actions remain unchanged and confirmed |
| Global search safety | Pass | TenantResource already has view and edit pages; this slice does not broaden global-search scope or semantics |
| Run observability | Pass | No new queued work, no new `OperationRun`, and no lifecycle transition is added |
| Ops-UX 3-surface feedback | Pass | No run feedback surface is added or changed |
| Ops-UX lifecycle ownership | Pass | `OperationRun.status` and `OperationRun.outcome` remain untouched |
| Ops-UX summary counts | Pass | No `summary_counts` change is needed |
| Data minimization | Pass | Registry posture uses existing derived summaries and does not expose deeper payload detail |
| Proportionality (PROP-001) | Pass | Changes stay inside existing list and builder seams; no new persistence, no new page shell, and no new portfolio framework |
| No premature abstraction (ABSTR-001) | Pass | The plan prefers local resource or page logic plus existing resolvers; any shared badge or copy extraction stays within already-existing seams |
| Persisted truth (PERSIST-001) | Pass | No new table, column, cache, or materialized posture mirror is introduced |
| Behavioral state (STATE-001) | Pass | Registry reuses existing posture states only; no new domain state family is added |
| UI semantics (UI-SEM-001) | Pass | The registry projects existing domain truth directly; no new explanation or confidence framework is introduced |
| Badge semantics (BADGE-001) | Pass | Status-like registry badges must use shared badge or copy semantics rather than ad-hoc local colors or labels |
| Filament-native UI (UI-FIL-001) | Pass | Existing Filament table columns, filters, and widgets remain the implementation seams |
| UI Action Surface Contract | Pass | `TenantResource` remains a list-first resource with row-click inspect and at most one inline safe shortcut; no redundant View action is added |
| Filament UX-001 | Pass with documented variance | No create or edit layout change is involved; this slice refines list scanability, filters, and drillthrough continuity only |
| Filament v5 / Livewire v4 compliance | Pass | The implementation stays inside the current Filament v5 and Livewire v4 stack |
| Provider registration location | Pass | No panel or provider change is required; Laravel 11+ provider registration remains in `bootstrap/providers.php` |
| Asset strategy | Pass | No new assets are planned; existing deployment `filament:assets` behavior remains unchanged |
## Phase 0 Research
Research outcomes are captured in `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/specs/186-tenant-registry-recovery-triage/research.md`.
Key decisions:
- Use workspace-scoped batch posture maps built from `TenantBackupHealthResolver::assessMany()` and `RestoreSafetyResolver::dashboardRecoveryEvidenceForTenants()` instead of per-row resolver calls or a persisted registry summary.
- Preserve workspace drilldown intent through exact `/admin/tenants` query parameters and `ListTenants` initialization rather than `ChooseTenant`, hidden session-only state, or a new page shell.
- Use multi-select posture filters so workspace backup attention can preselect `absent`, `stale`, and `degraded`, and workspace recovery attention can preselect `weakened` and `unvalidated`, without inventing a pseudo `needs attention` filter value.
- Keep the TenantResource action-surface contract intact: full-row click remains inspect, and the existing one-inline-shortcut pattern remains the fast next click.
- Reuse or extract one shared mapping seam from `RecoveryReadiness` for label, tone, and fallback-URL semantics so the registry and dashboard cannot drift into parallel local mapping tables for the same posture states.
- Extend the current workspace summary-metric tests, workspace continuity tests, tenant registry scope tests, and table-guard coverage rather than introducing a browser-first harness.
## Phase 1 Design
Design artifacts are created under `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/specs/186-tenant-registry-recovery-triage/`:
- `research.md`: implementation and design decisions for posture loading, exact filter intent, and continuity behavior
- `data-model.md`: existing persisted inputs plus the derived registry row and triage-intent model
- `contracts/tenant-registry-recovery-triage.openapi.yaml`: internal route contract for filtered registry and single-tenant dashboard drilldowns
- `quickstart.md`: focused automated and manual validation workflow for registry posture triage
Design decisions:
- No schema migration is required. The registry will consume existing tenant truth and derive row-level posture and rank at render time.
- `TenantResource` remains workspace-scoped and continues to own the canonical `/admin/tenants` surface. The feature adds columns, filters, and triage ordering, not a new resource or a new shell.
- Triage filtering should be exact, not vague. Backup drilldowns preselect `absent`, `stale`, and `degraded`; recovery drilldowns preselect `weakened` and `unvalidated`; both default to worst-first sorting.
- Worst-first ordering must operate over the filtered visible tenant set before pagination, using a deterministic tier map and a stable secondary order.
- `WorkspaceOverviewBuilder` is the only place where backup and recovery multi-tenant drilldown destinations need to change. Existing workspace widgets already honor `destination_url` and should not need a new interaction model.
- The tenant dashboard remains the safe fallback next step whenever deeper backup or restore surfaces would lose the same posture reason or fail permission checks.
## Project Structure
### Documentation (this feature)
```text
specs/186-tenant-registry-recovery-triage/
├── spec.md
├── plan.md
├── research.md
├── data-model.md
├── quickstart.md
├── contracts/
│ └── tenant-registry-recovery-triage.openapi.yaml
├── checklists/
│ └── requirements.md
└── tasks.md
```
### Source Code (repository root)
```text
apps/platform/
├── app/
│ ├── Filament/
│ │ ├── Pages/
│ │ │ └── WorkspaceOverview.php
│ │ ├── Resources/
│ │ │ ├── TenantResource.php
│ │ │ └── TenantResource/
│ │ │ └── Pages/
│ │ │ └── ListTenants.php
│ │ └── Widgets/
│ │ ├── Dashboard/
│ │ │ └── RecoveryReadiness.php
│ │ └── Workspace/
│ │ ├── WorkspaceNeedsAttention.php
│ │ └── WorkspaceSummaryStats.php
│ └── Support/
│ ├── BackupHealth/
│ │ ├── TenantBackupHealthAssessment.php
│ │ └── TenantBackupHealthResolver.php
│ ├── RestoreSafety/
│ │ └── RestoreSafetyResolver.php
│ ├── Workspaces/
│ │ └── WorkspaceOverviewBuilder.php
│ └── Badges/
│ ├── BadgeCatalog.php
│ ├── BadgeDomain.php
│ └── BadgeRenderer.php
└── tests/
├── Feature/
│ └── Filament/
│ ├── TenantResourceIndexIsWorkspaceScopedTest.php
│ ├── WorkspaceOverviewAuthorizationTest.php
│ ├── WorkspaceOverviewDrilldownContinuityTest.php
│ ├── WorkspaceOverviewSummaryMetricsTest.php
│ └── TenantRegistryRecoveryTriageTest.php
└── Feature/
└── Guards/
├── ActionSurfaceContractTest.php
└── FilamentTableStandardsGuardTest.php
```
**Structure Decision**: Standard Laravel monolith under `apps/platform`. The implementation stays inside the current tenant registry resource, current workspace overview builder, existing workspace widgets, and existing tests. If a shared presentation seam is required for the new registry badges, it must extend an existing badge or copy surface rather than introducing a new framework.
## Implementation Strategy
### Phase A — Build Workspace-Scoped Posture Snapshots For The Registry
**Goal**: Make backup posture, recovery evidence, filter sets, and triage rank available to the registry without per-row resolver calls or a new persisted summary.
| Step | File | Change |
|------|------|--------|
| A.1 | `apps/platform/app/Filament/Resources/TenantResource.php` | Add one request-scoped posture snapshot path over the already scoped tenant set using `assessMany()` and `dashboardRecoveryEvidenceForTenants()`, keyed by tenant ID and reusable by columns, filters, and worst-first ordering |
| A.2 | `apps/platform/app/Filament/Resources/TenantResource.php` | Derive exact filter buckets and triage rank buckets from the snapshot map so filtering and ordering operate on the same truth source |
| A.3 | `apps/platform/app/Filament/Widgets/Dashboard/RecoveryReadiness.php` and `apps/platform/app/Filament/Resources/TenantResource.php` | Reuse or extract one shared bounded label or tone mapping seam for `absent`, `stale`, `degraded`, `healthy`, `weakened`, `unvalidated`, and `no recent issues visible` rather than local page-only mappings |
### Phase B — Turn TenantResource Into A Working Triage Surface
**Goal**: Add visible posture truth, exact filters, and deterministic weak-first ordering while keeping the current list action surface stable.
| Step | File | Change |
|------|------|--------|
| B.1 | `apps/platform/app/Filament/Resources/TenantResource.php` | Add default-visible `Backup posture` and `Recovery evidence` columns near tenant identity and lifecycle, keeping metadata and recovery truth visually separate |
| B.2 | `apps/platform/app/Filament/Resources/TenantResource.php` | Add multi-select backup-posture and recovery-evidence filters using exact state values and workspace-scoped `whereIn` filtering over snapshot-derived tenant ID sets |
| B.3 | `apps/platform/app/Filament/Resources/TenantResource.php` | Add deterministic worst-first ordering using the triage-rank map and stable secondary ordering by tenant name while preserving current default calm browsing when triage ordering is not requested |
| B.4 | `apps/platform/app/Filament/Resources/TenantResource.php` | Keep row click as the canonical inspect model and reuse the existing safe inline shortcut for fast next-step clarity instead of adding a new View action |
### Phase C — Preserve Drilldown Intent On `/admin/tenants`
**Goal**: Ensure workspace backup and recovery drilldowns open the registry in a visibly filtered, weak-first state rather than losing cause in `ChooseTenant`.
| Step | File | Change |
|------|------|--------|
| C.1 | `apps/platform/app/Filament/Resources/TenantResource/Pages/ListTenants.php` | Add narrow query-string initialization for exact posture filter arrays and `triage_sort=worst_first`, translating them into the pages table filter or sort state without changing the core page model |
| C.2 | `apps/platform/app/Filament/Resources/TenantResource/Pages/ListTenants.php` | Preserve existing session-backed list state while allowing explicit query-string intent to override it on first load for workspace drilldowns |
| C.3 | `apps/platform/app/Filament/Resources/TenantResource.php` and `ListTenants.php` | Add a bounded empty-state or subheading strategy for zero-match filtered triage views so the registry stays scoped and honest even when no visible tenant matches the current filter |
### Phase D — Change Workspace Backup And Recovery Multi-Tenant Destinations
**Goal**: Move multi-tenant backup and recovery attention drilldowns from `ChooseTenant` to the filtered registry while keeping exact single-tenant behavior.
| Step | File | Change |
|------|------|--------|
| D.1 | `apps/platform/app/Support/Workspaces/WorkspaceOverviewBuilder.php` | Replace multi-tenant `choose_tenant` destinations for `backup_attention_tenants` and `recovery_attention_tenants` with exact `/admin/tenants` URLs carrying posture filter arrays and `triage_sort=worst_first` |
| D.2 | `apps/platform/app/Support/Workspaces/WorkspaceOverviewBuilder.php` | Keep single-tenant backup or recovery drilldowns routed directly to the tenant dashboard when only one visible tenant is affected |
| D.3 | `apps/platform/app/Support/Workspaces/WorkspaceOverviewBuilder.php` and existing workspace widgets | Keep the existing `destination_url` and destination-kind contract unchanged and do not introduce a new `tenant_registry` kind label for this slice |
### Phase E — Lock Semantics With Focused Regression Coverage
**Goal**: Protect list truth, triage ordering, workspace continuity, and scope safety against regression.
| Step | File | Change |
|------|------|--------|
| E.1 | `apps/platform/tests/Feature/Filament/TenantRegistryRecoveryTriageTest.php` | Add focused coverage for row posture rendering, exact backup filters, exact recovery filters, worst-first ordering, overclaim avoidance, and metadata separation |
| E.2 | `apps/platform/tests/Feature/Filament/WorkspaceOverviewSummaryMetricsTest.php` | Update the backup-attention multi-tenant expectation from `choose_tenant` to the filtered tenant registry and add equivalent multi-tenant recovery coverage where needed |
| E.3 | `apps/platform/tests/Feature/Filament/WorkspaceOverviewDrilldownContinuityTest.php` | Extend continuity coverage so backup and recovery attention preserve meaning through the tenant-registry destination as well as single-tenant dashboard destinations |
| E.4 | `apps/platform/tests/Feature/Filament/WorkspaceOverviewAuthorizationTest.php` and `TenantResourceIndexIsWorkspaceScopedTest.php` | Verify no hidden-tenant leakage across posture filters, triage ordering, or workspace drilldowns |
| E.5 | `apps/platform/tests/Feature/Guards/ActionSurfaceContractTest.php` and `FilamentTableStandardsGuardTest.php` | Keep existing list-surface and table-standards guards passing after the registry column and filter changes |
| E.6 | `cd apps/platform && ./vendor/bin/sail bin pint --dirty --format agent` and focused Pest runs | Required formatting and targeted verification before implementation is considered complete |
## Key Design Decisions
### D-001 — Registry posture stays fully derived
The tenant registry consumes existing backup and recovery truth at render time. There is no new tenant summary table, no cached portfolio score, and no new recovery-confidence persistence.
### D-002 — Exact states beat vague attention flags
Workspace drilldowns should preselect exact posture states, not a generic `needs attention` filter. That keeps the registry honest and makes the visible filter state itself the explanation.
### D-003 — Weak-first ordering must happen before pagination
If ranking happens only on the current page, the worst tenant may remain hidden on another page. The rank map therefore needs to shape the filtered query order over the full scoped tenant set before pagination.
### D-004 — Existing list interaction model remains intact
`TenantResource` already satisfies the list-first inspect model. The feature must not turn it into a multi-action dashboard or add a redundant View affordance.
### D-005 — Recovery-readiness semantics should not fork
The codebase already has one place that renders backup posture and recovery evidence together: `RecoveryReadiness`. The registry should call the same shared bounded mapping seam, either by reusing existing methods or by extracting a narrow helper into the same seam, rather than creating another local mapping language inside `TenantResource`.
### D-006 — Workspace widgets keep the existing destination contract
The workspace widgets already consume `destination_url` correctly. This slice changes those URLs for multi-tenant backup and recovery drilldowns, but it does not add a new destination-kind value or require a widget interaction redesign.
### D-007 — Workspace overview only changes destination logic
The workspace overview already computes backup and recovery attention correctly. This slice changes where multi-tenant clicks land, not what those metrics mean.
## Risk Assessment
| Risk | Impact | Likelihood | Mitigation |
|------|--------|------------|------------|
| Registry filtering or ranking is computed only for the current page and hides weaker tenants on later pages | High | Medium | Build filter buckets and triage rank across the full scoped tenant set before pagination and cover with ordering tests |
| Registry adds new local badge or tone mappings that drift from existing dashboard posture semantics | High | Medium | Reuse existing shared badge or copy seams and add truthfulness coverage for labels and tones |
| Multi-tenant workspace drilldowns still lose cause because filter intent is not visibly applied on first load | High | Medium | Initialize ListTenants from exact query parameters and cover continuity through summary-metric and drilldown tests |
| Metadata such as lifecycle status or last sync visually reads like recovery truth after the new columns are added | Medium | Medium | Place backup posture and recovery evidence explicitly, keep them separately labeled, and add no-metadata-substitution coverage |
| Batch posture loading over the scoped tenant set becomes too expensive in larger workspaces | Medium | Medium | Reuse existing batch resolvers, keep query shape bounded to the current scoped tenant set, and cover the feature with query-bounded regression expectations |
## Test Strategy
- Add a focused tenant-registry triage feature test as the primary acceptance harness for row rendering, filters, worst-first ordering, and truthfulness.
- Add explicit query-bounded regression coverage so registry posture loading does not degrade into uncontrolled per-row resolver fanout.
- Extend current workspace summary-metric and drilldown continuity tests so multi-tenant backup and recovery destinations move from `ChooseTenant` to the filtered registry without regressing the single-tenant dashboard path.
- Extend workspace authorization and tenant registry scope coverage so posture filters and registry drilldowns remain bounded to visible tenants only.
- Keep existing action-surface and table-standards guards green so the tenant registry stays compliant with the Filament list contract after the new columns and filters are added.
- Prefer Livewire or feature-level verification over browser-first testing because the codebase already has direct coverage seams for `ListTenants`, `WorkspaceOverviewBuilder`, and related widgets.
- Run all focused tests through Sail and finish with `cd apps/platform && ./vendor/bin/sail bin pint --dirty --format agent`.

View File

@ -0,0 +1,86 @@
# Quickstart: Tenant Registry Recovery Triage
## Purpose
Validate that the tenant registry becomes a useful recovery-triage surface and that workspace backup or recovery drilldowns preserve their meaning.
## Prerequisites
1. Start the local stack:
```bash
cd apps/platform && ./vendor/bin/sail up -d
```
2. Work on branch `186-tenant-registry-recovery-triage`.
3. Use an owner or readonly workspace member who can see multiple tenants in the same workspace.
## Primary Automated Verification
Run the focused test set that covers the registry, workspace drilldowns, and scope safety:
```bash
cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Filament/TenantRegistryRecoveryTriageTest.php
cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Filament/WorkspaceOverviewSummaryMetricsTest.php
cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Filament/WorkspaceOverviewDrilldownContinuityTest.php
cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Filament/TenantResourceIndexIsWorkspaceScopedTest.php
cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Filament/WorkspaceOverviewAuthorizationTest.php
```
Expected outcomes:
- Tenant registry rows show separate `Backup posture` and `Recovery evidence` signals.
- Exact backup and recovery filters return only matching visible tenants.
- Worst-first triage ordering places weak tenants ahead of calm rows.
- Query-bounded registry posture loading does not regress into uncontrolled per-row resolver fanout.
- Multi-tenant workspace backup and recovery metrics open the filtered tenant registry instead of `ChooseTenant`.
- Single-tenant workspace drilldowns still open the tenant dashboard.
- No out-of-scope tenant leaks into registry rows, filters, or destinations.
## Manual Smoke Validation
Use a workspace that already contains a mixed visible tenant set with at least these cases:
- one tenant with `absent` backup posture
- one tenant with `stale` backup posture
- one tenant with `degraded` backup posture
- one tenant with `weakened` recovery evidence
- one tenant with `unvalidated` recovery evidence
- one calm tenant with `healthy` backup posture and `no recent issues visible`
Then verify:
1. Open `/admin` and confirm `Backup attention` and `Recovery attention` counts still appear separately.
2. If `Backup attention > 1`, click the metric and confirm the destination is `/admin/tenants` with backup posture filter intent preserved and weak tenants first.
3. If `Recovery attention > 1`, click the metric and confirm the destination is `/admin/tenants` with recovery-evidence filter intent preserved and weak tenants first.
4. Open `/admin/tenants` directly and confirm the registry shows separate `Backup posture` and `Recovery evidence` columns next to metadata and lifecycle fields.
5. Apply a backup filter such as `degraded` and confirm only degraded visible tenants remain.
6. Apply a recovery filter such as `unvalidated` and confirm only unvalidated visible tenants remain.
7. Confirm calm rows do not erase weak rows when worst-first ordering is active.
8. Open a prioritized row and confirm the registry detail route preserves the same bounded weakness truth shown in the list.
9. Use the safe dashboard shortcut for that prioritized tenant and confirm the tenant dashboard still shows the same bounded weakness truth.
10. Confirm the registry never says `recoverable`, `recovery proven`, or `validated overall`.
11. Time the first `/admin/tenants` triage scan and confirm a workspace operator can identify which visible tenants are weak on backup posture, which are weak on recovery evidence, and which tenant to open first within 10 seconds.
## Optional Fixture Note
The existing fixture command below is useful for backup-health smoke work, but it does not seed the full multi-tenant mixed posture matrix required for Spec 186 by itself:
```bash
cd apps/platform && ./vendor/bin/sail artisan tenantpilot:backup-health:seed-browser-fixture --force-refresh
```
For Spec 186, the focused Pest tests remain the primary acceptance harness.
## Formatting
Before handing off implementation, run:
```bash
cd apps/platform && ./vendor/bin/sail bin pint --dirty --format agent
```
## No Migration Expected
This feature should complete with no schema migration and no new persisted posture summary.

View File

@ -0,0 +1,52 @@
# Research: Tenant Registry Recovery Triage
## Decision 1: Use workspace-scoped batch posture maps from existing resolvers
- **Decision**: Build tenant-registry posture data from `TenantBackupHealthResolver::assessMany()` and `RestoreSafetyResolver::dashboardRecoveryEvidenceForTenants()` over the already scoped visible tenant set instead of resolving posture per row.
- **Rationale**: Both resolvers already provide the exact truth the registry needs and are already used together in `WorkspaceOverviewBuilder`. Reusing them keeps backup posture and recovery evidence aligned with existing workspace and tenant surfaces and avoids N+1 list rendering.
- **Alternatives considered**:
- Per-row resolver calls inside table column closures: rejected because it creates predictable N+1 behavior and duplicates work between columns, filters, and sorting.
- Reimplement posture logic in SQL joins or subqueries: rejected because it duplicates domain truth outside the existing resolver path and risks semantic drift.
- Persist a tenant posture summary table: rejected because the spec explicitly forbids a second persisted truth.
## Decision 2: Preserve multi-tenant drilldown intent through exact query parameters on `/admin/tenants`
- **Decision**: Change workspace backup and recovery multi-tenant destinations to `/admin/tenants` with query parameters that encode exact posture selections and `triage_sort=worst_first`.
- **Rationale**: The codebase already uses query-string intent on list and page surfaces, and this approach preserves meaning directly in the URL without adding a new page, hidden state, or session-only transfer mechanism.
- **Alternatives considered**:
- Keep `ChooseTenant` for multi-tenant drilldowns: rejected because it drops the recovery or backup cause and sends the operator back to manual tenant-by-tenant inspection.
- Use only session state and no URL signal: rejected because it makes drilldown state invisible and harder to test.
- Add a dedicated portfolio recovery page: rejected because it introduces a larger IA change than the current release needs.
## Decision 3: Use multi-select posture filters with exact state values
- **Decision**: Represent triage filters as exact state arrays rather than introducing `needs attention` or other pseudo-filter values.
- **Rationale**: The spec explicitly requires clear posture-based filtering and rejects semantically vague attention filters. Multi-select filters allow workspace backup drilldowns to preselect `absent`, `stale`, and `degraded`, and recovery drilldowns to preselect `weakened` and `unvalidated`, while keeping the filter chips themselves truthful.
- **Alternatives considered**:
- Single-select filters plus an `attention` option: rejected because it hides the underlying state composition.
- Custom tabs or page variants per posture family: rejected because they expand the surface model unnecessarily.
- Hard-coded workspace-only registry variants: rejected because the registry should stay one canonical collection route.
## Decision 4: Keep the TenantResource list action surface contract intact
- **Decision**: Preserve full-row click as the one primary inspect model and keep at most one inline safe shortcut for fast next-step navigation.
- **Rationale**: `TenantResource` is already governed by the current action-surface contract and guard tests. The triage enhancement should be expressed through visible posture truth, filters, and ordering rather than additional row actions.
- **Alternatives considered**:
- Add a dedicated View action: rejected because it duplicates the existing row-click inspect model.
- Add multiple inline shortcuts to backup sets, restore runs, and dashboard: rejected because it would turn the registry into a crowded multi-action surface and violate the list hierarchy rules.
## Decision 5: Reuse existing recovery-readiness presentation semantics instead of inventing registry-local posture language
- **Decision**: Reuse or absorb the label, tone, and fallback-navigation semantics already present in `RecoveryReadiness` for `Backup posture` and `Recovery evidence` when the registry renders the same states.
- **Rationale**: The codebase already has one operator-facing composition that normalizes these exact states. Reusing that semantics avoids drift between dashboard and registry surfaces and keeps any new status-like rendering inside existing shared seams.
- **Alternatives considered**:
- Local `match` statements inside `TenantResource`: rejected because they would create another page-local status language.
- A new presentation framework or registry-specific presenter: rejected because there are only two concrete surfaces and the constitution prefers small extensions of existing seams over new frameworks.
## Decision 6: Use existing feature and guard tests as the primary acceptance harness
- **Decision**: Extend current workspace overview and tenant resource tests and add one focused registry triage test file instead of introducing a browser-first harness.
- **Rationale**: The repo already has precise seams for workspace summary metrics, drilldown continuity, tenant registry scope, and Filament table standards. Those seams are sufficient to prove truthfulness, scope safety, and ordering.
- **Alternatives considered**:
- New browser tests: rejected because the relevant behaviors are already observable at the builder and Livewire list layers.
- Manual-only QA: rejected because the spec explicitly requires regression-safe automated coverage.

View File

@ -0,0 +1,260 @@
# Feature Specification: Tenant Registry Recovery Triage
**Feature Branch**: `186-tenant-registry-recovery-triage`
**Created**: 2026-04-09
**Status**: Draft
**Input**: User description: "Spec 186 — Tenant Registry Recovery Triage"
## Spec Scope Fields *(mandatory)*
- **Scope**: workspace
- **Primary Routes**:
- `/admin` as the workspace overview that currently surfaces backup and recovery attention counts and needs a more useful portfolio drill-through
- `/admin/tenants` as the canonical tenant registry collection route that becomes the working recovery-triage surface for visible tenants
- `/admin/tenants/{tenant}` as the existing tenant-registry inspect route opened by full-row click from the registry
- `/admin/choose-tenant` only as the displaced multi-tenant fallback for backup and recovery attention drilldowns; this feature removes that fallback for those posture-specific cases
- `/admin/t/{tenant}` as the canonical tenant-dashboard follow-up opened by the existing safe shortcut when one affected tenant is in scope or when deeper tenant surfaces are not the safest truthful destination
- `/admin/t/{tenant}/backup-sets` and `/admin/t/{tenant}/restore-runs` only when an existing tenant-scoped destination preserves the same posture reason without overclaiming or breaking permissions
- **Data Ownership**:
- Tenant identity, lifecycle, and directory metadata remain authoritative on the tenant registry row and continue to belong to the existing tenant record
- Tenant backup posture remains derived from the existing tenant-level backup-health source of truth built from current backup and schedule records; this feature adds no new persisted portfolio posture table or score
- Tenant recovery evidence remains derived from the existing tenant-level recovery-evidence source of truth built from restore history; this feature adds no new persisted recovery registry summary or portfolio confidence artifact
- Registry filters, sorting, and workspace drilldowns remain derived views over visible tenant truth only; this feature does not create a second domain model for backup or recovery posture
- **RBAC**:
- Workspace membership remains required to render `/admin` and `/admin/tenants`
- Only tenants visible inside the current workspace and tenant-membership scope may appear in the registry, contribute to posture filters, or appear behind backup and recovery drilldowns
- The tenant dashboard and any deeper backup or restore surfaces continue to enforce existing tenant-scoped read and mutation permissions; registry visibility does not upgrade access
- Non-members remain deny-as-not-found, and any deeper destination that is not available must fall back to a safe allowed tenant surface rather than a broken or misleading link
## 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 registry list | CRUD / list-first resource | Full-row click remains the canonical inspect path for tenant registry detail, while one safe inline shortcut continues the triage flow into the tenant dashboard | required | One inline safe shortcut plus existing More menu | Existing More and bulk More placement remain unchanged | `/admin/tenants` | `/admin/tenants/{tenant}` | Active workspace plus visible-tenant scope stay explicit | Tenants / Tenant | Backup posture and recovery evidence are visible beside lifecycle metadata | Working-surface registry augmentation |
| Workspace overview backup and recovery drilldowns | Embedded status summary / drill-in surface | Explicit stat or attention click opens a filtered tenant registry for multi-tenant cases or the tenant dashboard for a single-tenant case | forbidden | none | none | `/admin` | `/admin/tenants` or `/admin/t/{tenant}` depending on affected-tenant count | Active workspace plus visible-tenant scope stay explicit before navigation | Backup attention / Recovery attention | Which visible tenants need backup or recovery follow-up and whether the cause is preserved through the next click | Mixed-count drilldown surface |
| Tenant dashboard follow-up landing | Tenant landing / detail-first follow-up | Direct page open from a registry shortcut or single-tenant workspace drilldown | forbidden | Existing dashboard actions only | none added by this feature | `/admin/t/{tenant}` | `/admin/t/{tenant}/backup-sets` or `/admin/t/{tenant}/restore-runs` remain secondary destinations | Workspace context plus tenant context remain explicit | Tenant dashboard | Tenant-level backup health and recovery evidence confirm why the registry prioritized the tenant | Existing tenant dashboard pattern |
## 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 registry list | Workspace operator | List-first working surface | Which visible tenants are weak on backups or recovery evidence, and which one should I open first? | Tenant identity, environment, lifecycle, backup posture, recovery evidence, and visible triage filter or sort state | Full backup-quality diagnostics, restore-run outcome detail, and deep explanations remain downstream | lifecycle, backup posture, recovery evidence | none added by this feature; existing tenant maintenance actions stay secondary | Filter by posture, sort worst-first, inspect the registry detail route by row click, use the tenant-dashboard shortcut when needed | Existing sync, archive, or other destructive or operational actions remain unchanged and secondary |
| Workspace overview backup and recovery drilldowns | Workspace operator | Embedded summary / drill-in | Which registry slice should I open from this count, and will it preserve why I clicked? | Backup-attention or recovery-attention count plus one bounded destination | Tenant-by-tenant diagnostics remain secondary to the drill-through | backup attention volume, recovery attention volume | none | Open filtered tenant registry or direct tenant dashboard | none |
| Tenant dashboard follow-up landing | Workspace operator after drill-through | Tenant landing page | Why was this tenant prioritized, and what deeper surface should I open next? | Tenant-level backup health, recovery evidence, and the same bounded posture context that triggered the registry ranking | Full backup-set and restore-run diagnostics remain deeper tenant surfaces | backup posture, recovery evidence, tenant-local follow-up | none added by this feature | Continue to backup sets or restore runs when needed | none added by this feature |
## Proportionality Review *(mandatory when structural complexity is introduced)*
- **New source of truth?**: No. Tenant backup-health truth and tenant recovery-evidence truth remain authoritative.
- **New persisted entity/table/artifact?**: No. The feature explicitly forbids a new persisted portfolio posture table, score, or confidence ledger.
- **New abstraction?**: No. The narrow implementation is to extend the existing tenant registry and workspace drill-through behavior with derived posture columns, filters, and ordering.
- **New enum/state/reason family?**: No. The feature reuses the existing tenant backup posture and recovery-evidence states and only defines ordering over them in registry context.
- **New cross-domain UI framework/taxonomy?**: No. The slice keeps the existing backup and recovery vocabulary and avoids a new confidence or triage framework.
- **Current operator problem**: Workspace operators can already see that some tenants are weak on backup health or recovery evidence, but they still cannot answer from one working surface which tenants are absent, stale, degraded, weakened, or unvalidated, nor which tenant should be opened first.
- **Existing structure is insufficient because**: The current tenant registry is still metadata-first and the current workspace backup and recovery drilldowns lose cause by sending multi-tenant cases to a generic tenant chooser instead of a filtered registry slice.
- **Narrowest correct implementation**: Reuse the existing tenant registry as the canonical portfolio triage surface, add posture columns and posture filters backed by existing truth, add a consistent worst-first ordering, and redirect backup and recovery drilldowns into that filtered registry without introducing new persistence or a broader portfolio matrix.
- **Ownership cost**: The repo takes on additional registry rendering logic, posture filter and ordering rules, drill-through preservation rules, query-bounded loading, and regression coverage for truthfulness, RBAC, and list usability.
- **Alternative intentionally rejected**: A new portfolio recovery dashboard, a persisted posture summary table, a new global score, or a redesigned tenant chooser were rejected because they add a second truth or a larger IA shift before the existing registry surface is used correctly.
- **Release truth**: Current-release workflow hardening. The domain truth already exists; this slice makes the portfolio workflow usable.
## User Scenarios & Testing *(mandatory)*
### User Story 1 - See Weak Tenants On One Registry Surface (Priority: P1)
As a workspace operator, I want the tenant registry to show backup posture and recovery evidence directly on each visible row so that I can identify weak tenants within seconds without opening every tenant dashboard.
**Why this priority**: This is the core workflow gap. Without visible posture on the registry itself, the product still forces tenant-by-tenant inspection.
**Independent Test**: Can be fully tested by seeding visible tenants with mixed backup and recovery states, opening `/admin/tenants`, and verifying that each row shows bounded backup posture and recovery evidence without turning metadata into recovery truth.
**Acceptance Scenarios**:
1. **Given** visible tenants include `absent`, `stale`, `degraded`, and `healthy` backup posture cases, **When** the operator opens `/admin/tenants`, **Then** each row shows a scan-friendly backup-posture signal.
2. **Given** visible tenants include `weakened`, `unvalidated`, and `no_recent_issues_visible` recovery evidence cases, **When** the operator opens `/admin/tenants`, **Then** each row shows a separate recovery-evidence signal.
3. **Given** a tenant has healthy backups and `no_recent_issues_visible` recovery evidence, **When** the row renders, **Then** the registry does not claim that the tenant is recovery proven, fully recoverable, or healthy overall.
---
### User Story 2 - Filter And Rank The Weakest Tenants First (Priority: P1)
As a workspace operator, I want posture filters and a worst-first ordering so that I can reduce the registry to the exact weak slice I need and start with the highest-priority tenants.
**Why this priority**: Visibility alone is not enough. The registry must behave like a triage surface, not just a directory.
**Independent Test**: Can be fully tested by applying backup and recovery filters and by invoking posture-oriented ordering in a mixed visible tenant set.
**Acceptance Scenarios**:
1. **Given** visible tenants have mixed backup posture, **When** the operator filters for `degraded`, **Then** only visible degraded tenants remain in the registry.
2. **Given** visible tenants have mixed recovery evidence, **When** the operator filters for `unvalidated`, **Then** only visible unvalidated tenants remain in the registry.
3. **Given** the registry contains calm and weak tenants, **When** the operator uses worst-first triage ordering, **Then** weak tenants appear before calm tenants in a consistent, testable order.
---
### User Story 3 - Preserve Workspace Meaning During Drilldown (Priority: P1)
As a workspace operator, I want backup and recovery attention counts to open a filtered tenant registry instead of a generic tenant chooser so that the reason I clicked remains visible after navigation.
**Why this priority**: The workspace overview is already honest about counts, but it still drops the operator into a surface that does not preserve the cause.
**Independent Test**: Can be fully tested by opening backup and recovery attention drilldowns for single-tenant and multi-tenant cases and verifying that the destination keeps the same meaning.
**Acceptance Scenarios**:
1. **Given** more than one visible tenant contributes to backup attention, **When** the operator opens that drilldown, **Then** the destination is `/admin/tenants` with backup-posture intent already preserved.
2. **Given** more than one visible tenant contributes to recovery attention, **When** the operator opens that drilldown, **Then** the destination is `/admin/tenants` with recovery-evidence intent already preserved.
3. **Given** exactly one visible tenant contributes to a backup or recovery attention drilldown, **When** the operator opens it, **Then** the destination may go directly to `/admin/t/{tenant}` instead of the registry.
---
### User Story 4 - Keep Scope And Truth Boundaries Honest (Priority: P3)
As a workspace operator with partial tenant visibility, I want the registry and its drilldowns to remain truthful without leaking hidden tenants or overstating what deeper surfaces prove.
**Why this priority**: Portfolio aggregation is only acceptable if it stays bounded by workspace and tenant scope and does not turn partial evidence into stronger claims.
**Independent Test**: Can be fully tested by mixing visible and hidden tenants with posture issues and verifying that only visible tenants appear in the registry, filters, and drilldown results.
**Acceptance Scenarios**:
1. **Given** hidden tenants have backup or recovery issues, **When** the operator uses registry filters or workspace drilldowns, **Then** hidden tenants do not appear in rows, counts, or labels.
2. **Given** a user can see registry posture but cannot open a deeper backup or restore surface, **When** the user continues triage from the registry, **Then** the UI offers an allowed fallback such as the tenant dashboard instead of a broken link.
3. **Given** a visible tenant has stale `last sync` metadata but healthy backup posture, **When** the registry renders, **Then** lifecycle and metadata signals remain distinct from backup posture and do not override it.
### Edge Cases
- A visible tenant may have healthy backups but `unvalidated` recovery evidence; the registry must keep that tenant discoverable through the recovery filter instead of letting healthy backup posture hide the weaker recovery truth.
- A visible tenant may have both backup attention and recovery-evidence weakness at the same time; the registry must show both domains independently while triage ordering uses one consistent highest-priority tier.
- A filtered registry view may return zero visible rows because the active filter no longer matches any in-scope tenant; the empty state must remain bounded to the visible workspace slice and must not imply that hidden tenants are calm.
- A tenant may be archived, inactive, or otherwise operationally unusual while still having calm backup and recovery posture; lifecycle presentation must not be used as a substitute for recovery triage.
- A user may be able to see posture truth on the registry but lack permission for deeper backup or restore surfaces; triage must still have one safe next click.
## Requirements *(mandatory)*
This feature introduces no new Microsoft Graph calls, no new queued or scheduled work, no new `OperationRun`, and no new persistence. It is a read-first workspace triage slice that reuses the existing tenant backup-health and recovery-evidence truth inside the tenant registry and workspace drilldown flow.
Authorization spans the workspace/admin plane at `/admin` and `/admin/tenants`, with read-only drill-through into the tenant plane at `/admin/t/{tenant}` and, only when already allowed, deeper backup or restore surfaces. Non-members remain `404`. Established members continue to rely on existing server-side capability checks for deeper destinations. Registry visibility and filter state must never broaden tenant access.
The feature changes status-like list signals, so centralized badge semantics remain authoritative. Backup posture and recovery evidence may be rendered on the registry with shared status primitives or equivalent central mappings, but the registry must not introduce page-local status language or new claim families. Labels must stay bounded to `Backup posture`, `Recovery evidence`, `absent`, `stale`, `degraded`, `healthy`, `weakened`, `unvalidated`, and `no recent issues visible`, or closely equivalent wording that carries the same limits.
The affected Filament surfaces keep one primary inspect or open model. The tenant registry remains the canonical collection route. Full-row click remains the canonical inspect affordance for the registry, while one safe inline dashboard shortcut remains the fast triage continuation. No redundant view action, empty action group, or destructive placement change is introduced. The workspace overview remains a read-only drill-in surface. UX-001 create and edit form rules are unchanged because this slice only refines list and drilldown behavior.
The feature must not create a second recovery-confidence truth. It may only derive registry ordering and filter behavior from existing tenant backup-health and recovery-evidence states. Tests must focus on business consequences such as wrong rows, wrong filters, wrong ordering, lost drilldown meaning, hidden-tenant leakage, metadata substitution, and overclaiming language.
### Functional Requirements
- **FR-186-001**: The system MUST add a visible backup-posture signal to each tenant-registry row and MUST derive that signal from the existing tenant-level backup-health source of truth.
- **FR-186-002**: The registry MUST recognize at least `absent`, `stale`, `degraded`, and `healthy` as backup-posture states without redefining how those states are determined.
- **FR-186-003**: Backup posture on the registry MUST remain a bounded summary signal and MUST NOT claim or imply restore success.
- **FR-186-004**: The system MUST add a visible recovery-evidence signal to each tenant-registry row and MUST derive that signal from the existing tenant-level recovery-evidence source of truth.
- **FR-186-005**: The registry MUST recognize at least `weakened`, `unvalidated`, and `no_recent_issues_visible` as recovery-evidence states without redefining how those states are determined.
- **FR-186-006**: Recovery evidence on the registry MUST remain a bounded summary signal and MUST NOT claim or imply that recovery is proven, guaranteed, or fully validated.
- **FR-186-007**: The tenant registry MUST keep tenant identity and lifecycle metadata visually and semantically separate from backup posture and recovery evidence.
- **FR-186-008**: The tenant registry MUST provide a backup-posture filter with at least `absent`, `stale`, `degraded`, and `healthy` as selectable states.
- **FR-186-009**: The tenant registry MUST provide a recovery-evidence filter with at least `weakened`, `unvalidated`, and `no_recent_issues_visible` as selectable states.
- **FR-186-010**: Registry posture filters MUST act only on visible rows inside the current workspace and tenant scope and MUST NOT reveal out-of-scope tenants.
- **FR-186-011**: The tenant registry MUST provide a consistent worst-first triage ordering for mixed posture rows.
- **FR-186-012**: Combined triage ordering MUST rank tenants by the highest active weakness tier using this order: backup `absent`, recovery `weakened`, backup `stale`, recovery `unvalidated`, backup `degraded`, then calm rows.
- **FR-186-013**: When multiple tenants share the same weakness tier, the registry MUST break ties by tenant name in ascending order.
- **FR-186-014**: A multi-tenant workspace backup-attention drilldown MUST land on `/admin/tenants` with backup-posture intent already preserved.
- **FR-186-015**: A multi-tenant workspace recovery-attention drilldown MUST land on `/admin/tenants` with recovery-evidence intent already preserved.
- **FR-186-016**: A single-tenant backup-attention or recovery-attention drilldown MAY continue directly to `/admin/t/{tenant}` when only one visible tenant is affected.
- **FR-186-017**: Drilldown intent MUST remain recoverable at the destination through visible filter state, visible ordering, or equivalent default-visible context.
- **FR-186-018**: Each posture-relevant tenant-registry row MUST expose at least one fast, permission-safe next step, with the tenant dashboard as the canonical fallback destination.
- **FR-186-019**: Deeper backup-set or restore-run destinations MAY be used only when they preserve the same posture reason and remain permission-safe for the current user.
- **FR-186-020**: The registry MUST NOT substitute lifecycle status, last sync, policy count, or general tenant status for backup posture or recovery evidence.
- **FR-186-021**: Registry rendering MUST remain query-bounded and MUST avoid uncontrolled per-row resolver fanout when posture data is loaded for a visible page of tenants.
- **FR-186-022**: Only visible tenants inside the current workspace and tenant scope may contribute to registry posture, posture filters, or workspace drilldown targets.
- **FR-186-023**: When a user can see registry posture but cannot open a deeper backup or restore destination, the registry MUST remain truthful and MUST fall back to an allowed tenant surface instead of offering a broken or misleading path.
- **FR-186-024**: Registry wording MUST NOT claim `recoverable`, `recovery proven`, `validated overall`, or any equivalent stronger statement that exceeds the underlying backup and recovery evidence.
- **FR-186-025**: The feature MUST ship without a schema migration, without a new persisted portfolio posture summary, without a new global recovery score, and without changing tenant-level resolver truth.
- **FR-186-026**: Regression coverage MUST prove posture-column rendering, backup filters, recovery filters, worst-first ordering, workspace backup drilldown, workspace recovery drilldown, single-tenant drilldown fallback, RBAC-safe visibility, no metadata substitution, and no overclaiming labels.
## Derived Registry Semantics
- **Visible backup posture**: The registry consumes the current tenant backup-health posture exactly as it already exists at tenant level. This slice does not redefine backup-health truth.
- **Visible recovery evidence**: The registry consumes the current tenant recovery-evidence overview state exactly as it already exists at tenant level. This slice does not redefine recovery-evidence truth.
- **Registry backup attention set**: The set of visible tenants whose backup posture is `absent`, `stale`, or `degraded`.
- **Registry recovery attention set**: The set of visible tenants whose recovery evidence is `weakened` or `unvalidated`.
- **Registry calm row**: A visible tenant row whose backup posture is `healthy` and whose recovery evidence is `no_recent_issues_visible`. A calm row is not a proof of recoverability.
- **Registry drilldown intent**: The preserved explanation of why the operator opened the registry, expressed through visible backup or recovery filter state, visible worst-first ordering, or an equivalent bounded context indicator.
## Triage Priority Rules
- **Backup-only order**: `absent` before `stale` before `degraded` before `healthy`.
- **Recovery-only order**: `weakened` before `unvalidated` before `no_recent_issues_visible`.
- **Combined registry order**: backup `absent`, recovery `weakened`, backup `stale`, recovery `unvalidated`, backup `degraded`, then calm rows.
- **Dual-signal rule**: If one tenant is weak in both posture domains, the highest active tier governs ordering while both posture signals remain visible.
- **Secondary order**: Ties inside one tier resolve by tenant name ascending.
- **Bounded-claim rule**: Even the calm tier may say only that no recent issues are visible; it may not say that recovery is proven.
## 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 registry list | Tenant registry list surface | Existing tenant-entry header action remains unchanged | Full-row click remains the canonical registry inspect affordance and opens the existing registry detail route | Existing inline dashboard shortcut remains the fast triage continuation into the tenant dashboard; existing conditional onboarding shortcut stays conditional | Existing grouped tenant maintenance actions remain unchanged | Existing tenant-entry empty-state CTA remains unchanged | Existing tenant detail header actions remain unchanged | Existing edit flow remains unchanged | no new audit behavior | This feature changes default-visible posture truth, filters, and ordering only. The action-surface contract remains satisfied with one primary inspect model and one safe dashboard follow-up shortcut, with no new destructive placement. |
| Workspace overview backup and recovery drilldowns | Workspace overview summary and attention surface | none | Explicit stat or attention click only | none | none | Existing workspace empty-state behavior remains bounded to visible scope | n/a | n/a | no new audit behavior | Multi-tenant backup and recovery drilldowns now open the filtered tenant registry. Single-tenant drilldowns may still open the tenant dashboard directly. |
| Tenant dashboard fallback landing | Tenant dashboard follow-up landing | Existing dashboard actions remain unchanged | Direct page open only | none added by this feature | none | Existing dashboard empty states remain unchanged | n/a | n/a | no new audit behavior | The tenant dashboard is the canonical safe fallback when deeper backup or restore surfaces would lose the same posture reason or violate permissions. |
### Key Entities *(include if feature involves data)*
- **Tenant registry row**: The visible portfolio record that combines tenant identity and lifecycle metadata with bounded backup posture and bounded recovery evidence.
- **Backup posture summary**: The existing tenant-level backup-health assessment rendered in a scan-friendly registry form.
- **Recovery evidence summary**: The existing tenant-level recovery-evidence overview rendered in a scan-friendly registry form.
- **Registry drilldown intent**: The preserved explanation of why the registry was opened, including the active posture filter and worst-first ordering context.
## Success Criteria *(mandatory)*
### Measurable Outcomes
- **SC-186-001**: In acceptance review, a workspace operator can identify within 10 seconds which visible tenants are weak on backup posture, which visible tenants are weak on recovery evidence, and which tenant should be opened first from `/admin/tenants`.
- **SC-186-002**: In 100% of covered regression scenarios, backup posture and recovery evidence render as separate signals on tenant-registry rows and never collapse into one ambiguous recovery score.
- **SC-186-003**: In 100% of covered filter scenarios, backup and recovery filters return only visible in-scope tenants matching the selected posture state.
- **SC-186-004**: In 100% of covered ordering scenarios, worst-first triage ordering places every weak visible tenant ahead of calm rows according to the defined priority rules.
- **SC-186-005**: In 100% of covered workspace drilldown scenarios, multi-tenant backup and recovery drilldowns preserve cause by landing on the filtered registry, while single-tenant drilldowns preserve the direct tenant-dashboard shortcut.
- **SC-186-006**: In 100% of covered truthfulness scenarios, the registry never uses labels that imply recovery proof, guaranteed recoverability, or an overall validated state.
- **SC-186-007**: In targeted query-bounded regression coverage, representative mixed-tenant registry rendering does not degrade into uncontrolled per-row resolver fanout when posture signals are enabled.
## Assumptions
- Existing tenant-level backup-health truth and tenant-level recovery-evidence truth are already stable enough to be reused on the registry without redefining either domain.
- The tenant dashboard remains the correct canonical fallback destination when a more specific backup or restore surface would not preserve the same posture reason or would not be permitted.
- The current tenant-registry inspect and maintenance flows remain in place; this slice hardens triage usefulness rather than redesigning the whole resource.
- The workspace overview keeps its existing backup-attention and recovery-attention meaning; this slice changes the destination for multi-tenant drilldowns, not the existence of those metrics.
## Dependencies
- Existing workspace overview counts and attention drilldowns on `/admin`
- Existing tenant registry on `/admin/tenants`
- Existing tenant dashboard on `/admin/t/{tenant}`
- Existing tenant-level backup-health truth and tenant-level recovery-evidence truth
- Existing workspace and tenant RBAC boundaries and safe destination fallback patterns
## Non-Goals
- Redesigning `Choose tenant` as a posture-triage surface
- Redesigning the workspace overview itself beyond changing backup and recovery drilldown targets
- Redesigning tenant detail pages or introducing a new tenant-registry visual language outside the recovery-triage goal
- Changing tenant-level resolver logic for backup health or recovery evidence
- Adding a new persisted portfolio posture table, a new global recovery score, or new dashboard widgets
- Changing backup, restore, or domain persistence models
## Risks
- If the registry visually blends lifecycle metadata with backup or recovery posture, operators may still misread directory metadata as recovery truth.
- If drilldown intent is not visibly preserved, the workspace overview will continue to feel semantically sharper than the registry it opens.
- If ordering rules are inconsistent or unstable, weak tenants will still be lost in normal directory browsing.
- If posture loading is implemented as naive per-row resolution, registry performance may regress under moderate visible-tenant counts.
- If registry wording overreaches, operators may treat healthy backups and calm recovery evidence as proof of successful recovery.
## Definition of Done
This feature is complete when:
- the tenant registry shows backup posture per visible row,
- the tenant registry shows recovery evidence per visible row,
- both posture domains are filterable,
- the registry supports a consistent weak-first triage ordering,
- multi-tenant workspace backup and recovery drilldowns land on a useful filtered tenant registry surface,
- single-tenant workspace backup and recovery drilldowns may still go directly to the tenant dashboard,
- the registry never overclaims recoverability or recovery proof,
- RBAC and query-bounded behavior are protected by focused regression coverage.

View File

@ -0,0 +1,247 @@
# Tasks: Tenant Registry Recovery Triage
**Input**: Design documents from `/specs/186-tenant-registry-recovery-triage/`
**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 `apps/platform/tests/Feature/Filament/TenantRegistryRecoveryTriageTest.php`, `apps/platform/tests/Feature/Filament/WorkspaceOverviewSummaryMetricsTest.php`, `apps/platform/tests/Feature/Filament/WorkspaceOverviewDrilldownContinuityTest.php`, `apps/platform/tests/Feature/Filament/TenantResourceIndexIsWorkspaceScopedTest.php`, `apps/platform/tests/Feature/Filament/WorkspaceOverviewAuthorizationTest.php`, `apps/platform/tests/Feature/Guards/ActionSurfaceContractTest.php`, and `apps/platform/tests/Feature/Guards/FilamentTableStandardsGuardTest.php`.
**Operations**: This feature does not create a new `OperationRun`, change run lifecycle ownership, or introduce queued or scheduled work. The work is limited to workspace registry rendering, filter continuity, and drilldown destinations.
**RBAC**: Existing workspace membership, tenant visibility, and deny-as-not-found semantics remain authoritative. Tasks must preserve visible-tenant-only posture buckets, visible-tenant-only drilldown destinations, and the tenant dashboard as the permission-safe fallback surface.
**Operator Surfaces**: `TenantResource` remains the canonical registry working surface, `WorkspaceOverview` remains the backup and recovery drilldown source, and `/admin/t/{tenant}` remains the canonical safe fallback destination.
**Filament UI Action Surfaces**: No new destructive or redundant inspect actions are added. Full-row click remains the registry inspect model, the existing safe dashboard shortcut remains the only inline fast-next-step action, and workspace summary widgets keep their current destination contract.
**Filament UI UX-001**: No create, edit, or view-page layout changes are introduced. This slice is limited to table truth, filters, ordering, and drilldown continuity.
**Badges**: Backup posture and recovery evidence must reuse existing bounded label and tone semantics from shared badge or readiness surfaces; no page-local status language is introduced.
**Organization**: Tasks are grouped by user story so each story can be implemented and verified as an independent increment where the underlying surface coupling allows it.
## Phase 1: Setup (Focused Regression Harness)
**Purpose**: Create the focused acceptance harness for the registry triage slice and the workspace drilldowns it changes.
- [X] T001 Create the focused registry triage regression scaffold in `apps/platform/tests/Feature/Filament/TenantRegistryRecoveryTriageTest.php`
- [X] T002 [P] Stage workspace backup and recovery drilldown regression assertions in `apps/platform/tests/Feature/Filament/WorkspaceOverviewSummaryMetricsTest.php` and `apps/platform/tests/Feature/Filament/WorkspaceOverviewDrilldownContinuityTest.php`
---
## Phase 2: Foundational (Blocking Shared Posture Semantics)
**Purpose**: Establish one request-scoped posture snapshot and one list-surface contract baseline before story work expands the registry.
**⚠️ CRITICAL**: No user story work should begin until this phase is complete.
- [X] T003 Implement request-scoped visible-tenant posture snapshot loading and canonical triage tier mapping in `apps/platform/app/Filament/Resources/TenantResource.php`
- [X] T004 [P] Add failing list-surface and table-standards guard scenarios for the forthcoming posture columns and filters in `apps/platform/tests/Feature/Guards/ActionSurfaceContractTest.php` and `apps/platform/tests/Feature/Guards/FilamentTableStandardsGuardTest.php`
**Checkpoint**: The registry has one shared posture source for columns, filters, and ranking, and the Filament table contract is guarded before story-specific UI work lands.
---
## Phase 3: User Story 1 - See Weak Tenants On One Registry Surface (Priority: P1) 🎯
**Goal**: Make `/admin/tenants` show bounded backup posture and recovery evidence directly on each visible row.
**Independent Test**: Seed visible tenants with mixed backup and recovery states, open `/admin/tenants`, and verify that every row shows separate `Backup posture` and `Recovery evidence` signals without turning metadata into recovery truth.
### Tests for User Story 1
- [X] T005 [US1] Add row-level backup posture, recovery evidence, dashboard-registry mapping parity, dual-signal rendering, metadata-separation, and no-overclaim coverage in `apps/platform/tests/Feature/Filament/TenantRegistryRecoveryTriageTest.php`
### Implementation for User Story 1
- [X] T006 [P] [US1] Reuse or extract one shared bounded backup and recovery mapping seam for registry rendering in `apps/platform/app/Filament/Widgets/Dashboard/RecoveryReadiness.php` and `apps/platform/app/Filament/Resources/TenantResource.php`
- [X] T007 [US1] Add default-visible `Backup posture` and `Recovery evidence` columns beside tenant identity and lifecycle metadata in `apps/platform/app/Filament/Resources/TenantResource.php`
- [X] T008 [US1] Keep full-row inspect pointed at the existing registry detail route and the safe dashboard shortcut as the only primary next steps while surfacing posture truth in `apps/platform/app/Filament/Resources/TenantResource.php`
- [X] T009 [US1] Run focused US1 verification from `specs/186-tenant-registry-recovery-triage/quickstart.md` against `apps/platform/tests/Feature/Filament/TenantRegistryRecoveryTriageTest.php`
**Checkpoint**: The tenant registry reads as a truthful at-a-glance recovery triage surface even before filters and workspace drilldowns are changed.
---
## Phase 4: User Story 2 - Filter And Rank The Weakest Tenants First (Priority: P1)
**Goal**: Turn the registry from a readable directory into a usable triage surface with exact posture filters and deterministic worst-first ordering.
**Independent Test**: Apply backup and recovery posture filters in a mixed visible tenant set and verify that weak tenants sort ahead of calm rows with stable tenant-name tie breaks.
### Tests for User Story 2
- [X] T010 [US2] Add exact posture-filter and worst-first ordering coverage in `apps/platform/tests/Feature/Filament/TenantRegistryRecoveryTriageTest.php`
### Implementation for User Story 2
- [X] T011 [US2] Add multi-select `backup_posture` and `recovery_evidence` filters over snapshot-derived visible tenant ID sets in `apps/platform/app/Filament/Resources/TenantResource.php`
- [X] T012 [US2] Implement canonical worst-first triage ordering and stable tenant-name tie breaks in `apps/platform/app/Filament/Resources/TenantResource.php`
- [X] T013 [P] [US2] Add filtered empty-state context for manual posture filtering without changing the default calm-browsing sort in `apps/platform/app/Filament/Resources/TenantResource/Pages/ListTenants.php`
- [X] T014 [US2] Run focused US2 verification from `specs/186-tenant-registry-recovery-triage/quickstart.md` against `apps/platform/tests/Feature/Filament/TenantRegistryRecoveryTriageTest.php`
**Checkpoint**: Operators can reduce the registry to the exact weak slice they need and start with the highest-priority tenants first.
---
## Phase 5: User Story 3 - Preserve Workspace Meaning During Drilldown (Priority: P1)
**Goal**: Make workspace backup and recovery attention drilldowns land on a filtered registry that preserves why the operator clicked.
**Independent Test**: Open backup and recovery attention drilldowns for both multi-tenant and single-tenant cases and verify that multi-tenant cases land on filtered `/admin/tenants` while single-tenant cases still go directly to `/admin/t/{tenant}`.
### Tests for User Story 3
- [X] T015 [P] [US3] Add multi-tenant backup and recovery destination URL and unchanged destination-kind assertions in `apps/platform/tests/Feature/Filament/WorkspaceOverviewSummaryMetricsTest.php`
- [X] T016 [P] [US3] Add registry drilldown continuity and single-tenant dashboard fallback coverage in `apps/platform/tests/Feature/Filament/WorkspaceOverviewDrilldownContinuityTest.php`
### Implementation for User Story 3
- [X] T017 [P] [US3] Initialize `backup_posture`, `recovery_evidence`, and `triage_sort` query intent on first registry load in `apps/platform/app/Filament/Resources/TenantResource/Pages/ListTenants.php`
- [X] T018 [P] [US3] Replace multi-tenant backup and recovery `choose_tenant` destinations with filtered `/admin/tenants` URLs while preserving single-tenant dashboard routing and the existing widget destination-kind contract in `apps/platform/app/Support/Workspaces/WorkspaceOverviewBuilder.php`
- [X] T019 [US3] Run focused US3 verification from `specs/186-tenant-registry-recovery-triage/quickstart.md` against `apps/platform/tests/Feature/Filament/WorkspaceOverviewSummaryMetricsTest.php` and `apps/platform/tests/Feature/Filament/WorkspaceOverviewDrilldownContinuityTest.php`
**Checkpoint**: Workspace backup and recovery counts now open a destination that keeps the same explanation visible after navigation.
---
## Phase 6: User Story 4 - Keep Scope And Truth Boundaries Honest (Priority: P3)
**Goal**: Keep posture rows, filters, and drilldowns bounded to visible tenants and bounded claims even when the member has partial visibility or limited follow-up permissions.
**Independent Test**: Mix visible and hidden tenants with posture issues, open the registry and workspace drilldowns as a partially entitled member, and verify that only visible tenants appear and only permission-safe destinations are offered.
### Tests for User Story 4
- [X] T020 [P] [US4] Add hidden-tenant leakage and posture-filter scope coverage in `apps/platform/tests/Feature/Filament/TenantResourceIndexIsWorkspaceScopedTest.php`
- [X] T021 [P] [US4] Add deny-as-not-found and permission-safe drillthrough fallback coverage in `apps/platform/tests/Feature/Filament/WorkspaceOverviewAuthorizationTest.php`
### Implementation for User Story 4
- [X] T022 [US4] Restrict posture buckets and filtered tenant ID sets to the already visible workspace tenant query in `apps/platform/app/Filament/Resources/TenantResource.php`
- [X] T023 [P] [US4] Keep workspace attention candidate selection and dashboard fallback destinations scoped to visible tenants only in `apps/platform/app/Support/Workspaces/WorkspaceOverviewBuilder.php`
- [X] T024 [US4] Enforce bounded wording and safe next-step fallback when deeper backup or restore destinations are unavailable in `apps/platform/app/Filament/Resources/TenantResource.php` and `apps/platform/app/Filament/Widgets/Dashboard/RecoveryReadiness.php`
- [X] T025 [US4] Run focused US4 verification from `specs/186-tenant-registry-recovery-triage/quickstart.md` against `apps/platform/tests/Feature/Filament/TenantResourceIndexIsWorkspaceScopedTest.php` and `apps/platform/tests/Feature/Filament/WorkspaceOverviewAuthorizationTest.php`
**Checkpoint**: The registry and its workspace drilldowns stay truthful, scoped, and permission-safe even for partially entitled members.
---
## Phase 7: Polish & Cross-Cutting Concerns
**Purpose**: Finish guard coverage, formatting, and the final focused acceptance pack.
- [X] T026 [P] Run final list-surface and table-standards guard verification for the added registry columns and filters against `apps/platform/tests/Feature/Guards/ActionSurfaceContractTest.php` and `apps/platform/tests/Feature/Guards/FilamentTableStandardsGuardTest.php`
- [X] T027 Add explicit query-bounded rendering and no per-row resolver fanout coverage in `apps/platform/tests/Feature/Filament/TenantRegistryRecoveryTriageTest.php`
- [X] T028 Run formatting with `cd apps/platform && ./vendor/bin/sail bin pint --dirty --format agent` using `specs/186-tenant-registry-recovery-triage/quickstart.md`
- [X] T029 Run the focused verification pack from `specs/186-tenant-registry-recovery-triage/quickstart.md` against `apps/platform/tests/Feature/Filament/TenantRegistryRecoveryTriageTest.php`, `apps/platform/tests/Feature/Filament/WorkspaceOverviewSummaryMetricsTest.php`, `apps/platform/tests/Feature/Filament/WorkspaceOverviewDrilldownContinuityTest.php`, `apps/platform/tests/Feature/Filament/TenantResourceIndexIsWorkspaceScopedTest.php`, and `apps/platform/tests/Feature/Filament/WorkspaceOverviewAuthorizationTest.php`
- [X] T030 Run the timed 10-second operator-scan and manual mixed-tenant smoke workflow from `specs/186-tenant-registry-recovery-triage/quickstart.md` on `/admin` and `/admin/tenants`
---
## Dependencies & Execution Order
### Phase Dependencies
- **Setup (Phase 1)**: Starts immediately and creates the focused regression harness.
- **Foundational (Phase 2)**: Depends on Setup and blocks all user-story work until the registry has one shared posture snapshot and the list contract baseline is guarded.
- **User Stories (Phase 3+)**: Depend on Foundational completion.
- **Polish (Phase 7)**: Depends on all desired user stories being complete.
### User Story Dependencies
- **User Story 1 (P1)**: Starts after Foundational and delivers the truthful row-level registry surface.
- **User Story 2 (P1)**: Starts after User Story 1 because exact filters and ranking extend the same registry surface and depend on the posture columns already being in place.
- **User Story 3 (P1)**: Starts after User Story 2 because workspace drilldowns must land on registry filters that already exist.
- **User Story 4 (P3)**: Starts after User Story 3 because the scope and fallback hardening must cover the finished registry and drilldown behavior together.
### Within Each User Story
- Story tests should be written before or alongside implementation and should fail before the story is considered complete.
- Shared posture semantics should land before any row-level label or filter rendering that depends on them.
- Registry resource changes should land before story-level verification runs.
- Workspace drilldown destination changes should land before continuity verification runs.
### Parallel Opportunities
- `T001` and `T002` can run in parallel during Setup.
- `T003` and `T004` can run in parallel after the target files are identified.
- `T006` can run in parallel with `T007` after `T005` defines the acceptance cases.
- `T013` can run in parallel with `T011` and `T012` once the filter contract is fixed.
- `T015` and `T016` can run in parallel for User Story 3.
- `T017` and `T018` can run in parallel for User Story 3 after the expected query contract is defined.
- `T020` and `T021` can run in parallel for User Story 4.
- `T022` and `T023` can run in parallel for User Story 4 once the scope assertions are in place.
- `T026` and `T027` can run in parallel before the final focused verification commands are executed.
---
## Parallel Example: User Story 1
```bash
# After T005 defines the acceptance cases:
Task: T006 apps/platform/app/Filament/Widgets/Dashboard/RecoveryReadiness.php
Task: T007 apps/platform/app/Filament/Resources/TenantResource.php
```
## Parallel Example: User Story 2
```bash
# After T010 locks the filter and ordering expectations:
Task: T011 apps/platform/app/Filament/Resources/TenantResource.php
Task: T013 apps/platform/app/Filament/Resources/TenantResource/Pages/ListTenants.php
```
## Parallel Example: User Story 3
```bash
# User Story 3 tests in parallel:
Task: T015 apps/platform/tests/Feature/Filament/WorkspaceOverviewSummaryMetricsTest.php
Task: T016 apps/platform/tests/Feature/Filament/WorkspaceOverviewDrilldownContinuityTest.php
# User Story 3 implementation in parallel after tests exist:
Task: T017 apps/platform/app/Filament/Resources/TenantResource/Pages/ListTenants.php
Task: T018 apps/platform/app/Support/Workspaces/WorkspaceOverviewBuilder.php
```
## Parallel Example: User Story 4
```bash
# User Story 4 tests in parallel:
Task: T020 apps/platform/tests/Feature/Filament/TenantResourceIndexIsWorkspaceScopedTest.php
Task: T021 apps/platform/tests/Feature/Filament/WorkspaceOverviewAuthorizationTest.php
# User Story 4 implementation in parallel after the scope assertions exist:
Task: T022 apps/platform/app/Filament/Resources/TenantResource.php
Task: T023 apps/platform/app/Support/Workspaces/WorkspaceOverviewBuilder.php
```
---
## Implementation Strategy
### MVP First (Registry Triage Surface)
1. Complete Phase 1: Setup.
2. Complete Phase 2: Foundational.
3. Complete Phase 3: User Story 1.
4. Complete Phase 4: User Story 2.
5. **STOP and VALIDATE**: Confirm `/admin/tenants` is a truthful and usable triage surface before changing workspace drilldowns.
### Incremental Delivery
1. Deliver Setup + Foundational to lock one posture source and the list contract.
2. Deliver User Story 1 so the registry shows bounded backup and recovery truth.
3. Deliver User Story 2 so operators can filter and rank the weak slice.
4. Deliver User Story 3 so workspace summary clicks preserve their meaning.
5. Deliver User Story 4 so scope, fallback, and truth boundaries remain enforced.
6. Finish with guard updates, formatting, focused verification, and the manual smoke pass.
### Parallel Team Strategy
1. One developer can build the focused registry acceptance test while another prepares the workspace drilldown regression cases.
2. After Foundational work, one developer can extend `TenantResource.php` while another keeps `RecoveryReadiness.php` semantics aligned for registry rendering.
3. Once User Story 2 expectations are fixed, one developer can wire `/admin/tenants` filter and ordering behavior while another handles `ListTenants.php` query-intent defaults.
4. User Story 3 and User Story 4 both split cleanly between registry-page work and workspace-builder work before final verification.
---
## Notes
- `[P]` tasks target different files or safe concurrent work after their prerequisite expectations are in place.
- `[US1]`, `[US2]`, `[US3]`, and `[US4]` labels map directly to the feature specification user stories.
- The suggested MVP scope is Phase 1 through Phase 4, ending with a fully usable tenant-registry triage surface with explicit query-bounded coverage before workspace drilldowns are switched over.
- No task in this plan adds a schema migration, a new persisted truth model, a new panel or provider registration, a new asset pipeline step, or a new destructive action.