supportsType((string) $run->type)) { return ReconciliationResult::unsupported( reasonCode: LifecycleReconciliationReason::AdapterOutOfSync->value, reasonMessage: 'This adapter only supports review pack generation runs.', evidence: [ 'adapter' => $this->key(), 'type' => (string) $run->type, ], ); } $context = is_array($run->context) ? $run->context : []; $workspaceId = (int) ($run->workspace_id ?? $context['workspace_id'] ?? 0); $tenantId = (int) ($run->managed_environment_id ?? $context['managed_environment_id'] ?? 0); $reviewId = is_numeric($context['environment_review_id'] ?? null) ? (int) $context['environment_review_id'] : null; $snapshotId = is_numeric($context['evidence_snapshot_id'] ?? null) ? (int) $context['evidence_snapshot_id'] : null; $options = $this->normalizeOptions($context); $evidence = [ 'adapter' => $this->key(), 'operation_run_id' => (int) $run->getKey(), 'workspace_id' => $workspaceId, 'managed_environment_id' => $tenantId, 'environment_review_id' => $reviewId, 'evidence_snapshot_id' => $snapshotId, 'include_pii' => $options['include_pii'], 'include_operations' => $options['include_operations'], ]; if ($workspaceId <= 0 || $tenantId <= 0 || ($reviewId === null && $snapshotId === null)) { return ReconciliationResult::attentionRequired( reasonCode: LifecycleReconciliationReason::AdapterOutOfSync->value, reasonMessage: 'The run no longer carries enough review pack scope to reconcile it safely.', evidence: $evidence, ); } $tenant = ManagedEnvironment::query() ->whereKey($tenantId) ->where('workspace_id', $workspaceId) ->first(); if (! $tenant instanceof ManagedEnvironment) { return ReconciliationResult::attentionRequired( reasonCode: LifecycleReconciliationReason::AdapterOutOfSync->value, reasonMessage: 'The run no longer points at a valid workspace-owned environment for review pack reconciliation.', evidence: $evidence, ); } $expectedFingerprint = null; $candidatesQuery = ReviewPack::query() ->where('workspace_id', $workspaceId) ->where('managed_environment_id', $tenantId) ->orderByDesc('generated_at') ->orderByDesc('id'); if ($reviewId !== null) { $review = EnvironmentReview::query() ->whereKey($reviewId) ->where('workspace_id', $workspaceId) ->where('managed_environment_id', $tenantId) ->first(); if (! $review instanceof EnvironmentReview) { return ReconciliationResult::attentionRequired( reasonCode: LifecycleReconciliationReason::AdapterOutOfSync->value, reasonMessage: 'The queued review scope no longer resolves to a current review record safely.', evidence: $evidence, ); } $expectedFingerprint = $this->reviewPacks->computeFingerprintForReview($review, $options); $candidatesQuery->where('environment_review_id', $reviewId); } else { $snapshot = EvidenceSnapshot::query() ->whereKey($snapshotId) ->where('workspace_id', $workspaceId) ->where('managed_environment_id', $tenantId) ->first(); if (! $snapshot instanceof EvidenceSnapshot) { return ReconciliationResult::attentionRequired( reasonCode: LifecycleReconciliationReason::AdapterOutOfSync->value, reasonMessage: 'The queued evidence basis for this review pack no longer resolves safely.', evidence: $evidence, ); } $expectedFingerprint = $this->reviewPacks->computeFingerprintForSnapshot($snapshot, $options); $candidatesQuery->where('evidence_snapshot_id', (int) $snapshot->getKey()); } $evidence['expected_fingerprint'] = $expectedFingerprint; $candidates = $candidatesQuery ->get() ->filter(fn (ReviewPack $pack): bool => $this->matchesOptions($pack, $options)) ->values(); $evidence['considered_pack_ids'] = $candidates->modelKeys(); $evidence['considered_packs'] = $candidates ->map(fn (ReviewPack $pack): array => $this->reviewPackReference($pack)) ->values() ->all(); /** @var ReviewPack|null $pack */ $pack = $candidates->first(fn (ReviewPack $candidate): bool => (string) $candidate->fingerprint === $expectedFingerprint); if (! $pack instanceof ReviewPack) { if ($candidates->isEmpty()) { return ReconciliationResult::notReconciled( reasonCode: LifecycleReconciliationReason::AdapterOutOfSync->value, reasonMessage: 'No matching review pack is available yet for this run.', evidence: $evidence, ); } if ($candidates->count() === 1) { $candidate = $candidates->first(); return ReconciliationResult::attentionRequired( reasonCode: LifecycleReconciliationReason::AdapterOutOfSync->value, reasonMessage: 'A review pack exists for this scope, but its fingerprint no longer matches the queued run safely.', evidence: $evidence + ['chosen_review_pack_id' => (int) $candidate->getKey()], related: $this->relatedReviewPackMetadata($candidate), summaryCounts: $this->summaryCounts($candidate), ); } return ReconciliationResult::attentionRequired( reasonCode: LifecycleReconciliationReason::AdapterOutOfSync->value, reasonMessage: 'Multiple review packs match this scope, so the run needs manual review.', evidence: $evidence, ); } $related = $this->relatedReviewPackMetadata($pack); if ($this->isUsablePack($pack)) { return ReconciliationResult::reconciledSucceeded( reasonCode: LifecycleReconciliationReason::AdapterOutOfSync->value, reasonMessage: (int) ($pack->operation_run_id ?? 0) === (int) $run->getKey() ? 'The queued review pack was already completed before the run finished updating.' : 'A matching review pack was already available for this run.', evidence: $evidence + ['chosen_review_pack_id' => (int) $pack->getKey()], related: $related, summaryCounts: $this->summaryCounts($pack), ); } if (in_array((string) $pack->status, [ ReviewPackStatus::Queued->value, ReviewPackStatus::Generating->value, ], true)) { return ReconciliationResult::notReconciled( reasonCode: LifecycleReconciliationReason::AdapterOutOfSync->value, reasonMessage: (int) ($pack->operation_run_id ?? 0) === (int) $run->getKey() ? 'The queued review pack is still generating and does not prove final output truth yet.' : 'A matching review pack exists, but it is still generating.', evidence: $evidence + ['chosen_review_pack_id' => (int) $pack->getKey()], related: $related, ); } $reasonMessage = match (true) { (string) $pack->status === ReviewPackStatus::Expired->value => 'A matching review pack exists, but it is already expired.', (string) $pack->status === ReviewPackStatus::Failed->value => 'A matching review pack exists, but generation failed.', ! $this->hasShareableFile($pack) => 'A matching review pack exists, but it does not expose a shareable artifact safely yet.', default => 'A matching review pack exists, but it is not ready for use.', }; return ReconciliationResult::attentionRequired( reasonCode: LifecycleReconciliationReason::AdapterOutOfSync->value, reasonMessage: $reasonMessage, evidence: $evidence + ['chosen_review_pack_id' => (int) $pack->getKey()], related: $related, summaryCounts: $this->summaryCounts($pack), ); } public function reconcileException(OperationRun $run, Throwable $throwable): ?ReconciliationResult { return null; } /** * @param array $context * @return array{include_pii: bool, include_operations: bool} */ private function normalizeOptions(array $context): array { return [ 'include_pii' => (bool) ($context['include_pii'] ?? true), 'include_operations' => (bool) ($context['include_operations'] ?? true), ]; } /** * @param array{include_pii: bool, include_operations: bool} $options */ private function matchesOptions(ReviewPack $pack, array $options): bool { $packOptions = is_array($pack->options) ? $pack->options : []; return (bool) ($packOptions['include_pii'] ?? true) === $options['include_pii'] && (bool) ($packOptions['include_operations'] ?? true) === $options['include_operations']; } private function isUsablePack(ReviewPack $pack): bool { if ((string) $pack->status !== ReviewPackStatus::Ready->value) { return false; } if ($pack->expires_at !== null && $pack->expires_at->isPast()) { return false; } return $this->hasShareableFile($pack); } private function hasShareableFile(ReviewPack $pack): bool { return is_string($pack->file_disk) && trim($pack->file_disk) !== '' && is_string($pack->file_path) && trim($pack->file_path) !== '' && is_string($pack->sha256) && trim($pack->sha256) !== ''; } /** * @return array */ private function relatedReviewPackMetadata(ReviewPack $pack): array { return array_filter([ 'type' => 'review_pack', 'id' => (int) $pack->getKey(), 'status' => (string) $pack->status, 'fingerprint' => (string) $pack->fingerprint, 'operation_run_id' => is_numeric($pack->operation_run_id) ? (int) $pack->operation_run_id : null, 'environment_review_id' => is_numeric($pack->environment_review_id) ? (int) $pack->environment_review_id : null, 'evidence_snapshot_id' => is_numeric($pack->evidence_snapshot_id) ? (int) $pack->evidence_snapshot_id : null, 'expires_at' => $pack->expires_at?->toIso8601String(), ], static fn (mixed $value): bool => $value !== null && $value !== []); } /** * @return array */ private function reviewPackReference(ReviewPack $pack): array { return [ 'id' => (int) $pack->getKey(), 'status' => (string) $pack->status, 'fingerprint' => (string) $pack->fingerprint, 'operation_run_id' => is_numeric($pack->operation_run_id) ? (int) $pack->operation_run_id : null, 'environment_review_id' => is_numeric($pack->environment_review_id) ? (int) $pack->environment_review_id : null, 'evidence_snapshot_id' => is_numeric($pack->evidence_snapshot_id) ? (int) $pack->evidence_snapshot_id : null, ]; } /** * @return array */ private function summaryCounts(ReviewPack $pack): array { $summary = is_array($pack->summary) ? $pack->summary : []; return array_filter([ 'finding_count' => is_numeric($summary['finding_count'] ?? null) ? (int) $summary['finding_count'] : null, 'report_count' => is_numeric($summary['report_count'] ?? null) ? (int) $summary['report_count'] : null, 'operation_count' => is_numeric($summary['operation_count'] ?? null) ? (int) $summary['operation_count'] : null, ], static fn (mixed $value): bool => is_int($value)); } }