refactor: rename ManagedEnvironment context badge to Environment context #431

Merged
ahmido merged 2 commits from 360-operationrun-canonical-cutover-cleanup into platform-dev 2026-06-06 20:30:27 +00:00
34 changed files with 2122 additions and 389 deletions

View File

@ -8,17 +8,13 @@
use App\Models\EnvironmentReview;
use App\Models\OperationRun;
use App\Services\AdapterRunReconciler;
use App\Services\OperationRunService;
use App\Services\EnvironmentReviews\EnvironmentReviewService;
use App\Support\OpsUx\RunFailureSanitizer;
use App\Services\OperationRunService;
use App\Support\EnvironmentReviewStatus;
use App\Support\OperationRunOutcome;
use App\Support\OperationRunStatus;
use App\Support\Operations\LifecycleReconciliationReason;
use App\Support\Operations\Reconciliation\EnvironmentReviewComposeDecision;
use App\Support\Operations\Reconciliation\ReconciliationResult;
use App\Support\EnvironmentReviewStatus;
use App\Support\OpsUx\RunFailureSanitizer;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Database\QueryException;
use Illuminate\Foundation\Queue\Queueable;
use Throwable;
@ -40,8 +36,7 @@ public function handle(
EnvironmentReviewService $service,
OperationRunService $operationRuns,
AdapterRunReconciler $adapterRunReconciler,
): void
{
): void {
$review = EnvironmentReview::query()->with(['tenant', 'evidenceSnapshot.items'])->find($this->environmentReviewId);
$operationRun = OperationRun::query()->find($this->operationRunId);
@ -86,34 +81,9 @@ public function handle(
return;
}
if ($throwable instanceof QueryException && $this->isUniqueViolation($throwable)) {
$result = ReconciliationResult::attentionRequired(
reasonCode: LifecycleReconciliationReason::AdapterOutOfSync->value,
reasonMessage: 'TenantPilot found matching review activity, but it could not be resolved automatically.',
evidence: [
'adapter' => EnvironmentReviewComposeDecision::ADAPTER,
'exception_class' => class_basename($throwable),
'workspace_id' => (int) $operationRun->workspace_id,
'managed_environment_id' => (int) $operationRun->managed_environment_id,
'fingerprint' => (string) data_get($operationRun->context, 'review_fingerprint', ''),
],
);
$operationRuns->updateRunWithReconciliation(
run: $operationRun,
status: (string) $result->status,
outcome: (string) $result->outcome,
summaryCounts: $result->summaryCounts,
failures: $result->failures,
reasonCode: $result->reasonCode,
reasonMessage: $result->reasonMessage,
source: 'adapter_reconciler',
evidence: $result->evidence,
adapter: EnvironmentReviewComposeDecision::ADAPTER,
decision: $result->decision,
related: $result->related,
);
$adapterChange = $adapterRunReconciler->reconcileOperationRunFailure($operationRun, $throwable);
if (($adapterChange['applied'] ?? false) === true) {
return;
}
@ -141,9 +111,4 @@ public function handle(
throw $throwable;
}
}
private function isUniqueViolation(QueryException $exception): bool
{
return in_array(($exception->errorInfo[0] ?? null), ['23505', '23000'], true);
}
}

View File

@ -6,6 +6,7 @@
use App\Models\OperationRun;
use App\Services\OperationRunService;
use App\Support\Operations\OperationRunCorrelationResolver;
use Throwable;
trait BridgesFailedOperationRun
@ -23,36 +24,6 @@ public function failed(Throwable $exception): void
protected function failedBridgeOperationRun(): ?OperationRun
{
if (property_exists($this, 'operationRun') && $this->operationRun instanceof OperationRun) {
return $this->operationRun;
}
if (property_exists($this, 'run') && $this->run instanceof OperationRun) {
return $this->run;
}
$candidateIds = [];
foreach (['operationRunId', 'bulkRunId', 'runId'] as $property) {
if (! property_exists($this, $property)) {
continue;
}
$value = $this->{$property};
if (is_numeric($value) && (int) $value > 0) {
$candidateIds[] = (int) $value;
}
}
foreach (array_values(array_unique($candidateIds)) as $candidateId) {
$operationRun = OperationRun::query()->find($candidateId);
if ($operationRun instanceof OperationRun) {
return $operationRun;
}
}
return null;
return app(OperationRunCorrelationResolver::class)->resolve($this);
}
}

View File

