diff --git a/apps/platform/app/Filament/Resources/RestoreRunResource.php b/apps/platform/app/Filament/Resources/RestoreRunResource.php index 72e59137..1c9bc11c 100644 --- a/apps/platform/app/Filament/Resources/RestoreRunResource.php +++ b/apps/platform/app/Filament/Resources/RestoreRunResource.php @@ -9,6 +9,7 @@ use App\Filament\Concerns\WorkspaceScopedEnvironmentRoutes; use App\Filament\Resources\RestoreRunResource\Pages; use App\Filament\Resources\RestoreRunResource\Presenters\RestoreRunCreatePresenter; +use App\Filament\Resources\RestoreRunResource\Presenters\RestoreRunDetailPresenter; use App\Jobs\BulkRestoreRunDeleteJob; use App\Jobs\BulkRestoreRunForceDeleteJob; use App\Jobs\BulkRestoreRunRestoreJob; @@ -300,7 +301,7 @@ public static function resolveScopedRecordOrFail(int|string $key): Model { return static::resolveTenantOwnedRecordOrFail( $key, - parent::getEloquentQuery()->withTrashed()->with('backupSet'), + parent::getEloquentQuery()->withTrashed()->with(['backupSet', 'operationRun', 'tenant']), ); } @@ -1671,38 +1672,43 @@ public static function infolist(Schema $schema): Schema { return $schema ->schema([ - Infolists\Components\TextEntry::make('backupSet.name')->label('Backup set'), - Infolists\Components\TextEntry::make('status') - ->badge() - ->formatStateUsing(BadgeRenderer::label(BadgeDomain::RestoreRunStatus)) - ->color(BadgeRenderer::color(BadgeDomain::RestoreRunStatus)) - ->icon(BadgeRenderer::icon(BadgeDomain::RestoreRunStatus)) - ->iconColor(BadgeRenderer::iconColor(BadgeDomain::RestoreRunStatus)), - Infolists\Components\TextEntry::make('counts') - ->label('Counts') - ->state(function (RestoreRun $record): string { - $meta = $record->metadata ?? []; - $total = (int) ($meta['total'] ?? 0); - $succeeded = (int) ($meta['succeeded'] ?? 0); - $failed = (int) ($meta['failed'] ?? 0); - - return sprintf('Total: %d • Applied: %d • Failed items: %d', $total, $succeeded, $failed); - }), - Infolists\Components\TextEntry::make('is_dry_run') - ->label('Dry-run') - ->formatStateUsing(fn ($state) => $state ? 'Yes' : 'No') - ->badge(), - Infolists\Components\TextEntry::make('requested_by'), - Infolists\Components\TextEntry::make('started_at')->dateTime(), - Infolists\Components\TextEntry::make('completed_at')->dateTime(), - Infolists\Components\ViewEntry::make('preview') - ->label('Preview') - ->view('filament.infolists.entries.restore-preview') - ->state(fn (RestoreRun $record): array => static::detailPreviewState($record)), Infolists\Components\ViewEntry::make('results') - ->label('Results') + ->label('Post-execution proof') ->view('filament.infolists.entries.restore-results') - ->state(fn (RestoreRun $record): array => static::detailResultsState($record)), + ->state(fn (RestoreRun $record): array => static::detailResultsState($record)) + ->columnSpanFull(), + Section::make('Technical preview evidence') + ->description('Secondary pre-execution context. The post-execution proof decision above is the operator-facing source of truth.') + ->schema([ + Infolists\Components\ViewEntry::make('preview') + ->hiddenLabel() + ->view('filament.infolists.entries.restore-preview') + ->state(fn (RestoreRun $record): array => static::detailPreviewState($record)), + ]) + ->collapsible() + ->collapsed() + ->columnSpanFull(), + Section::make('Technical record metadata') + ->description('Raw lifecycle fields for audit and support. Use the proof decision above for restore outcome status.') + ->schema([ + Infolists\Components\TextEntry::make('backupSet.name')->label('Backup set'), + Infolists\Components\TextEntry::make('status') + ->label('Record lifecycle') + ->badge() + ->formatStateUsing(fn ($state): string => Str::headline((string) $state)) + ->color('gray'), + Infolists\Components\TextEntry::make('is_dry_run') + ->label('Preview-only flag') + ->formatStateUsing(fn ($state) => $state ? 'Yes' : 'No') + ->badge(), + Infolists\Components\TextEntry::make('requested_by')->placeholder('Not recorded'), + Infolists\Components\TextEntry::make('started_at')->dateTime()->placeholder('—'), + Infolists\Components\TextEntry::make('completed_at')->dateTime()->placeholder('—'), + ]) + ->columns(3) + ->collapsible() + ->collapsed() + ->columnSpanFull(), ]); } @@ -2680,11 +2686,7 @@ private static function detailPreviewState(RestoreRun $record): array */ private static function detailResultsState(RestoreRun $record): array { - return [ - 'results' => is_array($record->results) ? $record->results : [], - 'resultAttention' => static::restoreSafetyResolver()->resultAttentionForRun($record)->toArray(), - 'executionSafetySnapshot' => $record->executionSafetySnapshot(), - ]; + return app(RestoreRunDetailPresenter::class)->forRun($record); } private static function restoreSafetyResolver(): RestoreSafetyResolver diff --git a/apps/platform/app/Filament/Resources/RestoreRunResource/Presenters/RestoreRunDetailPresenter.php b/apps/platform/app/Filament/Resources/RestoreRunResource/Presenters/RestoreRunDetailPresenter.php new file mode 100644 index 00000000..8c4d348f --- /dev/null +++ b/apps/platform/app/Filament/Resources/RestoreRunResource/Presenters/RestoreRunDetailPresenter.php @@ -0,0 +1,628 @@ + + */ + public function forRun(RestoreRun $restoreRun): array + { + $restoreRun->loadMissing(['backupSet', 'operationRun', 'tenant']); + + $attention = $this->restoreSafetyResolver->resultAttentionForRun($restoreRun); + $operationRun = $this->scopedOperationRun($restoreRun); + $operationProof = $this->operationProof($operationRun); + $postRunEvidence = $this->postRunEvidence($restoreRun, $operationRun); + $decision = $this->decision($restoreRun, $attention, $operationProof, $postRunEvidence); + $resultSummary = $this->resultSummary($restoreRun); + $itemOutcomes = $this->itemOutcomes($restoreRun); + $foundationOutcomes = $this->foundationOutcomes($restoreRun); + $itemOutcomeEvidence = $this->itemOutcomeEvidence($itemOutcomes, $resultSummary); + + return [ + 'decision' => $decision, + 'operationProof' => $operationProof, + 'postRunEvidence' => $postRunEvidence, + 'resultSummary' => $resultSummary, + 'itemOutcomeEvidence' => $itemOutcomeEvidence, + 'itemOutcomes' => $itemOutcomes, + 'foundationOutcomes' => $foundationOutcomes, + 'diagnostics' => [ + 'state' => 'collapsed', + 'summary' => 'Diagnostics are secondary. Raw restore payloads, provider request IDs, and low-level execution details stay out of the first decision path.', + 'execution_basis' => $this->executionBasis($restoreRun), + 'items_requiring_attention' => count(array_filter( + $itemOutcomes, + static fn (array $item): bool => (bool) ($item['needs_attention'] ?? false), + )), + ], + 'runContext' => [ + 'backup_set' => $restoreRun->backupSet?->name ?? 'Backup set unavailable', + 'target_environment' => $restoreRun->tenant?->name ?? 'Environment unavailable', + 'requested_by' => filled($restoreRun->requested_by) ? (string) $restoreRun->requested_by : 'Not recorded', + 'started_at' => $restoreRun->started_at?->toDayDateTimeString(), + 'completed_at' => $restoreRun->completed_at?->toDayDateTimeString(), + 'dry_run' => (bool) $restoreRun->is_dry_run, + ], + 'results' => is_array($restoreRun->results) ? $restoreRun->results : [], + 'resultAttention' => $attention->toArray(), + 'executionSafetySnapshot' => $restoreRun->executionSafetySnapshot(), + ]; + } + + /** + * @param array $operationProof + * @param array $postRunEvidence + * @return array + */ + private function decision( + RestoreRun $restoreRun, + RestoreResultAttention $attention, + array $operationProof, + array $postRunEvidence, + ): array { + $state = $this->decisionState($restoreRun, $attention, $operationProof, $postRunEvidence); + + $copy = match ($state) { + 'not_executed' => [ + 'status_label' => 'Not executed', + 'reason' => 'This record proves preview truth, not environment recovery.', + 'impact' => 'No execution proof or post-run evidence exists yet.', + 'primary_next_action' => 'Review preview', + 'primary_next_url' => null, + 'tone' => 'gray', + 'icon' => 'heroicon-m-eye', + ], + 'in_progress' => [ + 'status_label' => 'Execution in progress', + 'reason' => 'Restore execution is currently running.', + 'impact' => 'Results and post-run evidence are not final yet.', + 'primary_next_action' => 'View operation progress', + 'primary_next_url' => $operationProof['url'] ?? null, + 'tone' => 'info', + 'icon' => 'heroicon-m-arrow-path', + ], + 'completed_with_evidence' => [ + 'status_label' => 'Completed with evidence available', + 'reason' => 'Execution proof and post-run evidence are available.', + 'impact' => 'Review evidence before treating this restore as recovery proof.', + 'primary_next_action' => 'Open evidence', + 'primary_next_url' => $postRunEvidence['url'] ?? null, + 'tone' => 'success', + 'icon' => 'heroicon-m-shield-check', + ], + 'needs_review' => [ + 'status_label' => 'Completed with items needing review', + 'reason' => $attention->summary, + 'impact' => 'Review item outcomes before relying on the result.', + 'primary_next_action' => 'Review item outcomes', + 'primary_next_url' => null, + 'tone' => 'warning', + 'icon' => 'heroicon-m-exclamation-triangle', + ], + 'failed' => [ + 'status_label' => 'Restore failed', + 'reason' => 'The restore did not complete successfully.', + 'impact' => 'Some requested changes may not have been applied.', + 'primary_next_action' => 'Review failure details', + 'primary_next_url' => null, + 'tone' => 'danger', + 'icon' => 'heroicon-m-x-circle', + ], + 'blocked_or_cancelled' => [ + 'status_label' => 'Restore blocked / cancelled', + 'reason' => 'Restore did not execute due to cancellation or blocker.', + 'impact' => 'No recovery proof exists.', + 'primary_next_action' => 'Review blocker', + 'primary_next_url' => $operationProof['url'] ?? null, + 'tone' => 'warning', + 'icon' => 'heroicon-m-no-symbol', + ], + default => [ + 'status_label' => 'Completed, recovery proof incomplete', + 'reason' => 'Execution completed, but post-run evidence is not available yet.', + 'impact' => 'Do not treat this restore as verified recovery until evidence has been reviewed.', + 'primary_next_action' => filled($operationProof['url'] ?? null) ? 'Open operation proof' : 'Review proof gap', + 'primary_next_url' => $operationProof['url'] ?? null, + 'tone' => 'warning', + 'icon' => 'heroicon-m-shield-exclamation', + ], + }; + + return [ + 'state' => $state, + ...$copy, + 'question' => 'Was this restore executed safely, and is recovery proof available?', + 'attention_summary' => $attention->summary, + 'primary_cause_family' => RestoreSafetyCopy::primaryCauseFamily($attention->primaryCauseFamily), + 'result_next_action' => RestoreSafetyCopy::primaryNextAction($attention->primaryNextAction), + 'recovery_claim_boundary' => RestoreSafetyCopy::recoveryBoundary($attention->recoveryClaimBoundary), + ]; + } + + /** + * @param array $operationProof + * @param array $postRunEvidence + */ + private function decisionState( + RestoreRun $restoreRun, + RestoreResultAttention $attention, + array $operationProof, + array $postRunEvidence, + ): string { + $status = RestoreRunStatus::fromString((string) $restoreRun->status); + + if ($restoreRun->is_dry_run || in_array($status, [ + RestoreRunStatus::Draft, + RestoreRunStatus::Scoped, + RestoreRunStatus::Checked, + RestoreRunStatus::Previewed, + ], true)) { + return 'not_executed'; + } + + if (in_array($status, [RestoreRunStatus::Queued, RestoreRunStatus::Running], true) + || ($operationProof['state'] ?? null) === 'in_progress') { + return 'in_progress'; + } + + if ($status === RestoreRunStatus::Failed || ($operationProof['outcome'] ?? null) === OperationRunOutcome::Failed->value) { + return 'failed'; + } + + if (in_array($status, [RestoreRunStatus::Cancelled, RestoreRunStatus::Aborted], true) + || ($operationProof['outcome'] ?? null) === OperationRunOutcome::Blocked->value) { + return 'blocked_or_cancelled'; + } + + if (in_array($status, [RestoreRunStatus::Partial, RestoreRunStatus::CompletedWithErrors], true)) { + return 'needs_review'; + } + + if (($postRunEvidence['state'] ?? null) === 'available') { + return 'completed_with_evidence'; + } + + return 'completed_proof_incomplete'; + } + + /** + * @return array + */ + private function operationProof(?OperationRun $operationRun): array + { + if (! $operationRun instanceof OperationRun) { + return [ + 'state' => 'unavailable', + 'label' => 'Operation proof unavailable', + 'url' => null, + 'status' => null, + 'outcome' => null, + 'status_badge' => $this->statusBadge('gray', 'Unavailable', 'heroicon-m-minus-circle'), + 'outcome_badge' => null, + 'identifier' => null, + ]; + } + + $status = (string) $operationRun->status; + $outcome = (string) $operationRun->outcome; + $state = in_array($status, [OperationRunStatus::Queued->value, OperationRunStatus::Running->value], true) + ? 'in_progress' + : 'available'; + + return [ + 'state' => $state, + 'label' => $state === 'in_progress' ? 'Operation proof in progress' : 'Operation proof available', + 'url' => OperationRunLinks::tenantlessView($operationRun), + 'status' => $status, + 'outcome' => $outcome, + 'status_badge' => $this->badge(BadgeDomain::OperationRunStatus, $status), + 'outcome_badge' => $this->badge(BadgeDomain::OperationRunOutcome, $outcome), + 'identifier' => OperationRunLinks::identifier($operationRun), + ]; + } + + /** + * @return array + */ + private function postRunEvidence(RestoreRun $restoreRun, ?OperationRun $operationRun): array + { + if (! $operationRun instanceof OperationRun || ! $this->canViewEvidence($restoreRun)) { + return [ + 'state' => 'unavailable', + 'label' => 'Post-run evidence unavailable', + 'url' => null, + 'status' => null, + 'completeness' => null, + 'status_badge' => $this->statusBadge('gray', 'Unavailable', 'heroicon-m-minus-circle'), + 'completeness_badge' => null, + 'identifier' => null, + ]; + } + + $snapshots = EvidenceSnapshot::query() + ->where('operation_run_id', (int) $operationRun->getKey()) + ->where('workspace_id', (int) $restoreRun->workspace_id) + ->where('managed_environment_id', (int) $restoreRun->managed_environment_id) + ->whereIn('status', [ + EvidenceSnapshotStatus::Active->value, + EvidenceSnapshotStatus::Generating->value, + EvidenceSnapshotStatus::Queued->value, + ]) + ->latest('id') + ->get(); + + $snapshot = $snapshots->firstWhere('status', EvidenceSnapshotStatus::Active->value) + ?? $snapshots->first(); + + if (! $snapshot instanceof EvidenceSnapshot || ! EvidenceSnapshotResource::canView($snapshot)) { + return [ + 'state' => 'unavailable', + 'label' => 'Post-run evidence unavailable', + 'url' => null, + 'status' => null, + 'completeness' => null, + 'status_badge' => $this->statusBadge('gray', 'Unavailable', 'heroicon-m-minus-circle'), + 'completeness_badge' => null, + 'identifier' => null, + ]; + } + + $state = $snapshot->status === EvidenceSnapshotStatus::Active->value ? 'available' : 'in_progress'; + + return [ + 'state' => $state, + 'label' => $state === 'available' ? 'Post-run evidence available' : 'Post-run evidence in progress', + 'url' => EvidenceSnapshotResource::getUrl('view', ['record' => $snapshot], tenant: $restoreRun->tenant), + 'status' => (string) $snapshot->status, + 'completeness' => (string) $snapshot->completeness_state, + 'status_badge' => $this->badge(BadgeDomain::EvidenceSnapshotStatus, (string) $snapshot->status), + 'completeness_badge' => $this->badge(BadgeDomain::EvidenceCompleteness, (string) $snapshot->completeness_state), + 'identifier' => 'Evidence snapshot #'.$snapshot->getKey(), + ]; + } + + private function scopedOperationRun(RestoreRun $restoreRun): ?OperationRun + { + $operationRun = $restoreRun->operationRun; + + if (! $operationRun instanceof OperationRun) { + return null; + } + + if ((int) $operationRun->workspace_id !== (int) $restoreRun->workspace_id + || (int) $operationRun->managed_environment_id !== (int) $restoreRun->managed_environment_id) { + return null; + } + + $user = auth()->user(); + + if (! $user instanceof User || ! Gate::forUser($user)->allows('view', $operationRun)) { + return null; + } + + return $operationRun; + } + + private function canViewEvidence(RestoreRun $restoreRun): bool + { + $user = auth()->user(); + $tenant = $restoreRun->tenant; + + return $user instanceof User + && $tenant !== null + && $user->can(Capabilities::EVIDENCE_VIEW, $tenant); + } + + /** + * @return array + */ + private function resultSummary(RestoreRun $restoreRun): array + { + $metadata = is_array($restoreRun->metadata) ? $restoreRun->metadata : []; + $keys = ['total', 'succeeded', 'failed', 'skipped', 'partial', 'non_applied']; + $available = collect($keys)->contains( + static fn (string $key): bool => array_key_exists($key, $metadata) && is_numeric($metadata[$key]), + ); + + if (! $available) { + return [ + 'available' => false, + 'message' => 'Result summary unavailable', + 'counts' => [], + ]; + } + + $counts = [ + 'requested' => $this->numericCount($metadata, 'total'), + 'applied' => $this->numericCount($metadata, 'succeeded'), + 'failed' => $this->numericCount($metadata, 'failed'), + 'skipped' => $this->numericCount($metadata, 'skipped'), + 'partial' => $this->numericCount($metadata, 'partial'), + 'non_applied' => $this->numericCount($metadata, 'non_applied'), + ]; + + $reviewValues = array_filter([ + $counts['failed'], + $counts['skipped'], + $counts['partial'], + $counts['non_applied'], + ], static fn (?int $value): bool => $value !== null); + + $counts['needs_review'] = $reviewValues === [] ? null : array_sum($reviewValues); + + return [ + 'available' => true, + 'message' => 'Repo-backed result counts from restore metadata.', + 'counts' => $counts, + ]; + } + + /** + * @param list> $itemOutcomes + * @param array $resultSummary + * @return array + */ + private function itemOutcomeEvidence(array $itemOutcomes, array $resultSummary): array + { + if ($itemOutcomes !== []) { + return [ + 'state' => 'available', + 'label' => count($itemOutcomes).' item '.(count($itemOutcomes) === 1 ? 'record' : 'records'), + 'tone' => 'gray', + 'message' => 'Per-item outcome rows are available for review.', + ]; + } + + if (($resultSummary['available'] ?? false) === true) { + return [ + 'state' => 'summary_only', + 'label' => 'Metadata counts only', + 'tone' => 'warning', + 'message' => 'Aggregate restore counts are available, but item-level outcome rows are not stored for this run. Use the OperationRun proof and aggregate counts for follow-up.', + ]; + } + + return [ + 'state' => 'unavailable', + 'label' => 'No item records', + 'tone' => 'gray', + 'message' => 'No aggregate counts or item-level outcome rows are available for this run.', + ]; + } + + /** + * @param array $metadata + */ + private function numericCount(array $metadata, string $key): ?int + { + return array_key_exists($key, $metadata) && is_numeric($metadata[$key]) + ? (int) $metadata[$key] + : null; + } + + /** + * @return list> + */ + private function itemOutcomes(RestoreRun $restoreRun): array + { + $results = is_array($restoreRun->results) ? $restoreRun->results : []; + $items = is_array($results['items'] ?? null) ? array_values($results['items']) : []; + + return collect($items) + ->filter(static fn (mixed $item): bool => is_array($item)) + ->map(function (array $item, int $index): array { + $status = $this->stringValue($item['status'] ?? null, 'unknown'); + $assignmentSummary = is_array($item['assignment_summary'] ?? null) ? $item['assignment_summary'] : []; + $settingsApply = is_array($item['settings_apply'] ?? null) ? $item['settings_apply'] : []; + + return [ + 'name' => $this->itemName($item, $index), + 'type' => $this->stringValue($item['policy_type'] ?? null, 'Policy'), + 'platform' => $this->stringValue($item['platform'] ?? null, '—'), + 'status' => $status, + 'status_badge' => $this->badge(BadgeDomain::RestoreResultStatus, $status), + 'restore_mode' => $this->stringValue($item['restore_mode'] ?? null, null), + 'reason' => $this->safeReason($item), + 'assignments' => $this->summaryLine($assignmentSummary, [ + 'success' => 'applied', + 'failed' => 'failed', + 'skipped' => 'not applied', + ]), + 'settings' => $this->summaryLine($settingsApply, [ + 'applied' => 'applied', + 'failed' => 'failed', + 'manual_required' => 'manual', + ]), + 'needs_attention' => in_array($status, ['failed', 'partial', 'manual_required', 'skipped'], true), + 'diagnostics' => $this->itemDiagnostics($item), + ]; + }) + ->values() + ->all(); + } + + /** + * @return list> + */ + private function foundationOutcomes(RestoreRun $restoreRun): array + { + $results = is_array($restoreRun->results) ? $restoreRun->results : []; + $foundations = is_array($results['foundations'] ?? null) ? array_values($results['foundations']) : []; + + return collect($foundations) + ->filter(static fn (mixed $item): bool => is_array($item)) + ->map(function (array $item, int $index): array { + $decision = $this->stringValue($item['decision'] ?? null, 'unknown'); + $reason = $this->safeReason($item); + + return [ + 'name' => $this->stringValue($item['sourceName'] ?? $item['sourceId'] ?? null, 'Foundation '.($index + 1)), + 'type' => $this->stringValue($item['type'] ?? null, 'foundation'), + 'target' => $this->stringValue($item['targetName'] ?? null, null), + 'decision' => $decision, + 'decision_badge' => $this->badge( + BadgeDomain::RestorePreviewDecision, + $decision === 'dry_run' ? 'dry_run' : $decision, + ), + 'reason' => $this->safeFoundationReason($item), + 'needs_attention' => in_array($decision, ['failed', 'skipped'], true), + ]; + }) + ->values() + ->all(); + } + + /** + * @return array + */ + private function itemDiagnostics(array $item): array + { + $diagnostics = []; + + foreach (['graph_error_message', 'graph_error_code', 'graph_request_id', 'graph_client_request_id', 'graph_method', 'graph_path'] as $key) { + if (filled($item[$key] ?? null)) { + $diagnostics[] = Str::headline($key).' recorded'; + } + } + + return array_values(array_unique($diagnostics)); + } + + /** + * @param array $item + */ + private function itemName(array $item, int $index): string + { + foreach (['policy_display_name', 'display_name', 'policy_identifier', 'policy_id'] as $key) { + if (filled($item[$key] ?? null)) { + return (string) $item[$key]; + } + } + + return 'Policy '.($index + 1); + } + + /** + * @param array $item + */ + private function safeReason(array $item): ?string + { + $reason = $this->stringValue($item['reason'] ?? null, null); + + if ($reason === null) { + return null; + } + + return match ($reason) { + 'preview_only' => 'Preview only. This item was not applied during execution.', + default => Str::headline($reason), + }; + } + + /** + * @param array $item + */ + private function safeFoundationReason(array $item): ?string + { + $reason = $this->stringValue($item['reason'] ?? null, null); + + if ($reason === null) { + return null; + } + + return match ($reason) { + 'preview_only' => 'Preview only. This foundation type is not applied during execution.', + default => Str::headline($reason), + }; + } + + /** + * @param array $summary + * @param array $labels + */ + private function summaryLine(array $summary, array $labels): ?string + { + $parts = []; + + foreach ($labels as $key => $label) { + if (array_key_exists($key, $summary) && is_numeric($summary[$key])) { + $parts[] = ((int) $summary[$key]).' '.$label; + } + } + + return $parts === [] ? null : implode(' • ', $parts); + } + + private function executionBasis(RestoreRun $restoreRun): string + { + $snapshot = $restoreRun->executionSafetySnapshot(); + $safetyState = $snapshot['safety_state'] ?? null; + + if (! is_string($safetyState) || $safetyState === '') { + return 'No execution safety snapshot was recorded.'; + } + + return 'Execution basis: '.RestoreSafetyCopy::safetyStateLabel($safetyState).'.'; + } + + private function stringValue(mixed $value, ?string $fallback): ?string + { + return is_string($value) && trim($value) !== '' ? trim($value) : $fallback; + } + + /** + * @return array{label:string,color:string,icon:?string,iconColor:?string} + */ + private function badge(BadgeDomain $domain, ?string $state): array + { + $spec = BadgeRenderer::spec($domain, $state); + + return [ + 'label' => $spec->label, + 'color' => $spec->color, + 'icon' => $spec->icon, + 'iconColor' => $spec->iconColor, + ]; + } + + /** + * @return array{label:string,color:string,icon:?string,iconColor:?string} + */ + private function statusBadge(string $color, string $label, ?string $icon = null): array + { + return [ + 'label' => $label, + 'color' => $color, + 'icon' => $icon, + 'iconColor' => $color, + ]; + } +} diff --git a/apps/platform/resources/views/filament/infolists/entries/restore-results.blade.php b/apps/platform/resources/views/filament/infolists/entries/restore-results.blade.php index 559f2fd7..138894cb 100644 --- a/apps/platform/resources/views/filament/infolists/entries/restore-results.blade.php +++ b/apps/platform/resources/views/filament/infolists/entries/restore-results.blade.php @@ -1,452 +1,384 @@ @php - $state = $getState() ?? []; - $state = is_array($state) ? $state : []; - $resultAttention = is_array($state['resultAttention'] ?? null) ? $state['resultAttention'] : []; - $executionSafetySnapshot = is_array($state['executionSafetySnapshot'] ?? null) ? $state['executionSafetySnapshot'] : []; - $state = is_array($state['results'] ?? null) ? $state['results'] : $state; - $isFoundationEntry = function ($item) { - return is_array($item) && array_key_exists('decision', $item) && array_key_exists('sourceId', $item); - }; + $surface = $getState() ?? []; + $surface = is_array($surface) ? $surface : []; - if (is_array($state) && array_key_exists('items', $state)) { - $foundationItems = collect($state['foundations'] ?? [])->filter($isFoundationEntry); - $policyItems = collect($state['items'] ?? [])->values(); - $results = $state; - } else { - $results = $state; - $foundationItems = collect($results)->filter($isFoundationEntry); - $policyItems = collect($results)->reject($isFoundationEntry); - } + $decision = is_array($surface['decision'] ?? null) ? $surface['decision'] : []; + $operationProof = is_array($surface['operationProof'] ?? null) ? $surface['operationProof'] : []; + $postRunEvidence = is_array($surface['postRunEvidence'] ?? null) ? $surface['postRunEvidence'] : []; + $resultSummary = is_array($surface['resultSummary'] ?? null) ? $surface['resultSummary'] : []; + $summaryCounts = is_array($resultSummary['counts'] ?? null) ? $resultSummary['counts'] : []; + $itemOutcomeEvidence = is_array($surface['itemOutcomeEvidence'] ?? null) ? $surface['itemOutcomeEvidence'] : []; + $itemOutcomes = collect(is_array($surface['itemOutcomes'] ?? null) ? $surface['itemOutcomes'] : []); + $foundationOutcomes = collect(is_array($surface['foundationOutcomes'] ?? null) ? $surface['foundationOutcomes'] : []); + $diagnostics = is_array($surface['diagnostics'] ?? null) ? $surface['diagnostics'] : []; + $runContext = is_array($surface['runContext'] ?? null) ? $surface['runContext'] : []; + $resultAttention = is_array($surface['resultAttention'] ?? null) ? $surface['resultAttention'] : []; - $tenant = rescue(fn () => \App\Models\ManagedEnvironment::current(), null); - $groupLabelResolver = $tenant ? app(\App\Services\Directory\EntraGroupLabelResolver::class) : null; + $attentionSpec = \App\Support\Badges\BadgeRenderer::spec( + \App\Support\Badges\BadgeDomain::RestoreResultStatus, + $resultAttention['state'] ?? ($decision['state'] ?? 'not_executed') + ); - $formatGroupId = function ($groupId, $fallbackName = null) use ($tenant, $groupLabelResolver) { - if (! is_string($groupId) || $groupId === '') { - return null; - } + $summaryCards = [ + 'requested' => 'Requested', + 'applied' => 'Applied', + 'failed' => 'Failed', + 'skipped' => 'Skipped', + 'needs_review' => 'Needs review', + ]; - $cachedName = null; - - if ($tenant && $groupLabelResolver) { - $cached = $groupLabelResolver->lookupMany($tenant, [$groupId]); - $cachedName = $cached[strtolower($groupId)] ?? null; - } - - $name = is_string($fallbackName) && $fallbackName !== '' ? $fallbackName : null; - - return \App\Services\Directory\EntraGroupLabelResolver::formatLabel($cachedName ?? $name, $groupId); - }; + $formatCount = static fn (mixed $value): string => is_numeric($value) ? number_format((int) $value) : '—'; @endphp -@if ($foundationItems->isEmpty() && $policyItems->isEmpty()) -

