TenantAtlas/apps/platform/app/Support/ReviewPublicationResolution/ReviewPublicationStepPlanner.php
Ahmed Darrazi 314a157233
Some checks failed
PR Fast Feedback / fast-feedback (pull_request) Failing after 1m18s
feat: add review publication proof currentness contract
2026-06-19 20:59:05 +02:00

414 lines
19 KiB
PHP

<?php
declare(strict_types=1);
namespace App\Support\ReviewPublicationResolution;
use App\Models\EnvironmentReview;
use App\Models\ReviewPublicationResolutionCase;
use App\Models\ReviewPublicationResolutionStep;
use App\Support\OperationRunOutcome;
use App\Support\OperationRunStatus;
use Illuminate\Support\Collection;
final class ReviewPublicationStepPlanner
{
public function __construct(
private readonly ReviewPublicationProofResolver $proofResolver,
) {}
/**
* @param array<string, mixed> $readiness
* @return array{
* steps:list<array<string, mixed>>,
* 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<string, mixed> $readiness
* @return array<string, mixed>
*/
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<string, mixed> $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<string, ReviewPublicationResolutionStep> $existing
* @return list<ReviewPublicationResolutionStepKey>
*/
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<string, ReviewPublicationResolutionStep> $existing
*/
private function hasExistingStep(Collection $existing, ReviewPublicationResolutionStepKey $stepKey): bool
{
$step = $existing->get($stepKey->value);
return $step instanceof ReviewPublicationResolutionStep
&& $step->statusEnum() !== ReviewPublicationResolutionStepStatus::Superseded;
}
/**
* @param array<string, mixed> $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<string, mixed> $proof
* @return array<string, mixed>
*/
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<string, mixed> $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<string, mixed> $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<array<string, mixed>> $steps
* @return list<array<string, mixed>>
*/
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<array<string, mixed>> $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<string, mixed> $readiness
* @return array<string, mixed>
*/
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),
];
}
}