authorizeRequest($finding, $tenant, $actor); $ownerUserId = $this->validatedTenantMemberId( tenant: $tenant, userId: $payload['owner_user_id'] ?? null, field: 'owner_user_id', required: true, ); $requestReason = $this->validatedReason($payload['request_reason'] ?? null, 'request_reason'); $reviewDueAt = $this->validatedFutureDate($payload['review_due_at'] ?? null, 'review_due_at'); $expiresAt = $this->validatedOptionalExpiry($payload['expires_at'] ?? null, $reviewDueAt); $evidenceReferences = $this->validatedEvidenceReferences($payload['evidence_references'] ?? []); $requestedAt = CarbonImmutable::now(); /** @var FindingException $exception */ $exception = DB::transaction(function () use ($finding, $tenant, $actor, $ownerUserId, $requestReason, $reviewDueAt, $expiresAt, $evidenceReferences, $requestedAt): FindingException { $exception = FindingException::query() ->where('finding_id', (int) $finding->getKey()) ->lockForUpdate() ->first(); if ($exception instanceof FindingException && $exception->isPending()) { throw new InvalidArgumentException('An exception request is already pending for this finding.'); } if ($exception instanceof FindingException && $exception->isActiveLike()) { throw new InvalidArgumentException('This finding already has an active exception.'); } $exception ??= new FindingException([ 'workspace_id' => (int) $tenant->workspace_id, 'tenant_id' => (int) $tenant->getKey(), 'finding_id' => (int) $finding->getKey(), ]); $before = $this->exceptionSnapshot($exception); $exception->fill([ 'requested_by_user_id' => (int) $actor->getKey(), 'owner_user_id' => $ownerUserId, 'approved_by_user_id' => null, 'status' => FindingException::STATUS_PENDING, 'current_validity_state' => FindingException::VALIDITY_MISSING_SUPPORT, 'request_reason' => $requestReason, 'approval_reason' => null, 'rejection_reason' => null, 'revocation_reason' => null, 'requested_at' => $requestedAt, 'approved_at' => null, 'rejected_at' => null, 'revoked_at' => null, 'effective_from' => null, 'expires_at' => $expiresAt, 'review_due_at' => $reviewDueAt, 'evidence_summary' => $this->evidenceSummary($evidenceReferences), ]); $exception->save(); $this->replaceEvidenceReferences($exception, $evidenceReferences); $decision = $exception->decisions()->create([ 'workspace_id' => (int) $tenant->workspace_id, 'tenant_id' => (int) $tenant->getKey(), 'actor_user_id' => (int) $actor->getKey(), 'decision_type' => FindingExceptionDecision::TYPE_REQUESTED, 'reason' => $requestReason, 'expires_at' => $expiresAt, 'metadata' => [ 'review_due_at' => $reviewDueAt->toIso8601String(), 'evidence_reference_count' => count($evidenceReferences), ], 'decided_at' => $requestedAt, ]); $exception->forceFill([ 'current_decision_id' => (int) $decision->getKey(), ])->save(); $after = $this->exceptionSnapshot($exception->fresh($this->exceptionRelationships()) ?? $exception); $this->auditLogger->log( tenant: $tenant, action: AuditActionId::FindingExceptionRequested, actorId: (int) $actor->getKey(), actorEmail: $actor->email, actorName: $actor->name, resourceType: 'finding_exception', resourceId: (string) $exception->getKey(), targetLabel: 'Finding exception #'.$exception->getKey(), context: [ 'metadata' => [ 'finding_id' => (int) $finding->getKey(), 'decision_type' => FindingExceptionDecision::TYPE_REQUESTED, 'before' => $before, 'after' => $after, ], ], ); return $exception; }); return $this->governanceResolver->syncExceptionState( $exception->fresh($this->exceptionRelationships()) ?? $exception, ); } /** * @param array{ * effective_from?: mixed, * expires_at?: mixed, * approval_reason?: mixed * } $payload */ public function approve(FindingException $exception, User $actor, array $payload): FindingException { $tenant = $this->tenantForException($exception); $workspace = $this->workspaceForTenant($tenant); $this->authorizeApproval($exception, $tenant, $workspace, $actor); $effectiveFrom = $this->validatedDate($payload['effective_from'] ?? null, 'effective_from'); $expiresAt = $this->validatedOptionalExpiry($payload['expires_at'] ?? null, $effectiveFrom, required: true); $approvalReason = $this->validatedOptionalReason($payload['approval_reason'] ?? null, 'approval_reason'); $approvedAt = CarbonImmutable::now(); /** @var FindingException $approvedException */ $approvedException = DB::transaction(function () use ($exception, $tenant, $actor, $effectiveFrom, $expiresAt, $approvalReason, $approvedAt): FindingException { /** @var FindingException $lockedException */ $lockedException = FindingException::query() ->with(['finding', 'tenant', 'requester', 'currentDecision']) ->whereKey((int) $exception->getKey()) ->lockForUpdate() ->firstOrFail(); if (! $lockedException->isPending()) { throw new InvalidArgumentException('Only pending exception requests can be approved.'); } if ((int) $lockedException->requested_by_user_id === (int) $actor->getKey()) { throw new InvalidArgumentException('Requesters cannot approve their own exception requests.'); } $isRenewalApproval = $lockedException->isPendingRenewal(); $before = $this->exceptionSnapshot($lockedException); $lockedException->fill([ 'status' => FindingException::STATUS_ACTIVE, 'current_validity_state' => FindingException::VALIDITY_VALID, 'approved_by_user_id' => (int) $actor->getKey(), 'approval_reason' => $approvalReason, 'approved_at' => $approvedAt, 'effective_from' => $effectiveFrom, 'expires_at' => $expiresAt, 'rejection_reason' => null, 'rejected_at' => null, 'revocation_reason' => null, ]); $lockedException->save(); $decision = $lockedException->decisions()->create([ 'workspace_id' => (int) $tenant->workspace_id, 'tenant_id' => (int) $tenant->getKey(), 'actor_user_id' => (int) $actor->getKey(), 'decision_type' => $isRenewalApproval ? FindingExceptionDecision::TYPE_RENEWED : FindingExceptionDecision::TYPE_APPROVED, 'reason' => $approvalReason, 'effective_from' => $effectiveFrom, 'expires_at' => $expiresAt, 'metadata' => [ 'request_type' => $isRenewalApproval ? 'renewal' : 'initial', ], 'decided_at' => $approvedAt, ]); $lockedException->forceFill([ 'current_decision_id' => (int) $decision->getKey(), ])->save(); $finding = $lockedException->finding; if (! $finding instanceof Finding) { throw new InvalidArgumentException('The linked finding could not be resolved.'); } if (! $isRenewalApproval) { $this->findingWorkflowService->riskAcceptFromException( finding: $finding, tenant: $tenant, actor: $actor, reason: $this->findingRiskAcceptedReason($lockedException, $approvalReason), ); } $resolvedException = $this->governanceResolver->syncExceptionState( $lockedException->fresh($this->exceptionRelationships()) ?? $lockedException, ); $after = $this->exceptionSnapshot($resolvedException); $this->auditLogger->log( tenant: $tenant, action: $isRenewalApproval ? AuditActionId::FindingExceptionRenewed : AuditActionId::FindingExceptionApproved, actorId: (int) $actor->getKey(), actorEmail: $actor->email, actorName: $actor->name, resourceType: 'finding_exception', resourceId: (string) $resolvedException->getKey(), targetLabel: 'Finding exception #'.$resolvedException->getKey(), context: [ 'metadata' => [ 'finding_id' => (int) $finding->getKey(), 'decision_type' => $isRenewalApproval ? FindingExceptionDecision::TYPE_RENEWED : FindingExceptionDecision::TYPE_APPROVED, 'before' => $before, 'after' => $after, ], ], ); return $resolvedException; }); return $approvedException; } /** * @param array{ * rejection_reason?: mixed * } $payload */ public function reject(FindingException $exception, User $actor, array $payload): FindingException { $tenant = $this->tenantForException($exception); $workspace = $this->workspaceForTenant($tenant); $this->authorizeApproval($exception, $tenant, $workspace, $actor); $rejectionReason = $this->validatedReason($payload['rejection_reason'] ?? null, 'rejection_reason'); $rejectedAt = CarbonImmutable::now(); /** @var FindingException $rejectedException */ $rejectedException = DB::transaction(function () use ($exception, $tenant, $actor, $rejectionReason, $rejectedAt): FindingException { /** @var FindingException $lockedException */ $lockedException = FindingException::query() ->with(['finding', 'currentDecision']) ->whereKey((int) $exception->getKey()) ->lockForUpdate() ->firstOrFail(); if (! $lockedException->isPending()) { throw new InvalidArgumentException('Only pending exception requests can be rejected.'); } $isRenewalRejection = $lockedException->isPendingRenewal(); $before = $this->exceptionSnapshot($lockedException); if ($isRenewalRejection) { $lockedException->fill([ 'status' => FindingException::STATUS_ACTIVE, 'rejection_reason' => $rejectionReason, 'rejected_at' => $rejectedAt, 'review_due_at' => $this->metadataDate($lockedException, 'previous_review_due_at') ?? $lockedException->review_due_at, ]); } else { $lockedException->fill([ 'status' => FindingException::STATUS_REJECTED, 'current_validity_state' => FindingException::VALIDITY_REJECTED, 'rejection_reason' => $rejectionReason, 'rejected_at' => $rejectedAt, 'approved_by_user_id' => null, 'approved_at' => null, 'approval_reason' => null, 'effective_from' => null, ]); } $lockedException->save(); $decision = $lockedException->decisions()->create([ 'workspace_id' => (int) $tenant->workspace_id, 'tenant_id' => (int) $tenant->getKey(), 'actor_user_id' => (int) $actor->getKey(), 'decision_type' => FindingExceptionDecision::TYPE_REJECTED, 'reason' => $rejectionReason, 'metadata' => [ 'request_type' => $isRenewalRejection ? 'renewal' : 'initial', ], 'decided_at' => $rejectedAt, ]); $lockedException->forceFill([ 'current_decision_id' => (int) $decision->getKey(), ])->save(); $resolvedException = $this->governanceResolver->syncExceptionState( $lockedException->fresh($this->exceptionRelationships()) ?? $lockedException, ); $after = $this->exceptionSnapshot($resolvedException); $this->auditLogger->log( tenant: $tenant, action: AuditActionId::FindingExceptionRejected, actorId: (int) $actor->getKey(), actorEmail: $actor->email, actorName: $actor->name, resourceType: 'finding_exception', resourceId: (string) $resolvedException->getKey(), targetLabel: 'Finding exception #'.$resolvedException->getKey(), context: [ 'metadata' => [ 'finding_id' => (int) $resolvedException->finding_id, 'decision_type' => FindingExceptionDecision::TYPE_REJECTED, 'before' => $before, 'after' => $after, ], ], ); return $resolvedException; }); return $rejectedException; } /** * @param array{ * owner_user_id?: mixed, * request_reason?: mixed, * review_due_at?: mixed, * expires_at?: mixed, * evidence_references?: mixed * } $payload */ public function renew(FindingException $exception, User $actor, array $payload): FindingException { $tenant = $this->tenantForException($exception); $this->authorizeManagement($exception, $tenant, $actor); $requestReason = $this->validatedReason($payload['request_reason'] ?? null, 'request_reason'); $reviewDueAt = $this->validatedFutureDate($payload['review_due_at'] ?? null, 'review_due_at'); $requestedExpiry = $this->validatedOptionalExpiry($payload['expires_at'] ?? null, $reviewDueAt); $evidenceReferences = $this->validatedEvidenceReferences($payload['evidence_references'] ?? []); $requestedAt = CarbonImmutable::now(); /** @var FindingException $renewedException */ $renewedException = DB::transaction(function () use ($exception, $tenant, $actor, $payload, $requestReason, $reviewDueAt, $requestedExpiry, $evidenceReferences, $requestedAt): FindingException { /** @var FindingException $lockedException */ $lockedException = FindingException::query() ->with(['currentDecision', 'finding']) ->whereKey((int) $exception->getKey()) ->lockForUpdate() ->firstOrFail(); if (! $lockedException->canBeRenewed()) { throw new InvalidArgumentException('Only active, expiring, or expired exceptions can be renewed.'); } $ownerUserId = array_key_exists('owner_user_id', $payload) ? $this->validatedTenantMemberId($tenant, $payload['owner_user_id'], 'owner_user_id') : (is_numeric($lockedException->owner_user_id) ? (int) $lockedException->owner_user_id : null); $before = $this->exceptionSnapshot($lockedException); $lockedException->fill([ 'requested_by_user_id' => (int) $actor->getKey(), 'owner_user_id' => $ownerUserId, 'status' => FindingException::STATUS_PENDING, 'request_reason' => $requestReason, 'requested_at' => $requestedAt, 'review_due_at' => $reviewDueAt, 'rejection_reason' => null, 'rejected_at' => null, 'revocation_reason' => null, 'evidence_summary' => $this->evidenceSummary($evidenceReferences), ]); $lockedException->save(); $this->replaceEvidenceReferences($lockedException, $evidenceReferences); $decision = $lockedException->decisions()->create([ 'workspace_id' => (int) $tenant->workspace_id, 'tenant_id' => (int) $tenant->getKey(), 'actor_user_id' => (int) $actor->getKey(), 'decision_type' => FindingExceptionDecision::TYPE_RENEWAL_REQUESTED, 'reason' => $requestReason, 'expires_at' => $requestedExpiry, 'metadata' => [ 'review_due_at' => $reviewDueAt->toIso8601String(), 'requested_expires_at' => $requestedExpiry?->toIso8601String(), 'previous_review_due_at' => $lockedException->getOriginal('review_due_at'), 'previous_expires_at' => $lockedException->getOriginal('expires_at'), 'evidence_reference_count' => count($evidenceReferences), ], 'decided_at' => $requestedAt, ]); $lockedException->forceFill([ 'current_decision_id' => (int) $decision->getKey(), ])->save(); $resolvedException = $this->governanceResolver->syncExceptionState( $lockedException->fresh($this->exceptionRelationships()) ?? $lockedException, ); $after = $this->exceptionSnapshot($resolvedException); $this->auditLogger->log( tenant: $tenant, action: AuditActionId::FindingExceptionRenewalRequested, actorId: (int) $actor->getKey(), actorEmail: $actor->email, actorName: $actor->name, resourceType: 'finding_exception', resourceId: (string) $resolvedException->getKey(), targetLabel: 'Finding exception #'.$resolvedException->getKey(), context: [ 'metadata' => [ 'finding_id' => (int) $resolvedException->finding_id, 'decision_type' => FindingExceptionDecision::TYPE_RENEWAL_REQUESTED, 'before' => $before, 'after' => $after, ], ], ); return $resolvedException; }); return $renewedException; } /** * @param array{ * revocation_reason?: mixed * } $payload */ public function revoke(FindingException $exception, User $actor, array $payload): FindingException { $tenant = $this->tenantForException($exception); $this->authorizeManagement($exception, $tenant, $actor); $revocationReason = $this->validatedReason($payload['revocation_reason'] ?? null, 'revocation_reason'); $revokedAt = CarbonImmutable::now(); /** @var FindingException $revokedException */ $revokedException = DB::transaction(function () use ($exception, $tenant, $actor, $revocationReason, $revokedAt): FindingException { /** @var FindingException $lockedException */ $lockedException = FindingException::query() ->with(['currentDecision', 'finding']) ->whereKey((int) $exception->getKey()) ->lockForUpdate() ->firstOrFail(); if (! $lockedException->canBeRevoked()) { throw new InvalidArgumentException('Only active or pending-renewal exceptions can be revoked.'); } $before = $this->exceptionSnapshot($lockedException); $lockedException->fill([ 'status' => FindingException::STATUS_REVOKED, 'current_validity_state' => FindingException::VALIDITY_REVOKED, 'revocation_reason' => $revocationReason, 'revoked_at' => $revokedAt, ]); $lockedException->save(); $decision = $lockedException->decisions()->create([ 'workspace_id' => (int) $tenant->workspace_id, 'tenant_id' => (int) $tenant->getKey(), 'actor_user_id' => (int) $actor->getKey(), 'decision_type' => FindingExceptionDecision::TYPE_REVOKED, 'reason' => $revocationReason, 'metadata' => [], 'decided_at' => $revokedAt, ]); $lockedException->forceFill([ 'current_decision_id' => (int) $decision->getKey(), ])->save(); $resolvedException = $this->governanceResolver->syncExceptionState( $lockedException->fresh($this->exceptionRelationships()) ?? $lockedException, ); $after = $this->exceptionSnapshot($resolvedException); $this->auditLogger->log( tenant: $tenant, action: AuditActionId::FindingExceptionRevoked, actorId: (int) $actor->getKey(), actorEmail: $actor->email, actorName: $actor->name, resourceType: 'finding_exception', resourceId: (string) $resolvedException->getKey(), targetLabel: 'Finding exception #'.$resolvedException->getKey(), context: [ 'metadata' => [ 'finding_id' => (int) $resolvedException->finding_id, 'decision_type' => FindingExceptionDecision::TYPE_REVOKED, 'before' => $before, 'after' => $after, ], ], ); return $resolvedException; }); return $revokedException; } private function authorizeRequest(Finding $finding, Tenant $tenant, User $actor): void { if (! $actor->canAccessTenant($tenant)) { throw new NotFoundHttpException; } $this->assertFindingOwnedByTenant($finding, $tenant); if ($this->capabilityResolver->can($actor, $tenant, Capabilities::FINDING_EXCEPTION_MANAGE)) { return; } throw new AuthorizationException('Missing capability for exception request.'); } private function authorizeApproval(FindingException $exception, Tenant $tenant, Workspace $workspace, User $actor): void { if (! $actor->canAccessTenant($tenant)) { throw new NotFoundHttpException; } if (! $this->workspaceCapabilityResolver->isMember($actor, $workspace)) { throw new NotFoundHttpException; } if ((int) $exception->workspace_id !== (int) $workspace->getKey() || (int) $exception->tenant_id !== (int) $tenant->getKey()) { throw new NotFoundHttpException; } if ($this->workspaceCapabilityResolver->can($actor, $workspace, Capabilities::FINDING_EXCEPTION_APPROVE)) { return; } throw new AuthorizationException('Missing capability for exception approval.'); } private function authorizeManagement(FindingException $exception, Tenant $tenant, User $actor): void { if (! $actor->canAccessTenant($tenant)) { throw new NotFoundHttpException; } if ((int) $exception->workspace_id !== (int) $tenant->workspace_id || (int) $exception->tenant_id !== (int) $tenant->getKey()) { throw new NotFoundHttpException; } if ($this->capabilityResolver->can($actor, $tenant, Capabilities::FINDING_EXCEPTION_MANAGE)) { return; } throw new AuthorizationException('Missing capability for exception management.'); } private function tenantForException(FindingException $exception): Tenant { $tenant = $exception->tenant; if (! $tenant instanceof Tenant) { $tenant = Tenant::query()->findOrFail((int) $exception->tenant_id); } return $tenant; } private function workspaceForTenant(Tenant $tenant): Workspace { $workspace = $tenant->workspace; if (! $workspace instanceof Workspace) { $workspace = Workspace::query()->findOrFail((int) $tenant->workspace_id); } return $workspace; } private function assertFindingOwnedByTenant(Finding $finding, Tenant $tenant): void { if ((int) $finding->tenant_id !== (int) $tenant->getKey()) { throw new NotFoundHttpException; } if ((int) $finding->workspace_id !== (int) $tenant->workspace_id) { throw new NotFoundHttpException; } } private function validatedTenantMemberId(Tenant $tenant, mixed $userId, string $field, bool $required = false): ?int { if ($userId === null || $userId === '') { if ($required) { throw new InvalidArgumentException(sprintf('%s is required.', $field)); } return null; } if (! is_numeric($userId) || (int) $userId <= 0) { throw new InvalidArgumentException(sprintf('%s must reference a valid user.', $field)); } $resolvedUserId = (int) $userId; $isMember = TenantMembership::query() ->where('tenant_id', (int) $tenant->getKey()) ->where('user_id', $resolvedUserId) ->exists(); if (! $isMember) { throw new InvalidArgumentException(sprintf('%s must reference a current tenant member.', $field)); } return $resolvedUserId; } private function validatedReason(mixed $reason, string $field): string { if (! is_string($reason)) { throw new InvalidArgumentException(sprintf('%s is required.', $field)); } $resolved = trim($reason); if ($resolved === '') { throw new InvalidArgumentException(sprintf('%s is required.', $field)); } if (mb_strlen($resolved) > 2000) { throw new InvalidArgumentException(sprintf('%s must be at most 2000 characters.', $field)); } return $resolved; } private function validatedOptionalReason(mixed $reason, string $field): ?string { if ($reason === null || $reason === '') { return null; } return $this->validatedReason($reason, $field); } private function validatedDate(mixed $value, string $field): CarbonImmutable { try { return CarbonImmutable::parse((string) $value); } catch (\Throwable) { throw new InvalidArgumentException(sprintf('%s must be a valid date-time.', $field)); } } private function validatedFutureDate(mixed $value, string $field): CarbonImmutable { $date = $this->validatedDate($value, $field); if ($date->lessThanOrEqualTo(CarbonImmutable::now())) { throw new InvalidArgumentException(sprintf('%s must be in the future.', $field)); } return $date; } private function validatedOptionalExpiry(mixed $value, CarbonImmutable $minimum, bool $required = false): ?CarbonImmutable { if ($value === null || $value === '') { if ($required) { throw new InvalidArgumentException('expires_at is required.'); } return null; } $expiresAt = $this->validatedDate($value, 'expires_at'); if ($expiresAt->lessThanOrEqualTo($minimum)) { throw new InvalidArgumentException('expires_at must be after the related review or effective date.'); } return $expiresAt; } /** * @return list * }> */ private function validatedEvidenceReferences(mixed $references): array { if (! is_array($references)) { return []; } $resolved = []; foreach ($references as $reference) { if (! is_array($reference)) { continue; } $sourceType = trim((string) ($reference['source_type'] ?? '')); $label = trim((string) ($reference['label'] ?? '')); if ($sourceType === '' || $label === '') { continue; } $measuredAt = null; if (($reference['measured_at'] ?? null) !== null && (string) $reference['measured_at'] !== '') { $measuredAt = $this->validatedDate($reference['measured_at'], 'measured_at'); } $resolved[] = [ 'source_type' => $sourceType, 'source_id' => filled($reference['source_id'] ?? null) ? trim((string) $reference['source_id']) : null, 'source_fingerprint' => filled($reference['source_fingerprint'] ?? null) ? trim((string) $reference['source_fingerprint']) : null, 'label' => mb_substr($label, 0, 255), 'measured_at' => $measuredAt, 'summary_payload' => is_array($reference['summary_payload'] ?? null) ? $reference['summary_payload'] : [], ]; } return $resolved; } /** * @param list * }> $references */ private function replaceEvidenceReferences(FindingException $exception, array $references): void { $exception->evidenceReferences()->delete(); foreach ($references as $reference) { $exception->evidenceReferences()->create([ 'workspace_id' => (int) $exception->workspace_id, 'tenant_id' => (int) $exception->tenant_id, 'source_type' => $reference['source_type'], 'source_id' => $reference['source_id'], 'source_fingerprint' => $reference['source_fingerprint'], 'label' => $reference['label'], 'measured_at' => $reference['measured_at'], 'summary_payload' => $reference['summary_payload'], ]); } } /** * @param list * }> $references * @return array */ private function evidenceSummary(array $references): array { return [ 'reference_count' => count($references), 'labels' => array_values(array_map( static fn (array $reference): string => $reference['label'], array_slice($references, 0, 5), )), ]; } private function findingRiskAcceptedReason(FindingException $exception, ?string $approvalReason): string { if (is_string($approvalReason) && $approvalReason !== '') { return mb_substr($approvalReason, 0, 255); } return 'Governed by approved exception #'.$exception->getKey(); } private function metadataDate(FindingException $exception, string $key): ?CarbonImmutable { $currentDecision = $exception->relationLoaded('currentDecision') ? $exception->currentDecision : $exception->currentDecision()->first(); if (! $currentDecision instanceof FindingExceptionDecision) { return null; } $value = $currentDecision->metadata[$key] ?? null; if (! is_string($value) || trim($value) === '') { return null; } return CarbonImmutable::parse($value); } /** * @return array */ private function exceptionSnapshot(FindingException $exception): array { return [ 'status' => $exception->status, 'current_validity_state' => $exception->current_validity_state, 'current_decision_type' => $exception->currentDecisionType(), 'finding_id' => $exception->finding_id, 'requested_by_user_id' => $exception->requested_by_user_id, 'owner_user_id' => $exception->owner_user_id, 'approved_by_user_id' => $exception->approved_by_user_id, 'requested_at' => $exception->requested_at?->toIso8601String(), 'approved_at' => $exception->approved_at?->toIso8601String(), 'rejected_at' => $exception->rejected_at?->toIso8601String(), 'revoked_at' => $exception->revoked_at?->toIso8601String(), 'effective_from' => $exception->effective_from?->toIso8601String(), 'expires_at' => $exception->expires_at?->toIso8601String(), 'review_due_at' => $exception->review_due_at?->toIso8601String(), 'request_reason' => $exception->request_reason, 'approval_reason' => $exception->approval_reason, 'rejection_reason' => $exception->rejection_reason, 'revocation_reason' => $exception->revocation_reason, ]; } /** * @return array> */ private function exceptionRelationships(): array { return [ 'finding', 'tenant', 'requester', 'owner', 'approver', 'currentDecision', 'decisions.actor', 'evidenceReferences', ]; } }