$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 $data * @return array{ * backup_set_id: ?int, * scope_mode: string, * selected_item_ids: list, * group_mapping: array, * 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 $data * @return array{ * fingerprint: string, * ran_at: string, * blocking_count: int, * warning_count: int, * result_codes: list * }|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 $data * @return array{ * fingerprint: string, * generated_at: string, * summary: array * }|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 $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 $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 $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 $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 $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|null $basis * @return list */ 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 */ 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 */ 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; } }