feat: implement operation run actionability system (#439)

This PR introduces the Operation Run Actionability System.

Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de>
Reviewed-on: #439
This commit is contained in:
ahmido 2026-06-08 13:34:25 +00:00
parent f37056e1de
commit 564da05096
38 changed files with 2883 additions and 50 deletions

View File

@ -467,7 +467,10 @@ private function workbenchOperationPayload(OperationRun $run, bool $hasAttention
'primary_action_label' => is_string($primaryAction['label'] ?? null)
? (string) $primaryAction['label']
: OperationRunLinks::openLabel(),
'primary_action_url' => OperationRunResource::primaryActionUrl($run),
'primary_action_url' => OperationRunLinks::withNavigationContext(
OperationRunResource::primaryActionUrl($run),
OperationRunLinks::operationsIndexNavigationContext($run, $this->operationsUrl()),
),
'progress' => $progress,
'progress_label' => is_string($progress['label'] ?? null) ? $progress['label'] : null,
'show_progress_bar' => ($progress['display'] ?? null) === OperationRunProgressContract::COUNTED,
@ -607,6 +610,10 @@ public function updatedActiveTab(): void
public function table(Table $table): Table
{
return OperationRunResource::table($table)
->recordUrl(fn (OperationRun $record): string => OperationRunLinks::viewFromOperationsIndex(
$record,
$this->operationsUrl(),
))
->query(function (): Builder {
$workspaceId = app(WorkspaceContext::class)->currentWorkspaceId(request());
$tenantFilter = $this->currentTenantFilterId();
@ -678,7 +685,7 @@ private function applyActiveTab(Builder $query): Builder
return match ($this->activeTab) {
'active' => $query->healthyActive(),
OperationRun::PROBLEM_CLASS_ACTIVE_STALE_ATTENTION => $query->activeStaleAttention(),
OperationRun::PROBLEM_CLASS_TERMINAL_FOLLOW_UP => $query->terminalFollowUp(),
OperationRun::PROBLEM_CLASS_TERMINAL_FOLLOW_UP => $query->currentTerminalFollowUp(),
'blocked' => $query->dashboardNeedsFollowUp(),
'succeeded' => $query
->where('status', OperationRunStatus::Completed->value)

View File

@ -218,7 +218,7 @@ private function primaryOperationAction(): ?Action
->label((string) ($primary['label'] ?? OperationRunLinks::openLabel()))
->icon(is_string($primary['icon'] ?? null) ? (string) $primary['icon'] : 'heroicon-o-arrow-top-right-on-square')
->color(is_string($primary['color'] ?? null) ? (string) $primary['color'] : 'primary')
->url($url);
->url(OperationRunLinks::withNavigationContext($url, $this->navigationContext()));
}
/**

View File

@ -2,13 +2,14 @@
namespace App\Filament\Resources\ProviderConnectionResource\Pages;
use App\Models\OperationRun;
use App\Filament\Resources\ProviderConnectionResource;
use App\Models\ManagedEnvironment;
use App\Models\OperationRun;
use App\Models\ProviderConnection;
use App\Support\Auth\Capabilities;
use App\Support\Links\RequiredPermissionsLinks;
use App\Support\ManagedEnvironmentLinks;
use App\Support\Navigation\CanonicalNavigationContext;
use App\Support\OperationRunLinks;
use App\Support\Rbac\UiEnforcement;
use App\Support\ResolutionGuidance\Adapters\ProviderReadinessResolutionAdapter;
@ -101,7 +102,11 @@ private function primaryReadinessHeaderAction(?ManagedEnvironment $tenant): ?Act
? Actions\Action::make('open_verification_operation')
->label((string) ($primaryAction['label'] ?? 'Open verification operation'))
->icon('heroicon-o-eye')
->url(OperationRunLinks::view($this->latestVerificationRun($tenant), $tenant))
->url(OperationRunLinks::view(
$this->latestVerificationRun($tenant),
$tenant,
CanonicalNavigationContext::fromRequest(request()),
))
: ProviderConnectionResource::makeCheckConnectionAction()
->label('Run provider verification'),
'provider_readiness.connection_review_required' => ProviderConnectionResource::makeEditNavigationAction()

View File

@ -79,7 +79,7 @@ protected function getViewData(): array
->count();
$terminalFollowUpOperationsCount = (int) OperationRun::query()
->where('managed_environment_id', $tenantId)
->terminalFollowUp()
->currentTerminalFollowUp()
->count();
$activeRuns = (int) OperationRun::query()
->where('managed_environment_id', $tenantId)
@ -187,11 +187,11 @@ protected function getViewData(): array
if ($terminalFollowUpOperationsCount > 0) {
$items[] = [
'key' => 'operations_terminal_follow_up',
'title' => 'Terminal operations need follow-up',
'body' => "{$terminalFollowUpOperationsCount} run(s) finished blocked, partially, failed, or were automatically reconciled.",
'title' => 'Operations need current follow-up',
'body' => "{$terminalFollowUpOperationsCount} terminal run(s) still need operator action after current-state checks.",
'badge' => 'Operations',
'badgeColor' => 'danger',
'actionLabel' => 'Open terminal follow-up',
'actionLabel' => 'Open current follow-up',
'actionUrl' => OperationRunLinks::index(
$tenant,
activeTab: OperationRun::PROBLEM_CLASS_TERMINAL_FOLLOW_UP,

View File

@ -7,6 +7,7 @@
use App\Support\OperationRunOutcome;
use App\Support\OperationRunStatus;
use App\Support\OperationRunType;
use App\Support\Operations\Actionability\OperationRunActionabilityResolver;
use App\Support\Operations\OperationLifecyclePolicy;
use App\Support\Operations\OperationRunFreshnessState;
use App\Support\OperationTypeResolution;
@ -204,7 +205,7 @@ public function scopeDashboardNeedsFollowUp(Builder $query): Builder
return $query->where(function (Builder $query): void {
$query
->where(function (Builder $terminalQuery): void {
$terminalQuery->terminalFollowUp();
$terminalQuery->currentTerminalFollowUp();
})
->orWhere(function (Builder $activeQuery): void {
$activeQuery->activeStaleAttention();
@ -232,6 +233,11 @@ public function scopeTerminalFollowUp(Builder $query): Builder
});
}
public function scopeCurrentTerminalFollowUp(Builder $query): Builder
{
return app(OperationRunActionabilityResolver::class)->applyCurrentTerminalFollowUpScope($query);
}
public function getSelectionHashAttribute(): ?string
{
$context = is_array($this->context) ? $this->context : [];
@ -555,7 +561,17 @@ public function isCurrentlyActive(): bool
public function requiresOperatorReview(): bool
{
return $this->problemClass() !== self::PROBLEM_CLASS_NONE;
if ($this->problemClass() === self::PROBLEM_CLASS_ACTIVE_STALE_ATTENTION) {
return true;
}
if ($this->problemClass() !== self::PROBLEM_CLASS_TERMINAL_FOLLOW_UP) {
return false;
}
return app(OperationRunActionabilityResolver::class)
->evaluate($this)
->requiresCurrentFollowUp();
}
public function requiresDashboardFollowUp(): bool

View File

@ -625,7 +625,7 @@ private function orderedIntakeFindingsQuery(\Illuminate\Database\Eloquent\Builde
private function terminalOperationsQuery(Workspace $workspace, array $authorizedTenants, ?ManagedEnvironment $selectedTenant): \Illuminate\Database\Eloquent\Builder
{
return $this->operationsBaseQuery($workspace, $authorizedTenants, $selectedTenant)
->terminalFollowUp();
->currentTerminalFollowUp();
}
/**

View File

@ -141,6 +141,40 @@ public static function tenantlessView(OperationRun|int $run, ?CanonicalNavigatio
));
}
public static function viewFromOperationsIndex(OperationRun|int $run, string $backUrl): string
{
return self::tenantlessView(
$run,
self::operationsIndexNavigationContext($run, $backUrl),
);
}
public static function operationsIndexNavigationContext(OperationRun|int $run, string $backUrl): CanonicalNavigationContext
{
return new CanonicalNavigationContext(
sourceSurface: 'operations.index',
canonicalRouteName: 'admin.operations.index',
tenantId: $run instanceof OperationRun && is_numeric($run->managed_environment_id)
? (int) $run->managed_environment_id
: null,
backLinkLabel: 'Back to Operations',
backLinkUrl: $backUrl,
);
}
public static function withNavigationContext(string $url, ?CanonicalNavigationContext $context): string
{
if (! $context instanceof CanonicalNavigationContext) {
return $url;
}
if (! self::canCarryNavigationContext($url) || self::hasNavigationContext($url)) {
return $url;
}
return url()->query($url, $context->toQuery());
}
public static function view(OperationRun|int $run, ManagedEnvironment $tenant, ?CanonicalNavigationContext $context = null): string
{
return self::tenantlessView($run, $context);
@ -355,4 +389,29 @@ private static function resolveWorkspace(ManagedEnvironment|OperationRun|int|nul
return app(WorkspaceContext::class)->currentWorkspace(request());
}
private static function hasNavigationContext(string $url): bool
{
$query = parse_url($url, PHP_URL_QUERY);
return is_string($query)
&& (str_contains($query, 'nav%5B') || str_contains($query, 'nav['));
}
private static function canCarryNavigationContext(string $url): bool
{
$path = parse_url($url, PHP_URL_PATH);
if (! is_string($path) || ! str_starts_with($path, '/admin')) {
return false;
}
$host = parse_url($url, PHP_URL_HOST);
if ($host === null || $host === false) {
return true;
}
return $host === parse_url(url('/'), PHP_URL_HOST);
}
}

View File

@ -0,0 +1,226 @@
<?php
declare(strict_types=1);
namespace App\Support\Operations\Actionability;
use App\Models\OperationRun;
use App\Models\ProviderConnection;
use App\Support\OperationCatalog;
use App\Support\OperationRunOutcome;
use App\Support\OperationRunStatus;
use BackedEnum;
use Illuminate\Support\Collection;
final class OperationRunActionabilityEvaluationContext
{
/** @var Collection<int, OperationRun>|null */
private ?Collection $laterSuccessfulRuns = null;
/** @var Collection<int, ProviderConnection>|null */
private ?Collection $providerConnections = null;
/**
* @param Collection<int, OperationRun> $runs
*/
public function __construct(
private readonly Collection $runs,
private readonly ?OperationRunActionabilityRegistry $registry = null,
) {}
/**
* @param list<string> $canonicalTypes
* @param list<string> $matchContextKeys
*/
public function laterSuccessfulRun(
OperationRun $run,
array $canonicalTypes,
array $matchContextKeys = [],
): ?OperationRun {
$canonicalTypes = array_values(array_unique(array_filter($canonicalTypes, static fn (string $type): bool => trim($type) !== '')));
if ($canonicalTypes === []) {
return null;
}
return $this->laterSuccessfulRuns()
->first(function (OperationRun $candidate) use ($run, $canonicalTypes, $matchContextKeys): bool {
if ((int) $candidate->getKey() === (int) $run->getKey()) {
return false;
}
if (! in_array($candidate->canonicalOperationType(), $canonicalTypes, true)) {
return false;
}
if (! $this->sameScope($run, $candidate)) {
return false;
}
if (! $this->isLater($run, $candidate)) {
return false;
}
foreach ($matchContextKeys as $key) {
$left = data_get($run->context, $key);
$right = data_get($candidate->context, $key);
if ($left === null && $right === null) {
continue;
}
if ((string) $left !== (string) $right) {
return false;
}
}
return true;
});
}
public function healthyProviderConnection(OperationRun $run): ?ProviderConnection
{
$connectionId = data_get($run->context, 'provider_connection_id');
if (! is_numeric($connectionId)) {
return null;
}
$connection = $this->providerConnections()->get((int) $connectionId);
if (! $connection instanceof ProviderConnection) {
return null;
}
if ((int) $connection->workspace_id !== (int) $run->workspace_id) {
return null;
}
if ((int) $connection->managed_environment_id !== (int) $run->managed_environment_id) {
return null;
}
if (! $connection->is_enabled) {
return null;
}
return $this->enumValue($connection->consent_status) === 'granted'
&& $this->enumValue($connection->verification_status) === 'healthy'
? $connection
: null;
}
private function enumValue(mixed $value): string
{
return $value instanceof BackedEnum ? (string) $value->value : (string) $value;
}
private function sameScope(OperationRun $run, OperationRun $candidate): bool
{
return (int) $candidate->workspace_id === (int) $run->workspace_id
&& (int) ($candidate->managed_environment_id ?? 0) === (int) ($run->managed_environment_id ?? 0);
}
private function isLater(OperationRun $run, OperationRun $candidate): bool
{
$runTimestamp = $run->completed_at ?? $run->created_at;
$candidateTimestamp = $candidate->completed_at ?? $candidate->created_at;
if ($runTimestamp !== null && $candidateTimestamp !== null) {
if ($candidateTimestamp->greaterThan($runTimestamp)) {
return true;
}
if ($candidateTimestamp->lessThan($runTimestamp)) {
return false;
}
}
return (int) $candidate->getKey() > (int) $run->getKey();
}
/**
* @return Collection<int, OperationRun>
*/
private function laterSuccessfulRuns(): Collection
{
if ($this->laterSuccessfulRuns instanceof Collection) {
return $this->laterSuccessfulRuns;
}
$workspaceIds = $this->runs
->pluck('workspace_id')
->filter(static fn (mixed $value): bool => is_numeric($value))
->map(static fn (mixed $value): int => (int) $value)
->unique()
->values()
->all();
if ($workspaceIds === []) {
return $this->laterSuccessfulRuns = collect();
}
$rawTypes = collect($this->registry?->canonicalTypesWithLaterSuccessPolicy() ?? [])
->flatMap(static fn (string $type): array => OperationCatalog::rawValuesForCanonical($type))
->unique()
->values()
->all();
if ($rawTypes === []) {
return $this->laterSuccessfulRuns = collect();
}
$minCreatedAt = $this->runs
->map(static fn (OperationRun $run): mixed => $run->created_at ?? $run->completed_at)
->filter()
->sort()
->first();
$query = OperationRun::query()
->whereIn('workspace_id', $workspaceIds)
->whereIn('type', $rawTypes)
->where('status', OperationRunStatus::Completed->value)
->where('outcome', OperationRunOutcome::Succeeded->value)
->orderBy('completed_at')
->orderBy('created_at')
->orderBy('id');
if ($minCreatedAt !== null) {
$query->where(function ($query) use ($minCreatedAt): void {
$query
->whereNull('completed_at')
->orWhere('completed_at', '>=', $minCreatedAt)
->orWhere('created_at', '>=', $minCreatedAt);
});
}
return $this->laterSuccessfulRuns = $query->get();
}
/**
* @return Collection<int, ProviderConnection>
*/
private function providerConnections(): Collection
{
if ($this->providerConnections instanceof Collection) {
return $this->providerConnections;
}
$ids = $this->runs
->map(static fn (OperationRun $run): mixed => data_get($run->context, 'provider_connection_id'))
->filter(static fn (mixed $value): bool => is_numeric($value))
->map(static fn (mixed $value): int => (int) $value)
->unique()
->values()
->all();
if ($ids === []) {
return $this->providerConnections = collect();
}
return $this->providerConnections = ProviderConnection::query()
->whereIn('id', $ids)
->get()
->keyBy(static fn (ProviderConnection $connection): int => (int) $connection->getKey());
}
}

View File

@ -0,0 +1,20 @@
<?php
declare(strict_types=1);
namespace App\Support\Operations\Actionability;
final readonly class OperationRunActionabilityPolicyDefinition
{
/**
* @param list<string> $supersededByCanonicalTypes
* @param list<string> $matchContextKeys
*/
public function __construct(
public string $canonicalType,
public string $policyIdentifier,
public string $kind,
public array $supersededByCanonicalTypes = [],
public array $matchContextKeys = [],
) {}
}

View File

@ -0,0 +1,137 @@
<?php
declare(strict_types=1);
namespace App\Support\Operations\Actionability;
use App\Support\OperationCatalog;
final class OperationRunActionabilityRegistry
{
/**
* @return array<string, OperationRunActionabilityPolicyDefinition>
*/
public function definitions(): array
{
$definitions = [];
$add = static function (OperationRunActionabilityPolicyDefinition $definition) use (&$definitions): void {
$definitions[$definition->canonicalType] = $definition;
};
$add(new OperationRunActionabilityPolicyDefinition(
canonicalType: 'provider.connection.check',
policyIdentifier: 'provider_connection_check_current_state_v1',
kind: 'provider_connection',
supersededByCanonicalTypes: ['provider.connection.check'],
matchContextKeys: ['provider_connection_id', 'provider'],
));
foreach ([
'inventory.sync',
'policy.sync',
'policy.snapshot',
'policy.export',
'directory.groups.sync',
'directory.role_definitions.sync',
'compliance.snapshot',
'permission.posture.check',
'entra.admin_roles.scan',
'rbac.health_check',
'tenant.sync',
'assignments.fetch',
'alerts.evaluate',
'alerts.deliver',
'ops.reconcile_adapter_runs',
] as $type) {
$add(new OperationRunActionabilityPolicyDefinition(
canonicalType: $type,
policyIdentifier: 'repeatable_later_success_v1',
kind: 'repeatable',
supersededByCanonicalTypes: [$type],
matchContextKeys: ['selection_hash', 'provider_connection_id', 'provider'],
));
}
foreach ([
'baseline.capture',
'baseline.compare',
'tenant.evidence.snapshot.generate',
'environment.review.compose',
'environment.review_pack.generate',
'backup_set.update',
'backup.schedule.execute',
'backup.schedule.retention',
] as $type) {
$add(new OperationRunActionabilityPolicyDefinition(
canonicalType: $type,
policyIdentifier: 'artifact_or_later_success_v1',
kind: 'artifact_or_later_success',
supersededByCanonicalTypes: [$type],
matchContextKeys: ['baseline_profile_id', 'backup_set_id', 'backup_schedule_id', 'selection_hash'],
));
}
foreach ([
'policy.delete',
'policy.restore',
'backup_set.archive',
'backup_set.restore',
'backup_set.delete',
'backup.schedule.purge',
'restore.execute',
'promotion.execute',
'assignments.restore',
'restore_run.delete',
'restore_run.restore',
'restore_run.force_delete',
'policy_version.prune',
'policy_version.restore',
'policy_version.force_delete',
] as $type) {
$add(new OperationRunActionabilityPolicyDefinition(
canonicalType: $type,
policyIdentifier: 'high_risk_manual_review_v1',
kind: 'manual_review',
));
}
return $definitions;
}
public function forCanonicalType(string $canonicalType): ?OperationRunActionabilityPolicyDefinition
{
return $this->definitions()[$canonicalType] ?? null;
}
/**
* @return list<string>
*/
public function coveredCanonicalTypes(): array
{
return array_values(array_unique(array_keys($this->definitions())));
}
/**
* @return list<string>
*/
public function uncoveredCanonicalTypes(): array
{
return array_values(array_diff(
array_keys(OperationCatalog::canonicalInventory()),
$this->coveredCanonicalTypes(),
));
}
/**
* @return list<string>
*/
public function canonicalTypesWithLaterSuccessPolicy(): array
{
return collect($this->definitions())
->flatMap(static fn (OperationRunActionabilityPolicyDefinition $definition): array => $definition->supersededByCanonicalTypes)
->unique()
->values()
->all();
}
}

View File

@ -0,0 +1,404 @@
<?php
declare(strict_types=1);
namespace App\Support\Operations\Actionability;
use App\Models\BackupSet;
use App\Models\BaselineSnapshot;
use App\Models\EnvironmentReview;
use App\Models\EvidenceSnapshot;
use App\Models\OperationRun;
use App\Models\ReviewPack;
use App\Support\Baselines\BaselineSnapshotLifecycleState;
use App\Support\Evidence\EvidenceCompletenessState;
use App\Support\Evidence\EvidenceSnapshotStatus;
use App\Support\OperationRunOutcome;
use App\Support\OperationRunStatus;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Collection;
final class OperationRunActionabilityResolver
{
public function __construct(
private readonly OperationRunActionabilityRegistry $registry,
) {}
public function evaluate(OperationRun $run): OperationRunActionabilityResult
{
return $this->evaluateMany(collect([$run]))->get((int) $run->getKey())
?? $this->defaultResult($run);
}
/**
* @param Collection<int, OperationRun> $runs
* @return Collection<int, OperationRunActionabilityResult>
*/
public function evaluateMany(Collection $runs): Collection
{
$runs = $runs->values();
$context = new OperationRunActionabilityEvaluationContext($runs, $this->registry);
return $runs->mapWithKeys(fn (OperationRun $run): array => [
(int) $run->getKey() => $this->evaluateWithContext($run, $context)->withRun($run),
]);
}
/**
* Applies current terminal follow-up semantics to an already-scoped query.
*/
public function applyCurrentTerminalFollowUpScope(Builder $query): Builder
{
$candidateRuns = (clone $query)
->terminalFollowUp()
->select('operation_runs.*')
->get();
if ($candidateRuns->isEmpty()) {
return $query->whereRaw('1 = 0');
}
$actionableIds = $this->evaluateMany($candidateRuns)
->filter(static fn (OperationRunActionabilityResult $result): bool => $result->requiresCurrentFollowUp())
->keys()
->map(static fn (mixed $id): int => (int) $id)
->values()
->all();
if ($actionableIds === []) {
return $query->whereRaw('1 = 0');
}
return $query->whereIn('operation_runs.id', $actionableIds);
}
private function evaluateWithContext(
OperationRun $run,
OperationRunActionabilityEvaluationContext $context,
): OperationRunActionabilityResult {
if (! $this->isTerminalProblem($run)) {
return new OperationRunActionabilityResult(
status: OperationRunActionabilityStatus::NotTerminal,
reasonCode: 'not_terminal_problem',
explanation: 'This run is not a terminal problem and does not drive current follow-up.',
policyIdentifier: 'terminal_problem_gate_v1',
);
}
$definition = $this->registry->forCanonicalType($run->canonicalOperationType());
if (! $definition instanceof OperationRunActionabilityPolicyDefinition) {
return $this->manualReview(
reasonCode: 'unknown_operation_type',
explanation: 'This operation type has no explicit actionability policy, so it remains visible for manual review.',
policyIdentifier: 'unknown_manual_review_v1',
);
}
return match ($definition->kind) {
'provider_connection' => $this->providerConnectionResult($run, $definition, $context),
'repeatable' => $this->laterSuccessResult($run, $definition, $context),
'artifact_or_later_success' => $this->artifactOrLaterSuccessResult($run, $definition, $context),
'manual_review' => $this->manualReview(
reasonCode: 'manual_review_required',
explanation: 'This operation family is high impact or destructive-like, so terminal problems require deliberate review.',
policyIdentifier: $definition->policyIdentifier,
),
'informational' => new OperationRunActionabilityResult(
status: OperationRunActionabilityStatus::InformationalOnly,
reasonCode: 'informational_history',
explanation: 'This terminal record is historical context and does not represent current operator follow-up.',
policyIdentifier: $definition->policyIdentifier,
),
default => $this->manualReview(
reasonCode: 'unsupported_policy_kind',
explanation: 'This actionability policy kind is not supported, so the run remains visible for manual review.',
policyIdentifier: $definition->policyIdentifier,
),
};
}
private function providerConnectionResult(
OperationRun $run,
OperationRunActionabilityPolicyDefinition $definition,
OperationRunActionabilityEvaluationContext $context,
): OperationRunActionabilityResult {
$laterSuccess = $context->laterSuccessfulRun(
$run,
$definition->supersededByCanonicalTypes,
$definition->matchContextKeys,
);
if ($laterSuccess instanceof OperationRun) {
return new OperationRunActionabilityResult(
status: OperationRunActionabilityStatus::SupersededByLaterSuccess,
reasonCode: 'later_provider_check_succeeded',
explanation: 'A later same-scope provider connection check succeeded, so this old terminal blocker is not current follow-up.',
supersedingRunId: (int) $laterSuccess->getKey(),
policyIdentifier: $definition->policyIdentifier,
);
}
$connection = $context->healthyProviderConnection($run);
if ($connection !== null) {
return new OperationRunActionabilityResult(
status: OperationRunActionabilityStatus::ResolvedByCurrentState,
reasonCode: 'provider_connection_currently_healthy',
explanation: 'The same provider connection is currently enabled, consented, and healthy.',
resolvingModelType: 'provider_connection',
resolvingModelId: (int) $connection->getKey(),
policyIdentifier: $definition->policyIdentifier,
);
}
return $this->actionable(
reasonCode: 'provider_connection_still_needs_review',
explanation: 'No later same-scope successful check or healthy provider connection state proves this blocker is resolved.',
policyIdentifier: $definition->policyIdentifier,
);
}
private function laterSuccessResult(
OperationRun $run,
OperationRunActionabilityPolicyDefinition $definition,
OperationRunActionabilityEvaluationContext $context,
): OperationRunActionabilityResult {
$laterSuccess = $context->laterSuccessfulRun(
$run,
$definition->supersededByCanonicalTypes,
$definition->matchContextKeys,
);
if ($laterSuccess instanceof OperationRun) {
return new OperationRunActionabilityResult(
status: OperationRunActionabilityStatus::SupersededByLaterSuccess,
reasonCode: 'later_same_scope_success',
explanation: 'A later same-scope successful run proves this old terminal problem is no longer current follow-up.',
supersedingRunId: (int) $laterSuccess->getKey(),
policyIdentifier: $definition->policyIdentifier,
);
}
return $this->actionable(
reasonCode: 'no_later_same_scope_success',
explanation: 'No later same-scope successful run proves this terminal problem has been resolved.',
policyIdentifier: $definition->policyIdentifier,
);
}
private function artifactOrLaterSuccessResult(
OperationRun $run,
OperationRunActionabilityPolicyDefinition $definition,
OperationRunActionabilityEvaluationContext $context,
): OperationRunActionabilityResult {
$later = $this->laterSuccessResult($run, $definition, $context);
if (! $later->requiresCurrentFollowUp()) {
return $later;
}
$artifact = $this->resolvingArtifact($run);
if ($artifact !== null) {
return new OperationRunActionabilityResult(
status: OperationRunActionabilityStatus::ResolvedByCurrentState,
reasonCode: 'current_artifact_available',
explanation: 'A same-scope current artifact exists for this operation family, so this old terminal problem is not current follow-up.',
resolvingModelType: $artifact['type'],
resolvingModelId: $artifact['id'],
policyIdentifier: $definition->policyIdentifier,
);
}
return $later;
}
/**
* @return array{type:string,id:int}|null
*/
private function resolvingArtifact(OperationRun $run): ?array
{
return match ($run->canonicalOperationType()) {
'baseline.capture' => $this->baselineSnapshotArtifact($run),
'tenant.evidence.snapshot.generate' => $this->evidenceSnapshotArtifact($run),
'environment.review.compose' => $this->environmentReviewArtifact($run),
'environment.review_pack.generate' => $this->reviewPackArtifact($run),
'backup_set.update', 'backup.schedule.execute', 'backup.schedule.retention' => $this->backupSetArtifact($run),
default => null,
};
}
/**
* @return array{type:string,id:int}|null
*/
private function baselineSnapshotArtifact(OperationRun $run): ?array
{
$snapshotId = $run->reconciledRelatedBaselineSnapshotId()
?? (is_numeric(data_get($run->context, 'baseline_snapshot_id')) ? (int) data_get($run->context, 'baseline_snapshot_id') : null)
?? (is_numeric(data_get($run->context, 'result.snapshot_id')) ? (int) data_get($run->context, 'result.snapshot_id') : null);
if ($snapshotId === null) {
return null;
}
$snapshot = BaselineSnapshot::query()
->whereKey($snapshotId)
->where('workspace_id', (int) $run->workspace_id)
->where('lifecycle_state', BaselineSnapshotLifecycleState::Complete->value)
->first();
return $snapshot instanceof BaselineSnapshot
? ['type' => 'baseline_snapshot', 'id' => (int) $snapshot->getKey()]
: null;
}
/**
* @return array{type:string,id:int}|null
*/
private function evidenceSnapshotArtifact(OperationRun $run): ?array
{
$snapshotId = $run->reconciledRelatedEvidenceSnapshotId();
$query = EvidenceSnapshot::query()
->where('workspace_id', (int) $run->workspace_id)
->where('managed_environment_id', (int) $run->managed_environment_id)
->where('status', EvidenceSnapshotStatus::Active->value)
->where('completeness_state', EvidenceCompletenessState::Complete->value);
if ($snapshotId !== null) {
$query->whereKey($snapshotId);
} else {
$query->where('operation_run_id', (int) $run->getKey());
}
$snapshot = $query->latest('id')->first();
return $snapshot instanceof EvidenceSnapshot
? ['type' => 'evidence_snapshot', 'id' => (int) $snapshot->getKey()]
: null;
}
/**
* @return array{type:string,id:int}|null
*/
private function environmentReviewArtifact(OperationRun $run): ?array
{
$reviewId = $run->reconciledRelatedReviewId();
$query = EnvironmentReview::query()
->where('workspace_id', (int) $run->workspace_id)
->where('managed_environment_id', (int) $run->managed_environment_id);
if ($reviewId !== null) {
$query->whereKey($reviewId);
} else {
$query->where('operation_run_id', (int) $run->getKey());
}
$review = $query->latest('id')->first();
return $review instanceof EnvironmentReview
? ['type' => 'environment_review', 'id' => (int) $review->getKey()]
: null;
}
/**
* @return array{type:string,id:int}|null
*/
private function reviewPackArtifact(OperationRun $run): ?array
{
$packId = $run->reconciledRelatedReviewPackId();
$query = ReviewPack::query()
->where('workspace_id', (int) $run->workspace_id)
->where('managed_environment_id', (int) $run->managed_environment_id)
->where('status', ReviewPack::STATUS_READY);
if ($packId !== null) {
$query->whereKey($packId);
} else {
$query->where('operation_run_id', (int) $run->getKey());
}
$pack = $query->latest('id')->first();
return $pack instanceof ReviewPack
? ['type' => 'review_pack', 'id' => (int) $pack->getKey()]
: null;
}
/**
* @return array{type:string,id:int}|null
*/
private function backupSetArtifact(OperationRun $run): ?array
{
$backupSetId = $run->reconciledRelatedBackupSetId()
?? (is_numeric(data_get($run->context, 'backup_set_id')) ? (int) data_get($run->context, 'backup_set_id') : null);
if ($backupSetId === null) {
return null;
}
$backupSet = BackupSet::query()
->whereKey($backupSetId)
->where('workspace_id', (int) $run->workspace_id)
->where('managed_environment_id', (int) $run->managed_environment_id)
->whereNotNull('completed_at')
->first();
return $backupSet instanceof BackupSet
? ['type' => 'backup_set', 'id' => (int) $backupSet->getKey()]
: null;
}
private function isTerminalProblem(OperationRun $run): bool
{
if ((string) $run->status !== OperationRunStatus::Completed->value) {
return false;
}
if ((string) $run->outcome === OperationRunOutcome::Succeeded->value) {
return false;
}
if ($run->isLifecycleReconciled()) {
return true;
}
return in_array((string) $run->outcome, [
OperationRunOutcome::Blocked->value,
OperationRunOutcome::PartiallySucceeded->value,
OperationRunOutcome::Failed->value,
], true);
}
private function actionable(string $reasonCode, string $explanation, string $policyIdentifier): OperationRunActionabilityResult
{
return new OperationRunActionabilityResult(
status: OperationRunActionabilityStatus::Actionable,
reasonCode: $reasonCode,
explanation: $explanation,
policyIdentifier: $policyIdentifier,
);
}
private function manualReview(string $reasonCode, string $explanation, string $policyIdentifier): OperationRunActionabilityResult
{
return new OperationRunActionabilityResult(
status: OperationRunActionabilityStatus::RequiresManualReview,
reasonCode: $reasonCode,
explanation: $explanation,
policyIdentifier: $policyIdentifier,
);
}
private function defaultResult(OperationRun $run): OperationRunActionabilityResult
{
return $this->manualReview(
reasonCode: 'evaluation_missing',
explanation: 'Actionability could not be evaluated, so the run remains visible for manual review.',
policyIdentifier: 'evaluation_missing_v1',
);
}
}

View File

@ -0,0 +1,76 @@
<?php
declare(strict_types=1);
namespace App\Support\Operations\Actionability;
use App\Models\OperationRun;
final readonly class OperationRunActionabilityResult
{
/**
* @param array<string, mixed> $metadata
*/
public function __construct(
public OperationRunActionabilityStatus $status,
public string $reasonCode,
public string $explanation,
public ?int $supersedingRunId = null,
public ?string $resolvingModelType = null,
public ?int $resolvingModelId = null,
public string $policyIdentifier = 'default',
public array $metadata = [],
public ?int $runId = null,
public ?string $operationType = null,
public ?string $canonicalType = null,
) {}
public function withRun(OperationRun $run): self
{
return new self(
status: $this->status,
reasonCode: $this->reasonCode,
explanation: $this->explanation,
supersedingRunId: $this->supersedingRunId,
resolvingModelType: $this->resolvingModelType,
resolvingModelId: $this->resolvingModelId,
policyIdentifier: $this->policyIdentifier,
metadata: $this->metadata,
runId: (int) $run->getKey(),
operationType: (string) $run->type,
canonicalType: $run->canonicalOperationType(),
);
}
public function requiresCurrentFollowUp(): bool
{
return $this->status->requiresCurrentFollowUp();
}
public function isActionable(): bool
{
return $this->requiresCurrentFollowUp();
}
/**
* @return array<string, mixed>
*/
public function toArray(): array
{
return [
'status' => $this->status->value,
'status_label' => $this->status->label(),
'actionable' => $this->requiresCurrentFollowUp(),
'reason_code' => $this->reasonCode,
'explanation' => $this->explanation,
'superseding_run_id' => $this->supersedingRunId,
'resolving_model_type' => $this->resolvingModelType,
'resolving_model_id' => $this->resolvingModelId,
'policy_identifier' => $this->policyIdentifier,
'metadata' => $this->metadata,
'run_id' => $this->runId,
'operation_type' => $this->operationType,
'canonical_type' => $this->canonicalType,
];
}
}

View File

@ -0,0 +1,35 @@
<?php
declare(strict_types=1);
namespace App\Support\Operations\Actionability;
enum OperationRunActionabilityStatus: string
{
case Actionable = 'actionable';
case RequiresManualReview = 'requires_manual_review';
case SupersededByLaterSuccess = 'superseded_by_later_success';
case ResolvedByCurrentState = 'resolved_by_current_state';
case InformationalOnly = 'informational_only';
case NotTerminal = 'not_terminal';
public function requiresCurrentFollowUp(): bool
{
return in_array($this, [
self::Actionable,
self::RequiresManualReview,
], true);
}
public function label(): string
{
return match ($this) {
self::Actionable => __('localization.operations.actionability.status.actionable'),
self::RequiresManualReview => __('localization.operations.actionability.status.requires_manual_review'),
self::SupersededByLaterSuccess => __('localization.operations.actionability.status.superseded_by_later_success'),
self::ResolvedByCurrentState => __('localization.operations.actionability.status.resolved_by_current_state'),
self::InformationalOnly => __('localization.operations.actionability.status.informational_only'),
self::NotTerminal => __('localization.operations.actionability.status.not_terminal'),
};
}
}

View File

@ -16,6 +16,7 @@
use App\Support\OperationRunOutcome;
use App\Support\OperationRunStatus;
use App\Support\OperationRunType;
use App\Support\Operations\Actionability\OperationRunActionabilityResolver;
use App\Support\Operations\Reconciliation\OperationRunReconciliationAdapter;
use App\Support\Operations\Reconciliation\OperationRunReconciliationRegistry;
use App\Support\Operations\Reconciliation\ReconciliationResult;
@ -29,6 +30,7 @@ public function __construct(
private readonly OperationRunCapabilityResolver $capabilityResolver,
private readonly CapabilityResolver $tenantCapabilities,
private readonly WorkspaceCapabilityResolver $workspaceCapabilities,
private readonly OperationRunActionabilityResolver $actionabilityResolver,
) {}
/**
@ -52,6 +54,7 @@ public function forRun(OperationRun $run, ?User $user = null): array
$highRisk = $this->isHighRisk($canonicalType);
$disabledReasons = [];
$secondaryActions = [];
$actionability = $this->actionabilityResolver->evaluate($run);
if ($user instanceof User && Gate::forUser($user)->inspect('view', $run)->denied()) {
return $this->result(
@ -69,6 +72,32 @@ public function forRun(OperationRun $run, ?User $user = null): array
);
}
if ($run->problemClass() === OperationRun::PROBLEM_CLASS_TERMINAL_FOLLOW_UP
&& ! $actionability->requiresCurrentFollowUp()) {
$disabledReasons['reconcile'] = __('localization.operations.actions.disabled.no_current_follow_up');
$disabledReasons['retry'] = __('localization.operations.actions.disabled.no_current_follow_up');
$resolvedArtifactPrimary = $actionability->resolvingModelType !== null
&& $actionability->resolvingModelType !== 'provider_connection'
? $this->primaryRelatedAction($run)
: null;
if ($this->canViewDiagnostics($run, $user)) {
$secondaryActions[] = $this->diagnosticsAction();
} else {
$disabledReasons['open_support_diagnostics'] = __('localization.operations.actions.disabled.missing_diagnostics_capability');
}
return $this->result(
primaryAction: $resolvedArtifactPrimary ?? $this->viewDetailsAction($run),
secondaryActions: $secondaryActions,
disabledReasons: $disabledReasons,
attentionReason: $actionability->explanation,
mutationScope: null,
highRisk: $highRisk,
freshnessState: $freshnessState->value,
);
}
$relatedPrimary = $this->primaryRelatedAction($run);
$canReconcile = $this->canOfferReconcile($run, $user);
$reconcileAction = $this->reconcileAction($run);
@ -100,7 +129,7 @@ public function forRun(OperationRun $run, ?User $user = null): array
primaryAction: $primaryAction,
secondaryActions: $secondaryActions,
disabledReasons: $disabledReasons,
attentionReason: $this->attentionReason($run, $primaryAction, $retryReason),
attentionReason: $this->attentionReason($run, $primaryAction, $retryReason, $actionability->explanation),
mutationScope: $primaryAction['mutation_scope'] ?? null,
highRisk: $highRisk,
freshnessState: $freshnessState->value,
@ -419,8 +448,14 @@ private function viewDetailsAction(OperationRun $run): array
/**
* @param array<string, mixed>|null $primaryAction
*/
private function attentionReason(OperationRun $run, ?array $primaryAction, string $retryReason): string
private function attentionReason(OperationRun $run, ?array $primaryAction, string $retryReason, ?string $actionabilityExplanation = null): string
{
if ($run->problemClass() === OperationRun::PROBLEM_CLASS_TERMINAL_FOLLOW_UP
&& is_string($actionabilityExplanation)
&& trim($actionabilityExplanation) !== '') {
return $actionabilityExplanation;
}
if ($run->isLifecycleReconciled() && $run->reconciledRelatedType() !== null) {
return __('localization.operations.actions.attention.related_available');
}

View File

@ -4,8 +4,8 @@
namespace App\Support\OpsUx;
use App\Models\OperationRun;
use App\Models\ManagedEnvironment;
use App\Models\OperationRun;
use App\Support\OperationRunOutcome;
use App\Support\OperationRunStatus;
use Illuminate\Database\Eloquent\Builder;
@ -38,21 +38,14 @@ public static function shellVisibleQueryForTenantId(?int $tenantId): Builder
->where('outcome', OperationRunOutcome::Succeeded->value)
->whereNotNull('completed_at')
->where('completed_at', '>=', now()->subSeconds(self::TERMINAL_SUCCESS_GRACE_SECONDS));
})
->orWhere(function (Builder $followUpQuery): void {
$followUpQuery->terminalFollowUp();
});
})
->orderByRaw(
'case when status in (?, ?) then 0 when status = ? and outcome in (?, ?, ?) then 1 when status = ? and outcome = ? then 2 else 3 end',
'case when status in (?, ?) then 0 when status = ? and outcome = ? then 1 else 2 end',
[
OperationRunStatus::Queued->value,
OperationRunStatus::Running->value,
OperationRunStatus::Completed->value,
OperationRunOutcome::Blocked->value,
OperationRunOutcome::PartiallySucceeded->value,
OperationRunOutcome::Failed->value,
OperationRunStatus::Completed->value,
OperationRunOutcome::Succeeded->value,
],
)

View File

@ -7,11 +7,12 @@
use App\Filament\Resources\FindingResource;
use App\Models\AlertRule;
use App\Models\Finding;
use App\Models\ManagedEnvironment;
use App\Models\OperationRun;
use App\Models\PlatformUser;
use App\Models\ManagedEnvironment;
use App\Support\OperationCatalog;
use App\Support\OperationRunLinks;
use App\Support\Operations\Actionability\OperationRunActionabilityResolver;
use App\Support\Operations\OperationRunFreshnessState;
use App\Support\ReasonTranslation\ReasonPresenter;
use App\Support\ReasonTranslation\ReasonResolutionEnvelope;
@ -313,9 +314,16 @@ public static function problemClass(OperationRun $run): string
public static function problemClassLabel(OperationRun $run): ?string
{
if (self::problemClass($run) === OperationRun::PROBLEM_CLASS_TERMINAL_FOLLOW_UP) {
$actionability = app(OperationRunActionabilityResolver::class)->evaluate($run);
return $actionability->requiresCurrentFollowUp()
? 'Terminal follow-up'
: $actionability->status->label();
}
return match (self::problemClass($run)) {
OperationRun::PROBLEM_CLASS_ACTIVE_STALE_ATTENTION => 'Likely stale active run',
OperationRun::PROBLEM_CLASS_TERMINAL_FOLLOW_UP => 'Terminal follow-up',
default => null,
};
}
@ -339,12 +347,19 @@ public static function staleLineageNote(OperationRun $run): ?string
* isReconciled:bool,
* staleLineageNote:?string,
* primaryNextAction:string,
* attentionNote:?string
* attentionNote:?string,
* actionabilityStatus:?string,
* actionabilityReasonCode:?string,
* actionabilityExplanation:?string,
* requiresCurrentFollowUp:bool
* }
*/
public static function decisionZoneTruth(OperationRun $run): array
{
$freshnessState = self::freshnessState($run);
$actionability = self::problemClass($run) === OperationRun::PROBLEM_CLASS_TERMINAL_FOLLOW_UP
? app(OperationRunActionabilityResolver::class)->evaluate($run)
: null;
return [
'freshnessState' => $freshnessState->value,
@ -356,16 +371,25 @@ public static function decisionZoneTruth(OperationRun $run): array
'staleLineageNote' => self::staleLineageNote($run),
'primaryNextAction' => self::surfaceGuidance($run) ?? 'No action needed.',
'attentionNote' => self::decisionAttentionNote($run),
'actionabilityStatus' => $actionability?->status->value,
'actionabilityReasonCode' => $actionability?->reasonCode,
'actionabilityExplanation' => $actionability?->explanation,
'requiresCurrentFollowUp' => $actionability?->requiresCurrentFollowUp() ?? false,
];
}
public static function decisionAttentionNote(OperationRun $run): ?string
{
if (self::problemClass($run) === OperationRun::PROBLEM_CLASS_TERMINAL_FOLLOW_UP) {
$actionability = app(OperationRunActionabilityResolver::class)->evaluate($run);
return $actionability->requiresCurrentFollowUp()
? 'Still active: No. Current follow-up: Yes. '.$actionability->explanation
: 'Still active: No. Current follow-up: No. '.$actionability->explanation;
}
return match (self::problemClass($run)) {
OperationRun::PROBLEM_CLASS_ACTIVE_STALE_ATTENTION => 'Still active: Yes. Automatic reconciliation: No. This run is past its lifecycle window and needs stale-run investigation before retrying.',
OperationRun::PROBLEM_CLASS_TERMINAL_FOLLOW_UP => $run->hasStaleLineage()
? 'Still active: No. Automatic reconciliation: Yes. This terminal failure preserves stale-run lineage so operators can recover why the run stopped.'
: 'Still active: No. Automatic reconciliation: No. This run is terminal and still needs follow-up.',
default => null,
};
}
@ -391,12 +415,18 @@ public static function lifecycleAttentionSummaryFresh(OperationRun $run): ?strin
private static function buildLifecycleAttentionSummary(OperationRun $run): ?string
{
if (self::problemClass($run) === OperationRun::PROBLEM_CLASS_TERMINAL_FOLLOW_UP) {
$actionability = app(OperationRunActionabilityResolver::class)->evaluate($run);
return $actionability->requiresCurrentFollowUp()
? 'Terminal follow-up'
: $actionability->status->label();
}
return match (self::freshnessState($run)) {
OperationRunFreshnessState::LikelyStale => 'Likely stale',
OperationRunFreshnessState::ReconciledFailed => 'Automatically reconciled',
default => self::problemClass($run) === OperationRun::PROBLEM_CLASS_TERMINAL_FOLLOW_UP
? 'Terminal follow-up'
: null,
default => null,
};
}