No restore results have been recorded yet.

-@else - @php - $needsAttention = (bool) ($resultAttention['follow_up_required'] ?? false) - || $policyItems->contains(function ($item) { - $status = $item['status'] ?? null; +
+
+
+ + {{ $decision['status_label'] ?? 'Restore result unavailable' }} + - return in_array($status, ['partial', 'manual_required'], true); - }); - $attentionSpec = \App\Support\Badges\BadgeRenderer::spec( - \App\Support\Badges\BadgeDomain::RestoreResultStatus, - $resultAttention['state'] ?? ($needsAttention ? 'completed_with_follow_up' : 'completed') - ); - $executionBasisLabel = \App\Support\RestoreSafety\RestoreSafetyCopy::safetyStateLabel( - is_string($executionSafetySnapshot['safety_state'] ?? null) ? $executionSafetySnapshot['safety_state'] : null - ); - $primaryNextAction = \App\Support\RestoreSafety\RestoreSafetyCopy::primaryNextAction( - is_string($resultAttention['primary_next_action'] ?? null) ? $resultAttention['primary_next_action'] : 'review_result' - ); - $primaryCauseFamily = \App\Support\RestoreSafety\RestoreSafetyCopy::primaryCauseFamily( - is_string($resultAttention['primary_cause_family'] ?? null) ? $resultAttention['primary_cause_family'] : 'none' - ); - $recoveryBoundary = \App\Support\RestoreSafety\RestoreSafetyCopy::recoveryBoundary( - is_string($resultAttention['recovery_claim_boundary'] ?? null) - ? $resultAttention['recovery_claim_boundary'] - : 'run_completed_not_recovery_proven' - ); - @endphp + + {{ $attentionSpec->label }} + +
-
-
-
- - {{ $attentionSpec->label }} - - @if (($executionSafetySnapshot['safety_state'] ?? null) !== null) - - Execution basis: {{ $executionBasisLabel }} - +
+
+
+

