TenantAtlas/apps/platform/app/Support/Operations/Reconciliation/ReviewPackArtifactReconciliationAdapter.php
ahmido 252cd4513d feat: implement report evidence reconciliation (#432)
Implemented report evidence reconciliation.

Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de>
Reviewed-on: #432
2026-06-06 22:40:59 +00:00

315 lines
13 KiB
PHP

<?php
declare(strict_types=1);
namespace App\Support\Operations\Reconciliation;
use App\Models\EnvironmentReview;
use App\Models\EvidenceSnapshot;
use App\Models\ManagedEnvironment;
use App\Models\OperationRun;
use App\Models\ReviewPack;
use App\Services\ReviewPackService;
use App\Support\Operations\LifecycleReconciliationReason;
use App\Support\ReviewPackStatus;
use Throwable;
final class ReviewPackArtifactReconciliationAdapter implements OperationRunReconciliationAdapter
{
public function __construct(
private readonly ReviewPackService $reviewPacks,
) {}
public function key(): string
{
return 'review_pack';
}
public function supportedTypes(): array
{
return ['environment.review_pack.generate'];
}
public function supportsType(string $type): bool
{
return $type === 'environment.review_pack.generate';
}
public function reconcile(OperationRun $run): ?ReconciliationResult
{
if (! $this->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<string, mixed> $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<string, mixed>
*/
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<string, mixed>
*/
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<string, int>
*/
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));
}
}