View File

@ -12,6 +12,7 @@
use App\Services\Providers\ProviderConnectionResolver;
use App\Support\Links\RequiredPermissionsLinks;
use App\Support\ManagedEnvironmentLinks;
use App\Support\Navigation\CanonicalNavigationContext;
use App\Support\OperationRunLinks;
use App\Support\Providers\ProviderConsentStatus;
use App\Support\Providers\ProviderReasonCodes;
@ -701,7 +702,7 @@ private function verificationOperationAction(OperationRun $run, ManagedEnvironme
{
return [
'label' => __('localization.provider_guidance.action_open_verification_operation'),
'url' => OperationRunLinks::view($run, $environment),
'url' => OperationRunLinks::view($run, $environment, CanonicalNavigationContext::fromRequest(request())),
'action_name' => null,
'external' => false,
'disabled' => false,

View File

@ -370,7 +370,7 @@ private function tenantContexts(Collection $accessibleTenants, int $workspaceId,
$workspaceId,
$accessibleTenantIds,
)
->terminalFollowUp()
->currentTerminalFollowUp()
->selectRaw('managed_environment_id, count(*) as aggregate_count')
->groupBy('managed_environment_id')
->pluck('aggregate_count', 'managed_environment_id')

View File

@ -1291,6 +1291,16 @@
],
],
'operations' => [
'actionability' => [
'status' => [
'actionable' => 'Aktuelles Follow-up',
'requires_manual_review' => 'Manuelle Prüfung erforderlich',
'superseded_by_later_success' => 'Durch späteren Erfolg ersetzt',
'resolved_by_current_state' => 'Durch aktuellen Zustand gelöst',
'informational_only' => 'Nur Historie',
'not_terminal' => 'Kein terminales Follow-up',
],
],
'actions' => [
'view_details' => 'Details anzeigen',
'view_review' => 'Review anzeigen',
@ -1331,6 +1341,7 @@
'lifecycle_fresh' => 'Die Operation liegt noch im erwarteten Lebenszyklusfenster.',
'missing_capability' => 'Ihnen fehlt die erforderliche Berechtigung für diese Operator-Aktion.',
'missing_diagnostics_capability' => 'Support-Diagnosen erfordern die Support-Diagnose-Berechtigung.',
'no_current_follow_up' => 'Current-State-Prüfungen zeigen, dass dieser terminale Lauf kein Operator-Follow-up mehr benötigt.',
'retry_deferred' => 'Retry ist nicht verfügbar, weil für diese Operationsfamilie kein sicherer repo-verifizierter Retry-Seam existiert.',
'scope_unavailable' => 'Die Operation liegt außerhalb des aktuellen Benutzerkontexts.',
'terminal_run' => 'Terminale Läufe können über diese Aktion nicht abgeglichen werden.',

View File

@ -1291,6 +1291,16 @@
],
],
'operations' => [
'actionability' => [
'status' => [
'actionable' => 'Current follow-up',
'requires_manual_review' => 'Manual review required',
'superseded_by_later_success' => 'Superseded by later success',
'resolved_by_current_state' => 'Resolved by current state',
'informational_only' => 'Historical only',
'not_terminal' => 'No terminal follow-up',
],
],
'actions' => [
'view_details' => 'View details',
'view_review' => 'View review',
@ -1331,6 +1341,7 @@
'lifecycle_fresh' => 'The operation is still within its expected lifecycle window.',
'missing_capability' => 'You do not have the capability required for this operator action.',
'missing_diagnostics_capability' => 'Support diagnostics require the support diagnostics capability.',
'no_current_follow_up' => 'Current-state checks show this terminal run no longer needs operator follow-up.',
'retry_deferred' => 'Retry is unavailable because no safe repo-verified retry seam exists for this operation family.',
'scope_unavailable' => 'The operation is outside the current user scope.',
'terminal_run' => 'Terminal runs cannot be reconciled from this action.',

View File

@ -0,0 +1,71 @@
<?php
declare(strict_types=1);
use App\Filament\Pages\EnvironmentDashboard;
use App\Models\ManagedEnvironment;
use App\Models\OperationRun;
use App\Models\ProviderConnection;
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(20_000);
it('smokes current OperationRun actionability on dashboard and Operations surfaces in Spec367', function (): void {
$tenant = ManagedEnvironment::factory()->create(['name' => 'Spec367 Actionability Environment']);
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner');
$connection = ProviderConnection::factory()->verifiedHealthy()->create([
'workspace_id' => (int) $tenant->workspace_id,
'managed_environment_id' => (int) $tenant->getKey(),
'provider' => 'microsoft',
]);
OperationRun::factory()->forTenant($tenant)->create([
'type' => 'provider.connection.check',
'status' => OperationRunStatus::Completed->value,
'outcome' => OperationRunOutcome::Blocked->value,
'initiator_name' => 'Resolved provider blocker hidden from current follow-up',
'completed_at' => now()->subHour(),
'context' => [
'provider' => 'microsoft',
'provider_connection_id' => (int) $connection->getKey(),
],
]);
OperationRun::factory()->forTenant($tenant)->create([
'type' => 'policy.sync',
'status' => OperationRunStatus::Completed->value,
'outcome' => OperationRunOutcome::Failed->value,
'initiator_name' => 'Current policy failure visible for follow-up',
'completed_at' => now()->subMinutes(20),
]);
$this->actingAs($user)->withSession([
WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id,
]);
session()->put(WorkspaceContext::SESSION_KEY, (int) $tenant->workspace_id);
visit(EnvironmentDashboard::getUrl(tenant: $tenant))
->waitForText('Operations needing attention')
->assertSee('1 operation needs follow-up')
->assertSee('Open operations hub')
->assertDontSee('Resolved provider blocker hidden from current follow-up')
->assertNoJavaScriptErrors()
->assertNoConsoleLogs();
visit(OperationRunLinks::index(
tenant: $tenant,
activeTab: OperationRun::PROBLEM_CLASS_TERMINAL_FOLLOW_UP,
problemClass: OperationRun::PROBLEM_CLASS_TERMINAL_FOLLOW_UP,
))
->waitForText('Current policy failure visible for follow-up')
->assertSee('Current policy failure visible for follow-up')
->assertDontSee('Resolved provider blocker hidden from current follow-up')
->assertNoJavaScriptErrors()
->assertNoConsoleLogs();
});

View File

@ -189,13 +189,13 @@ function makeHealthyBackupForRecoveryNeedsAttention(\App\Models\ManagedEnvironme
'assignments' => [],
]);
RestoreRun::factory()
RestoreRun::withoutEvents(fn (): RestoreRun => RestoreRun::factory()
->for($tenant)
->for($healthyBackup)
->completedOutcome()
->create([
'completed_at' => now()->subMinutes(10),
]);
]));
OperationRun::factory()->create([
'managed_environment_id' => (int) $tenant->getKey(),
@ -409,9 +409,9 @@ function makeHealthyBackupForRecoveryNeedsAttention(\App\Models\ManagedEnvironme
Livewire::test(NeedsAttention::class)
->assertSee('Active operations look stale')
->assertSee('Terminal operations need follow-up')
->assertSee('Operations need current follow-up')
->assertSee('Open stale operations')
->assertSee('Open terminal follow-up')
->assertSee('Open current follow-up')
->assertDontSee('Current governance and findings signals look trustworthy.');
});
@ -553,13 +553,13 @@ function makeHealthyBackupForRecoveryNeedsAttention(\App\Models\ManagedEnvironme
'assignments' => [],
]);
RestoreRun::factory()
RestoreRun::withoutEvents(fn (): RestoreRun => RestoreRun::factory()
->for($tenant)
->for($healthyBackup)
->completedOutcome()
->create([
'completed_at' => now()->subMinutes(10),
]);
]));
OperationRun::factory()->create([
'managed_environment_id' => (int) $tenant->getKey(),
@ -691,13 +691,13 @@ function makeHealthyBackupForRecoveryNeedsAttention(\App\Models\ManagedEnvironme
'name' => 'Calm recovery restore backup',
]);
RestoreRun::factory()
RestoreRun::withoutEvents(fn (): RestoreRun => RestoreRun::factory()
->for($tenant)
->for($restoreBackupSet)
->completedOutcome()
->create([
'completed_at' => now()->subMinutes(10),
]);
]));
setAdminPanelContext($tenant);