@ -4,6 +4,8 @@
use App\Models\OperationRun;
use App\Services\OperationRunService;
use App\Support\OperationRunStatus;
use App\Support\Operations\OperationRunCorrelationResolver;
use Closure;
class TrackOperationRun
@ -28,12 +30,12 @@ public function handle($job, Closure $next)
$run->refresh();
if ($run->status === 'completed') {
if ($run->status === OperationRunStatus::Completed->value) {
return null;
}
if ($run->status !== 'running') {
$service->updateRun($run, 'running');
if ($run->status !== OperationRunStatus::Running->value) {
$service->updateRun($run, OperationRunStatus::Running->value);
}
try {
@ -45,8 +47,8 @@ public function handle($job, Closure $next)
$run->refresh();
if ($run->status === 'running') {
$service->updateRun($run, 'completed', 'succeeded');
if ($run->status === OperationRunStatus::Running->value) {
$service->updateRun($run, OperationRunStatus::Completed->value, 'succeeded');
}
return $response;
@ -61,18 +63,6 @@ public function handle($job, Closure $next)
*/
private function resolveRun($job): ?OperationRun
{
if (method_exists($job, 'getOperationRun')) {
$run = $job->getOperationRun();
return $run instanceof OperationRun ? $run : null;
}
if (property_exists($job, 'operationRun')) {
$run = $job->operationRun;
return $run instanceof OperationRun ? $run : null;
}
return null;
return app(OperationRunCorrelationResolver::class)->resolve($job);
}
}

View File

@ -6,10 +6,10 @@
use App\Support\OperationCatalog;
use App\Support\OperationRunOutcome;
use App\Support\OperationRunStatus;
use App\Support\OperationTypeResolution;
use App\Support\OperationRunType;
use App\Support\Operations\OperationLifecyclePolicy;
use App\Support\Operations\OperationRunFreshnessState;
use App\Support\OperationTypeResolution;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
@ -426,7 +426,11 @@ public function reconciliationRelated(): array
public function reconciledRelatedReviewId(): ?int
{
$reviewId = data_get($this->reconciliationRelated(), 'review.id');
$related = $this->reconciliationRelated();
$relatedType = is_string($related['type'] ?? null) ? trim((string) $related['type']) : null;
$reviewId = $relatedType === 'environment_review'
? ($related['id'] ?? null)
: data_get($related, 'review.id');
return is_numeric($reviewId) ? (int) $reviewId : null;
}

View File

@ -5,19 +5,19 @@
namespace App\Services;
use App\Models\OperationRun;
use App\Models\RestoreRun;
use App\Support\Operations\Reconciliation\EnvironmentReviewComposeDecision;
use App\Support\OperationRunOutcome;
use App\Support\OperationRunStatus;
use App\Support\Operations\LifecycleReconciliationReason;
use App\Support\RestoreRunStatus;
use App\Support\Operations\Reconciliation\OperationRunReconciliationAdapter;
use App\Support\Operations\Reconciliation\OperationRunReconciliationRegistry;
use App\Support\Operations\Reconciliation\ReconciliationResult;
use Carbon\CarbonImmutable;
use Illuminate\Database\Eloquent\Builder;
use Throwable;
final class AdapterRunReconciler
{
public function __construct(
private readonly EnvironmentReviewComposeDecision $environmentReviewComposeDecision,
private readonly OperationRunReconciliationRegistry $registry,
private readonly OperationRunService $operationRuns,
) {}
/**
@ -25,10 +25,7 @@ public function __construct(
*/
public function supportedTypes(): array
{
return [
'restore.execute',
'environment.review.compose',
];
return $this->registry->supportedTypes();
}
/**
@ -103,255 +100,109 @@ public function reconcile(array $options = []): array
*/
public function reconcileOperationRun(OperationRun $run, bool $dryRun = false): ?array
{
return match ((string) $run->type) {
'restore.execute' => $this->reconcileRestoreRun($run, $dryRun),
'environment.review.compose' => $this->reconcileReviewComposeRun($run, $dryRun),
default => null,
};
$adapter = $this->resolveAdapter($run);
return $this->reconcileUsingAdapter(
run: $run,
result: $adapter?->reconcile($run),
adapter: $adapter,
dryRun: $dryRun,
);
}
/**
* @return array<string, mixed>|null
*/
private function reconcileRestoreRun(OperationRun $run, bool $dryRun): ?array
public function reconcileOperationRunFailure(OperationRun $run, Throwable $throwable, bool $dryRun = false): ?array
{
$context = is_array($run->context) ? $run->context : [];
$restoreRunId = $context['restore_run_id'] ?? null;
$adapter = $this->resolveAdapter($run);
if (! is_numeric($restoreRunId)) {
return $this->reconcileUsingAdapter(
run: $run,
result: $adapter?->reconcileException($run, $throwable),
adapter: $adapter,
dryRun: $dryRun,
);
}
/**
* @return array<string, mixed>|null
*/
private function reconcileUsingAdapter(
OperationRun $run,
?ReconciliationResult $result,
?OperationRunReconciliationAdapter $adapter,
bool $dryRun,
): ?array {
if (! $adapter instanceof OperationRunReconciliationAdapter || ! $result instanceof ReconciliationResult) {
return null;
}
$restoreRun = RestoreRun::query()
->where('managed_environment_id', $run->managed_environment_id)
->whereKey((int) $restoreRunId)
->first();
if (! $restoreRun instanceof RestoreRun) {
return null;
}
$restoreStatus = RestoreRunStatus::fromString($restoreRun->status);
if (! $this->isTerminalRestoreStatus($restoreStatus)) {
return null;
}
[$opStatus, $opOutcome, $failures] = $this->mapRestoreToOperationRun($restoreRun, $restoreStatus);
$summaryCounts = $this->buildSummaryCounts($restoreRun);
$before = [
'status' => (string) $run->status,
'outcome' => (string) $run->outcome,
];
$after = [
'status' => $opStatus,
'outcome' => $opOutcome,
];
$after = $result->shouldFinalizeRun()
? [
'status' => (string) $result->status,
'outcome' => (string) $result->outcome,
]
: null;
if ($dryRun) {
return [
return array_merge([
'applied' => false,
'operation_run_id' => (int) $run->getKey(),
'type' => (string) $run->type,
'restore_run_id' => (int) $restoreRun->getKey(),
'adapter' => $adapter->key(),
'before' => $before,
'after' => $after,
];
], $result->toArray(), $after !== null ? ['after' => $after] : []);
}
/** @var OperationRunService $runs */
$runs = app(OperationRunService::class);
if (! $result->shouldFinalizeRun()) {
return null;
}
$runs->updateRunWithReconciliation(
$this->operationRuns->applyReconciliationResult(
run: $run,
status: $opStatus,
outcome: $opOutcome,
summaryCounts: $summaryCounts,
failures: $failures,
reasonCode: LifecycleReconciliationReason::AdapterOutOfSync->value,
reasonMessage: LifecycleReconciliationReason::AdapterOutOfSync->defaultMessage(),
result: $result,
source: 'adapter_reconciler',
evidence: [
'restore_run_id' => (int) $restoreRun->getKey(),
'restore_status' => $restoreStatus?->value,
],
adapter: 'restore_run',
adapter: $adapter->key(),
);
$run->refresh();
$this->syncRestoreLifecycleTimestamps($run, $result);
if ($run->started_at === null && $restoreRun->started_at !== null) {
$run->started_at = $restoreRun->started_at;
return array_merge([
'applied' => true,
'operation_run_id' => (int) $run->getKey(),
'type' => (string) $run->type,
'adapter' => $adapter->key(),
'before' => $before,
'after' => $after ?? [],
], $result->toArray());
}
private function resolveAdapter(OperationRun $run): ?OperationRunReconciliationAdapter
{
return $this->registry->forType((string) $run->type);
}
private function syncRestoreLifecycleTimestamps(OperationRun $run, ReconciliationResult $result): void
{
if (! array_key_exists('restore_run_id', $result->evidence)) {
return;
}
if ($run->completed_at === null && $restoreRun->completed_at !== null) {
$run->completed_at = $restoreRun->completed_at;
if ($run->started_at === null && is_string($result->evidence['restore_started_at'] ?? null)) {
$run->started_at = $result->evidence['restore_started_at'];
}
if ($run->completed_at === null && is_string($result->evidence['restore_completed_at'] ?? null)) {
$run->completed_at = $result->evidence['restore_completed_at'];
}
$run->save();
return [
'applied' => true,
'operation_run_id' => (int) $run->getKey(),
'type' => (string) $run->type,
'restore_run_id' => (int) $restoreRun->getKey(),
'before' => $before,
'after' => $after,
];
}
/**
* @return array<string, mixed>|null
*/
private function reconcileReviewComposeRun(OperationRun $run, bool $dryRun): ?array
{
$result = $this->environmentReviewComposeDecision->evaluate($run);
if (! $result->shouldFinalizeRun()) {
if (! $dryRun) {
return null;
}
return array_merge($result->toArray(), [
'applied' => false,
'operation_run_id' => (int) $run->getKey(),
'type' => (string) $run->type,
'before' => [
'status' => (string) $run->status,
'outcome' => (string) $run->outcome,
],
]);
}
$before = [
'status' => (string) $run->status,
'outcome' => (string) $run->outcome,
];
$after = [
'status' => (string) $result->status,
'outcome' => (string) $result->outcome,
];
if ($dryRun) {
return array_merge($result->toArray(), [
'applied' => false,
'operation_run_id' => (int) $run->getKey(),
'type' => (string) $run->type,
'before' => $before,
'after' => $after,
]);
}
/** @var OperationRunService $runs */
$runs = app(OperationRunService::class);
$runs->updateRunWithReconciliation(
run: $run,
status: (string) $result->status,
outcome: (string) $result->outcome,
summaryCounts: $result->summaryCounts,
failures: $result->failures,
reasonCode: $result->reasonCode,
reasonMessage: $result->reasonMessage,
source: 'adapter_reconciler',
evidence: $result->evidence,
adapter: EnvironmentReviewComposeDecision::ADAPTER,
decision: $result->decision,
related: $result->related,
);
$run->refresh();
return array_merge($result->toArray(), [
'applied' => true,
'operation_run_id' => (int) $run->getKey(),
'type' => (string) $run->type,
'before' => $before,
'after' => $after,
]);
}
private function isTerminalRestoreStatus(?RestoreRunStatus $status): bool
{
if (! $status instanceof RestoreRunStatus) {
return false;
}
return in_array($status, [
RestoreRunStatus::Previewed,
RestoreRunStatus::Completed,
RestoreRunStatus::Partial,
RestoreRunStatus::Failed,
RestoreRunStatus::Cancelled,
RestoreRunStatus::Aborted,
RestoreRunStatus::CompletedWithErrors,
], true);
}
/**
* @return array{0:string,1:string,2:array<int,array{code:string,message:string}>}
*/
private function mapRestoreToOperationRun(RestoreRun $restoreRun, RestoreRunStatus $status): array
{
$failureReason = is_string($restoreRun->failure_reason ?? null) ? (string) $restoreRun->failure_reason : '';
return match ($status) {
RestoreRunStatus::Previewed => [OperationRunStatus::Completed->value, OperationRunOutcome::Succeeded->value, []],
RestoreRunStatus::Completed => [OperationRunStatus::Completed->value, OperationRunOutcome::Succeeded->value, []],
RestoreRunStatus::Partial, RestoreRunStatus::CompletedWithErrors => [
OperationRunStatus::Completed->value,
OperationRunOutcome::PartiallySucceeded->value,
[[
'code' => 'restore.completed_with_warnings',
'message' => $failureReason !== '' ? $failureReason : 'Restore completed with warnings.',
]],
],
RestoreRunStatus::Failed, RestoreRunStatus::Aborted => [
OperationRunStatus::Completed->value,
OperationRunOutcome::Failed->value,
[[
'code' => 'restore.failed',
'message' => $failureReason !== '' ? $failureReason : 'Restore failed.',
]],
],
RestoreRunStatus::Cancelled => [
OperationRunStatus::Completed->value,
OperationRunOutcome::Failed->value,
[[
'code' => 'restore.cancelled',
'message' => 'Restore run was cancelled.',
]],
],
default => [OperationRunStatus::Running->value, OperationRunOutcome::Pending->value, []],
};
}
/**
* @return array<string, int>
*/
private function buildSummaryCounts(RestoreRun $restoreRun): array
{
$metadata = is_array($restoreRun->metadata) ? $restoreRun->metadata : [];
$counts = [];
foreach (['total', 'processed', 'succeeded', 'failed', 'skipped'] as $key) {
if (array_key_exists($key, $metadata) && is_numeric($metadata[$key])) {
$counts[$key] = (int) $metadata[$key];
}
}
if (! isset($counts['processed'])) {
$processed = (int) ($counts['succeeded'] ?? 0) + (int) ($counts['failed'] ?? 0) + (int) ($counts['skipped'] ?? 0);
if ($processed > 0) {
$counts['processed'] = $processed;
}
}
return $counts;
}
}

View File

@ -5,16 +5,16 @@
namespace App\Services\EnvironmentReviews;
use App\Jobs\ComposeEnvironmentReviewJob;
use App\Models\EvidenceSnapshot;
use App\Models\OperationRun;
use App\Models\ManagedEnvironment;
use App\Models\EnvironmentReview;
use App\Models\EvidenceSnapshot;
use App\Models\ManagedEnvironment;
use App\Models\OperationRun;
use App\Models\User;
use App\Services\Audit\WorkspaceAuditLogger;
use App\Services\OperationRunService;
use App\Support\Audit\AuditActionId;
use App\Support\OperationRunType;
use App\Support\EnvironmentReviewStatus;
use App\Support\OperationRunType;
use Illuminate\Database\QueryException;
use Illuminate\Support\Facades\DB;
use InvalidArgumentException;
@ -202,8 +202,8 @@ private function queueComposition(
);
if ($operationRun->wasRecentlyCreated) {
$this->operationRuns->dispatchOrFail($operationRun, function () use ($review, $operationRun): void {
ComposeEnvironmentReviewJob::dispatch(
$this->operationRuns->dispatchOrFail($operationRun, function () use ($review, $operationRun) {
return ComposeEnvironmentReviewJob::dispatch(
environmentReviewId: (int) $review->getKey(),
operationRunId: (int) $operationRun->getKey(),
);

View File

@ -2,8 +2,8 @@
namespace App\Services;
use App\Models\OperationRun;
use App\Models\ManagedEnvironment;
use App\Models\OperationRun;
use App\Models\User;
use App\Models\Workspace;
use App\Notifications\OperationRunCompleted as OperationRunCompletedNotification;
@ -19,7 +19,9 @@
use App\Support\Operations\ExecutionDenialReasonCode;
use App\Support\Operations\LifecycleReconciliationReason;
use App\Support\Operations\OperationRunCapabilityResolver;
use App\Support\Operations\OperationRunCorrelationResolver;
use App\Support\Operations\QueuedExecutionLegitimacyDecision;
use App\Support\Operations\Reconciliation\ReconciliationResult;
use App\Support\OpsUx\BulkRunContext;
use App\Support\OpsUx\RunFailureSanitizer;
use App\Support\OpsUx\SummaryCountsNormalizer;
@ -34,6 +36,7 @@
use App\Support\Verification\BlockedVerificationReportFactory;
use App\Support\Verification\VerificationReportWriter;
use Illuminate\Database\QueryException;
use Illuminate\Foundation\Bus\PendingDispatch;
use Illuminate\Support\Facades\DB;
use InvalidArgumentException;
use ReflectionFunction;
@ -45,6 +48,7 @@ class OperationRunService
public function __construct(
private readonly AuditRecorder $auditRecorder,
private readonly OperationRunCapabilityResolver $operationRunCapabilityResolver,
private readonly OperationRunCorrelationResolver $operationRunCorrelationResolver,
private readonly ReasonTranslator $reasonTranslator,
private readonly ProductTelemetryRecorder $productTelemetryRecorder,
) {}
@ -650,7 +654,8 @@ public function incrementSummaryCounts(OperationRun $run, array $delta): Operati
public function dispatchOrFail(OperationRun $run, callable $dispatcher, bool $emitQueuedNotification = false): void
{
try {
$this->invokeDispatcher($dispatcher, $run);
$dispatch = $this->invokeDispatcher($dispatcher, $run);
$this->recordDispatchContext($run, $dispatch);
if ($emitQueuedNotification && $run->wasRecentlyCreated && $run->user instanceof User) {
$run->user->notify(new OperationRunQueuedNotification($run));
@ -672,6 +677,32 @@ public function dispatchOrFail(OperationRun $run, callable $dispatcher, bool $em
}
}
public function applyReconciliationResult(
OperationRun $run,
ReconciliationResult $result,
string $source = 'adapter_reconciler',
?string $adapter = null,
): OperationRun {
if (! $result->shouldFinalizeRun()) {
return $run;
}
return $this->updateRunWithReconciliation(
run: $run,
status: (string) $result->status,
outcome: (string) $result->outcome,
summaryCounts: $result->summaryCounts,
failures: $result->failures,
reasonCode: $result->reasonCode,
reasonMessage: $result->reasonMessage,
source: $source,
evidence: $result->evidence,
adapter: $adapter,
decision: $result->decision,
related: $result->related,
);
}
/**
* Append failure entries to failure_summary (sanitized + bounded) without overwriting existing.
*
@ -890,7 +921,6 @@ public function updateRunWithReconciliation(
}
$context = is_array($locked->context) ? $locked->context : [];
$context['reason_code'] = RunFailureSanitizer::normalizeReasonCode($reasonCode);
$context['reconciliation'] = $this->reconciliationMetadata(
reasonCode: $reasonCode,
reasonMessage: $reasonMessage,
@ -1008,7 +1038,7 @@ public function finalizeExecutionLegitimacyBlockedRun(
return $run;
}
private function invokeDispatcher(callable $dispatcher, OperationRun $run): void
private function invokeDispatcher(callable $dispatcher, OperationRun $run): mixed
{
$ref = null;
@ -1024,12 +1054,10 @@ private function invokeDispatcher(callable $dispatcher, OperationRun $run): void
}
if ($ref && $ref->getNumberOfParameters() >= 1) {
$dispatcher($run);
return;
return $dispatcher($run);
}
$dispatcher();
return $dispatcher();
}
protected function calculateHash(int $tenantId, string $type, array $inputs): string
@ -1297,28 +1325,114 @@ private function reconciliationMetadata(
?string $decision = null,
array $related = [],
): array {
$timestamp = now()->toIso8601String();
$reconciledAt = now()->toIso8601String();
$reasonCode = RunFailureSanitizer::normalizeReasonCode($reasonCode);
return array_filter([
'reconciled_at' => $timestamp,
'timestamp' => $timestamp,
'reason' => RunFailureSanitizer::normalizeReasonCode($reasonCode),
'reason_code' => RunFailureSanitizer::normalizeReasonCode($reasonCode),
'reconciled_at' => $reconciledAt,
'reason_code' => $reasonCode,
'reason_message' => $this->sanitizeMessage($reasonMessage),
'source' => $this->sanitizeFailureCode($source),
'adapter' => is_string($adapter) && trim($adapter) !== '' ? trim($adapter) : null,
'decision' => is_string($decision) && trim($decision) !== '' ? trim($decision) : null,
'previous_status' => $previousStatus,
'previous_outcome' => $previousOutcome,
'previous' => [
'status' => $previousStatus,
'outcome' => $previousOutcome,
],
'related' => $related,
'related' => $this->normalizeReconciliationRelated($related),
'evidence' => $evidence,
], static fn (mixed $value): bool => $value !== null && $value !== []);
}
private function recordDispatchContext(OperationRun $run, mixed $dispatch): void
{
$job = $this->resolveDispatchedJob($dispatch);
if (! is_object($job)) {
return;
}
$context = is_array($run->context) ? $run->context : [];
$current = is_array($context['dispatch'] ?? null) ? $context['dispatch'] : [];
$dispatchContext = array_filter(array_replace($current, [
'job_class' => $job::class,
'queue' => $this->sanitizeDispatchScalar($job->queue ?? null),
'connection' => $this->sanitizeDispatchScalar($job->connection ?? null),
'dispatched_at' => is_string($current['dispatched_at'] ?? null) && trim((string) $current['dispatched_at']) !== ''
? trim((string) $current['dispatched_at'])
: now()->toIso8601String(),
'correlation_version' => $this->operationRunCorrelationResolver::VERSION,
'operation_run_id' => (int) $run->getKey(),
]), static fn (mixed $value): bool => $value !== null && $value !== '');
if ($dispatchContext === $current) {
return;
}
$context['dispatch'] = $dispatchContext;
$run->update([
'context' => $context,
]);
$run->refresh();
}
private function resolveDispatchedJob(mixed $dispatch): mixed
{
if ($dispatch instanceof PendingDispatch) {
return $dispatch->getJob();
}
return is_object($dispatch) ? $dispatch : null;
}
private function sanitizeDispatchScalar(mixed $value): ?string
{
if (! is_string($value)) {
return null;
}
$value = trim($value);
return $value === '' ? null : $value;
}
/**
* @param array<string, mixed> $related
* @return array<string, mixed>
*/
private function normalizeReconciliationRelated(array $related): array
{
$type = is_string($related['type'] ?? null) ? trim((string) $related['type']) : null;
$id = is_numeric($related['id'] ?? null) ? (int) $related['id'] : null;
if (($type === null || $id === null) && is_numeric(data_get($related, 'review.id'))) {
$type = 'environment_review';
$id = (int) data_get($related, 'review.id');
$related = array_merge(
is_array(data_get($related, 'review')) ? data_get($related, 'review') : [],
array_diff_key($related, ['review' => true]),
);
}
unset($related['type'], $related['id']);
$normalized = [
'type' => $type,
'id' => $id,
];
foreach ($related as $key => $value) {
if (! is_string($key) || $key === '' || $value === null || $value === []) {
continue;
}
$normalized[$key] = $value;
}
return array_filter($normalized, static fn (mixed $value): bool => $value !== null && $value !== []);
}
private function bridgeReasonForThrowable(Throwable $exception): LifecycleReconciliationReason
{
$className = strtolower(class_basename($exception));

View File

@ -74,6 +74,7 @@ private function rules(): array
new NavigationMatrixRule(self::SOURCE_OPERATION_RUN, self::SURFACE_DETAIL_SECTION, 'baseline_profile', 'baseline_profile', 'direct_record', 30, missingStatePolicy: 'hide'),
new NavigationMatrixRule(self::SOURCE_OPERATION_RUN, self::SURFACE_DETAIL_SECTION, 'baseline_snapshot', 'baseline_snapshot', 'direct_record', 40, missingStatePolicy: 'hide'),
new NavigationMatrixRule(self::SOURCE_OPERATION_RUN, self::SURFACE_DETAIL_SECTION, 'parent_policy', 'policy', 'direct_record', 50, missingStatePolicy: 'hide'),
new NavigationMatrixRule(self::SOURCE_OPERATION_RUN, self::SURFACE_DETAIL_SECTION, 'environment_review', 'environment_review', 'direct_record', 55, missingStatePolicy: 'hide'),
new NavigationMatrixRule(self::SOURCE_OPERATION_RUN, self::SURFACE_DETAIL_SECTION, 'operations', 'operations', 'canonical_page', 60, missingStatePolicy: 'hide'),
new NavigationMatrixRule(self::SOURCE_BASELINE_PROFILE, self::SURFACE_DETAIL_HEADER, 'baseline_snapshot', 'baseline_snapshot', 'direct_record', 10, missingStatePolicy: 'hide'),

View File

@ -17,6 +17,7 @@ final class RelatedActionLabelCatalog
'baseline_snapshot' => 'Snapshot',
'backup_set' => 'Backup set',
'current_policy_version' => 'Current policy version',
'environment_review' => 'ManagedEnvironment Review',
'operations' => 'Operations',
'parent_policy' => 'Policy',
'policy_version' => 'Policy version',
@ -32,6 +33,7 @@ final class RelatedActionLabelCatalog
'baseline_snapshot' => 'View snapshot',
'backup_set' => 'View backup set',
'current_policy_version' => 'View policy version',
'environment_review' => 'ManagedEnvironment Review',
'operations' => 'Open operations',
'parent_policy' => 'View policy',
'policy_version' => 'View policy version',

View File

@ -9,6 +9,7 @@
use App\Filament\Resources\BackupSetResource;
use App\Filament\Resources\BaselineProfileResource;
use App\Filament\Resources\BaselineSnapshotResource;
use App\Filament\Resources\EnvironmentReviewResource;
use App\Filament\Resources\FindingExceptionResource;
use App\Filament\Resources\FindingResource;
use App\Filament\Resources\PolicyVersionResource;
@ -18,6 +19,7 @@
use App\Models\BaselineProfile;
use App\Models\BaselineSnapshot;
use App\Models\BaselineSnapshotItem;
use App\Models\EnvironmentReview;
use App\Models\Finding;
use App\Models\FindingException;
use App\Models\ManagedEnvironment;
@ -507,6 +509,7 @@ private function resolveOperationRunRule(NavigationMatrixRule $rule, OperationRu
workspaceId: (int) $run->workspace_id,
),
'parent_policy' => $this->operationRunPolicyEntry($rule, $run),
'environment_review' => $this->environmentReviewEntry($rule, $run),
'operations' => $this->operationsEntry(
rule: $rule,
tenant: $run->tenant,
@ -669,6 +672,42 @@ private function operationRunPolicyEntry(NavigationMatrixRule $rule, OperationRu
return $this->policyEntry($rule, $policy);
}
private function environmentReviewEntry(NavigationMatrixRule $rule, OperationRun $run): ?RelatedContextEntry
{
$reviewId = $run->reconciledRelatedReviewId();
$tenant = $run->tenant;
if ($reviewId === null || $reviewId <= 0 || ! $tenant instanceof ManagedEnvironment) {
return null;
}
$review = EnvironmentReview::query()
->whereKey($reviewId)
->where('workspace_id', (int) $run->workspace_id)
->where('managed_environment_id', (int) $tenant->getKey())
->first();
if (! $review instanceof EnvironmentReview) {
return $this->unavailableEntry($rule, '#'.$reviewId, 'missing');
}
if (! $this->canOpenTenantRecord($tenant, Capabilities::ENVIRONMENT_REVIEW_VIEW)) {
return $this->unavailableEntry($rule, '#'.$reviewId, 'unauthorized');
}
return RelatedContextEntry::available(
key: $rule->relationKey,
label: $this->labels->entryLabel($rule->relationKey),
value: 'Review #'.$review->getKey(),
secondaryValue: ucfirst(str_replace('_', ' ', (string) $review->status)),
targetUrl: EnvironmentReviewResource::environmentScopedUrl('view', ['record' => $review], $tenant),
targetKind: $rule->targetType,
priority: $rule->priority,
actionLabel: $this->labels->actionLabel($rule->relationKey),
contextBadge: 'ManagedEnvironment',
);
}
private function parentPolicyEntryForFinding(NavigationMatrixRule $rule, Finding $finding): ?RelatedContextEntry
{
$policyVersionId = $this->findingPolicyVersionId($finding);
@ -909,7 +948,7 @@ private function operationsEntry(
targetKind: $rule->targetType,
priority: $rule->priority,
actionLabel: $this->labels->actionLabel($rule->relationKey),
contextBadge: 'ManagedEnvironment context',
contextBadge: 'Environment context',
);
}

View File

@ -0,0 +1,78 @@
<?php
declare(strict_types=1);
namespace App\Support\Operations;
use App\Models\OperationRun;
final class OperationRunCorrelationResolver
{
public const int VERSION = 1;
/**
* @param mixed $job
*/
public function resolve($job): ?OperationRun
{
$direct = $this->resolveDirectReference($job);
if ($direct instanceof OperationRun) {
return $direct;
}
$operationRunId = $this->resolveOperationRunId($job);
if ($operationRunId === null) {
return null;
}
return OperationRun::query()->find($operationRunId);
}
/**
* @param mixed $job
*/
public function resolveOperationRunId($job): ?int
{
if (method_exists($job, 'getOperationRunId')) {
$candidate = $job->getOperationRunId();
if (is_numeric($candidate) && (int) $candidate > 0) {
return (int) $candidate;
}
}
if (property_exists($job, 'operationRunId')) {
$candidate = $job->operationRunId;
if (is_numeric($candidate) && (int) $candidate > 0) {
return (int) $candidate;
}
}
$direct = $this->resolveDirectReference($job);
return $direct instanceof OperationRun ? (int) $direct->getKey() : null;
}
/**
* @param mixed $job
*/
private function resolveDirectReference($job): ?OperationRun
{
if (method_exists($job, 'getOperationRun')) {
$candidate = $job->getOperationRun();
return $candidate instanceof OperationRun ? $candidate : null;
}
if (property_exists($job, 'operationRun')) {
$candidate = $job->operationRun;
return $candidate instanceof OperationRun ? $candidate : null;
}
return null;
}
}

View File

@ -293,13 +293,12 @@ private function isBlockingState(EnvironmentReview $review): bool
private function relatedReviewMetadata(EnvironmentReview $review): array
{
return array_filter([
'review' => [
'id' => (int) $review->getKey(),
'status' => (string) $review->status,
'fingerprint' => (string) $review->fingerprint,
'operation_run_id' => is_numeric($review->operation_run_id) ? (int) $review->operation_run_id : null,
'superseded_by_review_id' => is_numeric($review->superseded_by_review_id) ? (int) $review->superseded_by_review_id : null,
],
'type' => 'environment_review',
'id' => (int) $review->getKey(),
'status' => (string) $review->status,
'fingerprint' => (string) $review->fingerprint,
'operation_run_id' => is_numeric($review->operation_run_id) ? (int) $review->operation_run_id : null,
'superseded_by_review_id' => is_numeric($review->superseded_by_review_id) ? (int) $review->superseded_by_review_id : null,
], static fn (mixed $value): bool => $value !== null && $value !== []);
}

View File

@ -0,0 +1,61 @@
<?php
declare(strict_types=1);
namespace App\Support\Operations\Reconciliation;
use App\Models\OperationRun;
use App\Support\Operations\LifecycleReconciliationReason;
use Illuminate\Database\QueryException;
use Throwable;
final class EnvironmentReviewComposeReconciliationAdapter implements OperationRunReconciliationAdapter
{
public function __construct(
private readonly EnvironmentReviewComposeDecision $decision,
) {}
public function key(): string
{
return EnvironmentReviewComposeDecision::ADAPTER;
}
public function supportedTypes(): array
{
return ['environment.review.compose'];
}
public function supportsType(string $type): bool
{
return $type === 'environment.review.compose';
}
public function reconcile(OperationRun $run): ?ReconciliationResult
{
return $this->decision->evaluate($run);
}
public function reconcileException(OperationRun $run, Throwable $throwable): ?ReconciliationResult
{
if (! $throwable instanceof QueryException || ! $this->isUniqueViolation($throwable)) {
return null;
}
return ReconciliationResult::attentionRequired(
reasonCode: LifecycleReconciliationReason::AdapterOutOfSync->value,
reasonMessage: 'TenantPilot found matching review activity, but it could not be resolved automatically.',
evidence: [
'adapter' => EnvironmentReviewComposeDecision::ADAPTER,
'exception_class' => class_basename($throwable),
'workspace_id' => (int) $run->workspace_id,
'managed_environment_id' => (int) $run->managed_environment_id,
'fingerprint' => (string) data_get($run->context, 'review_fingerprint', ''),
],
);
}
private function isUniqueViolation(QueryException $exception): bool
{
return in_array(($exception->errorInfo[0] ?? null), ['23505', '23000'], true);
}
}

View File

@ -0,0 +1,24 @@
<?php
declare(strict_types=1);
namespace App\Support\Operations\Reconciliation;
use App\Models\OperationRun;
use Throwable;
interface OperationRunReconciliationAdapter
{
public function key(): string;
/**
* @return array<int, string>
*/
public function supportedTypes(): array;
public function supportsType(string $type): bool;
public function reconcile(OperationRun $run): ?ReconciliationResult;
public function reconcileException(OperationRun $run, Throwable $throwable): ?ReconciliationResult;
}

View File

@ -0,0 +1,46 @@
<?php
declare(strict_types=1);
namespace App\Support\Operations\Reconciliation;
final class OperationRunReconciliationRegistry
{
public function __construct(
private readonly RestoreExecuteReconciliationAdapter $restoreExecuteAdapter,
private readonly EnvironmentReviewComposeReconciliationAdapter $environmentReviewComposeAdapter,
) {}
/**
* @return array<int, OperationRunReconciliationAdapter>
*/
public function all(): array
{
return [
$this->restoreExecuteAdapter,
$this->environmentReviewComposeAdapter,
];
}
/**
* @return array<int, string>
*/
public function supportedTypes(): array
{
return array_values(array_unique(array_merge(
$this->restoreExecuteAdapter->supportedTypes(),
$this->environmentReviewComposeAdapter->supportedTypes(),
)));
}
public function forType(string $type): ?OperationRunReconciliationAdapter
{
foreach ($this->all() as $adapter) {
if ($adapter->supportsType($type)) {
return $adapter;
}
}
return null;
}
}

View File

@ -15,7 +15,7 @@
* @param array<string, int> $summaryCounts
* @param array<int, array{code:string,reason_code:string,message:string}> $failures
*/
private function __construct(
public function __construct(
public string $decision,
public ?string $status,
public ?string $outcome,

View File

@ -0,0 +1,160 @@
<?php
declare(strict_types=1);
namespace App\Support\Operations\Reconciliation;
use App\Models\OperationRun;
use App\Models\RestoreRun;
use App\Support\OperationRunOutcome;
use App\Support\OperationRunStatus;
use App\Support\Operations\LifecycleReconciliationReason;
use App\Support\RestoreRunStatus;
use Throwable;
final class RestoreExecuteReconciliationAdapter implements OperationRunReconciliationAdapter
{
public function key(): string
{
return 'restore_run';
}
public function supportedTypes(): array
{
return ['restore.execute'];
}
public function supportsType(string $type): bool
{
return $type === 'restore.execute';
}
public function reconcile(OperationRun $run): ?ReconciliationResult
{
$context = is_array($run->context) ? $run->context : [];
$restoreRunId = $context['restore_run_id'] ?? null;
if (! is_numeric($restoreRunId)) {
return null;
}
$restoreRun = RestoreRun::query()
->where('managed_environment_id', $run->managed_environment_id)
->whereKey((int) $restoreRunId)
->first();
if (! $restoreRun instanceof RestoreRun) {
return null;
}
$restoreStatus = RestoreRunStatus::fromString($restoreRun->status);
if (! $this->isTerminalRestoreStatus($restoreStatus)) {
return null;
}
[$status, $outcome, $failures] = $this->mapRestoreToOperationRun($restoreRun, $restoreStatus);
return new ReconciliationResult(
decision: 'reconciled_succeeded',
status: $status,
outcome: $outcome,
reasonCode: LifecycleReconciliationReason::AdapterOutOfSync->value,
reasonMessage: LifecycleReconciliationReason::AdapterOutOfSync->defaultMessage(),
evidence: array_filter([
'restore_run_id' => (int) $restoreRun->getKey(),
'restore_status' => $restoreStatus?->value,
'restore_started_at' => $restoreRun->started_at?->toIso8601String(),
'restore_completed_at' => $restoreRun->completed_at?->toIso8601String(),
], static fn (mixed $value): bool => $value !== null),
related: [],
summaryCounts: $this->buildSummaryCounts($restoreRun),
failures: $failures,
safeForAutoCompletion: true,
);
}
public function reconcileException(OperationRun $run, Throwable $throwable): ?ReconciliationResult
{
return null;
}
private function isTerminalRestoreStatus(?RestoreRunStatus $status): bool
{
if (! $status instanceof RestoreRunStatus) {
return false;
}
return in_array($status, [
RestoreRunStatus::Previewed,
RestoreRunStatus::Completed,
RestoreRunStatus::Partial,
RestoreRunStatus::Failed,
RestoreRunStatus::Cancelled,
RestoreRunStatus::Aborted,
RestoreRunStatus::CompletedWithErrors,
], true);
}
/**
* @return array{0:string,1:string,2:array<int,array{code:string,message:string}>}
*/
private function mapRestoreToOperationRun(RestoreRun $restoreRun, RestoreRunStatus $status): array
{
$failureReason = is_string($restoreRun->failure_reason ?? null) ? (string) $restoreRun->failure_reason : '';
return match ($status) {
RestoreRunStatus::Previewed => [OperationRunStatus::Completed->value, OperationRunOutcome::Succeeded->value, []],
RestoreRunStatus::Completed => [OperationRunStatus::Completed->value, OperationRunOutcome::Succeeded->value, []],
RestoreRunStatus::Partial, RestoreRunStatus::CompletedWithErrors => [
OperationRunStatus::Completed->value,
OperationRunOutcome::PartiallySucceeded->value,
[[
'code' => 'restore.completed_with_warnings',
'message' => $failureReason !== '' ? $failureReason : 'Restore completed with warnings.',
]],
],
RestoreRunStatus::Failed, RestoreRunStatus::Aborted => [
OperationRunStatus::Completed->value,
OperationRunOutcome::Failed->value,
[[
'code' => 'restore.failed',
'message' => $failureReason !== '' ? $failureReason : 'Restore failed.',
]],
],
RestoreRunStatus::Cancelled => [
OperationRunStatus::Completed->value,
OperationRunOutcome::Failed->value,
[[
'code' => 'restore.cancelled',
'message' => 'Restore run was cancelled.',
]],
],
default => [OperationRunStatus::Running->value, OperationRunOutcome::Pending->value, []],
};
}
/**
* @return array<string, int>
*/
private function buildSummaryCounts(RestoreRun $restoreRun): array
{
$metadata = is_array($restoreRun->metadata) ? $restoreRun->metadata : [];
$counts = [];
foreach ([
'total' => $metadata['total_items'] ?? null,
'processed' => $metadata['processed_items'] ?? null,
'succeeded' => $metadata['succeeded_items'] ?? null,
'failed' => $metadata['failed_items'] ?? null,
'skipped' => $metadata['skipped_items'] ?? null,
] as $key => $value) {
if (is_numeric($value)) {
$counts[$key] = (int) $value;
}
}
return $counts;
}
}

View File

@ -60,7 +60,7 @@ public function resolve(ReferenceDescriptor $descriptor): ResolvedReference
targetKind: ReferenceClass::OperationRun->value,
url: OperationRunLinks::tenantlessView($run, $navigationContext),
actionLabel: OperationRunLinks::openLabel(),
contextBadge: $run->managed_environment_id ? 'ManagedEnvironment context' : 'Workspace',
contextBadge: $run->managed_environment_id ? 'Environment context' : 'Workspace',
),
);
}

View File

@ -0,0 +1,167 @@
<?php
declare(strict_types=1);
use App\Models\EnvironmentReview;
use App\Models\ManagedEnvironment;
use App\Models\OperationRun;
use App\Models\User;
use App\Services\OperationRunService;
use App\Support\EnvironmentReviewStatus;
use App\Support\OperationRunLinks;
use App\Support\OperationRunOutcome;
use App\Support\OperationRunStatus;
use App\Support\Workspaces\WorkspaceContext;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
pest()->browser()->timeout(60_000);
it('Spec360 smokes canonical review reconciliation drill-through on the existing operations surfaces', function (): void {
[$user, $environment] = createUserWithTenant(role: 'owner', workspaceRole: 'manager');
spec360AuthenticateBrowser($this, $user, $environment);
$review = spec360BrowserCreatePublishedMatchingReview($environment, $user, 'spec360-browser-reused');
$run = spec360BrowserCreateCanonicalReconciledReviewComposeRun($environment, $user, $review);
visit(OperationRunLinks::index($environment))
->resize(1440, 1100)
->waitForText('Operations Hub')
->assertSee('Automatically reconciled')
->assertSee('A matching review was already available.')
->assertNoJavaScriptErrors()
->assertNoConsoleLogs();
visit(OperationRunLinks::tenantlessView($run))
->waitForText('Monitoring detail')
->assertSee('A matching review was already available.')
->assertSee('No action needed. A matching review was already available.')
->click('Open')
->assertSee('ManagedEnvironment Review')
->assertNoJavaScriptErrors()
->assertNoConsoleLogs();
});
it('Spec360 smokes stale queued lifecycle guidance separately from canonical reconciliation', function (): void {
[$user, $environment] = createUserWithTenant(role: 'owner', workspaceRole: 'manager');
spec360AuthenticateBrowser($this, $user, $environment);
$staleRun = OperationRun::factory()->forTenant($environment)->create([
'user_id' => (int) $user->getKey(),
'initiator_name' => $user->name,
'type' => 'inventory.sync',
'status' => OperationRunStatus::Queued->value,
'outcome' => OperationRunOutcome::Pending->value,
'created_at' => now()->subWeeks(2),
]);
visit(OperationRunLinks::index($environment))
->resize(1440, 1100)
->waitForText('Operations Hub')
->assertSee('Likely stale')
->assertSee('Past the lifecycle window. Review worker health and logs before retrying.')
->assertNoJavaScriptErrors()
->assertNoConsoleLogs();
visit(OperationRunLinks::tenantlessView($staleRun))
->waitForText('Monitoring detail')
->assertSee('Likely stale operation')
->assertDontSee('No action needed yet. The operation is currently in progress.')
->assertNoJavaScriptErrors()
->assertNoConsoleLogs();
});
function spec360AuthenticateBrowser(mixed $test, User $user, ManagedEnvironment $environment): void
{
$workspaceId = (int) $environment->workspace_id;
$test->actingAs($user)->withSession([
WorkspaceContext::SESSION_KEY => $workspaceId,
WorkspaceContext::LAST_ENVIRONMENT_IDS_SESSION_KEY => [
(string) $workspaceId => (int) $environment->getKey(),
],
]);
session()->put(WorkspaceContext::SESSION_KEY, $workspaceId);
session()->put(WorkspaceContext::LAST_ENVIRONMENT_IDS_SESSION_KEY, [
(string) $workspaceId => (int) $environment->getKey(),
]);
setAdminPanelContext($environment);
}
function spec360BrowserCreatePublishedMatchingReview(
ManagedEnvironment $environment,
User $user,
string $fingerprint,
): EnvironmentReview {
$snapshot = seedEnvironmentReviewEvidence($environment, operationRunCount: 1);
$publishedRun = OperationRun::factory()->forTenant($environment)->create([
'user_id' => (int) $user->getKey(),
'initiator_name' => $user->name,
'type' => 'environment.review.compose',
'status' => OperationRunStatus::Completed->value,
'outcome' => OperationRunOutcome::Succeeded->value,
'completed_at' => now()->subMinutes(5),
'context' => [
'workspace_id' => (int) $environment->workspace_id,
'managed_environment_id' => (int) $environment->getKey(),
'review_fingerprint' => $fingerprint,
],
]);
return EnvironmentReview::factory()->published()->create([
'workspace_id' => (int) $environment->workspace_id,
'managed_environment_id' => (int) $environment->getKey(),
'evidence_snapshot_id' => (int) $snapshot->getKey(),
'initiated_by_user_id' => (int) $user->getKey(),
'operation_run_id' => (int) $publishedRun->getKey(),
'fingerprint' => $fingerprint,
'status' => EnvironmentReviewStatus::Published->value,
'published_by_user_id' => (int) $user->getKey(),
]);
}
function spec360BrowserCreateCanonicalReconciledReviewComposeRun(
ManagedEnvironment $environment,
User $user,
EnvironmentReview $relatedReview,
): OperationRun {
$run = OperationRun::factory()->forTenant($environment)->create([
'user_id' => (int) $user->getKey(),
'initiator_name' => $user->name,
'type' => 'environment.review.compose',
'status' => OperationRunStatus::Queued->value,
'outcome' => OperationRunOutcome::Pending->value,
'context' => [
'workspace_id' => (int) $environment->workspace_id,
'managed_environment_id' => (int) $environment->getKey(),
'review_fingerprint' => (string) $relatedReview->fingerprint,
],
]);
return app(OperationRunService::class)->updateRunWithReconciliation(
run: $run,
status: OperationRunStatus::Completed->value,
outcome: OperationRunOutcome::Succeeded->value,
summaryCounts: ['finding_count' => 2],
failures: [],
reasonCode: 'run.adapter_out_of_sync',
reasonMessage: 'A matching review was already available for this run.',
source: 'adapter_reconciler',
evidence: [
'fingerprint' => (string) $relatedReview->fingerprint,
],
adapter: 'environment_review_compose',
decision: 'reconciled_succeeded',
related: [
'type' => 'environment_review',
'id' => (int) $relatedReview->getKey(),
'status' => (string) $relatedReview->status,
],
)->fresh();
}

View File

@ -0,0 +1,66 @@
<?php
declare(strict_types=1);
use App\Models\EnvironmentReview;
use App\Models\OperationRun;
use App\Services\AdapterRunReconciler;
use App\Services\EnvironmentReviews\EnvironmentReviewFingerprint;
use App\Support\EnvironmentReviewStatus;
use App\Support\OperationRunOutcome;
use App\Support\OperationRunStatus;
use Illuminate\Database\QueryException;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
it('finalizes unique-write review compose failures through the canonical adapter seam in Spec360', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'owner');
$snapshot = seedEnvironmentReviewEvidence($tenant);
$fingerprint = app(EnvironmentReviewFingerprint::class)->forSnapshot($tenant, $snapshot);
$run = OperationRun::factory()->create([
'workspace_id' => (int) $tenant->workspace_id,
'managed_environment_id' => (int) $tenant->getKey(),
'user_id' => (int) $user->getKey(),
'initiator_name' => $user->name,
'type' => 'environment.review.compose',
'status' => OperationRunStatus::Queued->value,
'outcome' => OperationRunOutcome::Pending->value,
'context' => [
'workspace_id' => (int) $tenant->workspace_id,
'managed_environment_id' => (int) $tenant->getKey(),
'evidence_snapshot_id' => (int) $snapshot->getKey(),
'review_fingerprint' => $fingerprint,
],
]);
$review = EnvironmentReview::factory()->create([
'workspace_id' => (int) $tenant->workspace_id,
'managed_environment_id' => (int) $tenant->getKey(),
'evidence_snapshot_id' => (int) $snapshot->getKey(),
'initiated_by_user_id' => (int) $user->getKey(),
'operation_run_id' => (int) $run->getKey(),
'fingerprint' => $fingerprint,
'status' => EnvironmentReviewStatus::Draft->value,
]);
$previous = new \PDOException('duplicate key value violates unique constraint');
$previous->errorInfo = ['23505'];
$exception = new QueryException('pgsql', 'insert into environment_reviews ...', [], $previous);
$change = app(AdapterRunReconciler::class)->reconcileOperationRunFailure($run, $exception);
$run->refresh();
$review->refresh();
expect($change['applied'] ?? null)->toBeTrue()
->and($run->status)->toBe(OperationRunStatus::Completed->value)
->and($run->outcome)->toBe(OperationRunOutcome::Failed->value)
->and($run->reconciliationAdapter())->toBe('environment_review_compose')
->and($run->reconciliationDecision())->toBe('attention_required')
->and(data_get($run->context, 'reconciliation.reason_code'))->toBe('run.adapter_out_of_sync')
->and((string) data_get($run->failure_summary, '0.message'))->not->toContain('SQLSTATE')
->and((string) data_get($run->failure_summary, '0.message'))->not->toContain('duplicate key')
->and($review->status)->toBe(EnvironmentReviewStatus::Draft->value);
});

View File

@ -56,7 +56,7 @@
'linkTarget' => [
'url' => '/admin/operations/189',
'actionLabel' => 'Open operation',
'contextBadge' => 'ManagedEnvironment context',
'contextBadge' => 'Environment context',
],
'technicalDetail' => [
'displayId' => '189',
@ -81,7 +81,7 @@
],
);
$referenceBadgePosition = strpos($html, 'ManagedEnvironment context');
$referenceBadgePosition = strpos($html, 'Environment context');
$referenceActionPosition = strpos($html, 'Open operation');
$fallbackBadgePosition = strpos($html, 'Workspace context');
$fallbackActionPosition = strpos($html, 'Inspect operations');

View File

@ -0,0 +1,81 @@
<?php
declare(strict_types=1);
use App\Filament\Resources\EnvironmentReviewResource;
use App\Models\EnvironmentReview;
use App\Models\OperationRun;
use App\Services\AdapterRunReconciler;
use App\Services\EnvironmentReviews\EnvironmentReviewFingerprint;
use App\Support\Navigation\CrossResourceNavigationMatrix;
use App\Support\Navigation\RelatedNavigationResolver;
use App\Support\OperationRunLinks;
use App\Support\OperationRunOutcome;
use App\Support\OperationRunStatus;
use App\Support\Ui\GovernanceArtifactTruth\ArtifactTruthPresenter;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
it('prefers canonical reconciliation related metadata for review links and artifact truth in Spec360', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'owner');
$snapshot = seedEnvironmentReviewEvidence($tenant, operationRunCount: 2);
$fingerprint = app(EnvironmentReviewFingerprint::class)->forSnapshot($tenant, $snapshot);
$run = OperationRun::factory()->create([
'managed_environment_id' => (int) $tenant->getKey(),
'workspace_id' => (int) $tenant->workspace_id,
'user_id' => (int) $user->getKey(),
'type' => 'environment.review.compose',
'status' => OperationRunStatus::Queued->value,
'outcome' => OperationRunOutcome::Pending->value,
'created_at' => now()->subMinutes(20),
'context' => [
'managed_environment_id' => (int) $tenant->getKey(),
'workspace_id' => (int) $tenant->workspace_id,
'evidence_snapshot_id' => (int) $snapshot->getKey(),
'review_fingerprint' => $fingerprint,
],
]);
$otherRun = OperationRun::factory()->create([
'managed_environment_id' => (int) $tenant->getKey(),
'workspace_id' => (int) $tenant->workspace_id,
'type' => 'environment.review.compose',
'status' => OperationRunStatus::Completed->value,
'outcome' => OperationRunOutcome::Succeeded->value,
]);
$review = EnvironmentReview::factory()->ready()->create([
'workspace_id' => (int) $tenant->workspace_id,
'managed_environment_id' => (int) $tenant->getKey(),
'evidence_snapshot_id' => (int) $snapshot->getKey(),
'initiated_by_user_id' => (int) $user->getKey(),
'operation_run_id' => (int) $otherRun->getKey(),
'fingerprint' => $fingerprint,
]);
$change = app(AdapterRunReconciler::class)->reconcileOperationRun($run);
expect($change['applied'] ?? null)->toBeTrue()
->and(data_get($run->fresh()->context, 'reconciliation.related.type'))->toBe('environment_review')
->and(data_get($run->fresh()->context, 'reconciliation.related.id'))->toBe((int) $review->getKey());
$this->actingAs($user);
setAdminPanelContext($tenant);
$links = OperationRunLinks::related($run->fresh(), $tenant);
$sharedLinks = app(RelatedNavigationResolver::class)->operationLinks($run->fresh(), $tenant);
$sharedEntry = collect(app(RelatedNavigationResolver::class)->detailEntries(
CrossResourceNavigationMatrix::SOURCE_OPERATION_RUN,
$run->fresh(),
))->firstWhere('key', 'environment_review');
$truth = app(ArtifactTruthPresenter::class)->forOperationRun($run->fresh());
$expected = EnvironmentReviewResource::environmentScopedUrl('view', ['record' => $review], $tenant);
expect($links['ManagedEnvironment Review'] ?? null)->toBe($expected)
->and($sharedLinks['ManagedEnvironment Review'] ?? null)->toBe($expected)
->and($sharedEntry['targetUrl'] ?? null)->toBe($expected)
->and($sharedEntry['actionLabel'] ?? null)->toBe('ManagedEnvironment Review')
->and($truth->relatedArtifactUrl)->toBe($expected);
});

View File

@ -0,0 +1,89 @@
<?php
declare(strict_types=1);
use App\Jobs\Concerns\BridgesFailedOperationRun;
use App\Jobs\Middleware\TrackOperationRun;
use App\Models\OperationRun;
use App\Support\OperationRunOutcome;
use App\Support\OperationRunStatus;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
uses(RefreshDatabase::class);
class Spec360CorrelationTrackedJob implements ShouldQueue
{
use BridgesFailedOperationRun;
use Dispatchable;
use InteractsWithQueue;
use Queueable;
use SerializesModels;
public function __construct(public int $operationRunId)
{
$this->withFakeQueueInteractions();
}
}
it('uses the same canonical operationRunId contract for middleware and failed-job bridging in Spec360', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'owner');
$run = OperationRun::factory()->create([
'managed_environment_id' => (int) $tenant->getKey(),
'workspace_id' => (int) $tenant->workspace_id,
'user_id' => (int) $user->getKey(),
'type' => 'environment.review.compose',
'status' => OperationRunStatus::Queued->value,
'outcome' => OperationRunOutcome::Pending->value,
]);
$job = new Spec360CorrelationTrackedJob((int) $run->getKey());
app(TrackOperationRun::class)->handle($job, static function (): void {});
expect($run->fresh()->status)->toBe(OperationRunStatus::Completed->value)
->and($run->fresh()->outcome)->toBe(OperationRunOutcome::Succeeded->value);
$run->forceFill([
'status' => OperationRunStatus::Running->value,
'outcome' => OperationRunOutcome::Pending->value,
'completed_at' => null,
'failure_summary' => [],
])->save();
$job->failed(new RuntimeException('worker timed out'));
expect($run->fresh()->status)->toBe(OperationRunStatus::Completed->value)
->and($run->fresh()->outcome)->toBe(OperationRunOutcome::Failed->value)
->and(data_get($run->fresh()->context, 'reconciliation.source'))->toBe('failed_callback');
});
it('fails closed for out-of-scope runId-only jobs in Spec360', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'owner');
$run = OperationRun::factory()->create([
'managed_environment_id' => (int) $tenant->getKey(),
'workspace_id' => (int) $tenant->workspace_id,
'user_id' => (int) $user->getKey(),
'type' => 'environment.review.compose',
'status' => OperationRunStatus::Running->value,
'outcome' => OperationRunOutcome::Pending->value,
]);
$job = new class((int) $run->getKey())
{
use BridgesFailedOperationRun;
public function __construct(public int $runId) {}
};
$job->failed(new RuntimeException('should not bridge'));
expect($run->fresh()->status)->toBe(OperationRunStatus::Running->value)
->and($run->fresh()->outcome)->toBe(OperationRunOutcome::Pending->value);
});

View File

@ -0,0 +1,59 @@
<?php
declare(strict_types=1);
use App\Services\OperationRunService;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\Queue;
uses(RefreshDatabase::class);
class Spec360DispatchContextProbeJob implements ShouldQueue
{
use Dispatchable;
use InteractsWithQueue;
use Queueable;
use SerializesModels;
public function __construct(public int $operationRunId) {}
public function handle(): void {}
}
it('records canonical dispatch metadata without inventing queue job ids in Spec360', function (): void {
Queue::fake();
[$user, $tenant] = createUserWithTenant(role: 'owner');
$run = app(OperationRunService::class)->ensureRun(
tenant: $tenant,
type: 'environment.review.compose',
inputs: ['fingerprint' => 'spec360-dispatch'],
initiator: $user,
);
app(OperationRunService::class)->dispatchOrFail(
$run,
fn () => Spec360DispatchContextProbeJob::dispatch((int) $run->getKey())
->onConnection('database')
->onQueue('operations'),
);
$dispatch = (array) data_get($run->fresh()->context, 'dispatch', []);
expect($dispatch)->toMatchArray([
'job_class' => Spec360DispatchContextProbeJob::class,
'queue' => 'operations',
'connection' => 'database',
'correlation_version' => 1,
'operation_run_id' => (int) $run->getKey(),
])->and($dispatch['dispatched_at'] ?? null)->toBeString()
->and(array_key_exists('job_id', $dispatch))->toBeFalse();
Queue::assertPushed(Spec360DispatchContextProbeJob::class, fn (Spec360DispatchContextProbeJob $job): bool => $job->operationRunId === (int) $run->getKey());
});

View File

@ -3,10 +3,10 @@
declare(strict_types=1);
use App\Models\EnvironmentReview;
use App\Models\OperationRun;
use App\Models\ManagedEnvironment;
use App\Models\User;
use App\Models\EvidenceSnapshot;
use App\Models\ManagedEnvironment;
use App\Models\OperationRun;
use App\Models\User;
use App\Support\EnvironmentReviewStatus;
use App\Support\Operations\Reconciliation\EnvironmentReviewComposeDecision;
use Illuminate\Foundation\Testing\RefreshDatabase;
@ -66,7 +66,8 @@ function spec359ReviewComposeFixture(array $overrides = []): array
$result = app(EnvironmentReviewComposeDecision::class)->evaluate($run);
expect($result->decision)->toBe('reconciled_succeeded')
->and(data_get($result->related, 'review.id'))->toBe((int) $review->getKey())
->and(data_get($result->related, 'type'))->toBe('environment_review')
->and(data_get($result->related, 'id'))->toBe((int) $review->getKey())
->and($result->summaryCounts)->toMatchArray([
'finding_count' => 4,
'report_count' => 2,
@ -96,7 +97,8 @@ function spec359ReviewComposeFixture(array $overrides = []): array
$result = app(EnvironmentReviewComposeDecision::class)->evaluate($run->fresh());
expect($result->decision)->toBe('not_reconciled')
->and(data_get($result->related, 'review.id'))->toBe((int) $review->getKey());
->and(data_get($result->related, 'type'))->toBe('environment_review')
->and(data_get($result->related, 'id'))->toBe((int) $review->getKey());
});
it('returns blocked when an explicit successor review is still draft in Spec359', function (): void {
@ -131,7 +133,8 @@ function spec359ReviewComposeFixture(array $overrides = []): array
$result = app(EnvironmentReviewComposeDecision::class)->evaluate($run->fresh());
expect($result->decision)->toBe('blocked')
->and(data_get($result->related, 'review.id'))->toBe((int) $successor->getKey());
->and(data_get($result->related, 'type'))->toBe('environment_review')
->and(data_get($result->related, 'id'))->toBe((int) $successor->getKey());
});
it('fails closed when multiple usable same-scope reviews exist in Spec359', function (): void {

View File

@ -0,0 +1,16 @@
<?php
declare(strict_types=1);
use App\Support\Operations\Reconciliation\OperationRunReconciliationRegistry;
it('resolves the bounded canonical adapter seam for Spec360', function (): void {
$registry = app(OperationRunReconciliationRegistry::class);
expect($registry->supportedTypes())->toBe([
'restore.execute',
'environment.review.compose',
])->and($registry->forType('restore.execute')?->key())->toBe('restore_run')
->and($registry->forType('environment.review.compose')?->key())->toBe('environment_review_compose')
->and($registry->forType('policy.sync'))->toBeNull();
});

View File

@ -0,0 +1,67 @@
<?php
declare(strict_types=1);
use App\Models\OperationRun;
use App\Services\OperationRunService;
use App\Support\OperationRunOutcome;
use App\Support\OperationRunStatus;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
it('writes canonical reconciliation metadata without duplicate compatibility fields in Spec360', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'owner');
$run = OperationRun::factory()->create([
'managed_environment_id' => (int) $tenant->getKey(),
'workspace_id' => (int) $tenant->workspace_id,
'user_id' => (int) $user->getKey(),
'type' => 'environment.review.compose',
'status' => OperationRunStatus::Queued->value,
'outcome' => OperationRunOutcome::Pending->value,
'context' => [
'dispatch' => [
'job_class' => 'ExistingJob',
'dispatched_at' => now()->subMinute()->toIso8601String(),
],
'keep' => 'me',
],
]);
app(OperationRunService::class)->updateRunWithReconciliation(
run: $run,
status: OperationRunStatus::Completed->value,
outcome: OperationRunOutcome::Succeeded->value,
summaryCounts: ['created' => 1],
failures: [],
reasonCode: 'run.adapter_out_of_sync',
reasonMessage: 'Canonical reconciliation closed the run safely.',
source: 'adapter_reconciler',
evidence: ['proof' => 'bounded'],
adapter: 'environment_review_compose',
decision: 'reconciled_succeeded',
related: [
'review' => [
'id' => 99,
'status' => 'published',
],
],
);
$context = (array) $run->fresh()->context;
$reconciliation = (array) ($context['reconciliation'] ?? []);
expect($context['keep'] ?? null)->toBe('me')
->and(data_get($context, 'dispatch.job_class'))->toBe('ExistingJob')
->and($context['reason_code'] ?? null)->toBeNull()
->and($reconciliation['reason_code'] ?? null)->toBe('run.adapter_out_of_sync')
->and($reconciliation['previous_status'] ?? null)->toBe(OperationRunStatus::Queued->value)
->and($reconciliation['previous_outcome'] ?? null)->toBe(OperationRunOutcome::Pending->value)
->and($reconciliation['timestamp'] ?? null)->toBeNull()
->and($reconciliation['reason'] ?? null)->toBeNull()
->and($reconciliation['previous'] ?? null)->toBeNull()
->and(data_get($reconciliation, 'related.type'))->toBe('environment_review')
->and(data_get($reconciliation, 'related.id'))->toBe(99)
->and(data_get($reconciliation, 'related.status'))->toBe('published');
});

View File

@ -33,7 +33,7 @@
targetKind: ReferenceClass::OperationRun->value,
url: '/admin/operations/44',
actionLabel: 'Open operation',
contextBadge: 'ManagedEnvironment context',
contextBadge: 'Environment context',
),
technicalDetail: ReferenceTechnicalDetail::forIdentifier('44'),
),

View File

@ -9,6 +9,8 @@
expect($catalog->entryLabel('baseline_snapshot'))->toBe('Snapshot')
->and($catalog->actionLabel('baseline_snapshot'))->toBe('View snapshot')
->and($catalog->entryLabel('environment_review'))->toBe('ManagedEnvironment Review')
->and($catalog->actionLabel('environment_review'))->toBe('ManagedEnvironment Review')
->and($catalog->entryLabel('source_run'))->toBe('Operation')
->and($catalog->actionLabel('operations'))->toBe('Open operations');
});

View File

@ -28,14 +28,26 @@ ## Repo Baseline At Prep Time
- **Spec 358 baseline status**: completed foundation context only; use it as the generic queue-truth baseline and do not rewrite its historical close-out or validation language.
- **Related completed context**: Spec 357 remains regression context for report/review output; Spec 311 remains completed shell/scope foundation context only.
## Merge Close-Out Status
- **Merged baseline**: `3a750726 feat: implement review compose reconciliation adapter (spec 359) (#430)` on `platform-dev`
- **Merge note**: Spec 359 shipped the bounded review-compose adapter path, deterministic duplicate/superseded recovery, shared review truth resolution, and bounded Unit/Feature/Browser coverage.
- **Known blocked validation at merge**: local PGSQL validation remained unavailable because the PGSQL host or Docker runtime was not available.
- **Deferred follow-through moved to Spec 360**:
- canonical adapter-seam cleanup beyond the bounded Spec 359 path
- canonical `context.dispatch` and shared queue/failure correlation
- operation-type alias retirement and canonical read-side cutover
- only the bounded review-start feedback or link follow-through that depends on the canonical run or related-artifact truth cutover; no broad UI or localization cleanup rides with Spec 360
- **Historical baseline note**: the `Repo Baseline At Prep Time` section above remains the original prep-time context and is not the post-merge runtime baseline.
## Test Governance Checklist
- [ ] Lane assignment is named and is the narrowest sufficient proof for the changed behavior.
- [ ] New or changed tests stay in the smallest honest family, and the PGSQL/browser additions remain explicit.
- [ ] Shared helpers, factories, seeds, fixtures, and context defaults stay cheap by default; any widening is isolated or documented.
- [ ] Planned validation commands cover the change without widening into unrelated lane cost.
- [ ] The declared monitoring/detail surface profile is explicit.
- [ ] Any material budget, baseline, trend, or escalation note is recorded in the active feature close-out.
- [x] Lane assignment is named and is the narrowest sufficient proof for the changed behavior.
- [x] New or changed tests stay in the smallest honest family, and the PGSQL/browser additions remain explicit.
- [x] Shared helpers, factories, seeds, fixtures, and context defaults stay cheap by default; any widening is isolated or documented.
- [x] Planned validation commands cover the change without widening into unrelated lane cost.
- [x] The declared monitoring/detail surface profile is explicit.
- [x] Any material budget, baseline, trend, or escalation note is recorded in the active feature close-out.
## Phase 1: Setup (Repo Truth Inventory)
@ -98,7 +110,7 @@ ## Phase 4: User Story 2 - Recover duplicate fingerprint and superseded review l
### Tests for User Story 2
- [x] T017 [P] [US2] Add duplicate recovery and unrecoverable-duplicate cases to `apps/platform/tests/Feature/EnvironmentReview/Spec359ReviewComposeReconciliationTest.php`.
- [ ] T018 [P] [US2] Add PGSQL duplicate-index / locking proof to `apps/platform/tests/Feature/EnvironmentReview/Spec359ReviewComposeIdempotencyTest.php` and run it in the `phpunit.pgsql.xml` lane.
- [x] T018 [P] [US2] PGSQL duplicate-index / locking proof was deferred to `specs/360-operationrun-canonical-cutover-cleanup/` because Spec 359 merged with the runtime path in place while local PGSQL validation was still blocked.
- [x] T019 [P] [US2] Add ambiguous/superseded successor cases to `apps/platform/tests/Unit/Support/Operations/Reconciliation/Spec359EnvironmentReviewComposeAdapterTest.php`.
### Implementation for User Story 2
@ -120,12 +132,12 @@ ## Phase 5: User Story 3 - Keep review-start dispatch idempotent for repeated tr
### Tests for User Story 3
- [x] T023 [P] [US3] Add repeated-trigger coverage to `apps/platform/tests/Feature/EnvironmentReview/Spec359ReviewComposeIdempotencyTest.php` for existing mutable review reuse and existing active run reuse.
- [ ] T024 [P] [US3] Extend `apps/platform/tests/Feature/EnvironmentReview/EnvironmentReviewOperationsUxTest.php` or add a Spec 359 UX companion file so review-create feedback remains truthful for already-available and open-operation cases.
- [x] T024 [P] [US3] Deferred only the bounded review-create feedback follow-through to `specs/360-operationrun-canonical-cutover-cleanup/`, where the canonical run or related-artifact truth is finalized across current operations and review-start surfaces.
### Implementation for User Story 3
- [x] T025 [US3] Tighten `apps/platform/app/Services/EnvironmentReviews/EnvironmentReviewService.php` so `create()` / `refresh()` consider existing mutable review truth plus active compose runs before dispatching another job.
- [ ] T026 [US3] Keep `apps/platform/app/Filament/Resources/EnvironmentReviewResource.php` on the current notification/action family and extend it only as needed to surface reused review/run truth.
- [x] T026 [US3] Deferred only the bounded `EnvironmentReviewResource` notification or link follow-through to `specs/360-operationrun-canonical-cutover-cleanup/` so it lands against the canonical read-side and correlation truth instead of the bounded Spec 359 seam alone.
**Checkpoint**: User Story 3 is independently functional when duplicate clicks reuse truth and do not create competing mutable reviews or ghost runs.
@ -140,11 +152,11 @@ ## Phase 6: User Story 4 - Show calm review-compose reconciliation language on e
### Tests for User Story 4
- [x] T027 [P] [US4] Add `apps/platform/tests/Browser/Spec359ReviewComposeReconciliationSmokeTest.php` covering reconciled-success, attention-required, and duplicate-recovered visible states.
- [ ] T028 [P] [US4] Extend or add focused feature assertions for EN/DE localization keys if the visible copy is mapped through current localization helpers.
- [x] T028 [P] [US4] Deferred only the bounded review-start or operation-surface wording follow-through to `specs/360-operationrun-canonical-cutover-cleanup/`, where any remaining copy consolidation can land against the final canonical operator wording.
### Implementation for User Story 4
- [ ] T029 [US4] Add or extend bounded EN/DE localization keys in `apps/platform/lang/en/localization.php` and `apps/platform/lang/de/localization.php` for review-compose reconciliation outcomes, reusing the current review/operations families where possible.
- [x] T029 [US4] Deferred only the bounded wording or link follow-through needed by the canonical cutover to `specs/360-operationrun-canonical-cutover-cleanup/` instead of widening the already merged Spec 359 slice after the fact.
- [x] T030 [US4] Ensure visible copy on monitoring/detail/review-start surfaces never exposes `SQLSTATE`, `duplicate key`, `environment_reviews_fingerprint_mutable_unique`, or crash/orphan claims as primary operator messaging.
**Checkpoint**: User Story 4 is independently functional when operations and review-start surfaces show calm adapter-backed wording only.
@ -153,20 +165,14 @@ ### Implementation for User Story 4
## Phase 7: Polish & Validation
- [ ] T031 [P] Refresh `spec.md`, `plan.md`, and `checklists/requirements.md` only if implementation proves a thinner or broader touched-file boundary.
- [ ] T032 [P] Run `cd apps/platform && ./vendor/bin/sail artisan test --compact --filter=Spec359`.
- [ ] T033 [P] Run `cd apps/platform && ./vendor/bin/sail php vendor/bin/pest -c phpunit.pgsql.xml --filter=Spec359`.
- [ ] T034 [P] Run `cd apps/platform && ./vendor/bin/sail artisan test --compact --filter=Spec358`.
- [ ] T035 [P] Run the named review/report regressions from the active spec:
- `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Browser/CustomerReviewWorkspaceSmokeTest.php`
- `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Browser/Spec357ReportProfilesSmokeTest.php`
- `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/ReviewPack/Spec357RenderedReportProfileTest.php`
- `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/ReviewPack/ReviewPackDownloadTest.php`
- `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/ReviewPack/EnvironmentReviewDerivedReviewPackTest.php`
- `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/ReviewPack/EnvironmentReviewExecutivePackTest.php`
- [ ] T036 [P] Run `cd apps/platform && ./vendor/bin/sail bin pint --dirty --format agent`.
- [x] T031 [P] No broader touched-file boundary was confirmed at merge; remaining canonical-cutover artifact refresh is explicitly carried by `specs/360-operationrun-canonical-cutover-cleanup/`.
- [x] T032 [P] Completed at merge time in bounded form: Spec 359 shipped with focused Unit/Feature/Browser validation as recorded in merge commit `3a750726`.
- [x] T033 [P] PGSQL validation was explicitly blocked at merge time and is carried forward as a named validation gap into `specs/360-operationrun-canonical-cutover-cleanup/`.
- [x] T034 [P] The explicit Spec 358 regression rerun is carried forward to `specs/360-operationrun-canonical-cutover-cleanup/`, which is the post-merge canonical-cutover gate over the merged baseline.
- [x] T035 [P] The named review/report regression reruns are carried forward to `specs/360-operationrun-canonical-cutover-cleanup/`, which now owns the post-merge cutover validation gate.
- [x] T036 [P] Formatting and hygiene are carried forward to the active post-merge cutover validation gate in `specs/360-operationrun-canonical-cutover-cleanup/`.
- [x] T037 [P] Run `git diff --check`.
- [ ] T038 [P] Record the final adapter contract shape, restore-precedent handling decision, duplicate-recovery proof boundaries, and visible wording decisions in the active feature close-out entry `Guardrail / Smoke Coverage`.
- [x] T038 [P] The merge close-out now records the shipped bounded adapter shape, the blocked PGSQL validation state, and the explicit Spec 360 follow-through boundary for the remaining canonical-cutover work.
---
@ -202,8 +208,8 @@ ### Implementation Strategy
## Non-Goals / Must-Not-Do
- [ ] NT001 Do not add a new `reconciled` status column, boolean, or `OperationRun` state family.
- [ ] NT002 Do not expand report/evidence/review-pack/restore/sync/backup/alert business reconciliation in this feature.
- [ ] NT003 Do not add a new queue/job family, a second operator-center UI, or a generic provider framework.
- [ ] NT004 Do not mutate unrelated `EnvironmentReview` records, delete records, or perform cleanup/purge work.
- [ ] NT005 Do not expose raw SQL/constraint/duplicate-key wording on operator-primary or customer-facing surfaces.
- [x] NT001 Do not add a new `reconciled` status column, boolean, or `OperationRun` state family.
- [x] NT002 Do not expand report/evidence/review-pack/restore/sync/backup/alert business reconciliation in this feature.
- [x] NT003 Do not add a new queue/job family, a second operator-center UI, or a generic provider framework.
- [x] NT004 Do not mutate unrelated `EnvironmentReview` records, delete records, or perform cleanup/purge work.
- [x] NT005 Do not expose raw SQL/constraint/duplicate-key wording on operator-primary or customer-facing surfaces.

View File

@ -0,0 +1,64 @@
# Requirements Checklist: Spec 360 - OperationRun Canonical Cutover Cleanup
**Purpose**: Preparation analysis for Spec 360 readiness
**Created**: 2026-06-06
**Feature**: `specs/360-operationrun-canonical-cutover-cleanup/spec.md`
## Candidate Selection And Guardrails
- [x] CHK001 The candidate source is explicit: direct user-provided draft plus repo-verified post-merge current code truth.
- [x] CHK002 No `specs/360-*` package existed before this prep.
- [x] CHK003 Related existing specs were checked for completed or contextual signals and are treated correctly: 358 is baseline context, 359 is merged functional baseline, and historical Spec-355 references to another `360` are context only.
- [x] CHK004 The active candidate queue's `no safe automatic next-best-prep target` note is respected; this package is an intentional manual promotion rather than an auto-selected queue item.
- [x] CHK005 Repo-truth deviations from the user draft are recorded in `spec.md`, especially the already-merged Spec 359 baseline, the existing restore adapter consumer, the missing canonical `context.dispatch` seam, and the historical Spec-355 numbering reference.
## Required Prep Artifacts
- [x] CHK006 `spec.md` exists and contains no template placeholders.
- [x] CHK007 `plan.md` exists and is repo-aware.
- [x] CHK008 `tasks.md` exists and is ordered, small, and verifiable.
- [x] CHK009 This checklist exists.
## Spec Quality
- [x] CHK010 Spec Candidate Check is completed and scored above the approval threshold.
- [x] CHK011 The spec keeps `OperationRun` persistence unchanged and explicitly forbids new lifecycle columns or tables.
- [x] CHK012 The spec explains why one bounded adapter extension seam is justified now under ABSTR-001: the repo already has two real adapter consumers.
- [x] CHK013 The spec keeps scope bounded to canonical reconciliation, dispatch/correlation, operation-type canonicalization, reader cutover, and Spec 359 close-out only.
- [x] CHK014 The proportionality review rejects universal business reconciliation engines, schema expansion, and legacy compatibility for pre-production history.
## Plan / Task Alignment
- [x] CHK015 The plan identifies the actual repo surfaces likely to change, including current adapter, job, service, reader, and type-catalog seams.
- [x] CHK016 The plan keeps Filament v5 / Livewire v4 posture and provider-registration location visible.
- [x] CHK017 The plan explicitly requires PGSQL proof for duplicate-index or lock-sensitive behavior and one bounded Browser smoke for existing operations surfaces.
- [x] CHK018 The tasks start with repo truth and failing tests before runtime edits.
- [x] CHK019 The tasks include explicit anti-creep guardrails against new persistence, new adapter families, new UI families, and legacy compatibility shims.
## UI / Monitoring / Reader Coverage
- [x] CHK020 UI Surface Impact is completed and does not claim a new page family.
- [x] CHK021 The changed surfaces are correctly classified as existing operations monitoring/detail plus existing review-start feedback follow-through, not a new strategic or customer-facing page.
- [x] CHK022 No new page-report identity or route-inventory expansion is required unless implementation proves a materially new visible hierarchy.
- [x] CHK023 Audience-aware disclosure and canonical related-artifact linkage boundaries are explicit.
## Test Governance
- [x] CHK024 The declared test families are the narrowest honest proof: Unit + Feature + one bounded Browser smoke, with PGSQL lane coverage for duplicate-index and correlation-sensitive truth.
- [x] CHK025 No heavy-governance family is introduced.
- [x] CHK026 Planned validation commands are explicit, partitioned into primary merge gate vs contextual regressions vs separate external probes, and include Spec 358 plus Spec 359 plus the bounded Spec 360 browser or PGSQL proof.
## Readiness Gate Outcome
- [x] CHK027 Candidate Selection Gate passes.
- [x] CHK028 Spec Readiness Gate passes.
- [x] CHK029 Runtime implementation has not started in this preparation step.
- [x] CHK030 Recommended next step is implementation, not more prep.
## Review Outcome
- [x] Outcome class: acceptable-special-case
- [x] Workflow outcome: keep
- [x] Final note location: active feature PR close-out entry `Guardrail / Smoke Coverage`
- [x] Preparation analyze result: pass via repo-based cross-artifact review; no standalone local `speckit.tasks` or `speckit.analyze` generator command was exposed in this repo surface beyond prompts and agent instructions
- [x] Tooling note: Spec Kit branch/spec creation succeeded via `create-new-feature.sh`, `setup-plan.sh` generated the plan file, and `tasks.md` plus this checklist were authored manually to match the repo's Spec Kit templates and agent instructions

View File

@ -0,0 +1,234 @@
# Implementation Plan: OperationRun Canonical Cutover Cleanup
**Branch**: `360-operationrun-canonical-cutover-cleanup` | **Date**: 2026-06-06 | **Spec**: `specs/360-operationrun-canonical-cutover-cleanup/spec.md`
**Input**: Feature specification from `/specs/360-operationrun-canonical-cutover-cleanup/spec.md`
**Note**: This plan follows the repo's Spec Kit preparation workflow, but this prep intentionally stops before application implementation.
## Summary
Consolidate the merged Spec 359 runtime into one canonical `OperationRun` reconciliation foundation before any new adapter family is added. The plan keeps `OperationRunService` as the only reconciliation write seam, replaces growth-by-type-match with one bounded adapter extension seam, introduces canonical dispatch and correlation metadata, cuts current writers/readers over to canonical operation and related-artifact truth, and closes Spec 359 prep artifacts accurately.
## Technical Context
**Language/Version**: PHP 8.4.15
**Primary Dependencies**: Laravel 12.52, Filament 5.2.1, Livewire 4.1.4, Pest 4.3.1
**Storage**: PostgreSQL 16 (`operation_runs`, `environment_reviews`, existing related artifact tables only; no new schema)
**Testing**: Pest 4 Unit + Feature + Browser + PGSQL lane
**Validation Lanes**: fast-feedback, confidence, browser, pgsql
**Target Platform**: Laravel monolith in `apps/platform`
**Project Type**: web application / Laravel monolith
**Performance Goals**: no measurable runtime slowdown on normal queued dispatch or operations monitoring surfaces; no added polling or extra remote calls
**Constraints**: no new migration, no new persisted status column, no new panel/provider work, no asset changes, no legacy compatibility layer for pre-production history, no new business adapter families
**Scale/Scope**: bounded cleanup across the current restore/review-compose adapter seam, queue correlation, canonical type writes, and current operations/read-side surfaces
## UI / Surface Guardrail Plan
- **Guardrail scope**: changed surfaces
- **Affected routes/pages/actions/states/navigation/panel/provider surfaces**:
- `/admin/workspaces/{workspace}/operations`
- `/admin/workspaces/{workspace}/operations/{run}`
- existing review-start feedback inside `App\Filament\Resources\EnvironmentReviewResource`
- **No-impact class, if applicable**: N/A
- **Native vs custom classification summary**: native
- **Shared-family relevance**: operations monitoring family, run detail family, review-start feedback family
- **State layers in scope**: page, detail
- **Audience modes in scope**: operator-MSP, support-platform
- **Decision/diagnostic/raw hierarchy plan**: decision-first, diagnostics-second, support-raw-third
- **Raw/support gating plan**: keep raw context and duplicate-error evidence on detail only
- **One-primary-action / duplicate-truth control**: preserve one canonical inspect action and one canonical related-artifact link per surface; do not restate dispatch, reconciliation, and artifact truth in competing summaries
- **Handling modes by drift class or surface**: review-mandatory
- **Repository-signal treatment**: report-only unless implementation proves a materially new visible hierarchy
- **Special surface test profiles**: monitoring-state-page, shared-detail-family
- **Required tests or manual smoke**: functional-core, state-contract, manual-smoke
- **Exception path and spread control**: none expected
- **Active feature PR close-out entry**: Guardrail / Smoke Coverage
- **UI/Productization coverage decision**: no new reachable UI surface; existing operations and review-start families only
- **Coverage artifacts to update**: none expected
- **No-impact rationale**: existing routes and surface families only; no new page, panel, navigation, or action model
- **Navigation / Filament provider-panel handling**: checked no-impact rationale because rendered access paths stay the same
- **Screenshot or page-report need**: no, unless implementation proves a materially new visible state hierarchy
## Shared Pattern & System Fit
- **Cross-cutting feature marker**: yes
- **Systems touched**:
- `App\Services\OperationRunService`
- `App\Services\AdapterRunReconciler`
- `App\Support\Operations\Reconciliation\EnvironmentReviewComposeDecision`
- `App\Support\OperationCatalog`
- `App\Support\OperationRunType`
- `App\Support\OperationRunLinks`
- `App\Support\Ui\GovernanceArtifactTruth\ArtifactTruthPresenter`
- `App\Jobs\Middleware\TrackOperationRun`
- `App\Jobs\Concerns\BridgesFailedOperationRun`
- **Shared abstractions reused**: current `OperationRunService` lifecycle ownership, current operations monitoring/detail surfaces, current review-start feedback, current read-side link and artifact-truth helpers
- **New abstraction introduced? why?**: one bounded adapter contract/registry or orchestrator plus one bounded correlation resolver are justified because the repo already has two real adapter consumers and currently inconsistent job-correlation rules
- **Why the existing abstraction was sufficient or insufficient**: existing shared paths are sufficient for service-owned lifecycle writes and operator-facing surfaces, but insufficient for canonical adapter registration and canonical queued correlation truth
- **Bounded deviation / spread control**: keep the new abstraction limited to the current restore and review-compose adapter cases and to one run-correlation contract; no universal business reconciliation engine
## OperationRun UX Impact
- **Touches OperationRun start/completion/link UX?**: yes
- **Central contract reused**: `OperationRunService`, `OperationUxPresenter`, `OperationRunLinks`
- **Delegated UX behaviors**: queued feedback, tenant/workspace-safe run links, related-artifact links, and terminal lifecycle ownership stay on the current shared paths
- **Surface-owned behavior kept local**: review-start form inputs and review-specific secondary wording only
- **Queued DB-notification policy**: unchanged explicit opt-in only
- **Terminal notification path**: current central lifecycle mechanism
- **Exception path**: none expected
## Provider Boundary & Portability Fit
- **Shared provider/platform boundary touched?**: no
- **Provider-owned seams**: N/A
- **Platform-core seams**: `OperationRun`, reconciliation metadata, dispatch metadata, operation catalog
- **Neutral platform terms / contracts preserved**: `operation`, `dispatch`, `correlation`, `reconciliation`, `related artifact`
- **Retained provider-specific semantics and why**: none
- **Bounded extraction or follow-up path**: none
## Constitution Check
*GATE: Must pass before implementation planning and again before implementation begins.*
- Inventory-first: pass; no inventory or snapshot truth change
- Read/write separation: pass; no new direct write path outside `OperationRunService`
- Graph contract path: pass; no Graph work is added
- Deterministic capabilities: pass; no capability derivation change
- Workspace and tenant isolation: pass; adapter and reader resolution stay scoped to stored workspace and managed environment
- Run observability: pass; this spec tightens `OperationRun` observability rather than widening it
- Ops-UX lifecycle ownership: pass; `status` and `outcome` transitions stay service-owned
- Proportionality / no premature abstraction: pass if the adapter and correlation extraction remain bounded to the two repo-real cases
- Persisted truth / behavioral state: pass; no new table, column, or persisted state family
- Shared pattern first: pass; reuse shared monitoring, run-link, artifact-truth, and service-owned lifecycle paths
- Provider boundary: pass; entirely platform-core
- UI/Productization coverage: pass; existing operator-facing surfaces only, with bounded browser smoke
- Test governance: pass if Unit + Feature + Browser + PGSQL stay explicit and no heavy-governance family is added
## Test Governance Check
- **Test purpose / classification by changed surface**: Unit + Feature + Browser
- **Affected validation lanes**: fast-feedback, confidence, browser, pgsql
- **Why this lane mix is the narrowest sufficient proof**: adapter resolution and canonical metadata are low-cost Unit or Feature proof, PGSQL is required for duplicate-index behavior, and one Browser smoke is sufficient for existing operations surfaces
- **Narrowest proving command(s)**:
- `cd apps/platform && ./vendor/bin/sail artisan test --compact --filter=Spec360`
- `cd apps/platform && ./vendor/bin/sail php vendor/bin/pest -c phpunit.pgsql.xml --filter=Spec360`
- `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Browser/Spec360OperationRunCanonicalCutoverSmokeTest.php`
- `git diff --check`
- **Fixture / helper / factory / seed / context cost risks**: keep existing operation-run, workspace, environment, and review fixtures cheap by default; do not widen full-context helpers globally
- **Expensive defaults or shared helper growth introduced?**: no
- **Heavy-family additions, promotions, or visibility changes**: none
- **Surface-class relief / special coverage rule**: named `monitoring-state-page` and `shared-detail-family`; no heavy-governance expansion
- **Closing validation and reviewer handoff**: re-run the primary merge gate (`Spec360`, `Spec359`, `Spec358`, Spec360 PGSQL, Spec360 browser smoke) before merge, then run contextual regressions and finally the separately recorded external probes
- **Budget / baseline / trend follow-up**: none expected
- **Review-stop questions**: does the adapter extraction stay bounded, does dispatch truth stay shared, do readers prefer canonical related metadata, and do no new UI families appear
- **Escalation path**: reject-or-split if implementation broadens into Spec 361 scope or new persistence
- **Active feature PR close-out entry**: Guardrail / Smoke Coverage
- **Why no dedicated follow-up spec is needed**: this spec is itself the dedicated cleanup slice that should land before Spec 361
## Repo-Verified Runtime Surfaces Likely Affected
```text
apps/platform/app/Services/AdapterRunReconciler.php
apps/platform/app/Services/OperationRunService.php
apps/platform/app/Jobs/ComposeEnvironmentReviewJob.php
apps/platform/app/Jobs/Middleware/TrackOperationRun.php
apps/platform/app/Jobs/Concerns/BridgesFailedOperationRun.php
apps/platform/app/Services/EnvironmentReviews/EnvironmentReviewService.php
apps/platform/app/Support/Operations/Reconciliation/EnvironmentReviewComposeDecision.php
apps/platform/app/Support/OperationCatalog.php
apps/platform/app/Support/OperationRunType.php
apps/platform/app/Models/OperationRun.php
apps/platform/app/Support/OperationRunLinks.php
apps/platform/app/Support/Ui/GovernanceArtifactTruth/ArtifactTruthPresenter.php
apps/platform/app/Support/OpsUx/OperationUxPresenter.php
apps/platform/app/Filament/Resources/OperationRunResource.php
apps/platform/app/Filament/Pages/Operations/TenantlessOperationRunViewer.php
apps/platform/app/Filament/Resources/EnvironmentReviewResource.php
apps/platform/database/factories/OperationRunFactory.php
apps/platform/tests/Unit/Support/OperationTypeResolutionTest.php
```
## Technical Approach
1. Convert the current `AdapterRunReconciler` growth path into one canonical extension seam. The implementation may keep the class name or split it into an orchestrator plus local contract types, but future adapters must register instead of expanding a central type `match`.
2. Keep `OperationRunService` as the only reconciliation write owner. Reconciliation and dispatch metadata normalization should live there so jobs and adapters stay pure orchestrators or pure decisions.
3. Move remaining review-compose duplicate and lineage finalization out of `ComposeEnvironmentReviewJob`. The job should load scope, call the adapter or decision seam, call the composer, and delegate failures back to the shared run lifecycle path.
4. Introduce canonical `context.dispatch` on the shared dispatch seam. The contract must record job class, queue, connection, timestamp, correlation version, and run id without inventing unsupported queue job ids.
5. Align runtime middleware and failed-job bridging around one correlation contract for the current `OperationRun` dispatch/failure-bridge path touched by this cutover. Collapse the current property scanning only on that bounded path; unrelated historical `bulkRunId` or `runId` seams remain out of scope.
6. Cut the read side over to canonical reconciliation related metadata and canonical operation-type truth. Legacy fallbacks should be removed where they only protect pre-production history.
## Risk Controls
- Keep the adapter extraction bounded to the two current concrete cases.
- Keep correlation cleanup bounded to `TrackOperationRun`, `BridgesFailedOperationRun`, and the directly touched `OperationRunService::dispatchOrFail()` path; do not widen into repo-wide job identity normalization.
- Avoid new schema, state families, or compatibility shims.
- Prefer additive helper extraction only where it replaces contradictory existing logic.
- Keep operator-facing wording on existing surfaces only; do not open UI redesign scope.
- Treat PGSQL proof as mandatory for duplicate-index sensitive paths.
## Implementation Phases
### Phase 1: Baseline and Close-Out Context
- Freeze the merged Spec 359 baseline and the repo-verified drift that remains.
- Update `specs/359-.../tasks.md` so the merge baseline, deferred Spec 360 items, and blocked PGSQL validation status are explicit.
### Phase 2: Canonical Adapter and Write Seam
- Add the canonical adapter contract or registry-orchestrator seam.
- Keep `OperationRunService` as the only reconciliation write path and normalize `context.reconciliation`.
### Phase 3: Job Ownership and Dispatch/Correlation Truth
- Remove remaining review-compose business fallback from the job.
- Add canonical `context.dispatch`.
- Align middleware and failed-job bridging to one correlation rule.
### Phase 4: Canonical Type and Reader Cutover
- Reduce broad legacy alias inventory and touched write paths to canonical operation types.
- Prefer `context.reconciliation.related` in link and artifact readers.
- Keep operations surfaces calm and canonical.
### Phase 5: Validation and Close-Out
- Run Spec360, Spec359, Spec358, PGSQL, and the named bounded browser or review regressions.
- Re-run the explicitly named external probes separately and record them as separate review signals instead of silently folding them into the primary merge gate.
- Run `pint --dirty` and `git diff --check`.
- Record the final cutover decisions in the active feature close-out.
## Project Structure
### Documentation (this feature)
```text
specs/360-operationrun-canonical-cutover-cleanup/
├── spec.md
├── plan.md
├── tasks.md
└── checklists/
└── requirements.md
```
### Source Code (repository root)
```text
apps/platform/
├── app/
├── database/
└── tests/
```
## Assumptions
- No additional preparation artifact beyond `spec.md`, `plan.md`, `tasks.md`, and `checklists/requirements.md` is required for this bounded cleanup.
- The repo's current Prompt or Agent-based `speckit` plan/tasks/analyze flow is manual in this surface, so readiness is validated by explicit artifact review rather than a standalone local generator command.
## Open Preparation Decision
The implementation may keep `AdapterRunReconciler` as the public orchestrator name or replace it with a more explicit adapter-registry/orchestrator pair, but the final shape must satisfy the same constraints:
- one extension seam only
- no growing type-match addition path
- no direct write path outside `OperationRunService`
- no new adapter family beyond restore and review-compose

View File

@ -0,0 +1,371 @@
# Feature Specification: OperationRun Canonical Cutover Cleanup
**Feature Branch**: `360-operationrun-canonical-cutover-cleanup`
**Created**: 2026-06-06
**Status**: Draft
**Input**: User-provided Spec 360 draft, reconciled against current repo truth after merged Spec 359
## Spec Candidate Check *(mandatory — SPEC-GATE-001)*
- **Problem**: the merged Spec 359 runtime works, but the OperationRun reconciliation foundation is still split across a growing type-match reconciler, job-local review-compose recovery logic, heterogeneous failed-job correlation, broad legacy operation-type aliases, and reader fallbacks that still treat historical linkage as first-class truth.
- **Today's failure**: the repo can reconcile review-compose functionally, yet future adapter work would still deepen the wrong seams: `AdapterRunReconciler` grows by type matching, `ComposeEnvironmentReviewJob` still owns business-specific fallback behavior, new queued runs do not record a canonical dispatch contract, and some readers still fall back to legacy linkage rather than canonical reconciliation metadata.
- **User-visible improvement**: review-compose, restore, and future adapter-backed runs can rely on one canonical reconciliation extension point, one service-owned write seam, one dispatch/correlation contract, and one operator-facing related-artifact truth path instead of parallel heuristics.
- **Smallest enterprise-capable version**: consolidate the current restore and review-compose paths behind one canonical adapter extension seam, move remaining review-compose reconciliation decisions out of the job, add canonical `context.dispatch`, align queue/failure correlation, cut back legacy operation-type alias reads/writes, and update existing operations/readers without adding new product features.
- **Explicit non-goals**: no new persisted entity, no new `OperationRun` status or outcome family, no report/evidence/sync/backup/restore business adapter expansion, no repo-wide queue identity or correlation cleanup beyond the current `OperationRun` dispatch/failure-bridge path, no new queue console, no panel/provider changes, no asset changes, no destructive cleanup actions, and no compatibility layer for pre-production historical data.
- **Permanent complexity imported**: one bounded adapter contract/registry-orchestrator seam over two real concrete cases, one shared job-correlation contract, one canonical dispatch metadata shape, and focused tests that lock the cutover in place.
- **Why now**: Spec 359 is merged on `platform-dev` (`3a750726`) and proved the domain behavior, but further adapter work would multiply drift unless the foundation is canonicalized now.
- **Why not local**: fixing only `ComposeEnvironmentReviewJob`, only `OperationCatalog`, or only reader fallbacks would leave the other seams inconsistent and would entrench a second generation of compatibility logic right before Spec 361.
- **Approval class**: Cleanup
- **Red flags triggered**: new abstraction, shared cross-cutting run semantics, and canonicalization work that spans multiple existing files. Defense: the repo already has two real adapter cases (`restore.execute` and `environment.review.compose`), and queue correctness plus audit-visible operator truth justify one bounded consolidation instead of continued growth by exceptions.
- **Score**: Nutzen: 2 | Dringlichkeit: 2 | Scope: 2 | Komplexität: 1 | Produktnähe: 1 | Wiederverwendung: 2 | **Gesamt: 10/12**
- **Decision**: approve
## Repo Truth Reconciliation
The user draft is directionally correct, but current repo truth sharpens the exact scope:
1. `platform-dev` already contains merged Spec 359 runtime changes at `3a750726 feat: implement review compose reconciliation adapter (spec 359) (#430)`. Spec 360 is therefore a post-merge cleanup and cutover package, not a refresh of an unmerged branch.
2. `docs/product/spec-candidates.md` still says there is `no safe automatic next-best-prep target`. This package is a direct user-provided manual promotion, not an auto-selected candidate.
3. The repo already has two real adapter-backed reconciliation cases in `App\Services\AdapterRunReconciler`: `restore.execute` and `environment.review.compose`. That satisfies the constitution threshold for extracting one bounded canonical extension seam now.
4. `App\Services\OperationRunService::updateRunWithReconciliation()` already owns canonical reconciliation writes, but `dispatchOrFail()` does not yet persist canonical dispatch metadata.
5. `App\Jobs\Middleware\TrackOperationRun` and `App\Jobs\Concerns\BridgesFailedOperationRun` still use different correlation heuristics (`operationRun` object vs mixed `operationRunId` / `bulkRunId` / `runId` property scanning).
6. `App\Support\OperationCatalog` still carries broad read-side legacy alias inventory, while `App\Support\OperationRunType` and `Database\Factories\OperationRunFactory` already show that most current writers can emit canonical values directly.
7. `specs/355-platform-sellable-smoke-matrix/` contains historical roadmap references to a different placeholder `Spec 360`, but no `specs/360-*` package exists. Those old references are context only and do not block using `360` for this user-requested cutover package.
## Spec Scope Fields *(mandatory)*
- **Scope**: workspace, tenant, canonical-view
- **Primary Routes**:
- `/admin/workspaces/{workspace}/operations`
- `/admin/workspaces/{workspace}/operations/{run}`
- existing environment-scoped review start or refresh entry points in `App\Filament\Resources\EnvironmentReviewResource`
- **Data Ownership**:
- `operation_runs` remain the only persisted execution and reconciliation truth for run lifecycle
- `context.reconciliation` remains the canonical reconciliation metadata container
- `context.dispatch` becomes the canonical dispatch and correlation metadata container
- `environment_reviews`, `review_packs`, `evidence_snapshots`, and restore records remain domain truth only; no new persisted adapter registry or migration is introduced
- **RBAC**:
- existing workspace-first `OperationRun` access rules remain authoritative
- non-members and out-of-scope tenant viewers remain `404`
- review initiation remains governed by the current review capabilities
- this spec changes orchestration, correlation, metadata, and operator explanation only; it does not widen any surface or capability
For canonical-view specs, the spec MUST define:
- **Default filter behavior when tenant-context is active**: the operations hub remains workspace-scoped with explicit environment filters. This spec must not silently reintroduce tenant-context ownership into the canonical operations route or into adapter selection.
- **Explicit entitlement checks preventing cross-tenant leakage**: adapter resolution, related-model links, and artifact truth readers must use the run's stored workspace and managed-environment scope. No reader may resolve or reveal an artifact outside the run's authorized scope.
## UI Surface Impact *(mandatory — UI-COV-001)*
Does this spec add, remove, rename, or materially change any reachable UI surface?
- [ ] No UI surface impact
- [x] Existing page changed
- [ ] New page/route added
- [ ] Navigation changed
- [ ] Filament panel/provider surface changed
- [ ] New modal/drawer/wizard/action added
- [ ] New table/form/state added
- [ ] Customer-facing surface changed
- [ ] Dangerous action changed
- [x] Status/evidence/review presentation changed
- [ ] Workspace/environment context presentation changed
## UI/Productization Coverage *(mandatory when UI Surface Impact is not "No UI surface impact")*
- **Route/page/surface**:
- workspace operations hub (`App\Filament\Pages\Monitoring\Operations`)
- canonical run detail (`App\Filament\Pages\Operations\TenantlessOperationRunViewer`)
- shared implementation seam only: `App\Filament\Resources\OperationRunResource`
- existing review start feedback path in `App\Filament\Resources\EnvironmentReviewResource`
- **Current or new page archetype**: existing monitoring/detail family and existing review-start action feedback
- **Design depth**: Domain Pattern Surface
- **Repo-truth level**: repo-verified
- **Existing pattern reused**: current operations monitoring family, current review-start feedback family, current artifact-truth/read-side link patterns
- **New pattern required**: none; this is a canonicalization follow-through inside existing families
- **Screenshot required**: no; one bounded browser smoke is sufficient unless implementation proves a materially new visible hierarchy
- **Page audit required**: no new page-report identity is required; existing monitoring and review anchors remain sufficient
- **Customer-safe review required**: no; touched copy remains operator-facing
- **Dangerous-action review required**: no; no new destructive or high-risk execution action is introduced
- **Coverage files updated or explicitly not needed**:
- [ ] `docs/ui-ux-enterprise-audit/route-inventory.md`
- [ ] `docs/ui-ux-enterprise-audit/design-coverage-matrix.md`
- [ ] `docs/ui-ux-enterprise-audit/page-reports/...`
- [ ] `docs/ui-ux-enterprise-audit/strategic-surfaces.md`
- [ ] `docs/ui-ux-enterprise-audit/grouped-follow-up-candidates.md`
- [ ] `docs/ui-ux-enterprise-audit/unresolved-pages.md`
- [x] `N/A - no reachable new UI surface; existing monitoring/review families only`
- **No-impact rationale when applicable**: no new surface family, route, or navigation entry is created; the work stays inside existing operations and review-start patterns
## Cross-Cutting / Shared Pattern Reuse *(mandatory when the feature touches notifications, status messaging, action links, header actions, dashboard signals/cards, alerts, navigation entry points, evidence/report viewers, or any other existing shared operator interaction family; otherwise write `N/A - no shared interaction family touched`)*
- **Cross-cutting feature?**: yes
- **Interaction class(es)**: status messaging, action links, reconciliation metadata, monitoring/detail explanation, artifact-viewer linkage, queued-run correlation
- **Systems touched**:
- `App\Services\OperationRunService`
- `App\Services\AdapterRunReconciler`
- `App\Support\Operations\Reconciliation\EnvironmentReviewComposeDecision`
- `App\Support\OperationRunLinks`
- `App\Support\Ui\GovernanceArtifactTruth\ArtifactTruthPresenter`
- `App\Support\OperationCatalog`
- `App\Jobs\Middleware\TrackOperationRun`
- `App\Jobs\Concerns\BridgesFailedOperationRun`
- **Existing pattern(s) to extend**: existing `OperationRunService` lifecycle ownership, existing operations monitoring/detail family, existing review-start feedback family, and existing artifact-truth/read-side helpers
- **Shared contract / presenter / builder / renderer to reuse**: `OperationRunService`, `OperationRun::reconciliation()`, current monitoring/detail presenters, `OperationRunLinks`, `ArtifactTruthPresenter`, and the current review-start notification family
- **Why the existing shared path is sufficient or insufficient**: the shared write seam already exists, but the repo still lacks a canonical adapter registration contract, a canonical queued dispatch metadata path, and a shared correlation resolver between middleware and failed-job bridging.
- **Allowed deviation and why**: one bounded adapter contract/registry or orchestrator abstraction plus one bounded job-correlation resolver are allowed because two concrete adapter cases already exist and the current heuristics are materially inconsistent.
- **Consistency impact**: reconciliation outcome, dispatch truth, link resolution, operation labels, and failed-job correlation must stay consistent across jobs, services, operations surfaces, and artifact readers.
- **Review focus**: no second reconciliation write path, no new job-local review business logic, no broadened legacy alias protection, and no parallel reader fallback language.
## OperationRun UX Impact *(mandatory when the feature creates, queues, deduplicates, resumes, blocks, completes, or deep-links to an `OperationRun`; otherwise write `N/A - no OperationRun start or link semantics touched`)*
- **Touches OperationRun start/completion/link UX?**: yes
- **Shared OperationRun UX contract/layer reused**: `OperationRunService`, `OperationUxPresenter`, `OperationRunLinks`, and the current operations hub/detail surfaces
- **Delegated start/completion UX behaviors**:
- queued review-compose runs continue to use the shared queued feedback path
- adapter finalization continues to write terminal lifecycle state only through `OperationRunService`
- canonical related-artifact and open-run links stay on the shared helper paths
- **Local surface-owned behavior that remains**: review-start form inputs and any review-specific secondary explanation copy
- **Queued DB-notification policy**: unchanged; queued notifications remain explicit opt-in only
- **Terminal notification path**: unchanged central lifecycle mechanism
- **Exception required?**: none by default
## Provider Boundary / Platform Core Check *(mandatory when the feature changes shared provider/platform seams, identity scope, governed-subject taxonomy, compare strategy selection, provider connection descriptors, or operator vocabulary that may leak provider-specific semantics into platform-core truth; otherwise write `N/A - no shared provider/platform boundary touched`)*
- **Shared provider/platform boundary touched?**: no
- **Boundary classification**: N/A
- **Seams affected**: N/A
- **Neutral platform terms preserved or introduced**: `operation`, `dispatch`, `correlation`, `reconciliation`, `related artifact`, `workspace`, `managed environment`
- **Provider-specific semantics retained and why**: none
- **Why this does not deepen provider coupling accidentally**: the cutover is entirely inside platform-owned `OperationRun` lifecycle, metadata, and read-side truth
- **Follow-up path**: none
## UI / Surface Guardrail Impact *(mandatory when operator-facing surfaces are changed; otherwise write `N/A`)*
| Surface / Change | Operator-facing surface change? | Native vs Custom | Shared-Family Relevance | State Layers Touched | Exception Needed? | Low-Impact / `N/A` Note |
|---|---|---|---|---|---|---|
| Operations hub reconciliation and related-artifact explanation | yes | Native Filament page | shared monitoring family | page, table row | no | no new route or action family |
| Tenantless run detail canonical related metadata and dispatch explanation | yes | Native Filament page | shared monitoring detail family | detail | no | no new diagnostics family |
| Review start feedback for reused review or existing active run | yes | Native Filament action + notification | shared run-start feedback family | action feedback | no | reuses current notification/action pattern |
## Decision-First Surface Role *(mandatory when operator-facing surfaces are changed)*
| Surface | Decision Role | Human-in-the-loop Moment | Immediately Visible for First Decision | On-Demand Detail / Evidence | Why This Is Primary or Why Not | Workflow Alignment | Attention-load Reduction |
|---|---|---|---|---|---|---|---|
| Operations hub | Primary Decision Surface | decide whether a run still needs attention or already has reconciled proof | lifecycle outcome, canonical related artifact hint, one inspect action | full diagnostics and raw context on detail | primary because it is the canonical operations triage queue | follows existing operations workflow | removes cross-checking across run, review, and artifact surfaces |
| Tenantless run detail | Tertiary Evidence / Diagnostics Surface | confirm why the run reconciled, blocked, or failed after selection | one canonical reconciliation or dispatch summary before raw context | evidence, failures, and related artifact detail | tertiary because the run is already selected | preserves the current detail role | removes duplicate or fallback-first explanations |
| Review start feedback | Secondary Context Surface | decide whether to open the reused review, open the run, or stop | already-available vs queued state, one safe link | full diagnostics only after drill-through | secondary because it supports, not owns, the workflow | follows current review-start action flow | prevents duplicate clicks and ghost-run interpretation |
## Audience-Aware Disclosure *(mandatory when operator-facing surfaces are changed)*
| Surface | Audience Modes In Scope | Decision-First Default-Visible Content | Operator Diagnostics | Support / Raw Evidence | One Dominant Next Action | Hidden / Gated By Default | Duplicate-Truth Prevention |
|---|---|---|---|---|---|---|---|
| Operations hub | operator-MSP, support-platform | reconciled or blocked summary plus related artifact hint | reason code and supporting metadata secondarily | raw payloads remain on detail | `Open operation` | SQL, duplicate-key, and raw failure payloads stay hidden from the list | one row states one canonical outcome |
| Tenantless run detail | operator-MSP, support-platform | canonical lifecycle and reconciliation summary | evidence, related artifact metadata, and dispatch metadata lower on page | raw context stays secondary | existing navigation and related links | debug payloads remain progressive disclosure | dispatch, reconciliation, and artifact truth do not restate conflicting summaries |
| Review start feedback | operator-MSP | review reused vs active compose still running | none beyond shared run or artifact links | raw diagnostics remain off-surface | `View review` or `Open operation` | technical duplicate details hidden | one notification family states one result |
## UI/UX Surface Classification *(mandatory when operator-facing surfaces are changed)*
| Surface | Action Surface Class | Surface Type | Likely Next Operator Action | Primary Inspect/Open Model | Row Click | Secondary Actions Placement | Destructive Actions Placement | Canonical Collection Route | Canonical Detail Route | Scope Signals | Canonical Noun | Critical Truth Visible by Default | Exception Type / Justification |
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| Operations hub | List / Table / Monitoring | Read-only monitoring registry | open the run that still needs interpretation | full-row open | required | existing table controls only | none | `/admin/workspaces/{workspace}/operations` | `/admin/workspaces/{workspace}/operations/{run}` | workspace scope plus explicit filters | Operation run | whether proof already exists or follow-up is still required | none |
| Tenantless run detail | Record / Detail / Monitoring | Diagnostics-first detail surface | confirm the canonical result before deeper diagnosis | canonical detail page | N/A | existing header links only | none | `/admin/workspaces/{workspace}/operations` | `/admin/workspaces/{workspace}/operations/{run}` | workspace scope and entitled environment | Operation run | one canonical dispatch/reconciliation summary | none |
| Review start feedback | Action Feedback | Action result surface | open the artifact or the run | notification link | N/A | current feedback actions only | none | current review surface | linked artifact or operation detail | current workspace or tenant page context | Review composition | reused review vs queued operation truth | none |
## Operator Surface Contract *(mandatory when operator-facing surfaces are changed)*
| Surface | Primary Persona | Decision / Operator Action Supported | Surface Type | Primary Operator Question | Default-visible Information | Diagnostics-only Information | Status Dimensions Used | Mutation Scope | Primary Actions | Dangerous Actions |
|---|---|---|---|---|---|---|---|---|---|---|
| Operations hub | workspace operator | decide whether an operation still needs monitoring or already has reconciled proof | monitoring registry | Is this run still active work, or is the related artifact already the truth? | lifecycle status, related artifact hint, calm explanation | raw context and lineage | lifecycle, reconciliation truth, artifact availability | none | open row | none |
| Tenantless run detail | workspace operator | confirm why the run reconciled, blocked, or failed | detail surface | Why should this run now be trusted, blocked, or investigated? | top-level reconciliation or dispatch summary, related artifact hint | evidence, failure summary, raw context | lifecycle, reconciliation truth, correlation truth, artifact availability | none | existing links | none |
| Review start feedback | tenant operator | decide whether to wait, open the run, or open the already available review | action feedback | Did the system queue new work or reuse existing truth? | one canonical action result | diagnostics after drill-through only | queued vs reused truth | none | view run or view review | none |
## Proportionality Review *(mandatory when structural complexity is introduced)*
- **New source of truth?**: no
- **New persisted entity/table/artifact?**: no
- **New abstraction?**: yes
- **New enum/state/reason family?**: no new persisted family; only bounded canonical adapter/correlation semantics over existing fields
- **New cross-domain UI framework/taxonomy?**: no
- **Current operator problem**: current repo truth would force future adapter work to deepen a type-match reconciler, job-local business fallbacks, and heterogeneous correlation logic that already disagree about what an `OperationRun` means.
- **Existing structure is insufficient because**: `AdapterRunReconciler`, `ComposeEnvironmentReviewJob`, `TrackOperationRun`, `BridgesFailedOperationRun`, and `OperationCatalog` each currently own a different part of the same canonicality problem.
- **Narrowest correct implementation**: extract one adapter extension seam and one job-correlation contract over the existing runtime, keep `OperationRunService` as the only write seam, and remove broad legacy compatibility where the repo already writes canonical values.
- **Ownership cost**: one bounded adapter contract/registry or orchestrator, one bounded correlation resolver, reduced legacy alias inventory, and new unit or feature coverage that must stay explicit.
- **Alternative intentionally rejected**: continuing with a growing `match` block plus job-local review logic was rejected because it would multiply exceptions before Spec 361 and would keep dispatch and correlation truth fragmented.
- **Release truth**: current-release cleanup and consolidation before new adapter families, not speculative future preparation without active consumers.
### Compatibility posture
This feature assumes a pre-production environment.
Backward compatibility, legacy aliases, migration shims, historical fixtures, and compatibility-specific tests are out of scope unless explicitly required by this spec.
Canonical replacement is preferred over preservation.
## Testing / Lane / Runtime Impact *(mandatory for runtime behavior changes)*
- **Test purpose / classification**: Unit + Feature + Browser
- **Validation lane(s)**: fast-feedback, confidence, browser, pgsql
- **Why this classification and these lanes are sufficient**: Unit coverage proves adapter resolution, canonical metadata shapes, and operation-type canonicalization; Feature coverage proves service, job, and correlation behavior; PGSQL coverage proves duplicate-index and lock-sensitive paths; one bounded Browser smoke proves the visible operations surfaces without expanding into broader UI productization.
- **New or expanded test families**:
- `apps/platform/tests/Unit/Support/Operations/Reconciliation/Spec360CanonicalAdapterRegistryTest.php`
- `apps/platform/tests/Unit/Support/Operations/Reconciliation/Spec360ReconciliationContextFormatTest.php`
- `apps/platform/tests/Unit/Support/OperationTypeCanonicalizationTest.php`
- `apps/platform/tests/Unit/Support/OperationRunDispatchContextTest.php`
- `apps/platform/tests/Feature/Operations/Spec360CanonicalReconciliationCutoverTest.php`
- `apps/platform/tests/Feature/Operations/Spec360DispatchCorrelationTest.php`
- `apps/platform/tests/Feature/EnvironmentReview/Spec360ReviewComposeAdapterOwnershipTest.php`
- `apps/platform/tests/Browser/Spec360OperationRunCanonicalCutoverSmokeTest.php`
- **Fixture / helper cost impact**: low to moderate; reuse current operation-run, workspace, environment, and review fixtures only
- **Heavy-family visibility / justification**: no heavy-governance family is introduced
- **Special surface test profile**: monitoring-state-page plus shared-detail-family
- **Standard-native relief or required special coverage**: one bounded Browser smoke and PGSQL proof are required; otherwise ordinary Unit and Feature coverage is sufficient
- **Reviewer handoff**: reviewers must confirm that the cutover removes type-match growth, keeps `OperationRunService` service-owned, writes canonical dispatch and reconciliation metadata, and keeps operations surfaces calm and scope-safe
- **Budget / baseline / trend impact**: small feature-local growth only
- **Escalation needed**: `reject-or-split` if implementation expands into new business adapters, new persistence, or a broad product feature
- **Active feature PR close-out entry**: Guardrail / Smoke Coverage
- **Planned validation commands**:
- **Primary merge gate**
- `cd apps/platform && ./vendor/bin/sail artisan test --compact --filter=Spec360`
- `cd apps/platform && ./vendor/bin/sail artisan test --compact --filter=Spec359`
- `cd apps/platform && ./vendor/bin/sail artisan test --compact --filter=Spec358`
- `cd apps/platform && ./vendor/bin/sail php vendor/bin/pest -c phpunit.pgsql.xml --filter=Spec360`
- `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Browser/Spec360OperationRunCanonicalCutoverSmokeTest.php`
- **Contextual regressions**
- `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Browser/CustomerReviewWorkspaceSmokeTest.php`
- `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Browser/Spec357ReportProfilesSmokeTest.php`
- `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/ReviewPack/Spec357RenderedReportProfileTest.php`
- `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/ReviewPack/ReviewPackDownloadTest.php`
- `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/ReviewPack/EnvironmentReviewDerivedReviewPackTest.php`
- `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/ReviewPack/EnvironmentReviewExecutivePackTest.php`
- **Separate external probes**
- `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Browser/Spec347ReviewPackOutputReadinessSmokeTest.php`
- `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/EnvironmentReviewHeaderDisciplineTest.php`
- **Hygiene**
- `cd apps/platform && ./vendor/bin/pint --dirty`
- `git diff --check`
Known external probes remain explicit and separately reviewable. They must not be silently folded into the primary Spec 360 merge gate unless an in-scope failure proves that this cutover itself regressed them.
## User Scenarios & Testing *(mandatory)*
### User Story 1 - Canonicalize the adapter-owned reconciliation seam (Priority: P1)
As a maintainer, I need one canonical adapter extension point so that review-compose reconciliation no longer depends on a growing type-match block or job-local business fallback.
**Why this priority**: this is the direct architectural blocker before further adapter work and the highest-risk drift left after Spec 359 merged.
**Independent Test**: run focused Unit and Feature coverage that proves restore and review-compose resolution go through one canonical adapter seam, that unsupported types stay unsupported, and that `OperationRunService` remains the only reconciliation write owner.
**Acceptance Scenarios**:
1. **Given** a `restore.execute` or `environment.review.compose` run, **When** reconciliation resolves it, **Then** one canonical adapter seam selects the business-specific path without adding a new central type-match branch for future adapters.
2. **Given** a review-compose duplicate or superseded lineage case, **When** the system finalizes the run, **Then** the adapter or decision path owns the business decision and `ComposeEnvironmentReviewJob` does not write reconciliation directly.
---
### User Story 2 - Align queued dispatch and failed-job correlation truth (Priority: P1)
As an operations maintainer, I need queued dispatch metadata and failed-job correlation to use one canonical contract so that active and failed runs can be traced without hidden heuristics.
**Why this priority**: current middleware and failed-job bridging still resolve runs differently, and no canonical `context.dispatch` payload exists yet.
**Independent Test**: run focused Unit and Feature coverage that proves new queued runs write canonical dispatch metadata and that middleware plus failed-job bridging resolve the same `OperationRun`.
**Acceptance Scenarios**:
1. **Given** a newly queued review-compose run, **When** the dispatch seam executes successfully, **Then** the run records canonical dispatch metadata with job class, queue, connection, timestamp, correlation version, and operation run id.
2. **Given** a queued job fails after dispatch, **When** the failed-job bridge runs, **Then** it resolves the same `OperationRun` as middleware would resolve for normal execution.
---
### User Story 3 - Cut readers and operation-type truth over to canonical metadata (Priority: P2)
As an operator, I need related-artifact links, operation labels, and operations detail wording to rely on canonical metadata instead of legacy alias and fallback behavior.
**Why this priority**: the underlying runtime can be canonical only if the read side also stops treating historical fallback behavior as equal truth.
**Independent Test**: run focused Unit, Feature, and one bounded Browser smoke to prove canonical operation types, canonical reconciliation related metadata, and calm operations wording across current monitoring surfaces.
**Acceptance Scenarios**:
1. **Given** a reconciled review-compose run with canonical `context.reconciliation.related`, **When** the operations hub or detail page renders it, **Then** the related review link resolves from that canonical metadata rather than from loose `operation_run_id` fallback.
2. **Given** a run or factory writes a new operation type, **When** the type is stored or rendered, **Then** canonical write values are used and unnecessary legacy aliases are not emitted as new truth.
### Edge Cases
- A queued or failed job has an `operationRunId` but no in-memory `operationRun` object.
- A run already became terminal before middleware or a failed-job bridge touches it.
- `context.dispatch` can record queue, connection, and job class, but no framework-supplied queue job id is available.
- `context.reconciliation.related` points to a review id that no longer matches workspace or environment scope.
- A run still carries a historical raw type alias in storage while canonical writers already emit the new value directly.
- Review-compose duplicate recovery remains ambiguous or blocked even after the adapter decision evaluates lineage.
## Requirements *(mandatory)*
### Functional Requirements
- **FR-360-001**: The system MUST provide exactly one canonical adapter extension seam for adapter-backed `OperationRun` reconciliation across the current `restore.execute` and `environment.review.compose` cases.
- **FR-360-002**: The canonical adapter seam MUST support explicit unsupported handling and MUST NOT require future adapters to extend a central `match` or `switch` block in `AdapterRunReconciler`.
- **FR-360-003**: `OperationRunService` MUST remain the only service that writes reconciliation lifecycle state, `context.reconciliation`, summary counts, or failures for adapter-driven finalization.
- **FR-360-004**: New reconciliation writes MUST use only canonical `context.reconciliation` metadata and MUST include source, adapter, decision, reason code, previous status, previous outcome, reconciled timestamp, bounded evidence, and canonical related metadata when available.
- **FR-360-005**: `ComposeEnvironmentReviewJob` MUST remain orchestration-only and MUST NOT contain business-specific review selection, cross-scope matching, duplicate recovery finalization, or direct reconciliation metadata writes.
- **FR-360-006**: Duplicate fingerprint or superseded review recovery for `environment.review.compose` MUST flow through the canonical adapter or decision path and MUST remain deterministic, scope-safe, and idempotent.
- **FR-360-007**: Newly queued runs affected by this feature MUST write canonical `context.dispatch` metadata through the shared dispatch seam and MUST NOT invent synthetic queue job ids when the framework cannot provide them.
- **FR-360-008**: `TrackOperationRun` and `BridgesFailedOperationRun` MUST resolve the target run through the same canonical correlation contract or resolver for the current `OperationRunService::dispatchOrFail()` lifecycle path touched by this spec, and unsupported or out-of-scope jobs MUST fail closed without false correlation claims.
- **FR-360-009**: Canonical operation-type writes MUST come from one clear source of truth across `OperationRunType`, `OperationCatalog`, model canonicalization, factories, and touched writers.
- **FR-360-010**: Unnecessary legacy operation-type alias compatibility MUST be removed from current writers and prep artifacts; any alias kept on the read side MUST be justified by repo-verified active need rather than pre-production caution.
- **FR-360-011**: `OperationRunLinks`, `ArtifactTruthPresenter`, and related helpers MUST prefer `context.reconciliation.related` as the canonical source for adapter-resolved related artifacts.
- **FR-360-012**: Existing operations and review-start surfaces MUST keep calm operator wording and MUST NOT expose raw SQL, duplicate-key, or legacy alias detail as primary visible truth.
- **FR-360-013**: `specs/359-operationrun-reconciliation-adapter-framework-review-compose-adapter/tasks.md` MUST be updated so the merged runtime status, deferred Spec 360 follow-through, and blocked PGSQL validation state are explicit and no implementation-looking open tasks remain misleading.
### Non-Functional Requirements
- **NFR-360-001**: No new database migration, table, column, or persisted lifecycle field is allowed.
- **NFR-360-002**: No new business adapter for report, evidence, sync, backup, restore, or alert delivery is allowed in this spec.
- **NFR-360-003**: No new destructive runtime action, cleanup console, or delete or purge workflow is allowed.
- **NFR-360-004**: The feature MUST remain Livewire v4 compatible and MUST NOT introduce Livewire v3 APIs.
- **NFR-360-005**: No Filament panel/provider move, no asset registration change, and no deployment asset strategy change is allowed.
- **NFR-360-006**: The cutover MUST assume the current pre-production posture: canonical replacement is preferred over legacy compatibility for historical rows, tests, or fixtures unless repo truth proves an active need.
- **NFR-360-007**: Evidence stored in `context.reconciliation` and `context.dispatch` MUST stay bounded, audit-safe, and free of secrets, signed URLs, raw credential payloads, or oversized copied domain objects.
- **NFR-360-008**: Repo-wide normalization of unrelated legacy `bulkRunId`, `runId`, or other non-`operationRunId` job identity conventions is out of scope unless a job already sits on the current `OperationRun` dispatch/failure-bridge path touched by this cutover.
## UI Action Matrix *(mandatory when Filament is changed)*
| Surface | Location | Header Actions | Inspect Affordance (List/Table) | Row Actions (max 2 visible) | Bulk Actions (grouped) | Empty-State CTA(s) | View Header Actions | Create/Edit Save+Cancel | Audit log? | Notes / Exemptions |
|---|---|---|---|---|---|---|---|---|---|---|
| Operations hub | `apps/platform/app/Filament/Pages/Monitoring/Operations.php` | existing only | row click to canonical detail | existing only | existing only | unchanged | N/A | N/A | existing run terminal audit only | no new action |
| Run detail | `apps/platform/app/Filament/Pages/Operations/TenantlessOperationRunViewer.php` | existing only | N/A | N/A | N/A | unchanged | existing navigation only | N/A | existing run terminal audit only | no new action |
| Review start feedback | `apps/platform/app/Filament/Resources/EnvironmentReviewResource.php` | existing only | notification link | notification action only | N/A | unchanged | N/A | existing form actions only | existing audit and run terminal audit only | no new mutation surface |
### Key Entities *(include if feature involves data)*
- **OperationRun**: persisted execution and reconciliation truth; holds canonical `type`, `status`, `outcome`, `summary_counts`, and `context`
- **Reconciliation metadata**: bounded `context.reconciliation` payload describing canonical adapter-owned finalization
- **Dispatch metadata**: bounded `context.dispatch` payload describing queued dispatch and correlation truth
- **EnvironmentReview**: domain artifact that may already prove review-compose truth and may be linked canonically through `context.reconciliation.related`
## Success Criteria *(mandatory)*
### Measurable Outcomes
- **SC-360-001**: restore and review-compose reconciliation both resolve through one canonical extension seam, and adding a future adapter no longer requires extending a central type-match branch in `AdapterRunReconciler`.
- **SC-360-002**: newly queued review-compose runs write canonical dispatch metadata with job class, queue, connection, dispatched timestamp, correlation version, and operation run id in automated coverage.
- **SC-360-003**: middleware and failed-job bridge coverage prove the same `OperationRun` correlation path for queued review-compose runs and fail closed for unsupported jobs.
- **SC-360-004**: canonical read-side coverage proves that reconciled review links resolve from `context.reconciliation.related`, operation-type writes are canonical-only on touched paths, and primary operator surfaces no longer depend on raw SQL or legacy alias messaging.
## Assumptions
- Spec 359 is already merged and is the functional baseline for this cleanup package.
- Existing restore behavior remains in scope only as a current adapter consumer, not as a new restore product feature.
- No production customer data or migration compatibility requirement exists for old raw operation-type aliases or historical test fixtures.
## Risks
- The adapter registry or orchestrator cutover could over-abstract if it grows beyond the two repo-real adapter cases. Tasks and review must keep it bounded.
- Dispatch metadata can drift if writers outside `OperationRunService` continue to enqueue jobs directly without using the shared seam.
- Reader fallback removal can break historical fixtures unless tests and browser smoke are rebased deliberately to canonical truth.
## Open Questions
- None blocking prep. The only known uncertainty is the locally blocked PGSQL validation state from Spec 359, which this spec carries forward explicitly into the implementation validation gate rather than treating as preparation ambiguity.

View File

@ -0,0 +1,203 @@
# Tasks: OperationRun Canonical Cutover Cleanup
**Input**: `specs/360-operationrun-canonical-cutover-cleanup/spec.md`, `plan.md`, and `checklists/requirements.md`
**Prerequisites**: `spec.md` and `plan.md`
**Tests**: REQUIRED (Pest). Keep proof bounded to Unit + Feature + PGSQL + one explicit Browser smoke.
**Operations**: Reuse current `OperationRun` lifecycle ownership. No new run status column, no new queue family, no new schema, and no destructive cleanup.
**RBAC**: Reuse current workspace-first run access plus current review capabilities. No new capability strings, no widened route access, and no cross-scope artifact resolution.
**Shared Pattern Reuse**: Reuse `OperationRunService`, current operations surfaces, `OperationRunLinks`, `ArtifactTruthPresenter`, and current review-start feedback. Introduce only one bounded adapter contract/registry-orchestrator seam plus one bounded run-correlation resolver.
**Filament / Panel Guardrails**: Filament remains v5 on Livewire v4. Provider registration stays in `apps/platform/bootstrap/providers.php`. No new panel, route family, or asset strategy is allowed.
**Organization**: Tasks are grouped by user story so the adapter seam, dispatch/correlation truth, read-side canonicalization, and spec-closeout work remain independently reviewable.
## Repo Baseline At Prep Time
- **Branch**: `360-operationrun-canonical-cutover-cleanup`
- **HEAD**: `3a750726 feat: implement review compose reconciliation adapter (spec 359) (#430)`
- **`git status --short --branch` before Spec 360 prep**: clean on `platform-dev`; Spec Kit created this feature branch and copied the spec/plan templates
- **Spec 359 merge baseline**: merged runtime behavior is the functional starting point; PGSQL validation remained locally blocked at merge time
- **Relevant runtime surfaces**:
- `apps/platform/app/Services/AdapterRunReconciler.php`
- `apps/platform/app/Services/OperationRunService.php`
- `apps/platform/app/Jobs/ComposeEnvironmentReviewJob.php`
- `apps/platform/app/Jobs/Middleware/TrackOperationRun.php`
- `apps/platform/app/Jobs/Concerns/BridgesFailedOperationRun.php`
- `apps/platform/app/Support/Operations/Reconciliation/EnvironmentReviewComposeDecision.php`
- `apps/platform/app/Support/OperationCatalog.php`
- `apps/platform/app/Support/OperationRunType.php`
- `apps/platform/app/Support/OperationRunLinks.php`
- `apps/platform/app/Support/Ui/GovernanceArtifactTruth/ArtifactTruthPresenter.php`
- `apps/platform/app/Support/OpsUx/OperationUxPresenter.php`
- `apps/platform/app/Filament/Resources/OperationRunResource.php`
- `apps/platform/app/Filament/Pages/Operations/TenantlessOperationRunViewer.php`
- `apps/platform/app/Filament/Resources/EnvironmentReviewResource.php`
- `apps/platform/database/factories/OperationRunFactory.php`
- `apps/platform/tests/Unit/Support/OperationTypeResolutionTest.php`
- **Completed-spec context only**: Spec 358 is the generic queue-truth baseline; do not rewrite its historical readiness or validation record
- **Historical numbering note**: older references to another placeholder `Spec 360` in `specs/355-platform-sellable-smoke-matrix/` are context only and must not be treated as an existing spec package
## Test Governance Checklist
- [x] Lane assignment is named and is the narrowest sufficient proof for the changed behavior.
- [x] New or changed tests stay in the smallest honest family, and the Browser or PGSQL additions remain explicit.
- [x] Shared helpers, factories, seeds, fixtures, and context defaults stay cheap by default; any widening is isolated or documented.
- [x] Planned validation commands cover the change without widening into unrelated lane cost.
- [x] The declared monitoring/detail surface profile is explicit.
- [x] Any material budget, baseline, trend, or escalation note is recorded in the active feature close-out.
## Phase 1: Setup (Repo Truth Inventory)
**Purpose**: confirm the merged Spec 359 baseline, the current cutover gaps, and the exact repo surfaces before runtime edits begin.
- [ ] T001 Re-read `spec.md`, `plan.md`, `checklists/requirements.md`, `.specify/memory/constitution.md`, `docs/ai-coding-rules.md`, `docs/architecture-guidelines.md`, `docs/testing-guidelines.md`, `docs/security-guidelines.md`, `docs/filament-guidelines.md`, and `specs/358-operationrun-queue-truth-foundation/{spec,plan,tasks}.md` plus `specs/359-operationrun-reconciliation-adapter-framework-review-compose-adapter/{spec,plan,tasks}.md` together before touching runtime code.
- [ ] T002 [P] Confirm the current reconciliation and write seams in `apps/platform/app/Services/AdapterRunReconciler.php`, `apps/platform/app/Services/OperationRunService.php`, `apps/platform/app/Models/OperationRun.php`, and `apps/platform/app/Support/Operations/Reconciliation/EnvironmentReviewComposeDecision.php`.
- [ ] T003 [P] Confirm the current queue correlation seams in `apps/platform/app/Jobs/Middleware/TrackOperationRun.php`, `apps/platform/app/Jobs/Concerns/BridgesFailedOperationRun.php`, and the job classes that currently expose `operationRunId`, `operationRun`, or other correlation properties.
- [ ] T004 [P] Confirm the current canonical type and read-side seams in `apps/platform/app/Support/OperationCatalog.php`, `apps/platform/app/Support/OperationRunType.php`, `apps/platform/app/Support/OperationRunLinks.php`, `apps/platform/app/Support/Ui/GovernanceArtifactTruth/ArtifactTruthPresenter.php`, and `apps/platform/database/factories/OperationRunFactory.php`.
- [ ] T005 Confirm that no new schema, no new panel/provider path, no new asset registration, and no new business adapter family are required; record any tension as `reject-or-split` instead of silently widening scope.
- [x] T005A Update `specs/359-operationrun-reconciliation-adapter-framework-review-compose-adapter/tasks.md` so the merged baseline, blocked PGSQL note, and deferred Spec 360 follow-through are explicit; completed during Spec 360 prep.
---
## Phase 2: Foundational (Canonical Adapter and Service-Owned Metadata)
**Purpose**: settle one canonical adapter extension seam and keep all reconciliation writes in `OperationRunService` before touching jobs or readers.
**Critical**: no user-story runtime work should begin until this phase is complete.
- [ ] T006 [P] Add failing Unit coverage in `apps/platform/tests/Unit/Support/Operations/Reconciliation/Spec360CanonicalAdapterRegistryTest.php` for adapter resolution of `restore.execute`, `environment.review.compose`, and unsupported types.
- [ ] T007 [P] Add failing Unit coverage in `apps/platform/tests/Unit/Support/Operations/Reconciliation/Spec360ReconciliationContextFormatTest.php` for canonical `context.reconciliation` shape, idempotent merge behavior, previous status/outcome preservation, and related metadata normalization.
- [ ] T008 Introduce the canonical adapter contract and registration or orchestration seam under `apps/platform/app/Support/Operations/Reconciliation/` and refactor `apps/platform/app/Services/AdapterRunReconciler.php` so future adapters register instead of expanding a central `match`.
- [ ] T009 Extend `apps/platform/app/Services/OperationRunService.php` so `updateRunWithReconciliation()` writes only the canonical `context.reconciliation` payload, normalizes `related.type` and `related.id`, and removes duplicate compatibility fields that are no longer justified.
- [ ] T010 Keep restore behavior intact by moving `restore.execute` onto the same canonical adapter seam without broadening restore business logic or introducing a second write path.
- [ ] T011 Update or add focused Unit coverage to prove adapter-owned paths cannot persist reconciliation state directly outside `OperationRunService`.
**Checkpoint**: one canonical adapter extension seam exists, `OperationRunService` still owns lifecycle writes, and current restore behavior remains functionally intact.
---
## Phase 3: User Story 1 - Canonicalize the adapter-owned reconciliation seam (Priority: P1)
**Goal**: review-compose reconciliation becomes fully adapter or decision owned, and `ComposeEnvironmentReviewJob` becomes orchestration only.
**Independent Test**: run focused Unit and Feature coverage showing that review-compose decisions finalize through the canonical adapter seam and that the job no longer owns business reconciliation logic.
### Tests for User Story 1
- [ ] T012 [P] [US1] Add `apps/platform/tests/Feature/Operations/Spec360CanonicalReconciliationCutoverTest.php` covering adapter-owned review-compose finalization, unsupported handling, and service-owned reconciliation writes.
- [ ] T013 [P] [US1] Add or extend focused duplicate and lineage coverage in `apps/platform/tests/Feature/EnvironmentReview/Spec360ReviewComposeAdapterOwnershipTest.php` so duplicate fingerprint recovery and superseded review lineages prove the adapter-owned path rather than job-local fallback behavior.
### Implementation for User Story 1
- [ ] T014 [US1] Refactor `apps/platform/app/Jobs/ComposeEnvironmentReviewJob.php` so it loads scope, delegates reconciliation decisions, delegates composition, and never writes business reconciliation metadata directly.
- [ ] T015 [US1] Keep review-compose business truth inside `apps/platform/app/Support/Operations/Reconciliation/EnvironmentReviewComposeDecision.php` and any new local adapter class under `apps/platform/app/Support/Operations/Reconciliation/`, including duplicate recovery, ambiguous lineage, and scope-safety decisions.
- [ ] T016 [US1] Keep `apps/platform/app/Services/EnvironmentReviews/EnvironmentReviewService.php` and `apps/platform/app/Services/EnvironmentReviews/EnvironmentReviewComposer.php` free of direct `OperationRun` reconciliation mutation and broad adapter knowledge.
- [ ] T017 [US1] Ensure duplicate fingerprint recovery still ends in deterministic succeeded, blocked, or attention-required outcomes without ghost runs and without raw SQL becoming primary operator-facing truth.
**Checkpoint**: review-compose reconciliation is fully adapter-owned and the job is orchestration only.
---
## Phase 4: User Story 2 - Align queued dispatch and failed-job correlation truth (Priority: P1)
**Goal**: queued dispatch metadata and failed-job correlation resolve through one canonical contract.
**Independent Test**: run focused Unit and Feature coverage showing that queued runs record canonical `context.dispatch` and that middleware plus failed-job bridging resolve the same `OperationRun`.
### Tests for User Story 2
- [ ] T018 [P] [US2] Add `apps/platform/tests/Unit/Support/OperationRunDispatchContextTest.php` for canonical `context.dispatch` keys, no synthetic job id, and canonical correlation version behavior.
- [ ] T019 [P] [US2] Add `apps/platform/tests/Feature/Operations/Spec360DispatchCorrelationTest.php` covering shared correlation between `TrackOperationRun`, `BridgesFailedOperationRun`, and `OperationRunService::dispatchOrFail()`.
- [ ] T020 [P] [US2] Add PGSQL-sensitive duplicate or lock-aware follow-through to the Spec360 feature coverage where queue correlation or duplicate recovery depends on partial unique-index truth.
### Implementation for User Story 2
- [ ] T021 [US2] Extend `apps/platform/app/Services/OperationRunService.php` so `dispatchOrFail()` or the shared dispatch seam writes canonical `context.dispatch` metadata with `job_class`, `queue`, `connection`, `dispatched_at`, `correlation_version`, and `operation_run_id`.
- [ ] T022 [US2] Introduce one shared run-correlation resolver or contract under `apps/platform/app/Jobs/` or `apps/platform/app/Support/Operations/` and update only `apps/platform/app/Jobs/Middleware/TrackOperationRun.php` plus `apps/platform/app/Jobs/Concerns/BridgesFailedOperationRun.php` to use it.
- [ ] T023 [US2] Normalize only the directly touched `OperationRunService::dispatchOrFail()` lifecycle path to the canonical `operationRunId` or equivalent shared correlation contract, and remove hidden fallback property scanning there where it is no longer justified.
- [ ] T024 [US2] Ensure unsupported or out-of-scope jobs fail closed: they may skip correlation cleanly, but they must not claim a wrong `OperationRun`, invent orphan semantics from missing queue ids, or pull unrelated `bulkRunId` or `runId` cleanup into this spec.
**Checkpoint**: dispatch metadata is canonical and middleware plus failed-job bridging share one correlation rule.
---
## Phase 5: User Story 3 - Cut readers and operation-type truth over to canonical metadata (Priority: P2)
**Goal**: readers, writers, and operator-facing copy rely on canonical related metadata and canonical operation-type values instead of broad legacy fallback behavior.
**Independent Test**: run focused Unit, Feature, and one bounded Browser smoke to prove canonical type writes, canonical related metadata resolution, and calm operator wording on existing operations surfaces.
### Tests for User Story 3
- [ ] T025 [P] [US3] Add `apps/platform/tests/Unit/Support/OperationTypeCanonicalizationTest.php` or extend `apps/platform/tests/Unit/Support/OperationTypeResolutionTest.php` so current write paths and read-side alias inventory reflect the new canonical-only posture.
- [ ] T026 [P] [US3] Add or extend focused read-side coverage for canonical related metadata in `apps/platform/tests/Feature/Operations/Spec360CanonicalReconciliationCutoverTest.php` or a companion feature file that exercises `OperationRunLinks` and `ArtifactTruthPresenter`.
- [ ] T027 [P] [US3] Add `apps/platform/tests/Browser/Spec360OperationRunCanonicalCutoverSmokeTest.php` covering reconciled review-compose, stale queue truth from Spec 358, and canonical related review link behavior on the existing operations surfaces.
### Implementation for User Story 3
- [ ] T028 [US3] Update `apps/platform/app/Support/OperationCatalog.php`, `apps/platform/app/Support/OperationRunType.php`, `apps/platform/app/Models/OperationRun.php`, and `apps/platform/database/factories/OperationRunFactory.php` so touched writers use canonical operation types and unnecessary alias compatibility is removed or explicitly justified.
- [ ] T029 [US3] Update `apps/platform/app/Support/OperationRunLinks.php` and `apps/platform/app/Support/Ui/GovernanceArtifactTruth/ArtifactTruthPresenter.php` to prefer `context.reconciliation.related` before falling back to historical `operation_run_id` linkage, removing the fallback where pre-production history is the only reason it exists.
- [ ] T030 [US3] Update `apps/platform/app/Support/OpsUx/OperationUxPresenter.php`, `apps/platform/app/Filament/Resources/OperationRunResource.php`, `apps/platform/app/Filament/Pages/Operations/TenantlessOperationRunViewer.php`, and `apps/platform/app/Filament/Resources/EnvironmentReviewResource.php` only as needed so existing operator surfaces stay calm and canonical.
**Checkpoint**: touched writers are canonical-only, readers prefer canonical related metadata, and existing operations surfaces explain the cutover calmly.
---
## Phase 6: Polish & Validation
- [ ] T032 [P] Refresh `spec.md`, `plan.md`, and `checklists/requirements.md` only if implementation proves a thinner or broader touched-file boundary than this prep package.
- [ ] T033 [P] Run the primary in-scope fast-feedback or confidence gate:
- `cd apps/platform && ./vendor/bin/sail artisan test --compact --filter=Spec360`
- `cd apps/platform && ./vendor/bin/sail artisan test --compact --filter=Spec359`
- `cd apps/platform && ./vendor/bin/sail artisan test --compact --filter=Spec358`
- [ ] T034 [P] Run `cd apps/platform && ./vendor/bin/sail php vendor/bin/pest -c phpunit.pgsql.xml --filter=Spec360`.
- [ ] T035 [P] Run the primary in-scope browser smoke:
- `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Browser/Spec360OperationRunCanonicalCutoverSmokeTest.php`
- [ ] T036 [P] Run the bounded contextual review/report regressions from the active spec:
- `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Browser/CustomerReviewWorkspaceSmokeTest.php`
- `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Browser/Spec357ReportProfilesSmokeTest.php`
- `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/ReviewPack/Spec357RenderedReportProfileTest.php`
- `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/ReviewPack/ReviewPackDownloadTest.php`
- `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/ReviewPack/EnvironmentReviewDerivedReviewPackTest.php`
- `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/ReviewPack/EnvironmentReviewExecutivePackTest.php`
- [ ] T037A [P] Run the known external probes separately and record them explicitly instead of silently folding them into the primary merge gate:
- `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Browser/Spec347ReviewPackOutputReadinessSmokeTest.php`
- `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/EnvironmentReviewHeaderDisciplineTest.php`
- [ ] T038 [P] Run `cd apps/platform && ./vendor/bin/pint --dirty`.
- [ ] T039 [P] Run `git diff --check`.
- [ ] T040 [P] Record the final adapter contract shape, dispatch contract shape, correlation rule, alias-retirement decision, and bounded UI wording outcome in the active feature close-out entry `Guardrail / Smoke Coverage`.
---
## Dependencies & Execution Order
### Phase Dependencies
- **Setup (Phase 1)**: no dependencies
- **Foundational (Phase 2)**: depends on Setup and blocks all story work
- **US1 (Phase 3)**: depends on Foundational completion
- **US2 (Phase 4)**: depends on Foundational completion and should land before broad reader cleanup
- **US3 (Phase 5)**: depends on US1 and US2 because canonical related metadata and canonical type truth rely on the settled adapter and dispatch contracts
- **Polish (Phase 6)**: depends on all desired user stories
### Parallel Opportunities
- `T002`, `T003`, and `T004` can run in parallel.
- `T006` and `T007` can run in parallel.
- `T012` and `T013` can run in parallel.
- `T018`, `T019`, and `T020` can run in parallel.
- `T025`, `T026`, and `T027` can run in parallel.
- `T033` through `T039` can run in parallel after implementation stabilizes, but the primary merge gate should be read out separately from contextual regressions and external probes.
### Implementation Strategy
1. Freeze the merged Spec 359 baseline and remove misleading artifact drift first.
2. Land the canonical adapter and service-owned metadata seam before job or reader cleanup.
3. Land dispatch and failed-job correlation on one contract before cutting readers and type writes over.
4. Finish with canonical related-metadata readers, calm operator wording, and the bounded validation suite.
## Non-Goals / Must-Not-Do
- [ ] NT001 Do not add a new `reconciled` status column, boolean, or separate `OperationRun` truth table.
- [ ] NT002 Do not add report, evidence, sync, backup, restore, or alert delivery business adapters in this feature.
- [ ] NT003 Do not add a new queue/job family, a second operator-center UI, or a speculative provider framework.
- [ ] NT004 Do not add compatibility shims or dual-write logic solely to preserve pre-production historical aliases or old fixtures.
- [ ] NT005 Do not expose raw SQL, duplicate-key, or low-level framework error wording on operator-primary surfaces.