$readiness * @return array{ * steps:list>, * case_status:ReviewPublicationResolutionCaseStatus, * current_step_key:?string * } */ public function plan(EnvironmentReview $review, array $readiness, ?ReviewPublicationResolutionCase $case = null): array { $existing = $case instanceof ReviewPublicationResolutionCase ? $case->loadMissing(['steps.operationRun'])->steps->keyBy('step_key') : new Collection; $steps = []; foreach ($this->relevantStepKeys($readiness, $existing) as $index => $stepKey) { $steps[] = $this->stepPlan( review: $review, readiness: $readiness, stepKey: $stepKey, position: $index + 1, existingStep: $existing->get($stepKey->value), ); } $steps = $this->activateFirstIncompleteStep($steps); $currentStep = collect($steps)->first( static fn (array $step): bool => in_array($step['status'], [ ReviewPublicationResolutionStepStatus::Actionable->value, ReviewPublicationResolutionStepStatus::Running->value, ReviewPublicationResolutionStepStatus::Failed->value, ], true), ); $caseStatus = $this->caseStatus($steps, is_array($currentStep) ? (string) $currentStep['step_key'] : null); return [ 'steps' => $steps, 'case_status' => $caseStatus, 'current_step_key' => is_array($currentStep) ? (string) $currentStep['step_key'] : null, ]; } /** * @param array $readiness * @return array */ private function stepPlan( EnvironmentReview $review, array $readiness, ReviewPublicationResolutionStepKey $stepKey, int $position, ?ReviewPublicationResolutionStep $existingStep, ): array { $status = $this->baseStatus($stepKey, $readiness, $existingStep); $proofEvaluation = $this->proofResolver->evaluationFor($stepKey, $review, $readiness, $existingStep); $proof = $proofEvaluation->toStepPayload(); if ($this->requiresCurrentProof($stepKey)) { if ($proofEvaluation->canCompleteStep()) { $status = ReviewPublicationResolutionStepStatus::Completed; } elseif ($this->shouldReopenForCurrentProof($status, $proofEvaluation)) { $status = ReviewPublicationResolutionStepStatus::Pending; } } if ($existingStep instanceof ReviewPublicationResolutionStep && in_array($status, [ ReviewPublicationResolutionStepStatus::Running, ReviewPublicationResolutionStepStatus::Failed, ], true) && ($proof['proof_visibility'] ?? null) !== ResolutionProofVisibility::Hidden->value) { $proof = $this->mergeExistingProofFallback($existingStep, $proof); } return [ 'position' => $position, 'step_key' => $stepKey->value, 'status' => $status->value, 'primary_action_key' => $stepKey->primaryActionKey(), 'operation_run_id' => $proof['operation_run_id'], 'proof_type' => $proof['proof_type'], 'proof_id' => $proof['proof_id'], 'proof_status' => $proof['proof_status'], 'summary' => array_replace($this->summary($stepKey, $readiness, $status), [ 'proof_label' => $this->proofLabel($proof), 'proof_state_description' => $this->proofStateDescription($proof), 'proof_reason_code' => $proof['proof_reason_code'], 'proof_currentness' => $proof['proof_currentness'], 'proof_usability' => $proof['proof_usability'], 'proof_visibility' => $proof['proof_visibility'], 'proof_summary' => $proof['proof_summary'], ]), 'metadata' => [ 'readiness_fingerprint' => (string) $readiness['fingerprint'], 'planned_at' => now()->toIso8601String(), 'proof_currentness' => $proof['proof_currentness'], 'proof_usability' => $proof['proof_usability'], 'proof_visibility' => $proof['proof_visibility'], 'proof_reason_code' => $proof['proof_reason_code'], 'proof_evaluated_at' => $proof['proof_evaluated_at'], 'proof_timestamp' => $proof['proof_timestamp'], 'proof_summary' => $proof['proof_summary'], ], ]; } /** * @param array $readiness */ private function baseStatus( ReviewPublicationResolutionStepKey $stepKey, array $readiness, ?ReviewPublicationResolutionStep $existingStep, ): ReviewPublicationResolutionStepStatus { $readinessStatus = $this->readinessStatus($stepKey, $readiness); if ($readinessStatus === ReviewPublicationResolutionStepStatus::Completed) { return ReviewPublicationResolutionStepStatus::Completed; } if ($existingStep instanceof ReviewPublicationResolutionStep) { if ($stepKey === ReviewPublicationResolutionStepKey::ReturnToPublication && $existingStep->statusEnum() === ReviewPublicationResolutionStepStatus::Completed) { return ReviewPublicationResolutionStepStatus::Completed; } if ($existingStep->statusEnum() === ReviewPublicationResolutionStepStatus::Running && $existingStep->operationRun?->status !== OperationRunStatus::Completed->value) { return ReviewPublicationResolutionStepStatus::Running; } if ($existingStep->operationRun?->status === OperationRunStatus::Completed->value && in_array((string) $existingStep->operationRun->outcome, [OperationRunOutcome::Failed->value, OperationRunOutcome::Blocked->value], true)) { return ReviewPublicationResolutionStepStatus::Failed; } } return $readinessStatus; } /** * @param Collection $existing * @return list */ private function relevantStepKeys(array $readiness, Collection $existing): array { $keys = [ ReviewPublicationResolutionStepKey::ValidateReviewReadiness, ]; if (((array) ($readiness['missing_report_dimensions'] ?? [])) !== [] || $this->hasExistingStep($existing, ReviewPublicationResolutionStepKey::CompleteRequiredReports)) { $keys[] = ReviewPublicationResolutionStepKey::CompleteRequiredReports; } if ((bool) ($readiness['evidence_incomplete'] ?? true) || $this->hasExistingStep($existing, ReviewPublicationResolutionStepKey::CollectEvidenceSnapshot)) { $keys[] = ReviewPublicationResolutionStepKey::CollectEvidenceSnapshot; } if ((bool) ($readiness['review_requires_refresh'] ?? true) || $this->hasExistingStep($existing, ReviewPublicationResolutionStepKey::RefreshReviewComposition)) { $keys[] = ReviewPublicationResolutionStepKey::RefreshReviewComposition; } if (! (bool) ($readiness['has_ready_export'] ?? false) || $this->hasExistingStep($existing, ReviewPublicationResolutionStepKey::GenerateReviewPack)) { $keys[] = ReviewPublicationResolutionStepKey::GenerateReviewPack; } $keys[] = ReviewPublicationResolutionStepKey::ReturnToPublication; return $keys; } /** * @param Collection $existing */ private function hasExistingStep(Collection $existing, ReviewPublicationResolutionStepKey $stepKey): bool { $step = $existing->get($stepKey->value); return $step instanceof ReviewPublicationResolutionStep && $step->statusEnum() !== ReviewPublicationResolutionStepStatus::Superseded; } /** * @param array $readiness */ private function readinessStatus( ReviewPublicationResolutionStepKey $stepKey, array $readiness, ): ReviewPublicationResolutionStepStatus { return match ($stepKey) { ReviewPublicationResolutionStepKey::ValidateReviewReadiness => ReviewPublicationResolutionStepStatus::Completed, ReviewPublicationResolutionStepKey::CompleteRequiredReports => ((array) ($readiness['missing_report_dimensions'] ?? [])) === [] ? ReviewPublicationResolutionStepStatus::Completed : ReviewPublicationResolutionStepStatus::Pending, ReviewPublicationResolutionStepKey::CollectEvidenceSnapshot => (bool) ($readiness['evidence_incomplete'] ?? true) ? ReviewPublicationResolutionStepStatus::Pending : ReviewPublicationResolutionStepStatus::Completed, ReviewPublicationResolutionStepKey::RefreshReviewComposition => (bool) ($readiness['review_requires_refresh'] ?? true) ? ReviewPublicationResolutionStepStatus::Pending : ReviewPublicationResolutionStepStatus::Completed, ReviewPublicationResolutionStepKey::GenerateReviewPack => (bool) ($readiness['has_ready_export'] ?? false) ? ReviewPublicationResolutionStepStatus::Completed : ReviewPublicationResolutionStepStatus::Pending, ReviewPublicationResolutionStepKey::ReturnToPublication => ReviewPublicationResolutionStepStatus::Pending, }; } private function requiresCurrentProof(ReviewPublicationResolutionStepKey $stepKey): bool { return in_array($stepKey, [ ReviewPublicationResolutionStepKey::CompleteRequiredReports, ReviewPublicationResolutionStepKey::CollectEvidenceSnapshot, ReviewPublicationResolutionStepKey::RefreshReviewComposition, ReviewPublicationResolutionStepKey::GenerateReviewPack, ], true); } private function shouldReopenForCurrentProof( ReviewPublicationResolutionStepStatus $status, ResolutionProofEvaluation $proofEvaluation, ): bool { return match ($status) { ReviewPublicationResolutionStepStatus::Completed => true, ReviewPublicationResolutionStepStatus::Running => ! $this->isCurrentRunningOperationProof($proofEvaluation), ReviewPublicationResolutionStepStatus::Failed => ! $this->isCurrentTerminalOperationProof($proofEvaluation), default => false, }; } private function isCurrentRunningOperationProof(ResolutionProofEvaluation $proofEvaluation): bool { return $proofEvaluation->status === ResolutionProofStatus::Running && $proofEvaluation->currentness === ResolutionProofCurrentness::Current && $proofEvaluation->usability === ResolutionProofUsability::InspectionOnly && $proofEvaluation->visibility === ResolutionProofVisibility::OperatorVisible && $proofEvaluation->reasonCode === 'proof.operation_running'; } private function isCurrentTerminalOperationProof(ResolutionProofEvaluation $proofEvaluation): bool { return $proofEvaluation->status === ResolutionProofStatus::Failed && $proofEvaluation->currentness === ResolutionProofCurrentness::Current && $proofEvaluation->usability === ResolutionProofUsability::InspectionOnly && $proofEvaluation->visibility === ResolutionProofVisibility::OperatorVisible && $proofEvaluation->reasonCode === 'proof.operation_terminal_without_current_artifact'; } /** * @param array $proof * @return array */ private function mergeExistingProofFallback(ReviewPublicationResolutionStep $existingStep, array $proof): array { if (! is_string($existingStep->proof_type) && ! is_numeric($existingStep->proof_id) && ! is_numeric($existingStep->operation_run_id)) { return $proof; } return array_replace($proof, [ 'proof_type' => is_string($existingStep->proof_type) ? $existingStep->proof_type : $proof['proof_type'], 'proof_id' => is_numeric($existingStep->proof_id) ? (int) $existingStep->proof_id : $proof['proof_id'], 'proof_status' => is_string($existingStep->proof_status) ? $existingStep->proof_status : $proof['proof_status'], 'operation_run_id' => is_numeric($existingStep->operation_run_id) ? (int) $existingStep->operation_run_id : $proof['operation_run_id'], ]); } /** * @param array $proof */ private function proofLabel(array $proof): string { $status = (string) ($proof['proof_status'] ?? ''); $currentness = (string) ($proof['proof_currentness'] ?? ''); $usability = (string) ($proof['proof_usability'] ?? ''); $visibility = (string) ($proof['proof_visibility'] ?? ''); $reasonCode = (string) ($proof['proof_reason_code'] ?? ''); return match (true) { $visibility === ResolutionProofVisibility::Hidden->value, $visibility === ResolutionProofVisibility::OperatorLimited->value => 'Not available with your permissions', $status === ResolutionProofStatus::Missing->value => 'Proof missing', $status === ResolutionProofStatus::Running->value => 'Operation running', $status === ResolutionProofStatus::Failed->value => 'Action failed', $currentness === ResolutionProofCurrentness::Stale->value => 'Outdated proof', $currentness === ResolutionProofCurrentness::Superseded->value, str_contains($reasonCode, 'supersede') => 'Superseded by newer result', $usability === ResolutionProofUsability::Usable->value, $usability === ResolutionProofUsability::UsableWithWarning->value => 'Current proof', default => 'Proof cannot be verified', }; } /** * @param array $proof */ private function proofStateDescription(array $proof): string { $reasonCode = (string) ($proof['proof_reason_code'] ?? ''); return match (true) { str_contains($reasonCode, 'supersede') => 'A newer current artifact is available, so the older operation result is diagnostics-only.', str_contains($reasonCode, 'without_artifact') => 'The operation finished, but the expected artifact is still missing.', str_contains($reasonCode, 'running') => 'The linked operation can be inspected, but it does not complete this step yet.', str_contains($reasonCode, 'stale') => 'The linked proof no longer matches the current review inputs.', str_contains($reasonCode, 'missing') => 'TenantPilot has not found current proof for this step.', str_contains($reasonCode, 'type_mismatch') => 'The linked operation does not match this resolution step.', str_contains($reasonCode, 'context_missing'), str_contains($reasonCode, 'context_mismatch') => 'The linked operation does not match this review publication case.', str_contains($reasonCode, 'scope_mismatch') => 'Proof is not available for this workspace or environment.', default => 'TenantPilot evaluated proof against the current review-publication state.', }; } /** * @param list> $steps * @return list> */ private function activateFirstIncompleteStep(array $steps): array { foreach ($steps as $index => $step) { if ($step['status'] !== ReviewPublicationResolutionStepStatus::Pending->value) { continue; } $steps[$index]['status'] = ReviewPublicationResolutionStepStatus::Actionable->value; $steps[$index]['summary']['state_description'] = 'Ready for operator action.'; break; } return $steps; } /** * @param list> $steps */ private function caseStatus(array $steps, ?string $currentStepKey): ReviewPublicationResolutionCaseStatus { if ($currentStepKey === null) { return ReviewPublicationResolutionCaseStatus::Completed; } $currentStep = collect($steps)->firstWhere('step_key', $currentStepKey); if (is_array($currentStep) && $currentStep['status'] === ReviewPublicationResolutionStepStatus::Running->value) { return ReviewPublicationResolutionCaseStatus::WaitingForRun; } if (is_array($currentStep) && $currentStep['status'] === ReviewPublicationResolutionStepStatus::Failed->value) { return ReviewPublicationResolutionCaseStatus::Blocked; } if ($currentStepKey === ReviewPublicationResolutionStepKey::ReturnToPublication->value) { return ReviewPublicationResolutionCaseStatus::ReadyToContinue; } return ReviewPublicationResolutionCaseStatus::InProgress; } /** * @param array $readiness * @return array */ private function summary( ReviewPublicationResolutionStepKey $stepKey, array $readiness, ReviewPublicationResolutionStepStatus $status, ): array { return [ 'label' => $stepKey->label(), 'description' => match ($stepKey) { ReviewPublicationResolutionStepKey::ValidateReviewReadiness => 'Review readiness has been evaluated from current evidence and section state.', ReviewPublicationResolutionStepKey::CompleteRequiredReports => 'Required report-backed evidence dimensions must be current before publication can continue.', ReviewPublicationResolutionStepKey::CollectEvidenceSnapshot => 'The evidence snapshot must be complete and current for the review output.', ReviewPublicationResolutionStepKey::RefreshReviewComposition => 'Review sections must be recomposed from current evidence before publication.', ReviewPublicationResolutionStepKey::GenerateReviewPack => 'A current review pack is required before returning to publication.', ReviewPublicationResolutionStepKey::ReturnToPublication => 'Return to the review publication action after blockers are resolved.', }, 'state_description' => $status === ReviewPublicationResolutionStepStatus::Completed ? 'Requirement is satisfied.' : 'Waiting for prerequisite steps.', 'publication_blocker_count' => count((array) ($readiness['publication_blockers'] ?? [])), 'missing_report_dimensions' => array_values((array) ($readiness['missing_report_dimensions'] ?? [])), 'evidence_state' => (string) ($readiness['evidence_state'] ?? ''), 'review_status' => (string) ($readiness['review_status'] ?? ''), 'has_ready_export' => (bool) ($readiness['has_ready_export'] ?? false), ]; } }