View File

@ -2,8 +2,8 @@
declare(strict_types=1);
use App\Models\OperationRun;
use App\Models\ManagedEnvironment;
use App\Models\OperationRun;
use App\Support\Workspaces\WorkspaceContext;
it('shows only recent operations from the current users authorized tenant slice and does not enable polling', function (): void {
@ -83,6 +83,6 @@
->assertOk()
->assertSee('Diagnostic recency across your visible workspace slice. This does not define governance health on its own.')
->assertSee('Likely stale')
->assertSee('Automatically reconciled')
->assertSee('Terminal follow-up')
->assertDontSee('Visible governance, findings, compare posture, and activity currently look calm.');
});

View File

@ -0,0 +1,32 @@
<?php
declare(strict_types=1);
it('keeps current UI consumers off historical terminal follow-up queries in Spec367', function (): void {
$repoRoot = repo_root();
$currentConsumerFiles = [
'apps/platform/app/Filament/Widgets/Dashboard/NeedsAttention.php',
'apps/platform/app/Filament/Pages/Monitoring/Operations.php',
'apps/platform/app/Support/GovernanceInbox/GovernanceInboxSectionBuilder.php',
'apps/platform/app/Support/Workspaces/WorkspaceOverviewBuilder.php',
'apps/platform/app/Support/EnvironmentDashboard/EnvironmentDashboardSummaryBuilder.php',
'apps/platform/app/Support/OpsUx/ActiveRuns.php',
];
foreach ($currentConsumerFiles as $relativePath) {
$contents = file_get_contents($repoRoot.DIRECTORY_SEPARATOR.$relativePath);
expect($contents)->not->toContain('->terminalFollowUp(');
}
});
it('keeps dashboard follow-up and operator review helpers backed by derived actionability in Spec367', function (): void {
$repoRoot = repo_root();
$operationRunModel = file_get_contents($repoRoot.'/apps/platform/app/Models/OperationRun.php');
$presenter = file_get_contents($repoRoot.'/apps/platform/app/Support/OpsUx/OperationUxPresenter.php');
expect($operationRunModel)->toContain('currentTerminalFollowUp()')
->and($operationRunModel)->toContain('OperationRunActionabilityResolver::class')
->and($presenter)->toContain('OperationRunActionabilityResolver::class');
});

View File

@ -5,8 +5,14 @@
use App\Filament\Resources\BackupSetResource;
use App\Models\BackupSet;
use App\Models\OperationRun;
use App\Models\ProviderConnection;
use App\Support\ManagedEnvironmentLinks;
use App\Support\Navigation\CanonicalNavigationContext;
use App\Support\OperationRunLinks;
use App\Support\OperationRunOutcome;
use App\Support\OperationRunStatus;
use App\Support\Providers\ProviderConsentStatus;
use App\Support\Providers\ProviderVerificationStatus;
use App\Support\Workspaces\WorkspaceContext;
it('uses explicit back-link lineage on canonical run detail', function (): void {
@ -44,3 +50,110 @@
->assertSee('Related context')
->assertSee($backupSet->name);
});
it('preserves workspace overview lineage through operations row detail links', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'owner');
$run = OperationRun::factory()->for($tenant)->create([
'workspace_id' => (int) $tenant->workspace_id,
'type' => 'provider.connection.check',
'status' => OperationRunStatus::Completed->value,
'outcome' => OperationRunOutcome::Blocked->value,
'initiator_name' => 'Context preserving run',
]);
$overviewContext = new CanonicalNavigationContext(
sourceSurface: 'workspace.overview',
canonicalRouteName: 'admin.home',
backLinkLabel: 'Back to overview',
backLinkUrl: route('admin.home'),
);
$operationsUrl = OperationRunLinks::index(
context: $overviewContext,
workspace: $tenant->workspace,
);
$expectedDetailUrl = OperationRunLinks::viewFromOperationsIndex($run, $operationsUrl);
$response = $this->actingAs($user)
->withSession([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id])
->get($operationsUrl)
->assertOk()
->assertSee('Back to overview');
$html = html_entity_decode((string) $response->getContent(), ENT_QUOTES | ENT_HTML5);
expect($html)
->toContain($expectedDetailUrl)
->toContain('Back%20to%20Operations');
});
it('preserves operations lineage through provider connection verification detours', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'owner');
$connection = ProviderConnection::factory()->platform()->create([
'workspace_id' => (int) $tenant->workspace_id,
'managed_environment_id' => (int) $tenant->getKey(),
'is_default' => true,
'consent_status' => ProviderConsentStatus::Required->value,
'verification_status' => ProviderVerificationStatus::Unknown->value,
]);
$run = OperationRun::factory()->for($tenant)->create([
'workspace_id' => (int) $tenant->workspace_id,
'type' => 'provider.connection.check',
'status' => OperationRunStatus::Completed->value,
'outcome' => OperationRunOutcome::Blocked->value,
'context' => [
'provider_connection_id' => (int) $connection->getKey(),
],
]);
$overviewContext = new CanonicalNavigationContext(
sourceSurface: 'workspace.overview',
canonicalRouteName: 'admin.home',
backLinkLabel: 'Back to overview',
backLinkUrl: route('admin.home'),
);
$operationsUrl = OperationRunLinks::index(
context: $overviewContext,
workspace: $tenant->workspace,
);
$operationsContext = OperationRunLinks::operationsIndexNavigationContext($run, $operationsUrl);
$expectedProviderUrl = OperationRunLinks::withNavigationContext(
ManagedEnvironmentLinks::providerConnectionsUrl($tenant),
$operationsContext,
);
$expectedVerificationUrl = OperationRunLinks::view($run, $tenant, $operationsContext);
$detailResponse = $this->actingAs($user)
->withSession([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id])
->get(OperationRunLinks::tenantlessView($run, $operationsContext))
->assertOk()
->assertSee('Back to Operations');
expect(html_entity_decode((string) $detailResponse->getContent(), ENT_QUOTES | ENT_HTML5))
->toContain($expectedProviderUrl);
$providerResponse = $this->actingAs($user)
->withSession([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id])
->get($expectedProviderUrl)
->assertOk()
->assertSee('Provider consent required')
->assertSee('Open verification operation');
expect(html_entity_decode((string) $providerResponse->getContent(), ENT_QUOTES | ENT_HTML5))
->toContain($expectedVerificationUrl);
$verificationResponse = $this->actingAs($user)
->withSession([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id])
->get($expectedVerificationUrl)
->assertOk()
->assertSee('Back to Operations');
expect(html_entity_decode((string) $verificationResponse->getContent(), ENT_QUOTES | ENT_HTML5))
->toContain($operationsUrl);
});