Primary operator question

+

+ {{ $decision['question'] ?? 'Was this restore executed safely, and is recovery proof available?' }} +

+
+ +
+
+

Reason

+

{{ $decision['reason'] ?? 'Restore result reason unavailable.' }}

+ @if (filled($decision['attention_summary'] ?? null)) +

{{ $decision['attention_summary'] }}

+ @endif +
+
+

Impact

+

{{ $decision['impact'] ?? 'Review restore details before relying on this record.' }}

+
+
+

Primary next action

+

{{ $decision['primary_next_action'] ?? 'Review restore details' }}

+
+
+
+ +
+

Dominant action

+ @if (filled($decision['primary_next_url'] ?? null)) + + {{ $decision['primary_next_action'] ?? 'Open detail' }} + + @else +
+ {{ $decision['primary_next_action'] ?? 'Review restore details' }} +
@endif -
- -
-
-
What this run proves
-
{{ $resultAttention['summary'] ?? 'Restore result truth is unavailable.' }}
-
-
-
Primary next step
-
{{ $primaryNextAction }}
-
-
- -
-
-
Main follow-up driver
-
{{ $primaryCauseFamily }}
-
-
-
What this record does not prove
-
{{ $recoveryBoundary }}
+
+

Result next step: {{ $decision['result_next_action'] ?? 'Review the completed restore details.' }}

