, * severity:string, * status:string, * title:string, * reason:string, * impact:string, * primary_action:array{ * key:string, * label:string, * type:string, * url:?string, * icon:string, * kind:string, * action_name:?string, * capability:?string, * requires_confirmation:bool, * audit_event:?string, * operation_run_type:?string, * disabled_reason:?string * }, * secondary_actions:list, * source_refs:list, * evidence_refs:list, * technical_details:array * } */ public function forQueue( FindingException $exception, ?string $detailUrl = null, ?string $findingUrl = null, ): array { return $this->buildCase($exception, 'queue', [ 'detail_url' => $detailUrl, 'finding_url' => $findingUrl, ]); } /** * @return array{ * key:string, * scope:array, * severity:string, * status:string, * title:string, * reason:string, * impact:string, * primary_action:array{ * key:string, * label:string, * type:string, * url:?string, * icon:string, * kind:string, * action_name:?string, * capability:?string, * requires_confirmation:bool, * audit_event:?string, * operation_run_type:?string, * disabled_reason:?string * }, * secondary_actions:list, * source_refs:list, * evidence_refs:list, * technical_details:array * } */ public function forDetail( FindingException $exception, ?string $queueUrl = null, ?string $findingUrl = null, ): array { return $this->buildCase($exception, 'detail', [ 'queue_url' => $queueUrl, 'finding_url' => $findingUrl, ]); } /** * @param array{detail_url?: ?string, finding_url?: ?string, queue_url?: ?string} $actions * @return array{ * key:string, * scope:array, * severity:string, * status:string, * title:string, * reason:string, * impact:string, * primary_action:array{ * key:string, * label:string, * type:string, * url:?string, * icon:string, * kind:string, * action_name:?string, * capability:?string, * requires_confirmation:bool, * audit_event:?string, * operation_run_type:?string, * disabled_reason:?string * }, * secondary_actions:list, * source_refs:list, * evidence_refs:list, * technical_details:array * } */ private function buildCase(FindingException $exception, string $surface, array $actions): array { $finding = $exception->relationLoaded('finding') ? $exception->finding : $exception->finding()->withSubjectDisplayName()->first(); $case = $this->classifyCase($exception, $finding); $title = $this->title($case, $exception); $reason = $this->reason($case, $exception, $finding); $impact = $this->impact($case); $caseKey = 'accepted_risk.'.$case; $primaryAction = $this->primaryAction( $caseKey, $case, $surface, $actions, $this->dominantNextStep($case, $exception, $finding), ); return ResolutionCase::make( key: $caseKey, scope: array_filter([ 'type' => 'finding_exception', 'surface' => $surface, 'workspace_id' => (int) $exception->workspace_id, 'managed_environment_id' => (int) $exception->managed_environment_id, 'finding_exception_id' => (int) $exception->getKey(), 'finding_id' => (int) $exception->finding_id, ], static fn (mixed $value): bool => $value !== null && $value !== ''), severity: $this->severity($case), status: $this->statusLabel($case), title: $title, reason: $reason, impact: $impact, primaryAction: $primaryAction, secondaryActions: $this->secondaryActions( $caseKey, $surface, $actions, is_string($primaryAction['url'] ?? null) ? $primaryAction['url'] : null, ), sourceRefs: $this->sourceRefs($exception), evidenceRefs: $this->evidenceRefs($exception), technicalDetails: $this->technicalDetails($exception, $finding), ); } private function classifyCase(FindingException $exception, ?Finding $finding): string { $status = $this->resolver->resolveExceptionStatus($exception); $storedValidity = (string) $exception->current_validity_state; $validity = $finding instanceof Finding ? $this->resolver->resolveGovernanceValidity($finding, $exception) : $storedValidity; $isPendingRenewal = $exception->isPendingRenewal(); if ($finding instanceof Finding && $exception->requiresFreshDecisionForFinding($finding)) { return 'fresh_decision_required'; } if (! $isPendingRenewal && $status === FindingException::STATUS_PENDING) { return 'pending'; } if ($storedValidity === FindingException::VALIDITY_MISSING_SUPPORT || $validity === FindingException::VALIDITY_MISSING_SUPPORT) { return 'missing_support'; } if ($status === FindingException::STATUS_EXPIRED || $validity === FindingException::VALIDITY_EXPIRED) { return 'expired'; } if ($status === FindingException::STATUS_REVOKED || $validity === FindingException::VALIDITY_REVOKED) { return 'revoked_or_rejected'; } if ($status === FindingException::STATUS_REJECTED || $validity === FindingException::VALIDITY_REJECTED) { return 'revoked_or_rejected'; } if ($status === FindingException::STATUS_EXPIRING || $validity === FindingException::VALIDITY_EXPIRING) { return 'expiring'; } if ($status === FindingException::STATUS_PENDING) { return 'pending'; } if ($this->missingGovernanceFields($exception) !== []) { return 'incomplete_governance'; } return 'ready'; } private function severity(string $case): string { return match ($case) { 'ready' => 'success', 'expiring', 'pending', 'fresh_decision_required', 'incomplete_governance' => 'warning', default => 'danger', }; } private function statusLabel(string $case): string { return match ($case) { 'ready' => __('localization.accepted_risk_guidance.status_ready'), 'expiring', 'pending', 'fresh_decision_required', 'incomplete_governance' => __('localization.accepted_risk_guidance.status_action_required'), default => __('localization.accepted_risk_guidance.status_blocked'), }; } private function title(string $case, FindingException $exception): string { return match ($case) { 'ready' => __('localization.accepted_risk_guidance.title_ready'), 'expiring' => __('localization.accepted_risk_guidance.title_expiring'), 'expired' => __('localization.accepted_risk_guidance.title_expired'), 'pending' => $exception->isPendingRenewal() ? __('localization.accepted_risk_guidance.title_pending_renewal') : __('localization.accepted_risk_guidance.title_pending'), 'missing_support' => __('localization.accepted_risk_guidance.title_missing_support'), 'fresh_decision_required' => __('localization.accepted_risk_guidance.title_fresh_decision_required'), 'incomplete_governance' => __('localization.accepted_risk_guidance.title_incomplete_governance'), default => (string) ( $this->resolver->resolveExceptionStatus($exception) === FindingException::STATUS_REJECTED ? __('localization.accepted_risk_guidance.title_rejected') : __('localization.accepted_risk_guidance.title_revoked') ), }; } private function reason(string $case, FindingException $exception, ?Finding $finding): string { return match ($case) { 'ready' => __('localization.accepted_risk_guidance.reason_ready'), 'expiring' => __('localization.accepted_risk_guidance.reason_expiring'), 'expired' => __('localization.accepted_risk_guidance.reason_expired'), 'pending' => $exception->isPendingRenewal() ? __('localization.accepted_risk_guidance.reason_pending_renewal') : __('localization.accepted_risk_guidance.reason_pending'), 'missing_support' => __('localization.accepted_risk_guidance.reason_missing_support'), 'fresh_decision_required' => __('localization.accepted_risk_guidance.reason_fresh_decision_required'), 'incomplete_governance' => __('localization.accepted_risk_guidance.reason_incomplete_governance', [ 'fields' => implode(', ', $this->missingGovernanceFields($exception)), ]), default => __('localization.accepted_risk_guidance.reason_revoked_or_rejected'), }; } private function impact(string $case): string { return match ($case) { 'ready' => __('localization.accepted_risk_guidance.impact_ready'), 'expiring' => __('localization.accepted_risk_guidance.impact_expiring'), 'expired' => __('localization.accepted_risk_guidance.impact_expired'), 'pending' => __('localization.accepted_risk_guidance.impact_pending'), 'missing_support' => __('localization.accepted_risk_guidance.impact_missing_support'), 'fresh_decision_required' => __('localization.accepted_risk_guidance.impact_fresh_decision_required'), 'incomplete_governance' => __('localization.accepted_risk_guidance.impact_incomplete_governance'), default => __('localization.accepted_risk_guidance.impact_revoked_or_rejected'), }; } private function dominantNextStep(string $case, FindingException $exception, ?Finding $finding): string { return match ($case) { 'ready' => __('localization.accepted_risk_guidance.next_step_ready'), 'expiring' => __('localization.accepted_risk_guidance.next_step_expiring'), 'expired' => __('localization.accepted_risk_guidance.next_step_expired'), 'pending' => $exception->isPendingRenewal() ? __('localization.accepted_risk_guidance.next_step_pending_renewal') : __('localization.accepted_risk_guidance.next_step_pending'), 'revoked_or_rejected' => __('localization.accepted_risk_guidance.next_step_revoked_or_rejected'), 'missing_support' => __('localization.accepted_risk_guidance.next_step_missing_support'), 'fresh_decision_required' => __('localization.accepted_risk_guidance.next_step_fresh_decision_required'), 'incomplete_governance' => __('localization.accepted_risk_guidance.next_step_incomplete_governance'), }; } /** * @param array{detail_url?: ?string, finding_url?: ?string, queue_url?: ?string} $actions * @return array{ * key:string, * label:string, * type:string, * url:?string, * icon:string, * kind:string, * action_name:?string, * capability:?string, * requires_confirmation:bool, * audit_event:?string, * operation_run_type:?string, * disabled_reason:?string * } */ private function primaryAction(string $caseKey, string $case, string $surface, array $actions, string $label): array { $url = $surface === 'detail' && $case === 'pending' ? ($actions['queue_url'] ?? null) : null; if (is_string($url) && trim($url) !== '') { return ResolutionAction::fromArray([ 'key' => $caseKey.'.primary_action', 'label' => $label, 'url' => trim($url), 'icon' => 'heroicon-o-arrow-top-right-on-square', 'kind' => 'environment_link', ], $caseKey.'.primary_action', $label); } return ResolutionAction::none( key: $caseKey.'.primary_action', label: $label, ); } /** * @param array{detail_url?: ?string, finding_url?: ?string, queue_url?: ?string} $actions * @return list */ private function secondaryActions(string $caseKey, string $surface, array $actions, ?string $primaryUrl = null): array { $secondaryActions = $surface === 'queue' ? [ $this->navigationAction( $caseKey.'.open_exception', __('localization.accepted_risk_guidance.action_open_exception'), $actions['detail_url'] ?? null, ), $this->navigationAction( $caseKey.'.open_finding', __('localization.accepted_risk_guidance.action_open_finding'), $actions['finding_url'] ?? null, ), ] : [ $this->navigationAction( $caseKey.'.open_finding', __('localization.accepted_risk_guidance.action_open_finding'), $actions['finding_url'] ?? null, ), $this->navigationAction( $caseKey.'.open_queue', __('localization.accepted_risk_guidance.action_open_queue'), $actions['queue_url'] ?? null, ), ]; return array_values(array_filter( $secondaryActions, static fn (mixed $action): bool => is_array($action) && (! is_string($primaryUrl) || $primaryUrl === '' || ($action['url'] ?? null) !== $primaryUrl), )); } /** * @return array{ * key:string, * label:string, * type:string, * url:?string, * icon:string, * kind:string, * action_name:?string, * capability:?string, * requires_confirmation:bool, * audit_event:?string, * operation_run_type:?string, * disabled_reason:?string * }|null */ private function navigationAction(string $key, string $label, ?string $url): ?array { if (! is_string($url) || trim($url) === '') { return null; } return ResolutionAction::fromArray([ 'key' => $key, 'label' => $label, 'url' => trim($url), 'icon' => 'heroicon-o-arrow-top-right-on-square', 'kind' => 'environment_link', ], $key, $label); } /** * @return list */ private function sourceRefs(FindingException $exception): array { $refs = [ [ 'type' => 'finding_exception', 'id' => (int) $exception->getKey(), ], [ 'type' => 'finding', 'id' => (int) $exception->finding_id, ], ]; if (is_numeric($exception->current_decision_id)) { $refs[] = [ 'type' => 'finding_exception_decision', 'id' => (int) $exception->current_decision_id, ]; } return $refs; } /** * @return list */ private function evidenceRefs(FindingException $exception): array { $evidenceReferences = $exception->relationLoaded('evidenceReferences') ? $exception->evidenceReferences : $exception->evidenceReferences()->get(); return $evidenceReferences ->map(fn ($reference): array => [ 'type' => 'finding_exception_evidence_reference', 'id' => (int) $reference->getKey(), ]) ->all(); } /** * @return array */ private function technicalDetails(FindingException $exception, ?Finding $finding): array { $resolvedStatus = $this->resolver->resolveExceptionStatus($exception); $resolvedValidity = $finding instanceof Finding ? (string) ($this->resolver->resolveGovernanceValidity($finding, $exception) ?? $exception->current_validity_state) : (string) $exception->current_validity_state; $displayValidity = (string) $exception->current_validity_state === FindingException::VALIDITY_MISSING_SUPPORT ? FindingException::VALIDITY_MISSING_SUPPORT : $resolvedValidity; $decisionType = $exception->currentDecisionType(); $missingFields = $this->missingGovernanceFields($exception); return array_filter([ __('localization.accepted_risk_guidance.detail_environment_label') => (string) ($exception->tenant?->name ?: __('localization.accepted_risk_guidance.detail_not_recorded_value')), __('localization.accepted_risk_guidance.detail_status_label') => BadgeRenderer::label(BadgeDomain::FindingExceptionStatus)($resolvedStatus), __('localization.accepted_risk_guidance.detail_validity_label') => BadgeRenderer::label(BadgeDomain::FindingRiskGovernanceValidity)($displayValidity), __('localization.accepted_risk_guidance.detail_owner_label') => (string) ($exception->owner?->name ?: __('localization.accepted_risk_guidance.detail_missing_value')), __('localization.accepted_risk_guidance.detail_review_due_label') => $exception->review_due_at?->toDayDateTimeString() ?? __('localization.accepted_risk_guidance.detail_missing_value'), __('localization.accepted_risk_guidance.detail_expires_label') => $exception->expires_at?->toDayDateTimeString() ?? __('localization.accepted_risk_guidance.detail_not_recorded_value'), __('localization.accepted_risk_guidance.detail_current_decision_label') => $decisionType !== null ? Str::headline($decisionType) : __('localization.accepted_risk_guidance.detail_not_recorded_value'), __('localization.accepted_risk_guidance.detail_request_reason_label') => filled($exception->request_reason) ? Str::limit((string) $exception->request_reason, 120) : __('localization.accepted_risk_guidance.detail_missing_value'), __('localization.accepted_risk_guidance.detail_missing_fields_label') => $missingFields !== [] ? implode(', ', $missingFields) : null, ], static fn (mixed $value): bool => is_string($value) && $value !== ''); } /** * @return list */ private function missingGovernanceFields(FindingException $exception): array { $fields = []; if (! is_numeric($exception->owner_user_id)) { $fields[] = __('localization.accepted_risk_guidance.detail_owner_label'); } if (! filled($exception->request_reason)) { $fields[] = __('localization.accepted_risk_guidance.detail_request_reason_label'); } if (! $exception->review_due_at instanceof \DateTimeInterface) { $fields[] = __('localization.accepted_risk_guidance.detail_review_due_label'); } return $fields; } }