620 lines
25 KiB
PHP
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;
|
|
}
|
|
}
|