TenantAtlas/apps/platform/app/Support/Operations/Reconciliation/EvidenceSnapshotReconciliationAdapter.php
Ahmed Darrazi 86d1e0cf0d
Some checks failed
PR Fast Feedback / fast-feedback (pull_request) Failing after 1m3s
feat: implement report evidence reconciliation
2026-06-07 00:36:22 +02:00

178 lines
7.6 KiB
PHP

<?php
declare(strict_types=1);
namespace App\Support\Operations\Reconciliation;
use App\Models\EvidenceSnapshot;
use App\Models\OperationRun;
use App\Support\Evidence\EvidenceCompletenessState;
use App\Support\Evidence\EvidenceSnapshotStatus;
use App\Support\Operations\LifecycleReconciliationReason;
use Throwable;
final class EvidenceSnapshotReconciliationAdapter implements OperationRunReconciliationAdapter
{
public function key(): string
{
return 'evidence_snapshot';
}
public function supportedTypes(): array
{
return ['tenant.evidence.snapshot.generate'];
}
public function supportsType(string $type): bool
{
return $type === 'tenant.evidence.snapshot.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 evidence snapshot 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);
$fingerprint = is_string($context['fingerprint'] ?? null)
? trim((string) $context['fingerprint'])
: '';
$evidence = [
'adapter' => $this->key(),
'operation_run_id' => (int) $run->getKey(),
'workspace_id' => $workspaceId,
'managed_environment_id' => $tenantId,
'fingerprint' => $fingerprint,
];
if ($workspaceId <= 0 || $tenantId <= 0 || $fingerprint === '') {
return ReconciliationResult::attentionRequired(
reasonCode: LifecycleReconciliationReason::AdapterOutOfSync->value,
reasonMessage: 'The run no longer carries enough evidence snapshot scope to reconcile it safely.',
evidence: $evidence,
);
}
$snapshot = EvidenceSnapshot::query()
->where('workspace_id', $workspaceId)
->where('managed_environment_id', $tenantId)
->where('fingerprint', $fingerprint)
->latest('id')
->first();
if (! $snapshot instanceof EvidenceSnapshot) {
return ReconciliationResult::notReconciled(
reasonCode: LifecycleReconciliationReason::AdapterOutOfSync->value,
reasonMessage: 'No matching evidence snapshot is available yet for this run.',
evidence: $evidence,
);
}
$related = $this->relatedSnapshotMetadata($snapshot);
$status = (string) $snapshot->status;
$completeness = (string) $snapshot->completeness_state;
if ($this->isUsableSnapshot($snapshot)) {
return ReconciliationResult::reconciledSucceeded(
reasonCode: LifecycleReconciliationReason::AdapterOutOfSync->value,
reasonMessage: (int) ($snapshot->operation_run_id ?? 0) === (int) $run->getKey()
? 'The queued evidence snapshot was already completed before the run finished updating.'
: 'A matching evidence snapshot was already available for this run.',
evidence: $evidence + ['chosen_snapshot_id' => (int) $snapshot->getKey()],
related: $related,
summaryCounts: $this->summaryCounts($snapshot),
);
}
if (in_array($status, [
EvidenceSnapshotStatus::Queued->value,
EvidenceSnapshotStatus::Generating->value,
], true)) {
return ReconciliationResult::notReconciled(
reasonCode: LifecycleReconciliationReason::AdapterOutOfSync->value,
reasonMessage: (int) ($snapshot->operation_run_id ?? 0) === (int) $run->getKey()
? 'The queued evidence snapshot is still generating and does not prove final snapshot truth yet.'
: 'A matching evidence snapshot exists, but it is still generating.',
evidence: $evidence + ['chosen_snapshot_id' => (int) $snapshot->getKey()],
related: $related,
);
}
$reasonMessage = match (true) {
$status === EvidenceSnapshotStatus::Expired->value => 'A matching evidence snapshot exists, but it is already expired.',
$status === EvidenceSnapshotStatus::Superseded->value => 'A matching evidence snapshot exists, but it was superseded by newer evidence.',
$status === EvidenceSnapshotStatus::Failed->value => 'A matching evidence snapshot exists, but evidence generation failed.',
$completeness === EvidenceCompletenessState::Stale->value => 'A matching evidence snapshot exists, but its evidence basis is already stale.',
$completeness === EvidenceCompletenessState::Partial->value => 'A matching evidence snapshot exists, but its evidence basis is incomplete.',
default => 'A matching evidence snapshot exists, but it does not provide usable evidence truth yet.',
};
return ReconciliationResult::attentionRequired(
reasonCode: LifecycleReconciliationReason::AdapterOutOfSync->value,
reasonMessage: $reasonMessage,
evidence: $evidence + ['chosen_snapshot_id' => (int) $snapshot->getKey()],
related: $related,
summaryCounts: $this->summaryCounts($snapshot),
);
}
public function reconcileException(OperationRun $run, Throwable $throwable): ?ReconciliationResult
{
return null;
}
private function isUsableSnapshot(EvidenceSnapshot $snapshot): bool
{
if ((string) $snapshot->status !== EvidenceSnapshotStatus::Active->value) {
return false;
}
if ((string) $snapshot->completeness_state !== EvidenceCompletenessState::Complete->value) {
return false;
}
return $snapshot->expires_at === null || $snapshot->expires_at->isFuture();
}
/**
* @return array<string, mixed>
*/
private function relatedSnapshotMetadata(EvidenceSnapshot $snapshot): array
{
return array_filter([
'type' => 'evidence_snapshot',
'id' => (int) $snapshot->getKey(),
'status' => (string) $snapshot->status,
'completeness_state' => (string) $snapshot->completeness_state,
'fingerprint' => (string) $snapshot->fingerprint,
'operation_run_id' => is_numeric($snapshot->operation_run_id) ? (int) $snapshot->operation_run_id : null,
'expires_at' => $snapshot->expires_at?->toIso8601String(),
], static fn (mixed $value): bool => $value !== null && $value !== []);
}
/**
* @return array<string, int>
*/
private function summaryCounts(EvidenceSnapshot $snapshot): array
{
$summary = is_array($snapshot->summary) ? $snapshot->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));
}
}