TenantAtlas/apps/platform/app/Support/Operations/Reconciliation/EnvironmentReviewComposeDecision.php
Ahmed Darrazi 77c68d2d90
Some checks failed
PR Fast Feedback / fast-feedback (pull_request) Failing after 1m1s
feat: implement review compose reconciliation adapter (spec 359)
2026-06-06 16:41:10 +02:00

335 lines
15 KiB
PHP

<?php
declare(strict_types=1);
namespace App\Support\Operations\Reconciliation;
use App\Models\EnvironmentReview;
use App\Models\OperationRun;
use App\Support\EnvironmentReviewStatus;
use App\Support\Operations\LifecycleReconciliationReason;
use Illuminate\Support\Collection;
final class EnvironmentReviewComposeDecision
{
public const string ADAPTER = 'environment_review_compose';
public function evaluate(OperationRun $run): ReconciliationResult
{
if ((string) $run->type !== 'environment.review.compose') {
return ReconciliationResult::unsupported(
reasonCode: LifecycleReconciliationReason::AdapterOutOfSync->value,
reasonMessage: 'This adapter only supports environment review composition runs.',
evidence: [
'adapter' => self::ADAPTER,
'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);
$fingerprint = is_string($context['review_fingerprint'] ?? null)
? trim((string) $context['review_fingerprint'])
: '';
$explicitReviewId = is_numeric($context['review_id'] ?? null)
? (int) $context['review_id']
: null;
$evidence = [
'adapter' => self::ADAPTER,
'operation_run_id' => (int) $run->getKey(),
'workspace_id' => $workspaceId,
'managed_environment_id' => $tenantId,
'fingerprint' => $fingerprint,
'explicit_review_id' => $explicitReviewId,
];
if ($workspaceId <= 0 || $tenantId <= 0 || $fingerprint === '') {
return ReconciliationResult::attentionRequired(
reasonCode: LifecycleReconciliationReason::AdapterOutOfSync->value,
reasonMessage: 'The run no longer carries enough review scope to reconcile it safely.',
evidence: $evidence,
);
}
$candidates = EnvironmentReview::query()
->forWorkspace($workspaceId)
->forTenant($tenantId)
->where('fingerprint', $fingerprint)
->orderByDesc('id')
->get();
$evidence['considered_review_ids'] = $candidates->modelKeys();
$evidence['considered_reviews'] = $candidates
->map(fn (EnvironmentReview $review): array => $this->reviewReference($review))
->values()
->all();
$explicitReview = $explicitReviewId !== null
? $candidates->firstWhere('id', $explicitReviewId)
: null;
if ($explicitReviewId !== null && ! $explicitReview instanceof EnvironmentReview) {
return ReconciliationResult::attentionRequired(
reasonCode: LifecycleReconciliationReason::AdapterOutOfSync->value,
reasonMessage: 'The originally queued review no longer matches the current review truth safely.',
evidence: $evidence + ['explicit_review_missing' => true],
);
}
if ($explicitReview instanceof EnvironmentReview) {
$lineageDecision = $this->evaluateExplicitReviewLineage($explicitReview, $candidates, $evidence);
if ($lineageDecision instanceof ReconciliationResult) {
return $lineageDecision;
}
}
$usable = $candidates->filter(fn (EnvironmentReview $review): bool => $this->isUsableReview($review))->values();
if ($usable->count() === 1) {
/** @var EnvironmentReview $review */
$review = $usable->first();
return ReconciliationResult::reconciledSucceeded(
reasonCode: LifecycleReconciliationReason::AdapterOutOfSync->value,
reasonMessage: $review->status === EnvironmentReviewStatus::Published->value
? 'A matching published review was already available for this run.'
: 'A matching ready review was already available for this run.',
evidence: $evidence + ['chosen_review_id' => (int) $review->getKey()],
related: $this->relatedReviewMetadata($review),
summaryCounts: $this->summaryCounts($review),
);
}
if ($usable->count() > 1) {
return ReconciliationResult::attentionRequired(
reasonCode: LifecycleReconciliationReason::AdapterOutOfSync->value,
reasonMessage: 'Multiple matching reviews are available, so this run needs manual review.',
evidence: $evidence + ['ambiguous_review_ids' => $usable->modelKeys()],
);
}
if ($candidates->isEmpty()) {
return ReconciliationResult::notReconciled(
reasonCode: LifecycleReconciliationReason::AdapterOutOfSync->value,
reasonMessage: 'No matching review truth is available yet for this run.',
evidence: $evidence,
);
}
if ($candidates->contains(fn (EnvironmentReview $review): bool => $this->isBlockingState($review))) {
$blockingReview = $candidates->first(fn (EnvironmentReview $review): bool => $this->isBlockingState($review));
if ($blockingReview instanceof EnvironmentReview && (int) ($blockingReview->operation_run_id ?? 0) === (int) $run->getKey()) {
return ReconciliationResult::notReconciled(
reasonCode: LifecycleReconciliationReason::AdapterOutOfSync->value,
reasonMessage: 'The queued review is still composing and does not prove final review truth yet.',
evidence: $evidence + [
'chosen_review_id' => (int) $blockingReview->getKey(),
],
related: $this->relatedReviewMetadata($blockingReview),
);
}
return ReconciliationResult::blocked(
reasonCode: LifecycleReconciliationReason::AdapterOutOfSync->value,
reasonMessage: 'A matching review exists, but it is not ready yet.',
evidence: $evidence + [
'chosen_review_id' => $blockingReview instanceof EnvironmentReview ? (int) $blockingReview->getKey() : null,
],
related: $blockingReview instanceof EnvironmentReview ? $this->relatedReviewMetadata($blockingReview) : [],
summaryCounts: $blockingReview instanceof EnvironmentReview ? $this->summaryCounts($blockingReview) : [],
);
}
return ReconciliationResult::attentionRequired(
reasonCode: LifecycleReconciliationReason::AdapterOutOfSync->value,
reasonMessage: 'Review lineage needs operator confirmation before this run can be trusted.',
evidence: $evidence,
);
}
/**
* @param Collection<int, EnvironmentReview> $candidates
* @param array<string, mixed> $evidence
*/
private function evaluateExplicitReviewLineage(
EnvironmentReview $review,
Collection $candidates,
array $evidence,
): ?ReconciliationResult {
if ($this->isUsableReview($review)) {
return ReconciliationResult::reconciledSucceeded(
reasonCode: LifecycleReconciliationReason::AdapterOutOfSync->value,
reasonMessage: $review->status === EnvironmentReviewStatus::Published->value
? 'The queued review was already published before the run finished updating.'
: 'The queued review was already ready before the run finished updating.',
evidence: $evidence + ['chosen_review_id' => (int) $review->getKey()],
related: $this->relatedReviewMetadata($review),
summaryCounts: $this->summaryCounts($review),
);
}
$successorId = is_numeric($review->superseded_by_review_id)
? (int) $review->superseded_by_review_id
: null;
if ($successorId === null) {
if ($this->isBlockingState($review)) {
if ($candidates->contains(fn (EnvironmentReview $candidate): bool => $this->isUsableReview($candidate))) {
return null;
}
if ((int) ($review->operation_run_id ?? 0) === (int) ($evidence['operation_run_id'] ?? 0)) {
return ReconciliationResult::notReconciled(
reasonCode: LifecycleReconciliationReason::AdapterOutOfSync->value,
reasonMessage: 'The queued review is still composing and does not prove final review truth yet.',
evidence: $evidence + ['chosen_review_id' => (int) $review->getKey()],
related: $this->relatedReviewMetadata($review),
);
}
return ReconciliationResult::blocked(
reasonCode: LifecycleReconciliationReason::AdapterOutOfSync->value,
reasonMessage: 'The queued review still exists, but it is not ready yet.',
evidence: $evidence + ['chosen_review_id' => (int) $review->getKey()],
related: $this->relatedReviewMetadata($review),
summaryCounts: $this->summaryCounts($review),
);
}
if ($review->status === EnvironmentReviewStatus::Superseded->value) {
return ReconciliationResult::attentionRequired(
reasonCode: LifecycleReconciliationReason::AdapterOutOfSync->value,
reasonMessage: 'This run points at superseded review lineage that cannot be resolved safely.',
evidence: $evidence + ['lineage_review_id' => (int) $review->getKey()],
related: $this->relatedReviewMetadata($review),
);
}
return null;
}
/** @var EnvironmentReview|null $successor */
$successor = $candidates->firstWhere('id', $successorId);
if (! $successor instanceof EnvironmentReview) {
return ReconciliationResult::attentionRequired(
reasonCode: LifecycleReconciliationReason::AdapterOutOfSync->value,
reasonMessage: 'A successor review was recorded, but the matching review truth could not be resolved safely.',
evidence: $evidence + [
'lineage_review_id' => (int) $review->getKey(),
'lineage_successor_review_id' => $successorId,
],
related: $this->relatedReviewMetadata($review),
);
}
if ($this->isUsableReview($successor)) {
return ReconciliationResult::reconciledSucceeded(
reasonCode: LifecycleReconciliationReason::AdapterOutOfSync->value,
reasonMessage: 'A ready successor review was already available for this run.',
evidence: $evidence + [
'lineage_review_id' => (int) $review->getKey(),
'chosen_review_id' => (int) $successor->getKey(),
],
related: $this->relatedReviewMetadata($successor) + [
'lineage_review_id' => (int) $review->getKey(),
],
summaryCounts: $this->summaryCounts($successor),
);
}
if ($this->isBlockingState($successor)) {
return ReconciliationResult::blocked(
reasonCode: LifecycleReconciliationReason::AdapterOutOfSync->value,
reasonMessage: 'A successor review exists, but it is not ready yet.',
evidence: $evidence + [
'lineage_review_id' => (int) $review->getKey(),
'chosen_review_id' => (int) $successor->getKey(),
],
related: $this->relatedReviewMetadata($successor) + [
'lineage_review_id' => (int) $review->getKey(),
],
summaryCounts: $this->summaryCounts($successor),
);
}
return ReconciliationResult::attentionRequired(
reasonCode: LifecycleReconciliationReason::AdapterOutOfSync->value,
reasonMessage: 'The successor review needs operator review before this run can be trusted.',
evidence: $evidence + [
'lineage_review_id' => (int) $review->getKey(),
'chosen_review_id' => (int) $successor->getKey(),
],
related: $this->relatedReviewMetadata($successor) + [
'lineage_review_id' => (int) $review->getKey(),
],
);
}
private function isUsableReview(EnvironmentReview $review): bool
{
return in_array((string) $review->status, [
EnvironmentReviewStatus::Ready->value,
EnvironmentReviewStatus::Published->value,
], true);
}
private function isBlockingState(EnvironmentReview $review): bool
{
return in_array((string) $review->status, [
EnvironmentReviewStatus::Draft->value,
EnvironmentReviewStatus::Failed->value,
], true);
}
/**
* @return array<string, mixed>
*/
private function relatedReviewMetadata(EnvironmentReview $review): array
{
return array_filter([
'review' => [
'id' => (int) $review->getKey(),
'status' => (string) $review->status,
'fingerprint' => (string) $review->fingerprint,
'operation_run_id' => is_numeric($review->operation_run_id) ? (int) $review->operation_run_id : null,
'superseded_by_review_id' => is_numeric($review->superseded_by_review_id) ? (int) $review->superseded_by_review_id : null,
],
], static fn (mixed $value): bool => $value !== null && $value !== []);
}
/**
* @return array<string, mixed>
*/
private function reviewReference(EnvironmentReview $review): array
{
return [
'id' => (int) $review->getKey(),
'status' => (string) $review->status,
'operation_run_id' => is_numeric($review->operation_run_id) ? (int) $review->operation_run_id : null,
'superseded_by_review_id' => is_numeric($review->superseded_by_review_id) ? (int) $review->superseded_by_review_id : null,
];
}
/**
* @return array<string, int>
*/
private function summaryCounts(EnvironmentReview $review): array
{
$summary = is_array($review->summary) ? $review->summary : [];
$counts = [
'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,
'errors_recorded' => 0,
];
return array_filter($counts, static fn (mixed $value): bool => is_int($value));
}
}