Compare commits

...

1 Commits

Author SHA1 Message Date
Ahmed Darrazi
c184292efa feat: implement workspace recovery posture visibility 2026-04-09 14:54:13 +02:00
32 changed files with 3015 additions and 187 deletions

View File

@ -155,6 +155,8 @@ ## 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) - 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) - 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) - 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.15 (feat/005-bulk-operations) - PHP 8.4.15 (feat/005-bulk-operations)
@ -189,8 +191,8 @@ ## Code Style
PHP 8.4.15: Follow standard conventions PHP 8.4.15: Follow standard conventions
## Recent Changes ## Recent Changes
- 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 - 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 - 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 START -->
<!-- MANUAL ADDITIONS END --> <!-- MANUAL ADDITIONS END -->

View File

@ -66,8 +66,6 @@ public function handle(): int
'workspace_id' => (int) $workspace->getKey(), 'workspace_id' => (int) $workspace->getKey(),
'name' => (string) ($scenarioConfig['tenant_name'] ?? 'Spec 180 Blocked Backup Tenant'), 'name' => (string) ($scenarioConfig['tenant_name'] ?? 'Spec 180 Blocked Backup Tenant'),
'tenant_id' => $tenantRouteKey, '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_certificate_thumbprint' => null,
'app_status' => 'ok', 'app_status' => 'ok',
'app_notes' => null, 'app_notes' => null,

View File

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

View File

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

View File

