TenantAtlas/apps/platform/app/Support/Operations/Reconciliation/BaselineCaptureReconciliationAdapter.php
Ahmed Darrazi 0eda669d38
Some checks failed
PR Fast Feedback / fast-feedback (pull_request) Failing after 3m42s
feat: implement sync capture backup operation semantics
2026-06-07 03:17:08 +02:00

313 lines
14 KiB
PHP

<?php
declare(strict_types=1);
namespace App\Support\Operations\Reconciliation;
use App\Models\BaselineSnapshot;
use App\Models\OperationRun;
use App\Support\Baselines\BaselineReasonCodes;
use App\Support\Baselines\BaselineSnapshotLifecycleState;
use App\Support\OperationCatalog;
use App\Support\OperationRunOutcome;
use App\Support\OperationRunStatus;
use App\Support\OperationRunType;
use App\Support\Operations\LifecycleReconciliationReason;
use Throwable;
final class BaselineCaptureReconciliationAdapter implements OperationRunReconciliationAdapter
{
public function key(): string
{
return 'baseline_capture';
}
public function supportedTypes(): array
{
return [OperationRunType::BaselineCapture->value];
}
public function supportsType(string $type): bool
{
return OperationCatalog::canonicalCode($type) === OperationRunType::BaselineCapture->value;
}
public function reconcile(OperationRun $run): ?ReconciliationResult
{
if (! $this->supportsType((string) $run->type)) {
return ReconciliationResult::unsupported(
reasonCode: LifecycleReconciliationReason::AdapterOutOfSync->value,
reasonMessage: 'This adapter only supports baseline capture runs.',
evidence: [
'adapter' => $this->key(),
'type' => (string) $run->type,
],
);
}
$context = is_array($run->context) ? $run->context : [];
$profileId = is_numeric($context['baseline_profile_id'] ?? null) ? (int) $context['baseline_profile_id'] : null;
$reasonCode = $this->captureReasonCode($context);
$subjectsTotal = $this->intValue(data_get($context, 'baseline_capture.subjects_total'));
$resumeToken = data_get($context, 'baseline_capture.resume_token');
$gapsCount = $this->intValue(data_get($context, 'baseline_capture.gaps.count'));
$itemsCaptured = $this->intValue(data_get($context, 'result.items_captured'));
$evidence = [
'adapter' => $this->key(),
'operation_run_id' => (int) $run->getKey(),
'workspace_id' => (int) $run->workspace_id,
'baseline_profile_id' => $profileId,
'reason_code' => $reasonCode,
'subjects_total' => $subjectsTotal,
'gaps_count' => $gapsCount,
'items_captured' => $itemsCaptured,
'resume_token_present' => is_string($resumeToken) && trim($resumeToken) !== '',
];
[$snapshot, $scopeProblem] = $this->resolveSnapshot($run, $profileId);
if ($scopeProblem !== null) {
return ReconciliationResult::notReconciled(
reasonCode: LifecycleReconciliationReason::AdapterOutOfSync->value,
reasonMessage: $scopeProblem,
evidence: $evidence,
);
}
if ($snapshot instanceof BaselineSnapshot) {
$itemsCaptured = max($itemsCaptured, $this->intValue(data_get($snapshot->summary_jsonb, 'total_items')));
$subjectsTotal = max($subjectsTotal, $itemsCaptured);
$gapsCount = max($gapsCount, $this->intValue(data_get($snapshot->summary_jsonb, 'gaps.count')));
$reasonCode ??= is_string(data_get($snapshot->completion_meta_jsonb, 'finalization_reason_code'))
? (string) data_get($snapshot->completion_meta_jsonb, 'finalization_reason_code')
: null;
$summaryCounts = $this->summaryCounts($subjectsTotal, $itemsCaptured);
$related = [
'type' => 'baseline_snapshot',
'id' => (int) $snapshot->getKey(),
'lifecycle_state' => $snapshot->lifecycleState()->value,
'baseline_profile_id' => (int) $snapshot->baseline_profile_id,
];
if ($snapshot->lifecycleState() === BaselineSnapshotLifecycleState::Complete) {
if ((is_string($resumeToken) && trim($resumeToken) !== '') || $gapsCount > 0 || $itemsCaptured < $subjectsTotal) {
return new ReconciliationResult(
decision: 'reconciled_partially_succeeded',
status: OperationRunStatus::Completed->value,
outcome: OperationRunOutcome::PartiallySucceeded->value,
reasonCode: LifecycleReconciliationReason::AdapterOutOfSync->value,
reasonMessage: 'The baseline capture recorded a usable snapshot, but evidence gaps still limit it.',
evidence: $evidence + ['chosen_snapshot_id' => (int) $snapshot->getKey()],
related: $related,
summaryCounts: $summaryCounts,
failures: [[
'code' => 'baseline.capture.partial',
'reason_code' => LifecycleReconciliationReason::AdapterOutOfSync->value,
'message' => 'The baseline capture recorded a usable snapshot, but evidence gaps still limit it.',
]],
safeForAutoCompletion: true,
);
}
return ReconciliationResult::reconciledSucceeded(
reasonCode: LifecycleReconciliationReason::AdapterOutOfSync->value,
reasonMessage: 'The baseline capture already produced a complete snapshot before the run finished updating.',
evidence: $evidence + ['chosen_snapshot_id' => (int) $snapshot->getKey()],
related: $related,
summaryCounts: $summaryCounts,
);
}
if ($reasonCode === BaselineReasonCodes::CAPTURE_ZERO_SUBJECTS) {
return new ReconciliationResult(
decision: 'reconciled_partially_succeeded',
status: OperationRunStatus::Completed->value,
outcome: OperationRunOutcome::PartiallySucceeded->value,
reasonCode: LifecycleReconciliationReason::AdapterOutOfSync->value,
reasonMessage: 'The baseline capture finished without a usable baseline because no governed subjects were in scope.',
evidence: $evidence + ['chosen_snapshot_id' => (int) $snapshot->getKey()],
related: $related,
summaryCounts: $summaryCounts,
failures: [[
'code' => 'baseline.capture.partial',
'reason_code' => LifecycleReconciliationReason::AdapterOutOfSync->value,
'message' => 'The baseline capture finished without a usable baseline because no governed subjects were in scope.',
]],
safeForAutoCompletion: true,
);
}
if ($snapshot->lifecycleState() === BaselineSnapshotLifecycleState::Building) {
return ReconciliationResult::notReconciled(
reasonCode: LifecycleReconciliationReason::AdapterOutOfSync->value,
reasonMessage: 'The baseline snapshot is still building and does not prove final capture truth yet.',
evidence: $evidence + ['chosen_snapshot_id' => (int) $snapshot->getKey()],
related: $related,
);
}
return ReconciliationResult::failedUnrecoverable(
reasonCode: LifecycleReconciliationReason::AdapterOutOfSync->value,
reasonMessage: 'The baseline capture created a snapshot row, but it never became usable baseline truth.',
evidence: $evidence + ['chosen_snapshot_id' => (int) $snapshot->getKey()],
related: $related,
summaryCounts: $summaryCounts,
);
}
if ($reasonCode !== null && in_array($reasonCode, $this->blockedReasonCodes(), true)) {
return ReconciliationResult::blocked(
reasonCode: LifecycleReconciliationReason::AdapterOutOfSync->value,
reasonMessage: $this->blockedReasonMessage($reasonCode, (bool) data_get($context, 'baseline_capture.eligibility.changed_after_enqueue')),
evidence: $evidence,
summaryCounts: $this->summaryCounts($subjectsTotal, $itemsCaptured),
);
}
if ($reasonCode !== null && in_array($reasonCode, [
BaselineReasonCodes::SNAPSHOT_CAPTURE_FAILED,
BaselineReasonCodes::SNAPSHOT_COMPLETION_PROOF_FAILED,
BaselineReasonCodes::SNAPSHOT_INCOMPLETE,
BaselineReasonCodes::SNAPSHOT_LEGACY_NO_PROOF,
BaselineReasonCodes::SNAPSHOT_LEGACY_CONTRADICTORY,
], true)) {
return ReconciliationResult::failedUnrecoverable(
reasonCode: LifecycleReconciliationReason::AdapterOutOfSync->value,
reasonMessage: 'The baseline capture stopped before it could prove a usable snapshot.',
evidence: $evidence,
summaryCounts: $this->summaryCounts($subjectsTotal, $itemsCaptured),
);
}
return ReconciliationResult::notReconciled(
reasonCode: LifecycleReconciliationReason::AdapterOutOfSync->value,
reasonMessage: 'No current-scope baseline snapshot proof is available yet for this run.',
evidence: $evidence,
);
}
public function reconcileException(OperationRun $run, Throwable $throwable): ?ReconciliationResult
{
return null;
}
/**
* @return array{0:BaselineSnapshot|null,1:?string}
*/
private function resolveSnapshot(OperationRun $run, ?int $profileId): array
{
$context = is_array($run->context) ? $run->context : [];
$snapshotId = is_numeric($context['baseline_snapshot_id'] ?? null)
? (int) $context['baseline_snapshot_id']
: (is_numeric(data_get($context, 'result.snapshot_id')) ? (int) data_get($context, 'result.snapshot_id') : null);
if ($snapshotId !== null) {
$candidate = BaselineSnapshot::query()->whereKey($snapshotId)->first();
if (! $candidate instanceof BaselineSnapshot) {
return [null, null];
}
if ((int) $candidate->workspace_id !== (int) $run->workspace_id || ($profileId !== null && (int) $candidate->baseline_profile_id !== $profileId)) {
return [null, 'The recorded baseline snapshot no longer matches the queued capture scope safely.'];
}
return [$candidate, null];
}
if ($profileId === null) {
return [null, null];
}
$candidates = BaselineSnapshot::query()
->where('workspace_id', (int) $run->workspace_id)
->where('baseline_profile_id', $profileId)
->where('completion_meta_jsonb->producer_run_id', (int) $run->getKey())
->orderByDesc('id')
->get();
if ($candidates->count() > 1) {
return [null, 'Multiple baseline snapshots point at this run, so reconciliation stays fail-closed.'];
}
return [$candidates->first(), null];
}
/**
* @param array<string, mixed> $context
*/
private function captureReasonCode(array $context): ?string
{
foreach ([
data_get($context, 'baseline_capture.reason_code'),
$context['reason_code'] ?? null,
data_get($context, 'result.snapshot_reason_code'),
] as $candidate) {
if (is_string($candidate) && trim($candidate) !== '') {
return trim($candidate);
}
}
return null;
}
/**
* @return array<int, string>
*/
private function blockedReasonCodes(): array
{
return [
BaselineReasonCodes::CAPTURE_MISSING_SOURCE_TENANT,
BaselineReasonCodes::CAPTURE_PROFILE_NOT_ACTIVE,
BaselineReasonCodes::CAPTURE_ROLLOUT_DISABLED,
BaselineReasonCodes::CAPTURE_INVALID_SCOPE,
BaselineReasonCodes::CAPTURE_UNSUPPORTED_SCOPE,
BaselineReasonCodes::CAPTURE_INVENTORY_MISSING,
BaselineReasonCodes::CAPTURE_INVENTORY_BLOCKED,
BaselineReasonCodes::CAPTURE_INVENTORY_FAILED,
BaselineReasonCodes::CAPTURE_UNUSABLE_COVERAGE,
];
}
private function blockedReasonMessage(string $reasonCode, bool $changedAfterEnqueue): string
{
return match ($reasonCode) {
BaselineReasonCodes::CAPTURE_INVENTORY_MISSING => 'The baseline capture could not continue because no current inventory basis was available.',
BaselineReasonCodes::CAPTURE_INVENTORY_BLOCKED => $changedAfterEnqueue
? 'The baseline capture stopped because the latest inventory sync changed after the run was queued.'
: 'The baseline capture was blocked because the latest inventory sync was blocked.',
BaselineReasonCodes::CAPTURE_INVENTORY_FAILED => $changedAfterEnqueue
? 'The baseline capture stopped because the latest inventory sync failed after the run was queued.'
: 'The baseline capture was blocked because the latest inventory sync failed.',
BaselineReasonCodes::CAPTURE_UNUSABLE_COVERAGE => $changedAfterEnqueue
? 'The baseline capture stopped because the latest inventory coverage became unusable after the run was queued.'
: 'The baseline capture could not produce a usable baseline because the latest inventory coverage was not credible.',
default => 'The baseline capture was blocked before it could produce trustworthy snapshot proof.',
};
}
/**
* @return array<string, int>
*/
private function summaryCounts(int $subjectsTotal, int $itemsCaptured): array
{
$total = max(0, $subjectsTotal);
$succeeded = max(0, $itemsCaptured);
$total = max($total, $succeeded);
return [
'total' => $total,
'processed' => $total,
'succeeded' => $succeeded,
'failed' => max(0, $total - $succeeded),
];
}
private function intValue(mixed $value): int
{
return is_numeric($value) ? (int) $value : 0;
}
}