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 $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 */ 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 */ 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; } }