context)) { return self::empty(); } return self::fromContext($run->context); } /** * @param array $context */ public static function fromContext(array $context): array { $baselineCompare = $context['baseline_compare'] ?? null; if (! is_array($baselineCompare)) { return self::empty(); } return self::fromBaselineCompare($baselineCompare); } /** * @param array $baselineCompare */ public static function fromBaselineCompare(array $baselineCompare): array { $evidenceGaps = $baselineCompare['evidence_gaps'] ?? null; $evidenceGaps = is_array($evidenceGaps) ? $evidenceGaps : []; $byReason = self::normalizeCounts($evidenceGaps['by_reason'] ?? null); $normalizedSubjects = self::normalizeSubjects($evidenceGaps['subjects'] ?? null); foreach ($normalizedSubjects['subjects'] as $reasonCode => $subjects) { if (! array_key_exists($reasonCode, $byReason)) { $byReason[$reasonCode] = count($subjects); } } $count = self::normalizeTotalCount( $evidenceGaps['count'] ?? null, $byReason, $normalizedSubjects['subjects'], ); $detailState = self::detailState($count, $normalizedSubjects); $buckets = []; foreach (self::orderedReasons($byReason, $normalizedSubjects['subjects']) as $reasonCode) { $rows = $detailState === 'structured_details_recorded' ? array_map( static fn (array $subject): array => self::projectSubjectRow($subject), $normalizedSubjects['subjects'][$reasonCode] ?? [], ) : []; $reasonCount = $byReason[$reasonCode] ?? count($rows); if ($reasonCount <= 0 && $rows === []) { continue; } $recordedCount = count($rows); $structuralCount = count(array_filter( $rows, static fn (array $row): bool => (bool) ($row['structural'] ?? false), )); $transientCount = count(array_filter( $rows, static fn (array $row): bool => (bool) ($row['retryable'] ?? false), )); $operationalCount = max(0, $recordedCount - $structuralCount - $transientCount); $searchText = trim(implode(' ', array_filter([ Str::lower($reasonCode), Str::lower(self::reasonLabel($reasonCode)), ...array_map( static fn (array $row): string => (string) ($row['search_text'] ?? ''), $rows, ), ]))); $buckets[] = [ 'reason_code' => $reasonCode, 'reason_label' => self::reasonLabel($reasonCode), 'count' => $reasonCount, 'recorded_count' => $recordedCount, 'missing_detail_count' => max(0, $reasonCount - $recordedCount), 'structural_count' => $structuralCount, 'operational_count' => $operationalCount, 'transient_count' => $transientCount, 'detail_state' => self::bucketDetailState($detailState, $recordedCount), 'search_text' => $searchText, 'rows' => $rows, ]; } $recordedSubjectsTotal = array_sum(array_map( static fn (array $bucket): int => (int) ($bucket['recorded_count'] ?? 0), $buckets, )); $structuralCount = array_sum(array_map( static fn (array $bucket): int => (int) ($bucket['structural_count'] ?? 0), $buckets, )); $operationalCount = array_sum(array_map( static fn (array $bucket): int => (int) ($bucket['operational_count'] ?? 0), $buckets, )); $transientCount = array_sum(array_map( static fn (array $bucket): int => (int) ($bucket['transient_count'] ?? 0), $buckets, )); $legacyMode = $detailState === 'legacy_broad_reason'; return [ 'summary' => [ 'count' => $count, 'by_reason' => $byReason, 'detail_state' => $detailState, 'recorded_subjects_total' => $recordedSubjectsTotal, 'missing_detail_count' => max(0, $count - $recordedSubjectsTotal), 'structural_count' => $structuralCount, 'operational_count' => $operationalCount, 'transient_count' => $transientCount, 'legacy_mode' => $legacyMode, 'requires_regeneration' => $legacyMode, ], 'buckets' => $buckets, ]; } /** * @param array $baselineCompare * @return array */ public static function diagnosticsPayload(array $baselineCompare): array { return array_filter([ 'reason_code' => self::stringOrNull($baselineCompare['reason_code'] ?? null), 'subjects_total' => self::intOrNull($baselineCompare['subjects_total'] ?? null), 'resume_token' => self::stringOrNull($baselineCompare['resume_token'] ?? null), 'fidelity' => self::stringOrNull($baselineCompare['fidelity'] ?? null), 'coverage' => is_array($baselineCompare['coverage'] ?? null) ? $baselineCompare['coverage'] : null, 'evidence_capture' => is_array($baselineCompare['evidence_capture'] ?? null) ? $baselineCompare['evidence_capture'] : null, 'evidence_gaps' => is_array($baselineCompare['evidence_gaps'] ?? null) ? $baselineCompare['evidence_gaps'] : null, ], static fn (mixed $value): bool => $value !== null && $value !== []); } public static function reasonLabel(string $reason): string { $reason = trim($reason); return match ($reason) { 'ambiguous_match' => 'Ambiguous inventory match', 'policy_record_missing' => 'Policy record missing', 'inventory_record_missing' => 'Inventory record missing', 'foundation_not_policy_backed' => 'Foundation not policy-backed', 'invalid_subject' => 'Invalid subject', 'duplicate_subject' => 'Duplicate subject', 'capture_failed' => 'Evidence capture failed', 'retryable_capture_failure' => 'Retryable evidence capture failure', 'budget_exhausted' => 'Capture budget exhausted', 'throttled' => 'Graph throttled', 'invalid_support_config' => 'Invalid support configuration', 'missing_current' => 'Missing current evidence', 'missing_role_definition_baseline_version_reference' => 'Missing baseline role definition evidence', 'missing_role_definition_current_version_reference' => 'Missing current role definition evidence', 'missing_role_definition_compare_surface' => 'Missing role definition compare surface', 'rollout_disabled' => 'Rollout disabled', 'policy_not_found' => 'Legacy policy not found', default => Str::of($reason)->replace('_', ' ')->trim()->ucfirst()->toString(), }; } public static function subjectClassLabel(string $subjectClass): string { return match (trim($subjectClass)) { SubjectClass::PolicyBacked->value => 'Policy-backed', SubjectClass::InventoryBacked->value => 'Inventory-backed', SubjectClass::FoundationBacked->value => 'Foundation-backed', default => 'Derived', }; } public static function resolutionOutcomeLabel(string $resolutionOutcome): string { return match (trim($resolutionOutcome)) { ResolutionOutcome::ResolvedPolicy->value => 'Resolved policy', ResolutionOutcome::ResolvedInventory->value => 'Resolved inventory', ResolutionOutcome::PolicyRecordMissing->value => 'Policy record missing', ResolutionOutcome::InventoryRecordMissing->value => 'Inventory record missing', ResolutionOutcome::FoundationInventoryOnly->value => 'Foundation inventory only', ResolutionOutcome::InvalidSubject->value => 'Invalid subject', ResolutionOutcome::DuplicateSubject->value => 'Duplicate subject', ResolutionOutcome::AmbiguousMatch->value => 'Ambiguous match', ResolutionOutcome::InvalidSupportConfig->value => 'Invalid support configuration', ResolutionOutcome::Throttled->value => 'Graph throttled', ResolutionOutcome::CaptureFailed->value => 'Capture failed', ResolutionOutcome::RetryableCaptureFailure->value => 'Retryable capture failure', ResolutionOutcome::BudgetExhausted->value => 'Budget exhausted', }; } public static function operatorActionCategoryLabel(string $operatorActionCategory): string { return match (trim($operatorActionCategory)) { OperatorActionCategory::Retry->value => 'Retry', OperatorActionCategory::RunInventorySync->value => 'Run inventory sync', OperatorActionCategory::RunPolicySyncOrBackup->value => 'Run policy sync or backup', OperatorActionCategory::ReviewPermissions->value => 'Review permissions', OperatorActionCategory::InspectSubjectMapping->value => 'Inspect subject mapping', OperatorActionCategory::ProductFollowUp->value => 'Product follow-up', default => 'No action', }; } /** * @param array $byReason * @return list */ public static function topReasons(array $byReason, int $limit = 5): array { $normalized = self::normalizeCounts($byReason); arsort($normalized); return array_map( static fn (string $reason, int $count): array => [ 'reason_code' => $reason, 'reason_label' => self::reasonLabel($reason), 'count' => $count, ], array_slice(array_keys($normalized), 0, $limit), array_slice(array_values($normalized), 0, $limit), ); } /** * @param list> $buckets * @return list> */ public static function tableRows(array $buckets): array { $rows = []; foreach ($buckets as $bucket) { if (! is_array($bucket)) { continue; } $bucketRows = is_array($bucket['rows'] ?? null) ? $bucket['rows'] : []; foreach ($bucketRows as $row) { if (! is_array($row)) { continue; } $reasonCode = self::stringOrNull($row['reason_code'] ?? null); $policyType = self::stringOrNull($row['policy_type'] ?? null); $subjectKey = self::stringOrNull($row['subject_key'] ?? null); $subjectClass = self::stringOrNull($row['subject_class'] ?? null); $resolutionOutcome = self::stringOrNull($row['resolution_outcome'] ?? null); $operatorActionCategory = self::stringOrNull($row['operator_action_category'] ?? null); if ($reasonCode === null || $policyType === null || $subjectKey === null || $subjectClass === null || $resolutionOutcome === null || $operatorActionCategory === null) { continue; } $rows[] = [ '__id' => md5(implode('|', [$reasonCode, $policyType, $subjectKey, $resolutionOutcome])), 'reason_code' => $reasonCode, 'reason_label' => self::reasonLabel($reasonCode), 'policy_type' => $policyType, 'subject_key' => $subjectKey, 'subject_class' => $subjectClass, 'subject_class_label' => self::subjectClassLabel($subjectClass), 'resolution_path' => self::stringOrNull($row['resolution_path'] ?? null), 'resolution_outcome' => $resolutionOutcome, 'resolution_outcome_label' => self::resolutionOutcomeLabel($resolutionOutcome), 'operator_action_category' => $operatorActionCategory, 'operator_action_category_label' => self::operatorActionCategoryLabel($operatorActionCategory), 'structural' => (bool) ($row['structural'] ?? false), 'retryable' => (bool) ($row['retryable'] ?? false), 'search_text' => self::stringOrNull($row['search_text'] ?? null) ?? '', ]; } } return $rows; } /** * @param list> $rows * @return array */ public static function reasonFilterOptions(array $rows): array { return collect($rows) ->filter(fn (array $row): bool => filled($row['reason_code'] ?? null) && filled($row['reason_label'] ?? null)) ->mapWithKeys(fn (array $row): array => [ (string) $row['reason_code'] => (string) $row['reason_label'], ]) ->sortBy(fn (string $label): string => Str::lower($label)) ->all(); } /** * @param list> $rows * @return array */ public static function policyTypeFilterOptions(array $rows): array { return collect($rows) ->pluck('policy_type') ->filter(fn (mixed $value): bool => is_string($value) && trim($value) !== '') ->mapWithKeys(fn (string $value): array => [$value => $value]) ->sortKeysUsing('strnatcasecmp') ->all(); } /** * @param list> $rows * @return array */ public static function subjectClassFilterOptions(array $rows): array { return collect($rows) ->filter(fn (array $row): bool => filled($row['subject_class'] ?? null)) ->mapWithKeys(fn (array $row): array => [ (string) $row['subject_class'] => (string) ($row['subject_class_label'] ?? self::subjectClassLabel((string) $row['subject_class'])), ]) ->sortBy(fn (string $label): string => Str::lower($label)) ->all(); } /** * @param list> $rows * @return array */ public static function actionCategoryFilterOptions(array $rows): array { return collect($rows) ->filter(fn (array $row): bool => filled($row['operator_action_category'] ?? null)) ->mapWithKeys(fn (array $row): array => [ (string) $row['operator_action_category'] => (string) ($row['operator_action_category_label'] ?? self::operatorActionCategoryLabel((string) $row['operator_action_category'])), ]) ->sortBy(fn (string $label): string => Str::lower($label)) ->all(); } private static function empty(): array { return [ 'summary' => [ 'count' => 0, 'by_reason' => [], 'detail_state' => 'no_gaps', 'recorded_subjects_total' => 0, 'missing_detail_count' => 0, 'structural_count' => 0, 'operational_count' => 0, 'transient_count' => 0, 'legacy_mode' => false, 'requires_regeneration' => false, ], 'buckets' => [], ]; } /** * @return array */ private static function normalizeCounts(mixed $value): array { if (! is_array($value)) { return []; } $normalized = []; foreach ($value as $reason => $count) { if (! is_string($reason) || trim($reason) === '' || ! is_numeric($count)) { continue; } $intCount = (int) $count; if ($intCount <= 0) { continue; } $normalized[trim($reason)] = $intCount; } arsort($normalized); return $normalized; } /** * @return array{ * subjects: array>>, * legacy_mode: bool * } */ private static function normalizeSubjects(mixed $value): array { if ($value === null) { return [ 'subjects' => [], 'legacy_mode' => false, ]; } if (! is_array($value)) { return [ 'subjects' => [], 'legacy_mode' => true, ]; } if (! array_is_list($value)) { return [ 'subjects' => [], 'legacy_mode' => true, ]; } $subjects = []; foreach ($value as $item) { $normalized = self::normalizeStructuredSubject($item); if ($normalized === null) { return [ 'subjects' => [], 'legacy_mode' => true, ]; } $subjects[$normalized['reason_code']][] = $normalized; } foreach ($subjects as &$bucket) { usort($bucket, static function (array $left, array $right): int { return [$left['policy_type'], $left['subject_key'], $left['resolution_outcome']] <=> [$right['policy_type'], $right['subject_key'], $right['resolution_outcome']]; }); } unset($bucket); ksort($subjects); return [ 'subjects' => $subjects, 'legacy_mode' => false, ]; } /** * @return array|null */ private static function normalizeStructuredSubject(mixed $value): ?array { if (! is_array($value)) { return null; } $policyType = self::stringOrNull($value['policy_type'] ?? null); $subjectKey = self::stringOrNull($value['subject_key'] ?? null); $subjectClass = self::stringOrNull($value['subject_class'] ?? null); $resolutionPath = self::stringOrNull($value['resolution_path'] ?? null); $resolutionOutcome = self::stringOrNull($value['resolution_outcome'] ?? null); $reasonCode = self::stringOrNull($value['reason_code'] ?? null); $operatorActionCategory = self::stringOrNull($value['operator_action_category'] ?? null); if ($policyType === null || $subjectKey === null || $subjectClass === null || $resolutionPath === null || $resolutionOutcome === null || $reasonCode === null || $operatorActionCategory === null) { return null; } if (! SubjectClass::tryFrom($subjectClass) instanceof SubjectClass || ! ResolutionPath::tryFrom($resolutionPath) instanceof ResolutionPath || ! ResolutionOutcome::tryFrom($resolutionOutcome) instanceof ResolutionOutcome || ! OperatorActionCategory::tryFrom($operatorActionCategory) instanceof OperatorActionCategory) { return null; } $sourceModelExpected = self::stringOrNull($value['source_model_expected'] ?? null); $sourceModelExpected = in_array($sourceModelExpected, ['policy', 'inventory', 'derived'], true) ? $sourceModelExpected : null; $sourceModelFound = self::stringOrNull($value['source_model_found'] ?? null); $sourceModelFound = in_array($sourceModelFound, ['policy', 'inventory', 'derived'], true) ? $sourceModelFound : null; return [ 'policy_type' => $policyType, 'subject_external_id' => self::stringOrNull($value['subject_external_id'] ?? null), 'subject_key' => $subjectKey, 'subject_class' => $subjectClass, 'resolution_path' => $resolutionPath, 'resolution_outcome' => $resolutionOutcome, 'reason_code' => $reasonCode, 'operator_action_category' => $operatorActionCategory, 'structural' => self::boolOrFalse($value['structural'] ?? null), 'retryable' => self::boolOrFalse($value['retryable'] ?? null), 'source_model_expected' => $sourceModelExpected, 'source_model_found' => $sourceModelFound, 'legacy_reason_code' => self::stringOrNull($value['legacy_reason_code'] ?? null), ]; } /** * @param array $byReason * @param array>> $subjects * @return list */ private static function orderedReasons(array $byReason, array $subjects): array { $reasons = array_keys($byReason); foreach (array_keys($subjects) as $reason) { if (! in_array($reason, $reasons, true)) { $reasons[] = $reason; } } return $reasons; } /** * @param array $byReason * @param array>> $subjects */ private static function normalizeTotalCount(mixed $count, array $byReason, array $subjects): int { if (is_numeric($count)) { $intCount = (int) $count; if ($intCount >= 0) { return $intCount; } } $byReasonCount = array_sum($byReason); if ($byReasonCount > 0) { return $byReasonCount; } return array_sum(array_map( static fn (array $rows): int => count($rows), $subjects, )); } /** * @param array{subjects: array>>, legacy_mode: bool} $subjects */ private static function detailState(int $count, array $subjects): string { if ($count <= 0) { return 'no_gaps'; } if ($subjects['legacy_mode']) { return 'legacy_broad_reason'; } return $subjects['subjects'] !== [] ? 'structured_details_recorded' : 'details_not_recorded'; } private static function bucketDetailState(string $detailState, int $recordedCount): string { if ($detailState === 'legacy_broad_reason') { return 'legacy_broad_reason'; } if ($recordedCount > 0) { return 'structured_details_recorded'; } return 'details_not_recorded'; } /** * @param array $subject * @return array */ private static function projectSubjectRow(array $subject): array { $reasonCode = (string) $subject['reason_code']; $subjectClass = (string) $subject['subject_class']; $resolutionOutcome = (string) $subject['resolution_outcome']; $operatorActionCategory = (string) $subject['operator_action_category']; return array_merge($subject, [ 'reason_label' => self::reasonLabel($reasonCode), 'subject_class_label' => self::subjectClassLabel($subjectClass), 'resolution_outcome_label' => self::resolutionOutcomeLabel($resolutionOutcome), 'operator_action_category_label' => self::operatorActionCategoryLabel($operatorActionCategory), 'search_text' => Str::lower(trim(implode(' ', array_filter([ $reasonCode, self::reasonLabel($reasonCode), (string) ($subject['policy_type'] ?? ''), (string) ($subject['subject_key'] ?? ''), $subjectClass, self::subjectClassLabel($subjectClass), (string) ($subject['resolution_path'] ?? ''), $resolutionOutcome, self::resolutionOutcomeLabel($resolutionOutcome), $operatorActionCategory, self::operatorActionCategoryLabel($operatorActionCategory), (string) ($subject['subject_external_id'] ?? ''), ])))), ]); } private static function stringOrNull(mixed $value): ?string { if (! is_string($value)) { return null; } $trimmed = trim($value); return $trimmed !== '' ? $trimmed : null; } private static function intOrNull(mixed $value): ?int { return is_numeric($value) ? (int) $value : null; } private static function boolOrFalse(mixed $value): bool { if (is_bool($value)) { return $value; } if (is_int($value) || is_float($value) || is_string($value)) { return filter_var($value, FILTER_VALIDATE_BOOL); } return false; } }