+

Main follow-up driver: {{ $decision['primary_cause_family'] ?? 'No dominant cause recorded' }}

+

Boundary: {{ $decision['recovery_claim_boundary'] ?? 'Target environment recovery is not proven.' }}

+
- @if ($foundationItems->isNotEmpty()) -
-
Foundations
- @foreach ($foundationItems as $item) +
+
+
+
+
+

Restore result summary

+

{{ $resultSummary['message'] ?? 'Result summary unavailable' }}

+
+ Repo-backed +
+ + @if (($resultSummary['available'] ?? false) === true) +
+ @foreach ($summaryCards as $key => $label) +
+

{{ $label }}

+

{{ $formatCount($summaryCounts[$key] ?? null) }}

+
+ @endforeach +
+ @else +
+ Result summary unavailable. No fake zero counts are shown. +
+ @endif +
+ +
+
+
+

Item outcomes

+

Per-item results are table-first. Item diagnostics stay behind row disclosure.

+
+ + {{ $itemOutcomeEvidence['label'] ?? ($itemOutcomes->count().' items') }} + +
+ + @if ($itemOutcomes->isEmpty()) +
+ {{ $itemOutcomeEvidence['message'] ?? 'No item outcomes have been recorded yet.' }} +
+ @else +
+
+ + + + + + + + + + + + @foreach ($itemOutcomes as $item) + @php + $statusBadge = is_array($item['status_badge'] ?? null) ? $item['status_badge'] : []; + $diagnostics = collect(is_array($item['diagnostics'] ?? null) ? $item['diagnostics'] : []); + @endphp + + + + + + + + @endforeach + +
ItemStatusAssignmentsSettingsReview reason
+
{{ $item['name'] ?? 'Policy' }}
+
{{ $item['type'] ?? 'Policy' }} @if (($item['platform'] ?? '—') !== '—') • {{ $item['platform'] }} @endif
+
+ + {{ $statusBadge['label'] ?? 'Unknown' }} + + @if (filled($item['restore_mode'] ?? null)) +
{{ \Illuminate\Support\Str::headline($item['restore_mode']) }}
+ @endif +
{{ $item['assignments'] ?? '—' }}{{ $item['settings'] ?? '—' }} +
{{ $item['reason'] ?? (($item['needs_attention'] ?? false) ? 'Review required' : 'No follow-up recorded') }}
+ @if ($diagnostics->isNotEmpty()) +
+ Diagnostics recorded +
    + @foreach ($diagnostics as $diagnostic) +
  • {{ $diagnostic }}
  • + @endforeach +
+
+ @endif +
+
+
+ @endif +
+ + @if ($foundationOutcomes->isNotEmpty()) +
+

Foundations