View File

@ -0,0 +1,97 @@
<?php
declare(strict_types=1);
use App\Filament\Pages\Monitoring\Operations;
use App\Filament\Widgets\Dashboard\NeedsAttention;
use App\Models\ManagedEnvironment;
use App\Models\OperationRun;
use App\Models\ProviderConnection;
use App\Support\OperationRunOutcome;
use App\Support\OperationRunStatus;
use App\Support\Workspaces\WorkspaceContext;
use Livewire\Livewire;
it('hides resolved provider blockers from the current terminal follow-up Operations tab in Spec367', function (): void {
bindFailHardGraphClient();
$tenant = ManagedEnvironment::factory()->create();
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner');
$connection = ProviderConnection::factory()->verifiedHealthy()->create([
'workspace_id' => (int) $tenant->workspace_id,
'managed_environment_id' => (int) $tenant->getKey(),
'provider' => 'microsoft',
]);
$resolvedProviderBlocker = OperationRun::factory()->forTenant($tenant)->create([
'type' => 'provider.connection.check',
'status' => OperationRunStatus::Completed->value,
'outcome' => OperationRunOutcome::Blocked->value,
'initiator_name' => 'Resolved provider blocker',
'completed_at' => now()->subHour(),
'context' => [
'provider' => 'microsoft',
'provider_connection_id' => (int) $connection->getKey(),
],
]);
$currentPolicyFailure = OperationRun::factory()->forTenant($tenant)->create([
'type' => 'policy.sync',
'status' => OperationRunStatus::Completed->value,
'outcome' => OperationRunOutcome::Failed->value,
'initiator_name' => 'Current policy failure',
'completed_at' => now()->subMinutes(30),
]);
$this->actingAs($user);
setAdminPanelContext($tenant);
session()->put(WorkspaceContext::SESSION_KEY, (int) $tenant->workspace_id);
Livewire::actingAs($user)
->test(Operations::class)
->filterTable('managed_environment_id', (string) $tenant->getKey())
->set('activeTab', OperationRun::PROBLEM_CLASS_TERMINAL_FOLLOW_UP)
->assertCanSeeTableRecords([$currentPolicyFailure])
->assertCanNotSeeTableRecords([$resolvedProviderBlocker]);
});
it('shows dashboard operation attention only for current actionability in Spec367', function (): void {
$tenant = ManagedEnvironment::factory()->create();
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner');
$connection = ProviderConnection::factory()->verifiedHealthy()->create([
'workspace_id' => (int) $tenant->workspace_id,
'managed_environment_id' => (int) $tenant->getKey(),
'provider' => 'microsoft',
]);
OperationRun::factory()->forTenant($tenant)->create([
'type' => 'provider.connection.check',
'status' => OperationRunStatus::Completed->value,
'outcome' => OperationRunOutcome::Blocked->value,
'completed_at' => now()->subHour(),
'context' => [
'provider' => 'microsoft',
'provider_connection_id' => (int) $connection->getKey(),
],
]);
$this->actingAs($user);
setAdminPanelContext($tenant);
session()->put(WorkspaceContext::SESSION_KEY, (int) $tenant->workspace_id);
Livewire::actingAs($user)
->test(NeedsAttention::class)
->assertDontSee('Operations need current follow-up');
OperationRun::factory()->forTenant($tenant)->create([
'type' => 'policy.sync',
'status' => OperationRunStatus::Completed->value,
'outcome' => OperationRunOutcome::Failed->value,
'completed_at' => now()->subMinutes(30),
]);
Livewire::actingAs($user)
->test(NeedsAttention::class)
->assertSee('Operations need current follow-up')
->assertSee('Open current follow-up');
});

View File

@ -0,0 +1,238 @@
<?php
declare(strict_types=1);
use App\Models\BackupSet;
use App\Models\ManagedEnvironment;
use App\Models\OperationRun;
use App\Models\ProviderConnection;
use App\Support\OperationRunOutcome;
use App\Support\OperationRunStatus;
use App\Support\Operations\Actionability\OperationRunActionabilityResolver;
use App\Support\Operations\Actionability\OperationRunActionabilityStatus;
it('resolves old provider connection blockers when the same connection is currently healthy in Spec367', function (): void {
$tenant = ManagedEnvironment::factory()->create();
$connection = ProviderConnection::factory()->verifiedHealthy()->create([
'workspace_id' => (int) $tenant->workspace_id,
'managed_environment_id' => (int) $tenant->getKey(),
'provider' => 'microsoft',
]);
$run = spec367TerminalRun($tenant, [
'type' => 'provider.connection.check',
'outcome' => OperationRunOutcome::Blocked->value,
'context' => [
'provider' => 'microsoft',
'provider_connection_id' => (int) $connection->getKey(),
],
]);
$result = app(OperationRunActionabilityResolver::class)->evaluate($run->fresh());
expect($result->status)->toBe(OperationRunActionabilityStatus::ResolvedByCurrentState)
->and($result->requiresCurrentFollowUp())->toBeFalse()
->and($result->resolvingModelType)->toBe('provider_connection')
->and(OperationRun::query()->whereKey($run->getKey())->terminalFollowUp()->exists())->toBeTrue()
->and(OperationRun::query()->whereKey($run->getKey())->currentTerminalFollowUp()->exists())->toBeFalse();
});
it('supersedes old provider blockers with later same-scope successful checks in Spec367', function (): void {
$tenant = ManagedEnvironment::factory()->create();
$connection = ProviderConnection::factory()->create([
'workspace_id' => (int) $tenant->workspace_id,
'managed_environment_id' => (int) $tenant->getKey(),
'provider' => 'microsoft',
]);
$oldRun = spec367TerminalRun($tenant, [
'type' => 'provider.connection.check',
'outcome' => OperationRunOutcome::Failed->value,
'completed_at' => now()->subHours(2),
'created_at' => now()->subHours(2),
'context' => [
'provider' => 'microsoft',
'provider_connection_id' => (int) $connection->getKey(),
],
]);
$laterRun = spec367TerminalRun($tenant, [
'type' => 'provider.connection.check',
'outcome' => OperationRunOutcome::Succeeded->value,
'completed_at' => now()->subHour(),
'created_at' => now()->subHour(),
'context' => [
'provider' => 'microsoft',
'provider_connection_id' => (int) $connection->getKey(),
],
]);
$result = app(OperationRunActionabilityResolver::class)->evaluate($oldRun->fresh());
expect($result->status)->toBe(OperationRunActionabilityStatus::SupersededByLaterSuccess)
->and($result->supersedingRunId)->toBe((int) $laterRun->getKey())
->and($result->requiresCurrentFollowUp())->toBeFalse();
});
it('does not supersede repeatable runs from a different scope or correlation in Spec367', function (): void {
$tenant = ManagedEnvironment::factory()->create();
$otherTenant = ManagedEnvironment::factory()->create([
'workspace_id' => (int) $tenant->workspace_id,
]);
$oldRun = spec367TerminalRun($tenant, [
'type' => 'inventory.sync',
'outcome' => OperationRunOutcome::Failed->value,
'completed_at' => now()->subHours(2),
'created_at' => now()->subHours(2),
'context' => ['selection_hash' => 'selected-family-a'],
]);
spec367TerminalRun($otherTenant, [
'type' => 'inventory.sync',
'outcome' => OperationRunOutcome::Succeeded->value,
'completed_at' => now()->subHour(),
'created_at' => now()->subHour(),
'context' => ['selection_hash' => 'selected-family-a'],
]);
spec367TerminalRun($tenant, [
'type' => 'inventory.sync',
'outcome' => OperationRunOutcome::Succeeded->value,
'completed_at' => now()->subMinutes(30),
'created_at' => now()->subMinutes(30),
'context' => ['selection_hash' => 'selected-family-b'],
]);
$result = app(OperationRunActionabilityResolver::class)->evaluate($oldRun->fresh());
expect($result->status)->toBe(OperationRunActionabilityStatus::Actionable)
->and($result->requiresCurrentFollowUp())->toBeTrue();
});
it('supersedes repeatable runs only with later same-scope success in Spec367', function (): void {
$tenant = ManagedEnvironment::factory()->create();
$oldRun = spec367TerminalRun($tenant, [
'type' => 'inventory.sync',
'outcome' => OperationRunOutcome::PartiallySucceeded->value,
'completed_at' => now()->subHours(2),
'created_at' => now()->subHours(2),
'context' => ['selection_hash' => 'selected-family-a'],
]);
$laterRun = spec367TerminalRun($tenant, [
'type' => 'inventory.sync',
'outcome' => OperationRunOutcome::Succeeded->value,
'completed_at' => now()->subHour(),
'created_at' => now()->subHour(),
'context' => ['selection_hash' => 'selected-family-a'],
]);
$result = app(OperationRunActionabilityResolver::class)->evaluate($oldRun->fresh());
expect($result->status)->toBe(OperationRunActionabilityStatus::SupersededByLaterSuccess)
->and($result->supersedingRunId)->toBe((int) $laterRun->getKey())
->and(OperationRun::query()->whereKey($oldRun->getKey())->currentTerminalFollowUp()->exists())->toBeFalse();
});
it('uses repository artifact proof to resolve terminal backup update history in Spec367', function (): void {
$tenant = ManagedEnvironment::factory()->create();
$backupSet = BackupSet::factory()->for($tenant)->recentCompleted()->create([
'workspace_id' => (int) $tenant->workspace_id,
]);
$run = spec367TerminalRun($tenant, [
'type' => 'backup_set.update',
'outcome' => OperationRunOutcome::Blocked->value,
'context' => [
'backup_set_id' => (int) $backupSet->getKey(),
],
]);
$result = app(OperationRunActionabilityResolver::class)->evaluate($run->fresh());
expect($result->status)->toBe(OperationRunActionabilityStatus::ResolvedByCurrentState)
->and($result->resolvingModelType)->toBe('backup_set')
->and($result->resolvingModelId)->toBe((int) $backupSet->getKey())
->and($result->requiresCurrentFollowUp())->toBeFalse();
});
it('keeps high-risk terminal operations in manual review even when later runs succeed in Spec367', function (): void {
$tenant = ManagedEnvironment::factory()->create();
$oldRun = spec367TerminalRun($tenant, [
'type' => 'restore.execute',
'outcome' => OperationRunOutcome::Failed->value,
'completed_at' => now()->subHours(2),
'created_at' => now()->subHours(2),
]);
spec367TerminalRun($tenant, [
'type' => 'restore.execute',
'outcome' => OperationRunOutcome::Succeeded->value,
'completed_at' => now()->subHour(),
'created_at' => now()->subHour(),
]);
$result = app(OperationRunActionabilityResolver::class)->evaluate($oldRun->fresh());
expect($result->status)->toBe(OperationRunActionabilityStatus::RequiresManualReview)
->and($result->requiresCurrentFollowUp())->toBeTrue()
->and(OperationRun::query()->whereKey($oldRun->getKey())->currentTerminalFollowUp()->exists())->toBeTrue();
});
it('keeps reconciled successful terminal history out of current follow-up in Spec367', function (): void {
$tenant = ManagedEnvironment::factory()->create();
$run = spec367TerminalRun($tenant, [
'type' => 'restore.execute',
'outcome' => OperationRunOutcome::Succeeded->value,
'context' => [
'reconciliation' => [
'reconciled_at' => now()->toIso8601String(),
'decision' => 'completed',
'source' => 'restore_run_observer',
],
],
]);
$result = app(OperationRunActionabilityResolver::class)->evaluate($run->fresh());
expect($result->status)->toBe(OperationRunActionabilityStatus::NotTerminal)
->and($result->requiresCurrentFollowUp())->toBeFalse()
->and(OperationRun::query()->whereKey($run->getKey())->terminalFollowUp()->exists())->toBeTrue()
->and(OperationRun::query()->whereKey($run->getKey())->currentTerminalFollowUp()->exists())->toBeFalse();
});
it('fails closed for unknown terminal operation types in Spec367', function (): void {
$tenant = ManagedEnvironment::factory()->create();
$run = spec367TerminalRun($tenant, [
'type' => 'unknown.operation',
'outcome' => OperationRunOutcome::Failed->value,
]);
$result = app(OperationRunActionabilityResolver::class)->evaluate($run->fresh());
expect($result->status)->toBe(OperationRunActionabilityStatus::RequiresManualReview)
->and($result->reasonCode)->toBe('unknown_operation_type')
->and($result->requiresCurrentFollowUp())->toBeTrue();
});
/**
* @param array<string, mixed> $attributes
*/
function spec367TerminalRun(ManagedEnvironment $tenant, array $attributes = []): OperationRun
{
$completedAt = $attributes['completed_at'] ?? now()->subMinutes(5);
return OperationRun::factory()->forTenant($tenant)->create(array_replace([
'type' => 'policy.sync',
'status' => OperationRunStatus::Completed->value,
'outcome' => OperationRunOutcome::Failed->value,
'created_at' => $completedAt,
'completed_at' => $completedAt,
'context' => [],
], $attributes));
}

View File

