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
45 changed files with 100 additions and 6485 deletions

View File

@ -157,10 +157,6 @@ ## Active Technologies
- 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) - 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) - PostgreSQL unchanged; no schema change, new cache table, or persisted workspace recovery artifact is planned (185-workspace-recovery-posture-visibility)
- PHP 8.4, Laravel 12, Blade, Filament v5, Livewire v4 + Filament v5 resources and table filters, Livewire v4 `ListRecords`, Pest v4, Laravel Sail, existing `TenantResource`, `ListTenants`, `WorkspaceOverviewBuilder`, `TenantBackupHealthResolver`, `TenantBackupHealthAssessment`, `RestoreSafetyResolver`, `RecoveryReadiness`, and shared badge infrastructure (186-tenant-registry-recovery-triage)
- PostgreSQL with existing tenant-owned `tenants`, `backup_sets`, `backup_items`, `restore_runs`, `policies`, and membership records; no schema change planned (186-tenant-registry-recovery-triage)
- PHP 8.4.15 + Laravel 12, Filament v5, Livewire v4, Pest v4, existing `WorkspaceOverviewBuilder`, `TenantResource`, `TenantDashboard`, `CanonicalAdminTenantFilterState`, `TenantBackupHealthAssessment`, `RestoreSafetyResolver`, and continuity-aware backup or restore list pages (187-portfolio-triage-arrival-context)
- PostgreSQL unchanged; no new tables, caches, or durable workflow artifacts (187-portfolio-triage-arrival-context)
- PHP 8.4.15 (feat/005-bulk-operations) - PHP 8.4.15 (feat/005-bulk-operations)
@ -195,8 +191,8 @@ ## Code Style
PHP 8.4.15: Follow standard conventions PHP 8.4.15: Follow standard conventions
## Recent Changes ## Recent Changes
- 187-portfolio-triage-arrival-context: Added PHP 8.4.15 + Laravel 12, Filament v5, Livewire v4, Pest v4, existing `WorkspaceOverviewBuilder`, `TenantResource`, `TenantDashboard`, `CanonicalAdminTenantFilterState`, `TenantBackupHealthAssessment`, `RestoreSafetyResolver`, and continuity-aware backup or restore list pages
- 186-tenant-registry-recovery-triage: Added PHP 8.4, Laravel 12, Blade, Filament v5, Livewire v4 + Filament v5 resources and table filters, Livewire v4 `ListRecords`, Pest v4, Laravel Sail, existing `TenantResource`, `ListTenants`, `WorkspaceOverviewBuilder`, `TenantBackupHealthResolver`, `TenantBackupHealthAssessment`, `RestoreSafetyResolver`, `RecoveryReadiness`, and shared badge infrastructure
- 185-workspace-recovery-posture-visibility: Added PHP 8.4.15 + Laravel 12, Filament v5, Livewire v4, Pest v4, existing `WorkspaceOverviewBuilder`, `WorkspaceSummaryStats`, `WorkspaceNeedsAttention`, `TenantBackupHealthResolver`, `TenantBackupHealthAssessment`, `RestoreSafetyResolver`, tenant dashboard widgets, `WorkspaceCapabilityResolver`, `CapabilityResolver`, and the current workspace overview Blade surfaces - 185-workspace-recovery-posture-visibility: Added PHP 8.4.15 + Laravel 12, Filament v5, Livewire v4, Pest v4, existing `WorkspaceOverviewBuilder`, `WorkspaceSummaryStats`, `WorkspaceNeedsAttention`, `TenantBackupHealthResolver`, `TenantBackupHealthAssessment`, `RestoreSafetyResolver`, tenant dashboard widgets, `WorkspaceCapabilityResolver`, `CapabilityResolver`, and the current workspace overview Blade surfaces
- 184-dashboard-recovery-honesty: Added PHP 8.4, Laravel 12, Blade, Filament v5, Livewire v4 + Filament v5 widgets and resources, Livewire v4, Pest v4, existing `TenantDashboard`, `DashboardKpis`, `NeedsAttention`, `TenantBackupHealthResolver`, `TenantBackupHealthAssessment`, `RestoreRunResource`, `RestoreSafetyResolver`, `RestoreResultAttention`, `OperationRunLinks`, and existing RBAC helpers
- 183-website-workspace-foundation: Added PHP 8.4.15 and Laravel 12 for `apps/platform`; Node.js 20+ with pnpm 10 workspace tooling; Astro v6 for `apps/website`; Bash and Docker Compose for root orchestration + `laravel/framework`, `filament/filament`, `livewire/livewire`, `laravel/sail`, `vite`, `tailwindcss`, `pnpm` workspaces, Astro, existing `./scripts/platform-sail` wrapper, repo-root Docker Compose
<!-- MANUAL ADDITIONS START --> <!-- MANUAL ADDITIONS START -->
<!-- MANUAL ADDITIONS END --> <!-- MANUAL ADDITIONS END -->

View File