+
+ + + + + + + + + + + @foreach ($foundationOutcomes as $foundation) + @php + $decisionBadge = is_array($foundation['decision_badge'] ?? null) ? $foundation['decision_badge'] : []; + @endphp + + + + + + + @endforeach + +
FoundationDecisionTargetReason
+
{{ $foundation['name'] ?? 'Foundation' }}
+
{{ $foundation['type'] ?? 'foundation' }}
+
+ + {{ $decisionBadge['label'] ?? 'Unknown' }} + + {{ $foundation['target'] ?? '—' }}{{ $foundation['reason'] ?? '—' }}
+
+
+ @endif +
+ +
-@endif +
diff --git a/apps/platform/tests/Browser/Spec335RestoreRunDetailProductizationSmokeTest.php b/apps/platform/tests/Browser/Spec335RestoreRunDetailProductizationSmokeTest.php new file mode 100644 index 00000000..21711610 --- /dev/null +++ b/apps/platform/tests/Browser/Spec335RestoreRunDetailProductizationSmokeTest.php @@ -0,0 +1,195 @@ +browser()->timeout(60_000); + +uses(RefreshDatabase::class); + +function spec335BrowserScreenshotName(string $name): string +{ + return 'spec335-restore-run-detail-'.$name; +} + +function spec335BrowserLoginUrl(User $user, ManagedEnvironment $tenant, string $redirect): string +{ + return route('admin.local.smoke-login', [ + 'email' => $user->email, + 'tenant' => $tenant->external_id, + 'workspace' => $tenant->workspace->slug, + 'redirect' => $redirect, + ]); +} + +function spec335BrowserViewPath(ManagedEnvironment $tenant, RestoreRun $restoreRun): string +{ + $url = RestoreRunResource::getUrl('view', ['record' => $restoreRun], panel: 'admin', tenant: $tenant); + + return parse_url($url, PHP_URL_PATH) ?: '/admin'; +} + +function spec335BrowserBackupSet(ManagedEnvironment $tenant): BackupSet +{ + return BackupSet::factory()->create([ + 'managed_environment_id' => (int) $tenant->getKey(), + 'name' => 'Spec335 Browser Backup', + 'status' => 'completed', + 'item_count' => 4, + ]); +} + +function spec335BrowserOperationRun(ManagedEnvironment $tenant, string $outcome = OperationRunOutcome::Succeeded->value): OperationRun +{ + return OperationRun::factory()->forTenant($tenant)->create([ + 'type' => OperationRunType::RestoreExecute->value, + 'status' => OperationRunStatus::Completed->value, + 'outcome' => $outcome, + 'completed_at' => now(), + ]); +} + +function spec335BrowserCompletedRestoreRun(ManagedEnvironment $tenant, BackupSet $backupSet, OperationRun $operationRun): RestoreRun +{ + return RestoreRun::factory()->for($tenant, 'tenant')->for($backupSet)->create([ + 'status' => 'completed', + 'operation_run_id' => (int) $operationRun->getKey(), + 'requested_by' => 'Spec335 Browser Operator', + 'results' => [ + 'foundations' => [], + 'items' => [ + [ + 'status' => 'applied', + 'policy_identifier' => 'Spec335 Browser Policy', + 'policy_type' => 'settingsCatalogPolicy', + 'platform' => 'windows', + 'assignment_summary' => [ + 'success' => 1, + 'failed' => 0, + 'skipped' => 1, + ], + 'settings_apply' => [ + 'applied' => 4, + 'failed' => 0, + 'manual_required' => 0, + ], + 'graph_error_message' => 'browser raw payload should stay hidden', + ], + ], + ], + 'metadata' => [ + 'total' => 4, + 'succeeded' => 3, + 'failed' => 0, + 'skipped' => 1, + 'partial' => 0, + 'non_applied' => 1, + 'execution_safety_snapshot' => [ + 'safety_state' => 'ready_with_caution', + ], + ], + 'completed_at' => now(), + ]); +} + +it('Spec335 smokes restore run detail post-execution proof states and screenshots', function (): void { + [$user, $tenant] = createUserWithTenant(role: 'owner'); + $backupSet = spec335BrowserBackupSet($tenant); + + $draftRun = RestoreRun::factory()->for($tenant, 'tenant')->for($backupSet)->create([ + 'status' => 'draft', + 'is_dry_run' => true, + 'results' => [], + 'metadata' => [], + 'started_at' => null, + 'completed_at' => null, + ]); + + $completedOperation = spec335BrowserOperationRun($tenant); + $completedRun = spec335BrowserCompletedRestoreRun($tenant, $backupSet, $completedOperation); + + $failedOperation = spec335BrowserOperationRun($tenant, OperationRunOutcome::Failed->value); + $failedRun = RestoreRun::factory()->for($tenant, 'tenant')->for($backupSet)->create([ + 'status' => 'failed', + 'operation_run_id' => (int) $failedOperation->getKey(), + 'results' => [ + 'foundations' => [], + 'items' => [ + [ + 'status' => 'failed', + 'policy_identifier' => 'Spec335 Failed Browser Policy', + 'policy_type' => 'settingsCatalogPolicy', + 'platform' => 'windows', + ], + ], + ], + 'metadata' => [ + 'total' => 1, + 'succeeded' => 0, + 'failed' => 1, + ], + 'completed_at' => now(), + ]); + + $page = visit(spec335BrowserLoginUrl($user, $tenant, spec335BrowserViewPath($tenant, $draftRun))) + ->resize(1440, 1100) + ->waitForText('Was this restore executed safely, and is recovery proof available?') + ->assertSee('Not executed') + ->assertSee('Operation proof unavailable') + ->assertSee('Post-run evidence unavailable') + ->assertScript('document.querySelector("[data-testid=\"restore-run-diagnostics\"]")?.open === false', true) + ->assertNoJavaScriptErrors() + ->assertNoConsoleLogs() + ->screenshot(true, spec335BrowserScreenshotName('01-restore-run-draft')); + + $page = visit(spec335BrowserViewPath($tenant, $completedRun)) + ->resize(1440, 1100) + ->waitForText('Completed, recovery proof incomplete') + ->assertSee('Operation proof available') + ->assertSee('Post-run evidence unavailable') + ->assertSee('Restore result summary') + ->assertSee('Item outcomes') + ->assertSee('Spec335 Browser Policy') + ->assertDontSee('browser raw payload should stay hidden') + ->assertScript('document.querySelector("[data-testid=\"restore-run-diagnostics\"]")?.open === false', true) + ->assertNoJavaScriptErrors() + ->assertNoConsoleLogs() + ->screenshot(true, spec335BrowserScreenshotName('02-restore-run-completed-proof-incomplete')); + + $page->screenshot(true, spec335BrowserScreenshotName('03-restore-run-operation-proof')); + + $page->screenshot(true, spec335BrowserScreenshotName('04-restore-run-evidence-unavailable')); + + $page->screenshot(true, spec335BrowserScreenshotName('05-restore-run-item-outcomes')); + + $page->screenshot(true, spec335BrowserScreenshotName('07-restore-run-diagnostics-collapsed')); + + visit(spec335BrowserViewPath($tenant, $failedRun)) + ->resize(1440, 1100) + ->waitForText('Restore failed') + ->assertSee('Review failure details') + ->assertSee('Spec335 Failed Browser Policy') + ->assertScript('document.querySelector("[data-testid=\"restore-run-diagnostics\"]")?.open === false', true) + ->assertNoJavaScriptErrors() + ->assertNoConsoleLogs() + ->screenshot(true, spec335BrowserScreenshotName('06-restore-run-failed-if-supported')); + + visit(spec335BrowserLoginUrl($user, $tenant, spec335BrowserViewPath($tenant, $completedRun))) + ->inDarkMode() + ->resize(1440, 1100) + ->waitForText('Completed, recovery proof incomplete') + ->assertSee('Operation proof available') + ->assertNoJavaScriptErrors() + ->assertNoConsoleLogs() + ->screenshot(true, spec335BrowserScreenshotName('08-restore-run-dark-mode')); +}); diff --git a/apps/platform/tests/Feature/Filament/Spec335RestoreRunDetailProductizationTest.php b/apps/platform/tests/Feature/Filament/Spec335RestoreRunDetailProductizationTest.php new file mode 100644 index 00000000..166bcf9c --- /dev/null +++ b/apps/platform/tests/Feature/Filament/Spec335RestoreRunDetailProductizationTest.php @@ -0,0 +1,234 @@ +create([ + 'managed_environment_id' => (int) $tenant->getKey(), + 'name' => 'Spec335 Backup Set', + 'status' => 'completed', + 'item_count' => 10, + ]); +} + +function spec335RestoreOperationRun(ManagedEnvironment $tenant, string $outcome = OperationRunOutcome::Succeeded->value): OperationRun +{ + return OperationRun::factory()->forTenant($tenant)->create([ + 'type' => OperationRunType::RestoreExecute->value, + 'status' => OperationRunStatus::Completed->value, + 'outcome' => $outcome, + 'completed_at' => now(), + 'context' => [ + 'restore_run_id' => 999, + ], + ]); +} + +function spec335CompletedRestoreRun(ManagedEnvironment $tenant, BackupSet $backupSet, ?OperationRun $operationRun = null): RestoreRun +{ + return RestoreRun::factory()->for($tenant, 'tenant')->for($backupSet)->create([ + 'status' => 'completed', + 'operation_run_id' => $operationRun?->getKey(), + 'requested_by' => 'Spec335 Operator', + 'results' => [ + 'foundations' => [], + 'items' => [ + 10 => [ + 'status' => 'applied', + 'policy_identifier' => 'Spec335 Completed Policy', + 'policy_type' => 'settingsCatalogPolicy', + 'platform' => 'windows', + 'assignment_summary' => [ + 'success' => 2, + 'failed' => 0, + 'skipped' => 1, + ], + 'settings_apply' => [ + 'applied' => 8, + 'failed' => 0, + 'manual_required' => 0, + ], + 'graph_error_message' => 'raw payload should stay hidden', + 'graph_request_id' => 'raw-request-id-should-stay-hidden', + ], + ], + ], + 'metadata' => [ + 'total' => 10, + 'succeeded' => 8, + 'failed' => 1, + 'skipped' => 1, + 'partial' => 0, + 'non_applied' => 1, + 'execution_safety_snapshot' => [ + 'safety_state' => 'ready_with_caution', + 'follow_up_boundary' => 'run_completed_not_recovery_proven', + ], + ], + 'completed_at' => now(), + ]); +} + +it('renders the decision-first restore run detail with proof incomplete boundaries', function (): void { + [$user, $tenant] = createUserWithTenant(role: 'owner'); + $this->actingAs($user); + Filament::setTenant($tenant, true); + + $backupSet = spec335BackupSet($tenant); + $operationRun = spec335RestoreOperationRun($tenant); + $restoreRun = spec335CompletedRestoreRun($tenant, $backupSet, $operationRun); + + Livewire::test(ViewRestoreRun::class, ['record' => $restoreRun->getKey()]) + ->assertSee('Was this restore executed safely, and is recovery proof available?') + ->assertSee('Completed, recovery proof incomplete') + ->assertSee('Do not treat this restore as verified recovery until evidence has been reviewed.') + ->assertSee('Operation proof available') + ->assertSee('Open operation proof') + ->assertSee(OperationRunLinks::identifier($operationRun)) + ->assertSee('Post-run evidence unavailable') + ->assertSee('Restore result summary') + ->assertSee('Requested') + ->assertSee('10') + ->assertSee('Needs review') + ->assertSee('3') + ->assertSee('Item outcomes') + ->assertSee('Spec335 Completed Policy') + ->assertSee('Diagnostics collapsed') + ->assertDontSee('raw payload should stay hidden') + ->assertDontSee('raw-request-id-should-stay-hidden') + ->assertDontSee('Recovery verified') + ->assertDontSee('Healthy') + ->assertDontSee('Customer-safe'); +}); + +it('explains metadata-only follow-up counts when item outcome rows are absent', function (): void { + [$user, $tenant] = createUserWithTenant(role: 'owner'); + $this->actingAs($user); + Filament::setTenant($tenant, true); + + $backupSet = spec335BackupSet($tenant); + $operationRun = spec335RestoreOperationRun($tenant); + $restoreRun = spec335CompletedRestoreRun($tenant, $backupSet, $operationRun); + $restoreRun->forceFill([ + 'results' => [ + 'foundations' => [], + 'items' => [], + ], + ])->save(); + + Livewire::test(ViewRestoreRun::class, ['record' => $restoreRun->getKey()]) + ->assertSee('Needs review') + ->assertSee('3') + ->assertSee('Metadata counts only') + ->assertSee('Aggregate restore counts are available, but item-level outcome rows are not stored for this run.') + ->assertDontSee('0 items'); +}); + +it('links repo-backed post-run evidence when an authorized snapshot exists', function (): void { + [$user, $tenant] = createUserWithTenant(role: 'owner'); + $this->actingAs($user); + Filament::setTenant($tenant, true); + + $backupSet = spec335BackupSet($tenant); + $operationRun = spec335RestoreOperationRun($tenant); + $restoreRun = spec335CompletedRestoreRun($tenant, $backupSet, $operationRun); + + $snapshot = EvidenceSnapshot::query()->create([ + 'managed_environment_id' => (int) $tenant->getKey(), + 'workspace_id' => (int) $tenant->workspace_id, + 'operation_run_id' => (int) $operationRun->getKey(), + 'status' => EvidenceSnapshotStatus::Active->value, + 'completeness_state' => EvidenceCompletenessState::Complete->value, + 'summary' => ['finding_count' => 0], + 'generated_at' => now(), + ]); + + Livewire::test(ViewRestoreRun::class, ['record' => $restoreRun->getKey()]) + ->assertSee('Completed with evidence available') + ->assertSee('Post-run evidence available') + ->assertSee('Evidence snapshot #'.$snapshot->getKey()) + ->assertSee(EvidenceSnapshotResource::getUrl('view', ['record' => $snapshot], tenant: $tenant), false); +}); + +it('shows unavailable operation proof when a completed restore run has no linked operation', function (): void { + [$user, $tenant] = createUserWithTenant(role: 'owner'); + $this->actingAs($user); + Filament::setTenant($tenant, true); + + $restoreRun = RestoreRun::withoutEvents( + fn (): RestoreRun => spec335CompletedRestoreRun($tenant, spec335BackupSet($tenant)), + ); + + Livewire::test(ViewRestoreRun::class, ['record' => $restoreRun->getKey()]) + ->assertSee('Completed, recovery proof incomplete') + ->assertSee('Operation proof unavailable') + ->assertSee('Post-run evidence unavailable') + ->assertDontSee('Open operation'); +}); + +it('does not leak cross-workspace operation or evidence links', function (): void { + [$user, $tenant] = createUserWithTenant(role: 'owner'); + $this->actingAs($user); + Filament::setTenant($tenant, true); + + $foreignTenant = ManagedEnvironment::factory()->create(); + $foreignOperationRun = spec335RestoreOperationRun($foreignTenant); + $foreignSnapshot = EvidenceSnapshot::query()->create([ + 'managed_environment_id' => (int) $foreignTenant->getKey(), + 'workspace_id' => (int) $foreignTenant->workspace_id, + 'operation_run_id' => (int) $foreignOperationRun->getKey(), + 'status' => EvidenceSnapshotStatus::Active->value, + 'completeness_state' => EvidenceCompletenessState::Complete->value, + 'summary' => ['finding_count' => 0], + 'generated_at' => now(), + ]); + + $restoreRun = spec335CompletedRestoreRun($tenant, spec335BackupSet($tenant), $foreignOperationRun); + + Livewire::test(ViewRestoreRun::class, ['record' => $restoreRun->getKey()]) + ->assertSee('Operation proof unavailable') + ->assertSee('Post-run evidence unavailable') + ->assertDontSee(OperationRunLinks::tenantlessView($foreignOperationRun), false) + ->assertDontSee(EvidenceSnapshotResource::getUrl('view', ['record' => $foreignSnapshot], tenant: $foreignTenant), false); +}); + +it('preserves restore run view authorization semantics', function (): void { + [$user, $tenant] = createUserWithTenant(role: 'owner'); + $this->actingAs($user); + + $restoreRun = spec335CompletedRestoreRun($tenant, spec335BackupSet($tenant)); + + $this->withSession([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id]) + ->get(RestoreRunResource::getUrl('view', ['record' => $restoreRun], panel: 'admin', tenant: $tenant)) + ->assertOk(); + + $otherTenant = ManagedEnvironment::factory()->create(); + [$otherUser] = createUserWithTenant($otherTenant, role: 'owner'); + + $this->actingAs($otherUser) + ->withSession([WorkspaceContext::SESSION_KEY => (int) $otherTenant->workspace_id]) + ->get(RestoreRunResource::getUrl('view', ['record' => $restoreRun], panel: 'admin', tenant: $tenant)) + ->assertNotFound(); +}); diff --git a/apps/platform/tests/Unit/Filament/RestoreRunDetailPresenterDeterminismTest.php b/apps/platform/tests/Unit/Filament/RestoreRunDetailPresenterDeterminismTest.php new file mode 100644 index 00000000..81439e8e --- /dev/null +++ b/apps/platform/tests/Unit/Filament/RestoreRunDetailPresenterDeterminismTest.php @@ -0,0 +1,102 @@ +actingAs($user); + Filament::setTenant($tenant, true); + + $backupSet = BackupSet::factory()->create([ + 'managed_environment_id' => (int) $tenant->getKey(), + ]); + + $operationRun = OperationRun::factory()->forTenant($tenant)->create([ + 'type' => OperationRunType::RestoreExecute->value, + 'status' => OperationRunStatus::Completed->value, + 'outcome' => OperationRunOutcome::Succeeded->value, + 'completed_at' => now(), + ]); + + $restoreRun = RestoreRun::factory()->for($tenant, 'tenant')->for($backupSet)->create([ + 'status' => 'completed', + 'operation_run_id' => (int) $operationRun->getKey(), + 'metadata' => [ + 'total' => 1, + 'succeeded' => 1, + 'failed' => 0, + 'skipped' => 0, + 'partial' => 0, + 'non_applied' => 0, + ], + ]); + + $presenter = app(RestoreRunDetailPresenter::class); + + $first = $presenter->forRun($restoreRun->fresh(['backupSet', 'operationRun', 'tenant'])); + + expect(data_get($first, 'decision.state'))->toBe('completed_proof_incomplete') + ->and(data_get($first, 'operationProof.state'))->toBe('available') + ->and(data_get($first, 'postRunEvidence.state'))->toBe('unavailable'); + + EvidenceSnapshot::query()->create([ + 'managed_environment_id' => (int) $tenant->getKey(), + 'workspace_id' => (int) $tenant->workspace_id, + 'operation_run_id' => (int) $operationRun->getKey(), + 'status' => EvidenceSnapshotStatus::Active->value, + 'completeness_state' => EvidenceCompletenessState::Complete->value, + 'summary' => ['finding_count' => 0], + 'generated_at' => now(), + ]); + + $second = $presenter->forRun($restoreRun->fresh(['backupSet', 'operationRun', 'tenant'])); + + expect(data_get($second, 'decision.state'))->toBe('completed_with_evidence') + ->and(data_get($second, 'postRunEvidence.state'))->toBe('available') + ->and(data_get($second, 'postRunEvidence.identifier'))->toContain('Evidence snapshot #'); +}); + +it('does not expose operation proof from another workspace or environment', function (): void { + [$user, $tenant] = createUserWithTenant(role: 'owner'); + $this->actingAs($user); + Filament::setTenant($tenant, true); + + $backupSet = BackupSet::factory()->create([ + 'managed_environment_id' => (int) $tenant->getKey(), + ]); + + $foreignTenant = \App\Models\ManagedEnvironment::factory()->create(); + $foreignOperationRun = OperationRun::factory()->forTenant($foreignTenant)->create([ + 'type' => OperationRunType::RestoreExecute->value, + 'status' => OperationRunStatus::Completed->value, + 'outcome' => OperationRunOutcome::Succeeded->value, + 'completed_at' => now(), + ]); + + $restoreRun = RestoreRun::factory()->for($tenant, 'tenant')->for($backupSet)->create([ + 'status' => 'completed', + 'operation_run_id' => (int) $foreignOperationRun->getKey(), + ]); + + $surface = app(RestoreRunDetailPresenter::class) + ->forRun($restoreRun->fresh(['backupSet', 'operationRun', 'tenant'])); + + expect(data_get($surface, 'operationProof.state'))->toBe('unavailable') + ->and(data_get($surface, 'operationProof.url'))->toBeNull() + ->and(data_get($surface, 'postRunEvidence.state'))->toBe('unavailable'); +}); diff --git a/specs/335-restore-run-detail-post-execution-proof-productization/artifacts/screenshots/01-restore-run-draft.png b/specs/335-restore-run-detail-post-execution-proof-productization/artifacts/screenshots/01-restore-run-draft.png new file mode 100644 index 00000000..8fa55a4c Binary files /dev/null and b/specs/335-restore-run-detail-post-execution-proof-productization/artifacts/screenshots/01-restore-run-draft.png differ diff --git a/specs/335-restore-run-detail-post-execution-proof-productization/artifacts/screenshots/02-restore-run-completed-proof-incomplete.png b/specs/335-restore-run-detail-post-execution-proof-productization/artifacts/screenshots/02-restore-run-completed-proof-incomplete.png new file mode 100644 index 00000000..74f62ed3 Binary files /dev/null and b/specs/335-restore-run-detail-post-execution-proof-productization/artifacts/screenshots/02-restore-run-completed-proof-incomplete.png differ diff --git a/specs/335-restore-run-detail-post-execution-proof-productization/artifacts/screenshots/03-restore-run-operation-proof.png b/specs/335-restore-run-detail-post-execution-proof-productization/artifacts/screenshots/03-restore-run-operation-proof.png new file mode 100644 index 00000000..ab48b5a3 Binary files /dev/null and b/specs/335-restore-run-detail-post-execution-proof-productization/artifacts/screenshots/03-restore-run-operation-proof.png differ diff --git a/specs/335-restore-run-detail-post-execution-proof-productization/artifacts/screenshots/04-restore-run-evidence-unavailable.png b/specs/335-restore-run-detail-post-execution-proof-productization/artifacts/screenshots/04-restore-run-evidence-unavailable.png new file mode 100644 index 00000000..ab48b5a3 Binary files /dev/null and b/specs/335-restore-run-detail-post-execution-proof-productization/artifacts/screenshots/04-restore-run-evidence-unavailable.png differ diff --git a/specs/335-restore-run-detail-post-execution-proof-productization/artifacts/screenshots/05-restore-run-item-outcomes.png b/specs/335-restore-run-detail-post-execution-proof-productization/artifacts/screenshots/05-restore-run-item-outcomes.png new file mode 100644 index 00000000..ab48b5a3 Binary files /dev/null and b/specs/335-restore-run-detail-post-execution-proof-productization/artifacts/screenshots/05-restore-run-item-outcomes.png differ diff --git a/specs/335-restore-run-detail-post-execution-proof-productization/artifacts/screenshots/06-restore-run-failed-if-supported.png b/specs/335-restore-run-detail-post-execution-proof-productization/artifacts/screenshots/06-restore-run-failed-if-supported.png new file mode 100644 index 00000000..f4c07259 Binary files /dev/null and b/specs/335-restore-run-detail-post-execution-proof-productization/artifacts/screenshots/06-restore-run-failed-if-supported.png differ diff --git a/specs/335-restore-run-detail-post-execution-proof-productization/artifacts/screenshots/07-restore-run-diagnostics-collapsed.png b/specs/335-restore-run-detail-post-execution-proof-productization/artifacts/screenshots/07-restore-run-diagnostics-collapsed.png new file mode 100644 index 00000000..ab48b5a3 Binary files /dev/null and b/specs/335-restore-run-detail-post-execution-proof-productization/artifacts/screenshots/07-restore-run-diagnostics-collapsed.png differ diff --git a/specs/335-restore-run-detail-post-execution-proof-productization/artifacts/screenshots/08-restore-run-dark-mode.png b/specs/335-restore-run-detail-post-execution-proof-productization/artifacts/screenshots/08-restore-run-dark-mode.png new file mode 100644 index 00000000..253a3f29 Binary files /dev/null and b/specs/335-restore-run-detail-post-execution-proof-productization/artifacts/screenshots/08-restore-run-dark-mode.png differ diff --git a/specs/335-restore-run-detail-post-execution-proof-productization/repo-truth-map.md b/specs/335-restore-run-detail-post-execution-proof-productization/repo-truth-map.md index 91d53179..ad5f5bd1 100644 --- a/specs/335-restore-run-detail-post-execution-proof-productization/repo-truth-map.md +++ b/specs/335-restore-run-detail-post-execution-proof-productization/repo-truth-map.md @@ -80,6 +80,7 @@ ## Restore Results Shape (repo-verified) - Result summary counts: `derived from existing model` (from `metadata`) - Item outcome table: `derived from existing model` (from `results.items` and related metadata) +- Summary counts and item rows are independent. If metadata counts exist but `results.items` is empty, the UI must label the state as metadata-only rather than implying zero affected items. ## Restore Result Attention Contract (repo-verified) @@ -99,10 +100,10 @@ ## Restore Result Attention Contract (repo-verified) - `recovery_claim_boundary` (notably enforces "completed != recovery proven") - `tone` -Gap vs Spec 335 UX: +Spec 335 UX use: -- OperationRun proof state is not part of this contract today (`not available` in this contract). -- Post-run evidence state is not part of this contract today (`not available` in this contract). +- `RestoreResultAttention` remains the source for result attention copy and recovery-claim boundaries. +- OperationRun proof state and post-run evidence state are derived by the Restore Run detail presenter, not persisted into this contract. ## OperationRun Proof (repo-verified) @@ -120,6 +121,8 @@ ## OperationRun Proof (repo-verified) - OperationRun "proof" is repo-real for restore runs that have `operation_run_id`. - UI link helpers exist (repo-verified): `apps/platform/app/Support/OperationRunLinks.php` and Filament `OperationRunResource`. +- `apps/platform/app/Observers/RestoreRunObserver.php` and `apps/platform/app/Listeners/SyncRestoreRunToOperationRun.php` create/sync adapter `OperationRun` rows from `previewed` onward. Legacy/imported rows can still have no linked operation and must render proof as unavailable. +- Restore Run detail proof links must be same-workspace/same-managed-environment and authorized through the existing `OperationRun` view policy before a link is shown. ## EvidenceSnapshot (post-run evidence) (foundation-real) @@ -137,7 +140,10 @@ ## EvidenceSnapshot (post-run evidence) (foundation-real) Evidence availability for restore runs: - Model + viewer exist (`foundation-real`). -- Whether restore execution produces an evidence snapshot is workflow-dependent and must be verified at runtime/fixtures (`deferred`). +- Restore execution does not guarantee an evidence snapshot. Absence is rendered as "Post-run evidence unavailable" and never as verified recovery. +- The Restore Run detail presenter resolves evidence by linked `operation_run_id`, same `workspace_id`, same `managed_environment_id`, and statuses `active`, `generating`, or `queued`. +- If multiple snapshots exist for the operation run, the presenter links the latest `active` snapshot when present, otherwise the latest queued/generating snapshot as in-progress evidence. +- Evidence links require the current user to have `Capabilities::EVIDENCE_VIEW` for the tenant and `EvidenceSnapshotResource::canView($snapshot)`. ## Current Restore Run Detail UI (repo-verified) @@ -151,9 +157,9 @@ ## Current Restore Run Detail UI (repo-verified) - Preview entry view: `apps/platform/resources/views/filament/infolists/entries/restore-preview.blade.php` - Results entry view: `apps/platform/resources/views/filament/infolists/entries/restore-results.blade.php` -Known gap: +Spec 335 implementation target: -- No dedicated proof/evidence aside on the detail page today (proof panel exists for Create wizard, not for View). +- The detail page uses a derived presenter-backed decision card and proof/evidence aside. No new persisted proof state is introduced. ## Existing Tests (repo-verified) @@ -176,9 +182,6 @@ ## Permissions / Capabilities (repo-verified) - `RestoreRunResource::resolveScopedRecordOrFail()` routes through tenant-owned record resolution, preserving tenant/workspace scoping. -## Open Truth Questions (deferred) - -- Do restore execution operations in current fixtures produce an `EvidenceSnapshot` linked to the restore `OperationRun`? -- If multiple evidence snapshots exist for one operation run, which one should be linked (latest active vs latest any)? -- What are the RBAC rules for viewing EvidenceSnapshot and OperationRun from the Restore Run detail surface (capability names, deny-as-not-found vs forbidden)? +## Open Truth Questions +- None for Spec 335 implementation. Evidence generation itself remains out of scope; the page only reflects repo-backed snapshots that already exist. diff --git a/specs/335-restore-run-detail-post-execution-proof-productization/restore-result-state-contract.md b/specs/335-restore-run-detail-post-execution-proof-productization/restore-result-state-contract.md index 541b61f3..9805fbb8 100644 --- a/specs/335-restore-run-detail-post-execution-proof-productization/restore-result-state-contract.md +++ b/specs/335-restore-run-detail-post-execution-proof-productization/restore-result-state-contract.md @@ -15,6 +15,14 @@ ## Fields (required on first screen) - Result summary (repo-backed only) - Diagnostics default state: collapsed +## Resolution Rules + +- Operation proof is available only when the linked `operation_runs` row is same-workspace, same-managed-environment, and authorized for the current user. +- Post-run evidence is repo-backed only. It is available for the latest authorized `active` evidence snapshot for the linked operation run, same workspace, and same managed environment. +- Evidence snapshots in `queued` or `generating` state may be shown as in progress. Absent, unauthorized, or cross-workspace evidence is unavailable. +- Result attention copy remains visible, but for completed restore runs it does not override the top-level proof/evidence decision unless the persisted restore status itself is partial/error. +- Aggregate result counts may be present without item-level rows. The UI must label that as metadata-only evidence and must not show it as "0 items" when counts indicate affected or follow-up work. + ## State Matrix ### 1) Not Executed (Draft / Preview Only) @@ -55,13 +63,13 @@ ### 3) Completed (Recovery Proof Incomplete) - **State**: `completed_proof_incomplete` - **Persisted triggers**: - - terminal restore run (`status in { completed, partial, completed_with_errors }`) AND operation proof is present (`operation_run_id != null`) - - post-run evidence snapshot not present or not current for the operation run + - terminal completed restore run (`status = completed`) + - post-run evidence snapshot not present, not active/current for the operation run, unauthorized, or cross-workspace - **Visible status**: Completed, recovery proof incomplete - **Reason**: Execution completed, but post-run evidence is not available yet. - **Impact**: Do not treat this restore as verified recovery until evidence has been reviewed. -- **Primary next action**: Review restored details -- **Operation proof state**: Available +- **Primary next action**: Open operation proof when authorized proof exists; otherwise Review proof gap +- **Operation proof state**: Available if a scoped authorized operation run exists, otherwise Unavailable for legacy/imported rows - **Post-run evidence state**: Unavailable - **Result summary**: repo-backed counts from `restore_runs.metadata` when present - **Allowed proof claims**: "Execution proof available" (never "recovery verified") @@ -71,8 +79,8 @@ ### 4) Completed (Evidence Available) - **State**: `completed_with_evidence` - **Persisted triggers**: - - terminal restore run AND operation proof present - - at least one evidence snapshot exists for the same `operation_run_id` and tenant, with status/currentness appropriate to repo truth + - terminal completed restore run + - at least one authorized `active` evidence snapshot exists for the same `operation_run_id`, workspace, and managed environment - **Visible status**: Completed with evidence available - **Reason**: Execution proof and post-run evidence are available. - **Impact**: Review evidence before treating the restore as recovery proof. @@ -89,8 +97,8 @@ ### 5) Completed With Items Needing Review (Partial / Follow-Up) - **State**: `needs_review` - **Persisted triggers** (repo-verified via `RestoreResultAttention`): - - `RestoreSafetyResolver::resultAttentionForRun(...).state in { partial, completed_with_follow_up }`, OR - - `restore_runs.status = partial` + - `restore_runs.status in { partial, completed_with_errors }` + - `RestoreSafetyResolver::resultAttentionForRun(...).state in { partial, completed_with_follow_up }` is still displayed as secondary result-attention copy/badging for `completed` runs, but does not supersede the top proof/evidence decision. - **Visible status**: Completed with items needing review - **Reason**: Some items were skipped, partially applied, or failed. - **Impact**: Review item outcomes before relying on the result. @@ -132,4 +140,3 @@ ### 7) Cancelled / Blocked - **Result summary**: show "Unavailable" unless repo-backed - **Allowed proof claims**: none beyond the above - **Diagnostics default**: Collapsed - diff --git a/specs/335-restore-run-detail-post-execution-proof-productization/spec.md b/specs/335-restore-run-detail-post-execution-proof-productization/spec.md index a26df511..7f4f1b7a 100644 --- a/specs/335-restore-run-detail-post-execution-proof-productization/spec.md +++ b/specs/335-restore-run-detail-post-execution-proof-productization/spec.md @@ -161,8 +161,8 @@ ## Required UX Contract 'status_label' => 'Completed, recovery proof incomplete', 'reason' => 'Execution completed, but post-run evidence is not available yet.', 'impact' => 'Do not treat this restore as verified recovery until evidence has been reviewed.', - 'primary_next_action' => 'Review restored details', - 'primary_next_url' => null, + 'primary_next_action' => 'Open operation proof', + 'primary_next_url' => '...', 'operation_proof' => [ 'state' => 'available', 'label' => 'Operation proof available', @@ -265,4 +265,3 @@ ### Technical - No backend rewrite. - No migrations unless explicitly justified and added to this spec before implementation. - No new packages. - diff --git a/specs/335-restore-run-detail-post-execution-proof-productization/tasks.md b/specs/335-restore-run-detail-post-execution-proof-productization/tasks.md index ad096d98..a35587eb 100644 --- a/specs/335-restore-run-detail-post-execution-proof-productization/tasks.md +++ b/specs/335-restore-run-detail-post-execution-proof-productization/tasks.md @@ -11,60 +11,60 @@ # Tasks: Spec 335 - Restore Run Detail / Post-Execution Proof Productization ## Test Governance Checklist -- [ ] Lane assignment is named and is the narrowest sufficient proof for the changed behavior. -- [ ] New or changed tests stay in the smallest honest family, and browser additions are explicit. -- [ ] Shared helpers, factories, seeds, fixtures, and context defaults stay cheap by default. -- [ ] Planned validation commands cover the change without pulling in unrelated lane cost. -- [ ] The dangerous-workflow proof/evidence surface profile is explicit. -- [ ] Any material budget, baseline, trend, or escalation note is recorded in the active spec or PR. +- [x] Lane assignment is named and is the narrowest sufficient proof for the changed behavior. +- [x] New or changed tests stay in the smallest honest family, and browser additions are explicit. +- [x] Shared helpers, factories, seeds, fixtures, and context defaults stay cheap by default. +- [x] Planned validation commands cover the change without pulling in unrelated lane cost. +- [x] The dangerous-workflow proof/evidence surface profile is explicit. +- [x] Any material budget, baseline, trend, or escalation note is recorded in the active spec or PR. ## Phase 1: Repo Truth (blocks runtime changes) **Purpose**: Freeze repo truth for RestoreRun results/proof/evidence before changing UI. -- [ ] T001 Re-read `spec.md`, `plan.md`, and this `tasks.md`. -- [ ] T002 Verify current Restore Run view implementation and state sources: +- [x] T001 Re-read `spec.md`, `plan.md`, and this `tasks.md`. +- [x] T002 Verify current Restore Run view implementation and state sources: - `apps/platform/app/Filament/Resources/RestoreRunResource.php` (infolist + `detailResultsState`) - `apps/platform/resources/views/filament/infolists/entries/restore-results.blade.php` - `apps/platform/app/Support/RestoreSafety/RestoreSafetyResolver.php` (`resultAttentionForRun`) -- [ ] T003 Update/confirm `repo-truth-map.md` is accurate for: +- [x] T003 Update/confirm `repo-truth-map.md` is accurate for: - `RestoreRun` model fields + `RestoreRunStatus` values - results shape (`results.foundations`, `results.items`) and summary counts (`metadata.total/succeeded/failed/skipped/partial/non_applied`) - `operation_run_id` relationship + current OperationRun outcome/status behavior - Evidence snapshot availability (query path, status/completeness enums) -- [ ] T004 Update/confirm `restore-result-state-contract.md` is aligned to repo truth (no invented evidence/proof states). +- [x] T004 Update/confirm `restore-result-state-contract.md` is aligned to repo truth (no invented evidence/proof states). ## Phase 2: Restore Run Detail Presenter (derived view-model, optional) **Purpose**: Ensure one decision-first UI contract drives the view surface. -- [ ] T005 Decide whether a presenter/view-model is needed. If the view becomes a multi-section surface (decision card + proof panel + evidence state + table), prefer a presenter to avoid page-local logic drift. -- [ ] T006 If introduced, implement a thin derived presenter that outputs: +- [x] T005 Decide whether a presenter/view-model is needed. If the view becomes a multi-section surface (decision card + proof panel + evidence state + table), prefer a presenter to avoid page-local logic drift. +- [x] T006 If introduced, implement a thin derived presenter that outputs: - `status_label`, `reason`, `impact`, `primary_next_action` - `operation_proof` state + URL (tenant/workspace-safe, capability-gated) - `post_run_evidence` state + URL (repo-backed only) - `result_summary` counts (repo-backed only) - `diagnostics_state = collapsed` -- [ ] T007 Prove presenter output determinism with Unit tests (no static memoization). +- [x] T007 Prove presenter output determinism with Unit tests (no static memoization). ## Phase 3: Detail Page UI (decision-first main/aside) **Purpose**: Productize the page layout and hierarchy. -- [ ] T008 Refactor Restore Run view page into a main/aside hierarchy: +- [x] T008 Refactor Restore Run view page into a main/aside hierarchy: - Main: decision card + result summary + item outcomes (table) + secondary run details - Aside: proof panel (source backup, target env, requested by, operation proof, post-run evidence, audit trail) + diagnostics collapsed -- [ ] T009 Ensure diagnostics and raw payloads remain collapsed/secondary by default (no stack traces, no raw JSON as primary UI). -- [ ] T010 Ensure the page does not display "recovery verified", "healthy", "compliant", or "customer-safe" claims unless repo truth supports that semantics. +- [x] T009 Ensure diagnostics and raw payloads remain collapsed/secondary by default (no stack traces, no raw JSON as primary UI). +- [x] T010 Ensure the page does not display "recovery verified", "healthy", "compliant", or "customer-safe" claims unless repo truth supports that semantics. ## Phase 4: Proof/Evidence Links (repo-backed only) **Purpose**: Make execution proof and post-run evidence explicit, separate, and truthful. -- [ ] T011 Operation proof: +- [x] T011 Operation proof: - restore run with `operation_run_id` shows proof state + link to OperationRun detail - restore run without operation run shows "unavailable" state -- [ ] T012 Post-run evidence: +- [x] T012 Post-run evidence: - when evidence snapshots exist for the linked operation run (tenant-scoped), show state + link to Evidence Snapshot detail - when absent, show "unavailable" and do not imply recovery proof @@ -72,32 +72,32 @@ ## Phase 5: Item Outcomes (table-first, no payload dump) **Purpose**: Make per-item outcomes reviewable without flooding the page. -- [ ] T013 Render item outcomes as a table (not large cards) when `results.items` exists. -- [ ] T014 Show compact summary counts from `restore_runs.metadata` (only when repo-backed; no fake zeros). -- [ ] T015 Keep raw per-item payload/diff/diagnostics behind disclosure. +- [x] T013 Render item outcomes as a table (not large cards) when `results.items` exists. +- [x] T014 Show compact summary counts from `restore_runs.metadata` (only when repo-backed; no fake zeros). +- [x] T015 Keep raw per-item payload/diff/diagnostics behind disclosure. ## Phase 6: RBAC / Isolation -- [ ] T016 Add at least one positive and one negative authorization test for Restore Run view access. -- [ ] T017 Prove cross-workspace/tenant proof and evidence links cannot leak (deny-as-not-found semantics preserved). +- [x] T016 Add at least one positive and one negative authorization test for Restore Run view access. +- [x] T017 Prove cross-workspace/tenant proof and evidence links cannot leak (deny-as-not-found semantics preserved). ## Phase 7: Tests -- [ ] T018 Add Feature test: `apps/platform/tests/Feature/Filament/Spec335RestoreRunDetailProductizationTest.php` covering: +- [x] T018 Add Feature test: `apps/platform/tests/Feature/Filament/Spec335RestoreRunDetailProductizationTest.php` covering: - decision question visible - "Completed" does not imply recovery verified - operation proof state visible - post-run evidence state visible and truthful - diagnostics collapsed; raw payload hidden by default -- [ ] T019 Extend or align with existing coverage: +- [x] T019 Extend or align with existing coverage: - `apps/platform/tests/Feature/Filament/RestoreResultAttentionSurfaceTest.php` -- [ ] T020 Add Browser smoke/screenshot test: `apps/platform/tests/Browser/Spec335RestoreRunDetailProductizationSmokeTest.php`. +- [x] T020 Add Browser smoke/screenshot test: `apps/platform/tests/Browser/Spec335RestoreRunDetailProductizationSmokeTest.php`. ## Phase 8: Screenshots -- [ ] T021 Capture required screenshots under: +- [x] T021 Capture required screenshots under: - `specs/335-restore-run-detail-post-execution-proof-productization/artifacts/screenshots/` -- [ ] T022 Capture at least: +- [x] T022 Capture at least: - `01-restore-run-draft.png` - `02-restore-run-completed-proof-incomplete.png` - `03-restore-run-operation-proof.png` @@ -111,7 +111,7 @@ ## Phase 8: Screenshots ## Phase 9: Validation -- [ ] T023 Run: +- [x] T023 Run: - `cd apps/platform && ./vendor/bin/sail artisan test tests/Feature/Filament/Spec335RestoreRunDetailProductizationTest.php tests/Feature/Filament/RestoreResultAttentionSurfaceTest.php --compact` - `cd apps/platform && ./vendor/bin/sail php vendor/bin/pest tests/Browser/Spec335RestoreRunDetailProductizationSmokeTest.php --compact` - `cd apps/platform && ./vendor/bin/sail pint --dirty` @@ -119,10 +119,9 @@ ## Phase 9: Validation ## Explicit Non-Goals -- [ ] NT001 Do not change restore execution backend behavior. -- [ ] NT002 Do not add new Graph calls or ProviderGateway behavior. -- [ ] NT003 Do not change `OperationRun` lifecycle semantics (link-only). -- [ ] NT004 Do not add migrations, packages, env vars, queues, scheduler, or storage changes. -- [ ] NT005 Do not redesign Restore Create wizard (Spec 333 owns Create UX). -- [ ] NT006 Do not introduce any false recovery-proof claims. - +- [x] NT001 Do not change restore execution backend behavior. +- [x] NT002 Do not add new Graph calls or ProviderGateway behavior. +- [x] NT003 Do not change `OperationRun` lifecycle semantics (link-only). +- [x] NT004 Do not add migrations, packages, env vars, queues, scheduler, or storage changes. +- [x] NT005 Do not redesign Restore Create wizard (Spec 333 owns Create UX). +- [x] NT006 Do not introduce any false recovery-proof claims.