@ -825,8 +825,7 @@ function baselineCompareLandingLivewire(
ManagedEnvironment $tenant,
array $queryParams = [],
?\Illuminate\Contracts\Auth\Authenticatable $user = null,
): mixed
{
): mixed {
$manager = \Livewire\Livewire::withHeaders([
'Referer' => \App\Support\ManagedEnvironmentLinks::baselineCompareUrl($tenant),
]);
@ -971,12 +970,21 @@ function workspaceOverviewSeedRestoreHistory(
default => RestoreRun::factory()->completedOutcome(),
};
$attributes = array_merge([
'completed_at' => now()->subMinutes(10),
], $attributes);
if ($state === 'completed') {
return RestoreRun::withoutEvents(fn (): RestoreRun => $factory
->for($tenant)
->for($backupSet)
->create($attributes));
}
return $factory
->for($tenant)
->for($backupSet)
->create(array_merge([
'completed_at' => now()->subMinutes(10),
], $attributes));
->create($attributes);
}
/**
@ -1411,7 +1419,7 @@ function markEnvironmentReviewCustomerSafeReady(EnvironmentReview $review): Envi
])->save();
}
$review->sections->each(function (EnvironmentReviewSection $section) use ($controlSummary, $controlExplanation, $disclosure): void {
$review->sections->each(function (EnvironmentReviewSection $section) use ($controlExplanation, $disclosure): void {
$attributes = [
'completeness_state' => EnvironmentReviewCompletenessState::Complete->value,
];

View File

@ -0,0 +1,40 @@
<?php
declare(strict_types=1);
use App\Models\OperationRun;
use App\Models\ProviderConnection;
use App\Support\OperationRunOutcome;
use App\Support\OperationRunStatus;
use App\Support\Operations\OperationRunActionEligibility;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
it('keeps resolved provider blockers on run proof instead of provider connection CTAs in Spec367', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'owner');
$connection = ProviderConnection::factory()->verifiedHealthy()->create([
'workspace_id' => (int) $tenant->workspace_id,
'managed_environment_id' => (int) $tenant->getKey(),
'provider' => 'microsoft',
]);
$run = OperationRun::factory()->forTenant($tenant)->create([
'user_id' => (int) $user->getKey(),
'type' => 'provider.connection.check',
'status' => OperationRunStatus::Completed->value,
'outcome' => OperationRunOutcome::Blocked->value,
'completed_at' => now()->subHour(),
'context' => [
'provider' => 'microsoft',
'provider_connection_id' => (int) $connection->getKey(),
],
]);
$decision = app(OperationRunActionEligibility::class)->forRun($run->fresh(), $user);
expect(data_get($decision, 'primary_action.key'))->toBe('view_details')
->and($decision['disabled_reasons']['reconcile'])->toBe(__('localization.operations.actions.disabled.no_current_follow_up'))
->and($decision['disabled_reasons']['retry'])->toBe(__('localization.operations.actions.disabled.no_current_follow_up'))
->and($decision['attention_reason'])->toContain('currently enabled, consented, and healthy');
});

View File

@ -0,0 +1,25 @@
<?php
declare(strict_types=1);
use App\Support\OperationCatalog;
use App\Support\Operations\Actionability\OperationRunActionabilityRegistry;
it('covers every canonical operation type with an explicit actionability policy in Spec367', function (): void {
$registry = app(OperationRunActionabilityRegistry::class);
expect($registry->uncoveredCanonicalTypes())->toBe([])
->and($registry->coveredCanonicalTypes())->toContain(...array_keys(OperationCatalog::canonicalInventory()));
});
it('classifies high-risk and repeatable operation families deliberately in Spec367', function (): void {
$registry = app(OperationRunActionabilityRegistry::class);
expect($registry->forCanonicalType('provider.connection.check')?->kind)->toBe('provider_connection')
->and($registry->forCanonicalType('inventory.sync')?->kind)->toBe('repeatable')
->and($registry->forCanonicalType('policy.snapshot')?->kind)->toBe('repeatable')
->and($registry->forCanonicalType('alerts.deliver')?->kind)->toBe('repeatable')
->and($registry->forCanonicalType('baseline.capture')?->kind)->toBe('artifact_or_later_success')
->and($registry->forCanonicalType('restore.execute')?->kind)->toBe('manual_review')
->and($registry->forCanonicalType('backup.schedule.purge')?->kind)->toBe('manual_review');
});

View File

@ -0,0 +1,43 @@
<?php
declare(strict_types=1);
use App\Support\Operations\Actionability\OperationRunActionabilityResult;
use App\Support\Operations\Actionability\OperationRunActionabilityStatus;
it('exposes current-follow-up semantics and debug metadata in Spec367', function (): void {
$result = new OperationRunActionabilityResult(
status: OperationRunActionabilityStatus::SupersededByLaterSuccess,
reasonCode: 'later_same_scope_success',
explanation: 'A later same-scope successful run superseded this terminal run.',
supersedingRunId: 42,
policyIdentifier: 'repeatable_later_success_v1',
metadata: ['scope' => 'same-environment'],
runId: 24,
operationType: 'inventory.sync',
canonicalType: 'inventory.sync',
);
expect($result->requiresCurrentFollowUp())->toBeFalse()
->and($result->isActionable())->toBeFalse()
->and($result->toArray())->toMatchArray([
'status' => 'superseded_by_later_success',
'actionable' => false,
'reason_code' => 'later_same_scope_success',
'superseding_run_id' => 42,
'policy_identifier' => 'repeatable_later_success_v1',
'metadata' => ['scope' => 'same-environment'],
'run_id' => 24,
'operation_type' => 'inventory.sync',
'canonical_type' => 'inventory.sync',
]);
});
it('treats actionable and manual-review statuses as current follow-up in Spec367', function (): void {
expect(OperationRunActionabilityStatus::Actionable->requiresCurrentFollowUp())->toBeTrue()
->and(OperationRunActionabilityStatus::RequiresManualReview->requiresCurrentFollowUp())->toBeTrue()
->and(OperationRunActionabilityStatus::ResolvedByCurrentState->requiresCurrentFollowUp())->toBeFalse()
->and(OperationRunActionabilityStatus::SupersededByLaterSuccess->requiresCurrentFollowUp())->toBeFalse()
->and(OperationRunActionabilityStatus::InformationalOnly->requiresCurrentFollowUp())->toBeFalse()
->and(OperationRunActionabilityStatus::NotTerminal->requiresCurrentFollowUp())->toBeFalse();
});

View File

@ -0,0 +1,27 @@
# Browser Smoke Notes
Date: 2026-06-08
Command:
```bash
cd apps/platform && ./vendor/bin/sail php vendor/bin/pest tests/Browser/Spec367OperationRunActionabilitySmokeTest.php
```
Result: 1 passed, 11 assertions.
## Path
- Authenticated as an operations-capable user.
- Visited the environment dashboard via `EnvironmentDashboard::getUrl(tenant: $tenant)`.
- Visited the Operations page through `OperationRunLinks::index(...)` with the current terminal follow-up tab selected.
## Assertions
- The dashboard loaded without JavaScript/runtime errors.
- Operations needing attention were visible when a current terminal problem existed.
- A superseded Provider Connection blocker did not keep generating a current CTA.
- The Operations current terminal follow-up tab showed the current policy failure.
- Resolved or superseded terminal history stayed out of the current follow-up list.
- Workspace/tenant context remained scoped through the tested links.
No screenshot artifact was retained because the bounded browser proof is assertion-based and no screenshot deliverable was required.

View File

@ -0,0 +1,79 @@
# Requirements Checklist: OperationRun Actionability System v1
**Purpose**: Validate that Spec 367 is complete, bounded, constitution-aligned, and ready for implementation.
**Created**: 2026-06-08
**Feature**: `specs/367-operationrun-actionability-system/spec.md`
## Applicability And Low-Impact Gate
- [x] CHK001 The change explicitly says reachable operator-facing surfaces are affected.
- [x] CHK002 The spec, plan, and tasks carry forward the same monitoring/dashboard/shared-detail classification.
- [x] CHK003 UI Surface Impact is coherent and does not combine `No UI surface impact` with UI changes.
- [x] CHK004 UI/Productization Coverage names affected routes/pages/widgets and coverage-artifact decision.
- [x] CHK005 Navigation and Filament panel/provider changes are explicitly no-impact.
## Requirements Quality
- [x] CHK006 The spec states the operator problem and confirmed Provider Connection CTA loop.
- [x] CHK007 Functional requirements distinguish historical execution truth, current domain truth, and UI actionability truth.
- [x] CHK008 Acceptance criteria cover provider loop, repeatable superseding, high-risk manual review, history preservation, and unknown type coverage.
- [x] CHK009 Non-goals exclude manual acknowledge UI, persistence, historical rewrite, notification redesign, and destructive actions.
- [x] CHK010 Open questions do not block implementation; ProviderConnection proof fields are an implementation-verification task.
## Constitution And Proportionality
- [x] CHK011 The proportionality review is complete for the new derived status/resolver/registry layer.
- [x] CHK012 The spec explains why a provider-only local fix is insufficient.
- [x] CHK013 No new persisted truth is introduced.
- [x] CHK014 New derived statuses have clear behavioral consequences.
- [x] CHK015 High-risk restore/promotion/purge families fail closed as manual-review.
- [x] CHK016 LEAN-001 compatibility posture is explicit.
## Shared Pattern Reuse
- [x] CHK017 Existing `OperationCatalog` remains the operation-type source.
- [x] CHK018 Existing `OperationRunActionEligibility` is reused/aligned instead of replaced.
- [x] CHK019 Existing `OperationRunLinks`, `OperationUxPresenter`, and reconciliation registry are named as shared paths.
- [x] CHK020 The allowed new actionability layer is bounded and derived-only.
- [x] CHK021 The spec forbids a parallel UI action framework.
## OperationRun UX Contract
- [x] CHK022 The feature does not create new run-start, queued notification, browser event, or terminal notification behavior.
- [x] CHK023 Historical `OperationRun` status/outcome remains execution truth.
- [x] CHK024 Current dashboard follow-up truth is separate from historical terminal truth.
- [x] CHK025 OperationRun lifecycle state remains service-owned.
## RBAC, Isolation, And Provider Boundary
- [x] CHK026 Same-workspace and same-managed-environment proof is required for superseded/resolved outcomes.
- [x] CHK027 Cross-workspace and cross-environment proof is forbidden.
- [x] CHK028 Non-member leakage is explicitly guarded by existing RBAC and scoped evaluation.
- [x] CHK029 Provider-specific consent/health semantics are bounded to provider-connection policy.
- [x] CHK030 No Graph calls are allowed during actionability evaluation or render.
## Testing And Validation
- [x] CHK031 Unit, Feature, Guard, and optional Browser lanes are named.
- [x] CHK032 Tasks include failing tests before runtime changes where practical.
- [x] CHK033 Tasks include registry coverage for all known operation types.
- [x] CHK034 Tasks include guards against direct UI use of historical terminal-follow-up methods.
- [x] CHK035 Tasks include provider-loop, repeatable superseded, high-risk manual-review, cross-scope, and DB-only tests.
- [x] CHK036 Validation commands are explicit and bounded.
## Deployment / Ops
- [x] CHK037 No migrations are planned.
- [x] CHK038 No env vars are planned.
- [x] CHK039 No queues, workers, scheduler, storage, package, asset, or panel-provider changes are planned.
- [x] CHK040 Staging validation is noted because operator attention routing changes.
## Review Outcome
- [x] CHK041 Review outcome class: `acceptable-special-case`.
- [x] CHK042 Workflow outcome: `keep`.
- [x] CHK043 Final note location: implementation close-out / active PR close-out for UI coverage and guardrail proof.
## Notes
Spec 367 is ready for implementation prep-wise. The main implementation risk is over-aggressive superseding; the tasks deliberately default incomplete proof to actionable/manual-review.

View File

@ -0,0 +1,89 @@
# OperationRun Actionability Implementation Close-Out
Date: 2026-06-08
Branch: `367-operationrun-actionability-system`
## Summary
Implemented the Spec 367 read-time OperationRun actionability system. The new resolver classifies terminal operation history as current follow-up, manual review, superseded by later same-scope success, resolved by current state, informational, or not terminal. Dashboard, Operations, governance inbox, workspace overview, operation presenter, and action eligibility surfaces now use current actionability where current work is displayed while retaining historical run visibility.
## Gates
- Spec Readiness Gate: passed.
- Implementation Scope Gate: passed.
- Test Gate: passed.
- Browser Smoke Test Gate: passed.
- Post-Implementation Analysis Gate: passed.
- Merge Readiness Gate: passed for manual review, subject to normal reviewer approval.
## Validation
Focused Sail validation:
```bash
cd apps/platform && ./vendor/bin/sail php vendor/bin/pest \
tests/Unit/Support/Operations/Actionability \
tests/Feature/Operations/Spec367OperationRunActionabilityResolverTest.php \
tests/Feature/Monitoring/Spec367OperationsActionabilityUiTest.php \
tests/Feature/Guards/Spec367OperationRunActionabilityGuardTest.php \
tests/Browser/Spec367OperationRunActionabilitySmokeTest.php \
tests/Unit/Support/Operations/Spec365OperationRunActionEligibilityTest.php \
tests/Unit/Support/Operations/Spec365OperationRunPrimaryActionTest.php \
tests/Feature/Monitoring/OperationsDashboardDrillthroughTest.php \
tests/Feature/Monitoring/OperationsTenantScopeTest.php \
tests/Feature/Filament/BaselineCompareNowWidgetTest.php \
tests/Feature/Filament/NeedsAttentionWidgetTest.php \
tests/Feature/Filament/WorkspaceOverviewOperationsTest.php \
tests/Feature/Filament/Spec352EnvironmentDashboardGuidanceTest.php
```
Result: 73 passed, 474 assertions.
Additional checks:
- `cd apps/platform && ./vendor/bin/pint --dirty --test`: passed, 26 files.
- `git diff --check`: passed.
- Browser smoke: passed through Pest Browser. See `artifacts/browser-smoke.md`.
- Graph boundary scan: no `GraphClientInterface` or `graph()` usage in changed actionability/UI paths.
- Raw terminal follow-up scan: remaining usages are resolver-internal historical candidate selection, model compatibility helpers, active stale classification, tab routing, or historical/detail presentation.
Post-browser UX fix validation:
- Fixed Operations navigation lineage so Operations row links carry a `Back to Operations` context whose back URL preserves the original Workspace Overview `nav[...]` context.
- Fixed Provider Connections verification detours so `Open verification operation` forwards the incoming Operations context back into OperationRun detail.
- `cd apps/platform && ./vendor/bin/sail php vendor/bin/pest tests/Feature/Monitoring/OperationsRelatedNavigationTest.php tests/Feature/Monitoring/OperationsDashboardDrillthroughTest.php tests/Feature/Monitoring/Spec367OperationsActionabilityUiTest.php tests/Feature/Operations/Spec367OperationRunActionabilityResolverTest.php tests/Unit/Support/Operations/Actionability/Spec367OperationRunActionEligibilityAlignmentTest.php`: 20 passed, 97 assertions.
- `cd apps/platform && ./vendor/bin/sail php vendor/bin/pest tests/Browser/Spec367OperationRunActionabilitySmokeTest.php`: 1 passed, 11 assertions.
- Integrated Browser note: the active in-app browser rendered Workspace Overview and exposed the corrected Operations HREF, but direct navigation to `/admin/workspaces/3/operations` aborted with `net::ERR_ABORTED` in that browser session after the app had been on a create form. No console errors were present on the rendered Overview.
One unrelated existing test issue was observed during exploration: `tests/Unit/Support/GovernanceInbox/GovernanceInboxSectionBuilderTest.php` had an alert-delivery URL-filter expectation failure. The changed OperationRun actionability governance path is covered by the Spec 367 guard and focused validation above.
## UI Coverage Decision
No `docs/ui-ux-enterprise-audit/` route inventory, design matrix, or page report update was required. The implementation changes count semantics, labels, and action eligibility on existing pages and widgets; it does not add a route, page, navigation node, component family, panel, or new workflow surface. The existing Operations and dashboard surfaces were verified by focused feature tests and the browser smoke test.
## Deployment Impact
- Migrations: none.
- Environment variables: none.
- Composer or npm packages: none.
- Queues, cron, scheduler, or workers: none.
- Storage or volumes: none.
- Filament assets: none registered.
- Panel providers: unchanged.
- Global search: unchanged.
Existing deployment guidance remains unchanged. If registered Filament assets are used elsewhere in the app, the deployment process should continue to include `cd apps/platform && php artisan filament:assets`; Spec 367 does not add a new asset requirement.
## Filament v5 Output Contract
1. Livewire compliance: Filament v5 remains on Livewire v4.1.4; no Livewire v3 APIs or assumptions were introduced.
2. Provider registration: no panel provider changes were made; Laravel provider registration remains in `apps/platform/bootstrap/providers.php`.
3. Global search: no new globally searchable resources were added. `OperationRunResource` remains not enabled for global search.
4. Destructive actions: no destructive actions were added. Actionability only disables or labels existing current follow-up behavior; high-impact operation families still default to manual review.
5. Assets: no global or on-demand Filament assets were added. Asset deployment strategy is unchanged.
6. Testing plan and coverage: registry/result semantics, resolver proof rules, cross-scope denial, action eligibility, dashboard and Operations UI semantics, direct-consumer guardrails, existing related Filament regressions, and browser smoke were covered.
## Residual Risks
- `currentTerminalFollowUp()` evaluates terminal candidate rows in memory after a database candidate query. This is acceptable for v1 and keeps the feature derived/read-only, but a later spec can add query-native projections if scale demands it.
- Manual-review classifications intentionally keep restore, promotion, purge, and destructive-like terminal problems visible until a future manual acknowledge or resolve workflow exists.

View File

@ -0,0 +1,263 @@
# Implementation Plan: OperationRun Actionability System v1
**Branch**: `367-operationrun-actionability-system` | **Date**: 2026-06-08 | **Spec**: `specs/367-operationrun-actionability-system/spec.md`
**Input**: Feature specification from `specs/367-operationrun-actionability-system/spec.md`
## Summary
Introduce a derived OperationRun actionability layer that separates historical terminal execution truth from current UI follow-up truth. The implementation will keep `operation_runs` immutable history intact, classify every known operation type deliberately, migrate dashboard/Operations/current-follow-up consumers away from raw `terminalFollowUp()` semantics, and prove the known Provider Connection CTA loop is closed.
## Technical Context
**Language/Version**: PHP 8.4.15, Laravel 12.52.0
**Primary Dependencies**: Filament 5.2.1, Livewire 4.1.4, Pest 4.3.1, PostgreSQL
**Storage**: Existing PostgreSQL tables only; no migration planned
**Testing**: Pest 4 Unit, Feature, Architecture/guard, optional Browser smoke
**Validation Lanes**: fast-feedback, confidence, browser only if rendered UI changes
**Target Platform**: Laravel Sail locally, Dokploy container deployment for staging/production
**Project Type**: Laravel monolith under `apps/platform`
**Performance Goals**: DB-only render-time evaluation; batch-friendly dashboard and Operations table evaluation; no Graph calls during UI render
**Constraints**: preserve workspace/managed-environment isolation, RBAC, OperationRunService lifecycle ownership, global-search-disabled OperationRun posture, and existing Operations routes
**Scale/Scope**: Known operation types in `OperationCatalog`, provider registry, reconciliation registry, jobs, factories, seeders, and current tests
## UI / Surface Guardrail Plan
- **Guardrail scope**: changed existing dashboard and Operations status/follow-up surfaces.
- **Affected routes/pages/actions/states/navigation/panel/provider surfaces**:
- `/admin/workspaces/{workspace}/operations`
- `/admin/workspaces/{workspace}/operations/{run}`
- tenant dashboard widgets `NeedsAttention` and `BaselineCompareNow`
- shell active-work hint via `BulkOperationProgress` / `ActiveRuns`
- **No-impact class, if applicable**: N/A.
- **Native vs custom classification summary**: mixed existing native Filament table/detail plus existing dashboard widget Blade/Livewire surfaces.
- **Shared-family relevance**: status messaging, dashboard signals, operation links, current follow-up filters, primary action guidance.
- **State layers in scope**: widget, page, detail, shell hint, URL query for Operations filters.
- **Audience modes in scope**: operator-MSP and support-platform; no customer-facing surface.
- **Decision/diagnostic/raw hierarchy plan**: current actionability is default-visible; historical status remains visible; raw/support diagnostics stay secondary/collapsed/capability-gated.
- **Raw/support gating plan**: unchanged existing Operations detail gating.
- **One-primary-action / duplicate-truth control**: use one actionability result for dashboard count, Operations current-follow-up filters, and OperationRun action eligibility.
- **Handling modes by drift class or surface**: review-mandatory for high-risk operation families and insufficient correlation proof.
- **Repository-signal treatment**: hard-stop-candidate for any UI consumer that still directly treats raw terminal historical status as current follow-up after migration.
- **Special surface test profiles**: monitoring-state-page, dashboard-signal, shared-detail-family.
- **Required tests or manual smoke**: Unit actionability policies, Feature dashboard/Operations regressions, guard tests, optional browser smoke for the provider loop.
- **Exception path and spread control**: none expected.
- **Active feature PR close-out entry**: Guardrail + Smoke Coverage.
- **UI/Productization coverage decision**: implementation must update existing coverage artifacts or document checked no-update rationale.
- **Coverage artifacts to update**: existing Operations/dashboard entries only if visible surface contract materially changes; otherwise record no-update rationale in close-out.
- **No-impact rationale**: N/A.
- **Navigation / Filament provider-panel handling**: no panel/provider changes; providers remain in `apps/platform/bootstrap/providers.php`.
- **Screenshot or page-report need**: screenshot only if rendered dashboard/Operations layout or copy changes materially.
## Shared Pattern & System Fit
- **Shared pattern touched**: OperationRun monitoring, dashboard attention, links, current follow-up, and action eligibility.
- **Existing shared paths to reuse**:
- `App\Support\OperationCatalog`
- `App\Support\OperationRunLinks`
- `App\Support\Operations\OperationRunActionEligibility`
- `App\Support\Operations\Reconciliation\OperationRunReconciliationRegistry`
- `App\Support\Operations\OperationRunCorrelationResolver`
- `App\Support\OpsUx\OperationUxPresenter`
- `App\Support\OpsUx\ActiveRuns`
- **New shared path allowed**: bounded actionability resolver/registry/policies under `App\Support\Operations\Actionability` or equivalent local namespace.
- **Forbidden drift**: no parallel UI action framework, no persisted actionability table, no ad-hoc dashboard-only special case, no Graph calls, no broad Operations UX rebuild.
- **OperationRun UX contract**: no run-start or terminal-notification changes. Existing OperationRun lifecycle remains service-owned.
## Constitution Check
- **Inventory-first / snapshots-second**: N/A; uses existing OperationRun and domain state only.
- **Read/write separation**: PASS; actionability is read-only and DB-only.
- **Single Contract Path to Graph**: PASS; no Graph calls allowed in evaluation or render.
- **Proportionality First / BLOAT-001**: REQUIRED and satisfied in `spec.md`; new derived status/resolver/registry is justified by a confirmed false CTA loop and multiple current consumers.
- **No new persisted truth**: PASS; no migration/table.
- **No new state without behavioral consequence**: PASS; actionability states change dashboard counts, CTA routing, filters, and manual-review behavior.
- **Workspace/Tenant isolation**: REQUIRED; same-workspace/same-environment proof only unless operation is explicitly workspace-owned.
- **RBAC-UX**: REQUIRED; existing policies stay authoritative; UI state is not security.
- **OperationRun standards**: REQUIRED; historical execution truth remains `OperationRun`; current actionability is separate derived truth.
- **UI-COV-001**: REQUIRED; existing reachable UI surfaces change in status/follow-up semantics.
- **TEST-GOV-001**: REQUIRED; test lanes and fixture costs are explicit.
- **LEAN-001**: PASS; no legacy compatibility shims or data migration.
## Project Structure
Likely application surfaces for later implementation:
```text
apps/platform/app/Models/OperationRun.php
apps/platform/app/Support/OperationCatalog.php
apps/platform/app/Support/OperationRunLinks.php
apps/platform/app/Support/Operations/Actionability/
apps/platform/app/Support/Operations/OperationRunActionEligibility.php
apps/platform/app/Support/Operations/Reconciliation/
apps/platform/app/Support/OpsUx/ActiveRuns.php
apps/platform/app/Support/OpsUx/OperationUxPresenter.php
apps/platform/app/Support/GovernanceInbox/GovernanceInboxSectionBuilder.php
apps/platform/app/Support/EnvironmentDashboard/EnvironmentDashboardSummaryBuilder.php
apps/platform/app/Support/Workspaces/WorkspaceOverviewBuilder.php
apps/platform/app/Filament/Widgets/Dashboard/NeedsAttention.php
apps/platform/app/Filament/Widgets/Dashboard/BaselineCompareNow.php
apps/platform/app/Filament/Widgets/Operations/OperationsWorkbenchStats.php
apps/platform/app/Filament/Pages/Monitoring/Operations.php
apps/platform/app/Filament/Resources/OperationRunResource.php
apps/platform/app/Filament/Pages/Operations/TenantlessOperationRunViewer.php
apps/platform/app/Livewire/BulkOperationProgress.php
specs/367-operationrun-actionability-system/repo-truth-map.md
apps/platform/lang/en/localization.php
apps/platform/lang/de/localization.php
apps/platform/tests/Unit/Support/Operations/
apps/platform/tests/Feature/Operations/
apps/platform/tests/Feature/Monitoring/
apps/platform/tests/Feature/Guards/
apps/platform/tests/Browser/
```
## Data Model / Persistence Impact
- No new tables.
- No migration.
- No new persisted actionability column.
- Tenant-bound `OperationRun` records remain tenant-owned operational artifacts for authorization purposes even though canonical Operations routes are workspace-scoped; workspace-only runs are valid only for explicitly workspace-owned operation types.
- Existing `operation_runs.context` may be read for correlation proof; implementation must not mutate historical context as part of evaluation.
- Related current-state proof may read existing models such as `ProviderConnection`, `BaselineSnapshot`, `EvidenceSnapshot`, `EnvironmentReview`, `ReviewPack`, `BackupSet`, and `RestoreRun` only where the policy can prove same-scope ownership.
## Domain Model / Service Approach
1. Add a derived actionability result object.
2. Add a derived actionability status enum/value object.
3. Add a resolver with single-run and batch evaluation APIs.
4. Add a registry that maps canonical operation families to policies and fails coverage tests when known types are uncovered.
5. Implement initial policies:
- provider connection checks
- repeatable sync operations
- baseline operations
- evidence/review/review-pack artifact operations
- backup operations
- restore/promotion/destructive-like operations
- alert/notification/informational fallback decisions discovered during implementation
6. Explicitly classify every remaining `OperationCatalog::canonicalInventory()` entry as actionable, manual-review, superseded-capable, resolved-by-current-state-capable, or informational-only.
7. Reuse `OperationCatalog` for canonical/alias type resolution.
8. Reuse existing reconciliation proof where it is already same-scope and safe.
9. Feed result into existing `OperationRunActionEligibility` instead of replacing it.
## Policy Semantics
- **Actionable**: current operator action exists and should count in dashboard/current filters.
- **Requires manual review**: current action remains necessary because safe automatic resolution is not provable.
- **Superseded by later success**: later same-scope successful run proves old terminal problem no longer current.
- **Resolved by current state**: current domain model proves old terminal problem no longer current.
- **Informational only**: historical record only; never dashboard CTA.
- **Not terminal**: active/non-problem run; active stale remains owned by existing freshness/reconciliation path.
## UI / Filament / Livewire Implications
- Filament v5 / Livewire v4.0+ compliance remains required; current app has Livewire 4.1.4.
- Panel providers remain registered in `apps/platform/bootstrap/providers.php`; no panel provider changes planned.
- `OperationRunResource` remains `protected static bool $isGloballySearchable = false`; no global search changes.
- No destructive actions are added. Existing high-impact Operations actions continue using existing confirmation/authorization patterns.
- Dashboard widgets must show current actionability counts, not raw historical terminal follow-up.
- Operations list/current-follow-up filters must distinguish current actionable/manual-review rows from historical resolved/superseded rows.
- Operations workbench stats, Governance Inbox, environment dashboard summaries, workspace overview signals, and `OperationUxPresenter` decision copy must use actionability for current-follow-up truth or explicitly remain historical-only.
- Operation detail should show historical status and current actionability separately if implementation touches rendered detail.
- Shell active-run hint must not mix terminal actionability with active progress semantics; existing terminal follow-up visibility in `ActiveRuns::shellVisibleQueryForTenantId()` must be removed or converted into a distinct actionability-backed non-active signal.
## RBAC / Policy Implications
- Existing `OperationRunPolicy` and workspace/environment entitlement checks remain authoritative.
- Any actionability result that references a superseding run or resolving model must be same workspace and same managed environment or explicitly workspace-owned.
- Non-members must not learn that hidden runs or hidden resolving models exist.
- Dashboard counts and Operations filters must run inside actor-visible scope.
## Audit / Observability / Evidence Implications
- No new AuditLog writes for read-only evaluation.
- No changes to OperationRun lifecycle transitions.
- Historical terminal status remains audit truth.
- Current actionability explanation is derived UI truth; it should be testable and explainable but not persisted.
## Performance Plan
- Use batch evaluation for dashboard/Operations collections.
- Group lookups by operation family and scope.
- Avoid per-row `ProviderConnection`, artifact, or later-run queries in table render and in multi-tenant aggregate builders such as workspace overview and governance inbox.
- Keep evaluation DB-only and deterministic.
- Add tests or code-review tasks to catch obvious N+1 regressions in actionability consumer paths.
## Test Strategy
- **Unit**:
- resolver/result/status behavior
- provider connection policy
- repeatable sync policy
- baseline/artifact/backup policies
- high-risk manual-review policy
- registry coverage against operation catalog
- **Feature**:
- dashboard provider loop regression
- dashboard/BaselineCompareNow current follow-up counts
- Operations filters and action links
- cross-workspace/cross-environment non-resolution
- action eligibility alignment
- **Guard / Architecture**:
- no direct current-follow-up UI consumption of `terminalFollowUp()` / `dashboardNeedsFollowUp()`
- new operation types require actionability coverage
- no Graph calls in actionability/render tests through fail-hard binding where practical
- **Browser**:
- bounded provider-loop smoke if UI rendering changes: old provider blocker, current healthy state, dashboard no false CTA, Operations history still visible.
## Rollout / Deployment Considerations
- **Env vars**: none.
- **Migrations**: none.
- **Queues/workers**: none.
- **Scheduler**: none.
- **Storage/volumes**: none.
- **Filament assets**: no new assets; `filament:assets` not newly required by this spec.
- **Staging/production**: validate on Staging before Production because dashboard current-follow-up semantics change operator attention routing.
- **Rollback**: code rollback restores old raw terminal-follow-up behavior; no data rollback needed.
## Implementation Phases
### Phase 1 - Repo Truth Inventory
- Confirm all known operation types from `OperationCatalog`, provider registry, reconciliation registry, jobs, factories, seeders, and tests.
- Confirm every current consumer of terminal follow-up / dashboard follow-up / problem class.
- Confirm ProviderConnection current-state proof fields.
### Phase 2 - Failing Proof
- Add Unit/Feature/Guard tests before runtime changes where practical.
- Prove the provider CTA loop, repeatable superseded success, high-risk manual review, cross-scope non-resolution, and guard coverage.
### Phase 3 - Core Actionability Contract
- Add status/result/resolver/registry/policies.
- Add batch evaluation APIs.
- Register known operation families.
### Phase 4 - Consumer Migration
- Migrate Dashboard, BaselineCompareNow, Operations filters/list/detail, OperationRunActionEligibility, OperationRunLinks where relevant, and ActiveRuns boundaries.
- Keep history visible.
### Phase 5 - Validation and Close-Out
- Run focused tests and optional browser smoke.
- Update or document UI coverage artifacts.
- Record Filament/Livewire/global-search/destructive-action/asset/deployment posture in close-out.
## Risk Controls
- Default to actionable/manual-review when correlation proof is incomplete.
- Keep high-risk mutation families manual-review.
- Require same-scope proof for superseded/resolved outcomes.
- Do not evaluate with Graph calls.
- Do not add persistence.
- Do not remove `terminalFollowUp()` until all current consumers are migrated and guard tests exist.
## Implementation Verification Notes
- No product question blocks implementation. The implementation loop must verify which exact ProviderConnection timestamp or verification proof is safe for `resolved_by_current_state` when no later successful run exists. If no reliable field exists, the provider policy can still resolve via later same-scope successful runs and must otherwise leave the old blocker actionable/manual-review.
## Readiness Assessment
Ready for implementation once `tasks.md` is followed. The only verification note is ProviderConnection proof-field selection, not a product blocker, because the provider policy can rely on later same-scope successful runs if timestamp proof is insufficient.

View File

@ -0,0 +1,124 @@
# OperationRun Actionability Repo Truth Map
Date: 2026-06-08
Branch: `367-operationrun-actionability-system`
## Scope
Spec 367 adds read-time OperationRun actionability. It does not add persistence, migrations, Graph calls, queues, scheduler work, storage changes, Filament assets, panel providers, global search, or destructive actions.
## Operation Policy Inventory
The actionability registry covers all known canonical types from `OperationCatalog::canonicalInventory()`. Aliases resolve through `OperationCatalog::canonicalCode()` and `OperationCatalog::rawValuesForCanonical()`.
Provider connection current-state policy:
- `provider.connection.check`
Repeatable later-success policy:
- `inventory.sync`
- `policy.sync`
- `policy.snapshot`
- `policy.export`
- `directory.groups.sync`
- `directory.role_definitions.sync`
- `compliance.snapshot`
- `permission.posture.check`
- `entra.admin_roles.scan`
- `rbac.health_check`
- `tenant.sync`
- `assignments.fetch`
- `alerts.evaluate`
- `alerts.deliver`
- `ops.reconcile_adapter_runs`
Artifact-or-later-success policy:
- `baseline.capture`
- `baseline.compare`
- `tenant.evidence.snapshot.generate`
- `environment.review.compose`
- `environment.review_pack.generate`
- `backup_set.update`
- `backup.schedule.execute`
- `backup.schedule.retention`
Manual-review policy:
- `policy.delete`
- `policy.restore`
- `backup_set.archive`
- `backup_set.restore`
- `backup_set.delete`
- `backup.schedule.purge`
- `restore.execute`
- `promotion.execute`
- `assignments.restore`
- `restore_run.delete`
- `restore_run.restore`
- `restore_run.force_delete`
- `policy_version.prune`
- `policy_version.restore`
- `policy_version.force_delete`
Unknown operation types fail closed as `requires_manual_review`.
## Current-State Proof
Terminal problem gate:
- Only completed runs with `blocked`, `partially_succeeded`, or `failed` outcomes, or lifecycle-reconciled terminal rows, are eligible for current follow-up.
- Completed `succeeded` rows are not terminal problems, even when they carry reconciliation metadata.
Later success proof:
- Must be a later completed successful OperationRun.
- Must match workspace and managed environment.
- Must match the policy family's context keys where either side has a value, such as `provider_connection_id`, `provider`, `selection_hash`, `baseline_profile_id`, `backup_set_id`, or `backup_schedule_id`.
Provider connection proof:
- Uses the same `provider_connection_id` from OperationRun context.
- Requires the ProviderConnection to match workspace and managed environment.
- Requires `is_enabled = true`, `consent_status = granted`, and `verification_status = healthy`.
Artifact proof:
- `baseline.capture` can resolve through a same-workspace complete BaselineSnapshot.
- `tenant.evidence.snapshot.generate` can resolve through a same-scope active and complete EvidenceSnapshot.
- `environment.review.compose` can resolve through a same-scope EnvironmentReview.
- `environment.review_pack.generate` can resolve through a same-scope ready ReviewPack.
- `backup_set.update`, `backup.schedule.execute`, and `backup.schedule.retention` can resolve through a same-scope completed BackupSet.
All evaluation is read-only and database-backed.
## Consumer Map
Actionability-backed current follow-up:
- `OperationRun::currentTerminalFollowUp()`
- `OperationRun::dashboardNeedsFollowUp()`
- `OperationRun::requiresOperatorReview()` for terminal follow-up rows
- `NeedsAttention`
- `Operations` current terminal follow-up tab and blocked stats
- `GovernanceInboxSectionBuilder` operation follow-up section
- `WorkspaceOverviewBuilder` operation follow-up counts
- `OperationUxPresenter` decision truth, labels, notes, and actionability payload
- `OperationRunActionEligibility`
Active-run only behavior:
- `ActiveRuns::shellVisibleQueryForTenantId()` no longer treats terminal follow-up as active progress.
Remaining historical or compatibility usages:
- `terminalFollowUp()` remains the historical terminal-candidate scope used internally by `OperationRunActionabilityResolver`.
- `dashboardNeedsFollowUp()` remains as the compatibility entry point, now backed by current actionability.
- `problemClass()` remains for tab routing, historical labeling, detail display, and active stale classification.
- `requiresDashboardFollowUp()` remains a compatibility wrapper around `requiresOperatorReview()`.
- `OperationRunResource`, tenantless run detail, dashboard summary labels, and governance inbox entry presentation may still display historical problem class alongside the derived actionability result.
## No Graph Boundary
Changed actionability and UI paths do not reference `GraphClientInterface` or call `graph()`. Current-state checks rely on local ProviderConnection, OperationRun, and artifact records.

View File

@ -0,0 +1,393 @@
# Feature Specification: OperationRun Actionability System v1
**Feature Branch**: `367-operationrun-actionability-system`
**Created**: 2026-06-08
**Status**: Draft / Ready for implementation
**Input**: User-provided Spec 367 draft: separate historical terminal `OperationRun` truth from current UI follow-up truth.
## Spec Candidate Check *(mandatory - SPEC-GATE-001)*
- **Problem**: Historical terminal problem runs are currently treated as today's operator follow-up truth. A resolved old `provider.connection.check` blocker can still produce dashboard warnings and provider CTAs after later success or current healthy provider state.
- **Today's failure**: Operators can be sent from Dashboard to Provider Connections for an old `provider_consent_missing` run even when the Provider Connection page now shows `consent_status=granted` and `verification_status=healthy`. This creates a loop between false dashboard attention and correct domain state.
- **User-visible improvement**: Dashboard, Operations hub, shell active-run hints, baseline widgets, and primary CTAs will use one current actionability decision instead of raw historical terminal status. Historical failures remain visible in Operations history, but only current actionable runs drive follow-up counts and CTAs.
- **Smallest enterprise-capable version**: Add a derived, non-persisted OperationRun actionability layer that classifies known terminal OperationRun types, handles the provider CTA loop, supports superseded repeatable operations, keeps high-risk restore/promotion/purge runs manual-review by default, and migrates current UI follow-up consumers from `terminalFollowUp()` / `dashboardNeedsFollowUp()` to actionability.
- **Explicit non-goals**: No legacy data migration, no rewriting historical runs, no manual acknowledge/resolve UI, no new Operations tab system for Resolved/Historical, no notification redesign, no alert delivery UX, no new Provider Connection feature, no restore/backup feature expansion, no destructive actions, no global search enablement for `OperationRunResource`, no panel/provider/asset/theme changes.
- **Permanent complexity imported**: One derived status/value object family, one central resolver/registry/policy layer, correlation helpers only where existing context is insufficient, guard tests for operation-type coverage and direct UI use of historical terminal-follow-up scopes, and focused Unit/Feature/Browser coverage.
- **Why now**: Specs 358-365 made OperationRun execution, reconciliation, links, and operator actions more mature, but repo truth still exposes raw terminal follow-up via `OperationRun::terminalFollowUp()`, `dashboardNeedsFollowUp()`, `problemClass()`, `NeedsAttention`, `BaselineCompareNow`, and Operations filters.
- **Why not local**: Fixing the Provider Connections special case in one widget would leave other repeatable runs and other consumers with the same historical-vs-current truth bug. The repo already has multiple concrete consumers, so the boundary must be central and testable.
- **Approval class**: Core Enterprise
- **Red flags triggered**: New status axis, resolver/registry layer, multiple UI consumers. Defense: the layer is derived-only, persistence-free, anchored to a confirmed operator loop, reuses existing `OperationCatalog`, `OperationRunActionEligibility`, `OperationRunLinks`, and reconciliation helpers, and directly prevents false governance/operations CTAs.
- **Score**: Nutzen: 2 | Dringlichkeit: 2 | Scope: 2 | Komplexitaet: 1 | Produktnaehe: 2 | Wiederverwendung: 2 | **Gesamt: 11/12**
- **Decision**: approve as a bounded current-actionability truth slice.
## Repo Truth Reconciliation
The user draft is accepted as the candidate, with these repo-based scope corrections:
1. `OperationRunActionEligibility` already exists and is consumed by Operations list/detail action surfaces. Spec 367 must extend or feed that path; it must not create a parallel action eligibility framework.
2. `OperationCatalog` is the canonical operation-type inventory and alias resolver. Actionability coverage must compare against that catalog and known provider/reconciliation types instead of inventing a second source of operation-type truth.
3. `OperationRunReconciliationRegistry` and adapters already resolve stale active run proof for several families. Actionability must separate terminal current-follow-up truth from active stale reconciliation truth while reusing existing reconciliation evidence where useful.
4. `OperationRun::terminalFollowUp()`, `dashboardNeedsFollowUp()`, `problemClass()`, `requiresOperatorReview()`, and `requiresDashboardFollowUp()` are existing historical/problem helpers. This spec may deprecate or constrain them but must migrate UI consumers before any removal.
5. The current canonical Operations routes are workspace-scoped: `/admin/workspaces/{workspace}/operations` and `/admin/workspaces/{workspace}/operations/{run}`. `OperationRunResource` remains globally non-searchable.
## Completed-Spec Guardrail
Related specs are context only and are not modified by this prep package:
- `specs/358-operationrun-queue-truth-foundation/`
- `specs/359-operationrun-reconciliation-adapter-framework-review-compose-adapter/`
- `specs/360-operationrun-canonical-cutover-cleanup/`
- `specs/361-report-evidence-reconciliation/`
- `specs/362-sync-capture-backup-operation-semantics/`
- `specs/363-explicit-uiactioncontext-contract/` (implemented)
- `specs/364-restore-high-risk-operation-reconciliation/`
- `specs/365-operations-ui-operator-actions-regression-gate/` (implementation close-out signals)
Spec 367 is a new package because these predecessors do not fully define current terminal actionability or migrate all dashboard/current-follow-up consumers away from historical terminal status.
## Spec Scope Fields *(mandatory)*
- **Scope**: workspace, tenant, canonical-view
- **Primary Routes**:
- `/admin/workspaces/{workspace}/operations`
- `/admin/workspaces/{workspace}/operations/{run}`
- tenant dashboard surfaces that host `NeedsAttention` and `BaselineCompareNow`
- shell active-work hint surface through `BulkOperationProgress` / `ActiveRuns`
- **Data Ownership**: Existing `OperationRun` execution history stays in `operation_runs`. Tenant-bound runs remain tenant-owned operational artifacts via `workspace_id` + `managed_environment_id` and must enforce managed-environment entitlement; workspace-only runs are allowed only for explicitly workspace-owned operation types. No new table, migration, persisted current-actionability mirror, or historical data rewrite is introduced.
- **RBAC**: Existing workspace membership, managed-environment entitlement, and `OperationRunPolicy` checks remain authoritative. Non-members remain deny-as-not-found. Capability denial remains 403 after membership is established.
For canonical-view specs:
- **Default filter behavior when tenant-context is active**: Existing Operations workspace route and environment-prefilter behavior remain unchanged. Query filters may add or rename problem/actionability filters only if links remain workspace/environment safe.
- **Explicit entitlement checks preventing cross-tenant leakage**: Actionability evaluation and counts must operate only on runs already scoped to the actor's workspace and entitled managed environment. Resolved/superseded references must be same workspace and same managed environment unless the operation type is explicitly workspace-only.
## 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
- [x] New table/form/state added
- [ ] Customer-facing surface changed
- [ ] Dangerous action changed
- [x] Status/evidence/review presentation changed
- [x] Workspace/environment context presentation changed
## UI/Productization Coverage *(mandatory when UI Surface Impact is not "No UI surface impact")*
- **Route/page/surface**:
- `App\Filament\Widgets\Dashboard\NeedsAttention`
- `App\Filament\Widgets\Dashboard\BaselineCompareNow`
- `App\Filament\Pages\Monitoring\Operations`
- `App\Filament\Resources\OperationRunResource`
- `App\Filament\Pages\Operations\TenantlessOperationRunViewer`
- `App\Filament\Widgets\Operations\OperationsWorkbenchStats`
- `App\Livewire\BulkOperationProgress`
- `App\Support\GovernanceInbox\GovernanceInboxSectionBuilder`
- `App\Support\EnvironmentDashboard\EnvironmentDashboardSummaryBuilder`
- `App\Support\Workspaces\WorkspaceOverviewBuilder`
- `App\Support\OpsUx\OperationUxPresenter`
- shared link/action helpers that derive Operations follow-up links
- **Current or new page archetype**: Existing Operations Hub / monitoring-state page, tenant dashboard widgets, and shared active-run shell hint.
- **Design depth**: Strategic Surface for Operations Hub; Domain Pattern Surface for dashboard widgets and shell hint.
- **Repo-truth level**: repo-verified.
- **Existing pattern reused**: Operations Hub page report, `OperationRunActionEligibility`, `OperationRunLinks`, `OperationCatalog`, `OperationUxPresenter`, `ActiveRuns`, dashboard widget patterns, existing Spec 365 browser smoke conventions.
- **New pattern required**: one derived actionability truth contract; no new page, navigation branch, action modal family, or independent UI framework.
- **Screenshot required**: yes during implementation if visible dashboard/operations copy or filter state changes. Store under `specs/367-operationrun-actionability-system/artifacts/` if captured.
- **Page audit required**: no new page audit required during prep. Implementation must update existing UI coverage artifacts or record a checked no-update rationale if visual structure remains pattern-compatible.
- **Customer-safe review required**: no customer-facing surface. Operator-facing copy must still avoid raw provider payload, SQL, stack trace, secret, and debug leakage.
- **Dangerous-action review required**: yes by negative proof. Restore, promotion, purge, and destructive-like runs must not become auto-resolved by unrelated later successes and must not gain new destructive UI actions.
- **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`
- [ ] `N/A - no reachable UI surface impact`
- **Coverage artifact decision**: Implementation must either update existing Operations/dashboard coverage entries or record why no coverage artifact changed because the surface contract and visual hierarchy stayed unchanged.
- **No-impact rationale when applicable**: N/A.
## Cross-Cutting / Shared Pattern Reuse *(mandatory)*
- **Cross-cutting feature?**: yes
- **Interaction class(es)**: status messaging, dashboard signals/cards, action links, Operations filters, shell active-work hints, related-operation CTAs.
- **Systems touched**: `OperationRun`, `OperationCatalog`, `OperationRunLinks`, `OperationRunActionEligibility`, `OperationRunReconciliationRegistry`, `OperationRunCorrelationResolver`, `OperationUxPresenter`, `ActiveRuns`, dashboard widgets, Operations list/detail/workbench stats, governance inbox, environment dashboard summary, and workspace overview aggregation.
- **Existing pattern(s) to extend**: existing OperationRun monitoring family, existing action eligibility path, existing operation catalog and alias resolution, existing reconciliation registry.
- **Shared contract / presenter / builder / renderer to reuse**: Reuse `OperationCatalog` as operation-type inventory, reuse `OperationRunActionEligibility` for primary-action decisions, and feed Operations links through `OperationRunLinks`.
- **Why the existing shared path is sufficient or insufficient**: Existing paths know execution state, stale active reconciliation, links, and UI action eligibility, but no existing path answers "does this historical terminal problem still require action today?".
- **Allowed deviation and why**: Add a bounded derived actionability resolver/registry/policy family. Do not add persisted current-state truth or a parallel action UI system.
- **Consistency impact**: Dashboard counts, baseline dashboard calmness, Operations problem filters, shell hints, primary CTAs, and action eligibility must agree on current actionability.
- **Review focus**: No UI consumer may count raw `terminalFollowUp()` rows as current dashboard/action truth after the migration.
## OperationRun UX Impact *(mandatory)*
- **Touches OperationRun start/completion/link UX?**: yes, current-follow-up and deep-link semantics only.
- **Shared OperationRun UX contract/layer reused**: `OperationRunLinks`, `OperationRunActionEligibility`, `OperationUxPresenter`, `OperationRunReconciliationRegistry`, `OperationRunService` lifecycle ownership remains unchanged.
- **Delegated start/completion UX behaviors**: No new queued toast, browser event, run-start path, queued DB notification, or terminal notification path.
- **Local surface-owned behavior that remains**: Dashboard/widget density and Operations list placement only.
- **Queued DB-notification policy**: N/A - no new run-start behavior.
- **Terminal notification path**: unchanged central lifecycle mechanism.
- **Exception required?**: none.
## Provider Boundary / Platform Core Check *(mandatory)*
- **Shared provider/platform boundary touched?**: yes.
- **Boundary classification**: mixed. Actionability is platform-core. Provider health and consent reason codes remain provider-owned diagnostics.
- **Seams affected**: `provider.connection.check` actionability, provider connection health/consent state, OperationRun context/correlation, operator CTA copy.
- **Neutral platform terms preserved or introduced**: operation, actionability, historical execution truth, current domain truth, current follow-up, superseded, resolved, manual review.
- **Provider-specific semantics retained and why**: Provider Connection health and consent are current Microsoft-provider domain truth needed to fix the confirmed CTA loop.
- **Why this does not deepen provider coupling accidentally**: Provider-specific logic stays inside the provider-connection policy. The actionability resolver consumes operation type and same-scope domain proof; it does not make Microsoft provider concepts the platform default.
- **Follow-up path**: follow-up-spec only if later implementation discovers provider-specific actionability decisions spreading beyond provider-owned policy classes.
## UI / Surface Guardrail Impact *(mandatory)*
| Surface / Change | Operator-facing surface change? | Native vs Custom | Shared-Family Relevance | State Layers Touched | Exception Needed? | Low-Impact / N/A Note |
|---|---|---|---|---|---|---|
| Dashboard `NeedsAttention` current Operations card | yes | Existing Filament widget Blade | dashboard signal/status messaging | widget, link query | no | Replace raw terminal count with actionability count |
| Dashboard `BaselineCompareNow` calmness override | yes | Existing Filament widget Blade | dashboard signal/status messaging | widget | no | Use actionability count for Operations follow-up |
| Operations list problem filters/next action | yes | Native Filament table + existing presenters | monitoring list/action links | page, table, query | no | Preserve row history; current filters use actionability |
| OperationRun detail summary/action decision | yes | Existing Filament page/detail | shared-detail-family | detail, header, diagnostics | no | Historical run still visible; action guidance reflects current truth |
| Operations workbench stats | yes | Existing Filament stats widget | monitoring status messaging | widget, scoped query | no | "Needs attention" count uses current actionability |
| Governance inbox / environment dashboard / workspace overview operation follow-up | yes | Existing summary builders | dashboard and inbox signal/status messaging | aggregate queries, links | no | Current follow-up aggregates use actionability; historical runs remain reachable from Operations history |
| Shell active-work hint | yes | Existing Livewire shell hint | active-run status messaging | shell | no | Terminal follow-up is removed from active progress or shown only through a distinct actionability-backed non-active signal |
## Decision-First Surface Role *(mandatory)*
| 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 |
|---|---|---|---|---|---|---|---|
| Dashboard attention cards | Primary Decision Surface | Decide whether the environment needs operator follow-up now | current actionable count, direct CTA only when current action exists | Operations history and raw run detail remain on Operations | primary because it starts the operator's daily attention loop | follows environment governance dashboard workflow | removes old resolved blockers from today's work |
| Operations list | Primary Decision Surface | Triage current actionable operations while preserving history | status/outcome, actionability, scope, next action | raw context and reconciliation detail on detail page | primary for operations triage | filters reflect actual work, not storage history | prevents opening resolved historical rows as current tasks |
| OperationRun detail | Tertiary Evidence / Diagnostics Surface | Understand why a run is actionable, superseded, resolved, or manual-review | historical status plus current actionability explanation | full raw/support diagnostics below/gated | tertiary because the run is selected proof | preserves audit history while clarifying current action | removes conflict between history and dashboard |
| Operations workbench / governance inbox / workspace overview operation signals | Primary Decision Surface | Decide whether an operations follow-up family needs attention across workspace or environment context | current actionable/manual-review count and safe Operations CTA | Operations history and run detail | primary where it drives attention queues; otherwise secondary summary | follows existing dashboard/inbox/overview workflows | prevents aggregate false-positive attention |
| Shell active hint | Secondary Context Surface | Know if active work is in progress or stale | active-run state only | detail link to Operations | secondary; terminal actionability is not an active-run hint | supports ongoing work awareness | avoids mixing active progress with historical follow-up |
## Audience-Aware Disclosure *(mandatory)*
| 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 |
|---|---|---|---|---|---|---|---|
| Dashboard attention cards | operator-MSP | current follow-up count and CTA | none | none | open current actionable operations | raw run data | no historical terminal count appears as current work |
| Operations list | operator-MSP, support-platform | actionability state, operation label, scope, reason summary, next action | reason code and related proof summary | raw context on detail only | open run or related object | raw payloads, stack traces, provider payloads | one actionability label per row |
| OperationRun detail | operator-MSP, support-platform | historical result plus current actionability explanation | superseding run/current-state proof | raw context collapsed/capability-gated | follow actionability recommendation or inspect history | raw/support detail | history and current follow-up are separate sections |
| Aggregate operation signals | operator-MSP, support-platform | current actionability counts and CTA | none by default | Operations history/detail only | open current actionable operations | raw run data | no raw terminal count appears as current work |
## UI/UX Surface Classification *(mandatory)*
| 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 |
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| Dashboard attention cards | Dashboard signal | Governance attention widget | Open current actionable operations | explicit CTA | N/A | none | none | `/admin/workspaces/{workspace}/operations` | `/admin/workspaces/{workspace}/operations/{run}` | environment/workspace context | Operations | current actionability count | none |
| Operations list | List / Table / Monitoring | Monitoring-state page | Open actionable run or related object | row click opens detail | required | table/link actions | none | `/admin/workspaces/{workspace}/operations` | `/admin/workspaces/{workspace}/operations/{run}` | workspace/environment filters | Operation run | historical result plus current actionability | none |
| OperationRun detail | Detail / Diagnostics | Shared-detail-family | Understand or act on current actionability | detail page | N/A | More/header group | none | `/admin/workspaces/{workspace}/operations` | `/admin/workspaces/{workspace}/operations/{run}` | workspace/environment chips | Operation run | execution truth and actionability truth separately | none |
| Aggregate operation signals | Dashboard / Inbox / Overview signal | Existing summary widgets/builders | Open current actionable operations | explicit CTA | N/A | none | none | `/admin/workspaces/{workspace}/operations` | `/admin/workspaces/{workspace}/operations/{run}` | workspace/environment context | Operations | current actionability count | none |
## Operator Surface Contract *(mandatory)*
| 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 |
|---|---|---|---|---|---|---|---|---|---|---|
| Dashboard attention cards | tenant/MSP operator | Decide whether to investigate operations now | dashboard | Is there current operational work, or only historical noise? | actionable count, reason, link | none | current actionability, active stale attention | none | Open operations | none |
| Operations list | workspace/operator | Prioritize operation follow-up | monitoring list | Which runs require action today? | operation, scope, status/outcome, actionability, next action | raw context and proof detail | execution status, outcome, actionability, freshness | none | Open run/related object | none |
| OperationRun detail | operator/support | Resolve contradiction between historical failure and current state | detail | Was this run historically problematic, and does it still matter now? | execution truth, actionability result, proof summary | raw context, stack/provider diagnostics | execution, current domain truth, actionability | none | Open related/current action target | none |
| Aggregate operation signals | workspace/operator | Decide whether an operations family needs follow-up across one environment or workspace | dashboard/inbox/overview signal | Is there current operations follow-up, or only old history? | actionable/manual-review count, direct Operations CTA | raw run data | current actionability, active stale attention | none | Open current actionable operations | none |
## Proportionality Review *(mandatory when structural complexity is introduced)*
- **New source of truth?**: no. Historical truth remains `operation_runs`; current domain truth remains each domain model; actionability is derived at read time.
- **New persisted entity/table/artifact?**: no.
- **New abstraction?**: yes, a bounded actionability resolver/registry/policy family.
- **New enum/state/reason family?**: yes, derived actionability states such as `actionable`, `superseded_by_later_success`, `resolved_by_current_state`, `requires_manual_review`, `informational_only`, and `not_terminal`.
- **New cross-domain UI framework/taxonomy?**: no. This feeds existing Operations/dashboard UI and must not become a generic UI framework.
- **Current operator problem**: dashboards and CTAs can send users to fix already-resolved historical failures.
- **Existing structure is insufficient because**: `OperationRun` currently exposes historical terminal status as follow-up truth, while existing reconciliation/action eligibility handles active stale runs and UI actions but not terminal current actionability.
- **Narrowest correct implementation**: one derived resolver and policy registry over existing operation types, plus consumer migration and guard tests.
- **Ownership cost**: actionability policies must be maintained when operation types are added; tests must cover known operation types and high-risk defaults.
- **Alternative intentionally rejected**: a provider-only special case was rejected because repeatable sync, baseline, evidence, review, backup, restore, and promotion families need intentionally different current-actionability semantics.
- **Release truth**: current-release truth; this directly fixes an observed dashboard/CTA loop and prevents similar false follow-up loops.
### Compatibility posture
This feature assumes pre-production. No legacy aliases, migration shims, historical data rewrite, or dual-read compatibility path is required unless implementation proves an existing write path still emits a legacy operation alias and the spec is amended.
## Testing / Lane / Runtime Impact *(mandatory for runtime behavior changes)*
- **Test purpose / classification**: Unit, Feature, Architecture/guard, Browser smoke.
- **Validation lane(s)**: fast-feedback for Unit/Feature guards; confidence for Filament/Livewire dashboard and Operations behavior; browser for one bounded dashboard-to-Operations CTA loop smoke if rendered UI changes.
- **Why this classification and these lanes are sufficient**: Unit tests prove policy decisions; Feature tests prove dashboard counts, Operations filters, and authorization/isolation; guard tests prevent direct terminal-follow-up UI consumption; browser smoke proves the real user loop is gone.
- **New or expanded test families**: one focused OperationRun actionability family under Unit/Feature/Operations and one bounded browser smoke if UI changes.
- **Fixture / helper cost impact**: Use explicit factories for workspace, managed environment, provider connection, OperationRun, and related domain proof. Do not widen default test setup.
- **Heavy-family visibility / justification**: Browser smoke is explicit and limited to the confirmed Provider Connections dashboard loop if implementation changes rendered UI.
- **Special surface test profile**: monitoring-state-page, dashboard-signal, shared-detail-family.
- **Standard-native relief or required special coverage**: Required coverage for status/actionability semantics, cross-workspace isolation, high-risk manual-review defaults, and no raw terminal follow-up in dashboard.
- **Reviewer handoff**: Verify lane fit, guard coverage, actionability policy coverage, no N+1-prone per-row policy queries in Operations table, and no `terminalFollowUp()` UI consumer remains.
- **Budget / baseline / trend impact**: Bounded Unit/Feature tests plus optional single browser smoke. No new heavy-governance family.
- **Escalation needed**: none if actionability stays derived and bounded; follow-up-spec if implementation discovers a need for manual acknowledgement/resolution UI.
- **Active feature PR close-out entry**: Guardrail + Smoke Coverage.
- **Planned validation commands**:
- `cd apps/platform && ./vendor/bin/sail php vendor/bin/pest tests/Unit/Support/Operations tests/Feature/Operations tests/Feature/Monitoring tests/Feature/Filament`
- `cd apps/platform && ./vendor/bin/sail php vendor/bin/pest tests/Feature/Guards`
- `cd apps/platform && ./vendor/bin/sail php vendor/bin/pest tests/Browser/Spec367OperationRunActionabilitySmokeTest.php` when browser UI changes are implemented
- `cd apps/platform && ./vendor/bin/sail pint --dirty --test`
- `git diff --check`
## User Scenarios & Testing *(mandatory)*
### User Story 1 - Dashboard stops false Provider Connection CTA loop (Priority: P1)
As an MSP operator, I want an old provider connection blocker to disappear from today's dashboard follow-up when the same connection is now healthy, so I am not sent through a dead-end fix loop.
**Why this priority**: This is the confirmed root symptom and the clearest operator-trust failure.
**Independent Test**: Create an old blocked `provider.connection.check`, a later successful same-scope check or healthy ProviderConnection state, render dashboard attention, and verify no Provider Connection/terminal follow-up CTA appears for the old run.
**Acceptance Scenarios**:
1. **Given** a blocked old provider check and a later successful same-scope provider check, **When** the dashboard renders, **Then** the old run is not counted as current follow-up.
2. **Given** a blocked old provider check and current ProviderConnection `consent_status=granted` plus `verification_status=healthy`, **When** actionability is evaluated, **Then** the run is `resolved_by_current_state` and no current provider CTA is emitted.
3. **Given** a blocked provider check with no later success and unhealthy current state, **When** actionability is evaluated, **Then** the run remains actionable.
### User Story 2 - Repeatable operations are superseded by later same-scope success (Priority: P1)
As an operator, I want old failed sync/evidence/baseline/review/backup runs to stop driving current follow-up when a later successful same-scope run proves the work is now complete.
**Why this priority**: Repeatable operation families are common dashboard and Operations noise sources.
**Independent Test**: Create old failed repeatable runs and later successful same-scope runs for inventory, baseline, evidence/review, and backup families; verify actionability returns `superseded_by_later_success`.
**Acceptance Scenarios**:
1. **Given** an old failed `inventory.sync` and a later succeeded same-scope `inventory.sync`, **When** Operations follow-up filters run, **Then** only current actionable runs appear.
2. **Given** an old evidence generation failure and a later usable same-scope Evidence Snapshot, **When** actionability is evaluated, **Then** the old run does not drive a dashboard CTA.
3. **Given** insufficient correlation proof, **When** an old failed repeatable run is evaluated, **Then** it remains actionable or manual-review instead of being silently hidden.
### User Story 3 - High-risk operations remain manual-review unless explicitly resolved (Priority: P1)
As an operator, I want restore, promotion, purge, and destructive-like operation failures to remain visible for deliberate review unless a type-specific policy can prove resolution, so dangerous operations do not disappear from attention incorrectly.
**Why this priority**: False calm on high-risk operations is worse than extra review.
**Independent Test**: Create failed restore/promotion/purge runs plus unrelated later successes and verify actionability remains `requires_manual_review`.
**Acceptance Scenarios**:
1. **Given** a failed `restore.execute` and a later successful backup run, **When** actionability is evaluated, **Then** the restore remains `requires_manual_review`.
2. **Given** a failed `promotion.execute`, **When** Operations filters current follow-up, **Then** it remains visible unless a future explicit policy says otherwise.
3. **Given** high-risk runs, **When** action eligibility renders, **Then** no automatic retry/re-execute/destructive action is introduced by this spec.
### User Story 4 - Operations preserves history while separating current actionability (Priority: P2)
As a support/operator user, I want Operations history to show the historical outcome and the current actionability explanation separately, so audit history remains intact without confusing today's work.
**Why this priority**: The platform must preserve audit depth while keeping current work queues quiet and truthful.
**Independent Test**: Render Operations list/detail for actionable, superseded, resolved, manual-review, informational, active-stale, and succeeded runs and assert historical status remains visible while current actionability controls current follow-up.
**Acceptance Scenarios**:
1. **Given** a superseded old failed run, **When** Operations history renders, **Then** the row remains visible in history but not in current-follow-up filters.
2. **Given** a manual-review high-risk run, **When** Operations detail renders, **Then** current actionability explains why review is still needed.
3. **Given** an active stale run, **When** actionability is evaluated, **Then** active stale truth remains handled by existing freshness/reconciliation paths, not terminal actionability.
## Functional Requirements *(mandatory)*
- **FR-367-001**: The system MUST provide a central derived OperationRun actionability entry point that answers whether a terminal run is current operator follow-up truth.
- **FR-367-002**: The resolver MUST distinguish historical execution truth, current domain truth, and UI actionability truth.
- **FR-367-003**: The resolver MUST return at least status, actionable boolean, reason code, explanation, optional superseding run id, optional resolving model reference, and policy identifier or equivalent debug metadata.
- **FR-367-004**: The actionability registry MUST cover every canonical operation type known to `OperationCatalog` and any provider/reconciliation operation types discovered by implementation.
- **FR-367-005**: Unknown operation types MUST fail guard tests and MUST fail closed at runtime as manual-review/actionable or explicitly unsupported; no silent non-actionable default is allowed.
- **FR-367-006**: Provider connection check policy MUST mark old provider blockers non-actionable when a later same-scope successful check exists or current same-scope ProviderConnection state proves `consent_status=granted` and `verification_status=healthy`.
- **FR-367-007**: Repeatable sync policies MUST supersede old failed/blocked/partial runs only when later same-scope success is proven through canonical type/alias family, workspace, environment, provider/connection, and relevant selection or target scope.
- **FR-367-008**: Baseline, evidence, review, review-pack, and backup artifact policies MUST use current repo-backed artifact truth where available and MUST avoid guessing when correlation proof is missing.
- **FR-367-009**: Restore, promotion, purge, and destructive-like operation policies MUST default to `requires_manual_review` for terminal problem outcomes unless an explicit type-specific policy proves otherwise.
- **FR-367-010**: Dashboard and current-follow-up consumers MUST stop using raw `terminalFollowUp()` / `dashboardNeedsFollowUp()` as current actionability truth.
- **FR-367-011**: Operations history MUST keep historical terminal runs visible outside current-follow-up filters.
- **FR-367-012**: `OperationRunActionEligibility` MUST consume or align with actionability so primary actions and disabled reasons do not contradict dashboard/current-follow-up state.
- **FR-367-013**: Actionability evaluation MUST be batch-friendly for Operations list/dashboard counts and MUST avoid per-row N+1 domain queries where predictable eager loading or grouped lookup can be used.
- **FR-367-014**: Cross-workspace and cross-environment proofs MUST NOT supersede or resolve a run.
- **FR-367-015**: No Graph/provider calls may occur during actionability evaluation or UI render.
- **FR-367-016**: Guard tests MUST fail when new operation types are introduced without actionability policy coverage.
- **FR-367-017**: Guard tests MUST fail when dashboard/current-follow-up UI code directly consumes historical terminal-follow-up scopes or methods after migration.
## Non-Functional Requirements
- **NFR-367-001**: Actionability is derived, deterministic, and DB-only at render time.
- **NFR-367-002**: Evaluation must remain tenant/workspace scoped and RBAC-respecting.
- **NFR-367-003**: Copy must be operator-readable and must not expose raw provider payloads, secrets, stack traces, SQL, queue payloads, or internal exception text by default.
- **NFR-367-004**: Tests must protect business truth over thin presentation helpers.
- **NFR-367-005**: No new package, migration, queue, scheduler, asset registration, panel provider, or env var is required.
## Actionability Status Semantics
The exact implementation may be an enum or value object, but the following derived statuses are required:
| Status | Meaning | Counts as current dashboard follow-up? |
|---|---|---|
| `actionable` | Operator can or must take a current action | yes |
| `requires_manual_review` | Safe automatic resolution is not possible; deliberate review is required | yes |
| `superseded_by_later_success` | Later same-scope success proves the old terminal problem is no longer current | no |
| `resolved_by_current_state` | Current domain state proves the problem is no longer current | no |
| `informational_only` | Historical/audit information only | no |
| `not_terminal` | Run is active or not a terminal problem | no |
## Policy Groups
- **Provider connection checks**: `provider.connection.check`; can resolve through later same-scope success or healthy ProviderConnection current state.
- **Repeatable sync operations**: `inventory.sync`, `inventory_sync`, `policy.sync`, `directory.groups.sync`, `directory.role_definitions.sync`, `compliance.snapshot`, `permission_posture_check`; can supersede through later same-scope success.
- **Baseline operations**: `baseline.capture`, `baseline.compare`; can resolve/supersede only through same-scope later success or current baseline artifact truth.
- **Evidence/review/report artifact operations**: `environment.review.compose`, `environment.review_pack.generate`, `tenant.evidence.snapshot.generate`, `evidence_snapshot.generate`, plus stored report/report delivery types discovered in repo; can resolve through usable current artifact proof.
- **Backup operations**: `backup_set.update`, `backup.schedule.execute`, `backup.schedule.retention`, `backup.schedule.purge`; update/execute may supersede with proof, purge defaults manual-review if safety proof is insufficient.
- **Restore/promotion/mutation operations**: `restore.execute`, `promotion.execute`, destructive operation families; default manual-review for terminal problems.
- **Alert/notification/delivery operations**: classify discovered alert delivery/evaluation types deliberately; no silent default.
- **Informational/historical-only operations**: classify explicitly only when current product behavior proves no dashboard action is appropriate.
- **Remaining canonical `OperationCatalog` types**: every canonical type not already listed, including policy snapshot/export/delete/restore, assignment fetch/restore, backup set archive/restore/delete, restore-run delete/restore/force-delete, tenant sync, policy-version prune/restore/force-delete, ops reconciliation, RBAC health check, Entra admin role scan, and any discovered aliases, must receive an explicit actionability policy or explicit informational/manual-review classification.
## Out of Scope
- New persisted actionability state, table, or migration.
- Rewriting historical `operation_runs`.
- Manual acknowledge/resolve UI for operations.
- Full Resolved/Superseded/Historical Operations UX tabs.
- Notification redesign or alert delivery UX.
- Provider Connection feature expansion.
- Restore/backup/promotion behavior expansion.
- New destructive actions.
- Global search enablement for `OperationRunResource`.
- Filament panel/provider registration changes.
- Asset or theme registration.
## Success Criteria
- **SC-367-001**: The known Provider Connection loop is impossible in dashboard and Operations CTAs.
- **SC-367-002**: Every known canonical operation type has explicit actionability coverage or an explicit manual-review/informational policy.
- **SC-367-003**: Dashboard Operations follow-up counts use current actionability, not raw terminal historical status.
- **SC-367-004**: Operations list/detail preserve historical status while clearly separating current actionability.
- **SC-367-005**: High-risk operation failures remain manual-review by default.
- **SC-367-006**: Guard tests prevent direct UI consumption of historical terminal-follow-up scopes for current follow-up.
- **SC-367-007**: No application render path performs Graph calls or cross-tenant/current-state leakage.
## Risks
- **Over-abstraction risk**: A policy registry can become a generic framework. Mitigation: keep policies narrow, derived-only, and limited to known operation groups.
- **False calm risk**: Superseding too aggressively can hide real failures. Mitigation: require same-scope proof and default to actionable/manual-review when proof is incomplete.
- **Performance risk**: Operations tables could evaluate actionability per row with N+1 queries. Mitigation: require batch evaluation and grouped lookups.
- **UI drift risk**: Dashboard, Operations list, and detail may disagree. Mitigation: central resolver plus consumer guard tests.
## Assumptions
- The product remains pre-production under LEAN-001.
- `OperationCatalog` is the primary operation-type source for this slice.
- Provider Connection current state has enough persisted timestamps or later successful run proof to avoid guessing.
- Existing Operations routes, RBAC, and global-search-disabled posture stay unchanged.
## Open Questions
- None blocking preparation. Implementation must verify the exact healthy ProviderConnection timestamp/proof fields before using current state as resolution proof.
## Follow-up Spec Candidates
- Manual OperationRun acknowledgement / resolve UX.
- Resolved and Superseded Operations history tabs or filters beyond the minimum current-follow-up filter.
- Actionability explanation UI polish if operator/support audiences need richer proof detail.
- Alert-delivery actionability refinement if discovered operation families need more than v1 manual-review/informational defaults.

View File

@ -0,0 +1,125 @@
# Tasks: OperationRun Actionability System v1
**Input**: `specs/367-operationrun-actionability-system/spec.md`, `specs/367-operationrun-actionability-system/plan.md`
**Prerequisites**: Specs 358-365 are context only; do not rewrite completed close-out history.
**Tests**: Required. Use Pest 4 Unit, Feature, Guard/Architecture, and one bounded Browser smoke only if rendered UI changes.
**No implementation in prep**: This task list is for the later implementation loop.
## 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 any browser addition is 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 pulling in unrelated lane cost.
- [x] The declared surface test profiles are `monitoring-state-page`, `dashboard-signal`, and `shared-detail-family`.
- [x] Any material budget, baseline, trend, or escalation note is recorded in the active spec close-out.
## Phase 1: Setup and Repo Truth Inventory
**Purpose**: Confirm exact operation-type inventory, current consumers, and current-state proof seams before implementation.
- [x] T001 Confirm current branch is `367-operationrun-actionability-system` and working tree intent with `git status --short --branch`.
- [x] T002 Read `specs/367-operationrun-actionability-system/spec.md`, `plan.md`, `tasks.md`, and `checklists/requirements.md`.
- [x] T003 Read Specs 358-365 as context only; do not modify those packages.
- [x] T004 Inspect `apps/platform/app/Models/OperationRun.php` for `terminalFollowUp()`, `dashboardNeedsFollowUp()`, `problemClass()`, `requiresOperatorReview()`, and `requiresDashboardFollowUp()`.
- [x] T005 Inspect `apps/platform/app/Support/OperationCatalog.php`, `apps/platform/app/Support/OperationRunType.php`, and `apps/platform/app/Support/OperationTypeAlias.php` for canonical and legacy operation-type values.
- [x] T006 Inspect `apps/platform/app/Services/Providers/ProviderOperationRegistry.php` and provider start-gate code for provider operation types.
- [x] T007 Inspect `apps/platform/app/Support/Operations/Reconciliation/OperationRunReconciliationRegistry.php` and all reconciliation adapters for supported operation families.
- [x] T008 Search `apps/platform/app`, `apps/platform/database`, and `apps/platform/tests` for operation type strings; record every `OperationCatalog::canonicalInventory()` entry, discovered alias, policy group, and missing policy candidate in `specs/367-operationrun-actionability-system/repo-truth-map.md`.
- [x] T009 Search for current consumers of `terminalFollowUp`, `dashboardNeedsFollowUp`, `problemClass`, `requiresOperatorReview`, and `requiresDashboardFollowUp`; record all consumers in `repo-truth-map.md`, including Operations workbench stats, Governance Inbox, environment dashboard summary, workspace overview, `OperationUxPresenter`, and shell active-run paths.
- [x] T010 Inspect `apps/platform/app/Models/ProviderConnection.php` and related provider health services to identify safe current-state proof fields for `consent_status=granted` and `verification_status=healthy`.
- [x] T011 Confirm no migration, env var, package, queue, scheduler, storage, Filament asset, panel-provider, or global-search change is required; update spec/plan before coding if false.
## Phase 2: Failing Tests First
**Purpose**: Prove the behavior before changing runtime code.
- [x] T012 [P] Add `apps/platform/tests/Unit/Support/Operations/Spec367OperationRunActionabilityResultTest.php` for status/actionable boolean/result metadata semantics.
- [x] T013 [P] Add `apps/platform/tests/Unit/Support/Operations/Spec367ProviderConnectionActionabilityPolicyTest.php` for later same-scope success, healthy current state, unresolved blocker, and cross-scope non-resolution.
- [x] T014 [P] Add `apps/platform/tests/Unit/Support/Operations/Spec367RepeatableOperationActionabilityPolicyTest.php` for inventory, policy, directory groups, role definitions, compliance, and permission posture alias families.
- [x] T015 [P] Add `apps/platform/tests/Unit/Support/Operations/Spec367BaselineArtifactActionabilityPolicyTest.php` for baseline capture/compare and evidence/review/review-pack artifact proof.
- [x] T016 [P] Add `apps/platform/tests/Unit/Support/Operations/Spec367HighRiskActionabilityPolicyTest.php` for restore, promotion, purge, and destructive-like default manual-review behavior.
- [x] T017 [P] Add `apps/platform/tests/Unit/Support/Operations/Spec367ActionabilityRegistryCoverageTest.php` proving all known canonical `OperationCatalog` types and discovered aliases are explicitly covered or explicitly classified.
- [x] T018 [P] Add or extend guard coverage under `apps/platform/tests/Feature/Guards/` proving dashboard/current-follow-up UI consumers do not directly use raw `terminalFollowUp()`, `dashboardNeedsFollowUp()`, or `problemClass()` as current actionability truth after migration.
- [x] T019 Add `apps/platform/tests/Feature/Monitoring/Spec367DashboardOperationActionabilityTest.php` proving the Provider Connection CTA loop is gone in `NeedsAttention`.
- [x] T020 Add `apps/platform/tests/Feature/Filament/Spec367BaselineCompareNowActionabilityTest.php` proving `BaselineCompareNow` uses actionability counts for Operations calmness.
- [x] T021 Add `apps/platform/tests/Feature/Monitoring/Spec367OperationsActionabilityFilterTest.php` proving current-follow-up filters include actionable/manual-review rows and exclude superseded/resolved rows while history remains visible.
- [x] T022 Add `apps/platform/tests/Feature/Operations/Spec367OperationRunActionEligibilityAlignmentTest.php` proving `OperationRunActionEligibility` does not produce primary CTAs that contradict non-actionable actionability results.
- [x] T023 Add cross-workspace and cross-environment denial tests proving later success/current state in another scope cannot resolve a run.
- [x] T024 Add a fail-hard Graph client binding to at least one render/evaluation feature test to prove actionability is DB-only.
- [x] T025 If rendered UI changes materially, add `apps/platform/tests/Browser/Spec367OperationRunActionabilitySmokeTest.php` for the Provider Connection loop and Operations history visibility.
## Phase 3: Core Actionability Contract
**Purpose**: Add the derived actionability layer without persistence.
- [x] T026 Create an actionability status enum/value object, likely `apps/platform/app/Support/Operations/Actionability/OperationRunActionabilityStatus.php`.
- [x] T027 Create an actionability result value object, likely `apps/platform/app/Support/Operations/Actionability/OperationRunActionabilityResult.php`.
- [x] T028 Create an actionability policy interface or equivalent callable contract under `apps/platform/app/Support/Operations/Actionability/`.
- [x] T029 Create `OperationRunActionabilityResolver` with `evaluate(OperationRun $run)`, `evaluateMany(Collection $runs)`, and actionable/current-follow-up helpers.
- [x] T030 Create an actionability registry that maps canonical operation families to policies and exposes covered canonical types.
- [x] T031 Reuse `OperationCatalog::canonicalCode()` and `OperationCatalog::rawValuesForCanonical()` for aliases instead of creating another operation-type source.
- [x] T032 Add batch preloading/grouped lookup support so dashboard, Operations list, governance inbox, environment dashboard, and workspace overview consumers do not run per-row domain queries.
- [x] T033 Keep evaluation read-only; do not mutate `operation_runs.context`, status, outcome, related records, or audit logs.
## Phase 4: Policy Implementations
**Purpose**: Implement the minimum explicit policies needed by v1.
- [x] T034 Implement provider connection check actionability for `provider.connection.check`.
- [x] T035 In provider policy, mark old blockers `superseded_by_later_success` when later same-workspace/same-environment/same-provider-connection success exists.
- [x] T036 In provider policy, mark old blockers `resolved_by_current_state` only when current ProviderConnection proof is same-scope and reliably healthy.
- [x] T037 Implement repeatable sync actionability for inventory, policy, directory groups, role definitions, compliance, and permission posture alias families.
- [x] T038 Implement baseline capture/compare actionability using later same-scope success or current baseline artifact proof only when repo truth supports it.
- [x] T039 Implement evidence/review/review-pack artifact actionability using existing EvidenceSnapshot, EnvironmentReview, ReviewPack, and reconciliation proof where available.
- [x] T040 Implement backup operation actionability for `backup_set.update`, `backup.schedule.execute`, `backup.schedule.retention`, and `backup.schedule.purge`.
- [x] T041 Implement restore/promotion/destructive-like actionability as default `requires_manual_review` for terminal problem outcomes.
- [x] T042 Classify alert/notification/delivery/informational operation types and every remaining canonical `OperationCatalog` type discovered in Phase 1 explicitly as actionable, manual-review, superseded-capable, resolved-by-current-state-capable, or informational; do not leave silent defaults.
- [x] T043 For incomplete correlation proof, return actionable or manual-review rather than superseded/resolved.
## Phase 5: Consumer Migration
**Purpose**: Move current-follow-up UI from historical terminal truth to actionability.
- [x] T044 Update `OperationRun::dashboardNeedsFollowUp()` or add a replacement scope/helper so current dashboard follow-up uses actionability-backed query/evaluation semantics.
- [x] T045 Update `OperationRun::problemClass()` and related constants only if needed; preserve history and compatibility until consumers are migrated.
- [x] T046 Update `apps/platform/app/Filament/Widgets/Dashboard/NeedsAttention.php` to count current actionable/manual-review terminal runs through the resolver.
- [x] T047 Update `apps/platform/app/Filament/Widgets/Dashboard/BaselineCompareNow.php` to use actionability-backed Operations follow-up counts and links.
- [x] T048 Update `apps/platform/app/Support/OperationRunLinks.php` so current-follow-up links use actionability/problem filters that cannot target resolved/superseded historical rows as current work.
- [x] T049 Update `apps/platform/app/Filament/Pages/Monitoring/Operations.php` filters/prefilters/workbench state and `apps/platform/app/Filament/Widgets/Operations/OperationsWorkbenchStats.php` to support current actionability while keeping historical rows reachable.
- [x] T050 Update `apps/platform/app/Filament/Resources/OperationRunResource.php`, `apps/platform/app/Filament/Pages/Operations/TenantlessOperationRunViewer.php`, and `apps/platform/app/Support/OpsUx/OperationUxPresenter.php` so list/detail/decision copy shows actionability truth separately from historical status where current follow-up is displayed.
- [x] T051 Update `apps/platform/app/Support/GovernanceInbox/GovernanceInboxSectionBuilder.php`, `apps/platform/app/Support/EnvironmentDashboard/EnvironmentDashboardSummaryBuilder.php`, and `apps/platform/app/Support/Workspaces/WorkspaceOverviewBuilder.php` so aggregate operation follow-up uses current actionability counts and links, or explicitly documents any remaining historical-only usage.
- [x] T052 Update `apps/platform/app/Support/Operations/OperationRunActionEligibility.php` to consume actionability and avoid primary actions for superseded/resolved rows unless the action is pure history/detail navigation.
- [x] T053 Update `apps/platform/app/Livewire/BulkOperationProgress.php` and `App\Support\OpsUx\ActiveRuns` so shell active-run progress remains active-run only; remove current terminal follow-up from active progress or convert it to a distinct actionability-backed non-active signal with test coverage.
- [x] T054 Update EN/DE localization keys only for new visible actionability labels/reasons; avoid raw reason-code leakage.
## Phase 6: UI Coverage and Documentation-In-Feature
**Purpose**: Keep UI-COV and close-out evidence inside the active spec package.
- [x] T055 Decide whether implementation materially changes Operations/dashboard/governance-inbox/environment-dashboard/workspace-overview coverage artifacts.
- [x] T056 If coverage artifacts change, update the relevant `docs/ui-ux-enterprise-audit/` route inventory/design matrix/page report entries.
- [x] T057 If coverage artifacts do not change, record the checked no-update rationale in `specs/367-operationrun-actionability-system/implementation-close-out.md` or the active PR close-out.
- [x] T058 If browser smoke is run, store screenshots or notes under `specs/367-operationrun-actionability-system/artifacts/`.
- [x] T059 Record implementation close-out including Filament v5/Livewire v4 compliance, provider location, global search status, destructive action status, asset strategy, test commands, and deployment impact.
## Phase 7: Validation
**Purpose**: Prove the feature and guardrails without broad suite drift.
- [x] T060 Run `cd apps/platform && ./vendor/bin/sail php vendor/bin/pest tests/Unit/Support/Operations tests/Feature/Operations tests/Feature/Monitoring tests/Feature/Filament`.
- [x] T061 Run `cd apps/platform && ./vendor/bin/sail php vendor/bin/pest tests/Feature/Guards`.
- [x] T062 If browser coverage was added, run `cd apps/platform && ./vendor/bin/sail php vendor/bin/pest tests/Browser/Spec367OperationRunActionabilitySmokeTest.php`.
- [x] T063 Run `cd apps/platform && ./vendor/bin/sail pint --dirty --test`.
- [x] T064 Run `git diff --check`.
- [x] T065 Review `rg -n "terminalFollowUp|dashboardNeedsFollowUp|problemClass|requiresDashboardFollowUp|requiresOperatorReview" apps/platform/app` and confirm any remaining usages are historical-only, model/internal compatibility, or explicitly documented in `repo-truth-map.md` / implementation close-out.
- [x] T066 Review `rg -n "GraphClientInterface|graph\\("` in new/changed actionability files and confirm there are no render-time Graph calls.
- [x] T067 Confirm no migrations, packages, env vars, queues, scheduler changes, storage changes, panel provider changes, global search changes, or Filament asset registrations were introduced.
## Explicit Non-Goals
- [x] T068 Do not add manual acknowledge/resolve UI.
- [x] T069 Do not create a persisted actionability table or column.
- [x] T070 Do not rewrite historical OperationRun rows.
- [x] T071 Do not introduce new destructive actions, retries, restore re-execution, or force-complete actions.
- [x] T072 Do not enable global search for `OperationRunResource`.