TenantAtlas/app/Support/RestoreSafety/RestoreSafetyResolver.php
ahmido a107e7e41b feat: restore safety integrity and queue slide-over (#210)
## Summary
- add the Spec 181 restore-safety layer with scope fingerprinting, preview/check integrity states, execution safety snapshots, result attention, and operator-facing copy across the wizard, restore detail, and canonical operation detail
- add focused unit and feature coverage for restore-safety assessment, result attention, and restore-linked operation detail
- switch the finding exceptions queue `Inspect exception` action to a native Filament slide-over while preserving query-param-backed inline summary behavior

## Testing
- `vendor/bin/sail artisan test --compact tests/Feature/Monitoring/FindingExceptionsQueueTest.php tests/Feature/Filament/RestoreSafetyIntegrityWizardTest.php tests/Feature/Filament/RestoreResultAttentionSurfaceTest.php tests/Feature/Operations/RestoreLinkedOperationDetailTest.php tests/Unit/Support/RestoreSafety`

## Notes
- Spec 181 checklist is complete (`specs/181-restore-safety-integrity/checklists/requirements.md`)
- the branch still has unchecked follow-up tasks in `specs/181-restore-safety-integrity/tasks.md`: `T012`, `T018`, `T019`, `T023`, `T025`, `T029`, `T032`, `T033`, `T041`, `T042`, `T043`, `T044`
- Filament v5 / Livewire v4 compliance is preserved, no panel provider registration changes were made, no global-search behavior was added, destructive actions remain confirmation-gated, and no new Filament assets were introduced

Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de>
Reviewed-on: #210
2026-04-06 23:37:14 +00:00

620 lines
25 KiB
PHP

<?php
declare(strict_types=1);
namespace App\Support\RestoreSafety;
use App\Contracts\Hardening\WriteGateInterface;
use App\Exceptions\Hardening\ProviderAccessHardeningRequired;
use App\Models\RestoreRun;
use App\Models\Tenant;
use App\Models\User;
use App\Services\Auth\CapabilityResolver;
use App\Support\Auth\Capabilities;
final readonly class RestoreSafetyResolver
{
public function __construct(
private CapabilityResolver $capabilityResolver,
private WriteGateInterface $writeGate,
) {}
/**
* @param array<string, mixed> $data
*/
public function scopeFingerprintFromData(array $data): RestoreScopeFingerprint
{
return RestoreScopeFingerprint::fromInputs(
$data['backup_set_id'] ?? null,
$data['scope_mode'] ?? null,
$data['backup_item_ids'] ?? [],
$data['group_mapping'] ?? [],
);
}
/**
* @param array<string, mixed> $data
* @return array{
* backup_set_id: ?int,
* scope_mode: string,
* selected_item_ids: list<int>,
* group_mapping: array<string, string>,
* group_mapping_fingerprint: string,
* fingerprint: string,
* captured_at: string
* }
*/
public function scopeBasisFromData(array $data): array
{
$scope = $this->scopeFingerprintFromData($data);
return $scope->toArray() + [
'captured_at' => now('UTC')->toIso8601String(),
];
}
/**
* @param array<string, mixed> $data
* @return array{
* fingerprint: string,
* ran_at: string,
* blocking_count: int,
* warning_count: int,
* result_codes: list<string>
* }|null
*/
public function checksBasisFromData(array $data): ?array
{
$summary = $data['check_summary'] ?? null;
$ranAt = $data['checks_ran_at'] ?? null;
if (! is_array($summary) || ! is_string($ranAt) || $ranAt === '') {
return is_array($data['check_basis'] ?? null) ? $data['check_basis'] : null;
}
$scope = $this->scopeFingerprintFromData($data);
$results = is_array($data['check_results'] ?? null) ? $data['check_results'] : [];
return [
'fingerprint' => $scope->fingerprint,
'ran_at' => $ranAt,
'blocking_count' => (int) ($summary['blocking'] ?? 0),
'warning_count' => (int) ($summary['warning'] ?? 0),
'result_codes' => array_values(array_filter(array_map(static function (mixed $result): ?string {
$code = is_array($result) ? ($result['code'] ?? null) : null;
return is_string($code) && $code !== '' ? $code : null;
}, $results))),
];
}
/**
* @param array<string, mixed> $data
* @return array{
* fingerprint: string,
* generated_at: string,
* summary: array<string, mixed>
* }|null
*/
public function previewBasisFromData(array $data): ?array
{
$summary = $data['preview_summary'] ?? null;
$ranAt = $data['preview_ran_at'] ?? null;
if (! is_array($summary) || ! is_string($ranAt) || $ranAt === '') {
return is_array($data['preview_basis'] ?? null) ? $data['preview_basis'] : null;
}
$scope = $this->scopeFingerprintFromData($data);
return [
'fingerprint' => $scope->fingerprint,
'generated_at' => $ranAt,
'summary' => $summary,
];
}
/**
* @param array<string, mixed> $data
*/
public function previewIntegrityFromData(array $data): PreviewIntegrityState
{
$scope = $this->scopeFingerprintFromData($data);
$basis = is_array($data['preview_basis'] ?? null) ? $data['preview_basis'] : null;
$generatedAt = is_string($data['preview_ran_at'] ?? null) ? $data['preview_ran_at'] : null;
$hasPreviewEvidence = (is_array($data['preview_summary'] ?? null) && $data['preview_summary'] !== [])
|| ($basis !== null && $basis !== [])
|| (is_string($generatedAt) && $generatedAt !== '');
if (! $hasPreviewEvidence) {
return new PreviewIntegrityState(
state: PreviewIntegrityState::STATE_NOT_GENERATED,
freshnessPolicy: PreviewIntegrityState::FRESHNESS_POLICY,
fingerprint: null,
generatedAt: null,
invalidationReasons: [],
rerunRequired: true,
displaySummary: 'Generate a preview for the current scope before claiming calm execution readiness.',
);
}
$basisFingerprint = is_string($basis['fingerprint'] ?? null) ? $basis['fingerprint'] : null;
$reasons = $this->invalidationReasonsForBasis(
currentScope: $scope,
basis: $basis,
explicitReasons: $data['preview_invalidation_reasons'] ?? null,
);
if ($reasons !== []) {
return new PreviewIntegrityState(
state: PreviewIntegrityState::STATE_INVALIDATED,
freshnessPolicy: PreviewIntegrityState::FRESHNESS_POLICY,
fingerprint: $basisFingerprint,
generatedAt: is_string($basis['generated_at'] ?? null) ? $basis['generated_at'] : $generatedAt,
invalidationReasons: $reasons,
rerunRequired: true,
displaySummary: 'The last preview no longer matches the current restore scope. Regenerate it before real execution.',
);
}
if ($basisFingerprint === null || ! is_string($basis['generated_at'] ?? null)) {
return new PreviewIntegrityState(
state: PreviewIntegrityState::STATE_STALE,
freshnessPolicy: PreviewIntegrityState::FRESHNESS_POLICY,
fingerprint: $basisFingerprint,
generatedAt: is_string($basis['generated_at'] ?? null) ? $basis['generated_at'] : $generatedAt,
invalidationReasons: [],
rerunRequired: true,
displaySummary: 'Preview evidence exists, but it cannot prove it still belongs to the current scope.',
);
}
return new PreviewIntegrityState(
state: PreviewIntegrityState::STATE_CURRENT,
freshnessPolicy: PreviewIntegrityState::FRESHNESS_POLICY,
fingerprint: $basisFingerprint,
generatedAt: $basis['generated_at'],
invalidationReasons: [],
rerunRequired: false,
displaySummary: 'Preview evidence is current for the selected restore scope.',
);
}
/**
* @param array<string, mixed> $data
*/
public function checksIntegrityFromData(array $data): ChecksIntegrityState
{
$scope = $this->scopeFingerprintFromData($data);
$basis = is_array($data['check_basis'] ?? null) ? $data['check_basis'] : null;
$summary = is_array($data['check_summary'] ?? null) ? $data['check_summary'] : [];
$ranAt = is_string($data['checks_ran_at'] ?? null) ? $data['checks_ran_at'] : null;
$blockingCount = (int) ($summary['blocking'] ?? ($basis['blocking_count'] ?? 0));
$warningCount = (int) ($summary['warning'] ?? ($basis['warning_count'] ?? 0));
$hasCheckEvidence = $summary !== []
|| ($basis !== null && $basis !== [])
|| (is_string($ranAt) && $ranAt !== '');
if (! $hasCheckEvidence) {
return new ChecksIntegrityState(
state: ChecksIntegrityState::STATE_NOT_RUN,
freshnessPolicy: ChecksIntegrityState::FRESHNESS_POLICY,
fingerprint: null,
ranAt: null,
blockingCount: 0,
warningCount: 0,
invalidationReasons: [],
rerunRequired: true,
displaySummary: 'Run safety checks for the current scope before offering real execution calmly.',
);
}
$basisFingerprint = is_string($basis['fingerprint'] ?? null) ? $basis['fingerprint'] : null;
$reasons = $this->invalidationReasonsForBasis(
currentScope: $scope,
basis: $basis,
explicitReasons: $data['check_invalidation_reasons'] ?? null,
);
if ($reasons !== []) {
return new ChecksIntegrityState(
state: ChecksIntegrityState::STATE_INVALIDATED,
freshnessPolicy: ChecksIntegrityState::FRESHNESS_POLICY,
fingerprint: $basisFingerprint,
ranAt: is_string($basis['ran_at'] ?? null) ? $basis['ran_at'] : $ranAt,
blockingCount: $blockingCount,
warningCount: $warningCount,
invalidationReasons: $reasons,
rerunRequired: true,
displaySummary: 'The last checks no longer match the current restore scope. Run them again before real execution.',
);
}
if ($basisFingerprint === null || ! is_string($basis['ran_at'] ?? null)) {
return new ChecksIntegrityState(
state: ChecksIntegrityState::STATE_STALE,
freshnessPolicy: ChecksIntegrityState::FRESHNESS_POLICY,
fingerprint: $basisFingerprint,
ranAt: is_string($basis['ran_at'] ?? null) ? $basis['ran_at'] : $ranAt,
blockingCount: $blockingCount,
warningCount: $warningCount,
invalidationReasons: [],
rerunRequired: true,
displaySummary: 'Checks evidence exists, but it cannot prove it still belongs to the current scope.',
);
}
return new ChecksIntegrityState(
state: ChecksIntegrityState::STATE_CURRENT,
freshnessPolicy: ChecksIntegrityState::FRESHNESS_POLICY,
fingerprint: $basisFingerprint,
ranAt: $basis['ran_at'],
blockingCount: $blockingCount,
warningCount: $warningCount,
invalidationReasons: [],
rerunRequired: false,
displaySummary: 'Checks evidence is current for the selected restore scope.',
);
}
/**
* @param array<string, mixed> $data
*/
public function executionReadiness(Tenant $tenant, User $user, array $data, bool $dryRun = false): ExecutionReadinessState
{
$blockingReasons = [];
if (! $this->capabilityResolver->can($user, $tenant, Capabilities::TENANT_MANAGE)) {
$blockingReasons[] = 'missing_capability';
}
if (! $dryRun) {
try {
$this->writeGate->evaluate($tenant, 'restore.execute');
} catch (ProviderAccessHardeningRequired $exception) {
$blockingReasons[] = $exception->reasonCode;
}
}
$checkSummary = is_array($data['check_summary'] ?? null) ? $data['check_summary'] : [];
$blockingCount = (int) ($checkSummary['blocking'] ?? 0);
$hasBlockers = (bool) ($checkSummary['has_blockers'] ?? ($blockingCount > 0));
if ($hasBlockers) {
$blockingReasons[] = 'risk_blocker';
}
$blockingReasons = array_values(array_unique($blockingReasons));
$allowed = $blockingReasons === [];
$displaySummary = $allowed
? 'The platform can start a restore for this tenant once the operator chooses to proceed.'
: 'Technical startability is blocked until capability, write-gate, or hard-blocker issues are resolved.';
return new ExecutionReadinessState(
allowed: $allowed,
blockingReasons: $blockingReasons,
mutationScope: $dryRun ? 'simulation_only' : 'microsoft_tenant',
requiredCapability: Capabilities::TENANT_MANAGE,
displaySummary: $displaySummary,
);
}
/**
* @param array<string, mixed> $data
*/
public function safetyAssessment(Tenant $tenant, User $user, array $data): RestoreSafetyAssessment
{
$previewIntegrity = $this->previewIntegrityFromData($data);
$checksIntegrity = $this->checksIntegrityFromData($data);
$executionReadiness = $this->executionReadiness($tenant, $user, $data, false);
if (! $executionReadiness->allowed) {
return new RestoreSafetyAssessment(
state: RestoreSafetyAssessment::STATE_BLOCKED,
executionReadiness: $executionReadiness,
previewIntegrity: $previewIntegrity,
checksIntegrity: $checksIntegrity,
positiveClaimSuppressed: true,
primaryIssueCode: $executionReadiness->blockingReasons[0] ?? 'execution_blocked',
primaryNextAction: 'resolve_blockers',
summary: 'Real execution is blocked until the technical prerequisites are healthy again.',
);
}
if (! $previewIntegrity->isCurrent()) {
return new RestoreSafetyAssessment(
state: RestoreSafetyAssessment::STATE_RISKY,
executionReadiness: $executionReadiness,
previewIntegrity: $previewIntegrity,
checksIntegrity: $checksIntegrity,
positiveClaimSuppressed: true,
primaryIssueCode: $previewIntegrity->state,
primaryNextAction: 'regenerate_preview',
summary: 'Real execution is technically possible, but the preview basis is not current enough to support a calm go signal.',
);
}
if (! $checksIntegrity->isCurrent()) {
return new RestoreSafetyAssessment(
state: RestoreSafetyAssessment::STATE_RISKY,
executionReadiness: $executionReadiness,
previewIntegrity: $previewIntegrity,
checksIntegrity: $checksIntegrity,
positiveClaimSuppressed: true,
primaryIssueCode: $checksIntegrity->state,
primaryNextAction: 'rerun_checks',
summary: 'Real execution is technically possible, but the checks basis is not current enough to support a calm go signal.',
);
}
if ($checksIntegrity->warningCount > 0) {
return new RestoreSafetyAssessment(
state: RestoreSafetyAssessment::STATE_READY_WITH_CAUTION,
executionReadiness: $executionReadiness,
previewIntegrity: $previewIntegrity,
checksIntegrity: $checksIntegrity,
positiveClaimSuppressed: true,
primaryIssueCode: 'warnings_present',
primaryNextAction: 'review_warnings',
summary: 'Current preview and checks exist, but warnings remain. The restore can start, yet calm safety claims stay suppressed.',
);
}
return new RestoreSafetyAssessment(
state: RestoreSafetyAssessment::STATE_READY,
executionReadiness: $executionReadiness,
previewIntegrity: $previewIntegrity,
checksIntegrity: $checksIntegrity,
positiveClaimSuppressed: false,
primaryIssueCode: null,
primaryNextAction: 'execute',
summary: 'Current preview and checks support real execution for the selected scope.',
);
}
/**
* @param array<string, mixed> $data
*/
public function executionSafetySnapshot(Tenant $tenant, User $user, array $data): RestoreExecutionSafetySnapshot
{
$scope = $this->scopeFingerprintFromData($data);
$assessment = $this->safetyAssessment($tenant, $user, $data);
return new RestoreExecutionSafetySnapshot(
evaluatedAt: now('UTC')->toIso8601String(),
scopeFingerprint: $scope->fingerprint,
previewState: $assessment->previewIntegrity->state,
checksState: $assessment->checksIntegrity->state,
safetyState: $assessment->state,
blockingCount: $assessment->checksIntegrity->blockingCount,
warningCount: $assessment->checksIntegrity->warningCount,
primaryIssueCode: $assessment->primaryIssueCode,
followUpBoundary: 'run_completed_not_recovery_proven',
);
}
public function resultAttentionForRun(RestoreRun $restoreRun): RestoreResultAttention
{
$status = strtolower((string) $restoreRun->status);
$results = is_array($restoreRun->results) ? $restoreRun->results : [];
$items = is_array($results['items'] ?? null) ? array_values($results['items']) : [];
$foundations = is_array($results['foundations'] ?? null) ? array_values($results['foundations']) : [];
$operationOutcome = strtolower((string) ($restoreRun->operationRun?->outcome ?? ''));
$itemStatuses = array_values(array_filter(array_map(static function (mixed $item): ?string {
$status = is_array($item) ? ($item['status'] ?? null) : null;
return is_string($status) && $status !== '' ? strtolower($status) : null;
}, $items)));
$failedItems = count(array_filter($itemStatuses, static fn (string $itemStatus): bool => $itemStatus === 'failed'));
$partialItems = count(array_filter($itemStatuses, static fn (string $itemStatus): bool => in_array($itemStatus, ['partial', 'manual_required'], true)));
$skippedItems = count(array_filter($itemStatuses, static fn (string $itemStatus): bool => $itemStatus === 'skipped'));
$failedAssignments = $restoreRun->getFailedAssignmentsCount();
$skippedAssignments = $restoreRun->getSkippedAssignmentsCount();
$foundationSkips = count(array_filter($foundations, static function (mixed $entry): bool {
return is_array($entry) && in_array(($entry['decision'] ?? null), ['failed', 'skipped'], true);
}));
if ($restoreRun->is_dry_run || in_array($status, ['draft', 'scoped', 'checked', 'previewed'], true)) {
return new RestoreResultAttention(
state: RestoreResultAttention::STATE_NOT_EXECUTED,
followUpRequired: false,
primaryCauseFamily: 'none',
summary: 'This record proves preview truth, not tenant recovery.',
primaryNextAction: 'review_preview',
recoveryClaimBoundary: 'preview_only_no_execution_proven',
tone: 'gray',
);
}
if ($status === 'failed' || $operationOutcome === 'failed') {
return new RestoreResultAttention(
state: RestoreResultAttention::STATE_FAILED,
followUpRequired: true,
primaryCauseFamily: $this->primaryCauseFamilyForRun($restoreRun),
summary: 'The restore did not complete successfully. Follow-up is still required.',
primaryNextAction: 'review_failures',
recoveryClaimBoundary: 'execution_failed_no_recovery_claim',
tone: 'danger',
);
}
if ($failedItems > 0 || $partialItems > 0 || $failedAssignments > 0 || in_array($operationOutcome, ['partially_succeeded', 'blocked'], true)) {
return new RestoreResultAttention(
state: RestoreResultAttention::STATE_PARTIAL,
followUpRequired: true,
primaryCauseFamily: $this->primaryCauseFamilyForRun($restoreRun),
summary: 'The restore reached a terminal state, but some items or assignments still need follow-up.',
primaryNextAction: 'review_partial_items',
recoveryClaimBoundary: 'run_completed_not_recovery_proven',
tone: 'warning',
);
}
if ($skippedItems > 0 || $skippedAssignments > 0 || $foundationSkips > 0 || (int) (($restoreRun->metadata ?? [])['non_applied'] ?? 0) > 0) {
return new RestoreResultAttention(
state: RestoreResultAttention::STATE_COMPLETED_WITH_FOLLOW_UP,
followUpRequired: true,
primaryCauseFamily: $this->primaryCauseFamilyForRun($restoreRun),
summary: 'The restore completed, but follow-up remains for skipped or non-applied work.',
primaryNextAction: 'review_skipped_items',
recoveryClaimBoundary: 'run_completed_not_recovery_proven',
tone: 'warning',
);
}
return new RestoreResultAttention(
state: RestoreResultAttention::STATE_COMPLETED,
followUpRequired: false,
primaryCauseFamily: 'none',
summary: 'The restore completed without visible follow-up, but this still does not prove tenant-wide recovery.',
primaryNextAction: 'review_result',
recoveryClaimBoundary: 'run_completed_not_recovery_proven',
tone: 'success',
);
}
/**
* @param array<string, mixed>|null $basis
* @return list<string>
*/
public function invalidationReasonsForBasis(
RestoreScopeFingerprint $currentScope,
?array $basis,
mixed $explicitReasons = null,
): array {
$reasons = $this->normalizeReasons($explicitReasons);
if ($basis === null) {
return $reasons;
}
if ($reasons !== []) {
return $reasons;
}
$basisFingerprint = is_string($basis['fingerprint'] ?? null) ? $basis['fingerprint'] : null;
if ($basisFingerprint !== null && $currentScope->matches($basisFingerprint)) {
return [];
}
$basisBackupSetId = is_numeric($basis['backup_set_id'] ?? null) ? (int) $basis['backup_set_id'] : null;
$basisScopeMode = $basis['scope_mode'] ?? null;
$basisSelectedItemIds = is_array($basis['selected_item_ids'] ?? null) ? $basis['selected_item_ids'] : [];
$basisGroupMappingFingerprint = is_string($basis['group_mapping_fingerprint'] ?? null)
? $basis['group_mapping_fingerprint']
: null;
$derivedReasons = [];
if ($basisBackupSetId !== null && $basisBackupSetId !== $currentScope->backupSetId) {
$derivedReasons[] = 'backup_set_changed';
}
if (is_string($basisScopeMode) && $basisScopeMode !== $currentScope->scopeMode) {
$derivedReasons[] = 'scope_mode_changed';
}
if ($this->normalizeIds($basisSelectedItemIds) !== $currentScope->selectedItemIds) {
$derivedReasons[] = 'selected_items_changed';
}
if ($basisGroupMappingFingerprint !== null && $basisGroupMappingFingerprint !== $currentScope->groupMappingFingerprint) {
$derivedReasons[] = 'group_mapping_changed';
}
if ($derivedReasons === [] && $basisFingerprint !== null && ! $currentScope->matches($basisFingerprint)) {
$derivedReasons[] = 'scope_mismatch';
}
return $derivedReasons;
}
private function primaryCauseFamilyForRun(RestoreRun $restoreRun): string
{
$operationContext = is_array($restoreRun->operationRun?->context) ? $restoreRun->operationRun->context : [];
$reasonCode = strtolower((string) ($operationContext['reason_code'] ?? ''));
if ($reasonCode !== '' && (str_contains($reasonCode, 'capability') || str_contains($reasonCode, 'rbac') || str_contains($reasonCode, 'write'))) {
return 'write_gate_or_rbac';
}
$results = is_array($restoreRun->results) ? $restoreRun->results : [];
$items = is_array($results['items'] ?? null) ? array_values($results['items']) : [];
foreach ($items as $item) {
if (! is_array($item)) {
continue;
}
$reason = strtolower((string) ($item['reason'] ?? ''));
$graphMessage = strtolower((string) ($item['graph_error_message'] ?? ''));
if (str_contains($reason, 'mapping') || str_contains($reason, 'group') || str_contains($graphMessage, 'mapping')) {
return 'missing_dependency_or_mapping';
}
if (str_contains($reason, 'metadata only') || str_contains($reason, 'manual')) {
return 'payload_quality';
}
if ($graphMessage !== '' || filled($item['graph_error_code'] ?? null)) {
return 'item_level_failure';
}
}
return 'none';
}
/**
* @return list<string>
*/
private function normalizeReasons(mixed $reasons): array
{
if (! is_array($reasons)) {
return [];
}
$normalized = array_values(array_filter(array_map(static function (mixed $reason): ?string {
if (! is_string($reason)) {
return null;
}
$reason = trim($reason);
return $reason === '' ? null : $reason;
}, $reasons)));
return array_values(array_unique($normalized));
}
/**
* @return list<int>
*/
private function normalizeIds(array $ids): array
{
$normalized = [];
foreach ($ids as $id) {
if (is_int($id) && $id > 0) {
$normalized[] = $id;
continue;
}
if (is_string($id) && ctype_digit($id) && (int) $id > 0) {
$normalized[] = (int) $id;
}
}
$normalized = array_values(array_unique($normalized));
sort($normalized);
return $normalized;
}
}