@ -4,7 +4,6 @@
namespace App\Filament\Pages; namespace App\Filament\Pages;
use App\Filament\Widgets\Tenant\TenantTriageArrivalContinuity;
use App\Filament\Widgets\Dashboard\BaselineCompareNow; use App\Filament\Widgets\Dashboard\BaselineCompareNow;
use App\Filament\Widgets\Dashboard\DashboardKpis; use App\Filament\Widgets\Dashboard\DashboardKpis;
use App\Filament\Widgets\Dashboard\NeedsAttention; use App\Filament\Widgets\Dashboard\NeedsAttention;
@ -32,7 +31,6 @@ public static function getUrl(array $parameters = [], bool $isAbsolute = true, ?
public function getWidgets(): array public function getWidgets(): array
{ {
return [ return [
TenantTriageArrivalContinuity::class,
RecoveryReadiness::class, RecoveryReadiness::class,
DashboardKpis::class, DashboardKpis::class,
NeedsAttention::class, NeedsAttention::class,

View File

@ -2,7 +2,6 @@
namespace App\Filament\Resources; namespace App\Filament\Resources;
use App\Filament\Pages\TenantDashboard;
use App\Filament\Resources\TenantResource\Pages; use App\Filament\Resources\TenantResource\Pages;
use App\Filament\Resources\TenantResource\RelationManagers; use App\Filament\Resources\TenantResource\RelationManagers;
use App\Http\Controllers\RbacDelegatedAuthController; use App\Http\Controllers\RbacDelegatedAuthController;
@ -31,8 +30,6 @@
use App\Support\Audit\AuditActionId; use App\Support\Audit\AuditActionId;
use App\Support\Auth\Capabilities; use App\Support\Auth\Capabilities;
use App\Support\Auth\UiTooltips; use App\Support\Auth\UiTooltips;
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\Badges\TagBadgeDomain; use App\Support\Badges\TagBadgeDomain;
@ -41,16 +38,13 @@
use App\Support\OperationRunLinks; use App\Support\OperationRunLinks;
use App\Support\OpsUx\OperationUxPresenter; use App\Support\OpsUx\OperationUxPresenter;
use App\Support\OpsUx\OpsUxBrowserEvents; use App\Support\OpsUx\OpsUxBrowserEvents;
use App\Support\PortfolioTriage\PortfolioArrivalContextToken;
use App\Support\Rbac\UiEnforcement; use App\Support\Rbac\UiEnforcement;
use App\Support\RestoreSafety\RestoreSafetyResolver;
use App\Support\Tenants\TenantActionDescriptor; use App\Support\Tenants\TenantActionDescriptor;
use App\Support\Tenants\TenantActionSurface; use App\Support\Tenants\TenantActionSurface;
use App\Support\Tenants\TenantInteractionLane; use App\Support\Tenants\TenantInteractionLane;
use App\Support\Tenants\TenantLifecyclePresentation; use App\Support\Tenants\TenantLifecyclePresentation;
use App\Support\Tenants\TenantOperabilityOutcome; use App\Support\Tenants\TenantOperabilityOutcome;
use App\Support\Tenants\TenantOperabilityQuestion; use App\Support\Tenants\TenantOperabilityQuestion;
use App\Support\Tenants\TenantRecoveryTriagePresentation;
use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration; use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration;
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceInspectAffordance; use App\Support\Ui\ActionSurface\Enums\ActionSurfaceInspectAffordance;
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile; use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile;
@ -70,7 +64,6 @@
use Filament\Schemas\Components\Utilities\Set; use Filament\Schemas\Components\Utilities\Set;
use Filament\Schemas\Schema; use Filament\Schemas\Schema;
use Filament\Tables; use Filament\Tables;
use Filament\Tables\Contracts\HasTable;
use Filament\Tables\Table; use Filament\Tables\Table;
use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
@ -101,16 +94,6 @@ class TenantResource extends Resource
*/ */
protected static array $tenantActionCatalogCache = []; protected static array $tenantActionCatalogCache = [];
private const string POSTURE_SNAPSHOT_REQUEST_KEY = 'tenant_resource.posture_snapshot';
/**
* @var array<string, true>
*/
private const array TRIAGE_SORT_VALUES = [
TenantRecoveryTriagePresentation::TRIAGE_SORT_DEFAULT => true,
TenantRecoveryTriagePresentation::TRIAGE_SORT_WORST_FIRST => true,
];
/** /**
* Tenant creation is handled exclusively by the onboarding wizard. * Tenant creation is handled exclusively by the onboarding wizard.
* The CRUD create page has been removed. * The CRUD create page has been removed.
@ -258,19 +241,7 @@ public static function getGlobalSearchEloquentQuery(): Builder
public static function table(Table $table): Table public static function table(Table $table): Table
{ {
return $table return $table
->defaultSort(function (Builder $query, string $direction, mixed $livewire): Builder { ->defaultSort('name')
if (static::triageSortIsWorstFirst(static::triageSortState($livewire))) {
return static::applyWorstFirstTriageOrdering($query);
}
$nameColumn = (new Tenant)->qualifyColumn('name');
if (! method_exists($livewire, 'getTableSortColumn') || $livewire->getTableSortColumn() !== 'name') {
$query->orderBy($nameColumn, $direction);
}
return $query;
})
->paginated(TablePaginationProfiles::resource()) ->paginated(TablePaginationProfiles::resource())
->persistFiltersInSession() ->persistFiltersInSession()
->persistSearchInSession() ->persistSearchInSession()
@ -280,28 +251,6 @@ public static function table(Table $table): Table
Tables\Columns\TextColumn::make('name') Tables\Columns\TextColumn::make('name')
->searchable() ->searchable()
->sortable(), ->sortable(),
Tables\Columns\TextColumn::make('backup_posture')
->label('Backup posture')
->badge()
->state(fn (Tenant $record): ?string => static::backupHealthAssessmentForTenant($record)?->posture)
->formatStateUsing(fn (mixed $state, Tenant $record): string => TenantRecoveryTriagePresentation::backupPostureLabel(
static::backupHealthAssessmentForTenant($record),
))
->color(fn (Tenant $record): string => TenantRecoveryTriagePresentation::backupPostureTone(
static::backupHealthAssessmentForTenant($record),
)),
Tables\Columns\TextColumn::make('recovery_evidence')
->label('Recovery evidence')
->badge()
->state(fn (Tenant $record): ?string => TenantRecoveryTriagePresentation::recoveryEvidenceState(
static::recoveryEvidenceForTenant($record),
))
->formatStateUsing(fn (mixed $state, Tenant $record): string => TenantRecoveryTriagePresentation::recoveryEvidenceLabel(
static::recoveryEvidenceForTenant($record),
))
->color(fn (Tenant $record): string => TenantRecoveryTriagePresentation::recoveryEvidenceTone(
static::recoveryEvidenceForTenant($record),
)),
Tables\Columns\TextColumn::make('tenant_id') Tables\Columns\TextColumn::make('tenant_id')
->label('Tenant ID') ->label('Tenant ID')
->copyable() ->copyable()
@ -354,32 +303,6 @@ public static function table(Table $table): Table
'staging' => 'STAGING', 'staging' => 'STAGING',
'other' => 'Other', 'other' => 'Other',
]), ]),
Tables\Filters\SelectFilter::make('backup_posture')
->label('Backup posture')
->multiple()
->options(TenantRecoveryTriagePresentation::backupPostureOptions())
->query(function (Builder $query, array $data): Builder {
return static::applySnapshotTenantSubsetFilter(
$query,
static::sanitizeBackupPostures($data['values'] ?? []),
snapshotKey: 'backup_posture_ids',
);
}),
Tables\Filters\SelectFilter::make('recovery_evidence')
->label('Recovery evidence')
->multiple()
->options(TenantRecoveryTriagePresentation::recoveryEvidenceOptions())
->query(function (Builder $query, array $data): Builder {
return static::applyRecoveryEvidenceFilter(
$query,
static::sanitizeRecoveryEvidenceStates($data['values'] ?? []),
);
}),
Tables\Filters\SelectFilter::make('triage_sort')
->label('Sort order')
->options(TenantRecoveryTriagePresentation::triageSortOptions())
->placeholder('Default order')
->query(static fn (Builder $query): Builder => $query),
]) ])
->actions([ ->actions([
Actions\Action::make('related_onboarding') Actions\Action::make('related_onboarding')
@ -387,34 +310,6 @@ public static function table(Table $table): Table
->icon(fn (Tenant $record): string => static::relatedOnboardingDraftAction($record, TenantActionSurface::TenantIndexRow)?->icon ?? 'heroicon-o-arrow-path') ->icon(fn (Tenant $record): string => static::relatedOnboardingDraftAction($record, TenantActionSurface::TenantIndexRow)?->icon ?? 'heroicon-o-arrow-path')
->url(fn (Tenant $record): string => static::relatedOnboardingDraftUrl($record) ?? route('admin.onboarding')) ->url(fn (Tenant $record): string => static::relatedOnboardingDraftUrl($record) ?? route('admin.onboarding'))
->visible(fn (Tenant $record): bool => static::tenantIndexPrimaryAction($record)?->key === 'related_onboarding'), ->visible(fn (Tenant $record): bool => static::tenantIndexPrimaryAction($record)?->key === 'related_onboarding'),
Actions\Action::make('openTenant')
->label('Open')
->icon('heroicon-o-arrow-right')
->color('primary')
->url(function (?Tenant $record = null, mixed $livewire = null): string {
if (! $record instanceof Tenant) {
return '#';
}
$triageState = $livewire instanceof Pages\ListTenants
? static::portfolioReturnFilters(
static::backupPostureState($livewire),
static::recoveryEvidenceState($livewire),
static::triageSortState($livewire),
)
: [];
if (! static::hasActivePortfolioTriageState(
static::sanitizeBackupPostures($triageState['backup_posture'] ?? []),
static::sanitizeRecoveryEvidenceStates($triageState['recovery_evidence'] ?? []),
static::sanitizeTriageSort($triageState['triage_sort'] ?? null),
)) {
$triageState = static::portfolioReturnFiltersFromRequest(request()->query());
}
return static::tenantDashboardOpenUrl($record, $triageState);
})
->visible(fn (Tenant $record): bool => $record->isActive() && static::tenantIndexPrimaryAction($record)?->key !== 'related_onboarding'),
ActionGroup::make([ ActionGroup::make([
Actions\Action::make('related_onboarding_overflow') Actions\Action::make('related_onboarding_overflow')
->label(fn (Tenant $record): string => static::relatedOnboardingDraftActionLabel($record, TenantActionSurface::TenantIndexRow) ?? 'View related onboarding') ->label(fn (Tenant $record): string => static::relatedOnboardingDraftActionLabel($record, TenantActionSurface::TenantIndexRow) ?? 'View related onboarding')
@ -422,6 +317,12 @@ public static function table(Table $table): Table
->url(fn (Tenant $record): string => static::relatedOnboardingDraftUrl($record) ?? route('admin.onboarding')) ->url(fn (Tenant $record): string => static::relatedOnboardingDraftUrl($record) ?? route('admin.onboarding'))
->visible(fn (Tenant $record): bool => static::relatedOnboardingDraftAction($record, TenantActionSurface::TenantIndexRow) instanceof TenantActionDescriptor ->visible(fn (Tenant $record): bool => static::relatedOnboardingDraftAction($record, TenantActionSurface::TenantIndexRow) instanceof TenantActionDescriptor
&& static::tenantIndexPrimaryAction($record)?->key !== 'related_onboarding'), && static::tenantIndexPrimaryAction($record)?->key !== 'related_onboarding'),
Actions\Action::make('openTenant')
->label('Open')
->icon('heroicon-o-arrow-right')
->color('primary')
->url(fn (Tenant $record) => \App\Filament\Resources\PolicyResource::getUrl('index', panel: 'tenant', tenant: $record))
->visible(fn (Tenant $record) => $record->isActive()),
UiEnforcement::forAction( UiEnforcement::forAction(
Actions\Action::make('edit') Actions\Action::make('edit')
->label('Edit') ->label('Edit')
@ -907,531 +808,6 @@ public static function table(Table $table): Table
->emptyStateIcon('heroicon-o-building-office-2'); ->emptyStateIcon('heroicon-o-building-office-2');
} }
/**
* @return list<string>
*/
public static function sanitizeBackupPostures(mixed $value): array
{
return static::sanitizeRequestedValues(
$value,
array_keys(TenantRecoveryTriagePresentation::backupPostureOptions()),
);
}
/**
* @return list<string>
*/
public static function sanitizeRecoveryEvidenceStates(mixed $value): array
{
return static::sanitizeRequestedValues(
$value,
array_keys(TenantRecoveryTriagePresentation::recoveryEvidenceOptions()),
);
}
public static function sanitizeTriageSort(mixed $value): ?string
{
if (! is_string($value) || ! isset(self::TRIAGE_SORT_VALUES[$value])) {
return null;
}
return $value;
}
/**
* @param array{
* backup_posture?: list<string>,
* recovery_evidence?: list<string>,
* triage_sort?: string|null
* } $triageState
*/
public static function tenantDashboardOpenUrl(Tenant $record, array $triageState = []): string
{
$arrivalState = static::portfolioArrivalStateForTenant($record, $triageState);
if ($arrivalState === null) {
return TenantDashboard::getUrl(panel: 'tenant', tenant: $record);
}
return TenantDashboard::getUrl(
parameters: [
PortfolioArrivalContextToken::QUERY_PARAMETER => PortfolioArrivalContextToken::encode($arrivalState),
],
panel: 'tenant',
tenant: $record,
);
}
/**
* @param array{
* backup_posture?: list<string>,
* recovery_evidence?: list<string>,
* triage_sort?: string|null
* } $triageState
* @return array<string, mixed>|null
*/
private static function portfolioArrivalStateForTenant(Tenant $record, array $triageState = []): ?array
{
$backupPostures = static::sanitizeBackupPostures($triageState['backup_posture'] ?? []);
$recoveryEvidenceFilters = static::sanitizeRecoveryEvidenceStates($triageState['recovery_evidence'] ?? []);
$triageSort = static::sanitizeTriageSort($triageState['triage_sort'] ?? null);
if (! static::hasActivePortfolioTriageState($backupPostures, $recoveryEvidenceFilters, $triageSort)) {
return null;
}
$backupAssessment = static::backupHealthAssessmentForTenant($record);
$backupPosture = $backupAssessment?->posture;
$recoveryEvidence = static::recoveryEvidenceForTenant($record);
$recoveryState = is_array($recoveryEvidence)
? TenantRecoveryTriagePresentation::recoveryEvidenceState($recoveryEvidence)
: null;
$matchedConcerns = [];
if ($backupAssessment instanceof TenantBackupHealthAssessment
&& $backupPostures !== []
&& in_array($backupPosture, $backupPostures, true)
&& in_array($backupPosture, [
TenantBackupHealthAssessment::POSTURE_ABSENT,
TenantBackupHealthAssessment::POSTURE_STALE,
TenantBackupHealthAssessment::POSTURE_DEGRADED,
], true)) {
$matchedConcerns[] = [
'priority' => static::portfolioConcernPriority('backup_health', $backupPosture),
'family' => 'backup_health',
'state' => $backupPosture,
'reason' => $backupAssessment->primaryReason,
];
}
if (is_string($recoveryState)
&& $recoveryEvidenceFilters !== []
&& in_array($recoveryState, $recoveryEvidenceFilters, true)
&& in_array($recoveryState, [
TenantRecoveryTriagePresentation::RECOVERY_EVIDENCE_UNVALIDATED,
TenantRecoveryTriagePresentation::RECOVERY_EVIDENCE_WEAKENED,
], true)) {
$matchedConcerns[] = [
'priority' => static::portfolioConcernPriority('recovery_evidence', $recoveryState),
'family' => 'recovery_evidence',
'state' => $recoveryState,
'reason' => is_string($recoveryEvidence['reason'] ?? null) ? $recoveryEvidence['reason'] : null,
];
}
if ($matchedConcerns === [] && $triageSort === TenantRecoveryTriagePresentation::TRIAGE_SORT_WORST_FIRST) {
if ($backupAssessment instanceof TenantBackupHealthAssessment
&& in_array($backupPosture, [
TenantBackupHealthAssessment::POSTURE_ABSENT,
TenantBackupHealthAssessment::POSTURE_STALE,
TenantBackupHealthAssessment::POSTURE_DEGRADED,
], true)) {
$matchedConcerns[] = [
'priority' => static::portfolioConcernPriority('backup_health', $backupPosture),
'family' => 'backup_health',
'state' => $backupPosture,
'reason' => $backupAssessment->primaryReason,
];
}
if (is_string($recoveryState)
&& in_array($recoveryState, [
TenantRecoveryTriagePresentation::RECOVERY_EVIDENCE_UNVALIDATED,
TenantRecoveryTriagePresentation::RECOVERY_EVIDENCE_WEAKENED,
], true)) {
$matchedConcerns[] = [
'priority' => static::portfolioConcernPriority('recovery_evidence', $recoveryState),
'family' => 'recovery_evidence',
'state' => $recoveryState,
'reason' => is_string($recoveryEvidence['reason'] ?? null) ? $recoveryEvidence['reason'] : null,
];
}
}
if ($matchedConcerns === []) {
return null;
}
usort($matchedConcerns, static fn (array $left, array $right): int => $left['priority'] <=> $right['priority']);
$primaryConcern = $matchedConcerns[0];
$workspaceId = app(WorkspaceContext::class)->currentWorkspaceId(request());
return [
'sourceSurface' => PortfolioArrivalContextToken::SOURCE_TENANT_REGISTRY,
'tenantRouteKey' => filled($record->external_id) ? (string) $record->external_id : (string) $record->getKey(),
'workspaceId' => $workspaceId,
'concernFamily' => $primaryConcern['family'],
'concernState' => $primaryConcern['state'],
'concernReason' => $primaryConcern['reason'],
'returnFilters' => static::portfolioReturnFilters($backupPostures, $recoveryEvidenceFilters, $triageSort),
];
}
/**
* @return array<string, mixed>
*/
public static function portfolioReturnFilters(
array $backupPostures,
array $recoveryEvidence,
?string $triageSort,
): array {
$filters = [];
if ($backupPostures !== []) {
$filters['backup_posture'] = $backupPostures;
}
if ($recoveryEvidence !== []) {
$filters['recovery_evidence'] = $recoveryEvidence;
}
if ($triageSort !== null) {
$filters['triage_sort'] = $triageSort;
}
return $filters;
}
/**
* @param array<string, mixed> $query
* @return array<string, mixed>
*/
private static function portfolioReturnFiltersFromRequest(array $query): array
{
return static::portfolioReturnFilters(
static::sanitizeBackupPostures($query['backup_posture'] ?? []),
static::sanitizeRecoveryEvidenceStates($query['recovery_evidence'] ?? []),
static::sanitizeTriageSort($query['triage_sort'] ?? null),
);
}
private static function hasActivePortfolioTriageState(
array $backupPostures,
array $recoveryEvidence,
?string $triageSort,
): bool {
return $backupPostures !== []
|| $recoveryEvidence !== []
|| $triageSort === TenantRecoveryTriagePresentation::TRIAGE_SORT_WORST_FIRST;
}
private static function portfolioConcernPriority(string $family, string $state): int
{
return match (true) {
$family === 'backup_health' && $state === TenantBackupHealthAssessment::POSTURE_ABSENT => 1,
$family === 'recovery_evidence' && $state === TenantRecoveryTriagePresentation::RECOVERY_EVIDENCE_WEAKENED => 2,
$family === 'backup_health' && $state === TenantBackupHealthAssessment::POSTURE_STALE => 3,
$family === 'recovery_evidence' && $state === TenantRecoveryTriagePresentation::RECOVERY_EVIDENCE_UNVALIDATED => 4,
$family === 'backup_health' && $state === TenantBackupHealthAssessment::POSTURE_DEGRADED => 5,
default => 99,
};
}
/**
* @param list<string> $allowedValues
* @return list<string>
*/
private static function sanitizeRequestedValues(mixed $value, array $allowedValues): array
{
$allowed = array_fill_keys($allowedValues, true);
return array_values(array_filter(
array_unique(array_filter(static::normalizeRequestedValues($value), static fn (string $item): bool => isset($allowed[$item]))),
static fn (string $item): bool => isset($allowed[$item]),
));
}
/**
* @return list<string>
*/
private static function normalizeRequestedValues(mixed $value): array
{
if (is_string($value) && $value !== '') {
return [$value];
}
if (! is_array($value)) {
return [];
}
return array_values(array_filter($value, static fn (mixed $item): bool => is_string($item) && $item !== ''));
}
private static function triageSortState(mixed $livewire): ?string
{
if (! is_object($livewire) || ! method_exists($livewire, 'getTableFilterState')) {
return null;
}
$state = $livewire->getTableFilterState('triage_sort');
$value = is_array($state) ? ($state['value'] ?? null) : null;
return static::sanitizeTriageSort($value);
}
/**
* @return list<string>
*/
private static function backupPostureState(mixed $livewire): array
{
if (! is_object($livewire) || ! method_exists($livewire, 'getTableFilterState')) {
return [];
}
$state = $livewire->getTableFilterState('backup_posture');
$values = is_array($state) ? ($state['values'] ?? []) : [];
return static::sanitizeBackupPostures($values);
}
/**
* @return list<string>
*/
private static function recoveryEvidenceState(mixed $livewire): array
{
if (! is_object($livewire) || ! method_exists($livewire, 'getTableFilterState')) {
return [];
}
$state = $livewire->getTableFilterState('recovery_evidence');
$values = is_array($state) ? ($state['values'] ?? []) : [];
return static::sanitizeRecoveryEvidenceStates($values);
}
private static function triageSortIsWorstFirst(?string $value): bool
{
return $value === TenantRecoveryTriagePresentation::TRIAGE_SORT_WORST_FIRST;
}
private static function backupHealthAssessmentForTenant(Tenant $tenant): ?TenantBackupHealthAssessment
{
return static::postureSnapshot()['backup_health'][(int) $tenant->getKey()] ?? null;
}
/**
* @return array<string, mixed>|null
*/
private static function recoveryEvidenceForTenant(Tenant $tenant): ?array
{
return static::postureSnapshot()['recovery_evidence'][(int) $tenant->getKey()] ?? null;
}
private static function applySnapshotTenantSubsetFilter(
Builder $query,
array $selectedValues,
string $snapshotKey,
): Builder {
if ($selectedValues === []) {
return $query;
}
$snapshot = static::postureSnapshot();
$tenantIds = collect($selectedValues)
->flatMap(static fn (string $value): array => $snapshot[$snapshotKey][$value] ?? [])
->map(static fn (mixed $tenantId): int => (int) $tenantId)
->unique()
->values()
->all();
if ($tenantIds === []) {
return $query->whereRaw('1 = 0');
}
return $query->whereIn((new Tenant)->qualifyColumn('id'), $tenantIds);
}
private static function applyRecoveryEvidenceFilter(Builder $query, array $selectedValues): Builder
{
if ($selectedValues === []) {
return $query;
}
$tenantIds = collect(static::postureSnapshot()['recovery_evidence'])
->filter(static fn (array $evidence): bool => in_array(
TenantRecoveryTriagePresentation::recoveryEvidenceState($evidence),
$selectedValues,
true,
))
->keys()
->map(static fn (mixed $tenantId): int => (int) $tenantId)
->values()
->all();
if ($tenantIds === []) {
return $query->whereRaw('1 = 0');
}
return $query->whereIn((new Tenant)->qualifyColumn('id'), $tenantIds);
}
private static function applyWorstFirstTriageOrdering(Builder $query): Builder
{
$tiers = static::postureSnapshot()['triage_tiers'];
if ($tiers === []) {
return $query;
}
$qualifiedId = (new Tenant)->qualifyColumn('id');
$qualifiedName = (new Tenant)->qualifyColumn('name');
$clauses = [];
$bindings = [];
foreach ([1, 2, 3, 4, 5] as $tier) {
$tenantIds = array_keys(array_filter($tiers, static fn (int $value): bool => $value === $tier));
if ($tenantIds === []) {
continue;
}
$placeholders = implode(', ', array_fill(0, count($tenantIds), '?'));
$clauses[] = "when {$qualifiedId} in ({$placeholders}) then {$tier}";
array_push($bindings, ...$tenantIds);
}
if ($clauses === []) {
return $query->orderBy($qualifiedName);
}
return $query
->orderByRaw('case '.implode(' ', $clauses).' else 6 end', $bindings)
->orderBy($qualifiedName);
}
/**
* @return array{
* tenant_ids: list<int>,
* backup_health: array<int, TenantBackupHealthAssessment>,
* recovery_evidence: array<int, array<string, mixed>>,
* backup_posture_ids: array<string, list<int>>,
* recovery_evidence_ids: array<string, list<int>>,
* triage_tiers: array<int, int>
* }
*/
private static function postureSnapshot(): array
{
$request = request();
$snapshot = $request->attributes->get(self::POSTURE_SNAPSHOT_REQUEST_KEY);
if (is_array($snapshot)) {
return $snapshot;
}
$snapshot = static::buildPostureSnapshot();
$request->attributes->set(self::POSTURE_SNAPSHOT_REQUEST_KEY, $snapshot);
return $snapshot;
}
/**
* @return array{
* tenant_ids: list<int>,
* backup_health: array<int, TenantBackupHealthAssessment>,
* recovery_evidence: array<int, array<string, mixed>>,
* backup_posture_ids: array<string, list<int>>,
* recovery_evidence_ids: array<string, list<int>>,
* triage_tiers: array<int, int>
* }
*/
private static function buildPostureSnapshot(): array
{
$tenantIds = static::visibleTenantIdsForPostureSnapshot();
if ($tenantIds === []) {
return [
'tenant_ids' => [],
'backup_health' => [],
'recovery_evidence' => [],
'backup_posture_ids' => [],
'recovery_evidence_ids' => [],
'triage_tiers' => [],
];
}
/** @var TenantBackupHealthResolver $backupHealthResolver */
$backupHealthResolver = app(TenantBackupHealthResolver::class);
$backupHealth = $backupHealthResolver->assessMany($tenantIds);
/** @var RestoreSafetyResolver $restoreSafetyResolver */
$restoreSafetyResolver = app(RestoreSafetyResolver::class);
$recoveryEvidence = $restoreSafetyResolver->dashboardRecoveryEvidenceForTenants($tenantIds, $backupHealth);
$triageTiers = [];
foreach ($tenantIds as $tenantId) {
$triageTiers[$tenantId] = TenantRecoveryTriagePresentation::triageTier(
$backupHealth[$tenantId] ?? null,
$recoveryEvidence[$tenantId] ?? null,
);
}
return [
'tenant_ids' => $tenantIds,
'backup_health' => $backupHealth,
'recovery_evidence' => $recoveryEvidence,
'backup_posture_ids' => static::snapshotTenantIdsByBackupPosture($backupHealth),
'recovery_evidence_ids' => static::snapshotTenantIdsByRecoveryEvidence($recoveryEvidence),
'triage_tiers' => $triageTiers,
];
}
/**
* @return list<int>
*/
private static function visibleTenantIdsForPostureSnapshot(): array
{
/** @var Builder $query */
$query = clone static::getEloquentQuery();
return $query
->reorder()
->orderBy((new Tenant)->qualifyColumn('name'))
->orderBy((new Tenant)->qualifyColumn('id'))
->pluck((new Tenant)->qualifyColumn('id'))
->map(static fn (mixed $tenantId): int => (int) $tenantId)
->all();
}
/**
* @param array<int, TenantBackupHealthAssessment> $assessments
* @return array<string, list<int>>
*/
private static function snapshotTenantIdsByBackupPosture(array $assessments): array
{
$tenantIdsByPosture = [];
foreach ($assessments as $tenantId => $assessment) {
$tenantIdsByPosture[$assessment->posture] ??= [];
$tenantIdsByPosture[$assessment->posture][] = (int) $tenantId;
}
return $tenantIdsByPosture;
}
/**
* @param array<int, array<string, mixed>> $recoveryEvidence
* @return array<string, list<int>>
*/
private static function snapshotTenantIdsByRecoveryEvidence(array $recoveryEvidence): array
{
$tenantIdsByState = [];
foreach ($recoveryEvidence as $tenantId => $evidence) {
$state = TenantRecoveryTriagePresentation::recoveryEvidenceState($evidence);
if ($state === null) {
continue;
}
$tenantIdsByState[$state] ??= [];
$tenantIdsByState[$state][] = (int) $tenantId;
}
return $tenantIdsByState;
}
public static function infolist(Schema $schema): Schema public static function infolist(Schema $schema): Schema
{ {
// ... [Infolist Omitted - No Change] ... // ... [Infolist Omitted - No Change] ...

View File

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

View File

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

View File

@ -1,35 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Filament\Widgets\Tenant;
use App\Models\Tenant;
use App\Support\PortfolioTriage\PortfolioArrivalContextResolver;
use Filament\Facades\Filament;
use Filament\Widgets\Widget;
class TenantTriageArrivalContinuity extends Widget
{
protected static bool $isLazy = false;
protected int|string|array $columnSpan = 'full';
protected string $view = 'filament.widgets.tenant.triage-arrival-continuity';
/**
* @return array<string, mixed>
*/
protected function getViewData(): array
{
$tenant = Filament::getTenant();
if (! $tenant instanceof Tenant) {
return ['context' => null];
}
return [
'context' => app(PortfolioArrivalContextResolver::class)->resolve(request(), $tenant),
];
}
}

View File

@ -1,69 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Support\PortfolioTriage;
use App\Support\BackupHealth\TenantBackupHealthAssessment;
use App\Support\Tenants\TenantRecoveryTriagePresentation;
final readonly class PortfolioArrivalContext
{
/**
* @param array{
* kind: string,
* label: string,
* url: string|null,
* disabled: bool,
* helperText: string|null
* } $nextStep
* @param array{
* kind: string,
* label: string,
* url: string,
* filters: array<string, mixed>|null
* }|null $returnTarget
*/
public function __construct(
public string $sourceSurface,
public string $concernFamily,
public string $concernState,
public ?string $concernReason,
public string $arrivalSummary,
public ?string $claimBoundary,
public array $nextStep,
public ?array $returnTarget,
public ?string $currentTruthDelta,
) {}
public function sourceLabel(): string
{
return match ($this->sourceSurface) {
PortfolioArrivalContextToken::SOURCE_WORKSPACE_OVERVIEW => 'Workspace overview triage',
PortfolioArrivalContextToken::SOURCE_TENANT_REGISTRY => 'Tenant registry triage',
default => 'Portfolio triage',
};
}
public function concernFamilyLabel(): string
{
return match ($this->concernFamily) {
PortfolioArrivalContextToken::FAMILY_BACKUP_HEALTH => 'Backup health',
PortfolioArrivalContextToken::FAMILY_RECOVERY_EVIDENCE => 'Recovery evidence',
default => 'Portfolio concern',
};
}
public function concernStateLabel(): string
{
return match ($this->concernState) {
TenantBackupHealthAssessment::POSTURE_ABSENT => 'Absent',
TenantBackupHealthAssessment::POSTURE_STALE => 'Stale',
TenantBackupHealthAssessment::POSTURE_DEGRADED => 'Degraded',
TenantRecoveryTriagePresentation::RECOVERY_EVIDENCE_UNVALIDATED => 'Unvalidated',
TenantRecoveryTriagePresentation::RECOVERY_EVIDENCE_WEAKENED => 'Weakened',
TenantRecoveryTriagePresentation::RECOVERY_EVIDENCE_NO_RECENT_ISSUES_VISIBLE => 'No recent issues visible',
default => ucfirst(str_replace('_', ' ', $this->concernState)),
};
}
}

View File

@ -1,527 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Support\PortfolioTriage;
use App\Filament\Resources\BackupSetResource;
use App\Filament\Resources\RestoreRunResource;
use App\Filament\Resources\TenantResource;
use App\Models\RestoreRun;
use App\Models\Tenant;
use App\Models\User;
use App\Services\Auth\CapabilityResolver;
use App\Support\Auth\Capabilities;
use App\Support\BackupHealth\TenantBackupHealthAssessment;
use App\Support\BackupHealth\TenantBackupHealthResolver;
use App\Support\Rbac\UiTooltips;
use App\Support\RestoreSafety\RestoreResultAttention;
use App\Support\RestoreSafety\RestoreSafetyResolver;
use App\Support\Tenants\TenantRecoveryTriagePresentation;
use App\Support\Workspaces\WorkspaceContext;
use Illuminate\Http\Request;
final readonly class PortfolioArrivalContextResolver
{
private const string REQUEST_CACHE_KEY = 'portfolio_triage.arrival_context';
private const string REQUEST_CACHE_HIT_KEY = 'portfolio_triage.arrival_context.hit';
public function __construct(
private CapabilityResolver $capabilityResolver,
private TenantBackupHealthResolver $tenantBackupHealthResolver,
private RestoreSafetyResolver $restoreSafetyResolver,
private WorkspaceContext $workspaceContext,
) {}
public function resolve(Request $request, Tenant $tenant): ?PortfolioArrivalContext
{
if ($request->attributes->has(self::REQUEST_CACHE_HIT_KEY)) {
$cached = $request->attributes->get(self::REQUEST_CACHE_KEY);
return $cached instanceof PortfolioArrivalContext ? $cached : null;
}
$request->attributes->set(self::REQUEST_CACHE_HIT_KEY, true);
$state = PortfolioArrivalContextToken::decode(
$request->query(PortfolioArrivalContextToken::QUERY_PARAMETER),
);
if ($state === null || ! $this->matchesScope($tenant, $request, $state)) {
$request->attributes->set(self::REQUEST_CACHE_KEY, null);
return null;
}
$context = $this->buildContext($tenant, $state);
$request->attributes->set(self::REQUEST_CACHE_KEY, $context);
return $context;
}
/**
* @param array{
* sourceSurface: string,
* tenantRouteKey: string|null,
* workspaceId: int|null,
* concernFamily: string,
* concernState: string,
* concernReason: string|null,
* returnFilters: array<string, mixed>|null
* } $state
*/
private function matchesScope(Tenant $tenant, Request $request, array $state): bool
{
$tenantRouteKey = $state['tenantRouteKey'];
if (! is_string($tenantRouteKey) || $tenantRouteKey === '') {
return false;
}
$allowedRouteKeys = array_filter([
filled($tenant->external_id) ? (string) $tenant->external_id : null,
(string) $tenant->getKey(),
]);
if (! in_array($tenantRouteKey, $allowedRouteKeys, true)) {
return false;
}
$workspaceId = $state['workspaceId'];
return $workspaceId === null
|| $this->workspaceContext->currentWorkspaceId($request) === $workspaceId;
}
/**
* @param array{
* sourceSurface: string,
* tenantRouteKey: string|null,
* workspaceId: int|null,
* concernFamily: string,
* concernState: string,
* concernReason: string|null,
* returnFilters: array<string, mixed>|null
* } $state
*/
private function buildContext(Tenant $tenant, array $state): PortfolioArrivalContext
{
$backupHealth = $this->tenantBackupHealthResolver->assess($tenant);
$recoveryEvidence = $this->restoreSafetyResolver->dashboardRecoveryEvidence($tenant);
return new PortfolioArrivalContext(
sourceSurface: $state['sourceSurface'],
concernFamily: $state['concernFamily'],
concernState: $state['concernState'],
concernReason: $state['concernReason'],
arrivalSummary: $this->arrivalSummary(
$state['sourceSurface'],
$state['concernFamily'],
$state['concernState'],
$state['concernReason'],
),
claimBoundary: $this->claimBoundary($state['concernFamily'], $backupHealth, $recoveryEvidence),
nextStep: $this->nextStepTarget(
tenant: $tenant,
concernFamily: $state['concernFamily'],
concernState: $state['concernState'],
concernReason: $state['concernReason'],
recoveryEvidence: $recoveryEvidence,
),
returnTarget: $this->returnTarget(
sourceSurface: $state['sourceSurface'],
returnFilters: $state['returnFilters'],
),
currentTruthDelta: $this->currentTruthDelta(
concernFamily: $state['concernFamily'],
concernState: $state['concernState'],
backupHealth: $backupHealth,
recoveryEvidence: $recoveryEvidence,
),
);
}
private function arrivalSummary(
string $sourceSurface,
string $concernFamily,
string $concernState,
?string $concernReason,
): string {
$source = $sourceSurface === PortfolioArrivalContextToken::SOURCE_WORKSPACE_OVERVIEW
? 'workspace overview triage'
: 'tenant registry triage';
return match ($concernFamily) {
PortfolioArrivalContextToken::FAMILY_BACKUP_HEALTH => match ($concernState) {
TenantBackupHealthAssessment::POSTURE_ABSENT => "Opened from {$source} because no usable backup basis was visible.",
TenantBackupHealthAssessment::POSTURE_STALE => "Opened from {$source} because the latest backup was stale.",
TenantBackupHealthAssessment::POSTURE_DEGRADED => "Opened from {$source} because the latest backup was degraded.",
default => "Opened from {$source} for backup follow-up.",
},
PortfolioArrivalContextToken::FAMILY_RECOVERY_EVIDENCE => match ($concernReason ?? $concernState) {
'no_history' => "Opened from {$source} because recovery evidence was unvalidated.",
RestoreResultAttention::STATE_FAILED => "Opened from {$source} because a recent restore failed.",
RestoreResultAttention::STATE_PARTIAL => "Opened from {$source} because a recent restore completed partially.",
RestoreResultAttention::STATE_COMPLETED_WITH_FOLLOW_UP => "Opened from {$source} because restore follow-up is still required.",
default => "Opened from {$source} because recovery evidence still needs follow-up.",
},
default => "Opened from {$source}.",
};
}
/**
* @param array<string, mixed> $recoveryEvidence
*/
private function claimBoundary(
string $concernFamily,
TenantBackupHealthAssessment $backupHealth,
array $recoveryEvidence,
): ?string {
if ($concernFamily === PortfolioArrivalContextToken::FAMILY_BACKUP_HEALTH) {
return $backupHealth->positiveClaimBoundary;
}
$claimBoundary = $recoveryEvidence['claim_boundary'] ?? null;
return is_string($claimBoundary) && $claimBoundary !== ''
? $claimBoundary
: null;
}
/**
* @param array<string, mixed> $recoveryEvidence
* @return array{
* kind: string,
* label: string,
* url: string|null,
* disabled: bool,
* helperText: string|null
* }
*/
private function nextStepTarget(
Tenant $tenant,
string $concernFamily,
string $concernState,
?string $concernReason,
array $recoveryEvidence,
): array {
return match ($concernFamily) {
PortfolioArrivalContextToken::FAMILY_BACKUP_HEALTH => $this->backupNextStepTarget(
tenant: $tenant,
concernState: $concernState,
concernReason: $concernReason,
),
PortfolioArrivalContextToken::FAMILY_RECOVERY_EVIDENCE => $this->recoveryNextStepTarget(
tenant: $tenant,
concernState: $concernState,
concernReason: $concernReason,
recoveryEvidence: $recoveryEvidence,
),
default => $this->disabledTarget(kind: 'tenant_dashboard', label: 'Open tenant dashboard'),
};
}
/**
* @return array{
* kind: string,
* label: string,
* url: string|null,
* disabled: bool,
* helperText: string|null
* }
*/
private function backupNextStepTarget(Tenant $tenant, string $concernState, ?string $concernReason): array
{
$label = 'Open backup sets';
if (! $this->canOpenTenantFollowUp($tenant)) {
return $this->disabledTarget(kind: 'backup_sets', label: $label);
}
$reason = is_string($concernReason) && $concernReason !== ''
? $concernReason
: match ($concernState) {
TenantBackupHealthAssessment::POSTURE_ABSENT => TenantBackupHealthAssessment::REASON_NO_BACKUP_BASIS,
TenantBackupHealthAssessment::POSTURE_STALE => TenantBackupHealthAssessment::REASON_LATEST_BACKUP_STALE,
default => TenantBackupHealthAssessment::REASON_LATEST_BACKUP_DEGRADED,
};
return [
'kind' => 'backup_sets',
'label' => $label,
'url' => BackupSetResource::getUrl('index', [
'backup_health_reason' => $reason,
], panel: 'tenant', tenant: $tenant),
'disabled' => false,
'helperText' => null,
];
}
/**
* @param array<string, mixed> $recoveryEvidence
* @return array{
* kind: string,
* label: string,
* url: string|null,
* disabled: bool,
* helperText: string|null
* }
*/
private function recoveryNextStepTarget(
Tenant $tenant,
string $concernState,
?string $concernReason,
array $recoveryEvidence,
): array {
$resolvedReason = is_string($concernReason) && $concernReason !== ''
? $concernReason
: null;
$currentReason = is_string($recoveryEvidence['reason'] ?? null)
? $recoveryEvidence['reason']
: null;
if (in_array($currentReason, [
'no_history',
'no_recent_issues_visible',
RestoreResultAttention::STATE_FAILED,
RestoreResultAttention::STATE_PARTIAL,
RestoreResultAttention::STATE_COMPLETED_WITH_FOLLOW_UP,
], true)) {
$resolvedReason = $currentReason;
}
if (! is_string($resolvedReason) || $resolvedReason === '') {
$resolvedReason = $concernState === TenantRecoveryTriagePresentation::RECOVERY_EVIDENCE_UNVALIDATED
? 'no_history'
: RestoreResultAttention::STATE_COMPLETED_WITH_FOLLOW_UP;
}
if (! $this->canOpenTenantFollowUp($tenant)) {
return $this->disabledTarget(
kind: 'restore_runs',
label: in_array($resolvedReason, [
RestoreResultAttention::STATE_FAILED,
RestoreResultAttention::STATE_PARTIAL,
RestoreResultAttention::STATE_COMPLETED_WITH_FOLLOW_UP,
], true)
? 'Open restore run'
: 'Open restore history',
);
}
$latestRunId = is_numeric($recoveryEvidence['latest_relevant_restore_run_id'] ?? null)
? (int) $recoveryEvidence['latest_relevant_restore_run_id']
: null;
if (in_array($resolvedReason, [
RestoreResultAttention::STATE_FAILED,
RestoreResultAttention::STATE_PARTIAL,
RestoreResultAttention::STATE_COMPLETED_WITH_FOLLOW_UP,
], true) && $latestRunId !== null) {
if (RestoreRun::query()
->whereKey($latestRunId)
->where('tenant_id', (int) $tenant->getKey())
->exists()) {
return [
'kind' => 'restore_run_detail',
'label' => 'Open restore run',
'url' => RestoreRunResource::getUrl('view', [
'record' => $latestRunId,
'recovery_posture_reason' => $resolvedReason,
], panel: 'tenant', tenant: $tenant),
'disabled' => false,
'helperText' => null,
];
}
return [
'kind' => 'restore_runs',
'label' => 'Open restore history',
'url' => RestoreRunResource::getUrl('index', [
'recovery_posture_reason' => $resolvedReason,
], panel: 'tenant', tenant: $tenant),
'disabled' => false,
'helperText' => 'The latest restore detail is no longer available.',
];
}
return [
'kind' => 'restore_runs',
'label' => 'Open restore history',
'url' => RestoreRunResource::getUrl('index', [
'recovery_posture_reason' => $resolvedReason,
], panel: 'tenant', tenant: $tenant),
'disabled' => false,
'helperText' => null,
];
}
/**
* @param array<string, mixed>|null $returnFilters
* @return array{
* kind: string,
* label: string,
* url: string,
* filters: array<string, mixed>|null
* }|null
*/
private function returnTarget(string $sourceSurface, ?array $returnFilters): ?array
{
if ($sourceSurface === PortfolioArrivalContextToken::SOURCE_WORKSPACE_OVERVIEW) {
return [
'kind' => 'workspace_overview',
'label' => 'Return to workspace overview',
'url' => route('admin.home'),
'filters' => null,
];
}
$filters = $this->sanitizeRegistryReturnFilters($returnFilters ?? []);
if ($filters === []) {
return null;
}
return [
'kind' => 'tenant_registry',
'label' => 'Return to tenant triage',
'url' => TenantResource::getUrl('index', $filters, panel: 'admin'),
'filters' => $filters,
];
}
/**
* @param array<string, mixed> $filters
* @return array<string, mixed>
*/
private function sanitizeRegistryReturnFilters(array $filters): array
{
$sanitized = [];
$backupPostures = TenantResource::sanitizeBackupPostures($filters['backup_posture'] ?? []);
if ($backupPostures !== []) {
$sanitized['backup_posture'] = $backupPostures;
}
$recoveryEvidence = TenantResource::sanitizeRecoveryEvidenceStates($filters['recovery_evidence'] ?? []);
if ($recoveryEvidence !== []) {
$sanitized['recovery_evidence'] = $recoveryEvidence;
}
$triageSort = TenantResource::sanitizeTriageSort($filters['triage_sort'] ?? null);
if ($triageSort !== null) {
$sanitized['triage_sort'] = $triageSort;
}
return $sanitized;
}
/**
* @param array<string, mixed> $recoveryEvidence
*/
private function currentTruthDelta(
string $concernFamily,
string $concernState,
TenantBackupHealthAssessment $backupHealth,
array $recoveryEvidence,
): ?string {
$parts = [];
if ($concernFamily === PortfolioArrivalContextToken::FAMILY_BACKUP_HEALTH) {
if ($backupHealth->posture !== $concernState) {
$parts[] = sprintf(
'Current backup posture now looks %s.',
$this->labelForState($backupHealth->posture),
);
}
if ($this->hasRecoveryAttention($recoveryEvidence)) {
$parts[] = 'Recovery evidence also still needs follow-up.';
}
} else {
$currentRecoveryState = is_string($recoveryEvidence['overview_state'] ?? null)
? $recoveryEvidence['overview_state']
: null;
if ($currentRecoveryState !== null && $currentRecoveryState !== $concernState) {
$parts[] = sprintf(
'Current recovery evidence now looks %s.',
$this->labelForState($currentRecoveryState),
);
}
if ($this->hasBackupAttention($backupHealth)) {
$parts[] = 'Backup posture also still needs follow-up.';
}
}
return $parts !== [] ? implode(' ', $parts) : null;
}
private function canOpenTenantFollowUp(Tenant $tenant): bool
{
$user = auth()->user();
return $user instanceof User
&& $this->capabilityResolver->can($user, $tenant, Capabilities::TENANT_VIEW);
}
/**
* @return array{
* kind: string,
* label: string,
* url: null,
* disabled: true,
* helperText: string
* }
*/
private function disabledTarget(string $kind, string $label): array
{
return [
'kind' => $kind,
'label' => $label,
'url' => null,
'disabled' => true,
'helperText' => UiTooltips::INSUFFICIENT_PERMISSION,
];
}
private function hasBackupAttention(TenantBackupHealthAssessment $backupHealth): bool
{
return in_array($backupHealth->posture, [
TenantBackupHealthAssessment::POSTURE_ABSENT,
TenantBackupHealthAssessment::POSTURE_STALE,
TenantBackupHealthAssessment::POSTURE_DEGRADED,
], true);
}
/**
* @param array<string, mixed> $recoveryEvidence
*/
private function hasRecoveryAttention(array $recoveryEvidence): bool
{
return in_array($recoveryEvidence['overview_state'] ?? null, [
TenantRecoveryTriagePresentation::RECOVERY_EVIDENCE_UNVALIDATED,
TenantRecoveryTriagePresentation::RECOVERY_EVIDENCE_WEAKENED,
], true);
}
private function labelForState(?string $state): string
{
return match ($state) {
TenantBackupHealthAssessment::POSTURE_ABSENT => 'Absent',
TenantBackupHealthAssessment::POSTURE_STALE => 'Stale',
TenantBackupHealthAssessment::POSTURE_DEGRADED => 'Degraded',
TenantBackupHealthAssessment::POSTURE_HEALTHY => 'Healthy',
TenantRecoveryTriagePresentation::RECOVERY_EVIDENCE_UNVALIDATED => 'Unvalidated',
TenantRecoveryTriagePresentation::RECOVERY_EVIDENCE_WEAKENED => 'Weakened',
TenantRecoveryTriagePresentation::RECOVERY_EVIDENCE_NO_RECENT_ISSUES_VISIBLE => 'No recent issues visible',
default => 'Updated',
};
}
}

View File

@ -1,245 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Support\PortfolioTriage;
use App\Support\BackupHealth\TenantBackupHealthAssessment;
use App\Support\RestoreSafety\RestoreResultAttention;
use App\Support\Tenants\TenantRecoveryTriagePresentation;
use JsonException;
final class PortfolioArrivalContextToken
{
public const string QUERY_PARAMETER = 'arrival';
public const string SOURCE_WORKSPACE_OVERVIEW = 'workspace_overview';
public const string SOURCE_TENANT_REGISTRY = 'tenant_registry';
public const string FAMILY_BACKUP_HEALTH = 'backup_health';
public const string FAMILY_RECOVERY_EVIDENCE = 'recovery_evidence';
private const int VERSION = 1;
private const array SOURCE_ALLOWLIST = [
self::SOURCE_WORKSPACE_OVERVIEW => true,
self::SOURCE_TENANT_REGISTRY => true,
];
private const array BACKUP_STATE_ALLOWLIST = [
TenantBackupHealthAssessment::POSTURE_ABSENT => true,
TenantBackupHealthAssessment::POSTURE_STALE => true,
TenantBackupHealthAssessment::POSTURE_DEGRADED => true,
];
private const array RECOVERY_STATE_ALLOWLIST = [
TenantRecoveryTriagePresentation::RECOVERY_EVIDENCE_UNVALIDATED => true,
TenantRecoveryTriagePresentation::RECOVERY_EVIDENCE_WEAKENED => true,
];
private const array BACKUP_REASON_ALLOWLIST = [
TenantBackupHealthAssessment::REASON_NO_BACKUP_BASIS => true,
TenantBackupHealthAssessment::REASON_LATEST_BACKUP_STALE => true,
TenantBackupHealthAssessment::REASON_LATEST_BACKUP_DEGRADED => true,
];
private const array RECOVERY_REASON_ALLOWLIST = [
'no_history' => true,
RestoreResultAttention::STATE_FAILED => true,
RestoreResultAttention::STATE_PARTIAL => true,
RestoreResultAttention::STATE_COMPLETED_WITH_FOLLOW_UP => true,
];
private const array RETURN_FILTER_ALLOWLIST = [
'backup_posture' => true,
'recovery_evidence' => true,
'triage_sort' => true,
];
/**
* @param array<string, mixed> $state
*/
public static function encode(array $state): string
{
$payload = ['v' => self::VERSION] + $state;
$json = json_encode($payload, JSON_THROW_ON_ERROR);
return self::base64UrlEncode($json);
}
/**
* @return array{
* sourceSurface: string,
* tenantRouteKey: string|null,
* workspaceId: int|null,
* concernFamily: string,
* concernState: string,
* concernReason: string|null,
* returnFilters: array<string, mixed>|null
* }|null
*/
public static function decode(?string $token): ?array
{
if (! is_string($token) || trim($token) === '') {
return null;
}
$json = self::base64UrlDecode(trim($token));
if ($json === null) {
return null;
}
try {
$decoded = json_decode($json, true, flags: JSON_THROW_ON_ERROR);
} catch (JsonException) {
return null;
}
if (! is_array($decoded)) {
return null;
}
$version = $decoded['v'] ?? null;
$version = is_int($version) ? $version : (is_numeric($version) ? (int) $version : null);
if ($version !== self::VERSION) {
return null;
}
$sourceSurface = $decoded['sourceSurface'] ?? null;
if (! is_string($sourceSurface) || ! isset(self::SOURCE_ALLOWLIST[$sourceSurface])) {
return null;
}
$concernFamily = $decoded['concernFamily'] ?? null;
if (! is_string($concernFamily) || ! in_array($concernFamily, [
self::FAMILY_BACKUP_HEALTH,
self::FAMILY_RECOVERY_EVIDENCE,
], true)) {
return null;
}
$concernState = $decoded['concernState'] ?? null;
if (! is_string($concernState) || ! self::isAllowedState($concernFamily, $concernState)) {
return null;
}
$concernReason = $decoded['concernReason'] ?? null;
if (! is_string($concernReason) && $concernReason !== null) {
return null;
}
if (! self::isAllowedReason($concernFamily, $concernReason)) {
return null;
}
$tenantRouteKey = $decoded['tenantRouteKey'] ?? null;
if (! is_string($tenantRouteKey) && $tenantRouteKey !== null) {
return null;
}
$tenantRouteKey = is_string($tenantRouteKey) && $tenantRouteKey !== ''
? $tenantRouteKey
: null;
$workspaceId = $decoded['workspaceId'] ?? null;
$workspaceId = is_int($workspaceId) ? $workspaceId : (is_numeric($workspaceId) ? (int) $workspaceId : null);
$returnFilters = self::sanitizeReturnFilters($decoded['returnFilters'] ?? null);
return [
'sourceSurface' => $sourceSurface,
'tenantRouteKey' => $tenantRouteKey,
'workspaceId' => $workspaceId,
'concernFamily' => $concernFamily,
'concernState' => $concernState,
'concernReason' => $concernReason,
'returnFilters' => $returnFilters,
];
}
private static function isAllowedState(string $family, string $state): bool
{
return match ($family) {
self::FAMILY_BACKUP_HEALTH => isset(self::BACKUP_STATE_ALLOWLIST[$state]),
self::FAMILY_RECOVERY_EVIDENCE => isset(self::RECOVERY_STATE_ALLOWLIST[$state]),
default => false,
};
}
private static function isAllowedReason(string $family, ?string $reason): bool
{
if ($reason === null) {
return true;
}
return match ($family) {
self::FAMILY_BACKUP_HEALTH => isset(self::BACKUP_REASON_ALLOWLIST[$reason]),
self::FAMILY_RECOVERY_EVIDENCE => isset(self::RECOVERY_REASON_ALLOWLIST[$reason]),
default => false,
};
}
/**
* @return array<string, mixed>|null
*/
private static function sanitizeReturnFilters(mixed $filters): ?array
{
if (! is_array($filters)) {
return null;
}
$sanitized = [];
foreach ($filters as $key => $value) {
if (! is_string($key) || ! isset(self::RETURN_FILTER_ALLOWLIST[$key])) {
continue;
}
if (is_scalar($value) || $value === null) {
$sanitized[$key] = $value;
continue;
}
if (! is_array($value)) {
continue;
}
$sanitized[$key] = array_values(array_filter(
$value,
static fn (mixed $item): bool => is_scalar($item) && $item !== '',
));
}
return $sanitized !== [] ? $sanitized : null;
}
private static function base64UrlEncode(string $value): string
{
return rtrim(strtr(base64_encode($value), '+/', '-_'), '=');
}
private static function base64UrlDecode(string $value): ?string
{
$padded = strtr($value, '-_', '+/');
$padding = strlen($padded) % 4;
if ($padding !== 0) {
$padded .= str_repeat('=', 4 - $padding);
}
$decoded = base64_decode($padded, true);
return is_string($decoded) ? $decoded : null;
}
}

View File

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

View File

@ -8,7 +8,6 @@
use App\Filament\Pages\ChooseTenant; use App\Filament\Pages\ChooseTenant;
use App\Filament\Pages\TenantDashboard; use App\Filament\Pages\TenantDashboard;
use App\Filament\Resources\FindingResource; use App\Filament\Resources\FindingResource;
use App\Filament\Resources\TenantResource;
use App\Models\AlertDelivery; use App\Models\AlertDelivery;
use App\Models\FindingException; use App\Models\FindingException;
use App\Models\OperationRun; use App\Models\OperationRun;
@ -30,12 +29,10 @@
use App\Support\OperationCatalog; use App\Support\OperationCatalog;
use App\Support\OperationRunLinks; use App\Support\OperationRunLinks;
use App\Support\OpsUx\OperationUxPresenter; use App\Support\OpsUx\OperationUxPresenter;
use App\Support\PortfolioTriage\PortfolioArrivalContextToken;
use App\Support\Rbac\UiTooltips; use App\Support\Rbac\UiTooltips;
use App\Support\RestoreSafety\RestoreResultAttention; use App\Support\RestoreSafety\RestoreResultAttention;
use App\Support\RestoreSafety\RestoreSafetyCopy; use App\Support\RestoreSafety\RestoreSafetyCopy;
use App\Support\RestoreSafety\RestoreSafetyResolver; use App\Support\RestoreSafety\RestoreSafetyResolver;
use App\Support\Tenants\TenantRecoveryTriagePresentation;
use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Collection; use Illuminate\Support\Collection;
@ -324,7 +321,8 @@ private function tenantContexts(Collection $accessibleTenants, int $workspaceId,
'latest_relevant_restore_run_id' => is_array($recoveryEvidence) && is_numeric($recoveryEvidence['latest_relevant_restore_run_id'] ?? 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'] ? (int) $recoveryEvidence['latest_relevant_restore_run_id']
: null, : null,
'has_recovery_attention' => in_array($recoveryOverviewState, ['weakened', 'unvalidated'], true), '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),
@ -681,11 +679,7 @@ private function backupHealthAttentionItem(Tenant $tenant, array $context, User
body: $assessment->supportingMessage ?? $assessment->headline, body: $assessment->supportingMessage ?? $assessment->headline,
badge: 'Backup health', badge: 'Backup health',
badgeColor: $assessment->tone(), badgeColor: $assessment->tone(),
destination: $this->tenantDashboardTarget( destination: $this->tenantDashboardTarget($tenant, $user),
$tenant,
$user,
arrivalState: $this->workspaceOverviewArrivalState($tenant, $reasonContext),
),
supportingMessage: $assessment->positiveClaimBoundary, supportingMessage: $assessment->positiveClaimBoundary,
reasonContext: $reasonContext, reasonContext: $reasonContext,
); );
@ -739,15 +733,7 @@ private function recoveryEvidenceAttentionItem(Tenant $tenant, array $context, U
badgeColor: $overviewState === 'weakened' && $attentionState === RestoreResultAttention::STATE_FAILED badgeColor: $overviewState === 'weakened' && $attentionState === RestoreResultAttention::STATE_FAILED
? 'danger' ? 'danger'
: 'warning', : 'warning',
destination: $this->tenantDashboardTarget( destination: $this->tenantDashboardTarget($tenant, $user),
$tenant,
$user,
arrivalState: $this->workspaceOverviewArrivalState($tenant, [
'family' => 'recovery_evidence',
'state' => $overviewState,
'reason' => is_string($recoveryEvidence['reason'] ?? null) ? $recoveryEvidence['reason'] : null,
]),
),
supportingMessage: trim(implode(' ', array_filter($supportingParts, static fn (?string $part): bool => filled($part)))), supportingMessage: trim(implode(' ', array_filter($supportingParts, static fn (?string $part): bool => filled($part)))),
reasonContext: [ reasonContext: [
'family' => 'recovery_evidence', 'family' => 'recovery_evidence',
@ -953,24 +939,7 @@ private function attentionMetricDestination(array $tenantContexts, User $user, s
} }
if (count($affectedContexts) > 1) { if (count($affectedContexts) > 1) {
return match ($stateKey) { return $this->chooseTenantTarget('Choose tenant');
'has_backup_attention' => $this->filteredTenantRegistryTarget([
'backup_posture' => [
TenantBackupHealthAssessment::POSTURE_ABSENT,
TenantBackupHealthAssessment::POSTURE_STALE,
TenantBackupHealthAssessment::POSTURE_DEGRADED,
],
'triage_sort' => TenantRecoveryTriagePresentation::TRIAGE_SORT_WORST_FIRST,
], 'Choose tenant'),
'has_recovery_attention' => $this->filteredTenantRegistryTarget([
'recovery_evidence' => [
TenantRecoveryTriagePresentation::RECOVERY_EVIDENCE_WEAKENED,
TenantRecoveryTriagePresentation::RECOVERY_EVIDENCE_UNVALIDATED,
],
'triage_sort' => TenantRecoveryTriagePresentation::TRIAGE_SORT_WORST_FIRST,
], 'Choose tenant'),
default => $this->chooseTenantTarget('Choose tenant'),
};
} }
$tenant = $affectedContexts[0]['tenant'] ?? null; $tenant = $affectedContexts[0]['tenant'] ?? null;
@ -979,11 +948,7 @@ private function attentionMetricDestination(array $tenantContexts, User $user, s
return null; return null;
} }
return $this->tenantDashboardTarget( return $this->tenantDashboardTarget($tenant, $user);
$tenant,
$user,
arrivalState: $this->workspaceOverviewArrivalStateFromContext($tenant, $affectedContexts[0]),
);
} }
/** /**
@ -1228,20 +1193,6 @@ private function chooseTenantTarget(string $label = 'Choose tenant'): array
); );
} }
/**
* @param array<string, mixed> $filters
* @return array<string, mixed>
*/
private function filteredTenantRegistryTarget(array $filters, string $label = 'Choose tenant'): array
{
return $this->destination(
kind: 'choose_tenant',
url: $this->appendQuery(TenantResource::getUrl(panel: 'admin'), $filters),
label: $label,
filters: $filters,
);
}
/** /**
* @return array<string, mixed> * @return array<string, mixed>
*/ */
@ -1257,12 +1208,7 @@ private function switchWorkspaceTarget(string $label = 'Switch workspace'): arra
/** /**
* @return array<string, mixed> * @return array<string, mixed>
*/ */
private function tenantDashboardTarget( private function tenantDashboardTarget(Tenant $tenant, User $user, string $label = 'Open tenant dashboard'): array
Tenant $tenant,
User $user,
string $label = 'Open tenant dashboard',
?array $arrivalState = null,
): array
{ {
if (! $this->canAccessTenantDashboard($user, $tenant)) { if (! $this->canAccessTenantDashboard($user, $tenant)) {
return $this->disabledDestination( return $this->disabledDestination(
@ -1274,63 +1220,12 @@ private function tenantDashboardTarget(
return $this->destination( return $this->destination(
kind: 'tenant_dashboard', kind: 'tenant_dashboard',
url: $this->appendArrivalToken( url: TenantDashboard::getUrl(panel: 'tenant', tenant: $tenant),
TenantDashboard::getUrl(panel: 'tenant', tenant: $tenant),
$arrivalState,
),
label: $label, label: $label,
tenant: $tenant, tenant: $tenant,
); );
} }
/**
* @param array{family: string, state: string|null, reason: string|null} $reasonContext
* @return array<string, mixed>|null
*/
private function workspaceOverviewArrivalState(Tenant $tenant, array $reasonContext): ?array
{
$family = is_string($reasonContext['family'] ?? null) ? $reasonContext['family'] : null;
$state = is_string($reasonContext['state'] ?? null) ? $reasonContext['state'] : null;
if ($family === null || $state === null) {
return null;
}
return [
'sourceSurface' => PortfolioArrivalContextToken::SOURCE_WORKSPACE_OVERVIEW,
'tenantRouteKey' => $this->tenantRouteKey($tenant),
'workspaceId' => (int) $tenant->workspace_id,
'concernFamily' => $family,
'concernState' => $state,
'concernReason' => is_string($reasonContext['reason'] ?? null) ? $reasonContext['reason'] : null,
];
}
/**
* @param array<string, mixed> $context
* @return array<string, mixed>|null
*/
private function workspaceOverviewArrivalStateFromContext(Tenant $tenant, array $context): ?array
{
if (($context['has_backup_attention'] ?? false) === true) {
return $this->workspaceOverviewArrivalState($tenant, [
'family' => 'backup_health',
'state' => is_string($context['backup_health_posture'] ?? null) ? $context['backup_health_posture'] : null,
'reason' => is_string($context['backup_health_reason'] ?? null) ? $context['backup_health_reason'] : null,
]);
}
if (($context['has_recovery_attention'] ?? false) === true) {
return $this->workspaceOverviewArrivalState($tenant, [
'family' => 'recovery_evidence',
'state' => is_string($context['recovery_evidence_state'] ?? null) ? $context['recovery_evidence_state'] : null,
'reason' => is_string($context['recovery_evidence_reason'] ?? null) ? $context['recovery_evidence_reason'] : null,
]);
}
return null;
}
/** /**
* @param array<string, mixed> $filters * @param array<string, mixed> $filters
* @return array<string, mixed> * @return array<string, mixed>
@ -1524,20 +1419,6 @@ private function appendQuery(string $url, array $query): string
return $url.(str_contains($url, '?') ? '&' : '?').http_build_query($query); return $url.(str_contains($url, '?') ? '&' : '?').http_build_query($query);
} }
/**
* @param array<string, mixed>|null $arrivalState
*/
private function appendArrivalToken(string $url, ?array $arrivalState = null): string
{
if ($arrivalState === null) {
return $url;
}
return $this->appendQuery($url, [
PortfolioArrivalContextToken::QUERY_PARAMETER => PortfolioArrivalContextToken::encode($arrivalState),
]);
}
private function switchWorkspaceUrl(): string private function switchWorkspaceUrl(): string
{ {
return route('filament.admin.pages.choose-workspace').'?choose=1'; return route('filament.admin.pages.choose-workspace').'?choose=1';

View File

@ -2,7 +2,6 @@
namespace Database\Factories; namespace Database\Factories;
use App\Models\BackupItem;
use App\Models\Tenant; use App\Models\Tenant;
use Illuminate\Database\Eloquent\Factories\Factory; use Illuminate\Database\Eloquent\Factories\Factory;
@ -42,37 +41,6 @@ public function staleCompleted(): static
return $this->state(fn (): array => [ return $this->state(fn (): array => [
'status' => 'completed', 'status' => 'completed',
'completed_at' => now()->subDays(2), 'completed_at' => now()->subDays(2),
'item_count' => 1, ]);
])->afterCreating(function ($backupSet): void {
BackupItem::factory()
->for($backupSet->tenant)
->for($backupSet)
->create([
'payload' => ['id' => 'stale-backup-item'],
'metadata' => [],
'assignments' => [],
]);
});
}
public function degradedCompleted(): static
{
return $this->state(fn (): array => [
'status' => 'completed',
'completed_at' => now()->subMinutes(30),
'item_count' => 1,
])->afterCreating(function ($backupSet): void {
BackupItem::factory()
->for($backupSet->tenant)
->for($backupSet)
->create([
'payload' => [],
'metadata' => [
'source' => 'metadata_only',
'assignments_fetch_failed' => true,
],
'assignments' => [],
]);
});
} }
} }

View File

@ -1,97 +0,0 @@
@php
/** @var ?\App\Support\PortfolioTriage\PortfolioArrivalContext $context */
@endphp
<div>
@if ($context)
@php
$familyColor = $context->concernFamily === 'backup_health' ? 'danger' : 'warning';
$stateColor = match ($context->concernState) {
'absent' => 'danger',
'stale', 'degraded', 'weakened', 'unvalidated' => 'warning',
default => 'gray',
};
@endphp
<x-filament::section>
<x-slot name="heading">
<span class="flex items-center gap-2">
<x-filament::icon
icon="heroicon-m-arrow-trending-up"
class="h-5 w-5 text-warning-500"
/>
Triage arrival
</span>
</x-slot>
<div class="flex flex-col gap-4">
<div class="flex flex-wrap items-start justify-between gap-4">
<div class="min-w-0 flex-1">
<div class="flex flex-wrap gap-2">
<x-filament::badge color="gray" size="sm">
{{ $context->sourceLabel() }}
</x-filament::badge>
<x-filament::badge :color="$familyColor" size="sm">
{{ $context->concernFamilyLabel() }}
</x-filament::badge>
<x-filament::badge :color="$stateColor" size="sm">
{{ $context->concernStateLabel() }}
</x-filament::badge>
</div>
<div class="mt-3 text-sm font-semibold text-gray-950 dark:text-white">
Why this tenant opened
</div>
<div class="mt-1 text-sm leading-relaxed text-gray-600 dark:text-gray-300">
{{ $context->arrivalSummary }}
</div>
@if (filled($context->currentTruthDelta))
<div class="mt-3 rounded-lg bg-gray-50 p-3 text-sm text-gray-700 dark:bg-white/5 dark:text-gray-200">
{{ $context->currentTruthDelta }}
</div>
@endif
@if (filled($context->claimBoundary))
<div class="mt-3 text-xs leading-relaxed text-gray-500 dark:text-gray-400">
{{ $context->claimBoundary }}
</div>
@endif
</div>
<div class="flex w-full flex-col gap-2 sm:w-auto sm:min-w-52 sm:items-end">
@if (filled($context->nextStep['url'] ?? null))
<x-filament::button
tag="a"
:href="$context->nextStep['url']"
size="sm"
color="warning"
>
{{ $context->nextStep['label'] }}
</x-filament::button>
@elseif (($context->nextStep['disabled'] ?? false) === true)
<div class="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">
{{ $context->nextStep['label'] }}
</div>
@endif
@if (filled($context->returnTarget['url'] ?? null))
<x-filament::link :href="$context->returnTarget['url']" size="sm" class="font-medium">
{{ $context->returnTarget['label'] }}
</x-filament::link>
@endif
@if (filled($context->nextStep['helperText'] ?? null))
<div class="text-xs text-gray-500 dark:text-gray-400">
{{ $context->nextStep['helperText'] }}
</div>
@endif
</div>
</div>
</div>
</x-filament::section>
@endif
</div>

View File

@ -1,165 +0,0 @@
<?php
declare(strict_types=1);
namespace Tests\Feature\Concerns;
use App\Filament\Resources\TenantResource\Pages\ListTenants;
use App\Models\BackupSet;
use App\Models\RestoreRun;
use App\Models\Tenant;
use App\Models\User;
use App\Support\BackupHealth\TenantBackupHealthAssessment;
use App\Support\RestoreSafety\RestoreResultAttention;
use App\Support\Tenants\TenantRecoveryTriagePresentation;
use App\Support\Workspaces\WorkspaceContext;
use Filament\Facades\Filament;
use Livewire\Livewire;
trait BuildsPortfolioTriageFixtures
{
/**
* @return array{0: User, 1: Tenant}
*/
protected function makePortfolioTriageActor(
string $tenantName = 'Anchor Tenant',
string $role = 'owner',
string $workspaceRole = 'readonly',
): array {
$tenant = Tenant::factory()->create([
'status' => 'active',
'name' => $tenantName,
]);
[$user, $tenant] = createUserWithTenant(
tenant: $tenant,
role: $role,
workspaceRole: $workspaceRole,
);
workspaceOverviewSeedQuietTenantTruth($tenant);
return [$user, $tenant];
}
protected function makePortfolioTriagePeer(User $user, Tenant $workspaceTenant, string $name): Tenant
{
$tenant = Tenant::factory()->create([
'status' => 'active',
'workspace_id' => (int) $workspaceTenant->workspace_id,
'name' => $name,
]);
createUserWithTenant(
tenant: $tenant,
user: $user,
role: 'owner',
workspaceRole: 'readonly',
);
workspaceOverviewSeedQuietTenantTruth($tenant);
return $tenant;
}
protected function seedPortfolioBackupConcern(Tenant $tenant, string $state): ?BackupSet
{
return match ($state) {
TenantBackupHealthAssessment::POSTURE_ABSENT => null,
TenantBackupHealthAssessment::POSTURE_STALE => BackupSet::factory()
->for($tenant)
->staleCompleted()
->create([
'name' => 'Portfolio stale backup',
]),
TenantBackupHealthAssessment::POSTURE_DEGRADED => BackupSet::factory()
->for($tenant)
->degradedCompleted()
->create([
'name' => 'Portfolio degraded backup',
]),
default => workspaceOverviewSeedHealthyBackup($tenant, [
'name' => 'Portfolio healthy backup',
]),
};
}
protected function seedPortfolioRecoveryConcern(
Tenant $tenant,
string $reason = 'no_history',
?BackupSet $backupSet = null,
): ?RestoreRun {
$backupSet ??= workspaceOverviewSeedHealthyBackup($tenant, [
'name' => 'Portfolio recovery backup',
]);
return match ($reason) {
'no_history' => null,
RestoreResultAttention::STATE_FAILED => RestoreRun::factory()
->for($tenant)
->for($backupSet)
->failedOutcome()
->create([
'completed_at' => now()->subMinutes(10),
]),
RestoreResultAttention::STATE_PARTIAL => RestoreRun::factory()
->for($tenant)
->for($backupSet)
->partialOutcome()
->create([
'completed_at' => now()->subMinutes(10),
]),
RestoreResultAttention::STATE_COMPLETED_WITH_FOLLOW_UP => RestoreRun::factory()
->for($tenant)
->for($backupSet)
->completedWithFollowUp()
->create([
'completed_at' => now()->subMinutes(10),
]),
default => RestoreRun::factory()
->for($tenant)
->for($backupSet)
->completedOutcome()
->create([
'completed_at' => now()->subMinutes(10),
]),
};
}
protected function usePortfolioTriageWorkspace(User $user, Tenant $tenant): void
{
test()->actingAs($user);
session()->put(WorkspaceContext::SESSION_KEY, (int) $tenant->workspace_id);
Filament::setTenant(null, true);
request()->attributes->remove('tenant_resource.posture_snapshot');
session()->forget('tables.'.md5(ListTenants::class).'_filters');
session()->forget('tables.'.md5(ListTenants::class).'_search');
session()->forget('tables.'.md5(ListTenants::class).'_sort');
}
protected function portfolioTriageRegistryList(User $user, Tenant $workspaceTenant, array $query = []): mixed
{
$this->usePortfolioTriageWorkspace($user, $workspaceTenant);
$factory = $query !== []
? Livewire::withQueryParams($query)->actingAs($user)
: Livewire::actingAs($user);
return $factory->test(ListTenants::class);
}
/**
* @return array{backup_posture: list<string>, recovery_evidence: list<string>, triage_sort: string|null}
*/
protected function portfolioReturnFilters(
array $backupPosture = [],
array $recoveryEvidence = [],
?string $triageSort = TenantRecoveryTriagePresentation::TRIAGE_SORT_WORST_FIRST,
): array {
return [
'backup_posture' => $backupPosture,
'recovery_evidence' => $recoveryEvidence,
'triage_sort' => $triageSort,
];
}
}

View File

@ -1,90 +0,0 @@
<?php
declare(strict_types=1);
use App\Filament\Pages\TenantDashboard;
use App\Support\PortfolioTriage\PortfolioArrivalContextResolver;
use App\Support\PortfolioTriage\PortfolioArrivalContextToken;
use App\Support\RestoreSafety\RestoreResultAttention;
use App\Support\Tenants\TenantRecoveryTriagePresentation;
use App\Support\Workspaces\WorkspaceContext;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Tests\Feature\Concerns\BuildsPortfolioTriageFixtures;
uses(BuildsPortfolioTriageFixtures::class);
function performanceArrivalRequest(array $query, int $workspaceId): Request
{
$request = Request::create('/admin/t/test-tenant', 'GET', $query);
$session = app('session.store');
$session->start();
$session->put(WorkspaceContext::SESSION_KEY, $workspaceId);
$request->setLaravelSession($session);
return $request;
}
it('memoizes arrival-context resolution within a single request', function (): void {
[$user, $tenant] = $this->makePortfolioTriageActor('Performance Tenant');
$this->seedPortfolioRecoveryConcern($tenant, RestoreResultAttention::STATE_COMPLETED_WITH_FOLLOW_UP);
$this->actingAs($user);
$request = performanceArrivalRequest([
PortfolioArrivalContextToken::QUERY_PARAMETER => PortfolioArrivalContextToken::encode([
'sourceSurface' => PortfolioArrivalContextToken::SOURCE_TENANT_REGISTRY,
'tenantRouteKey' => (string) $tenant->external_id,
'workspaceId' => (int) $tenant->workspace_id,
'concernFamily' => PortfolioArrivalContextToken::FAMILY_RECOVERY_EVIDENCE,
'concernState' => TenantRecoveryTriagePresentation::RECOVERY_EVIDENCE_WEAKENED,
'concernReason' => RestoreResultAttention::STATE_COMPLETED_WITH_FOLLOW_UP,
'returnFilters' => [
'recovery_evidence' => [TenantRecoveryTriagePresentation::RECOVERY_EVIDENCE_WEAKENED],
'triage_sort' => TenantRecoveryTriagePresentation::TRIAGE_SORT_WORST_FIRST,
],
]),
], (int) $tenant->workspace_id);
DB::flushQueryLog();
DB::enableQueryLog();
$resolver = app(PortfolioArrivalContextResolver::class);
$resolver->resolve($request, $tenant);
$firstQueryCount = count(DB::getQueryLog());
$resolver->resolve($request, $tenant);
$secondQueryCount = count(DB::getQueryLog());
expect($firstQueryCount)->toBeGreaterThan(0)
->and($secondQueryCount)->toBe($firstQueryCount);
});
it('renders the arrival continuity block DB-only with bounded query volume', function (): void {
[$user, $tenant] = $this->makePortfolioTriageActor('DB Only Arrival Tenant');
$this->seedPortfolioRecoveryConcern($tenant, RestoreResultAttention::STATE_PARTIAL);
$this->actingAs($user);
$arrivalUrl = TenantDashboard::getUrl([
PortfolioArrivalContextToken::QUERY_PARAMETER => PortfolioArrivalContextToken::encode([
'sourceSurface' => PortfolioArrivalContextToken::SOURCE_WORKSPACE_OVERVIEW,
'tenantRouteKey' => (string) $tenant->external_id,
'workspaceId' => (int) $tenant->workspace_id,
'concernFamily' => PortfolioArrivalContextToken::FAMILY_RECOVERY_EVIDENCE,
'concernState' => TenantRecoveryTriagePresentation::RECOVERY_EVIDENCE_WEAKENED,
'concernReason' => RestoreResultAttention::STATE_PARTIAL,
]),
], panel: 'tenant', tenant: $tenant);
DB::flushQueryLog();
DB::enableQueryLog();
assertNoOutboundHttp(function () use ($arrivalUrl): void {
$this->get($arrivalUrl)
->assertOk()
->assertSee('Triage arrival')
->assertSee('Open restore run');
});
expect(count(DB::getQueryLog()))->toBeLessThanOrEqual(35);
});

View File

@ -1,166 +0,0 @@
<?php
declare(strict_types=1);
use App\Filament\Pages\TenantDashboard;
use App\Filament\Resources\BackupSetResource;
use App\Filament\Resources\RestoreRunResource;
use App\Filament\Resources\TenantResource;
use App\Services\Auth\CapabilityResolver;
use App\Support\Auth\Capabilities;
use App\Support\BackupHealth\TenantBackupHealthAssessment;
use App\Support\PortfolioTriage\PortfolioArrivalContextToken;
use App\Support\Rbac\UiTooltips;
use App\Support\RestoreSafety\RestoreResultAttention;
use App\Support\Tenants\TenantRecoveryTriagePresentation;
use Tests\Feature\Concerns\BuildsPortfolioTriageFixtures;
use function Pest\Laravel\mock;
uses(BuildsPortfolioTriageFixtures::class);
function tenantDashboardArrivalUrl(\App\Models\Tenant $tenant, array $state): string
{
return TenantDashboard::getUrl([
PortfolioArrivalContextToken::QUERY_PARAMETER => PortfolioArrivalContextToken::encode($state),
], panel: 'tenant', tenant: $tenant);
}
it('renders source surface, concern, next step, and return flow for workspace backup arrivals', function (): void {
[$user, $tenant] = $this->makePortfolioTriageActor('Workspace Backup Tenant');
$this->actingAs($user);
$arrivalUrl = tenantDashboardArrivalUrl($tenant, [
'sourceSurface' => PortfolioArrivalContextToken::SOURCE_WORKSPACE_OVERVIEW,
'tenantRouteKey' => (string) $tenant->external_id,
'workspaceId' => (int) $tenant->workspace_id,
'concernFamily' => PortfolioArrivalContextToken::FAMILY_BACKUP_HEALTH,
'concernState' => TenantBackupHealthAssessment::POSTURE_ABSENT,
'concernReason' => TenantBackupHealthAssessment::REASON_NO_BACKUP_BASIS,
]);
$this->get($arrivalUrl)
->assertOk()
->assertSee('Triage arrival')
->assertSee('Workspace overview triage')
->assertSee('Backup health')
->assertSee('Absent')
->assertSee('Opened from workspace overview triage because no usable backup basis was visible.')
->assertSee('Open backup sets')
->assertSee('Return to workspace overview')
->assertSee(BackupSetResource::getUrl('index', [
'backup_health_reason' => TenantBackupHealthAssessment::REASON_NO_BACKUP_BASIS,
], panel: 'tenant', tenant: $tenant), false)
->assertSee(route('admin.home'), false);
});
it('renders registry arrival continuity with restore follow-up and preserved return filters', function (): void {
[$user, $tenant] = $this->makePortfolioTriageActor('Registry Recovery Tenant');
$restoreRun = $this->seedPortfolioRecoveryConcern($tenant, RestoreResultAttention::STATE_COMPLETED_WITH_FOLLOW_UP);
$this->actingAs($user);
$returnUrl = TenantResource::getUrl('index', [
'recovery_evidence' => [TenantRecoveryTriagePresentation::RECOVERY_EVIDENCE_WEAKENED],
'triage_sort' => TenantRecoveryTriagePresentation::TRIAGE_SORT_WORST_FIRST,
], panel: 'admin');
$arrivalUrl = tenantDashboardArrivalUrl($tenant, [
'sourceSurface' => PortfolioArrivalContextToken::SOURCE_TENANT_REGISTRY,
'tenantRouteKey' => (string) $tenant->external_id,
'workspaceId' => (int) $tenant->workspace_id,
'concernFamily' => PortfolioArrivalContextToken::FAMILY_RECOVERY_EVIDENCE,
'concernState' => TenantRecoveryTriagePresentation::RECOVERY_EVIDENCE_WEAKENED,
'concernReason' => RestoreResultAttention::STATE_COMPLETED_WITH_FOLLOW_UP,
'returnFilters' => [
'recovery_evidence' => [TenantRecoveryTriagePresentation::RECOVERY_EVIDENCE_WEAKENED],
'triage_sort' => TenantRecoveryTriagePresentation::TRIAGE_SORT_WORST_FIRST,
],
]);
$this->get($arrivalUrl)
->assertOk()
->assertSee('Tenant registry triage')
->assertSee('Recovery evidence')
->assertSee('Weakened')
->assertSee('Open restore run')
->assertSee('Return to tenant triage')
->assertSee(RestoreRunResource::getUrl('view', [
'record' => (int) $restoreRun?->getKey(),
'recovery_posture_reason' => RestoreResultAttention::STATE_COMPLETED_WITH_FOLLOW_UP,
], panel: 'tenant', tenant: $tenant), false)
->assertSee($returnUrl, false);
});
it('suppresses the continuity block for generic or malformed sessions', function (): void {
[$user, $tenant] = $this->makePortfolioTriageActor('Generic Tenant Session');
$this->actingAs($user);
$this->get(TenantDashboard::getUrl(panel: 'tenant', tenant: $tenant))
->assertOk()
->assertDontSee('Triage arrival');
$this->get(TenantDashboard::getUrl([
PortfolioArrivalContextToken::QUERY_PARAMETER => 'not-base64url',
], panel: 'tenant', tenant: $tenant))
->assertOk()
->assertDontSee('Triage arrival');
});
it('keeps the continuity block truthful when current truth has changed and multiple concerns remain visible', function (): void {
[$user, $tenant] = $this->makePortfolioTriageActor('Truth Shift Tenant');
$backupSet = $this->seedPortfolioBackupConcern($tenant, TenantBackupHealthAssessment::POSTURE_STALE);
$this->seedPortfolioRecoveryConcern($tenant, RestoreResultAttention::STATE_PARTIAL, $backupSet);
$this->actingAs($user);
$arrivalUrl = tenantDashboardArrivalUrl($tenant, [
'sourceSurface' => PortfolioArrivalContextToken::SOURCE_TENANT_REGISTRY,
'tenantRouteKey' => (string) $tenant->external_id,
'workspaceId' => (int) $tenant->workspace_id,
'concernFamily' => PortfolioArrivalContextToken::FAMILY_RECOVERY_EVIDENCE,
'concernState' => TenantRecoveryTriagePresentation::RECOVERY_EVIDENCE_UNVALIDATED,
'concernReason' => 'no_history',
'returnFilters' => [
'triage_sort' => TenantRecoveryTriagePresentation::TRIAGE_SORT_WORST_FIRST,
],
]);
$this->get($arrivalUrl)
->assertOk()
->assertSee('Current recovery evidence now looks Weakened.')
->assertSee('Backup posture also still needs follow-up.')
->assertSee('Open restore run');
});
it('degrades the next-step CTA when the operator cannot open deeper follow-up surfaces', function (): void {
[$user, $tenant] = $this->makePortfolioTriageActor('Restricted Arrival Tenant');
$this->actingAs($user);
mock(CapabilityResolver::class, function ($mock) use ($tenant): void {
$mock->shouldReceive('isMember')
->andReturnUsing(static fn ($user, $resolvedTenant): bool => (int) $resolvedTenant->getKey() === (int) $tenant->getKey());
$mock->shouldReceive('can')
->andReturnUsing(static function ($user, $resolvedTenant, string $capability) use ($tenant): bool {
expect((int) $resolvedTenant->getKey())->toBe((int) $tenant->getKey());
return $capability !== Capabilities::TENANT_VIEW;
});
});
$arrivalUrl = tenantDashboardArrivalUrl($tenant, [
'sourceSurface' => PortfolioArrivalContextToken::SOURCE_WORKSPACE_OVERVIEW,
'tenantRouteKey' => (string) $tenant->external_id,
'workspaceId' => (int) $tenant->workspace_id,
'concernFamily' => PortfolioArrivalContextToken::FAMILY_BACKUP_HEALTH,
'concernState' => TenantBackupHealthAssessment::POSTURE_ABSENT,
'concernReason' => TenantBackupHealthAssessment::REASON_NO_BACKUP_BASIS,
]);
$this->get($arrivalUrl)
->assertOk()
->assertSee('Triage arrival')
->assertSee(UiTooltips::INSUFFICIENT_PERMISSION)
->assertDontSee(BackupSetResource::getUrl('index', [
'backup_health_reason' => TenantBackupHealthAssessment::REASON_NO_BACKUP_BASIS,
], panel: 'tenant', tenant: $tenant), false);
});

View File

@ -1,94 +0,0 @@
<?php
declare(strict_types=1);
use App\Filament\Pages\TenantDashboard;
use App\Filament\Resources\TenantResource;
use App\Support\BackupHealth\TenantBackupHealthAssessment;
use App\Support\PortfolioTriage\PortfolioArrivalContextToken;
use App\Support\RestoreSafety\RestoreResultAttention;
use App\Support\Tenants\TenantRecoveryTriagePresentation;
use Tests\Feature\Concerns\BuildsPortfolioTriageFixtures;
uses(BuildsPortfolioTriageFixtures::class);
function tenantRegistryArrivalStateFromUrl(string $url): ?array
{
parse_str((string) parse_url($url, PHP_URL_QUERY), $query);
return PortfolioArrivalContextToken::decode($query[PortfolioArrivalContextToken::QUERY_PARAMETER] ?? null);
}
it('adds arrival context and bounded return state to triage-driven tenant opens', function (): void {
[$user, $anchorTenant] = $this->makePortfolioTriageActor('Anchor Tenant');
$weakenedTenant = $this->makePortfolioTriagePeer($user, $anchorTenant, 'Weakened Tenant');
$backupSet = $this->seedPortfolioBackupConcern($weakenedTenant, TenantBackupHealthAssessment::POSTURE_STALE);
$this->seedPortfolioRecoveryConcern($weakenedTenant, RestoreResultAttention::STATE_COMPLETED_WITH_FOLLOW_UP, $backupSet);
$triageState = [
'backup_posture' => [TenantBackupHealthAssessment::POSTURE_STALE],
'recovery_evidence' => [TenantRecoveryTriagePresentation::RECOVERY_EVIDENCE_WEAKENED],
'triage_sort' => TenantRecoveryTriagePresentation::TRIAGE_SORT_WORST_FIRST,
];
$this->usePortfolioTriageWorkspace($user, $anchorTenant);
$expectedUrl = TenantResource::tenantDashboardOpenUrl($weakenedTenant, $triageState);
$this->portfolioTriageRegistryList($user, $anchorTenant, $triageState)
->assertTableActionHasUrl('openTenant', $expectedUrl, $weakenedTenant);
expect(tenantRegistryArrivalStateFromUrl($expectedUrl))->toMatchArray([
'sourceSurface' => PortfolioArrivalContextToken::SOURCE_TENANT_REGISTRY,
'tenantRouteKey' => (string) $weakenedTenant->external_id,
'workspaceId' => (int) $weakenedTenant->workspace_id,
'concernFamily' => PortfolioArrivalContextToken::FAMILY_RECOVERY_EVIDENCE,
'concernState' => TenantRecoveryTriagePresentation::RECOVERY_EVIDENCE_WEAKENED,
'concernReason' => RestoreResultAttention::STATE_COMPLETED_WITH_FOLLOW_UP,
'returnFilters' => [
'backup_posture' => [TenantBackupHealthAssessment::POSTURE_STALE],
'recovery_evidence' => [TenantRecoveryTriagePresentation::RECOVERY_EVIDENCE_WEAKENED],
'triage_sort' => TenantRecoveryTriagePresentation::TRIAGE_SORT_WORST_FIRST,
],
]);
});
it('keeps generic registry opens free of arrival context when triage intent is not active', function (): void {
[$user, $tenant] = $this->makePortfolioTriageActor('Generic Registry Tenant');
$expectedUrl = TenantDashboard::getUrl(panel: 'tenant', tenant: $tenant);
$this->usePortfolioTriageWorkspace($user, $tenant);
$this->get(TenantResource::getUrl('index', panel: 'admin'))
->assertOk()
->assertSee($expectedUrl, false);
expect($expectedUrl)->not->toContain(PortfolioArrivalContextToken::QUERY_PARAMETER.'=');
});
it('chooses the leading visible concern when worst-first triage is active without explicit family filters', function (): void {
[$user, $anchorTenant] = $this->makePortfolioTriageActor('Worst First Anchor');
$priorityTenant = $this->makePortfolioTriagePeer($user, $anchorTenant, 'Priority Tenant');
$this->seedPortfolioBackupConcern($priorityTenant, TenantBackupHealthAssessment::POSTURE_STALE);
$this->seedPortfolioRecoveryConcern($priorityTenant, RestoreResultAttention::STATE_PARTIAL);
$this->usePortfolioTriageWorkspace($user, $anchorTenant);
$expectedUrl = TenantResource::tenantDashboardOpenUrl($priorityTenant, [
'triage_sort' => TenantRecoveryTriagePresentation::TRIAGE_SORT_WORST_FIRST,
]);
$this->portfolioTriageRegistryList($user, $anchorTenant, [
'triage_sort' => TenantRecoveryTriagePresentation::TRIAGE_SORT_WORST_FIRST,
])
->assertTableActionHasUrl('openTenant', $expectedUrl, $priorityTenant);
expect(tenantRegistryArrivalStateFromUrl($expectedUrl))->toMatchArray([
'sourceSurface' => PortfolioArrivalContextToken::SOURCE_TENANT_REGISTRY,
'concernFamily' => PortfolioArrivalContextToken::FAMILY_RECOVERY_EVIDENCE,
'concernState' => TenantRecoveryTriagePresentation::RECOVERY_EVIDENCE_WEAKENED,
'concernReason' => RestoreResultAttention::STATE_PARTIAL,
]);
});

View File

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

View File

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

View File

@ -1,72 +0,0 @@
<?php
declare(strict_types=1);
use App\Support\BackupHealth\TenantBackupHealthAssessment;
use App\Support\PortfolioTriage\PortfolioArrivalContextToken;
use App\Support\RestoreSafety\RestoreResultAttention;
use App\Support\Workspaces\WorkspaceOverviewBuilder;
use Tests\Feature\Concerns\BuildsPortfolioTriageFixtures;
uses(BuildsPortfolioTriageFixtures::class);
function workspaceOverviewArrivalStateFromUrl(string $url): ?array
{
parse_str((string) parse_url($url, PHP_URL_QUERY), $query);
return PortfolioArrivalContextToken::decode($query[PortfolioArrivalContextToken::QUERY_PARAMETER] ?? null);
}
it('emits bounded arrival tokens for workspace backup and recovery attention drilldowns', function (): void {
[$user, $backupTenant] = $this->makePortfolioTriageActor('Backup Weak Tenant');
$recoveryTenant = $this->makePortfolioTriagePeer($user, $backupTenant, 'Recovery Weak Tenant');
$this->seedPortfolioRecoveryConcern($recoveryTenant, RestoreResultAttention::STATE_COMPLETED_WITH_FOLLOW_UP);
$workspace = $backupTenant->workspace()->firstOrFail();
$items = collect(app(WorkspaceOverviewBuilder::class)->build($workspace, $user)['attention_items'])->keyBy('key');
$backupArrivalState = workspaceOverviewArrivalStateFromUrl((string) $items->get('tenant_backup_absent')['destination']['url']);
$recoveryArrivalState = workspaceOverviewArrivalStateFromUrl((string) $items->get('tenant_recovery_weakened')['destination']['url']);
expect($backupArrivalState)->toMatchArray([
'sourceSurface' => PortfolioArrivalContextToken::SOURCE_WORKSPACE_OVERVIEW,
'tenantRouteKey' => (string) $backupTenant->external_id,
'workspaceId' => (int) $backupTenant->workspace_id,
'concernFamily' => PortfolioArrivalContextToken::FAMILY_BACKUP_HEALTH,
'concernState' => TenantBackupHealthAssessment::POSTURE_ABSENT,
'concernReason' => TenantBackupHealthAssessment::REASON_NO_BACKUP_BASIS,
])->and($recoveryArrivalState)->toMatchArray([
'sourceSurface' => PortfolioArrivalContextToken::SOURCE_WORKSPACE_OVERVIEW,
'tenantRouteKey' => (string) $recoveryTenant->external_id,
'workspaceId' => (int) $recoveryTenant->workspace_id,
'concernFamily' => PortfolioArrivalContextToken::FAMILY_RECOVERY_EVIDENCE,
'concernState' => 'weakened',
'concernReason' => RestoreResultAttention::STATE_COMPLETED_WITH_FOLLOW_UP,
]);
});
it('emits summary-metric arrival tokens only when the workspace drilldown lands on a single tenant dashboard', function (): void {
[$user, $backupTenant] = $this->makePortfolioTriageActor('Single Backup Tenant');
$recoveryTenantA = $this->makePortfolioTriagePeer($user, $backupTenant, 'Recovery Tenant A');
$this->seedPortfolioRecoveryConcern($recoveryTenantA, RestoreResultAttention::STATE_FAILED);
$recoveryTenantB = $this->makePortfolioTriagePeer($user, $backupTenant, 'Recovery Tenant B');
$this->seedPortfolioRecoveryConcern($recoveryTenantB, RestoreResultAttention::STATE_PARTIAL);
$workspace = $backupTenant->workspace()->firstOrFail();
$metrics = collect(app(WorkspaceOverviewBuilder::class)->build($workspace, $user)['summary_metrics'])->keyBy('key');
$backupDestinationUrl = (string) $metrics->get('backup_attention_tenants')['destination_url'];
$recoveryDestinationUrl = (string) $metrics->get('recovery_attention_tenants')['destination_url'];
$backupArrivalState = workspaceOverviewArrivalStateFromUrl($backupDestinationUrl);
expect($backupArrivalState)->toMatchArray([
'sourceSurface' => PortfolioArrivalContextToken::SOURCE_WORKSPACE_OVERVIEW,
'tenantRouteKey' => (string) $backupTenant->external_id,
'concernFamily' => PortfolioArrivalContextToken::FAMILY_BACKUP_HEALTH,
'concernState' => TenantBackupHealthAssessment::POSTURE_ABSENT,
])->and($recoveryDestinationUrl)->toContain('/admin/tenants')
->and($recoveryDestinationUrl)->not->toContain(PortfolioArrivalContextToken::QUERY_PARAMETER.'=');
});

View File

@ -81,10 +81,6 @@
$backupTenant = Tenant::factory()->create(['status' => 'active']); $backupTenant = Tenant::factory()->create(['status' => 'active']);
[$user, $backupTenant] = createUserWithTenant($backupTenant, role: 'readonly', workspaceRole: 'readonly'); [$user, $backupTenant] = createUserWithTenant($backupTenant, role: 'readonly', workspaceRole: 'readonly');
workspaceOverviewSeedQuietTenantTruth($backupTenant); workspaceOverviewSeedQuietTenantTruth($backupTenant);
$backupTenantSet = workspaceOverviewSeedHealthyBackup($backupTenant, [
'completed_at' => now()->subDays(2),
]);
workspaceOverviewSeedRestoreHistory($backupTenant, $backupTenantSet, 'completed');
$recoveryTenant = Tenant::factory()->create([ $recoveryTenant = Tenant::factory()->create([
'status' => 'active', 'status' => 'active',
@ -123,63 +119,3 @@
->and($metrics->get('recovery_attention_tenants')['destination']['disabled'])->toBeFalse() ->and($metrics->get('recovery_attention_tenants')['destination']['disabled'])->toBeFalse()
->and($metrics->get('recovery_attention_tenants')['destination_url'])->toContain('/admin/t/'); ->and($metrics->get('recovery_attention_tenants')['destination_url'])->toContain('/admin/t/');
}); });
it('falls back to the visible tenant dashboard when hidden peers are excluded from backup and recovery metric drill-through', function (): void {
CarbonImmutable::setTestNow(CarbonImmutable::create(2026, 4, 9, 9, 0, 0, 'UTC'));
$visibleBackupTenant = Tenant::factory()->create(['status' => 'active']);
[$user, $visibleBackupTenant] = createUserWithTenant($visibleBackupTenant, role: 'readonly', workspaceRole: 'readonly');
workspaceOverviewSeedQuietTenantTruth($visibleBackupTenant);
$visibleBackupSet = workspaceOverviewSeedHealthyBackup($visibleBackupTenant, [
'completed_at' => now()->subDays(2),
]);
workspaceOverviewSeedRestoreHistory($visibleBackupTenant, $visibleBackupSet, 'completed');
$visibleRecoveryTenant = Tenant::factory()->create([
'status' => 'active',
'workspace_id' => (int) $visibleBackupTenant->workspace_id,
'name' => 'Visible Recovery Tenant',
]);
createUserWithTenant($visibleRecoveryTenant, $user, role: 'readonly', workspaceRole: 'readonly');
workspaceOverviewSeedQuietTenantTruth($visibleRecoveryTenant);
$visibleRecoveryBackup = workspaceOverviewSeedHealthyBackup($visibleRecoveryTenant, [
'completed_at' => now()->subMinutes(20),
]);
workspaceOverviewSeedRestoreHistory($visibleRecoveryTenant, $visibleRecoveryBackup, 'follow_up');
$hiddenBackupTenant = Tenant::factory()->create([
'status' => 'active',
'workspace_id' => (int) $visibleBackupTenant->workspace_id,
'name' => 'Hidden Backup Tenant',
]);
workspaceOverviewSeedQuietTenantTruth($hiddenBackupTenant);
$hiddenRecoveryTenant = Tenant::factory()->create([
'status' => 'active',
'workspace_id' => (int) $visibleBackupTenant->workspace_id,
'name' => 'Hidden Recovery Tenant',
]);
workspaceOverviewSeedQuietTenantTruth($hiddenRecoveryTenant);
$hiddenRecoveryBackup = workspaceOverviewSeedHealthyBackup($hiddenRecoveryTenant, [
'completed_at' => now()->subMinutes(18),
]);
workspaceOverviewSeedRestoreHistory($hiddenRecoveryTenant, $hiddenRecoveryBackup, 'follow_up');
mock(CapabilityResolver::class, function ($mock): void {
$mock->shouldReceive('primeMemberships')->once();
$mock->shouldReceive('isMember')->andReturnTrue();
$mock->shouldReceive('can')->andReturnFalse();
});
$metrics = collect(
app(\App\Support\Workspaces\WorkspaceOverviewBuilder::class)
->build($visibleBackupTenant->workspace()->firstOrFail(), $user)['summary_metrics'],
)->keyBy('key');
expect($metrics->get('backup_attention_tenants')['value'])->toBe(1)
->and($metrics->get('backup_attention_tenants')['destination']['kind'])->toBe('tenant_dashboard')
->and($metrics->get('backup_attention_tenants')['destination_url'])->toContain('/admin/t/')
->and($metrics->get('recovery_attention_tenants')['value'])->toBe(1)
->and($metrics->get('recovery_attention_tenants')['destination']['kind'])->toBe('tenant_dashboard')
->and($metrics->get('recovery_attention_tenants')['destination_url'])->toContain('/admin/t/');
});

View File

@ -2,12 +2,8 @@
declare(strict_types=1); declare(strict_types=1);
use App\Filament\Resources\TenantResource;
use App\Models\Tenant;
use App\Models\OperationRun; use App\Models\OperationRun;
use App\Support\OperationRunStatus; use App\Support\OperationRunStatus;
use App\Support\RestoreSafety\RestoreResultAttention;
use App\Support\Workspaces\WorkspaceOverviewBuilder;
use App\Support\Workspaces\WorkspaceContext; use App\Support\Workspaces\WorkspaceContext;
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 {
@ -50,71 +46,3 @@
->assertSee('Calm wording stays bounded to visible tenants and checked domains') ->assertSee('Calm wording stays bounded to visible tenants and checked domains')
->assertSee('Inventory sync'); ->assertSee('Inventory sync');
}); });
it('keeps multi-tenant backup and recovery metric drilldowns on the registry when multiple tenants remain in scope', function (): void {
[$user, $anchorTenant] = createUserWithTenant(role: 'owner', workspaceRole: 'readonly');
workspaceOverviewSeedQuietTenantTruth($anchorTenant);
$backupTenant = Tenant::factory()->create([
'status' => 'active',
'workspace_id' => (int) $anchorTenant->workspace_id,
'name' => 'Backup Attention Tenant',
]);
createUserWithTenant($backupTenant, $user, role: 'owner', workspaceRole: 'readonly');
workspaceOverviewSeedQuietTenantTruth($backupTenant);
workspaceOverviewSeedHealthyBackup($backupTenant, [
'completed_at' => now()->subDays(2),
]);
$backupTenantB = Tenant::factory()->create([
'status' => 'active',
'workspace_id' => (int) $anchorTenant->workspace_id,
'name' => 'Backup Attention Tenant B',
]);
createUserWithTenant($backupTenantB, $user, role: 'owner', workspaceRole: 'readonly');
workspaceOverviewSeedQuietTenantTruth($backupTenantB);
workspaceOverviewSeedHealthyBackup($backupTenantB, [
'completed_at' => now()->subMinutes(19),
], [
'payload' => [],
'metadata' => [
'source' => 'metadata_only',
'assignments_fetch_failed' => true,
],
'assignments' => [],
]);
$recoveryTenantA = Tenant::factory()->create([
'status' => 'active',
'workspace_id' => (int) $anchorTenant->workspace_id,
'name' => 'Recovery Attention Tenant A',
]);
createUserWithTenant($recoveryTenantA, $user, role: 'owner', workspaceRole: 'readonly');
workspaceOverviewSeedQuietTenantTruth($recoveryTenantA);
$recoveryBackupA = workspaceOverviewSeedHealthyBackup($recoveryTenantA, [
'completed_at' => now()->subMinutes(18),
]);
workspaceOverviewSeedRestoreHistory($recoveryTenantA, $recoveryBackupA, 'failed');
$recoveryTenantB = Tenant::factory()->create([
'status' => 'active',
'workspace_id' => (int) $anchorTenant->workspace_id,
'name' => 'Recovery Attention Tenant B',
]);
createUserWithTenant($recoveryTenantB, $user, role: 'owner', workspaceRole: 'readonly');
workspaceOverviewSeedQuietTenantTruth($recoveryTenantB);
$recoveryBackupB = workspaceOverviewSeedHealthyBackup($recoveryTenantB, [
'completed_at' => now()->subMinutes(17),
]);
workspaceOverviewSeedRestoreHistory($recoveryTenantB, $recoveryBackupB, 'follow_up');
$overview = app(WorkspaceOverviewBuilder::class)->build($anchorTenant->workspace()->firstOrFail(), $user);
$metrics = collect($overview['summary_metrics'])->keyBy('key');
expect($metrics->get('backup_attention_tenants')['destination']['kind'])->toBe('choose_tenant')
->and($metrics->get('backup_attention_tenants')['destination_url'])->toContain(TenantResource::getUrl('index', panel: 'admin'))
->and($metrics->get('backup_attention_tenants')['destination_url'])->not->toContain('arrival=')
->and($metrics->get('recovery_attention_tenants')['destination']['kind'])->toBe('choose_tenant')
->and($metrics->get('recovery_attention_tenants')['destination_url'])->toContain(TenantResource::getUrl('index', panel: 'admin'))
->and($metrics->get('recovery_attention_tenants')['destination_url'])->not->toContain('arrival=');
});

View File

@ -2,7 +2,6 @@
declare(strict_types=1); declare(strict_types=1);
use App\Filament\Resources\TenantResource\Pages\ListTenants;
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\Dashboard\NeedsAttention as TenantNeedsAttention;
@ -14,17 +13,10 @@
use App\Support\OperationRunOutcome; use App\Support\OperationRunOutcome;
use App\Support\OperationRunStatus; use App\Support\OperationRunStatus;
use App\Support\OperationRunType; use App\Support\OperationRunType;
use App\Support\Tenants\TenantRecoveryTriagePresentation;
use App\Support\Workspaces\WorkspaceOverviewBuilder; use App\Support\Workspaces\WorkspaceOverviewBuilder;
use App\Support\Workspaces\WorkspaceContext;
use Carbon\CarbonImmutable;
use Filament\Facades\Filament; use Filament\Facades\Filament;
use Livewire\Livewire; use Livewire\Livewire;
afterEach(function (): void {
CarbonImmutable::setTestNow();
});
it('preserves canonical findings, compare, alerts, and operations drill-through continuity from the workspace overview', function (): void { it('preserves canonical findings, compare, alerts, and operations drill-through continuity from the workspace overview', function (): void {
$tenantDashboard = Tenant::factory()->create(['status' => 'active']); $tenantDashboard = Tenant::factory()->create(['status' => 'active']);
[$user, $tenantDashboard] = createUserWithTenant($tenantDashboard, role: 'owner', workspaceRole: 'readonly'); [$user, $tenantDashboard] = createUserWithTenant($tenantDashboard, role: 'owner', workspaceRole: 'readonly');
@ -208,127 +200,6 @@
->toContain($reviewUrl); ->toContain($reviewUrl);
}); });
it('hydrates filtered tenant-registry triage state from multi-tenant workspace backup and recovery metrics', function (): void {
CarbonImmutable::setTestNow(CarbonImmutable::create(2026, 4, 9, 9, 0, 0, 'UTC'));
$absentTenant = Tenant::factory()->create([
'status' => 'active',
'name' => 'Absent Backup Tenant',
]);
[$user, $absentTenant] = createUserWithTenant($absentTenant, role: 'owner', workspaceRole: 'readonly');
workspaceOverviewSeedQuietTenantTruth($absentTenant);
$staleTenant = Tenant::factory()->create([
'status' => 'active',
'workspace_id' => (int) $absentTenant->workspace_id,
'name' => 'Stale Backup Tenant',
]);
createUserWithTenant($staleTenant, $user, role: 'owner', workspaceRole: 'readonly');
workspaceOverviewSeedQuietTenantTruth($staleTenant);
$staleBackup = workspaceOverviewSeedHealthyBackup($staleTenant, [
'completed_at' => now()->subDays(2),
]);
workspaceOverviewSeedRestoreHistory($staleTenant, $staleBackup, 'completed');
$degradedTenant = Tenant::factory()->create([
'status' => 'active',
'workspace_id' => (int) $absentTenant->workspace_id,
'name' => 'Degraded Backup Tenant',
]);
createUserWithTenant($degradedTenant, $user, role: 'owner', workspaceRole: 'readonly');
workspaceOverviewSeedQuietTenantTruth($degradedTenant);
$degradedBackup = workspaceOverviewSeedHealthyBackup($degradedTenant, [
'completed_at' => now()->subMinutes(20),
], [
'payload' => [],
'metadata' => [
'source' => 'metadata_only',
'assignments_fetch_failed' => true,
],
'assignments' => [],
]);
workspaceOverviewSeedRestoreHistory($degradedTenant, $degradedBackup, 'completed');
$weakenedTenant = Tenant::factory()->create([
'status' => 'active',
'workspace_id' => (int) $absentTenant->workspace_id,
'name' => 'Weakened Recovery Tenant',
]);
createUserWithTenant($weakenedTenant, $user, role: 'owner', workspaceRole: 'readonly');
workspaceOverviewSeedQuietTenantTruth($weakenedTenant);
$weakenedBackup = workspaceOverviewSeedHealthyBackup($weakenedTenant, [
'completed_at' => now()->subMinutes(18),
]);
workspaceOverviewSeedRestoreHistory($weakenedTenant, $weakenedBackup, 'follow_up');
$unvalidatedTenant = Tenant::factory()->create([
'status' => 'active',
'workspace_id' => (int) $absentTenant->workspace_id,
'name' => 'Unvalidated Recovery Tenant',
]);
createUserWithTenant($unvalidatedTenant, $user, role: 'owner', workspaceRole: 'readonly');
workspaceOverviewSeedQuietTenantTruth($unvalidatedTenant);
workspaceOverviewSeedHealthyBackup($unvalidatedTenant, [
'completed_at' => now()->subMinutes(16),
]);
$calmTenant = Tenant::factory()->create([
'status' => 'active',
'workspace_id' => (int) $absentTenant->workspace_id,
'name' => 'Calm Tenant',
]);
createUserWithTenant($calmTenant, $user, role: 'owner', workspaceRole: 'readonly');
workspaceOverviewSeedQuietTenantTruth($calmTenant);
$calmBackup = workspaceOverviewSeedHealthyBackup($calmTenant, [
'completed_at' => now()->subMinutes(14),
]);
workspaceOverviewSeedRestoreHistory($calmTenant, $calmBackup, 'completed');
$workspace = $absentTenant->workspace()->firstOrFail();
$metrics = collect(app(WorkspaceOverviewBuilder::class)->build($workspace, $user)['summary_metrics'])->keyBy('key');
$this->actingAs($user);
session()->put(WorkspaceContext::SESSION_KEY, (int) $workspace->getKey());
Filament::setTenant(null, true);
parse_str((string) parse_url((string) $metrics->get('backup_attention_tenants')['destination_url'], PHP_URL_QUERY), $backupQuery);
$backupRegistry = Livewire::withQueryParams($backupQuery)
->actingAs($user)
->test(ListTenants::class)
->assertSet('tableFilters.backup_posture.values', [
'absent',
'stale',
'degraded',
])
->assertSet('tableFilters.triage_sort.value', TenantRecoveryTriagePresentation::TRIAGE_SORT_WORST_FIRST);
expect($backupRegistry->instance()->getFilteredSortedTableQuery()?->pluck('tenants.name')->all())
->toBe([
'Absent Backup Tenant',
'Stale Backup Tenant',
'Degraded Backup Tenant',
]);
parse_str((string) parse_url((string) $metrics->get('recovery_attention_tenants')['destination_url'], PHP_URL_QUERY), $recoveryQuery);
$recoveryRegistry = Livewire::withQueryParams($recoveryQuery)
->actingAs($user)
->test(ListTenants::class)
->assertSet('tableFilters.recovery_evidence.values', [
'weakened',
'unvalidated',
])
->assertSet('tableFilters.triage_sort.value', TenantRecoveryTriagePresentation::TRIAGE_SORT_WORST_FIRST);
expect($recoveryRegistry->instance()->getFilteredSortedTableQuery()?->pluck('tenants.name')->all())
->toBe([
'Absent Backup Tenant',
'Weakened Recovery Tenant',
'Unvalidated Recovery Tenant',
]);
});
it('routes backup and recovery workspace attention into tenant dashboards that still show the same weakness', function (): void { it('routes backup and recovery workspace attention into tenant dashboards that still show the same weakness', function (): void {
$backupTenant = Tenant::factory()->create([ $backupTenant = Tenant::factory()->create([
'status' => 'active', 'status' => 'active',

View File

@ -133,63 +133,39 @@
it('counts backup and recovery attention tenants separately and chooses precise or fallback destinations', function (): void { 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')); CarbonImmutable::setTestNow(CarbonImmutable::create(2026, 4, 9, 9, 0, 0, 'UTC'));
$recoveryTenantA = Tenant::factory()->create([ $singleRecoveryTenant = Tenant::factory()->create([
'status' => 'active', 'status' => 'active',
'name' => 'Recovery Tenant A', 'name' => 'Single Recovery Tenant',
]); ]);
[$user, $recoveryTenantA] = createUserWithTenant($recoveryTenantA, role: 'owner', workspaceRole: 'readonly'); [$user, $singleRecoveryTenant] = createUserWithTenant($singleRecoveryTenant, role: 'owner', workspaceRole: 'readonly');
workspaceOverviewSeedQuietTenantTruth($recoveryTenantA); workspaceOverviewSeedQuietTenantTruth($singleRecoveryTenant);
$recoveryTenantABackup = workspaceOverviewSeedHealthyBackup($recoveryTenantA, [ $singleRecoveryBackup = workspaceOverviewSeedHealthyBackup($singleRecoveryTenant, [
'completed_at' => now()->subMinutes(20), 'completed_at' => now()->subMinutes(20),
]); ]);
workspaceOverviewSeedRestoreHistory($recoveryTenantA, $recoveryTenantABackup, 'follow_up'); workspaceOverviewSeedRestoreHistory($singleRecoveryTenant, $singleRecoveryBackup, 'follow_up');
$recoveryTenantB = Tenant::factory()->create([
'status' => 'active',
'workspace_id' => (int) $recoveryTenantA->workspace_id,
'name' => 'Recovery Tenant B',
]);
createUserWithTenant($recoveryTenantB, $user, role: 'owner', workspaceRole: 'readonly');
workspaceOverviewSeedQuietTenantTruth($recoveryTenantB);
$recoveryTenantBBackup = workspaceOverviewSeedHealthyBackup($recoveryTenantB, [
'completed_at' => now()->subMinutes(22),
]);
workspaceOverviewSeedRestoreHistory($recoveryTenantB, $recoveryTenantBBackup, 'failed');
$backupTenantA = Tenant::factory()->create([ $backupTenantA = Tenant::factory()->create([
'status' => 'active', 'status' => 'active',
'workspace_id' => (int) $recoveryTenantA->workspace_id, 'workspace_id' => (int) $singleRecoveryTenant->workspace_id,
'name' => 'Backup Tenant A', 'name' => 'Backup Tenant A',
]); ]);
createUserWithTenant($backupTenantA, $user, role: 'owner', workspaceRole: 'readonly'); createUserWithTenant($backupTenantA, $user, role: 'owner', workspaceRole: 'readonly');
workspaceOverviewSeedQuietTenantTruth($backupTenantA); workspaceOverviewSeedQuietTenantTruth($backupTenantA);
$backupTenantABackup = workspaceOverviewSeedHealthyBackup($backupTenantA, [
'completed_at' => now()->subDays(2),
]);
workspaceOverviewSeedRestoreHistory($backupTenantA, $backupTenantABackup, 'completed');
$backupTenantB = Tenant::factory()->create([ $backupTenantB = Tenant::factory()->create([
'status' => 'active', 'status' => 'active',
'workspace_id' => (int) $recoveryTenantA->workspace_id, 'workspace_id' => (int) $singleRecoveryTenant->workspace_id,
'name' => 'Backup Tenant B', 'name' => 'Backup Tenant B',
]); ]);
createUserWithTenant($backupTenantB, $user, role: 'owner', workspaceRole: 'readonly'); createUserWithTenant($backupTenantB, $user, role: 'owner', workspaceRole: 'readonly');
workspaceOverviewSeedQuietTenantTruth($backupTenantB); workspaceOverviewSeedQuietTenantTruth($backupTenantB);
$backupTenantBBackup = workspaceOverviewSeedHealthyBackup($backupTenantB, [ workspaceOverviewSeedHealthyBackup($backupTenantB, [
'completed_at' => now()->subMinutes(18), 'completed_at' => now()->subDays(2),
], [
'payload' => [],
'metadata' => [
'source' => 'metadata_only',
'assignments_fetch_failed' => true,
],
'assignments' => [],
]); ]);
workspaceOverviewSeedRestoreHistory($backupTenantB, $backupTenantBBackup, 'completed');
$calmTenant = Tenant::factory()->create([ $calmTenant = Tenant::factory()->create([
'status' => 'active', 'status' => 'active',
'workspace_id' => (int) $recoveryTenantA->workspace_id, 'workspace_id' => (int) $singleRecoveryTenant->workspace_id,
'name' => 'Calm Tenant', 'name' => 'Calm Tenant',
]); ]);
createUserWithTenant($calmTenant, $user, role: 'owner', workspaceRole: 'readonly'); createUserWithTenant($calmTenant, $user, role: 'owner', workspaceRole: 'readonly');
@ -199,28 +175,18 @@
]); ]);
workspaceOverviewSeedRestoreHistory($calmTenant, $calmBackup, 'completed'); workspaceOverviewSeedRestoreHistory($calmTenant, $calmBackup, 'completed');
$workspace = $recoveryTenantA->workspace()->firstOrFail(); $workspace = $singleRecoveryTenant->workspace()->firstOrFail();
$overview = app(WorkspaceOverviewBuilder::class)->build($workspace, $user); $overview = app(WorkspaceOverviewBuilder::class)->build($workspace, $user);
$metrics = collect($overview['summary_metrics'])->keyBy('key'); $metrics = collect($overview['summary_metrics'])->keyBy('key');
$backupDestination = $metrics->get('backup_attention_tenants')['destination'];
$recoveryDestination = $metrics->get('recovery_attention_tenants')['destination'];
expect($metrics->get('backup_attention_tenants')['value'])->toBe(2) 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')['category'])->toBe('backup_health')
->and($metrics->get('backup_attention_tenants')['destination']['kind'])->toBe('choose_tenant') ->and($metrics->get('backup_attention_tenants')['destination']['kind'])->toBe('choose_tenant')
->and($metrics->get('backup_attention_tenants')['destination_url'])->toStartWith(\App\Filament\Resources\TenantResource::getUrl(panel: 'admin')) ->and($metrics->get('recovery_attention_tenants')['value'])->toBe(1)
->and($backupDestination['filters'])->toBe([
'backup_posture' => ['absent', 'stale', 'degraded'],
'triage_sort' => 'worst_first',
])
->and($metrics->get('recovery_attention_tenants')['value'])->toBe(2)
->and($metrics->get('recovery_attention_tenants')['category'])->toBe('recovery_evidence') ->and($metrics->get('recovery_attention_tenants')['category'])->toBe('recovery_evidence')
->and($recoveryDestination['kind'])->toBe('choose_tenant') ->and($metrics->get('recovery_attention_tenants')['destination']['kind'])->toBe('tenant_dashboard')
->and($metrics->get('recovery_attention_tenants')['destination_url'])->toStartWith(\App\Filament\Resources\TenantResource::getUrl(panel: 'admin')) ->and($metrics->get('recovery_attention_tenants')['destination']['tenant_route_key'])->toBe((string) $singleRecoveryTenant->external_id)
->and($recoveryDestination['filters'])->toBe([ ->and($metrics->get('recovery_attention_tenants')['destination_url'])->toContain('/admin/t/');
'recovery_evidence' => ['weakened', 'unvalidated'],
'triage_sort' => 'worst_first',
]);
}); });
it('keeps backup and recovery attention counts at zero for calm visible tenants', function (): void { it('keeps backup and recovery attention counts at zero for calm visible tenants', function (): void {

View File

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

View File

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

View File

@ -1,97 +0,0 @@
<?php
declare(strict_types=1);
use App\Filament\Pages\TenantDashboard;
use App\Filament\Resources\RestoreRunResource;
use App\Models\Tenant;
use App\Services\Auth\CapabilityResolver;
use App\Support\Auth\Capabilities;
use App\Support\PortfolioTriage\PortfolioArrivalContextToken;
use App\Support\Rbac\UiTooltips;
use App\Support\RestoreSafety\RestoreResultAttention;
use App\Support\Tenants\TenantRecoveryTriagePresentation;
use Tests\Feature\Concerns\BuildsPortfolioTriageFixtures;
use function Pest\Laravel\mock;
uses(BuildsPortfolioTriageFixtures::class);
function tenantDashboardVisibilityArrivalUrl(\App\Models\Tenant $tenant): string
{
return TenantDashboard::getUrl([
PortfolioArrivalContextToken::QUERY_PARAMETER => PortfolioArrivalContextToken::encode([
'sourceSurface' => PortfolioArrivalContextToken::SOURCE_TENANT_REGISTRY,
'tenantRouteKey' => (string) $tenant->external_id,
'workspaceId' => (int) $tenant->workspace_id,
'concernFamily' => PortfolioArrivalContextToken::FAMILY_RECOVERY_EVIDENCE,
'concernState' => TenantRecoveryTriagePresentation::RECOVERY_EVIDENCE_WEAKENED,
'concernReason' => RestoreResultAttention::STATE_FAILED,
'returnFilters' => [
'recovery_evidence' => [TenantRecoveryTriagePresentation::RECOVERY_EVIDENCE_WEAKENED],
'triage_sort' => TenantRecoveryTriagePresentation::TRIAGE_SORT_WORST_FIRST,
],
]),
], panel: 'tenant', tenant: $tenant);
}
it('shows an actionable follow-up link for in-scope members who can open the target surface', function (): void {
[$user, $tenant] = $this->makePortfolioTriageActor('Visible Arrival Tenant', role: 'readonly');
$restoreRun = $this->seedPortfolioRecoveryConcern($tenant, RestoreResultAttention::STATE_FAILED);
$this->actingAs($user);
$this->get(tenantDashboardVisibilityArrivalUrl($tenant))
->assertOk()
->assertSee('Triage arrival')
->assertSee('Open restore run')
->assertSee('Return to tenant triage');
$this->get(RestoreRunResource::getUrl('view', [
'record' => (int) $restoreRun->getKey(),
'recovery_posture_reason' => RestoreResultAttention::STATE_FAILED,
], panel: 'tenant', tenant: $tenant))
->assertOk();
});
it('keeps the arrival block truthful while degrading the CTA for in-scope members without follow-up capability', function (): void {
[$user, $tenant] = $this->makePortfolioTriageActor('Restricted Arrival Tenant');
$restoreRun = $this->seedPortfolioRecoveryConcern($tenant, RestoreResultAttention::STATE_FAILED);
$this->actingAs($user);
mock(CapabilityResolver::class, function ($mock) use ($tenant): void {
$mock->shouldReceive('isMember')
->andReturnUsing(static fn ($user, $resolvedTenant): bool => (int) $resolvedTenant->getKey() === (int) $tenant->getKey());
$mock->shouldReceive('can')
->andReturnUsing(static function ($user, $resolvedTenant, string $capability) use ($tenant): bool {
expect((int) $resolvedTenant->getKey())->toBe((int) $tenant->getKey());
return $capability !== Capabilities::TENANT_VIEW;
});
});
$this->get(tenantDashboardVisibilityArrivalUrl($tenant))
->assertOk()
->assertSee('Triage arrival')
->assertSee(UiTooltips::INSUFFICIENT_PERMISSION)
->assertDontSee('href="'.e(RestoreRunResource::getUrl('view', [
'record' => (int) $restoreRun->getKey(),
'recovery_posture_reason' => RestoreResultAttention::STATE_FAILED,
], panel: 'tenant', tenant: $tenant)).'"', false);
$this->get(RestoreRunResource::getUrl('view', [
'record' => (int) $restoreRun->getKey(),
'recovery_posture_reason' => RestoreResultAttention::STATE_FAILED,
], panel: 'tenant', tenant: $tenant))
->assertForbidden();
});
it('keeps tenant-dashboard arrival routes deny-as-not-found for non-members', function (): void {
$tenant = Tenant::factory()->create();
[$user] = $this->makePortfolioTriageActor('Other Tenant');
$this->seedPortfolioRecoveryConcern($tenant, RestoreResultAttention::STATE_FAILED);
$this->actingAs($user);
$this->get(tenantDashboardVisibilityArrivalUrl($tenant))
->assertNotFound();
});

View File

@ -1,190 +0,0 @@
<?php
declare(strict_types=1);
use App\Support\Auth\Capabilities;
use App\Support\BackupHealth\TenantBackupHealthAssessment;
use App\Support\PortfolioTriage\PortfolioArrivalContextResolver;
use App\Support\PortfolioTriage\PortfolioArrivalContextToken;
use App\Support\RestoreSafety\RestoreResultAttention;
use App\Support\Tenants\TenantRecoveryTriagePresentation;
use App\Support\Workspaces\WorkspaceContext;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Http\Request;
use Tests\Feature\Concerns\BuildsPortfolioTriageFixtures;
use function Pest\Laravel\mock;
uses(RefreshDatabase::class, BuildsPortfolioTriageFixtures::class);
function portfolioArrivalRequest(array $query, int $workspaceId): Request
{
$request = Request::create('/admin/t/test-tenant', 'GET', $query);
$session = app('session.store');
$session->start();
$session->put(WorkspaceContext::SESSION_KEY, $workspaceId);
$request->setLaravelSession($session);
return $request;
}
it('resolves a workspace-bound backup arrival context into the dashboard follow-up contract', function (): void {
[$user, $tenant] = $this->makePortfolioTriageActor('Absent Backup Tenant');
$this->actingAs($user);
$request = portfolioArrivalRequest([
PortfolioArrivalContextToken::QUERY_PARAMETER => PortfolioArrivalContextToken::encode([
'sourceSurface' => PortfolioArrivalContextToken::SOURCE_WORKSPACE_OVERVIEW,
'tenantRouteKey' => (string) $tenant->external_id,
'workspaceId' => (int) $tenant->workspace_id,
'concernFamily' => PortfolioArrivalContextToken::FAMILY_BACKUP_HEALTH,
'concernState' => TenantBackupHealthAssessment::POSTURE_ABSENT,
'concernReason' => TenantBackupHealthAssessment::REASON_NO_BACKUP_BASIS,
]),
], (int) $tenant->workspace_id);
$context = app(PortfolioArrivalContextResolver::class)->resolve($request, $tenant);
expect($context)->not->toBeNull();
expect($context->sourceSurface)->toBe(PortfolioArrivalContextToken::SOURCE_WORKSPACE_OVERVIEW)
->and($context->concernFamily)->toBe(PortfolioArrivalContextToken::FAMILY_BACKUP_HEALTH)
->and($context->concernState)->toBe(TenantBackupHealthAssessment::POSTURE_ABSENT)
->and($context->arrivalSummary)->toContain('workspace overview triage')
->and($context->nextStep['kind'])->toBe('backup_sets')
->and($context->nextStep['url'])->toContain('backup_health_reason='.TenantBackupHealthAssessment::REASON_NO_BACKUP_BASIS)
->and($context->returnTarget['kind'])->toBe('workspace_overview')
->and($context->currentTruthDelta)->toBe('Recovery evidence also still needs follow-up.');
});
it('returns null when the arrival token is bound to another tenant or workspace', function (): void {
[$user, $tenant] = $this->makePortfolioTriageActor('Bound Tenant');
$this->actingAs($user);
$wrongTenantRequest = portfolioArrivalRequest([
PortfolioArrivalContextToken::QUERY_PARAMETER => PortfolioArrivalContextToken::encode([
'sourceSurface' => PortfolioArrivalContextToken::SOURCE_WORKSPACE_OVERVIEW,
'tenantRouteKey' => 'other-tenant',
'workspaceId' => (int) $tenant->workspace_id,
'concernFamily' => PortfolioArrivalContextToken::FAMILY_BACKUP_HEALTH,
'concernState' => TenantBackupHealthAssessment::POSTURE_ABSENT,
'concernReason' => TenantBackupHealthAssessment::REASON_NO_BACKUP_BASIS,
]),
], (int) $tenant->workspace_id);
$wrongWorkspaceRequest = portfolioArrivalRequest([
PortfolioArrivalContextToken::QUERY_PARAMETER => PortfolioArrivalContextToken::encode([
'sourceSurface' => PortfolioArrivalContextToken::SOURCE_WORKSPACE_OVERVIEW,
'tenantRouteKey' => (string) $tenant->external_id,
'workspaceId' => 9999,
'concernFamily' => PortfolioArrivalContextToken::FAMILY_BACKUP_HEALTH,
'concernState' => TenantBackupHealthAssessment::POSTURE_ABSENT,
'concernReason' => TenantBackupHealthAssessment::REASON_NO_BACKUP_BASIS,
]),
], (int) $tenant->workspace_id);
expect(app(PortfolioArrivalContextResolver::class)->resolve($wrongTenantRequest, $tenant))->toBeNull()
->and(app(PortfolioArrivalContextResolver::class)->resolve($wrongWorkspaceRequest, $tenant))->toBeNull();
});
it('sanitizes tenant-registry return targets back to allowlisted filters only', function (): void {
[$user, $tenant] = $this->makePortfolioTriageActor('Registry Tenant');
$this->actingAs($user);
$request = portfolioArrivalRequest([
PortfolioArrivalContextToken::QUERY_PARAMETER => PortfolioArrivalContextToken::encode([
'sourceSurface' => PortfolioArrivalContextToken::SOURCE_TENANT_REGISTRY,
'tenantRouteKey' => (string) $tenant->external_id,
'workspaceId' => (int) $tenant->workspace_id,
'concernFamily' => PortfolioArrivalContextToken::FAMILY_BACKUP_HEALTH,
'concernState' => TenantBackupHealthAssessment::POSTURE_ABSENT,
'concernReason' => TenantBackupHealthAssessment::REASON_NO_BACKUP_BASIS,
'returnFilters' => [
'backup_posture' => [
TenantBackupHealthAssessment::POSTURE_ABSENT,
'invalid',
],
'recovery_evidence' => [
TenantRecoveryTriagePresentation::RECOVERY_EVIDENCE_UNVALIDATED,
],
'triage_sort' => TenantRecoveryTriagePresentation::TRIAGE_SORT_WORST_FIRST,
'leak' => ['tenant_id' => 999],
],
]),
], (int) $tenant->workspace_id);
$context = app(PortfolioArrivalContextResolver::class)->resolve($request, $tenant);
expect($context)->not->toBeNull();
expect($context->returnTarget['kind'])->toBe('tenant_registry')
->and($context->returnTarget['url'])->toContain('backup_posture%5B0%5D=absent')
->and($context->returnTarget['url'])->toContain('recovery_evidence%5B0%5D=unvalidated')
->and($context->returnTarget['url'])->toContain('triage_sort=worst_first')
->and($context->returnTarget['url'])->not->toContain('leak');
});
it('degrades the next step truthfully when the operator cannot open deeper follow-up surfaces', function (): void {
[$user, $tenant] = $this->makePortfolioTriageActor('Restricted Tenant');
$this->actingAs($user);
mock(\App\Services\Auth\CapabilityResolver::class, function ($mock) use ($tenant): void {
$mock->shouldReceive('can')
->andReturnUsing(static function ($user, $resolvedTenant, string $capability) use ($tenant): bool {
expect((int) $resolvedTenant->getKey())->toBe((int) $tenant->getKey());
return $capability !== Capabilities::TENANT_VIEW;
});
});
$request = portfolioArrivalRequest([
PortfolioArrivalContextToken::QUERY_PARAMETER => PortfolioArrivalContextToken::encode([
'sourceSurface' => PortfolioArrivalContextToken::SOURCE_WORKSPACE_OVERVIEW,
'tenantRouteKey' => (string) $tenant->external_id,
'workspaceId' => (int) $tenant->workspace_id,
'concernFamily' => PortfolioArrivalContextToken::FAMILY_BACKUP_HEALTH,
'concernState' => TenantBackupHealthAssessment::POSTURE_ABSENT,
'concernReason' => TenantBackupHealthAssessment::REASON_NO_BACKUP_BASIS,
]),
], (int) $tenant->workspace_id);
$context = app(PortfolioArrivalContextResolver::class)->resolve($request, $tenant);
expect($context)->not->toBeNull();
expect($context->nextStep['disabled'])->toBeTrue()
->and($context->nextStep['url'])->toBeNull()
->and($context->nextStep['helperText'])->toBe(\App\Support\Rbac\UiTooltips::INSUFFICIENT_PERMISSION);
});
it('maps weakened recovery arrivals to restore-run detail and explains when current truth has changed', function (): void {
[$user, $tenant] = $this->makePortfolioTriageActor('Recovery Tenant');
$this->actingAs($user);
$backupSet = $this->seedPortfolioBackupConcern($tenant, TenantBackupHealthAssessment::POSTURE_HEALTHY);
$restoreRun = $this->seedPortfolioRecoveryConcern(
tenant: $tenant,
reason: RestoreResultAttention::STATE_COMPLETED_WITH_FOLLOW_UP,
backupSet: $backupSet,
);
$request = portfolioArrivalRequest([
PortfolioArrivalContextToken::QUERY_PARAMETER => PortfolioArrivalContextToken::encode([
'sourceSurface' => PortfolioArrivalContextToken::SOURCE_TENANT_REGISTRY,
'tenantRouteKey' => (string) $tenant->external_id,
'workspaceId' => (int) $tenant->workspace_id,
'concernFamily' => PortfolioArrivalContextToken::FAMILY_RECOVERY_EVIDENCE,
'concernState' => TenantRecoveryTriagePresentation::RECOVERY_EVIDENCE_UNVALIDATED,
'concernReason' => 'no_history',
'returnFilters' => [
'recovery_evidence' => [TenantRecoveryTriagePresentation::RECOVERY_EVIDENCE_UNVALIDATED],
'triage_sort' => TenantRecoveryTriagePresentation::TRIAGE_SORT_WORST_FIRST,
],
]),
], (int) $tenant->workspace_id);
$context = app(PortfolioArrivalContextResolver::class)->resolve($request, $tenant);
expect($context)->not->toBeNull();
expect($context->nextStep['kind'])->toBe('restore_run_detail')
->and($context->nextStep['url'])->toContain('/restore-runs/'.(string) $restoreRun->getKey())
->and($context->nextStep['url'])->toContain('recovery_posture_reason=completed_with_follow_up')
->and($context->currentTruthDelta)->toContain('Current recovery evidence now looks Weakened.');
});

View File

@ -1,114 +0,0 @@
<?php
declare(strict_types=1);
use App\Support\BackupHealth\TenantBackupHealthAssessment;
use App\Support\PortfolioTriage\PortfolioArrivalContextToken;
use App\Support\RestoreSafety\RestoreResultAttention;
use App\Support\Tenants\TenantRecoveryTriagePresentation;
it('encodes and decodes bounded portfolio arrival token state deterministically', function (): void {
$state = [
'sourceSurface' => PortfolioArrivalContextToken::SOURCE_TENANT_REGISTRY,
'tenantRouteKey' => 'tenant-abc',
'workspaceId' => 12,
'concernFamily' => PortfolioArrivalContextToken::FAMILY_BACKUP_HEALTH,
'concernState' => TenantBackupHealthAssessment::POSTURE_STALE,
'concernReason' => TenantBackupHealthAssessment::REASON_LATEST_BACKUP_STALE,
'returnFilters' => [
'backup_posture' => [
TenantBackupHealthAssessment::POSTURE_STALE,
TenantBackupHealthAssessment::POSTURE_DEGRADED,
],
'triage_sort' => TenantRecoveryTriagePresentation::TRIAGE_SORT_WORST_FIRST,
],
];
$token = PortfolioArrivalContextToken::encode($state);
expect($token)->toBeString()
->and($token)->not->toContain('+')
->and($token)->not->toContain('/')
->and($token)->not->toContain('=')
->and(PortfolioArrivalContextToken::decode($token))->toBe($state);
});
it('returns null for malformed or unsupported portfolio arrival tokens', function (): void {
expect(PortfolioArrivalContextToken::decode(''))->toBeNull()
->and(PortfolioArrivalContextToken::decode('not-base64url'))->toBeNull();
$payload = json_encode([
'v' => 999,
'sourceSurface' => PortfolioArrivalContextToken::SOURCE_WORKSPACE_OVERVIEW,
'tenantRouteKey' => 'tenant-abc',
'concernFamily' => PortfolioArrivalContextToken::FAMILY_BACKUP_HEALTH,
'concernState' => TenantBackupHealthAssessment::POSTURE_ABSENT,
], JSON_THROW_ON_ERROR);
$token = rtrim(strtr(base64_encode($payload), '+/', '-_'), '=');
expect(PortfolioArrivalContextToken::decode($token))->toBeNull();
});
it('rejects unsupported family state and reason combinations', function (): void {
$unknownFamilyPayload = json_encode([
'v' => 1,
'sourceSurface' => PortfolioArrivalContextToken::SOURCE_WORKSPACE_OVERVIEW,
'tenantRouteKey' => 'tenant-abc',
'concernFamily' => 'unknown_family',
'concernState' => TenantBackupHealthAssessment::POSTURE_ABSENT,
], JSON_THROW_ON_ERROR);
$unknownStatePayload = json_encode([
'v' => 1,
'sourceSurface' => PortfolioArrivalContextToken::SOURCE_WORKSPACE_OVERVIEW,
'tenantRouteKey' => 'tenant-abc',
'concernFamily' => PortfolioArrivalContextToken::FAMILY_RECOVERY_EVIDENCE,
'concernState' => TenantBackupHealthAssessment::POSTURE_STALE,
], JSON_THROW_ON_ERROR);
$unknownReasonPayload = json_encode([
'v' => 1,
'sourceSurface' => PortfolioArrivalContextToken::SOURCE_TENANT_REGISTRY,
'tenantRouteKey' => 'tenant-abc',
'concernFamily' => PortfolioArrivalContextToken::FAMILY_RECOVERY_EVIDENCE,
'concernState' => TenantRecoveryTriagePresentation::RECOVERY_EVIDENCE_WEAKENED,
'concernReason' => TenantBackupHealthAssessment::REASON_LATEST_BACKUP_STALE,
], JSON_THROW_ON_ERROR);
expect(PortfolioArrivalContextToken::decode(rtrim(strtr(base64_encode($unknownFamilyPayload), '+/', '-_'), '=')))->toBeNull()
->and(PortfolioArrivalContextToken::decode(rtrim(strtr(base64_encode($unknownStatePayload), '+/', '-_'), '=')))->toBeNull()
->and(PortfolioArrivalContextToken::decode(rtrim(strtr(base64_encode($unknownReasonPayload), '+/', '-_'), '=')))->toBeNull();
});
it('drops unsupported tenant-registry return filters while keeping allowlisted ones', function (): void {
$state = [
'sourceSurface' => PortfolioArrivalContextToken::SOURCE_TENANT_REGISTRY,
'tenantRouteKey' => 'tenant-abc',
'workspaceId' => 12,
'concernFamily' => PortfolioArrivalContextToken::FAMILY_RECOVERY_EVIDENCE,
'concernState' => TenantRecoveryTriagePresentation::RECOVERY_EVIDENCE_WEAKENED,
'concernReason' => RestoreResultAttention::STATE_FAILED,
'returnFilters' => [
'backup_posture' => [TenantBackupHealthAssessment::POSTURE_ABSENT],
'triage_sort' => TenantRecoveryTriagePresentation::TRIAGE_SORT_WORST_FIRST,
'ignore_me' => ['leak'],
'nested' => ['bad' => ['value']],
],
];
$decoded = PortfolioArrivalContextToken::decode(
PortfolioArrivalContextToken::encode($state),
);
expect($decoded)->toMatchArray([
'sourceSurface' => PortfolioArrivalContextToken::SOURCE_TENANT_REGISTRY,
'concernFamily' => PortfolioArrivalContextToken::FAMILY_RECOVERY_EVIDENCE,
'concernState' => TenantRecoveryTriagePresentation::RECOVERY_EVIDENCE_WEAKENED,
'concernReason' => RestoreResultAttention::STATE_FAILED,
'returnFilters' => [
'backup_posture' => [TenantBackupHealthAssessment::POSTURE_ABSENT],
'triage_sort' => TenantRecoveryTriagePresentation::TRIAGE_SORT_WORST_FIRST,
],
]);
});

View File

@ -1,35 +0,0 @@
# Specification Quality Checklist: Tenant Registry Recovery Triage
**Purpose**: Validate specification completeness and quality before proceeding to planning
**Created**: 2026-04-09
**Feature**: [spec.md](../spec.md)
## Content Quality
- [x] No implementation details (languages, frameworks, APIs)
- [x] Focused on user value and business needs
- [x] Written for non-technical stakeholders
- [x] All mandatory sections completed
## Requirement Completeness
- [x] No [NEEDS CLARIFICATION] markers remain
- [x] Requirements are testable and unambiguous
- [x] Success criteria are measurable
- [x] Success criteria are technology-agnostic (no implementation details)
- [x] All acceptance scenarios are defined
- [x] Edge cases are identified
- [x] Scope is clearly bounded
- [x] Dependencies and assumptions identified
## Feature Readiness
- [x] All functional requirements have clear acceptance criteria
- [x] User scenarios cover primary flows
- [x] Feature meets measurable outcomes defined in Success Criteria
- [x] No implementation details leak into specification
## Notes
- Validated on 2026-04-09 after correcting the feature number to 186.
- No clarification markers remain; the spec is ready for `/speckit.plan`.

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,36 +0,0 @@
# Specification Quality Checklist: Portfolio Triage Arrival Context
**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 pass completed on 2026-04-09.
- Route and surface identifiers are included only where the project template requires surface scoping; the behavioral requirements stay focused on operator workflow continuity, truth boundaries, and bounded next actions.
- The spec introduces no unresolved clarification markers, no new persistence, and no new recovery-truth model.

View File

@ -1,335 +0,0 @@
openapi: 3.1.0
info:
title: Portfolio Triage Arrival Context Internal Surface Contract
version: 0.1.0
summary: Internal logical contract for preserving portfolio triage intent into tenant arrival surfaces
description: |
This contract is an internal planning artifact for Spec 187. The affected routes still
return HTML. The schemas below describe the bounded request data and rendered continuity
models that must be derivable before the tenant dashboard or downstream backup/restore
destinations are rendered. No public API is introduced.
servers:
- url: /internal
x-arrival-context-consumers:
- surface: workspace.overview.triage
sourceFiles:
- apps/platform/app/Support/Workspaces/WorkspaceOverviewBuilder.php
- apps/platform/app/Filament/Widgets/Workspace/WorkspaceNeedsAttention.php
- apps/platform/app/Filament/Widgets/Workspace/WorkspaceSummaryStats.php
mustEmit:
- reason_context
- destination
- arrival_context_token
- surface: tenant.registry.triage
sourceFiles:
- apps/platform/app/Filament/Resources/TenantResource.php
- apps/platform/app/Filament/Resources/TenantResource/Pages/ListTenants.php
mustPreserve:
- backup_posture
- recovery_evidence
- triage_sort
- arrival_context_token
- surface: tenant.dashboard.arrival
sourceFiles:
- apps/platform/app/Filament/Pages/TenantDashboard.php
- apps/platform/app/Filament/Widgets/Tenant/TenantTriageArrivalContinuity.php
mustRender:
- source_surface
- concern_family
- concern_state
- next_step
- return_target
paths:
/admin/w/{workspace}:
get:
summary: Workspace overview recovery triage surfaces emit bounded tenant-arrival context
operationId: viewWorkspaceOverviewWithArrivalContext
parameters:
- name: workspace
in: path
required: true
schema:
type: string
responses:
'200':
description: Rendered workspace overview with triage destinations
content:
text/html:
schema:
type: string
application/vnd.tenantpilot.workspace-triage+json:
schema:
$ref: '#/components/schemas/WorkspaceTriageBundle'
'404':
description: Workspace is outside the actor's entitlement scope
/admin/tenants:
get:
summary: Tenant registry triage accepts allowlisted filters and can preserve portfolio return context
operationId: viewTenantRegistryWithTriageContext
parameters:
- name: backup_posture
in: query
required: false
schema:
type: array
items:
$ref: '#/components/schemas/BackupConcernState'
- name: recovery_evidence
in: query
required: false
schema:
type: array
items:
$ref: '#/components/schemas/RecoveryConcernState'
- name: triage_sort
in: query
required: false
schema:
$ref: '#/components/schemas/TriageSort'
responses:
'200':
description: Rendered tenant registry with optional triage filter context
content:
text/html:
schema:
type: string
application/vnd.tenantpilot.tenant-registry-triage+json:
schema:
$ref: '#/components/schemas/TenantRegistryTriageBundle'
'404':
description: Workspace scope is not available to the actor
/admin/t/{tenant}:
get:
summary: Tenant dashboard renders a continuity block only when a valid arrival token is present
operationId: viewTenantDashboardWithArrivalContext
parameters:
- name: tenant
in: path
required: true
schema:
type: string
- name: arrival
in: query
required: false
schema:
type: string
description: Versioned base64url token carrying bounded portfolio-arrival metadata.
responses:
'200':
description: Rendered tenant dashboard with optional arrival continuity block
content:
text/html:
schema:
type: string
application/vnd.tenantpilot.tenant-dashboard-arrival+json:
schema:
$ref: '#/components/schemas/TenantDashboardArrivalBundle'
'403':
description: Actor is in scope but lacks the capability required to open a deeper next-step destination
'404':
description: Tenant is outside workspace or tenant entitlement scope
components:
schemas:
SourceSurface:
type: string
enum:
- workspace_overview
- tenant_registry
ConcernFamily:
type: string
enum:
- backup_health
- recovery_evidence
BackupConcernState:
type: string
enum:
- absent
- stale
- degraded
RecoveryConcernState:
type: string
enum:
- unvalidated
- weakened
ConcernState:
type: string
enum:
- absent
- stale
- degraded
- unvalidated
- weakened
TriageSort:
type: string
enum:
- worst_first
NavigationTargetKind:
type: string
enum:
- tenant_dashboard
- backup_sets
- restore_runs
- restore_run_detail
- workspace_overview
- tenant_registry
NavigationTarget:
type: object
additionalProperties: false
required:
- kind
- label
- disabled
properties:
kind:
$ref: '#/components/schemas/NavigationTargetKind'
label:
type: string
url:
type:
- string
- 'null'
disabled:
type: boolean
helperText:
type:
- string
- 'null'
filters:
type:
- object
- 'null'
additionalProperties: true
ArrivalTokenState:
type: object
additionalProperties: false
required:
- v
- sourceSurface
- concernFamily
- concernState
properties:
v:
type: integer
const: 1
sourceSurface:
$ref: '#/components/schemas/SourceSurface'
tenantRouteKey:
type:
- string
- 'null'
workspaceId:
type:
- integer
- 'null'
concernFamily:
$ref: '#/components/schemas/ConcernFamily'
concernState:
$ref: '#/components/schemas/ConcernState'
concernReason:
type:
- string
- 'null'
returnFilters:
type:
- object
- 'null'
additionalProperties: true
PortfolioArrivalContext:
type: object
additionalProperties: false
required:
- sourceSurface
- concernFamily
- concernState
- nextStep
- returnTarget
properties:
sourceSurface:
$ref: '#/components/schemas/SourceSurface'
concernFamily:
$ref: '#/components/schemas/ConcernFamily'
concernState:
$ref: '#/components/schemas/ConcernState'
concernReason:
type:
- string
- 'null'
arrivalSummary:
type: string
claimBoundary:
type:
- string
- 'null'
currentTruthDelta:
type:
- string
- 'null'
nextStep:
$ref: '#/components/schemas/NavigationTarget'
returnTarget:
$ref: '#/components/schemas/NavigationTarget'
WorkspaceAttentionItem:
type: object
additionalProperties: false
required:
- tenantRouteKey
- family
- destination
properties:
tenantRouteKey:
type: string
family:
$ref: '#/components/schemas/ConcernFamily'
reasonContext:
type: object
additionalProperties: false
properties:
family:
$ref: '#/components/schemas/ConcernFamily'
state:
$ref: '#/components/schemas/ConcernState'
reason:
type:
- string
- 'null'
destination:
$ref: '#/components/schemas/NavigationTarget'
arrivalToken:
type:
- string
- 'null'
WorkspaceTriageBundle:
type: object
additionalProperties: false
required:
- attentionItems
properties:
attentionItems:
type: array
items:
$ref: '#/components/schemas/WorkspaceAttentionItem'
TenantRegistryTriageBundle:
type: object
additionalProperties: false
required:
- activeFilters
properties:
activeFilters:
type: object
additionalProperties: true
tenantOpenArrivalToken:
type:
- string
- 'null'
TenantDashboardArrivalBundle:
type: object
additionalProperties: false
required:
- arrivalContextPresent
properties:
arrivalContextPresent:
type: boolean
arrivalContext:
oneOf:
- $ref: '#/components/schemas/PortfolioArrivalContext'
- type: 'null'

View File

@ -1,130 +0,0 @@
# Data Model: Portfolio Triage Arrival Context
## Overview
This feature introduces no new persisted tables or stored entities. The model impact is a small set of request-scoped, derived runtime contracts that connect existing workspace triage truth to tenant-dashboard arrival rendering.
## Existing Source Truths
### Workspace overview attention item
**Type**: Existing derived workspace summary payload
**Source**: `WorkspaceOverviewBuilder`
| Field | Type | Notes |
|------|------|-------|
| `tenant_route_key` | string | Tenant route identifier already used in workspace attention items |
| `family` | string | Existing concern family, such as `backup_health` or `recovery_evidence` |
| `title` | string | Operator-facing concern headline |
| `body` | string | Operator-facing bounded summary |
| `supporting_message` | string or null | Existing claim-boundary or next-step phrasing |
| `reason_context.family` | enum | `backup_health` or `recovery_evidence` |
| `reason_context.state` | enum | Existing posture state emitted from workspace triage |
| `reason_context.reason` | string or null | Existing bounded reason code |
| `destination.kind` | string | Existing destination type |
| `destination.url` | string or null | Existing destination URL |
| `destination.disabled` | bool | Existing capability-aware destination availability |
| `destination.helper_text` | string or null | Existing degraded-access explanation |
### Tenant-registry triage state
**Type**: Existing query-driven list state
**Source**: `ListTenants::applyRequestedTriageIntent()`
| Field | Type | Validation |
|------|------|------------|
| `backup_posture` | array<string> | Sanitized through `TenantResource::sanitizeBackupPostures()` |
| `recovery_evidence` | array<string> | Sanitized through `TenantResource::sanitizeRecoveryEvidenceStates()` |
| `triage_sort` | string or null | Sanitized through `TenantResource::sanitizeTriageSort()` |
### Existing destination continuity inputs
**Type**: Existing route-level continuity params
| Surface | Param | Purpose |
|--------|-------|---------|
| Backup-set list | `backup_health_reason` | Explains why backup detail or backup list was opened |
| Restore-run list | `recovery_posture_reason` | Explains why restore history was opened |
| Restore-run detail | `recovery_posture_reason` | Explains why a specific restore run was opened |
## New Derived Runtime Contracts
### PortfolioArrivalContext
**Type**: Request-scoped value object
**Lifecycle**: Decoded from a tenant-dashboard query token, validated against current scope, discarded after the request completes
| Field | Type | Validation |
|------|------|------------|
| `version` | integer | Must match the current token version |
| `sourceSurface` | enum | `workspace_overview` or `tenant_registry` |
| `tenantRouteKey` | string | Must match the current tenant route or resolve to the same tenant |
| `workspaceId` | integer or null | Optional scope binding; when present, must match current workspace context |
| `concernFamily` | enum | `backup_health` or `recovery_evidence` |
| `concernState` | enum | `absent`, `stale`, `degraded`, `unvalidated`, or `weakened` |
| `concernReason` | string or null | Allowlisted per concern family |
| `arrivalSummary` | string | Derived bounded explanation of why the operator arrived |
| `claimBoundary` | string or null | Derived reminder that arrival reason is not a stronger truth than current evidence supports |
| `nextStep` | `NextStepTarget` | Capability-aware navigation target |
| `returnTarget` | `ReturnTarget` | Portfolio return link with bounded route and query state |
| `currentTruthDelta` | string or null | Optional note when current tenant truth differs from the arrival reason |
### NextStepTarget
**Type**: Request-scoped navigation descriptor
| Field | Type | Notes |
|------|------|-------|
| `kind` | enum | `tenant_dashboard`, `backup_sets`, `restore_runs`, `restore_run_detail` |
| `label` | string | Operator-facing next-step verb + object label |
| `url` | string or null | Null when unavailable under current RBAC |
| `disabled` | bool | True when the user can see the arrival block but cannot access the target |
| `helperText` | string or null | Truthful reason the next step cannot be opened |
| `reasonParam` | array<string, scalar> | Existing `backup_health_reason` or `recovery_posture_reason` continuation payload when needed |
### ReturnTarget
**Type**: Request-scoped navigation descriptor
| Field | Type | Notes |
|------|------|-------|
| `kind` | enum | `workspace_overview` or `tenant_registry` |
| `label` | string | Operator-facing return label |
| `url` | string | Canonical return URL |
| `filters` | array<string, mixed> | Allowlisted triage filters and sort state for registry returns |
## Validation Rules
### Concern family and state compatibility
| Concern Family | Allowed States | Allowed Reasons |
|---------------|----------------|-----------------|
| `backup_health` | `absent`, `stale`, `degraded` | Existing `TenantBackupHealthAssessment` reason constants that justify tenant-opening triage |
| `recovery_evidence` | `unvalidated`, `weakened` | Existing restore-safety or recovery-triage reason codes such as `no_history`, `failed`, `partial`, or `completed_with_follow_up` |
### Token decode rules
- Empty, malformed, or unsupported-version tokens decode to `null`.
- Unknown families, states, reasons, or return kinds decode to `null`.
- Tokens bound to a different tenant route or incompatible workspace context decode to `null`.
- Successful decode does not replace current tenant truth; it only authorizes rendering of the continuity block.
### Return filter rules
- Registry return filters may include only the existing triage keys: `backup_posture`, `recovery_evidence`, and `triage_sort`.
- Filter values must pass the same sanitizers used by `ListTenants` on mount.
- Unknown or empty filters are dropped from the return target.
## Relationships
- One workspace attention item may generate one `PortfolioArrivalContext` when its destination is a tenant-level surface.
- One filtered tenant-registry session may generate one `PortfolioArrivalContext` per tenant-open action.
- One `PortfolioArrivalContext` owns exactly one `NextStepTarget` and one `ReturnTarget`.
- One tenant dashboard request may resolve zero or one valid `PortfolioArrivalContext`.
## Rendering Rules
- No valid `PortfolioArrivalContext` means the tenant dashboard renders normally with no continuity block.
- A valid `PortfolioArrivalContext` may render even if current tenant truth has changed, but the block must differentiate arrival reason from current truth.
- `NextStepTarget.disabled = true` still allows the block to render, but the CTA must be absent or disabled with helper text.
- `ReturnTarget` renders only when the origin surface can be reconstituted safely.

View File

@ -1,277 +0,0 @@
# Implementation Plan: Portfolio Triage Arrival Context
**Branch**: `187-portfolio-triage-arrival-context` | **Date**: 2026-04-09 | **Spec**: `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/specs/187-portfolio-triage-arrival-context/spec.md`
**Input**: Feature specification from `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/specs/187-portfolio-triage-arrival-context/spec.md`
**Note**: This template is filled in by the `/speckit.plan` command. See `.specify/scripts/` for helper scripts.
## Summary
Preserve portfolio triage intent when operators move from workspace recovery surfaces or filtered tenant-registry triage into tenant-level destinations by adding one small request-scoped arrival-context contract, propagating it through existing destination URLs, and rendering a compact top-of-page continuity block on the tenant dashboard. The implementation will reuse existing workspace `reason_context`, existing tenant-registry query-driven triage filters, existing backup/restore list continuity params, and existing capability-aware destination helpers so the feature stays additive, truthful, and free of new persistence or new posture computation.
## Technical Context
**Language/Version**: PHP 8.4.15
**Primary Dependencies**: Laravel 12, Filament v5, Livewire v4, Pest v4, existing `WorkspaceOverviewBuilder`, `TenantResource`, `TenantDashboard`, `CanonicalAdminTenantFilterState`, `TenantBackupHealthAssessment`, `RestoreSafetyResolver`, and continuity-aware backup or restore list pages
**Storage**: PostgreSQL unchanged; no new tables, caches, or durable workflow artifacts
**Testing**: Pest 4 unit and feature tests, shared feature test concerns where reuse is warranted, and Livewire component coverage, run through Laravel Sail
**Target Platform**: Laravel monolith web application in Sail locally and containerized Linux deployment in staging and production
**Project Type**: web application
**Performance Goals**: No additional external calls at render time; keep the feature DB-only; append arrival context to existing URLs without regressing current drilldown routing; decode and resolve continuity once per request; keep workspace overview and tenant-registry changes query-neutral because they only append bounded URL state; avoid new N+1 queries on tenant-dashboard renders
**Constraints**: Request-scoped only; no persistence; no new recovery-truth model; generic tenant sessions remain unchanged; next-step guidance must stay RBAC-safe; arrival context must be bookmark-safe and fail closed to the generic tenant experience when invalid
**Scale/Scope**: One lightweight continuity abstraction spanning three operator surfaces: workspace overview triage, tenant-registry triage, and tenant dashboard arrival, while reusing existing backup and restore destinations for deeper follow-up
## 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 | The feature only carries existing backup-health and recovery-evidence truth into tenant arrival; no new source-of-truth path is introduced. |
| Read/write separation | PASS | PASS | The slice is read-only. No new write, restore, or operational start surface is added. |
| Graph contract path | N/A | N/A | No Microsoft Graph calls, provider contracts, or `config/graph_contracts.php` changes are required. |
| Deterministic capabilities | PASS | PASS | Existing capability checks remain authoritative for next-step access and destination degradation. |
| Workspace + tenant isolation | PASS | PASS | Arrival context is scoped to the active workspace and tenant route, and invalid or out-of-scope context degrades to the generic dashboard. |
| RBAC-UX authorization semantics | PASS | PASS | Non-members remain 404; members missing deeper capabilities receive degraded guidance rather than false CTAs. |
| Run observability / Ops-UX | PASS | PASS | No new `OperationRun`, no change to notification surfaces, and no long-running work. |
| Data minimization | PASS | PASS | Only bounded reason codes, posture states, return filters, and route-safe context travel in the request; nothing new is persisted. |
| Proportionality / no premature abstraction | PASS WITH JUSTIFIED ABSTRACTION | PASS WITH JUSTIFIED ABSTRACTION | One small arrival-context value object plus token codec and resolver are justified because multiple existing surfaces already share the same triage vocabulary but lose it at navigation boundaries. |
| Persisted truth / behavioral state | PASS | PASS | No new table, persisted entity, or new posture state family is introduced. |
| UI semantics / few layers | PASS | PASS | The continuity block reuses existing domain truth and claim boundaries instead of creating a new presentation framework. |
| Filament v5 / Livewire v4 compliance | PASS | PASS | The design stays inside the existing Filament v5 + Livewire v4 stack. No legacy API is introduced. |
| Provider registration location | PASS | PASS | No panel or provider registration changes are required. Laravel 11+ provider registration remains in `bootstrap/providers.php`. |
| Global search hard rule | PASS | PASS | No global search behavior changes are proposed. Impacted searchable resources already satisfy the rule: `TenantResource` has View and Edit pages, `BackupSetResource` has a View page, and `RestoreRunResource` has a View page. |
| Destructive action safety | PASS | PASS | No new destructive action is added. All new CTAs are navigation-only. |
| Asset strategy | PASS | PASS | No new assets are introduced. Existing deploy behavior remains unchanged, including `cd apps/platform && php artisan filament:assets` when registered assets are in play elsewhere. |
| Filament-native UI / Action Surface Contract | PASS | PASS | The tenant dashboard change is an additive read-only widget or section. Workspace overview and tenant registry keep their existing action models. |
| Filament UX-001 | PASS | PASS | No create, edit, or view form layout changes are proposed; only summary and arrival guidance surfaces change. |
| Testing truth (TEST-TRUTH-001) | PASS | PASS | The plan adds workflow and regression tests that protect operator-visible continuity, truth boundaries, return flow, and RBAC-safe degradation. |
## Phase 0 Research
Research outcomes are captured in `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/specs/187-portfolio-triage-arrival-context/research.md`.
Key decisions:
- Reuse existing workspace `reason_context` and existing destination payloads as the authoritative inputs for portfolio-arrival context instead of inventing a second concern model.
- Carry tenant-dashboard arrival intent in one bounded, versioned base64url token, following the repo's existing token pattern, so the context stays request-scoped, bookmark-safe, and easy to ignore when invalid.
- Preserve the current `backup_health_reason` and `recovery_posture_reason` query-param continuity on backup and restore destinations rather than replacing every downstream surface with a new universal token.
- Render the continuity treatment as the first non-lazy tenant dashboard widget or section so the operator sees it before scanning Recovery Readiness, KPIs, or Needs Attention.
- Preserve return-to-portfolio flow by reusing existing tenant-registry filter and sort query params and the canonical workspace overview route instead of creating session-backed triage state.
- Reuse existing capability-aware destination and disabled-helper patterns from `WorkspaceOverviewBuilder` so the continuity block does not overpromise inaccessible next steps.
## Phase 1 Design
Design artifacts are created under `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/specs/187-portfolio-triage-arrival-context/`:
- `data-model.md`: request-scoped continuity entities, source inputs, validation rules, and derived relationships
- `contracts/portfolio-triage-arrival-context.logical.openapi.yaml`: internal logical contract for source surfaces, arrival token shape, and tenant dashboard continuity rendering
- `quickstart.md`: focused implementation and verification workflow
Design decisions:
- The new continuity layer is one derived runtime contract, not a persisted triage session or new recovery-confidence engine.
- The arrival token carries only bounded routing and concern metadata. Current tenant truth remains authoritative and is re-derived on arrival.
- `WorkspaceOverviewBuilder` remains the source of workspace triage reason and destination selection; `ListTenants` remains the source of filtered tenant-registry return context.
- Existing backup-set and restore-run continuity params remain authoritative for deeper follow-up surfaces.
- The tenant dashboard becomes continuity-aware through an additive top-of-page widget or section, with no redesign of the underlying dashboard architecture.
## Project Structure
### Documentation (this feature)
```text
specs/187-portfolio-triage-arrival-context/
├── spec.md
├── plan.md
├── research.md
├── data-model.md
├── quickstart.md
├── contracts/
│ └── portfolio-triage-arrival-context.logical.openapi.yaml
├── checklists/
│ └── requirements.md
└── tasks.md
```
### Source Code (repository root)
```text
apps/platform/
├── app/
│ ├── Filament/
│ │ ├── Pages/
│ │ │ ├── TenantDashboard.php
│ │ │ └── WorkspaceOverview.php
│ │ ├── Resources/
│ │ │ ├── BackupSetResource/
│ │ │ │ └── Pages/ListBackupSets.php
│ │ │ ├── RestoreRunResource/
│ │ │ │ ├── Pages/ListRestoreRuns.php
│ │ │ │ └── Pages/ViewRestoreRun.php
│ │ │ └── TenantResource/
│ │ │ └── Pages/ListTenants.php
│ │ ├── Widgets/
│ │ │ ├── Workspace/
│ │ │ │ ├── WorkspaceNeedsAttention.php
│ │ │ │ └── WorkspaceSummaryStats.php
│ │ │ └── Tenant/
│ │ │ ├── TenantArchivedBanner.php
│ │ │ └── TenantTriageArrivalContinuity.php
│ │ └── Resources/TenantResource.php
│ ├── Support/
│ │ ├── PortfolioTriage/
│ │ │ ├── PortfolioArrivalContext.php
│ │ │ ├── PortfolioArrivalContextToken.php
│ │ │ └── PortfolioArrivalContextResolver.php
│ │ ├── BackupHealth/TenantBackupHealthAssessment.php
│ │ ├── RestoreSafety/RestoreSafetyResolver.php
│ │ └── Workspaces/WorkspaceOverviewBuilder.php
│ └── resources/views/filament/widgets/tenant/
│ └── triage-arrival-continuity.blade.php
└── tests/
├── Feature/
│ ├── Concerns/
│ │ └── BuildsPortfolioTriageFixtures.php
│ ├── Filament/
│ │ ├── WorkspaceOverviewArrivalContextTest.php
│ │ ├── TenantRegistryArrivalContextTest.php
│ │ ├── TenantDashboardArrivalContextTest.php
│ │ ├── TenantDashboardArrivalContextPerformanceTest.php
│ │ ├── BackupSetListContinuityTest.php
│ │ ├── RestoreRunListContinuityTest.php
│ │ ├── WorkspaceOverviewContentTest.php
│ │ └── TenantRegistryRecoveryTriageTest.php
│ └── Rbac/
│ └── TenantDashboardArrivalContextVisibilityTest.php
└── Unit/
└── Support/
└── PortfolioTriage/
├── PortfolioArrivalContextTokenTest.php
└── PortfolioArrivalContextResolverTest.php
```
**Structure Decision**: Keep the existing Laravel monolith structure under `apps/platform`. Add one narrow support namespace for the arrival-context contract and resolver, one additive tenant widget or view for rendering, focused Filament feature tests, narrow unit coverage for the token and resolver seam, and one shared feature-test concern instead of creating a broader navigation framework or new base directories.
## Implementation Strategy
### Phase A — Introduce the Arrival-Context Runtime Contract
**Goal**: Add one lightweight, request-scoped continuity contract and a bounded token codec.
| Step | File | Change |
|------|------|--------|
| A.1 | `apps/platform/app/Support/PortfolioTriage/PortfolioArrivalContext.php` | Add the runtime value object that holds source surface, concern family and state, reason, claim boundary, next step, and return target for one tenant-opening action |
| A.2 | `apps/platform/app/Support/PortfolioTriage/PortfolioArrivalContextToken.php` | Add a versioned base64url codec following the existing resume-token pattern, with strict decode-to-null behavior for malformed or unsupported state |
| A.3 | `apps/platform/app/Support/PortfolioTriage/PortfolioArrivalContextResolver.php` | Add the resolver that validates decoded payloads, binds them to the current tenant and workspace scope, and derives capability-aware next-step and return targets |
### Phase B — Emit Arrival Context From Portfolio Sources
**Goal**: Append the bounded arrival token only where existing portfolio triage already knows why the tenant should open.
| Step | File | Change |
|------|------|--------|
| B.1 | `apps/platform/app/Support/Workspaces/WorkspaceOverviewBuilder.php` | Extend backup-health and recovery-evidence tenant destinations so tenant-dashboard URLs carry the new arrival token while preserving existing destination semantics and disabled-destination fallbacks |
| B.2 | `apps/platform/app/Filament/Widgets/Workspace/WorkspaceNeedsAttention.php` and `apps/platform/app/Filament/Widgets/Workspace/WorkspaceSummaryStats.php` | Preserve current card and stat CTA behavior while ensuring any tenant-dashboard target emitted from overview data carries arrival context |
| B.3 | `apps/platform/app/Filament/Resources/TenantResource.php` and `apps/platform/app/Filament/Resources/TenantResource/Pages/ListTenants.php` | When triage filters are active, append arrival context and return-state data to the existing tenant-open affordance without changing generic list behavior |
### Phase C — Render The Tenant-Dashboard Continuity Block
**Goal**: Surface arrival reason, next step, and return path at the top of the tenant dashboard with no dashboard redesign.
| Step | File | Change |
|------|------|--------|
| C.1 | `apps/platform/app/Filament/Widgets/Tenant/TenantTriageArrivalContinuity.php` | Add a non-lazy, full-width widget that resolves optional arrival context from the current request and current tenant scope |
| C.2 | `apps/platform/resources/views/filament/widgets/tenant/triage-arrival-continuity.blade.php` | Render a compact continuity block with bounded title, reason, current-truth boundary, next-step CTA, and return link |
| C.3 | `apps/platform/app/Filament/Pages/TenantDashboard.php` | Register the continuity widget first in the dashboard widget stack so it appears before Recovery Readiness and deeper tenant widgets |
### Phase D — Reuse Existing Deep-Link Continuity And RBAC Degradation
**Goal**: Keep deeper backup and restore surfaces authoritative while ensuring next-step navigation stays truthful under capability limits.
| Step | File | Change |
|------|------|--------|
| D.1 | `apps/platform/app/Support/PortfolioTriage/PortfolioArrivalContextResolver.php` | Map concern families to existing backup-set or restore-run targets and preserve current `backup_health_reason` and `recovery_posture_reason` params for downstream continuity |
| D.2 | `apps/platform/app/Support/Workspaces/WorkspaceOverviewBuilder.php` patterns reused in new resolver or helper | Reuse existing disabled-target semantics (`disabled`, `helper_text`, null `url`) so unavailable next-step guidance never looks actionable |
| D.3 | `apps/platform/app/Filament/Resources/BackupSetResource/Pages/ListBackupSets.php`, `apps/platform/app/Filament/Resources/RestoreRunResource/Pages/ListRestoreRuns.php`, and `apps/platform/app/Filament/Resources/RestoreRunResource/Pages/ViewRestoreRun.php` | Keep current subheading continuity behavior unchanged and verify the new dashboard continuity flows into these existing destinations without replacing them |
### Phase E — Preserve Return-To-Portfolio Flow
**Goal**: Allow operators to resume the originating portfolio triage route without reconstructing filters or context manually.
| Step | File | Change |
|------|------|--------|
| E.1 | `apps/platform/app/Support/PortfolioTriage/PortfolioArrivalContextResolver.php` | Build return targets for workspace overview and tenant-registry triage using bounded route names plus allowlisted filter and sort query params |
| E.2 | `apps/platform/app/Filament/Resources/TenantResource/Pages/ListTenants.php` | Reuse the existing query-driven triage filter contract on re-entry; no new persistence or session-backed return logic |
| E.3 | `apps/platform/app/Filament/Widgets/Tenant/TenantTriageArrivalContinuity.php` | Render the return link only when valid arrival context exists; generic tenant sessions remain calm |
### Phase F — Regression Protection And Verification
**Goal**: Prove continuity rendering, route preservation, and RBAC-safe degradation without regressing existing drilldowns.
| Step | File | Change |
|------|------|--------|
| F.1 | `apps/platform/tests/Feature/Filament/WorkspaceOverviewArrivalContextTest.php` | Add focused coverage for workspace overview backup-absent and recovery-evidence drilldowns carrying arrival context into tenant-level destinations |
| F.2 | `apps/platform/tests/Feature/Filament/TenantRegistryArrivalContextTest.php` | Add focused coverage for filtered tenant-registry triage carrying arrival context and preserving return filters and sort |
| F.3 | `apps/platform/tests/Feature/Filament/TenantDashboardArrivalContextTest.php` | Add dashboard-focused coverage for continuity rendering, generic-session suppression, stale-context honesty, and RBAC-safe next-step degradation |
| F.4 | `apps/platform/tests/Feature/Filament/WorkspaceOverviewContentTest.php` and `apps/platform/tests/Feature/Filament/TenantRegistryRecoveryTriageTest.php` | Extend legacy route-preservation regressions so existing non-dashboard destinations remain authoritative when current routing chooses them |
| F.5 | `apps/platform/tests/Feature/Filament/TenantDashboardArrivalContextPerformanceTest.php` | Add query-shape and request-local resolution coverage that guards against new N+1 behavior and repeated arrival-context resolution on tenant-dashboard renders while overview and registry changes remain URL-only |
| F.6 | `apps/platform/tests/Feature/Filament/BackupSetListContinuityTest.php`, `apps/platform/tests/Feature/Filament/RestoreRunListContinuityTest.php`, `apps/platform/tests/Feature/Rbac/TenantDashboardArrivalContextVisibilityTest.php`, and targeted Sail/Pest/Pint runs | Keep current continuity surfaces green and run the complete arrival-context verification pack across new and existing regressions |
## Key Design Decisions
### D-001 — One bounded token beats scattered dashboard query params
The spec explicitly prefers one small arrival-context abstraction. A single versioned base64url token keeps tenant-dashboard arrival state coherent and removable without proliferating ad hoc query params.
### D-002 — Existing downstream continuity params remain authoritative
Backup-set and restore-run destinations already explain why the operator landed there via `backup_health_reason` and `recovery_posture_reason`. The new feature should feed those surfaces, not replace them.
### D-003 — The tenant dashboard should become continuity-aware through an additive widget, not a page rewrite
The dashboard already uses non-lazy tenant banner widgets. Reusing that pattern satisfies the placement requirement while keeping the change narrow and easy to remove.
### D-004 — Current tenant truth must remain separate from arrival reason
Arrival context explains why the operator came, but current posture is still recomputed from existing tenant truth. This separation is the main safeguard against stale or misleading continuity copy.
### D-005 — Return context should reuse existing tenant-registry query semantics
`ListTenants` already knows how to sanitize and reapply `backup_posture`, `recovery_evidence`, and `triage_sort`. Reusing that contract avoids session-backed state or a broader navigation system.
## Risk Assessment
| Risk | Impact | Likelihood | Mitigation |
|------|--------|------------|------------|
| Arrival token becomes stale or hand-edited and shows misleading copy | High | Medium | Strict allowlist decode, bind the token to current tenant and workspace scope, and degrade to the generic dashboard on invalid or unsupported input |
| Existing workspace or registry drilldown routes regress | High | Medium | Only append continuity context to existing URLs and preserve existing destination kinds, labels, helper text, and regression tests |
| Return links drop triage filters and collapse into generic browsing | Medium | Medium | Reuse `ListTenants` triage query contract exactly and add explicit filtered-registry return tests |
| Next-step CTA overpromises access | High | Medium | Reuse existing capability-aware disabled-destination semantics and add RBAC-limited dashboard arrival tests |
| Continuity block adds noise to generic sessions | Medium | Low | Render only when arrival context resolves successfully; no token means no block, no return affordance |
## Test Strategy
- Add focused unit tests for token encoding, malformed-token handling, allowlisted decode behavior, and resolver scope validation in `tests/Unit/Support/PortfolioTriage/PortfolioArrivalContextTokenTest.php` and `tests/Unit/Support/PortfolioTriage/PortfolioArrivalContextResolverTest.php`.
- Add focused Filament feature tests that cover workspace overview backup-absent and recovery-unvalidated drilldowns into the tenant dashboard with the continuity block visible.
- Add focused tenant-registry triage tests that prove triage filters and sort survive a tenant-open and a return-to-registry action.
- Add tenant-dashboard arrival tests that cover backup-absent, backup-stale, backup-degraded, recovery-unvalidated, recovery-weakened, stale-context honesty, multiple-concern wording, generic-session suppression, and RBAC-safe CTA degradation.
- Add query-shape regression coverage in `tests/Feature/Filament/TenantDashboardArrivalContextPerformanceTest.php` so arrival-context resolution stays request-local and does not introduce new N+1 behavior on tenant-dashboard renders; workspace overview and tenant-registry changes stay URL-only and are protected through their existing functional regressions rather than a separate performance suite.
- Keep existing `WorkspaceOverviewContentTest`, `TenantRegistryRecoveryTriageTest`, `BackupSetListContinuityTest`, and `RestoreRunListContinuityTest` green so source-route preservation and deeper continuity surfaces remain authoritative.
- No relation managers or destructive actions are added in this slice. Covered Filament surfaces are `WorkspaceOverview`, `ListTenants`, `TenantDashboard`, `ListBackupSets`, `ListRestoreRuns`, and `ViewRestoreRun`.
- Run the minimum focused Sail pack before completion: `cd apps/platform && ./vendor/bin/sail artisan test --compact` with the unit tests, the new arrival-context feature tests, the existing workspace overview and tenant-registry regressions, the backup or restore continuity regressions, the RBAC arrival-visibility test, the performance regression, then run Pint with `cd apps/platform && ./vendor/bin/sail bin pint --dirty --format agent`.
## Complexity Tracking
| Violation | Why Needed | Simpler Alternative Rejected Because |
|-----------|------------|-------------------------------------|
| New request-scoped arrival-context contract and token codec | Workspace overview and tenant-registry triage already know why a tenant should open, but the dashboard currently loses that intent at the navigation boundary | Referrer-only inference is unreliable, scattered query params would sprawl across surfaces, and persisted triage sessions would add unnecessary storage and lifecycle cost |
## Proportionality Review
- **Current operator problem**: Operators arrive on the correct tenant but lose the portfolio triage reason, next step, and return path as soon as the tenant dashboard loads.
- **Existing structure is insufficient because**: Existing workspace attention items, summary metrics, and tenant-registry filters already compute concern family and destination choice, but none of them preserve that intent into the tenant dashboard render.
- **Narrowest correct implementation**: Add one small request-scoped arrival-context contract plus a bounded token codec and resolver, then render one additive dashboard continuity block using existing backup or restore destinations for deeper follow-up.
- **Ownership cost created**: One small support namespace, one additive dashboard widget or view, small URL propagation changes, and focused regression tests across overview, registry, and dashboard surfaces.
- **Alternative intentionally rejected**: Persisted triage sessions, a new recovery-confidence model, and a broad breadcrumb or navigation framework were rejected because they create new truth or new workflow machinery for a narrow continuity problem.
- **Release truth**: Current-release truth. The feature hardens an existing operator workflow without adding new posture semantics or persistence.

View File

@ -1,83 +0,0 @@
# Quickstart: Portfolio Triage Arrival Context
## Goal
Implement a small continuity layer that preserves why a tenant was opened from portfolio triage, what the operator should do next, and how to return to the portfolio flow, without adding persistence or new posture logic.
## Implementation Sequence
1. Add the request-scoped arrival-context core.
- Create `PortfolioArrivalContext`, `PortfolioArrivalContextToken`, and `PortfolioArrivalContextResolver` under `apps/platform/app/Support/PortfolioTriage/`.
- Add focused unit coverage for the token and resolver seam under `apps/platform/tests/Unit/Support/PortfolioTriage/`.
- Reuse the existing base64url token pattern already used elsewhere in the repo.
2. Emit arrival tokens from portfolio sources.
- Update workspace overview drilldown builders in `apps/platform/app/Support/Workspaces/WorkspaceOverviewBuilder.php`.
- Update tenant-registry tenant-open URLs when triage filters are active in `apps/platform/app/Filament/Resources/TenantResource.php` and `apps/platform/app/Filament/Resources/TenantResource/Pages/ListTenants.php`.
3. Render continuity on the tenant dashboard.
- Add `TenantTriageArrivalContinuity` under `apps/platform/app/Filament/Widgets/Tenant/`.
- Add its Blade view under `apps/platform/resources/views/filament/widgets/tenant/`.
- Register the widget first in `apps/platform/app/Filament/Pages/TenantDashboard.php`.
4. Reuse deeper continuity surfaces instead of replacing them.
- Keep `backup_health_reason` on backup-set destinations.
- Keep `recovery_posture_reason` on restore-run list or detail destinations.
- Keep subheading continuity in `ListBackupSets`, `ListRestoreRuns`, and `ViewRestoreRun` intact.
5. Add regression coverage.
- Add workspace overview arrival-context tests.
- Add tenant-registry filtered return-path tests.
- Add tenant-dashboard rendering and RBAC degradation tests.
- Add tenant-dashboard performance coverage so arrival-context resolution stays request-local and query-safe.
- Keep existing workspace overview content, tenant-registry recovery triage, and backup or restore continuity regressions green.
## Suggested Test Files
- `apps/platform/tests/Unit/Support/PortfolioTriage/PortfolioArrivalContextTokenTest.php`
- `apps/platform/tests/Unit/Support/PortfolioTriage/PortfolioArrivalContextResolverTest.php`
- `apps/platform/tests/Feature/Filament/WorkspaceOverviewArrivalContextTest.php`
- `apps/platform/tests/Feature/Filament/TenantRegistryArrivalContextTest.php`
- `apps/platform/tests/Feature/Filament/TenantDashboardArrivalContextTest.php`
- `apps/platform/tests/Feature/Filament/TenantDashboardArrivalContextPerformanceTest.php`
- `apps/platform/tests/Feature/Rbac/TenantDashboardArrivalContextVisibilityTest.php`
- `apps/platform/tests/Feature/Filament/BackupSetListContinuityTest.php`
- `apps/platform/tests/Feature/Filament/RestoreRunListContinuityTest.php`
## Existing Regression Suites To Keep Green
- `apps/platform/tests/Feature/Filament/WorkspaceOverviewContentTest.php`
- `apps/platform/tests/Feature/Filament/TenantRegistryRecoveryTriageTest.php`
## Minimum Verification Commands
Run all commands through Sail from `apps/platform`.
```bash
cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Unit/Support/PortfolioTriage/PortfolioArrivalContextTokenTest.php
cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Unit/Support/PortfolioTriage/PortfolioArrivalContextResolverTest.php
cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Filament/WorkspaceOverviewArrivalContextTest.php
cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Filament/TenantRegistryArrivalContextTest.php
cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Filament/TenantDashboardArrivalContextTest.php
cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Filament/TenantDashboardArrivalContextPerformanceTest.php
cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Rbac/TenantDashboardArrivalContextVisibilityTest.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/TenantRegistryRecoveryTriageTest.php
cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Filament/BackupSetListContinuityTest.php
cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Filament/RestoreRunListContinuityTest.php
cd apps/platform && ./vendor/bin/sail bin pint --dirty --format agent
```
## Manual Acceptance Checklist
1. Open a flagged tenant from workspace overview backup attention and verify the tenant dashboard explains the backup concern before deeper widgets.
2. Open a flagged tenant from workspace overview recovery attention and verify the dashboard explains the recovery concern and recommended next action.
3. Open a tenant from filtered registry triage and verify the return link preserves filters and sort.
4. Open the tenant dashboard directly without any arrival token and verify no continuity block appears.
5. Use a role with limited destination access and verify the continuity block stays truthful without exposing a broken CTA.
## Deployment Notes
- No migrations are required.
- No new assets are expected. Existing Filament asset deployment behavior remains unchanged.
- The feature should degrade cleanly if old or malformed links reach the tenant dashboard.

View File

@ -1,79 +0,0 @@
# Research: Portfolio Triage Arrival Context
## Decision: Reuse existing workspace `reason_context` and destination payloads as the source of tenant-arrival intent
### Rationale
`WorkspaceOverviewBuilder` already emits bounded `reason_context` payloads for backup-health and recovery-evidence attention items, along with capability-aware destination metadata and supporting claim-boundary copy. Reusing that structure preserves the current truth model and avoids inventing a second concern taxonomy just for navigation continuity.
### Alternatives considered
- Recompute arrival reason solely from tenant state on the destination page: rejected because it loses the specific reason the operator acted on and cannot preserve source-surface intent.
- Persist portfolio triage sessions: rejected because the continuity need is request-scoped and does not justify new storage or lifecycle overhead.
## Decision: Carry tenant-dashboard arrival state in one bounded, versioned base64url token
### Rationale
The spec explicitly prefers one small arrival-context abstraction over scattered ad hoc query params. The codebase already uses a versioned base64url token pattern for resume state, which provides deterministic encoding, bookmark safety, and graceful decode-to-null fallback without new persistence. The token will carry only allowlisted concern and return-routing metadata; current tenant truth remains re-derived on arrival.
### Alternatives considered
- Infer continuity from the HTTP referrer: rejected because the spec forbids referrer-only inference and browser history is not reliable enough for operator-critical guidance.
- Add many plain dashboard query params such as `source`, `family`, `state`, `return_url`, and `return_filters`: rejected because that spreads continuity semantics across multiple surfaces and increases drift risk.
- Use a persisted triage session key: rejected because it adds new persistence for a problem that can be handled safely in the request.
## Decision: Keep existing backup and restore continuity query params on deeper destinations
### Rationale
`ListBackupSets`, `ListRestoreRuns`, and `ViewRestoreRun` already render bounded explanatory subheadings from `backup_health_reason` and `recovery_posture_reason`, and those behaviors are covered by existing regression tests. The new arrival feature should hand operators to those surfaces intact instead of replacing them with a second downstream continuity system.
### Alternatives considered
- Replace all downstream continuity with the new token: rejected because it would duplicate already-working continuity behavior and expand the scope well beyond tenant-dashboard arrival.
- Drop downstream continuity once the tenant dashboard receives an arrival block: rejected because operators still need semantic confirmation after following the next-step CTA.
## Decision: Render arrival continuity as the first non-lazy tenant dashboard widget
### Rationale
The tenant dashboard is currently composed from widgets, and the codebase already has a non-lazy tenant-banner pattern for high-priority contextual messaging. A dedicated first widget or section satisfies the placement requirement, stays additive, and avoids a dashboard view rewrite.
### Alternatives considered
- Rewrite the dashboard page layout or publish a custom page view: rejected because the spec explicitly avoids a dashboard redesign and the existing widget stack already supports this placement.
- Inject continuity into existing widgets like Recovery Readiness or Needs Attention: rejected because arrival continuity is a separate concern that should appear before the operator interprets domain widgets.
## Decision: Reuse existing query-driven tenant-registry triage state for the return path
### Rationale
`ListTenants` already sanitizes and reapplies `backup_posture`, `recovery_evidence`, and `triage_sort` from the query string on mount. Using the same allowlisted query contract for return links preserves operator flow without session state, back-button dependence, or a new navigation framework.
### Alternatives considered
- Store the previous URL in session and link back to it blindly: rejected because the spec wants an explicit, meaningful portfolio return path rather than browser-history dependence.
- Always return to an unfiltered tenant registry: rejected because it breaks multi-tenant triage continuity and forces the operator to reconstruct the filtered slice manually.
## Decision: Use existing capability-aware destination degradation rules for next-step CTAs
### Rationale
`WorkspaceOverviewBuilder` already emits disabled destinations with helper text when the current user cannot access the target surface. Reusing that pattern keeps next-step guidance truthful and consistent with the rest of the product under RBAC restrictions.
### Alternatives considered
- Hide next-step guidance entirely when access is limited: rejected because the operator still needs to understand what the normal next step would be and why it is unavailable.
- Leave the CTA visible and let it fail on click: rejected because the spec explicitly forbids misleading next-step guidance.
## Decision: Distinguish arrival reason from current tenant truth on the dashboard
### Rationale
The spec requires continuity copy to preserve why the operator arrived while still showing the latest truth if posture changed before the page loaded. The continuity block therefore needs two separate concepts: arrival reason and current-truth boundary. This keeps stale arrival context from becoming a false current claim.
### Alternatives considered
- Treat arrival reason as the current tenant posture until the next refresh: rejected because it can overstate or understate the latest truth.
- Ignore stale arrival context completely once current state changes: rejected because it breaks the operator's understanding of why the tenant was opened in the first place.

View File

@ -1,196 +0,0 @@
title + explanation + exactly 1 CTA, and tables provide search/sort/filters for core dimensions.
# Feature Specification: Portfolio Triage Arrival Context
**Feature Branch**: `[187-portfolio-triage-arrival-context]`
**Created**: 2026-04-09
**Status**: Draft
**Input**: User description: "Spec 187 - Portfolio Triage Arrival Context"
## Spec Scope Fields *(mandatory)*
- **Scope**: workspace
- **Primary Routes**: `/admin/w/{workspace}`, `/admin/tenants`, `/admin/t/{tenant}`, plus existing concern-specific tenant routes such as `/admin/t/{tenant}/backup-sets`, `/admin/t/{tenant}/restore-runs`, and `/admin/t/{tenant}/restore-runs/{record}` when current routing already chooses them
- **Data Ownership**: Workspace overview and tenant-registry triage remain derived views over existing tenant-owned backup-health and restore-history truth scoped to the active workspace. Arrival context stays request-scoped and non-persisted; no new stored workflow state is introduced.
- **RBAC**: Workspace membership remains required on portfolio surfaces, and tenant membership remains required on `/admin/t/{tenant}` and deeper tenant surfaces. Non-members continue to receive deny-as-not-found semantics. Members who can see portfolio posture but cannot open a deeper backup or restore destination must still receive truthful arrival copy and only actionable next steps they can actually use.
## 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 recovery attention and summary metrics | Workspace summary / attention surface | Explicit card or stat CTA to the existing best destination | forbidden | Helper copy within the card or stat only | none | `/admin/w/{workspace}` | Existing chosen destination: `/admin/tenants`, `/admin/t/{tenant}`, `/admin/t/{tenant}/backup-sets`, or `/admin/t/{tenant}/restore-runs` | Active workspace, visible tenant label when singular, backup vs recovery family | Workspace recovery attention / flagged tenant | Why this tenant needs follow-up stays visible before leaving the portfolio surface | existing widget pattern |
| Tenant registry recovery triage list | List-first directory / triage view | Existing tenant-open affordance with preserved triage context | allowed under the existing list contract | Existing filters, sort controls, and open affordance only | none | `/admin/tenants` | `/admin/t/{tenant}` | Active workspace visibility, backup posture, recovery evidence, triage sort and filters | Tenants / Tenant | Backup posture and recovery evidence stay separate while preserving which concern triggered the open action | existing triage list pattern |
| Tenant dashboard arrival continuity block | Embedded dashboard callout | Explicit next-step CTA plus explicit return-to-portfolio link inside the continuity block | forbidden | Inline secondary link and compact helper copy inside the block | none | `/admin/t/{tenant}` | Existing next-step tenant surfaces such as `/admin/t/{tenant}/backup-sets`, `/admin/t/{tenant}/restore-runs`, or `/admin/t/{tenant}/restore-runs/{record}` | Workspace context, tenant context, arrival source label, concern family, concern state, and current-truth boundary | Tenant dashboard / triage arrival | Why the operator arrived, which concern triggered the open, what to do next, and how to return | additive continuity block |
## 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 recovery attention and summary metrics | Workspace operator | Portfolio summary and attention surface | Which visible tenant needs review next, and why is it being escalated? | Tenant label, bounded concern headline, supporting reason, destination label | Raw scoring inputs, builder internals, low-level reason payloads | concern family, concern state, urgency, destination availability | None; read-only triage surface | Open tenant, choose tenant, or open the existing concern-specific destination | none |
| Tenant registry recovery triage list | Workspace operator | Filtered portfolio list | Which flagged tenant should I open next, and which recovery domain triggered the flag? | Tenant label, backup posture, recovery evidence, triage filters, bounded reason cues | Hidden sort internals, raw query payloads, low-level derivation details | backup posture, recovery evidence, triage urgency, current filter scope | None; read-only triage surface | Open tenant, adjust filters, return to default calm browsing order | none |
| Tenant dashboard arrival continuity block | Workspace operator arriving in tenant context | Embedded arrival and guidance surface | Why was this tenant opened from portfolio triage, what should I do next, and how do I get back? | Arrival reason, source surface, triggering concern, bounded current concern, next-step guidance, return target | Raw reason codes, encoded return payload, low-level route state | arrival reason, concern family, concern state, current-truth divergence, access availability | None; read-only guidance surface | Open recommended next step, return to originating portfolio surface | none |
## Proportionality Review *(mandatory when structural complexity is introduced)*
- **New source of truth?**: no
- **New persisted entity/table/artifact?**: no
- **New abstraction?**: yes
- **New enum/state/reason family?**: no
- **New cross-domain UI framework/taxonomy?**: no
- **Current operator problem**: Portfolio surfaces already identify weak tenants and the relevant domain, but once the operator lands on the tenant dashboard the workflow becomes generic again and the operator must rediscover why the tenant was opened.
- **Existing structure is insufficient because**: Existing workspace attention items, workspace summary metrics, and tenant-registry filters can compute concern family and destination choice, but that triage intent stops at the navigation boundary. The tenant dashboard renders current tenant truth without preserving source, reason, next step, or return path.
- **Narrowest correct implementation**: Reuse existing bounded reason context and current routing decisions inside one lightweight request-scoped arrival-context contract that travels with the drilldown, renders a compact continuity block on the tenant dashboard, and preserves a safe return target.
- **Ownership cost**: A small derived arrival-context abstraction, limited route or query encoding rules, dashboard copy, and focused regression tests across overview, registry, and tenant arrival surfaces.
- **Alternative intentionally rejected**: Persisted triage sessions, a new recovery-confidence model, or a broad breadcrumb or navigation refactor were rejected because they add new truth or new ownership cost when the gap is a last-mile continuity problem.
- **Release truth**: current-release workflow continuity hardening
## User Scenarios & Testing *(mandatory)*
### User Story 1 - Understand Why The Tenant Opened (Priority: P1)
A workspace operator clicks a flagged tenant from workspace recovery triage and needs the tenant destination to immediately explain why this tenant was opened.
**Why this priority**: This is the core workflow gap. If the operator still has to rescan the tenant dashboard to rediscover the concern, the portfolio triage flow loses most of its value.
**Independent Test**: Can be fully tested by opening a tenant dashboard from a workspace overview attention item or filtered tenant-registry triage link and verifying that a top-of-page continuity block names the source surface, concern family, and bounded reason before the operator scans deeper widgets.
**Acceptance Scenarios**:
1. **Given** a workspace overview attention item opens a tenant because backup posture is absent, **When** the tenant dashboard loads, **Then** the arrival block states that the tenant was opened from workspace triage for an absent backup posture concern.
2. **Given** a filtered tenant-registry triage list opens a tenant because recovery evidence is unvalidated, **When** the tenant dashboard loads, **Then** the arrival block states that the tenant was opened from portfolio triage for that recovery-evidence concern rather than presenting a generic tenant entry.
---
### User Story 2 - See The Right Next Action (Priority: P2)
A workspace operator arriving on a tenant because of a backup or recovery concern needs the page to tell them what to do next without deep scanning.
**Why this priority**: Preserving reason without preserving the next action still leaves the operator translating posture into action manually.
**Independent Test**: Can be fully tested by rendering triage arrivals for backup-absent, backup-stale, backup-degraded, recovery-unvalidated, and recovery-weakened cases and verifying that the continuity block recommends the matching existing backup or restore follow-up surface.
**Acceptance Scenarios**:
1. **Given** the operator arrives because backup posture is stale or degraded, **When** the continuity block renders, **Then** the next step points to existing backup-readiness or backup-set follow-up rather than generic tenant browsing language.
2. **Given** the operator arrives because recovery evidence is weakened or unvalidated, **When** the continuity block renders, **Then** the next step points to existing restore-history or flagged restore-run follow-up rather than backup-only guidance.
3. **Given** the operator lacks access to the deeper destination, **When** the continuity block renders, **Then** the guidance stays truthful and does not present an impossible CTA.
---
### User Story 3 - Return To Portfolio Flow (Priority: P2)
A workspace operator triaging multiple tenants needs an explicit way to leave the tenant surface and continue the same portfolio workflow they came from.
**Why this priority**: Triage efficiency degrades if every tenant review ends in a contextless tenant-only browsing session.
**Independent Test**: Can be fully tested by opening a tenant from both workspace overview and filtered tenant-registry triage, then using the return affordance and verifying that the operator lands back on the originating portfolio surface with meaningful workspace, filter, and sort context preserved.
**Acceptance Scenarios**:
1. **Given** the operator opened a tenant from workspace overview attention, **When** they use the return affordance, **Then** they return to the relevant workspace overview context rather than reconstructing it manually.
2. **Given** the operator opened a tenant from a filtered tenant-registry triage list, **When** they use the return affordance, **Then** they return to that filtered registry context instead of an unfiltered tenant directory.
---
### User Story 4 - Stay Honest When Truth Or Access Changes (Priority: P3)
A workspace operator needs continuity copy that preserves arrival reason without overstating the tenant's current recovery truth or implying unavailable actions.
**Why this priority**: Arrival continuity becomes dangerous if stale context, multiple concerns, or RBAC limits cause the product to overclaim recovery truth or promise actions the operator cannot take.
**Independent Test**: Can be fully tested by opening triage arrivals after posture changes, with multiple simultaneous concerns, and under RBAC-limited visibility, then verifying that the arrival block preserves why the operator came while keeping current truth and access boundaries explicit.
**Acceptance Scenarios**:
1. **Given** the tenant concern changed before the page loaded, **When** the operator lands on the tenant dashboard, **Then** the arrival block preserves why they came but does not present the old concern as current fact.
2. **Given** the tenant has both backup and recovery-evidence issues, **When** the operator arrives from one triggering concern, **Then** the arrival block preserves that specific trigger or explicitly says that multiple recovery-related concerns remain visible.
3. **Given** the operator opens the tenant through ordinary navigation without triage context, **When** the tenant dashboard renders, **Then** no portfolio-arrival block or portfolio-return affordance is shown.
### Edge Cases
- A tenant was flagged earlier, but current posture is now healthier by the time the destination loads; the page preserves why the operator arrived while still showing the latest truth.
- A tenant has both backup and recovery-evidence concerns; the continuity layer preserves the specific triggering concern or explicitly signals multiple concerns instead of a vague problem statement.
- A filtered tenant-registry triage view opened the tenant with non-default filters or sort; the return affordance must preserve meaningful triage context rather than dropping the operator into an unfiltered list.
- A user can see the tenant dashboard but cannot access restore history or backup detail pages; the continuity block must not overpromise inaccessible next actions.
- Arrival context is missing, malformed, stale, or intentionally shared without valid auth; the destination fails safely into the normal tenant experience with no misleading triage messaging.
## Requirements *(mandatory)*
This feature introduces no new Microsoft Graph calls, no new background work, no new `OperationRun`, and no new persistence. It is a read-first workflow-continuity slice that carries existing portfolio reason context into tenant arrival without redefining backup health, recovery evidence, or destination semantics.
Authorization spans existing workspace portfolio surfaces and the tenant/admin plane under `/admin/t/{tenant}`. Non-members continue to receive 404 responses. Members who can see high-level posture but cannot open deeper backup or restore destinations must receive truthful degraded guidance rather than impossible CTAs. No new mutation, confirmation flow, or destructive action is introduced.
This slice reuses existing Filament pages, widgets, list surfaces, and concern-specific destinations. UI-FIL-001 is satisfied by keeping the tenant arrival treatment inside existing Filament page and widget primitives or a shared callout primitive rather than introducing a new local status language. UI-NAMING-001 is satisfied by preserving current operator-facing vocabulary such as backup posture, recovery evidence, restore history, and workspace triage across origin surface, arrival block, and next-step links.
Action Surface Contract expectations remain satisfied. Workspace overview and tenant registry keep their existing primary open models. The tenant dashboard receives an additive read-only continuity block with explicit navigation-only links. No redundant View action, no empty action group, and no destructive action is added. UX-001 create, edit, and view-form rules are not materially changed because this feature modifies dashboard and list guidance surfaces rather than form layouts.
Direct mapping from current route alone to a useful arrival message is insufficient because the same tenant dashboard can be opened generically or from several distinct recovery-triage paths. A small request-scoped arrival-context layer is therefore warranted, but it must derive from existing bounded `reason_context`, existing portfolio filters, and existing destination rules rather than becoming a second posture model.
### Functional Requirements
- **FR-187-001**: Portfolio recovery and backup drilldowns that open tenant-level surfaces MUST carry a request-scoped arrival context that identifies source surface, concern family, concern state, bounded operator-facing reason, intended next step, and return target.
- **FR-187-002**: The arrival-context contract MUST support at least the existing concern states `backup absent`, `backup stale`, `backup degraded`, `recovery evidence unvalidated`, and `recovery evidence weakened` without introducing a new posture family.
- **FR-187-003**: Arrival context MUST NOT rely on the HTTP referrer alone and MUST fail safely when missing or invalid by rendering the ordinary tenant destination with no false continuity messaging.
- **FR-187-004**: When `/admin/t/{tenant}` is opened with valid triage arrival context, the tenant dashboard MUST render a compact top-of-page continuity block before deep scanning of tenant widgets begins.
- **FR-187-005**: The continuity block MUST explicitly answer why the operator is here, which concern triggered the drilldown, what next action is recommended, and how to return to the originating portfolio workflow.
- **FR-187-006**: Continuity messaging MUST reuse existing backup-health and recovery-evidence claim boundaries and MUST NOT introduce statements that tenant recovery is broken, proven, or guaranteed unless current product truth already supports that claim elsewhere.
- **FR-187-007**: If the tenant's current posture changed between click time and arrival time, the continuity block MAY preserve why the operator came, but it MUST distinguish arrival reason from current tenant truth and MUST NOT suppress the latest visible posture.
- **FR-187-008**: If multiple recovery-related concerns exist on the same tenant, the continuity layer MUST preserve the triggering concern specifically or clearly state that multiple concerns remain visible; it MUST NOT collapse them into an ambiguous generic warning.
- **FR-187-009**: Recommended next-step guidance MUST remain concern-family-specific. Backup concerns MUST point toward existing backup-readiness or backup-set follow-up, while recovery-evidence concerns MUST point toward existing restore-history or restore-run follow-up.
- **FR-187-010**: If the operator cannot access the deeper next-step destination, the continuity block MUST degrade gracefully with truthful copy or a disabled or absent CTA and MUST NOT imply that an unavailable action can be performed.
- **FR-187-011**: When arrival context exists, the tenant destination MUST display a visible return-to-portfolio affordance that routes back to the originating workspace overview or filtered tenant-registry triage context.
- **FR-187-012**: Return-to-portfolio behavior from a filtered tenant-registry triage flow MUST preserve meaningful filter and sort context required to continue triage, rather than dropping the operator into an unfiltered generic tenant directory.
- **FR-187-013**: Generic tenant browsing sessions MUST remain generic. Opening a tenant through ordinary navigation MUST NOT show triage-specific continuity messaging or portfolio-return affordances.
- **FR-187-014**: Existing destination semantics from workspace overview, summary metrics, and tenant-registry triage MUST be preserved. The continuity layer MUST augment current routing decisions instead of forcing every recovery concern through the tenant dashboard.
- **FR-187-015**: Existing concern-specific tenant destinations that already provide semantically specific continuity, such as restore-history surfaces, MUST remain authoritative when current routing chooses them; this feature MUST NOT replace those destinations with a generic tenant landing.
- **FR-187-016**: Arrival-context encoding MUST be safe for bookmarking and authenticated sharing within the product's existing auth model and MUST degrade simply when the encoded context is stale, malformed, or no longer valid.
- **FR-187-017**: The tenant dashboard MUST remain renderable without workspace-only services during generic sessions. Arrival-context rendering MUST be additive and removable without making normal tenant dashboard rendering depend on portfolio-only state.
- **FR-187-018**: Focused regression coverage MUST prove continuity rendering, concern-specific next-step guidance, return-path preservation, generic-session calmness, posture-change honesty, and RBAC-safe degradation across workspace overview, tenant-registry triage, and tenant dashboard surfaces.
## Assumptions
- Existing workspace overview reason context and tenant-registry triage state already expose or can derive a bounded concern family, concern state, and destination intent without inventing a second concern model.
- Existing restore-history and backup-posture destinations remain the canonical deeper follow-up surfaces for recovery-evidence and backup concerns once the operator leaves the tenant dashboard.
- A safe return target can be encoded as a bounded route plus allowed filter or sort state without creating a persisted triage session.
- The tenant dashboard remains the primary tenant-level arrival surface for single-tenant portfolio drilldowns that do not already land on a more semantically specific destination.
## Dependencies
- Workspace overview attention items and summary metrics remain the source of workspace-level triage reason and destination choice.
- The tenant-registry triage list remains the source of filtered portfolio follow-up context when the operator opens a tenant from `/admin/tenants`.
- The existing tenant dashboard remains the first tenant-level summary surface above Recovery Readiness, Dashboard KPIs, and Needs Attention.
- Existing backup-set and restore-run surfaces remain the canonical next-step destinations for backup and recovery follow-up.
## Out of Scope and Follow-up
- No new backup-health or recovery-evidence computation, state family, or confidence engine.
- No new persistence, tables, migrations, or stored triage workflow state.
- No redesign of `ChooseTenant`, `ManagedTenantsLanding`, or the full tenant dashboard architecture.
- No broad navigation or breadcrumb refactor.
- No full sequential "next affected tenant" workflow beyond preserving the current return path.
- Reasonable follow-up work, if later needed, is a dedicated multi-tenant triage queue once this smaller continuity gap is closed.
## UI Action Matrix *(mandatory when Filament is changed)*
| Surface | Location | Header Actions | Inspect Affordance (List/Table) | Row Actions (max 2 visible) | Bulk Actions (grouped) | Empty-State CTA(s) | View Header Actions | Create/Edit Save+Cancel | Audit log? | Notes / Exemptions |
|---|---|---|---|---|---|---|---|---|---|---|
| Workspace overview recovery attention and summary metrics | `app/Filament/Pages/WorkspaceOverview.php`, `app/Filament/Widgets/Workspace/WorkspaceNeedsAttention.php`, `app/Filament/Widgets/Workspace/WorkspaceSummaryStats.php` | none added | Existing card or stat CTA to the current destination | none | n/a | Existing calm-state messaging remains read-only | n/a | n/a | no | Read-only portfolio surfaces only; no destructive action or new action placement rule is introduced. |
| Tenant registry recovery triage list | `app/Filament/Resources/TenantResource.php`, `app/Filament/Resources/TenantResource/Pages/ListTenants.php` | none added | Existing tenant-open affordance and list-open behavior remain authoritative | Existing tenant-open affordance only; no new mutation | Existing bulk behavior unchanged and out of scope | Existing filter-reset guidance remains | n/a | n/a | no | This feature preserves triage-open context and return context, but does not redesign the tenant list or its destructive surfaces. |
| Tenant dashboard arrival continuity block | `app/Filament/Pages/TenantDashboard.php` plus an additive top-of-page continuity widget or section on the dashboard | none added | Explicit next-step CTA and return link inside the continuity block only | none | n/a | none | n/a | n/a | no | Read-only arrival guidance only. UI-FIL-001 stays satisfied by using existing Filament page or widget primitives. No destructive action is introduced. |
## Key Entities *(include if feature involves data)*
- **Portfolio arrival context**: A request-scoped envelope that carries source surface, triggering concern family and state, bounded reason, recommended next step, and return target for one tenant-opening action.
- **Portfolio source surface**: The workspace overview or filtered tenant-registry triage surface from which the operator initiated the tenant drilldown.
- **Concern focus**: The existing backup-health or recovery-evidence posture that triggered the drilldown and determines which next action should be recommended.
- **Return context**: A bounded portfolio destination and allowed filter or sort state that lets the operator continue triage after reviewing one tenant.
## Success Criteria *(mandatory)*
### Measurable Outcomes
- **SC-001**: In acceptance testing, operators can identify within 10 seconds why a tenant opened from portfolio recovery triage without rescanning the full tenant dashboard.
- **SC-002**: In 100% of tested triage-driven tenant-dashboard arrivals, the top-of-page continuity block names the source surface and the triggering concern family.
- **SC-003**: In 100% of tested backup and recovery concern arrivals, the recommended next action matches the triggering concern family and does not instruct operators to use a destination they cannot access.
- **SC-004**: In 100% of tested filtered-registry arrivals, the return affordance restores meaningful triage context instead of dropping the operator into an unfiltered generic tenant directory.
- **SC-005**: In 100% of tested generic tenant-opening sessions, no triage-specific continuity block or portfolio-return affordance is shown.
- **SC-006**: In 100% of tested stale-context, changed-posture, and RBAC-limited scenarios, arrival messaging remains truthful, preserves the reason for arrival, and introduces no overclaim about recovery truth.

View File

@ -1,249 +0,0 @@
# Tasks: Portfolio Triage Arrival Context
**Input**: Design documents from `/specs/187-portfolio-triage-arrival-context/`
**Prerequisites**: `plan.md`, `spec.md`, `research.md`, `data-model.md`, `contracts/portfolio-triage-arrival-context.logical.openapi.yaml`, `quickstart.md`
**Tests**: Tests are REQUIRED for this feature. Use Pest unit coverage for the arrival-context codec and resolver, Filament feature coverage for workspace overview drilldowns, tenant-registry triage, tenant-dashboard continuity rendering, explicit legacy overview and registry regressions, and existing backup or restore continuity surfaces.
**Operations**: This feature introduces no new `OperationRun`, queue, scheduler, remote call, or audit-log mutation surface. All work stays request-scoped and read-only.
**RBAC**: Existing tenant-context membership and deny-as-not-found behavior remain authoritative. Tasks must preserve 404 responses for non-members, truthful degraded guidance for in-scope operators who cannot open deeper follow-up surfaces, and no new capability strings outside the existing registry.
**Operator Surfaces**: The affected operator surfaces are workspace overview recovery triage, tenant-registry recovery triage, the tenant dashboard arrival surface, and the existing backup-set or restore-run follow-up pages that already own deeper continuity messaging.
**Filament UI Action Surfaces**: No new destructive action, bulk action, or action group is introduced. The tenant dashboard receives only read-only continuity links, and existing tenant-open affordances remain authoritative on workspace overview and tenant registry.
**Filament UI UX-001**: No create, edit, or view-form layouts change. This slice adds an additive dashboard widget and preserves existing page layouts and action placement.
**Badges**: No new badge family is introduced. Existing backup-posture and recovery-evidence semantics remain unchanged.
**Organization**: Tasks are grouped by user story so each story can be implemented and verified independently after the shared arrival-context foundation is in place.
## Phase 1: Setup (Shared Test Infrastructure)
**Purpose**: Prepare reusable test data and helpers for portfolio-arrival scenarios shared across all stories.
- [X] T001 [P] Add stale and degraded backup fixture states in `apps/platform/database/factories/BackupSetFactory.php` and failed, partial, and completed-with-follow-up restore fixture states in `apps/platform/database/factories/RestoreRunFactory.php`
- [X] T002 [P] Create reusable portfolio triage fixtures for workspace, tenant, operator, and generic-session scenarios in `apps/platform/tests/Feature/Concerns/BuildsPortfolioTriageFixtures.php`
**Checkpoint**: Shared fixtures are ready for overview, registry, and tenant-dashboard arrival tests.
---
## Phase 2: Foundational (Blocking Arrival-Context Core)
**Purpose**: Build the single request-scoped arrival-context contract, token codec, and resolver that every story depends on.
**CRITICAL**: No user story work should start before this phase is complete.
- [X] T003 [P] Add encode, decode, unsupported-version, malformed-token, and allowlist regression coverage in `apps/platform/tests/Unit/Support/PortfolioTriage/PortfolioArrivalContextTokenTest.php`
- [X] T004 [P] Add resolver coverage for tenant binding, workspace binding, concern-family allowlists, next-step derivation, disabled-target degradation, and return-target sanitization in `apps/platform/tests/Unit/Support/PortfolioTriage/PortfolioArrivalContextResolverTest.php`
- [X] T005 Create the request-scoped arrival-context value object and versioned token codec in `apps/platform/app/Support/PortfolioTriage/PortfolioArrivalContext.php` and `apps/platform/app/Support/PortfolioTriage/PortfolioArrivalContextToken.php`
- [X] T006 Implement scope validation, concern-to-target mapping, claim-boundary text, and bounded return-target resolution in `apps/platform/app/Support/PortfolioTriage/PortfolioArrivalContextResolver.php`
**Checkpoint**: One authoritative arrival-context seam exists and is covered by focused token and resolver tests.
---
## Phase 3: User Story 1 - Understand Why The Tenant Opened (Priority: P1) MVP
**Goal**: Make tenant arrivals from workspace overview and filtered tenant-registry triage immediately explain why the operator opened this tenant.
**Independent Test**: Open a tenant dashboard from workspace recovery triage and from filtered tenant-registry triage, then verify a top-of-page continuity block names the source surface, concern family, and bounded reason before deeper widgets load.
### Tests for User Story 1
- [X] T007 [P] [US1] Add workspace-overview arrival-token emission coverage for backup-absent and recovery-evidence drilldowns in `apps/platform/tests/Feature/Filament/WorkspaceOverviewArrivalContextTest.php`
- [X] T008 [P] [US1] Add filtered tenant-registry arrival-token emission coverage for triage-driven tenant opens in `apps/platform/tests/Feature/Filament/TenantRegistryArrivalContextTest.php`
- [X] T009 [P] [US1] Add tenant-dashboard continuity rendering coverage for source surface, concern family, and bounded arrival reason in `apps/platform/tests/Feature/Filament/TenantDashboardArrivalContextTest.php`
### Implementation for User Story 1
- [X] T010 [US1] Append bounded arrival tokens to tenant-dashboard targets emitted from workspace recovery attention and recovery summary metrics in `apps/platform/app/Support/Workspaces/WorkspaceOverviewBuilder.php`
- [X] T011 [US1] Append arrival-context and return-state payloads only for triage-driven tenant opens in `apps/platform/app/Filament/Resources/TenantResource.php` and `apps/platform/app/Filament/Resources/TenantResource/Pages/ListTenants.php`
- [X] T012 [US1] Create the tenant-dashboard continuity widget and template in `apps/platform/app/Filament/Widgets/Tenant/TenantTriageArrivalContinuity.php` and `apps/platform/resources/views/filament/widgets/tenant/triage-arrival-continuity.blade.php`
- [X] T013 [US1] Register the arrival continuity widget first in the tenant dashboard widget stack in `apps/platform/app/Filament/Pages/TenantDashboard.php`
**Checkpoint**: User Story 1 is independently functional and every triage-driven tenant arrival explains why the tenant opened.
---
## Phase 4: User Story 2 - See The Right Next Action (Priority: P2)
**Goal**: Make the arrival continuity block recommend the correct existing backup or restore follow-up surface for the triggering concern.
**Independent Test**: Render arrivals for backup-absent, backup-stale, backup-degraded, recovery-unvalidated, and recovery-weakened concerns and verify the continuity block recommends the matching existing follow-up surface while staying truthful when access is limited.
### Tests for User Story 2
- [X] T014 [P] [US2] Extend tenant-dashboard continuity coverage for backup-vs-restore next-step guidance and disabled-CTA degradation in `apps/platform/tests/Feature/Filament/TenantDashboardArrivalContextTest.php`
- [X] T015 [P] [US2] Extend downstream continuity regressions for arrival-driven `backup_health_reason` and `recovery_posture_reason` behavior in `apps/platform/tests/Feature/Filament/BackupSetListContinuityTest.php` and `apps/platform/tests/Feature/Filament/RestoreRunListContinuityTest.php`
### Implementation for User Story 2
- [X] T016 [US2] Derive concern-family-specific next-step targets, downstream reason params, and disabled helper text in `apps/platform/app/Support/PortfolioTriage/PortfolioArrivalContextResolver.php`
- [X] T017 [US2] Render concern-specific next-step labels, claim boundaries, and disabled follow-up guidance in `apps/platform/app/Filament/Widgets/Tenant/TenantTriageArrivalContinuity.php` and `apps/platform/resources/views/filament/widgets/tenant/triage-arrival-continuity.blade.php`
- [X] T018 [US2] Preserve existing backup and restore continuity ownership while accepting arrival-driven follow-up params in `apps/platform/app/Filament/Resources/BackupSetResource/Pages/ListBackupSets.php`, `apps/platform/app/Filament/Resources/RestoreRunResource/Pages/ListRestoreRuns.php`, and `apps/platform/app/Filament/Resources/RestoreRunResource/Pages/ViewRestoreRun.php`
**Checkpoint**: User Story 2 is independently functional and the arrival block tells the operator the correct next action for each concern family.
---
## Phase 5: User Story 3 - Return To Portfolio Flow (Priority: P2)
**Goal**: Let operators leave the tenant surface and resume the same workspace or tenant-registry triage flow they came from.
**Independent Test**: Open a tenant from workspace overview and from filtered tenant-registry triage, use the return affordance, and verify the originating workspace or filtered registry context is restored.
### Tests for User Story 3
- [X] T019 [P] [US3] Add workspace-overview return-target coverage for tenant-dashboard arrivals in `apps/platform/tests/Feature/Filament/WorkspaceOverviewArrivalContextTest.php`
- [X] T020 [P] [US3] Add filtered tenant-registry return-state coverage for preserved `backup_posture`, `recovery_evidence`, and `triage_sort` values in `apps/platform/tests/Feature/Filament/TenantRegistryArrivalContextTest.php`
- [X] T021 [P] [US3] Add tenant-dashboard return-affordance rendering and navigation coverage in `apps/platform/tests/Feature/Filament/TenantDashboardArrivalContextTest.php`
### Implementation for User Story 3
- [X] T022 [US3] Build bounded workspace-overview and tenant-registry return targets in `apps/platform/app/Support/PortfolioTriage/PortfolioArrivalContextResolver.php`
- [X] T023 [US3] Reuse the existing triage-filter sanitizers when emitting and restoring return-state query params in `apps/platform/app/Filament/Resources/TenantResource.php` and `apps/platform/app/Filament/Resources/TenantResource/Pages/ListTenants.php`
- [X] T024 [US3] Render the return-to-portfolio affordance only for valid arrival contexts in `apps/platform/app/Filament/Widgets/Tenant/TenantTriageArrivalContinuity.php` and `apps/platform/resources/views/filament/widgets/tenant/triage-arrival-continuity.blade.php`
**Checkpoint**: User Story 3 is independently functional and operators can continue portfolio triage without reconstructing context manually.
---
## Phase 6: User Story 4 - Stay Honest When Truth Or Access Changes (Priority: P3)
**Goal**: Keep arrival continuity truthful when posture changes, multiple concerns coexist, the token is invalid, or the operator lacks access to the deeper follow-up surface.
**Independent Test**: Open triage-driven arrivals after posture changes, with multiple concerns, without any arrival token, with malformed tokens, and under RBAC-limited access; verify the page preserves why the operator came while keeping current truth and access limits explicit.
### Tests for User Story 4
- [X] T025 [P] [US4] Extend tenant-dashboard continuity coverage for stale-context honesty, multiple-concern wording, invalid-token suppression, and generic-session calmness in `apps/platform/tests/Feature/Filament/TenantDashboardArrivalContextTest.php`
- [X] T026 [P] [US4] Add positive and negative RBAC arrival-visibility coverage for enabled follow-up links, degraded guidance, 403 capability denials, and 404 deny-as-not-found semantics in `apps/platform/tests/Feature/Rbac/TenantDashboardArrivalContextVisibilityTest.php`
### Implementation for User Story 4
- [X] T027 [US4] Differentiate arrival reason from current tenant truth, surface multi-concern wording, and fail closed on stale or malformed tokens in `apps/platform/app/Support/PortfolioTriage/PortfolioArrivalContextResolver.php`
- [X] T028 [US4] Keep the continuity block additive, suppress it for generic or invalid sessions, and render truthful current-truth deltas in `apps/platform/app/Filament/Widgets/Tenant/TenantTriageArrivalContinuity.php`, `apps/platform/resources/views/filament/widgets/tenant/triage-arrival-continuity.blade.php`, and `apps/platform/app/Filament/Pages/TenantDashboard.php`
**Checkpoint**: User Story 4 is independently functional and continuity remains truthful across stale truth, multiple concerns, generic sessions, and RBAC limits.
---
## Phase 7: Polish & Cross-Cutting Concerns
**Purpose**: Final alignment, formatting, and focused verification across all stories.
- [X] T029 [P] Extend legacy source-route preservation regressions in `apps/platform/tests/Feature/Filament/WorkspaceOverviewContentTest.php` and `apps/platform/tests/Feature/Filament/TenantRegistryRecoveryTriageTest.php` so existing non-dashboard destinations remain authoritative when current routing chooses them
- [X] T030 [P] Add query-shape and request-local resolution regression coverage in `apps/platform/tests/Feature/Filament/TenantDashboardArrivalContextPerformanceTest.php`
- [X] T031 [P] Review operator-facing continuity copy and `Verb + Object` link labels in `apps/platform/app/Support/PortfolioTriage/PortfolioArrivalContextResolver.php`, `apps/platform/app/Filament/Widgets/Tenant/TenantTriageArrivalContinuity.php`, `apps/platform/resources/views/filament/widgets/tenant/triage-arrival-continuity.blade.php`, `apps/platform/app/Filament/Resources/BackupSetResource/Pages/ListBackupSets.php`, and `apps/platform/app/Filament/Resources/RestoreRunResource/Pages/ListRestoreRuns.php`
- [X] T032 [P] Run formatting for touched files with `cd apps/platform && ./vendor/bin/sail bin pint --dirty --format agent` using `specs/187-portfolio-triage-arrival-context/quickstart.md`
- [X] T033 Run the focused verification pack from `specs/187-portfolio-triage-arrival-context/quickstart.md` against `apps/platform/tests/Unit/Support/PortfolioTriage/PortfolioArrivalContextTokenTest.php`, `apps/platform/tests/Unit/Support/PortfolioTriage/PortfolioArrivalContextResolverTest.php`, `apps/platform/tests/Feature/Filament/WorkspaceOverviewArrivalContextTest.php`, `apps/platform/tests/Feature/Filament/TenantRegistryArrivalContextTest.php`, `apps/platform/tests/Feature/Filament/TenantDashboardArrivalContextTest.php`, `apps/platform/tests/Feature/Filament/TenantDashboardArrivalContextPerformanceTest.php`, `apps/platform/tests/Feature/Filament/WorkspaceOverviewContentTest.php`, `apps/platform/tests/Feature/Filament/TenantRegistryRecoveryTriageTest.php`, `apps/platform/tests/Feature/Filament/BackupSetListContinuityTest.php`, `apps/platform/tests/Feature/Filament/RestoreRunListContinuityTest.php`, and `apps/platform/tests/Feature/Rbac/TenantDashboardArrivalContextVisibilityTest.php`
---
## Dependencies & Execution Order
### Phase Dependencies
- **Setup (Phase 1)**: Starts immediately and prepares shared fixtures.
- **Foundational (Phase 2)**: Depends on Setup and blocks all user stories until the arrival-context contract, token, and resolver exist.
- **User Story 1 (Phase 3)**: Starts after Foundational and is the recommended MVP slice.
- **User Story 2 (Phase 4)**: Starts after User Story 1 because the next-step CTA extends the continuity widget and arrival-token seams established there.
- **User Story 3 (Phase 5)**: Starts after User Story 1 because the return affordance depends on the same arrival-context seams but can proceed in parallel with User Story 2 once the base widget exists.
- **User Story 4 (Phase 6)**: Starts after User Stories 2 and 3 because honesty and degradation rules need the final next-step and return-path behaviors in place.
- **Polish (Phase 7)**: Starts after all desired user stories are complete.
### User Story Dependencies
- **US1**: Depends only on the shared arrival-context foundation.
- **US2**: Depends on US1 because concern-specific next-step guidance extends the continuity widget and the emitted arrival tokens.
- **US3**: Depends on US1 because return targets are only meaningful once arrival tokens are already emitted and rendered.
- **US4**: Depends on US2 and US3 because truthful stale-state and RBAC degradation must account for both next-step and return-target behavior.
### Within Each User Story
- Write or extend the story tests first and confirm they fail before implementation is considered complete.
- Land resolver or routing changes before widget copy and view adjustments in the same story.
- Keep each story shippable on its own before moving to the next priority.
### Parallel Opportunities
- `T001` and `T002` can run in parallel during Setup.
- `T003` and `T004` can run in parallel during Foundational work, then `T005` and `T006` can proceed sequentially.
- Within US1, `T007`, `T008`, and `T009` can run in parallel, then `T010`, `T011`, and `T012` can be split before `T013` registers the widget.
- Within US2, `T014` and `T015` can run in parallel, then `T016`, `T017`, and `T018` can be split across contributors.
- Within US3, `T019`, `T020`, and `T021` can run in parallel, then `T022`, `T023`, and `T024` can be split across contributors.
- Within US4, `T025` and `T026` can run in parallel, then `T027` and `T028` can proceed.
- Within Phase 7, `T029` and `T030` can run in parallel, then `T031`, `T032`, and `T033` follow.
---
## Parallel Example: User Story 1
```bash
# User Story 1 tests in parallel
T007 apps/platform/tests/Feature/Filament/WorkspaceOverviewArrivalContextTest.php
T008 apps/platform/tests/Feature/Filament/TenantRegistryArrivalContextTest.php
T009 apps/platform/tests/Feature/Filament/TenantDashboardArrivalContextTest.php
# User Story 1 implementation split
T010 apps/platform/app/Support/Workspaces/WorkspaceOverviewBuilder.php
T011 apps/platform/app/Filament/Resources/TenantResource.php and apps/platform/app/Filament/Resources/TenantResource/Pages/ListTenants.php
T012 apps/platform/app/Filament/Widgets/Tenant/TenantTriageArrivalContinuity.php and apps/platform/resources/views/filament/widgets/tenant/triage-arrival-continuity.blade.php
```
## Parallel Example: User Story 2
```bash
# User Story 2 tests in parallel
T014 apps/platform/tests/Feature/Filament/TenantDashboardArrivalContextTest.php
T015 apps/platform/tests/Feature/Filament/BackupSetListContinuityTest.php and apps/platform/tests/Feature/Filament/RestoreRunListContinuityTest.php
# User Story 2 implementation split
T016 apps/platform/app/Support/PortfolioTriage/PortfolioArrivalContextResolver.php
T017 apps/platform/app/Filament/Widgets/Tenant/TenantTriageArrivalContinuity.php and apps/platform/resources/views/filament/widgets/tenant/triage-arrival-continuity.blade.php
T018 apps/platform/app/Filament/Resources/BackupSetResource/Pages/ListBackupSets.php and apps/platform/app/Filament/Resources/RestoreRunResource/Pages/ListRestoreRuns.php
```
## Parallel Example: User Story 3
```bash
# User Story 3 tests in parallel
T019 apps/platform/tests/Feature/Filament/WorkspaceOverviewArrivalContextTest.php
T020 apps/platform/tests/Feature/Filament/TenantRegistryArrivalContextTest.php
T021 apps/platform/tests/Feature/Filament/TenantDashboardArrivalContextTest.php
# User Story 3 implementation split
T022 apps/platform/app/Support/PortfolioTriage/PortfolioArrivalContextResolver.php
T023 apps/platform/app/Filament/Resources/TenantResource.php and apps/platform/app/Filament/Resources/TenantResource/Pages/ListTenants.php
T024 apps/platform/app/Filament/Widgets/Tenant/TenantTriageArrivalContinuity.php and apps/platform/resources/views/filament/widgets/tenant/triage-arrival-continuity.blade.php
```
---
## Implementation Strategy
### MVP First
1. Complete Phase 1: Setup.
2. Complete Phase 2: Foundational.
3. Complete Phase 3: User Story 1.
4. Validate that triage-driven tenant arrivals now explain why the tenant opened before any deeper dashboard scan is required.
### Incremental Delivery
1. Add User Story 2 to align the arrival block with the correct existing follow-up surface.
2. Add User Story 3 to preserve the operator's return-to-portfolio flow.
3. Add User Story 4 to harden truthfulness, generic-session calmness, and RBAC-safe degradation.
4. Finish with formatting and the focused verification pack.
### Parallel Team Strategy
1. One contributor can prepare fixture and token coverage while another scaffolds the overview, registry, and dashboard feature tests.
2. After US1 lands, one contributor can take next-step mapping while another implements return-target behavior.
3. Rejoin for US4 and Phase 7 so honesty, legacy route preservation, performance protection, formatting, and verification land together.
---
## Notes
- `[P]` tasks touch separate files or can be split without waiting on unfinished work in the same phase.
- `[US1]`, `[US2]`, `[US3]`, and `[US4]` labels map directly to the user stories in `spec.md`.
- The suggested MVP scope is Phase 1 through Phase 3 only.
- This task plan stays within Filament v5 on Livewire v4, makes no panel-provider changes, introduces no globally searchable resource changes, adds no destructive action, and requires no new asset strategy beyond the project's existing `filament:assets` deployment behavior.