@ -26,13 +26,233 @@ public function assess(Tenant|int $tenant): TenantBackupHealthAssessment
? (int) $tenant->getKey() ? (int) $tenant->getKey()
: (int) $tenant; : (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'); $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 $qualitySummary = $latestBackupSet instanceof BackupSet
? $this->backupQualityResolver->summarizeBackupSet($latestBackupSet) ? $this->backupQualityResolver->summarizeBackupSet($latestBackupSet)
: null; : null;
$freshnessEvaluation = $this->freshnessEvaluation($latestBackupSet?->completed_at, $now); $freshnessEvaluation = $this->freshnessEvaluation($latestBackupSet?->completed_at, $now);
$scheduleFollowUp = $this->scheduleFollowUpEvaluation($tenantId, $now);
if (! $latestBackupSet instanceof BackupSet) { if (! $latestBackupSet instanceof BackupSet) {
return new TenantBackupHealthAssessment( 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 */ $tenantIds = [];
$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',
]);
return $backupSet; foreach ($tenants as $tenant) {
} $tenantId = $tenant instanceof Tenant
? (int) $tenant->getKey()
: (int) $tenant;
private function freshnessEvaluation(?CarbonInterface $latestCompletedAt, CarbonImmutable $now): BackupFreshnessEvaluation if ($tenantId > 0) {
{ $tenantIds[] = $tenantId;
$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();
} }
} }
return new BackupScheduleFollowUpEvaluation( return array_values(array_unique($tenantIds));
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 latestBackupAgeMessage(?CarbonInterface $completedAt, CarbonImmutable $now): ?string private function latestBackupAgeMessage(?CarbonInterface $completedAt, CarbonImmutable $now): ?string

View File

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

View File

@ -10,6 +10,7 @@
use App\Models\Tenant; use App\Models\Tenant;
use App\Models\User; use App\Models\User;
use App\Services\Auth\CapabilityResolver; use App\Services\Auth\CapabilityResolver;
use App\Support\BackupHealth\TenantBackupHealthAssessment;
use App\Support\BackupHealth\TenantBackupHealthResolver; use App\Support\BackupHealth\TenantBackupHealthResolver;
use App\Support\Auth\Capabilities; use App\Support\Auth\Capabilities;
@ -20,6 +21,7 @@
public function __construct( public function __construct(
private CapabilityResolver $capabilityResolver, private CapabilityResolver $capabilityResolver,
private WriteGateInterface $writeGate, private WriteGateInterface $writeGate,
private TenantBackupHealthResolver $backupHealthResolver,
) {} ) {}
/** /**
@ -496,57 +498,59 @@ public function resultAttentionForRun(RestoreRun $restoreRun): RestoreResultAtte
*/ */
public function dashboardRecoveryEvidence(Tenant $tenant): array public function dashboardRecoveryEvidence(Tenant $tenant): array
{ {
$backupHealth = app(TenantBackupHealthResolver::class)->assess($tenant); $backupHealth = $this->backupHealthResolver->assess($tenant);
$relevantRestoreHistory = $this->latestRelevantRestoreHistory($tenant); $relevantRestoreHistory = $this->latestRelevantRestoreHistory($tenant);
$relevantRun = $relevantRestoreHistory['run']; return $this->dashboardRecoveryEvidencePayload(
$relevantAttention = $relevantRestoreHistory['attention']; backupHealth: $backupHealth,
relevantRun: $relevantRestoreHistory['run'],
relevantAttention: $relevantRestoreHistory['attention'],
);
}
if (! $relevantRun instanceof RestoreRun || ! $relevantAttention instanceof RestoreResultAttention) { /**
return [ * @param iterable<int, Tenant|int> $tenants
'backup_posture' => $backupHealth->posture, * @param array<int, TenantBackupHealthAssessment>|null $backupHealthAssessments
'overview_state' => 'unvalidated', * @return array<int, array{
'headline' => 'Recovery evidence is unvalidated', * backup_posture: string,
'summary' => 'No executed restore history is visible in the latest tenant restore records.', * overview_state: string,
'claim_boundary' => RestoreSafetyCopy::recoveryBoundary('run_completed_not_recovery_proven'), * headline: string,
'latest_relevant_restore_run_id' => null, * summary: string,
'latest_relevant_attention_state' => null, * claim_boundary: string,
'latest_relevant_restore_run' => null, * latest_relevant_restore_run_id: ?int,
'latest_relevant_attention' => null, * latest_relevant_attention_state: ?string,
'reason' => 'no_history', * 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, [ $resolvedBackupHealthAssessments = $backupHealthAssessments ?? $this->backupHealthResolver->assessMany($tenantIds);
RestoreResultAttention::STATE_FAILED, $candidatesByTenant = $this->dashboardRecoveryCandidatesForTenants($tenantIds)->groupBy('tenant_id');
RestoreResultAttention::STATE_PARTIAL, $evidence = [];
RestoreResultAttention::STATE_COMPLETED_WITH_FOLLOW_UP,
], true)) { foreach ($tenantIds as $tenantId) {
return [ $backupHealth = $resolvedBackupHealthAssessments[$tenantId] ?? $this->backupHealthResolver->assess($tenantId);
'backup_posture' => $backupHealth->posture, $relevantRestoreHistory = $this->latestRelevantRestoreHistoryFromCandidates(
'overview_state' => 'weakened', $candidatesByTenant->get($tenantId, collect()),
'headline' => 'Recent restore history weakens confidence', );
'summary' => $relevantAttention->summary,
'claim_boundary' => RestoreSafetyCopy::recoveryBoundary($relevantAttention->recoveryClaimBoundary), $evidence[$tenantId] = $this->dashboardRecoveryEvidencePayload(
'latest_relevant_restore_run_id' => (int) $relevantRun->getKey(), backupHealth: $backupHealth,
'latest_relevant_attention_state' => $relevantAttention->state, relevantRun: $relevantRestoreHistory['run'],
'latest_relevant_restore_run' => $relevantRun, relevantAttention: $relevantRestoreHistory['attention'],
'latest_relevant_attention' => $relevantAttention, );
'reason' => $relevantAttention->state,
];
} }
return [ return $evidence;
'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',
];
} }
/** /**
@ -611,7 +615,16 @@ public function invalidationReasonsForBasis(
*/ */
private function latestRelevantRestoreHistory(Tenant $tenant): array 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); $attention = $this->resultAttentionForRun($candidate);
if ($attention->state === RestoreResultAttention::STATE_NOT_EXECUTED) { 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> * @return \Illuminate\Database\Eloquent\Collection<int, RestoreRun>
*/ */
@ -644,6 +691,73 @@ private function dashboardRecoveryCandidates(Tenant $tenant)
->get(); ->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 private function primaryCauseFamilyForRun(RestoreRun $restoreRun): string
{ {
$operationContext = is_array($restoreRun->operationRun?->context) ? $restoreRun->operationRun->context : []; $operationContext = is_array($restoreRun->operationRun?->context) ? $restoreRun->operationRun->context : [];
@ -726,4 +840,25 @@ private function normalizeIds(array $ids): array
return $normalized; 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

@ -18,6 +18,8 @@
use App\Services\Auth\WorkspaceCapabilityResolver; use App\Services\Auth\WorkspaceCapabilityResolver;
use App\Services\Auth\WorkspaceRoleCapabilityMap; use App\Services\Auth\WorkspaceRoleCapabilityMap;
use App\Support\Auth\Capabilities; use App\Support\Auth\Capabilities;
use App\Support\BackupHealth\TenantBackupHealthAssessment;
use App\Support\BackupHealth\TenantBackupHealthResolver;
use App\Support\Badges\BadgeDomain; use App\Support\Badges\BadgeDomain;
use App\Support\Badges\BadgeRenderer; use App\Support\Badges\BadgeRenderer;
use App\Support\Baselines\BaselineCompareSummaryAssessment; use App\Support\Baselines\BaselineCompareSummaryAssessment;
@ -28,6 +30,9 @@
use App\Support\OperationRunLinks; use App\Support\OperationRunLinks;
use App\Support\OpsUx\OperationUxPresenter; use App\Support\OpsUx\OperationUxPresenter;
use App\Support\Rbac\UiTooltips; use App\Support\Rbac\UiTooltips;
use App\Support\RestoreSafety\RestoreResultAttention;
use App\Support\RestoreSafety\RestoreSafetyCopy;
use App\Support\RestoreSafety\RestoreSafetyResolver;
use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Collection; use Illuminate\Support\Collection;
@ -37,6 +42,8 @@ public function __construct(
private WorkspaceCapabilityResolver $workspaceCapabilityResolver, private WorkspaceCapabilityResolver $workspaceCapabilityResolver,
private CapabilityResolver $capabilityResolver, private CapabilityResolver $capabilityResolver,
private TenantGovernanceAggregateResolver $tenantGovernanceAggregateResolver, private TenantGovernanceAggregateResolver $tenantGovernanceAggregateResolver,
private TenantBackupHealthResolver $tenantBackupHealthResolver,
private RestoreSafetyResolver $restoreSafetyResolver,
) {} ) {}
/** /**
@ -62,6 +69,14 @@ public function build(Workspace $workspace, User $user): array
$tenantContexts, $tenantContexts,
static fn (array $context): bool => (bool) ($context['has_governance_attention'] ?? false), 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( $totalProblemOperationsCount = array_sum(array_map(
static fn (array $context): int => (int) ($context['terminal_follow_up_operations_count'] ?? 0) static fn (array $context): int => (int) ($context['terminal_follow_up_operations_count'] ?? 0)
@ -92,6 +107,8 @@ public function build(Workspace $workspace, User $user): array
accessibleTenantCount: $accessibleTenants->count(), accessibleTenantCount: $accessibleTenants->count(),
attentionItems: $attentionItems, attentionItems: $attentionItems,
governanceAttentionTenantCount: $governanceAttentionTenantCount, governanceAttentionTenantCount: $governanceAttentionTenantCount,
backupAttentionTenantCount: $backupAttentionTenantCount,
recoveryAttentionTenantCount: $recoveryAttentionTenantCount,
totalProblemOperationsCount: $totalProblemOperationsCount, totalProblemOperationsCount: $totalProblemOperationsCount,
totalActiveOperationsCount: $totalActiveOperationsCount, totalActiveOperationsCount: $totalActiveOperationsCount,
totalAlertFailuresCount: $totalAlertFailuresCount, totalAlertFailuresCount: $totalAlertFailuresCount,
@ -115,9 +132,13 @@ public function build(Workspace $workspace, User $user): array
$summaryMetrics = $this->summaryMetrics( $summaryMetrics = $this->summaryMetrics(
accessibleTenantCount: $accessibleTenants->count(), accessibleTenantCount: $accessibleTenants->count(),
governanceAttentionTenantCount: $governanceAttentionTenantCount, governanceAttentionTenantCount: $governanceAttentionTenantCount,
backupAttentionTenantCount: $backupAttentionTenantCount,
recoveryAttentionTenantCount: $recoveryAttentionTenantCount,
totalActiveOperationsCount: $totalActiveOperationsCount, totalActiveOperationsCount: $totalActiveOperationsCount,
totalAlertFailuresCount: $totalAlertFailuresCount, totalAlertFailuresCount: $totalAlertFailuresCount,
canViewAlerts: $canViewAlerts, canViewAlerts: $canViewAlerts,
tenantContexts: $tenantContexts,
user: $user,
navigationContext: $navigationContext, navigationContext: $navigationContext,
); );
@ -183,6 +204,12 @@ private function tenantContexts(Collection $accessibleTenants, int $workspaceId,
return []; return [];
} }
$backupHealthByTenant = $this->tenantBackupHealthResolver->assessMany($accessibleTenantIds);
$recoveryEvidenceByTenant = $this->restoreSafetyResolver->dashboardRecoveryEvidenceForTenants(
$accessibleTenantIds,
$backupHealthByTenant,
);
$terminalFollowUpCounts = $this->scopeToVisibleTenants( $terminalFollowUpCounts = $this->scopeToVisibleTenants(
OperationRun::query(), OperationRun::query(),
$workspaceId, $workspaceId,
@ -235,13 +262,67 @@ private function tenantContexts(Collection $accessibleTenants, int $workspaceId,
: []; : [];
return $accessibleTenants 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(); $tenantId = (int) $tenant->getKey();
$aggregate = $this->governanceAggregate($tenant); $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 [ return [
'tenant' => $tenant, 'tenant' => $tenant,
'aggregate' => $aggregate, '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' => ! $hasBackupAttention
&& in_array($recoveryOverviewState, ['weakened', 'unvalidated'], true),
'has_governance_attention' => $this->hasGovernanceAttention($aggregate), 'has_governance_attention' => $this->hasGovernanceAttention($aggregate),
'terminal_follow_up_operations_count' => (int) ($terminalFollowUpCounts[$tenantId] ?? 0), 'terminal_follow_up_operations_count' => (int) ($terminalFollowUpCounts[$tenantId] ?? 0),
'stale_attention_operations_count' => (int) ($staleAttentionCounts[$tenantId] ?? 0), 'stale_attention_operations_count' => (int) ($staleAttentionCounts[$tenantId] ?? 0),
@ -409,6 +490,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 = []; $items = [];
$terminalFollowUpOperationsCount = (int) ($context['terminal_follow_up_operations_count'] ?? 0); $terminalFollowUpOperationsCount = (int) ($context['terminal_follow_up_operations_count'] ?? 0);
@ -534,6 +623,11 @@ private function attentionPriority(array $item): int
'tenant_compare_attention' => 90, 'tenant_compare_attention' => 90,
'tenant_high_severity_findings' => 80, 'tenant_high_severity_findings' => 80,
'tenant_expiring_governance' => 70, '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_stale_attention' => 45,
'tenant_operations_terminal_follow_up' => 40, 'tenant_operations_terminal_follow_up' => 40,
'tenant_active_operations' => 20, 'tenant_active_operations' => 20,
@ -542,6 +636,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> * @return array<string, mixed>
*/ */
@ -556,6 +777,7 @@ private function makeAttentionItem(
string $badgeColor, string $badgeColor,
array $destination, array $destination,
?string $supportingMessage = null, ?string $supportingMessage = null,
?array $reasonContext = null,
): array { ): array {
$item = [ $item = [
'key' => $key, 'key' => $key,
@ -569,6 +791,7 @@ private function makeAttentionItem(
'supporting_message' => $supportingMessage, 'supporting_message' => $supportingMessage,
'badge' => $badge, 'badge' => $badge,
'badge_color' => $badgeColor, 'badge_color' => $badgeColor,
'reason_context' => $reasonContext,
'destination' => $destination, 'destination' => $destination,
'action_disabled' => (bool) ($destination['disabled'] ?? false), 'action_disabled' => (bool) ($destination['disabled'] ?? false),
'helper_text' => $destination['helper_text'] ?? null, 'helper_text' => $destination['helper_text'] ?? null,
@ -585,9 +808,13 @@ private function makeAttentionItem(
private function summaryMetrics( private function summaryMetrics(
int $accessibleTenantCount, int $accessibleTenantCount,
int $governanceAttentionTenantCount, int $governanceAttentionTenantCount,
int $backupAttentionTenantCount,
int $recoveryAttentionTenantCount,
int $totalActiveOperationsCount, int $totalActiveOperationsCount,
int $totalAlertFailuresCount, int $totalAlertFailuresCount,
bool $canViewAlerts, bool $canViewAlerts,
array $tenantContexts,
User $user,
CanonicalNavigationContext $navigationContext, CanonicalNavigationContext $navigationContext,
): array { ): array {
$metrics = [ $metrics = [
@ -615,6 +842,32 @@ private function summaryMetrics(
? $this->chooseTenantTarget('Choose tenant') ? $this->chooseTenantTarget('Choose tenant')
: null, : 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( $this->makeSummaryMetric(
key: 'active_operations', key: 'active_operations',
label: 'Active operations', label: 'Active operations',
@ -671,6 +924,33 @@ 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 $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 * @param array<int, int> $accessibleTenantIds
* @return list<array<string, mixed>> * @return list<array<string, mixed>>
@ -725,13 +1005,15 @@ private function calmnessState(
int $accessibleTenantCount, int $accessibleTenantCount,
array $attentionItems, array $attentionItems,
int $governanceAttentionTenantCount, int $governanceAttentionTenantCount,
int $backupAttentionTenantCount,
int $recoveryAttentionTenantCount,
int $totalProblemOperationsCount, int $totalProblemOperationsCount,
int $totalActiveOperationsCount, int $totalActiveOperationsCount,
int $totalAlertFailuresCount, int $totalAlertFailuresCount,
bool $canViewAlerts, bool $canViewAlerts,
CanonicalNavigationContext $navigationContext, CanonicalNavigationContext $navigationContext,
): array { ): array {
$checkedDomains = ['tenant_access', 'governance', 'findings', 'compare', 'operations']; $checkedDomains = ['tenant_access', 'governance', 'findings', 'compare', 'backup_health', 'recovery_evidence', 'operations'];
if ($canViewAlerts) { if ($canViewAlerts) {
$checkedDomains[] = 'alerts'; $checkedDomains[] = 'alerts';
@ -747,17 +1029,20 @@ private function calmnessState(
]; ];
} }
$hasPortfolioRecoveryAttention = $backupAttentionTenantCount > 0 || $recoveryAttentionTenantCount > 0;
$hasActivityAttention = $totalActiveOperationsCount > 0 $hasActivityAttention = $totalActiveOperationsCount > 0
|| $totalProblemOperationsCount > 0 || $totalProblemOperationsCount > 0
|| ($canViewAlerts && $totalAlertFailuresCount > 0); || ($canViewAlerts && $totalAlertFailuresCount > 0);
$isCalm = $governanceAttentionTenantCount === 0 && ! $hasActivityAttention; $isCalm = $governanceAttentionTenantCount === 0
&& ! $hasPortfolioRecoveryAttention
&& ! $hasActivityAttention;
if ($isCalm) { if ($isCalm) {
return [ return [
'is_calm' => true, 'is_calm' => true,
'checked_domains' => $checkedDomains, 'checked_domains' => $checkedDomains,
'title' => 'Nothing urgent in your visible workspace slice', '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(), 'next_action' => $this->chooseTenantTarget(),
]; ];
} }
@ -767,7 +1052,7 @@ private function calmnessState(
'is_calm' => false, 'is_calm' => false,
'checked_domains' => $checkedDomains, 'checked_domains' => $checkedDomains,
'title' => 'Workspace activity still needs review', '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'), 'next_action' => $this->operationsIndexTarget(null, $navigationContext, 'active'),
]; ];
} }
@ -776,7 +1061,7 @@ private function calmnessState(
'is_calm' => false, 'is_calm' => false,
'checked_domains' => $checkedDomains, 'checked_domains' => $checkedDomains,
'title' => 'Visible tenants still need attention', '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(), 'next_action' => $attentionItems[0]['destination'] ?? $this->chooseTenantTarget(),
]; ];
} }
@ -925,7 +1210,7 @@ private function switchWorkspaceTarget(string $label = 'Switch workspace'): arra
*/ */
private function tenantDashboardTarget(Tenant $tenant, User $user, string $label = 'Open tenant dashboard'): array 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( return $this->disabledDestination(
kind: 'tenant_dashboard', kind: 'tenant_dashboard',
label: $label, label: $label,
@ -1058,6 +1343,11 @@ private function canTenantView(User $user, Tenant $tenant): bool
return $this->capabilityResolver->can($user, $tenant, Capabilities::TENANT_VIEW); 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 private function canOpenFindings(User $user, Tenant $tenant): bool
{ {
return $this->capabilityResolver->can($user, $tenant, Capabilities::TENANT_FINDINGS_VIEW); return $this->capabilityResolver->can($user, $tenant, Capabilities::TENANT_FINDINGS_VIEW);

View File

@ -26,7 +26,7 @@
</h1> </h1>
<p class="max-w-2xl text-sm leading-6 text-gray-600 dark:text-gray-300"> <p class="max-w-2xl text-sm leading-6 text-gray-600 dark:text-gray-300">
This home stays workspace-scoped even when you were previously working in a tenant. 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> </p>
</div> </div>
</x-filament::section> </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 Governance risk counts affected tenants
</span> </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"> <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>
<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"> <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 Recent operations stay diagnostic

View File

@ -7,6 +7,7 @@
use App\Models\BackupItem; use App\Models\BackupItem;
use App\Models\BackupSet; use App\Models\BackupSet;
use App\Models\RestoreRun; use App\Models\RestoreRun;
use App\Support\RestoreSafety\RestoreSafetyResolver;
use Filament\Facades\Filament; use Filament\Facades\Filament;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
use Livewire\Livewire; use Livewire\Livewire;
@ -68,6 +69,66 @@ function makeHealthyBackupForRecoveryPerformance(\App\Models\Tenant $tenant): Ba
->assertDontSee('Weakened'); ->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 { it('renders dashboard recovery posture and restore-history list with bounded query volume', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'owner', ensureDefaultMicrosoftProviderConnection: false); [$user, $tenant] = createUserWithTenant(role: 'owner', ensureDefaultMicrosoftProviderConnection: false);
$this->actingAs($user); $this->actingAs($user);

View File

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

View File

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

View File

@ -3,9 +3,19 @@
declare(strict_types=1); declare(strict_types=1);
use App\Models\User; use App\Models\User;
use App\Models\Tenant;
use App\Models\Workspace; use App\Models\Workspace;
use App\Models\WorkspaceMembership; use App\Models\WorkspaceMembership;
use App\Services\Auth\CapabilityResolver;
use App\Support\Auth\Capabilities;
use App\Support\Workspaces\WorkspaceContext; 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 { it('returns 404 when the active workspace is outside the users membership scope', function (): void {
$user = User::factory()->create(); $user = User::factory()->create();
@ -28,6 +38,10 @@
[$user, $tenant] = createUserWithTenant($tenant, role: 'readonly', workspaceRole: 'readonly'); [$user, $tenant] = createUserWithTenant($tenant, role: 'readonly', workspaceRole: 'readonly');
[$profile, $snapshot] = seedActiveBaselineForTenant($tenant); [$profile, $snapshot] = seedActiveBaselineForTenant($tenant);
seedBaselineCompareRun($tenant, $profile, $snapshot, workspaceOverviewCompareCoverage()); 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([ \App\Models\OperationRun::factory()->tenantlessForWorkspace($tenant->workspace()->firstOrFail())->create([
'status' => \App\Support\OperationRunStatus::Running->value, 'status' => \App\Support\OperationRunStatus::Running->value,
@ -60,3 +74,48 @@
->and($overview['calmness']['next_action']['kind'])->toBe('switch_workspace') ->and($overview['calmness']['next_action']['kind'])->toBe('switch_workspace')
->and($overview['attention_empty_state']['action_label'])->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);
$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/');
});

View File

@ -8,8 +8,11 @@
it('shows workspace identity, summary cards, recent operations, and quick actions', function (): void { it('shows workspace identity, summary cards, recent operations, and quick actions', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'owner'); [$user, $tenant] = createUserWithTenant(role: 'owner');
[$profile, $snapshot] = seedActiveBaselineForTenant($tenant); workspaceOverviewSeedQuietTenantTruth($tenant);
seedBaselineCompareRun($tenant, $profile, $snapshot, workspaceOverviewCompareCoverage()); $backupSet = workspaceOverviewSeedHealthyBackup($tenant, [
'completed_at' => now()->subMinutes(10),
]);
workspaceOverviewSeedRestoreHistory($tenant, $backupSet, 'completed');
OperationRun::factory()->create([ OperationRun::factory()->create([
'tenant_id' => (int) $tenant->getKey(), 'tenant_id' => (int) $tenant->getKey(),
@ -26,6 +29,8 @@
->assertSee('Workspace overview') ->assertSee('Workspace overview')
->assertSee('Accessible tenants') ->assertSee('Accessible tenants')
->assertSee('Governance attention') ->assertSee('Governance attention')
->assertSee('Backup attention')
->assertSee('Recovery attention')
->assertSee('Active operations') ->assertSee('Active operations')
->assertSee('Needs attention') ->assertSee('Needs attention')
->assertSee('Recent operations') ->assertSee('Recent operations')
@ -34,6 +39,10 @@
->assertSee('Open alerts') ->assertSee('Open alerts')
->assertSee('Review current and recent workspace-wide operations.') ->assertSee('Review current and recent workspace-wide operations.')
->assertSee('Activity only. Active execution does not imply governance health.') ->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('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'); ->assertSee('Inventory sync');
}); });

View File

@ -6,13 +6,19 @@
use App\Models\OperationRun; use App\Models\OperationRun;
use App\Models\Tenant; use App\Models\Tenant;
use App\Support\Workspaces\WorkspaceContext; use App\Support\Workspaces\WorkspaceContext;
use Carbon\CarbonImmutable;
use Illuminate\Support\Facades\DB; 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 { 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']); $tenantA = Tenant::factory()->create(['status' => 'active']);
[$user, $tenantA] = createUserWithTenant($tenantA, role: 'owner', workspaceRole: 'readonly'); [$user, $tenantA] = createUserWithTenant($tenantA, role: 'owner', workspaceRole: 'readonly');
[$profileA, $snapshotA] = seedActiveBaselineForTenant($tenantA); workspaceOverviewSeedQuietTenantTruth($tenantA);
seedBaselineCompareRun($tenantA, $profileA, $snapshotA, workspaceOverviewCompareCoverage());
Finding::factory()->for($tenantA)->create([ Finding::factory()->for($tenantA)->create([
'workspace_id' => (int) $tenantA->workspace_id, 'workspace_id' => (int) $tenantA->workspace_id,
@ -26,14 +32,10 @@
'name' => 'Second Tenant', 'name' => 'Second Tenant',
]); ]);
createUserWithTenant($tenantB, $user, role: 'owner', workspaceRole: 'readonly'); createUserWithTenant($tenantB, $user, role: 'owner', workspaceRole: 'readonly');
[$profileB, $snapshotB] = seedActiveBaselineForTenant($tenantB); workspaceOverviewSeedQuietTenantTruth($tenantB);
seedBaselineCompareRun( workspaceOverviewSeedHealthyBackup($tenantB, [
$tenantB, 'completed_at' => now()->subDays(2),
$profileB, ]);
$snapshotB,
workspaceOverviewCompareCoverage(),
completedAt: now()->subDays(10),
);
$tenantC = Tenant::factory()->create([ $tenantC = Tenant::factory()->create([
'status' => 'active', 'status' => 'active',
@ -41,8 +43,11 @@
'name' => 'Third Tenant', 'name' => 'Third Tenant',
]); ]);
createUserWithTenant($tenantC, $user, role: 'owner', workspaceRole: 'readonly'); createUserWithTenant($tenantC, $user, role: 'owner', workspaceRole: 'readonly');
[$profileC, $snapshotC] = seedActiveBaselineForTenant($tenantC); workspaceOverviewSeedQuietTenantTruth($tenantC);
seedBaselineCompareRun($tenantC, $profileC, $snapshotC, workspaceOverviewCompareCoverage()); $tenantCBackup = workspaceOverviewSeedHealthyBackup($tenantC, [
'completed_at' => now()->subMinutes(20),
]);
workspaceOverviewSeedRestoreHistory($tenantC, $tenantCBackup, 'follow_up');
OperationRun::factory()->create([ OperationRun::factory()->create([
'tenant_id' => (int) $tenantC->getKey(), 'tenant_id' => (int) $tenantC->getKey(),
@ -61,6 +66,8 @@
->get('/admin') ->get('/admin')
->assertOk() ->assertOk()
->assertSee('Governance attention') ->assertSee('Governance attention')
->assertSee('Backup attention')
->assertSee('Recovery attention')
->assertSee('Recent operations'); ->assertSee('Recent operations');
}); });

View File

@ -4,6 +4,7 @@
use App\Filament\Resources\EvidenceSnapshotResource; use App\Filament\Resources\EvidenceSnapshotResource;
use App\Filament\Resources\TenantReviewResource; use App\Filament\Resources\TenantReviewResource;
use App\Filament\Widgets\Dashboard\NeedsAttention as TenantNeedsAttention;
use App\Filament\Widgets\Workspace\WorkspaceNeedsAttention; use App\Filament\Widgets\Workspace\WorkspaceNeedsAttention;
use App\Models\AlertDelivery; use App\Models\AlertDelivery;
use App\Models\Finding; use App\Models\Finding;
@ -13,6 +14,7 @@
use App\Support\OperationRunStatus; use App\Support\OperationRunStatus;
use App\Support\OperationRunType; use App\Support\OperationRunType;
use App\Support\Workspaces\WorkspaceOverviewBuilder; use App\Support\Workspaces\WorkspaceOverviewBuilder;
use Filament\Facades\Filament;
use Livewire\Livewire; use Livewire\Livewire;
it('preserves canonical findings, compare, alerts, and operations drill-through continuity from the workspace overview', function (): void { it('preserves canonical findings, compare, alerts, and operations drill-through continuity from the workspace overview', function (): void {
@ -20,6 +22,10 @@
[$user, $tenantDashboard] = createUserWithTenant($tenantDashboard, role: 'owner', workspaceRole: 'readonly'); [$user, $tenantDashboard] = createUserWithTenant($tenantDashboard, role: 'owner', workspaceRole: 'readonly');
[$dashboardProfile, $dashboardSnapshot] = seedActiveBaselineForTenant($tenantDashboard); [$dashboardProfile, $dashboardSnapshot] = seedActiveBaselineForTenant($tenantDashboard);
seedBaselineCompareRun($tenantDashboard, $dashboardProfile, $dashboardSnapshot, workspaceOverviewCompareCoverage()); seedBaselineCompareRun($tenantDashboard, $dashboardProfile, $dashboardSnapshot, workspaceOverviewCompareCoverage());
$tenantDashboardBackup = workspaceOverviewSeedHealthyBackup($tenantDashboard, [
'completed_at' => now()->subMinutes(15),
]);
workspaceOverviewSeedRestoreHistory($tenantDashboard, $tenantDashboardBackup, 'completed');
Finding::factory()->riskAccepted()->create([ Finding::factory()->riskAccepted()->create([
'workspace_id' => (int) $tenantDashboard->workspace_id, 'workspace_id' => (int) $tenantDashboard->workspace_id,
@ -33,6 +39,10 @@
createUserWithTenant($tenantFindings, $user, role: 'owner', workspaceRole: 'readonly'); createUserWithTenant($tenantFindings, $user, role: 'owner', workspaceRole: 'readonly');
[$findingsProfile, $findingsSnapshot] = seedActiveBaselineForTenant($tenantFindings); [$findingsProfile, $findingsSnapshot] = seedActiveBaselineForTenant($tenantFindings);
seedBaselineCompareRun($tenantFindings, $findingsProfile, $findingsSnapshot, workspaceOverviewCompareCoverage()); seedBaselineCompareRun($tenantFindings, $findingsProfile, $findingsSnapshot, workspaceOverviewCompareCoverage());
$tenantFindingsBackup = workspaceOverviewSeedHealthyBackup($tenantFindings, [
'completed_at' => now()->subMinutes(14),
]);
workspaceOverviewSeedRestoreHistory($tenantFindings, $tenantFindingsBackup, 'completed');
Finding::factory()->for($tenantFindings)->create([ Finding::factory()->for($tenantFindings)->create([
'workspace_id' => (int) $tenantFindings->workspace_id, 'workspace_id' => (int) $tenantFindings->workspace_id,
@ -54,6 +64,10 @@
workspaceOverviewCompareCoverage(), workspaceOverviewCompareCoverage(),
completedAt: now()->subDays(10), completedAt: now()->subDays(10),
); );
$tenantCompareBackup = workspaceOverviewSeedHealthyBackup($tenantCompare, [
'completed_at' => now()->subMinutes(13),
]);
workspaceOverviewSeedRestoreHistory($tenantCompare, $tenantCompareBackup, 'completed');
$tenantOperations = Tenant::factory()->create([ $tenantOperations = Tenant::factory()->create([
'status' => 'active', 'status' => 'active',
@ -63,6 +77,10 @@
createUserWithTenant($tenantOperations, $user, role: 'owner', workspaceRole: 'readonly'); createUserWithTenant($tenantOperations, $user, role: 'owner', workspaceRole: 'readonly');
[$operationsProfile, $operationsSnapshot] = seedActiveBaselineForTenant($tenantOperations); [$operationsProfile, $operationsSnapshot] = seedActiveBaselineForTenant($tenantOperations);
seedBaselineCompareRun($tenantOperations, $operationsProfile, $operationsSnapshot, workspaceOverviewCompareCoverage()); seedBaselineCompareRun($tenantOperations, $operationsProfile, $operationsSnapshot, workspaceOverviewCompareCoverage());
$tenantOperationsBackup = workspaceOverviewSeedHealthyBackup($tenantOperations, [
'completed_at' => now()->subMinutes(12),
]);
workspaceOverviewSeedRestoreHistory($tenantOperations, $tenantOperationsBackup, 'completed');
OperationRun::factory()->create([ OperationRun::factory()->create([
'tenant_id' => (int) $tenantOperations->getKey(), 'tenant_id' => (int) $tenantOperations->getKey(),
@ -80,6 +98,10 @@
createUserWithTenant($tenantAlerts, $user, role: 'owner', workspaceRole: 'readonly'); createUserWithTenant($tenantAlerts, $user, role: 'owner', workspaceRole: 'readonly');
[$alertsProfile, $alertsSnapshot] = seedActiveBaselineForTenant($tenantAlerts); [$alertsProfile, $alertsSnapshot] = seedActiveBaselineForTenant($tenantAlerts);
seedBaselineCompareRun($tenantAlerts, $alertsProfile, $alertsSnapshot, workspaceOverviewCompareCoverage()); seedBaselineCompareRun($tenantAlerts, $alertsProfile, $alertsSnapshot, workspaceOverviewCompareCoverage());
$tenantAlertsBackup = workspaceOverviewSeedHealthyBackup($tenantAlerts, [
'completed_at' => now()->subMinutes(11),
]);
workspaceOverviewSeedRestoreHistory($tenantAlerts, $tenantAlertsBackup, 'completed');
AlertDelivery::factory()->create([ AlertDelivery::factory()->create([
'tenant_id' => (int) $tenantAlerts->getKey(), 'tenant_id' => (int) $tenantAlerts->getKey(),
@ -177,3 +199,46 @@
->toContain($evidenceUrl) ->toContain($evidenceUrl)
->toContain($reviewUrl); ->toContain($reviewUrl);
}); });
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\Workspace;
use App\Models\WorkspaceMembership; use App\Models\WorkspaceMembership;
use App\Support\Workspaces\WorkspaceContext; 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 { it('renders intentional empty states when the workspace has no accessible tenant data', function (): void {
$user = User::factory()->create(); $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 { 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']); $tenant = Tenant::factory()->create(['status' => 'active']);
[$user, $tenant] = createUserWithTenant($tenant, role: 'owner', workspaceRole: 'readonly'); [$user, $tenant] = createUserWithTenant($tenant, role: 'owner', workspaceRole: 'readonly');
[$profile, $snapshot] = seedActiveBaselineForTenant($tenant); workspaceOverviewSeedQuietTenantTruth($tenant);
seedBaselineCompareRun($tenant, $profile, $snapshot, workspaceOverviewCompareCoverage()); $backupSet = workspaceOverviewSeedHealthyBackup($tenant, [
'completed_at' => now()->subMinutes(10),
]);
workspaceOverviewSeedRestoreHistory($tenant, $backupSet, 'completed');
$this->actingAs($user) $this->actingAs($user)
->withSession([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id]) ->withSession([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id])
->get('/admin') ->get('/admin')
->assertOk() ->assertOk()
->assertSee('Nothing urgent in your visible workspace slice') ->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'); [$user, $tenantGovernance] = createUserWithTenant($tenantGovernance, role: 'owner', workspaceRole: 'readonly');
[$profile, $snapshot] = seedActiveBaselineForTenant($tenantGovernance); [$profile, $snapshot] = seedActiveBaselineForTenant($tenantGovernance);
seedBaselineCompareRun($tenantGovernance, $profile, $snapshot, workspaceOverviewCompareCoverage()); seedBaselineCompareRun($tenantGovernance, $profile, $snapshot, workspaceOverviewCompareCoverage());
$tenantGovernanceBackup = workspaceOverviewSeedHealthyBackup($tenantGovernance, [
'completed_at' => now()->subMinutes(15),
]);
workspaceOverviewSeedRestoreHistory($tenantGovernance, $tenantGovernanceBackup, 'completed');
Finding::factory()->for($tenantGovernance)->create([ Finding::factory()->for($tenantGovernance)->create([
'workspace_id' => (int) $tenantGovernance->workspace_id, 'workspace_id' => (int) $tenantGovernance->workspace_id,
@ -32,6 +36,10 @@
createUserWithTenant($tenantActivity, $user, role: 'owner', workspaceRole: 'readonly'); createUserWithTenant($tenantActivity, $user, role: 'owner', workspaceRole: 'readonly');
[$activityProfile, $activitySnapshot] = seedActiveBaselineForTenant($tenantActivity); [$activityProfile, $activitySnapshot] = seedActiveBaselineForTenant($tenantActivity);
seedBaselineCompareRun($tenantActivity, $activityProfile, $activitySnapshot, workspaceOverviewCompareCoverage()); seedBaselineCompareRun($tenantActivity, $activityProfile, $activitySnapshot, workspaceOverviewCompareCoverage());
$tenantActivityBackup = workspaceOverviewSeedHealthyBackup($tenantActivity, [
'completed_at' => now()->subMinutes(14),
]);
workspaceOverviewSeedRestoreHistory($tenantActivity, $tenantActivityBackup, 'completed');
OperationRun::factory()->create([ OperationRun::factory()->create([
'tenant_id' => (int) $tenantActivity->getKey(), 'tenant_id' => (int) $tenantActivity->getKey(),
@ -49,6 +57,10 @@
createUserWithTenant($tenantAlerts, $user, role: 'owner', workspaceRole: 'readonly'); createUserWithTenant($tenantAlerts, $user, role: 'owner', workspaceRole: 'readonly');
[$alertsProfile, $alertsSnapshot] = seedActiveBaselineForTenant($tenantAlerts); [$alertsProfile, $alertsSnapshot] = seedActiveBaselineForTenant($tenantAlerts);
seedBaselineCompareRun($tenantAlerts, $alertsProfile, $alertsSnapshot, workspaceOverviewCompareCoverage()); seedBaselineCompareRun($tenantAlerts, $alertsProfile, $alertsSnapshot, workspaceOverviewCompareCoverage());
$tenantAlertsBackup = workspaceOverviewSeedHealthyBackup($tenantAlerts, [
'completed_at' => now()->subMinutes(13),
]);
workspaceOverviewSeedRestoreHistory($tenantAlerts, $tenantAlertsBackup, 'completed');
AlertDelivery::factory()->create([ AlertDelivery::factory()->create([
'tenant_id' => (int) $tenantAlerts->getKey(), 'tenant_id' => (int) $tenantAlerts->getKey(),
@ -73,6 +85,10 @@
[$user, $tenantExpiring] = createUserWithTenant($tenantExpiring, role: 'owner', workspaceRole: 'readonly'); [$user, $tenantExpiring] = createUserWithTenant($tenantExpiring, role: 'owner', workspaceRole: 'readonly');
[$expiringProfile, $expiringSnapshot] = seedActiveBaselineForTenant($tenantExpiring); [$expiringProfile, $expiringSnapshot] = seedActiveBaselineForTenant($tenantExpiring);
seedBaselineCompareRun($tenantExpiring, $expiringProfile, $expiringSnapshot, workspaceOverviewCompareCoverage()); seedBaselineCompareRun($tenantExpiring, $expiringProfile, $expiringSnapshot, workspaceOverviewCompareCoverage());
$tenantExpiringBackup = workspaceOverviewSeedHealthyBackup($tenantExpiring, [
'completed_at' => now()->subMinutes(15),
]);
workspaceOverviewSeedRestoreHistory($tenantExpiring, $tenantExpiringBackup, 'completed');
$finding = Finding::factory()->riskAccepted()->create([ $finding = Finding::factory()->riskAccepted()->create([
'workspace_id' => (int) $tenantExpiring->workspace_id, 'workspace_id' => (int) $tenantExpiring->workspace_id,
@ -112,6 +128,10 @@
workspaceOverviewCompareCoverage(), workspaceOverviewCompareCoverage(),
completedAt: now()->subDays(10), completedAt: now()->subDays(10),
); );
$tenantStaleBackup = workspaceOverviewSeedHealthyBackup($tenantStale, [
'completed_at' => now()->subMinutes(14),
]);
workspaceOverviewSeedRestoreHistory($tenantStale, $tenantStaleBackup, 'completed');
$workspace = $tenantExpiring->workspace()->firstOrFail(); $workspace = $tenantExpiring->workspace()->firstOrFail();
$overview = app(WorkspaceOverviewBuilder::class)->build($workspace, $user); $overview = app(WorkspaceOverviewBuilder::class)->build($workspace, $user);
@ -128,6 +148,10 @@
[$user, $tenantLapsed] = createUserWithTenant($tenantLapsed, role: 'owner', workspaceRole: 'readonly'); [$user, $tenantLapsed] = createUserWithTenant($tenantLapsed, role: 'owner', workspaceRole: 'readonly');
[$lapsedProfile, $lapsedSnapshot] = seedActiveBaselineForTenant($tenantLapsed); [$lapsedProfile, $lapsedSnapshot] = seedActiveBaselineForTenant($tenantLapsed);
seedBaselineCompareRun($tenantLapsed, $lapsedProfile, $lapsedSnapshot, workspaceOverviewCompareCoverage()); seedBaselineCompareRun($tenantLapsed, $lapsedProfile, $lapsedSnapshot, workspaceOverviewCompareCoverage());
$tenantLapsedBackup = workspaceOverviewSeedHealthyBackup($tenantLapsed, [
'completed_at' => now()->subMinutes(15),
]);
workspaceOverviewSeedRestoreHistory($tenantLapsed, $tenantLapsedBackup, 'completed');
Finding::factory()->riskAccepted()->create([ Finding::factory()->riskAccepted()->create([
'workspace_id' => (int) $tenantLapsed->workspace_id, 'workspace_id' => (int) $tenantLapsed->workspace_id,
@ -148,6 +172,10 @@
workspaceOverviewCompareCoverage(), workspaceOverviewCompareCoverage(),
outcome: OperationRunOutcome::Failed->value, outcome: OperationRunOutcome::Failed->value,
); );
$tenantFailedBackup = workspaceOverviewSeedHealthyBackup($tenantFailedCompare, [
'completed_at' => now()->subMinutes(14),
]);
workspaceOverviewSeedRestoreHistory($tenantFailedCompare, $tenantFailedBackup, 'completed');
$tenantHighSeverity = Tenant::factory()->create([ $tenantHighSeverity = Tenant::factory()->create([
'status' => 'active', 'status' => 'active',
@ -157,6 +185,10 @@
createUserWithTenant($tenantHighSeverity, $user, role: 'owner', workspaceRole: 'readonly'); createUserWithTenant($tenantHighSeverity, $user, role: 'owner', workspaceRole: 'readonly');
[$highProfile, $highSnapshot] = seedActiveBaselineForTenant($tenantHighSeverity); [$highProfile, $highSnapshot] = seedActiveBaselineForTenant($tenantHighSeverity);
seedBaselineCompareRun($tenantHighSeverity, $highProfile, $highSnapshot, workspaceOverviewCompareCoverage()); seedBaselineCompareRun($tenantHighSeverity, $highProfile, $highSnapshot, workspaceOverviewCompareCoverage());
$tenantHighBackup = workspaceOverviewSeedHealthyBackup($tenantHighSeverity, [
'completed_at' => now()->subMinutes(13),
]);
workspaceOverviewSeedRestoreHistory($tenantHighSeverity, $tenantHighBackup, 'completed');
Finding::factory()->for($tenantHighSeverity)->create([ Finding::factory()->for($tenantHighSeverity)->create([
'workspace_id' => (int) $tenantHighSeverity->workspace_id, 'workspace_id' => (int) $tenantHighSeverity->workspace_id,
@ -174,3 +206,66 @@
'tenant_high_severity_findings', '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\Services\Auth\CapabilityResolver;
use App\Support\Auth\Capabilities; use App\Support\Auth\Capabilities;
use App\Support\Workspaces\WorkspaceOverviewBuilder; use App\Support\Workspaces\WorkspaceOverviewBuilder;
use Carbon\CarbonImmutable;
use function Pest\Laravel\mock; 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 { it('keeps switch workspace visible while hiding manage workspaces and unauthorized tenant counts for readonly members', function (): void {
$tenantA = Tenant::factory()->create(['status' => 'active']); $tenantA = Tenant::factory()->create(['status' => 'active']);
[$user, $tenantA] = createUserWithTenant($tenantA, role: 'owner', workspaceRole: 'readonly'); [$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 { 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']); $tenant = Tenant::factory()->create(['status' => 'active']);
[$user, $tenant] = createUserWithTenant($tenant, role: 'readonly', workspaceRole: 'readonly'); [$user, $tenant] = createUserWithTenant($tenant, role: 'readonly', workspaceRole: 'readonly');
[$profile, $snapshot] = seedActiveBaselineForTenant($tenant); workspaceOverviewSeedQuietTenantTruth($tenant);
seedBaselineCompareRun($tenant, $profile, $snapshot, workspaceOverviewCompareCoverage()); $backupSet = workspaceOverviewSeedHealthyBackup($tenant, [
'completed_at' => now()->subMinutes(10),
]);
workspaceOverviewSeedRestoreHistory($tenant, $backupSet, 'completed');
\App\Models\Finding::factory()->for($tenant)->create([ \App\Models\Finding::factory()->for($tenant)->create([
'workspace_id' => (int) $tenant->workspace_id, 'workspace_id' => (int) $tenant->workspace_id,
@ -62,3 +70,101 @@
->and($item['destination']['kind'])->toBe('tenant_findings') ->and($item['destination']['kind'])->toBe('tenant_findings')
->and($item['helper_text'])->not->toBeNull(); ->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\OperationRun;
use App\Models\Tenant; use App\Models\Tenant;
use App\Support\Workspaces\WorkspaceOverviewBuilder; 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 { it('counts governance attention by affected tenant instead of raw issue totals', function (): void {
$tenantOverdue = Tenant::factory()->create(['status' => 'active']); $tenantOverdue = Tenant::factory()->create(['status' => 'active']);
@ -124,3 +129,82 @@
->and($metrics->get('alert_failures')['value'])->toBe(1) ->and($metrics->get('alert_failures')['value'])->toBe(1)
->and($metrics->get('alert_failures')['category'])->toBe('alerts'); ->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'));
$singleRecoveryTenant = Tenant::factory()->create([
'status' => 'active',
'name' => 'Single Recovery Tenant',
]);
[$user, $singleRecoveryTenant] = createUserWithTenant($singleRecoveryTenant, role: 'owner', workspaceRole: 'readonly');
workspaceOverviewSeedQuietTenantTruth($singleRecoveryTenant);
$singleRecoveryBackup = workspaceOverviewSeedHealthyBackup($singleRecoveryTenant, [
'completed_at' => now()->subMinutes(20),
]);
workspaceOverviewSeedRestoreHistory($singleRecoveryTenant, $singleRecoveryBackup, 'follow_up');
$backupTenantA = Tenant::factory()->create([
'status' => 'active',
'workspace_id' => (int) $singleRecoveryTenant->workspace_id,
'name' => 'Backup Tenant A',
]);
createUserWithTenant($backupTenantA, $user, role: 'owner', workspaceRole: 'readonly');
workspaceOverviewSeedQuietTenantTruth($backupTenantA);
$backupTenantB = Tenant::factory()->create([
'status' => 'active',
'workspace_id' => (int) $singleRecoveryTenant->workspace_id,
'name' => 'Backup Tenant B',
]);
createUserWithTenant($backupTenantB, $user, role: 'owner', workspaceRole: 'readonly');
workspaceOverviewSeedQuietTenantTruth($backupTenantB);
workspaceOverviewSeedHealthyBackup($backupTenantB, [
'completed_at' => now()->subDays(2),
]);
$calmTenant = Tenant::factory()->create([
'status' => 'active',
'workspace_id' => (int) $singleRecoveryTenant->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 = $singleRecoveryTenant->workspace()->firstOrFail();
$overview = app(WorkspaceOverviewBuilder::class)->build($workspace, $user);
$metrics = collect($overview['summary_metrics'])->keyBy('key');
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('recovery_attention_tenants')['value'])->toBe(1)
->and($metrics->get('recovery_attention_tenants')['category'])->toBe('recovery_evidence')
->and($metrics->get('recovery_attention_tenants')['destination']['kind'])->toBe('tenant_dashboard')
->and($metrics->get('recovery_attention_tenants')['destination']['tenant_route_key'])->toBe((string) $singleRecoveryTenant->external_id)
->and($metrics->get('recovery_attention_tenants')['destination_url'])->toContain('/admin/t/');
});
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

@ -7,7 +7,7 @@
it('keeps covered derived-state consumers on declared access paths without ad hoc caches', function (): void { it('keeps covered derived-state consumers on declared access paths without ad hoc caches', function (): void {
$root = SourceFileScanner::projectRoot(); $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 */ /** @var array<string, mixed> $contract */
$contract = Yaml::parseFile($contractPath); $contract = Yaml::parseFile($contractPath);

View File

@ -3,11 +3,14 @@
use App\Models\BaselineProfile; use App\Models\BaselineProfile;
use App\Models\BaselineSnapshot; use App\Models\BaselineSnapshot;
use App\Models\BaselineTenantAssignment; use App\Models\BaselineTenantAssignment;
use App\Models\BackupItem;
use App\Models\BackupSet;
use App\Models\EvidenceSnapshot; use App\Models\EvidenceSnapshot;
use App\Models\Finding; use App\Models\Finding;
use App\Models\OperationRun; use App\Models\OperationRun;
use App\Models\ProviderConnection; use App\Models\ProviderConnection;
use App\Models\ProviderCredential; use App\Models\ProviderCredential;
use App\Models\RestoreRun;
use App\Models\StoredReport; use App\Models\StoredReport;
use App\Models\Tenant; use App\Models\Tenant;
use App\Models\TenantOnboardingSession; 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} * @return array{tenant: string}
*/ */

View File

@ -49,3 +49,31 @@ function useTemporaryPublicPath(): string
expect(PanelThemeAsset::resolve('resources/css/filament/admin/theme.css')) expect(PanelThemeAsset::resolve('resources/css/filament/admin/theme.css'))
->toEndWith('/build/assets/theme-test.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.