diff --git a/apps/platform/app/Filament/Pages/CrossTenantComparePage.php b/apps/platform/app/Filament/Pages/CrossTenantComparePage.php index 5de3ac21..8189b9be 100644 --- a/apps/platform/app/Filament/Pages/CrossTenantComparePage.php +++ b/apps/platform/app/Filament/Pages/CrossTenantComparePage.php @@ -12,8 +12,13 @@ use App\Services\Audit\WorkspaceAuditLogger; use App\Services\Auth\CapabilityResolver; use App\Services\Auth\WorkspaceCapabilityResolver; +use App\Services\PortfolioCompare\CrossTenantPromotionExecutionService; use App\Support\Auth\Capabilities; use App\Support\Navigation\CanonicalNavigationContext; +use App\Support\OperationalControls\OperationalControlBlockedException; +use App\Support\OperationRunLinks; +use App\Support\OpsUx\OpsUxBrowserEvents; +use App\Support\OpsUx\ProviderOperationStartResultPresenter; use App\Support\PortfolioCompare\CrossTenantComparePreviewBuilder; use App\Support\PortfolioCompare\CrossTenantCompareSelection; use App\Support\PortfolioCompare\CrossTenantPromotionPreflight; @@ -23,13 +28,16 @@ use App\Support\Ui\ActionSurface\Enums\ActionSurfaceSlot; use App\Support\Workspaces\WorkspaceContext; use BackedEnum; +use DomainException; use Filament\Actions\Action; +use Filament\Notifications\Notification; use Filament\Forms\Components\Select; use Filament\Forms\Concerns\InteractsWithForms; use Filament\Forms\Contracts\HasForms; use Filament\Pages\Page; use Filament\Schemas\Components\Grid; use Filament\Schemas\Schema; +use InvalidArgumentException; use Illuminate\Support\Collection; use Illuminate\Support\Str; use UnitEnum; @@ -192,6 +200,7 @@ protected function getHeaderActions(): array ->label('Generate promotion preflight') ->icon('heroicon-o-sparkles') ->color('primary') + ->visible(fn (): bool => ! is_array($this->preflight)) ->disabled(fn (): bool => $this->preflightDisabledReason() !== null) ->tooltip(fn (): ?string => $this->preflightDisabledReason()) ->action(fn (): mixed => $this->generatePromotionPreflight()); @@ -201,6 +210,7 @@ protected function getHeaderActions(): array fn (): ?Workspace => $this->workspace(), ) ->requireCapability(Capabilities::WORKSPACE_BASELINES_MANAGE) + ->preserveVisibility() ->preserveDisabled() ->tooltip('You need workspace baseline manage access to generate a promotion preflight.') ->apply() @@ -223,6 +233,19 @@ protected function getHeaderActions(): array $actions[] = $preflightAction; + $actions[] = Action::make('executePromotion') + ->label('Execute promotion') + ->icon('heroicon-o-play') + ->color('warning') + ->visible(fn (): bool => is_array($this->preflight)) + ->requiresConfirmation() + ->modalHeading('Execute promotion') + ->modalDescription(fn (): string => $this->executePromotionConfirmationDescription()) + ->modalSubmitActionLabel('Queue promotion') + ->disabled(fn (): bool => $this->executePromotionDisabledReason() !== null) + ->tooltip(fn (): ?string => $this->executePromotionDisabledReason()) + ->action(fn (): mixed => $this->executePromotion()); + return $actions; } @@ -282,6 +305,74 @@ public function generatePromotionPreflight(): void } } + public function executePromotion(): void + { + $this->authorizePageAccess(); + $this->authorizePromotionExecution(); + + if (! is_array($this->preview) || ! is_array($this->preflight)) { + Notification::make() + ->title('Promotion execution unavailable') + ->body('Generate a current promotion preflight before executing promotion.') + ->warning() + ->send(); + + return; + } + + $selection = $this->compareSelection(); + $user = auth()->user(); + + if (! $selection instanceof CrossTenantCompareSelection || ! $user instanceof User) { + Notification::make() + ->title('Promotion execution unavailable') + ->body('Refresh the compare selection before executing promotion.') + ->warning() + ->send(); + + return; + } + + try { + $result = app(CrossTenantPromotionExecutionService::class)->start( + selection: $selection, + preview: $this->preview, + preflight: $this->preflight, + actor: $user, + ); + } catch (OperationalControlBlockedException $exception) { + Notification::make() + ->title($exception->title()) + ->body($exception->getMessage()) + ->warning() + ->send(); + + return; + } catch (DomainException|InvalidArgumentException $exception) { + Notification::make() + ->title('Promotion execution unavailable') + ->body($exception->getMessage()) + ->warning() + ->send(); + + return; + } + + $notification = app(ProviderOperationStartResultPresenter::class)->notification( + result: $result, + blockedTitle: 'Promotion execution blocked', + runUrl: OperationRunLinks::tenantlessView($result->run), + scopeBusyTitle: 'Promotion scope busy', + scopeBusyBody: 'Another promotion or restore operation is already active for this target scope. Open the active operation for progress and next steps.', + ); + + if (in_array($result->status, ['started', 'deduped', 'scope_busy'], true)) { + OpsUxBrowserEvents::dispatchRunEnqueued($this); + } + + $notification->send(); + } + public function clearSelectionUrl(): string { return static::getUrl($this->routeParameters([ @@ -453,6 +544,30 @@ private function authorizePreflightExecution(): void } } + private function authorizePromotionExecution(): void + { + $this->authorizePreflightExecution(); + + $user = auth()->user(); + + if (! $user instanceof User) { + abort(403); + } + + $targetTenant = $this->selectedTargetTenant(); + + if (! $targetTenant instanceof Tenant) { + abort(404); + } + + /** @var CapabilityResolver $resolver */ + $resolver = app(CapabilityResolver::class); + + if (! $resolver->can($user, $targetTenant, Capabilities::TENANT_MANAGE)) { + abort(403); + } + } + private function compareSelection(): ?CrossTenantCompareSelection { $sourceTenant = $this->selectedSourceTenant(); @@ -593,6 +708,73 @@ private function preflightDisabledReason(): ?string return null; } + private function executePromotionDisabledReason(): ?string + { + if ($this->selectionMessage !== null) { + return $this->selectionMessage; + } + + if (! is_array($this->preview)) { + return 'Run compare preview before executing promotion.'; + } + + if (! is_array($this->preflight)) { + return 'Generate a current promotion preflight before executing promotion.'; + } + + if ((int) data_get($this->preflight, 'summary.ready', 0) <= 0) { + return 'Current promotion preflight has no ready governed subjects to execute.'; + } + + $user = auth()->user(); + $workspace = $this->workspace(); + + if ($user instanceof User && $workspace instanceof Workspace) { + /** @var WorkspaceCapabilityResolver $workspaceResolver */ + $workspaceResolver = app(WorkspaceCapabilityResolver::class); + + if ($workspaceResolver->isMember($user, $workspace) + && ! $workspaceResolver->can($user, $workspace, Capabilities::WORKSPACE_BASELINES_MANAGE)) { + return 'You need workspace baseline manage access to execute promotion.'; + } + + $targetTenant = $this->selectedTargetTenant(); + + if ($targetTenant instanceof Tenant) { + /** @var CapabilityResolver $resolver */ + $resolver = app(CapabilityResolver::class); + + if (! $resolver->can($user, $targetTenant, Capabilities::TENANT_MANAGE)) { + return 'You need target tenant manage access to execute promotion.'; + } + } + } + + return null; + } + + private function executePromotionConfirmationDescription(): string + { + $selection = $this->compareSelection(); + $ready = (int) data_get($this->preflight, 'summary.ready', 0); + $blocked = (int) data_get($this->preflight, 'summary.blocked', 0); + $manualMappingRequired = (int) data_get($this->preflight, 'summary.manual_mapping_required', 0); + $excluded = $blocked + $manualMappingRequired; + + $sourceTenantName = $selection?->sourceTenant->name ?? 'Source tenant'; + $targetTenantName = $selection?->targetTenant->name ?? 'Target tenant'; + + return sprintf( + 'Queue one promotion run from %s to %s for %d ready governed subject%s. %d subject%s remain excluded on the compare page.', + $sourceTenantName, + $targetTenantName, + $ready, + $ready === 1 ? '' : 's', + $excluded, + $excluded === 1 ? '' : 's', + ); + } + /** * @param mixed $value */ diff --git a/apps/platform/app/Jobs/Operations/CrossTenantPromotionExecutionJob.php b/apps/platform/app/Jobs/Operations/CrossTenantPromotionExecutionJob.php new file mode 100644 index 00000000..9cc02ae0 --- /dev/null +++ b/apps/platform/app/Jobs/Operations/CrossTenantPromotionExecutionJob.php @@ -0,0 +1,393 @@ +operationRun = $operationRun; + } + + /** + * @return array + */ + public function middleware(): array + { + return [ + new EnsureQueuedExecutionLegitimate, + new TrackOperationRun, + ]; + } + + public function handle( + OperationRunService $operationRuns, + RestoreService $restoreService, + TargetScopeConcurrencyLimiter $limiter, + WorkspaceAuditLogger $auditLogger, + ): void { + if (! $this->operationRun instanceof OperationRun) { + throw new RuntimeException('OperationRun is required for promotion execution.'); + } + + $this->operationRun->refresh(); + + if ($this->operationRun->status === OperationRunStatus::Completed->value) { + return; + } + + $tenant = $this->operationRun->tenant; + + if (! $tenant instanceof Tenant) { + throw new RuntimeException('Promotion execution target tenant is missing.'); + } + + $context = is_array($this->operationRun->context) ? $this->operationRun->context : []; + $targetScope = is_array($context['target_scope'] ?? null) ? $context['target_scope'] : []; + + $lock = $limiter->acquireSlot((int) $tenant->getKey(), $targetScope); + + if (! $lock) { + $this->release(max(1, (int) config('tenantpilot.bulk_operations.poll_interval_seconds', 3))); + + return; + } + + try { + $plan = is_array(data_get($context, 'promotion_execution.plan')) + ? data_get($context, 'promotion_execution.plan') + : null; + + if (! is_array($plan)) { + throw new RuntimeException('Promotion execution plan is missing from operation context.'); + } + + $summary = [ + 'total' => 0, + 'processed' => 0, + 'succeeded' => 0, + 'failed' => 0, + 'skipped' => 0, + 'created' => 0, + 'updated' => 0, + ]; + $failures = []; + + $items = is_array($plan['items'] ?? null) ? array_values(array_filter($plan['items'], 'is_array')) : []; + $summary['total'] = count($items); + + [$backupSet, $selectedItemIds, $preRestoreSummary, $preRestoreFailures] = $this->buildRestoreInputs( + tenant: $tenant, + operationRun: $this->operationRun, + items: $items, + ); + + $summary = array_replace($summary, $preRestoreSummary); + $failures = array_merge($failures, $preRestoreFailures); + + $restoreRun = null; + + if ($selectedItemIds !== []) { + $restoreRun = RestoreRun::withoutEvents(fn (): RestoreRun => $restoreService->execute( + tenant: $tenant, + backupSet: $backupSet, + selectedItemIds: $selectedItemIds, + dryRun: false, + actorEmail: $this->operationRun->user?->email, + actorName: $this->operationRun->initiator_name, + providerConnectionId: is_numeric($context['provider_connection_id'] ?? null) ? (int) $context['provider_connection_id'] : null, + )); + + RestoreRun::withoutEvents(function () use ($restoreRun): void { + $restoreRun->forceFill(['operation_run_id' => (int) $this->operationRun?->getKey()])->save(); + }); + + [$restoreSummary, $restoreFailures] = $this->summaryFromRestoreRun($restoreRun, $items); + $summary = $this->mergeSummary($summary, $restoreSummary); + $failures = array_merge($failures, $restoreFailures); + + $context['restore_run_id'] = (int) $restoreRun->getKey(); + $context['backup_set_id'] = (int) $backupSet->getKey(); + $this->operationRun->forceFill(['context' => $context])->save(); + } else { + $backupSet?->delete(); + } + + $outcome = $this->outcome($summary); + + $updated = $operationRuns->updateRun( + run: $this->operationRun, + status: OperationRunStatus::Completed->value, + outcome: $outcome, + summaryCounts: $summary, + failures: $failures, + ); + + $auditLogger->logCrossTenantPromotionExecutionCompleted( + operationRun: $updated, + sourceTenantId: is_numeric($context['source_tenant_id'] ?? null) ? (int) $context['source_tenant_id'] : null, + targetTenant: $tenant, + summaryCounts: $summary, + restoreRun: $restoreRun, + ); + } catch (Throwable $exception) { + throw $exception; + } finally { + $lock->release(); + } + } + + public function getOperationRun(): ?OperationRun + { + return $this->operationRun; + } + + /** + * @param list> $items + * @return array{0: ?BackupSet, 1: list, 2: array, 3: list} + */ + private function buildRestoreInputs(Tenant $tenant, OperationRun $operationRun, array $items): array + { + $summary = [ + 'processed' => 0, + 'succeeded' => 0, + 'failed' => 0, + 'skipped' => 0, + 'created' => 0, + 'updated' => 0, + ]; + $failures = []; + $backupSet = BackupSet::query()->create([ + 'tenant_id' => (int) $tenant->getKey(), + 'name' => 'Cross-tenant promotion • Operation #'.$operationRun->getKey(), + 'created_by' => $operationRun->user?->email, + 'status' => 'completed', + 'item_count' => 0, + 'completed_at' => CarbonImmutable::now(), + 'metadata' => [ + 'source' => 'cross_tenant_promotion', + 'operation_run_id' => (int) $operationRun->getKey(), + ], + ]); + $selectedItemIds = []; + + foreach ($items as $item) { + $action = (string) ($item['execution_action'] ?? ''); + + if ($action === 'skip_aligned') { + $summary['processed']++; + $summary['skipped']++; + + continue; + } + + $versionId = data_get($item, 'source.policy_version_id'); + $sourceTenantId = data_get($item, 'source.tenant_id'); + + $version = is_numeric($versionId) && is_numeric($sourceTenantId) + ? PolicyVersion::query() + ->with('policy') + ->whereKey((int) $versionId) + ->where('tenant_id', (int) $sourceTenantId) + ->first() + : null; + + if (! $version instanceof PolicyVersion || ! $version->policy instanceof Policy) { + $summary['processed']++; + $summary['failed']++; + $failures[] = [ + 'code' => 'promotion.source_version_missing', + 'message' => 'Source policy version for '.$this->itemLabel($item).' was not found.', + ]; + + continue; + } + + $sourcePolicy = $version->policy; + $targetExternalId = data_get($item, 'target.subject_external_id'); + $sourceExternalId = data_get($item, 'source.subject_external_id'); + $policyIdentifier = is_string($targetExternalId) && trim($targetExternalId) !== '' + ? trim($targetExternalId) + : (is_string($sourceExternalId) && trim($sourceExternalId) !== '' ? trim($sourceExternalId) : (string) $sourcePolicy->external_id); + + $targetPolicy = Policy::query() + ->where('tenant_id', (int) $tenant->getKey()) + ->where('policy_type', (string) $sourcePolicy->policy_type) + ->where('external_id', $policyIdentifier) + ->first(); + + $backupItem = BackupItem::query()->create([ + 'tenant_id' => (int) $tenant->getKey(), + 'backup_set_id' => (int) $backupSet->getKey(), + 'policy_id' => $targetPolicy?->getKey(), + 'policy_identifier' => $policyIdentifier, + 'policy_type' => (string) $sourcePolicy->policy_type, + 'platform' => (string) $sourcePolicy->platform, + 'captured_at' => $version->captured_at ?? CarbonImmutable::now(), + 'payload' => is_array($version->snapshot) ? $version->snapshot : [], + 'metadata' => [ + 'source' => 'cross_tenant_promotion', + 'display_name' => (string) $sourcePolicy->display_name, + 'operation_run_id' => (int) $operationRun->getKey(), + 'source_tenant_id' => (int) $sourcePolicy->tenant_id, + 'source_policy_id' => (int) $sourcePolicy->getKey(), + 'source_policy_version_id' => (int) $version->getKey(), + 'source_subject_key' => (string) ($item['subject_key'] ?? ''), + 'execution_action' => $action, + 'target_subject_external_id' => is_string($targetExternalId) ? $targetExternalId : null, + ], + 'assignments' => is_array($version->assignments) ? $version->assignments : [], + ]); + + $selectedItemIds[] = (int) $backupItem->getKey(); + } + + $backupSet->forceFill(['item_count' => count($selectedItemIds)])->save(); + + return [$backupSet, $selectedItemIds, $summary, $failures]; + } + + /** + * @param list> $items + * @return array{0: array, 1: list} + */ + private function summaryFromRestoreRun(RestoreRun $restoreRun, array $items): array + { + $metadata = is_array($restoreRun->metadata) ? $restoreRun->metadata : []; + $results = is_array($restoreRun->results) ? $restoreRun->results : []; + $resultItems = is_array($results['items'] ?? null) ? $results['items'] : []; + $succeeded = (int) ($metadata['succeeded'] ?? 0); + $failed = (int) ($metadata['failed'] ?? 0) + (int) ($metadata['partial'] ?? 0); + $skipped = (int) ($metadata['skipped'] ?? 0); + $processed = $succeeded + $failed + $skipped; + $created = 0; + $updated = 0; + $failures = []; + + foreach ($items as $item) { + $action = (string) ($item['execution_action'] ?? ''); + + if ($action === 'create_missing') { + $created++; + } elseif ($action === 'update_existing') { + $updated++; + } + } + + foreach ($resultItems as $result) { + if (! is_array($result)) { + continue; + } + + $status = (string) ($result['status'] ?? ''); + + if (in_array($status, ['applied', 'dry_run'], true)) { + continue; + } + + $failures[] = [ + 'code' => 'promotion.restore_item_not_applied', + 'message' => (string) ($result['reason'] ?? 'Promotion restore item did not apply.'), + ]; + } + + return [[ + 'processed' => $processed, + 'succeeded' => $succeeded, + 'failed' => $failed, + 'skipped' => $skipped, + 'created' => min($created, $succeeded), + 'updated' => min($updated, max(0, $succeeded - min($created, $succeeded))), + ], $failures]; + } + + /** + * @param array $left + * @param array $right + * @return array + */ + private function mergeSummary(array $left, array $right): array + { + foreach ($right as $key => $value) { + $left[$key] = (int) ($left[$key] ?? 0) + (int) $value; + } + + return $left; + } + + /** + * @param array $summary + */ + private function outcome(array $summary): string + { + $total = (int) ($summary['total'] ?? 0); + $failed = (int) ($summary['failed'] ?? 0); + $succeeded = (int) ($summary['succeeded'] ?? 0); + $skipped = (int) ($summary['skipped'] ?? 0); + + if ($total > 0 && $failed >= $total) { + return OperationRunOutcome::Failed->value; + } + + if ($failed > 0) { + return OperationRunOutcome::PartiallySucceeded->value; + } + + if ($succeeded > 0 || $skipped > 0) { + return OperationRunOutcome::Succeeded->value; + } + + return OperationRunOutcome::Failed->value; + } + + /** + * @param array $item + */ + private function itemLabel(array $item): string + { + $displayName = (string) ($item['display_name'] ?? ''); + + if ($displayName !== '') { + return $displayName; + } + + return (string) ($item['subject_key'] ?? 'unknown subject'); + } +} diff --git a/apps/platform/app/Services/Audit/WorkspaceAuditLogger.php b/apps/platform/app/Services/Audit/WorkspaceAuditLogger.php index 208fe080..7fa5b15a 100644 --- a/apps/platform/app/Services/Audit/WorkspaceAuditLogger.php +++ b/apps/platform/app/Services/Audit/WorkspaceAuditLogger.php @@ -6,6 +6,7 @@ use App\Models\OperationRun; use App\Models\PlatformUser; +use App\Models\RestoreRun; use App\Models\SupportRequest; use App\Models\Tenant; use App\Models\User; @@ -176,6 +177,85 @@ public function logCrossTenantPromotionPreflightGenerated( ); } + /** + * @param array $plan + */ + public function logCrossTenantPromotionExecutionQueued( + Workspace $workspace, + Tenant $sourceTenant, + Tenant $targetTenant, + OperationRun $operationRun, + array $plan, + User|PlatformUser|null $actor = null, + ): \App\Models\AuditLog { + $summary = is_array($plan['summary'] ?? null) ? $plan['summary'] : []; + + return $this->log( + workspace: $workspace, + action: AuditActionId::CrossTenantPromotionExecutionQueued, + context: [ + 'source_tenant_id' => (int) $sourceTenant->getKey(), + 'source_tenant_name' => (string) $sourceTenant->name, + 'target_tenant_id' => (int) $targetTenant->getKey(), + 'target_tenant_name' => (string) $targetTenant->name, + 'selection' => is_array($plan['selection'] ?? null) ? $plan['selection'] : [], + 'ready_count' => (int) ($summary['ready'] ?? 0), + 'excluded_count' => (int) ($summary['excluded'] ?? 0), + 'created_count' => (int) ($summary['created'] ?? 0), + 'updated_count' => (int) ($summary['updated'] ?? 0), + ], + actor: $actor, + status: 'queued', + resourceType: 'operation_run', + resourceId: (string) $operationRun->getKey(), + targetLabel: $sourceTenant->name.' -> '.$targetTenant->name, + summary: 'Cross-tenant promotion execution queued for '.$sourceTenant->name.' -> '.$targetTenant->name, + operationRunId: (int) $operationRun->getKey(), + tenant: $targetTenant, + ); + } + + /** + * @param array $summaryCounts + */ + public function logCrossTenantPromotionExecutionCompleted( + OperationRun $operationRun, + ?int $sourceTenantId, + Tenant $targetTenant, + array $summaryCounts, + ?RestoreRun $restoreRun = null, + ): \App\Models\AuditLog { + $context = is_array($operationRun->context) ? $operationRun->context : []; + $sourceTenantName = is_string($context['source_tenant_name'] ?? null) + ? (string) $context['source_tenant_name'] + : null; + + return $this->log( + workspace: $targetTenant->workspace, + action: AuditActionId::CrossTenantPromotionExecutionCompleted, + context: [ + 'source_tenant_id' => $sourceTenantId, + 'source_tenant_name' => $sourceTenantName, + 'target_tenant_id' => (int) $targetTenant->getKey(), + 'target_tenant_name' => (string) $targetTenant->name, + 'summary_counts' => $summaryCounts, + 'restore_run_id' => $restoreRun?->getKey(), + 'operation_outcome' => (string) $operationRun->outcome, + ], + status: match ((string) $operationRun->outcome) { + 'failed' => 'failed', + 'partially_succeeded' => 'partial', + default => 'success', + }, + resourceType: 'operation_run', + resourceId: (string) $operationRun->getKey(), + targetLabel: ($sourceTenantName !== null ? $sourceTenantName.' -> ' : '').$targetTenant->name, + summary: 'Cross-tenant promotion execution completed for '.(($sourceTenantName !== null ? $sourceTenantName.' -> ' : '')).$targetTenant->name, + operationRunId: (int) $operationRun->getKey(), + tenant: $targetTenant, + ); + } + public function logSupportRequestCreated( SupportRequest $supportRequest, User|PlatformUser|null $actor = null, diff --git a/apps/platform/app/Services/Operations/QueuedExecutionLegitimacyGate.php b/apps/platform/app/Services/Operations/QueuedExecutionLegitimacyGate.php index b4d67016..43ab5459 100644 --- a/apps/platform/app/Services/Operations/QueuedExecutionLegitimacyGate.php +++ b/apps/platform/app/Services/Operations/QueuedExecutionLegitimacyGate.php @@ -86,6 +86,19 @@ public function evaluate(OperationRun $run): QueuedExecutionLegitimacyDecision ); } } + + if ($context->workspaceRequiredCapability !== null) { + $checks['capability'] = $this->initiatorHasRequiredWorkspaceCapability($context) ? 'passed' : 'failed'; + + if ($checks['capability'] === 'failed') { + return QueuedExecutionLegitimacyDecision::deny( + $context, + $checks, + ExecutionDenialReasonCode::MissingCapability, + ['required_capability' => $context->workspaceRequiredCapability], + ); + } + } } else { if (! $this->isSystemAuthorityAllowed($context->operationType)) { $checks['execution_prerequisites'] = 'failed'; @@ -151,6 +164,9 @@ public function buildContext(OperationRun $run): QueuedExecutionContext requiredCapability: is_string($context['required_capability'] ?? null) ? $context['required_capability'] : $this->operationRunCapabilityResolver->requiredExecutionCapabilityForType($operationType), + workspaceRequiredCapability: is_string($context['workspace_required_capability'] ?? null) + ? $context['workspace_required_capability'] + : null, providerConnectionId: $providerConnectionId, targetScope: [ 'workspace_id' => $workspaceId, @@ -259,6 +275,29 @@ private function initiatorHasRequiredCapability(QueuedExecutionContext $context) ); } + private function initiatorHasRequiredWorkspaceCapability(QueuedExecutionContext $context): bool + { + if (! $context->initiator instanceof User || ! is_string($context->workspaceRequiredCapability) || $context->workspaceRequiredCapability === '') { + return false; + } + + if ($context->workspaceId <= 0) { + return false; + } + + $workspace = $context->run->tenant?->workspace ?? $context->run->workspace()->first(); + + if ($workspace === null) { + return false; + } + + return $this->workspaceCapabilityResolver->can( + $context->initiator, + $workspace, + $context->workspaceRequiredCapability, + ); + } + /** * @return list */ @@ -270,7 +309,7 @@ private function prerequisiteClassesFor(string $operationType, ?int $providerCon $prerequisites[] = 'provider_connection'; } - if (str_starts_with($operationType, 'restore.')) { + if (str_starts_with($operationType, 'restore.') || $operationType === 'promotion.execute') { $prerequisites[] = 'write_gate'; } @@ -279,6 +318,10 @@ private function prerequisiteClassesFor(string $operationType, ?int $providerCon private function questionForContext(QueuedExecutionContext $context): TenantOperabilityQuestion { + if ($context->operationType === 'promotion.execute') { + return TenantOperabilityQuestion::RestoreEligibility; + } + if ($context->providerConnectionId !== null || in_array($context->operationType, ['provider.connection.check', 'compliance.snapshot', 'provider.compliance.snapshot'], true)) { return TenantOperabilityQuestion::VerificationReadinessEligibility; } diff --git a/apps/platform/app/Services/PortfolioCompare/CrossTenantPromotionExecutionService.php b/apps/platform/app/Services/PortfolioCompare/CrossTenantPromotionExecutionService.php new file mode 100644 index 00000000..8e99e28d --- /dev/null +++ b/apps/platform/app/Services/PortfolioCompare/CrossTenantPromotionExecutionService.php @@ -0,0 +1,161 @@ + $preview + * @param array $preflight + */ + public function start( + CrossTenantCompareSelection $selection, + array $preview, + array $preflight, + User $actor, + ): ProviderOperationStartResult { + $workspace = $selection->targetTenant->workspace; + + if (! $workspace instanceof Workspace) { + throw new \RuntimeException('Promotion execution requires a workspace context.'); + } + + $decision = $this->operationalControls->evaluate('promotion.execute', $workspace); + + if ($decision->isPaused()) { + $this->auditLogger->log( + workspace: $workspace, + action: AuditActionId::OperationalControlExecutionBlocked, + context: [ + 'metadata' => array_filter([ + 'control_key' => $decision->controlKey, + 'scope_type' => $decision->matchedScopeType, + 'workspace_id' => (int) $workspace->getKey(), + 'reason_text' => $decision->reasonText, + 'expires_at' => $decision->expiresAt?->toIso8601String(), + 'actor_id' => (int) $actor->getKey(), + 'source_tenant_id' => (int) $selection->sourceTenant->getKey(), + 'target_tenant_id' => (int) $selection->targetTenant->getKey(), + 'requested_scope' => 'promotion.execute', + ], static fn (mixed $value): bool => $value !== null && $value !== ''), + ], + actor: $actor, + status: 'blocked', + resourceType: 'operational_control', + resourceId: $decision->sourceActivationId !== null ? (string) $decision->sourceActivationId : null, + targetLabel: 'Promotion execution', + summary: 'Promotion execution blocked by operational control', + tenant: $selection->targetTenant, + ); + + throw OperationalControlBlockedException::forDecision($decision, 'Promotion execution'); + } + + $plan = $this->planner->build($preview, $preflight); + $providerConnection = $this->defaultProviderConnection((int) $selection->targetTenant->getKey()); + $now = CarbonImmutable::now(); + + $identity = array_replace($plan['identity'], [ + 'provider_connection_id' => $providerConnection?->getKey(), + ]); + + $context = [ + 'operation_type' => 'promotion.execute', + 'source_tenant_id' => (int) $selection->sourceTenant->getKey(), + 'source_tenant_name' => (string) $selection->sourceTenant->name, + 'target_tenant_id' => (int) $selection->targetTenant->getKey(), + 'target_tenant_name' => (string) $selection->targetTenant->name, + 'provider_connection_id' => $providerConnection instanceof ProviderConnection ? (int) $providerConnection->getKey() : null, + 'required_capability' => Capabilities::TENANT_MANAGE, + 'workspace_required_capability' => Capabilities::WORKSPACE_BASELINES_MANAGE, + 'target_scope' => [ + 'workspace_id' => (int) $workspace->getKey(), + 'tenant_id' => (int) $selection->targetTenant->getKey(), + 'provider_connection_id' => $providerConnection instanceof ProviderConnection ? (int) $providerConnection->getKey() : null, + 'entra_tenant_id' => $providerConnection instanceof ProviderConnection + ? (string) $providerConnection->entra_tenant_id + : (string) ($selection->targetTenant->tenant_id ?? $selection->targetTenant->external_id ?? $selection->targetTenant->getKey()), + ], + 'promotion_execution' => [ + 'queued_at' => $now->toIso8601String(), + 'queued_by_user_id' => (int) $actor->getKey(), + 'plan' => $plan, + ], + 'selection' => $plan['selection'], + ]; + + $run = $this->operationRuns->ensureRunWithIdentity( + tenant: $selection->targetTenant, + type: 'promotion.execute', + identityInputs: $identity, + context: $context, + initiator: $actor, + ); + + if (! $run->wasRecentlyCreated) { + return ProviderOperationStartResult::deduped($run); + } + + $this->operationRuns->updateRun($run, OperationRunStatus::Queued->value, summaryCounts: [ + 'total' => (int) $plan['summary']['ready'], + 'processed' => 0, + 'succeeded' => 0, + 'failed' => 0, + 'skipped' => 0, + 'created' => 0, + 'updated' => 0, + ]); + + $this->operationRuns->dispatchOrFail( + $run, + fn (OperationRun $operationRun): mixed => CrossTenantPromotionExecutionJob::dispatch($operationRun), + ); + + $this->auditLogger->logCrossTenantPromotionExecutionQueued( + workspace: $workspace, + sourceTenant: $selection->sourceTenant, + targetTenant: $selection->targetTenant, + operationRun: $run->fresh() ?? $run, + plan: $plan, + actor: $actor, + ); + + return ProviderOperationStartResult::started($run->fresh() ?? $run, true); + } + + private function defaultProviderConnection(int $tenantId): ?ProviderConnection + { + return ProviderConnection::query() + ->where('tenant_id', $tenantId) + ->where('provider', 'microsoft') + ->where('is_default', true) + ->orderBy('id') + ->first(); + } +} diff --git a/apps/platform/app/Support/Audit/AuditActionId.php b/apps/platform/app/Support/Audit/AuditActionId.php index 3ff2662c..10ad9e63 100644 --- a/apps/platform/app/Support/Audit/AuditActionId.php +++ b/apps/platform/app/Support/Audit/AuditActionId.php @@ -73,6 +73,8 @@ enum AuditActionId: string case BaselineCompareCompleted = 'baseline_compare.completed'; case BaselineCompareFailed = 'baseline_compare.failed'; case CrossTenantPromotionPreflightGenerated = 'cross_tenant_promotion_preflight.generated'; + case CrossTenantPromotionExecutionQueued = 'cross_tenant_promotion_execution.queued'; + case CrossTenantPromotionExecutionCompleted = 'cross_tenant_promotion_execution.completed'; case BaselineAssignmentCreated = 'baseline_assignment.created'; case BaselineAssignmentUpdated = 'baseline_assignment.updated'; case BaselineAssignmentDeleted = 'baseline_assignment.deleted'; @@ -227,6 +229,8 @@ private static function labels(): array self::BaselineCompareCompleted->value => 'Baseline compare completed', self::BaselineCompareFailed->value => 'Baseline compare failed', self::CrossTenantPromotionPreflightGenerated->value => 'Cross-tenant promotion preflight generated', + self::CrossTenantPromotionExecutionQueued->value => 'Cross-tenant promotion execution queued', + self::CrossTenantPromotionExecutionCompleted->value => 'Cross-tenant promotion execution completed', self::BaselineAssignmentCreated->value => 'Baseline assignment created', self::BaselineAssignmentUpdated->value => 'Baseline assignment updated', self::BaselineAssignmentDeleted->value => 'Baseline assignment deleted', @@ -326,6 +330,8 @@ private static function summaries(): array self::BaselineProfileArchived->value => 'Baseline profile archived', self::BaselineProfileScopeBackfilled->value => 'Baseline profile scope backfilled', self::CrossTenantPromotionPreflightGenerated->value => 'Cross-tenant promotion preflight generated', + self::CrossTenantPromotionExecutionQueued->value => 'Cross-tenant promotion execution queued', + self::CrossTenantPromotionExecutionCompleted->value => 'Cross-tenant promotion execution completed', self::AlertDestinationCreated->value => 'Alert destination created', self::AlertDestinationUpdated->value => 'Alert destination updated', self::AlertDestinationDeleted->value => 'Alert destination deleted', diff --git a/apps/platform/app/Support/OperationCatalog.php b/apps/platform/app/Support/OperationCatalog.php index 5778b251..65925c3f 100644 --- a/apps/platform/app/Support/OperationCatalog.php +++ b/apps/platform/app/Support/OperationCatalog.php @@ -257,6 +257,7 @@ private static function canonicalDefinitions(): array 'backup.schedule.retention' => new CanonicalOperationType('backup.schedule.retention', 'intune', null, 'Backup schedule retention'), 'backup.schedule.purge' => new CanonicalOperationType('backup.schedule.purge', 'intune', null, 'Backup schedule purge'), 'restore.execute' => new CanonicalOperationType('restore.execute', 'intune', null, 'Restore execution'), + 'promotion.execute' => new CanonicalOperationType('promotion.execute', 'platform_foundation', null, 'Promotion execution', true, 120), 'assignments.fetch' => new CanonicalOperationType('assignments.fetch', 'intune', null, 'Assignment fetch', false, 60), 'assignments.restore' => new CanonicalOperationType('assignments.restore', 'intune', null, 'Assignment restore', false, 60), 'ops.reconcile_adapter_runs' => new CanonicalOperationType('ops.reconcile_adapter_runs', 'platform_foundation', null, 'Reconcile adapter runs', false, 120), @@ -315,6 +316,7 @@ private static function operationAliases(): array new OperationTypeAlias('backup.schedule.purge', 'backup.schedule.purge', 'canonical', true), new OperationTypeAlias('backup_schedule_purge', 'backup.schedule.purge', 'legacy_alias', false, 'Legacy backup schedule purge values resolve to backup.schedule.purge.', 'Prefer dotted canonical backup schedule naming on new read paths.'), new OperationTypeAlias('restore.execute', 'restore.execute', 'canonical', true), + new OperationTypeAlias('promotion.execute', 'promotion.execute', 'canonical', true), new OperationTypeAlias('assignments.fetch', 'assignments.fetch', 'canonical', true), new OperationTypeAlias('assignments.restore', 'assignments.restore', 'canonical', true), new OperationTypeAlias('ops.reconcile_adapter_runs', 'ops.reconcile_adapter_runs', 'canonical', true), diff --git a/apps/platform/app/Support/OperationRunType.php b/apps/platform/app/Support/OperationRunType.php index 97c35248..6cc87010 100644 --- a/apps/platform/app/Support/OperationRunType.php +++ b/apps/platform/app/Support/OperationRunType.php @@ -15,6 +15,7 @@ enum OperationRunType: string case BackupSchedulePurge = 'backup.schedule.purge'; case DirectoryRoleDefinitionsSync = 'directory.role_definitions.sync'; case RestoreExecute = 'restore.execute'; + case PromotionExecute = 'promotion.execute'; case EntraAdminRolesScan = 'entra.admin_roles.scan'; case ReviewPackGenerate = 'tenant.review_pack.generate'; case TenantReviewCompose = 'tenant.review.compose'; diff --git a/apps/platform/app/Support/OperationalControls/OperationalControlCatalog.php b/apps/platform/app/Support/OperationalControls/OperationalControlCatalog.php index c50d6e94..693b9985 100644 --- a/apps/platform/app/Support/OperationalControls/OperationalControlCatalog.php +++ b/apps/platform/app/Support/OperationalControls/OperationalControlCatalog.php @@ -17,6 +17,13 @@ final class OperationalControlCatalog 'operation_types' => ['restore.execute'], 'affected_surfaces' => ['tenant.restore_runs.create'], ], + 'promotion.execute' => [ + 'key' => 'promotion.execute', + 'label' => 'Promotion execution', + 'supported_scopes' => ['global', 'workspace'], + 'operation_types' => ['promotion.execute'], + 'affected_surfaces' => ['admin.cross_tenant_compare.execute'], + ], 'ai.execution' => [ 'key' => 'ai.execution', 'label' => 'AI execution', diff --git a/apps/platform/app/Support/Operations/OperationRunCapabilityResolver.php b/apps/platform/app/Support/Operations/OperationRunCapabilityResolver.php index da627a8a..b8327e09 100644 --- a/apps/platform/app/Support/Operations/OperationRunCapabilityResolver.php +++ b/apps/platform/app/Support/Operations/OperationRunCapabilityResolver.php @@ -25,7 +25,7 @@ public function requiredCapabilityForType(string $operationType): ?string 'inventory.sync' => Capabilities::TENANT_INVENTORY_SYNC_RUN, 'directory.groups.sync' => Capabilities::TENANT_SYNC, 'backup.schedule.execute', 'backup.schedule.retention', 'backup.schedule.purge' => Capabilities::TENANT_BACKUP_SCHEDULES_RUN, - 'restore.execute' => Capabilities::TENANT_MANAGE, + 'restore.execute', 'promotion.execute' => Capabilities::TENANT_MANAGE, 'directory.role_definitions.sync' => Capabilities::TENANT_MANAGE, 'alerts.evaluate', 'alerts.deliver' => Capabilities::ALERTS_VIEW, 'tenant.review.compose' => Capabilities::TENANT_REVIEW_VIEW, @@ -51,7 +51,7 @@ public function requiredExecutionCapabilityForType(string $operationType): ?stri 'provider.connection.check', 'inventory.sync', 'compliance.snapshot' => Capabilities::PROVIDER_RUN, 'policy.sync', 'tenant.sync' => Capabilities::TENANT_SYNC, 'policy.delete' => Capabilities::TENANT_MANAGE, - 'assignments.restore', 'restore.execute' => Capabilities::TENANT_MANAGE, + 'assignments.restore', 'restore.execute', 'promotion.execute' => Capabilities::TENANT_MANAGE, 'tenant.review.compose' => Capabilities::TENANT_REVIEW_MANAGE, default => $this->requiredCapabilityForType($operationType), }; diff --git a/apps/platform/app/Support/Operations/QueuedExecutionContext.php b/apps/platform/app/Support/Operations/QueuedExecutionContext.php index 024ded08..1e3cf105 100644 --- a/apps/platform/app/Support/Operations/QueuedExecutionContext.php +++ b/apps/platform/app/Support/Operations/QueuedExecutionContext.php @@ -23,6 +23,7 @@ public function __construct( public ?User $initiator, public ExecutionAuthorityMode $authorityMode, public ?string $requiredCapability, + public ?string $workspaceRequiredCapability, public ?int $providerConnectionId, public array $targetScope, public array $prerequisiteClasses = [], diff --git a/apps/platform/app/Support/PortfolioCompare/CrossTenantPromotionExecutionPlanner.php b/apps/platform/app/Support/PortfolioCompare/CrossTenantPromotionExecutionPlanner.php new file mode 100644 index 00000000..35da13ed --- /dev/null +++ b/apps/platform/app/Support/PortfolioCompare/CrossTenantPromotionExecutionPlanner.php @@ -0,0 +1,271 @@ + $preview + * @param array $preflight + * @return array{ + * selection: array, + * summary: array{total: int, ready: int, excluded: int, skipped: int, created: int, updated: int}, + * items: list>, + * excluded: list>, + * identity: array + * } + */ + public function build(array $preview, array $preflight): array + { + $previewSelection = $this->selection($preview); + $preflightSelection = $this->selection($preflight); + + if ($previewSelection !== $preflightSelection) { + throw new InvalidArgumentException('Promotion preflight is stale. Regenerate the preflight before execution.'); + } + + $items = []; + $excluded = $this->excludedSubjects($preflight); + + foreach ($this->readySubjects($preflight) as $subject) { + $item = $this->executionItem($subject); + + if ($item === null) { + $excluded[] = $this->excludedSubject($subject, 'source_policy_version_missing'); + + continue; + } + + $items[] = $item; + } + + $items = $this->sortItems($items); + $excluded = $this->sortItems($excluded); + + if ($items === []) { + throw new DomainException('Promotion preflight has no executable ready subjects.'); + } + + $summary = [ + 'total' => count($items) + count($excluded), + 'ready' => count($items), + 'excluded' => count($excluded), + 'skipped' => count(array_filter($items, static fn (array $item): bool => ($item['execution_action'] ?? null) === 'skip_aligned')), + 'created' => count(array_filter($items, static fn (array $item): bool => ($item['execution_action'] ?? null) === 'create_missing')), + 'updated' => count(array_filter($items, static fn (array $item): bool => ($item['execution_action'] ?? null) === 'update_existing')), + ]; + + return [ + 'selection' => $previewSelection, + 'summary' => $summary, + 'items' => $items, + 'excluded' => $excluded, + 'identity' => $this->identity($previewSelection, $items), + ]; + } + + /** + * @param array $payload + * @return array{sourceTenantId: ?int, targetTenantId: ?int, policyTypes: list} + */ + private function selection(array $payload): array + { + $selection = is_array($payload['selection'] ?? null) ? $payload['selection'] : []; + $policyTypes = is_array($selection['policyTypes'] ?? null) ? $selection['policyTypes'] : []; + + $policyTypes = array_values(array_unique(array_filter(array_map( + static fn (mixed $value): string => is_string($value) ? trim($value) : '', + $policyTypes, + ), static fn (string $value): bool => $value !== ''))); + + sort($policyTypes); + + return [ + 'sourceTenantId' => is_numeric($selection['sourceTenantId'] ?? null) ? (int) $selection['sourceTenantId'] : null, + 'targetTenantId' => is_numeric($selection['targetTenantId'] ?? null) ? (int) $selection['targetTenantId'] : null, + 'policyTypes' => $policyTypes, + ]; + } + + /** + * @param array $preflight + * @return list> + */ + private function readySubjects(array $preflight): array + { + $subjects = data_get($preflight, 'buckets.ready', []); + + if (! is_array($subjects)) { + return []; + } + + return array_values(array_filter($subjects, 'is_array')); + } + + /** + * @param array $preflight + * @return list> + */ + private function excludedSubjects(array $preflight): array + { + $excluded = []; + + foreach (['blocked', 'manual_mapping_required'] as $bucket) { + $subjects = data_get($preflight, 'buckets.'.$bucket, []); + + if (! is_array($subjects)) { + continue; + } + + foreach ($subjects as $subject) { + if (! is_array($subject)) { + continue; + } + + $excluded[] = $this->excludedSubject($subject, $bucket); + } + } + + return $excluded; + } + + /** + * @param array $subject + * @return array|null + */ + private function executionItem(array $subject): ?array + { + $policyVersionId = data_get($subject, 'source.evidence.policyVersionId'); + + if (! is_numeric($policyVersionId)) { + return null; + } + + $state = is_string($subject['state'] ?? null) ? (string) $subject['state'] : 'blocked'; + $action = match ($state) { + 'match' => 'skip_aligned', + 'missing' => 'create_missing', + default => 'update_existing', + }; + + return [ + 'policy_type' => $this->stringValue($subject, 'policyType'), + 'display_name' => $this->stringValue($subject, 'displayName'), + 'subject_key' => $this->stringValue($subject, 'subjectKey'), + 'compare_state' => $state, + 'execution_action' => $action, + 'readiness_reason_codes' => $this->stringList(data_get($subject, 'preflight.reasonCodes', [])), + 'source' => [ + 'tenant_id' => $this->intValue(data_get($subject, 'source.tenantId')), + 'inventory_item_id' => $this->intValue(data_get($subject, 'source.inventoryItemId')), + 'subject_external_id' => $this->nullableString(data_get($subject, 'source.subjectExternalId')), + 'policy_version_id' => (int) $policyVersionId, + 'evidence_hash' => $this->nullableString(data_get($subject, 'source.evidence.hash')), + ], + 'target' => [ + 'tenant_id' => $this->intValue(data_get($subject, 'target.tenantId')), + 'inventory_item_id' => $this->intValue(data_get($subject, 'target.inventoryItemId')), + 'subject_external_id' => $this->nullableString(data_get($subject, 'target.subjectExternalId')), + ], + ]; + } + + /** + * @param array $subject + * @return array + */ + private function excludedSubject(array $subject, string $reason): array + { + return [ + 'policy_type' => $this->stringValue($subject, 'policyType'), + 'display_name' => $this->stringValue($subject, 'displayName'), + 'subject_key' => $this->stringValue($subject, 'subjectKey'), + 'compare_state' => $this->stringValue($subject, 'state'), + 'excluded_reason' => $reason, + 'reason_codes' => $this->stringList(data_get($subject, 'preflight.reasonCodes', [])), + ]; + } + + /** + * @param list> $items + * @return list> + */ + private function sortItems(array $items): array + { + usort($items, static function (array $left, array $right): int { + return [ + (string) ($left['policy_type'] ?? ''), + (string) ($left['subject_key'] ?? ''), + (string) ($left['display_name'] ?? ''), + ] <=> [ + (string) ($right['policy_type'] ?? ''), + (string) ($right['subject_key'] ?? ''), + (string) ($right['display_name'] ?? ''), + ]; + }); + + return $items; + } + + /** + * @param array $selection + * @param list> $items + * @return array + */ + private function identity(array $selection, array $items): array + { + return [ + 'source_tenant_id' => $selection['sourceTenantId'] ?? null, + 'target_tenant_id' => $selection['targetTenantId'] ?? null, + 'policy_types' => $selection['policyTypes'] ?? [], + 'subjects' => array_map(static fn (array $item): array => [ + 'policy_type' => $item['policy_type'] ?? '', + 'subject_key' => $item['subject_key'] ?? '', + 'source_policy_version_id' => data_get($item, 'source.policy_version_id'), + 'source_evidence_hash' => data_get($item, 'source.evidence_hash'), + 'target_subject_external_id' => data_get($item, 'target.subject_external_id'), + 'execution_action' => $item['execution_action'] ?? '', + ], $items), + ]; + } + + /** + * @param array $subject + */ + private function stringValue(array $subject, string $key): string + { + $value = $subject[$key] ?? null; + + return is_string($value) ? $value : ''; + } + + private function nullableString(mixed $value): ?string + { + return is_string($value) && trim($value) !== '' ? trim($value) : null; + } + + private function intValue(mixed $value): ?int + { + return is_numeric($value) ? (int) $value : null; + } + + /** + * @return list + */ + private function stringList(mixed $values): array + { + if (! is_array($values)) { + return []; + } + + return array_values(array_filter(array_map( + static fn (mixed $value): string => is_string($value) ? trim($value) : '', + $values, + ), static fn (string $value): bool => $value !== '')); + } +} diff --git a/apps/platform/config/tenantpilot.php b/apps/platform/config/tenantpilot.php index 89e1b87b..ed732d18 100644 --- a/apps/platform/config/tenantpilot.php +++ b/apps/platform/config/tenantpilot.php @@ -92,6 +92,14 @@ 'direct_failed_bridge' => false, 'scheduled_reconciliation' => true, ], + 'promotion.execute' => [ + 'job_class' => \App\Jobs\Operations\CrossTenantPromotionExecutionJob::class, + 'queued_stale_after_seconds' => 300, + 'running_stale_after_seconds' => 1500, + 'expected_max_runtime_seconds' => 420, + 'direct_failed_bridge' => false, + 'scheduled_reconciliation' => true, + ], 'tenant.review_pack.generate' => [ 'job_class' => \App\Jobs\GenerateReviewPackJob::class, 'queued_stale_after_seconds' => 300, diff --git a/apps/platform/resources/views/filament/pages/cross-tenant-compare.blade.php b/apps/platform/resources/views/filament/pages/cross-tenant-compare.blade.php index bfe60b0e..d0ac2477 100644 --- a/apps/platform/resources/views/filament/pages/cross-tenant-compare.blade.php +++ b/apps/platform/resources/views/filament/pages/cross-tenant-compare.blade.php @@ -12,7 +12,7 @@ - Compare one authorized source tenant to one authorized target tenant from a canonical workspace surface. Preview stays read only and promotion remains preflight only. + Compare one authorized source tenant to one authorized target tenant from a canonical workspace surface. Preview stays read only until you explicitly confirm promotion execution.
@@ -147,7 +147,7 @@ @if ($preflight !== null) - Read-only readiness view. No target mutation, queue dispatch, or operation run is created in this slice. + Read-only readiness view until you explicitly confirm Execute promotion. Target mutation happens only through the queued operation run.
diff --git a/apps/platform/tests/Browser/PortfolioCompare/CrossTenantPromotionExecutionSmokeTest.php b/apps/platform/tests/Browser/PortfolioCompare/CrossTenantPromotionExecutionSmokeTest.php new file mode 100644 index 00000000..84df205c --- /dev/null +++ b/apps/platform/tests/Browser/PortfolioCompare/CrossTenantPromotionExecutionSmokeTest.php @@ -0,0 +1,56 @@ +browser()->timeout(15_000); + +it('smokes queued promotion execution handoff from compare page into the operation viewer', function (): void { + $fixture = $this->makeCrossTenantCompareFixture(); + + $this->createPortfolioCompareSubject( + tenant: $fixture['sourceTenant'], + displayName: 'Browser Promotion Policy', + snapshot: ['settings' => [['key' => 'browser', 'value' => 1]]], + ); + + $this->actingAs($fixture['user'])->withSession([ + WorkspaceContext::SESSION_KEY => (int) $fixture['workspace']->getKey(), + ]); + session()->put(WorkspaceContext::SESSION_KEY, (int) $fixture['workspace']->getKey()); + + $page = visit(CrossTenantComparePage::getUrl(parameters: [ + 'source_tenant_id' => (int) $fixture['sourceTenant']->getKey(), + 'target_tenant_id' => (int) $fixture['targetTenant']->getKey(), + 'policy_type' => ['deviceConfiguration'], + ], panel: 'admin')); + + $page + ->assertNoJavaScriptErrors() + ->waitForText('Cross-tenant compare') + ->assertSee('Compare preview') + ->click('Generate promotion preflight') + ->waitForText('Promotion preflight') + ->assertSee('Execute promotion') + ->click('Execute promotion') + ->waitForText('Queue promotion') + ->click('Queue promotion') + ->waitForText('Promotion execution queued') + ->assertSee('Open operation'); + + $run = OperationRun::query()->latest('id')->firstOrFail(); + + $page + ->click('Open operation') + ->waitForText(OperationRunLinks::identifier((int) $run->getKey())) + ->assertRoute('admin.operations.view', ['run' => (int) $run->getKey()]) + ->assertNoJavaScriptErrors() + ->assertSee(OperationRunLinks::identifier((int) $run->getKey())); +}); \ No newline at end of file diff --git a/apps/platform/tests/Feature/PortfolioCompare/CrossTenantCompareLaunchContextTest.php b/apps/platform/tests/Feature/PortfolioCompare/CrossTenantCompareLaunchContextTest.php index f51742b5..e95c9513 100644 --- a/apps/platform/tests/Feature/PortfolioCompare/CrossTenantCompareLaunchContextTest.php +++ b/apps/platform/tests/Feature/PortfolioCompare/CrossTenantCompareLaunchContextTest.php @@ -4,19 +4,24 @@ use App\Filament\Pages\CrossTenantComparePage; use App\Filament\Resources\TenantResource; +use App\Jobs\Operations\CrossTenantPromotionExecutionJob; +use App\Models\OperationRun; use App\Models\Tenant; use App\Services\Auth\CapabilityResolver; use App\Services\Auth\WorkspaceCapabilityResolver; use App\Support\Auth\Capabilities; use App\Support\BackupHealth\TenantBackupHealthAssessment; +use App\Support\Navigation\CanonicalNavigationContext; use App\Support\RestoreSafety\RestoreResultAttention; use App\Support\Tenants\TenantRecoveryTriagePresentation; use Filament\Actions\Action; use Illuminate\Foundation\Testing\RefreshDatabase; +use Illuminate\Support\Facades\Queue; use Livewire\Livewire; +use Tests\Feature\Concerns\BuildsPortfolioCompareFixtures; use Tests\Feature\Concerns\BuildsPortfolioTriageFixtures; -uses(RefreshDatabase::class, BuildsPortfolioTriageFixtures::class); +uses(RefreshDatabase::class, BuildsPortfolioTriageFixtures::class, BuildsPortfolioCompareFixtures::class); function crossTenantCompareLaunchQuery(string $url): array { @@ -119,6 +124,80 @@ function crossTenantCompareLaunchQuery(string $url): array ->assertActionVisible('return_to_origin'); }); +it('keeps launch context after queueing promotion from an exact-two registry launch', function (): void { + Queue::fake(); + + [$user, $anchorTenant] = $this->makePortfolioTriageActor( + tenantName: 'Anchor Tenant', + workspaceRole: 'owner', + ); + $targetTenant = $this->makePortfolioTriagePeer($user, $anchorTenant, 'Target Tenant'); + createMinimalUserWithTenant( + tenant: $targetTenant, + user: $user, + role: 'owner', + workspaceRole: 'owner', + ); + + $this->createPortfolioCompareSubject( + tenant: $anchorTenant, + displayName: 'Queued Launch Context Policy', + snapshot: ['settings' => [['key' => 'launch-context', 'value' => 1]]], + ); + + $triageState = $this->portfolioReturnFilters( + [TenantBackupHealthAssessment::POSTURE_STALE], + [TenantRecoveryTriagePresentation::RECOVERY_EVIDENCE_WEAKENED], + [], + TenantRecoveryTriagePresentation::TRIAGE_SORT_WORST_FIRST, + ); + + $expectedUrl = TenantResource::crossTenantCompareOpenUrlForSelection( + targetTenant: $targetTenant, + triageState: $triageState, + sourceTenant: $anchorTenant, + ); + $expectedBackUrl = TenantResource::getUrl(panel: 'admin', parameters: $triageState); + $query = crossTenantCompareLaunchQuery($expectedUrl); + $query['policy_type'] = ['deviceConfiguration']; + + $this->usePortfolioTriageWorkspace($user, $anchorTenant); + + $component = Livewire::withQueryParams($query) + ->actingAs($user) + ->test(CrossTenantComparePage::class) + ->assertSet('sourceTenantId', (string) $anchorTenant->getKey()) + ->assertSet('targetTenantId', (string) $targetTenant->getKey()) + ->assertSet('selectedPolicyTypes', ['deviceConfiguration']) + ->assertActionVisible('return_to_origin') + ->assertActionExists('return_to_origin', fn (Action $action): bool => $action->getLabel() === 'Back to tenant registry' + && $action->getUrl() === $expectedBackUrl); + + $page = $component->instance(); + $page->generatePromotionPreflight(); + $page->executePromotion(); + + $run = OperationRun::query()->latest('id')->first(); + $navigationContext = CanonicalNavigationContext::fromPayload($page->navigationContextPayload); + + expect($run) + ->not->toBeNull() + ->and($run?->type)->toBe('promotion.execute') + ->and(data_get($run?->context, 'selection.sourceTenantId'))->toBe((int) $anchorTenant->getKey()) + ->and(data_get($run?->context, 'selection.targetTenantId'))->toBe((int) $targetTenant->getKey()) + ->and(data_get($run?->context, 'selection.policyTypes'))->toBe(['deviceConfiguration']) + ->and($page->sourceTenantId)->toBe((string) $anchorTenant->getKey()) + ->and($page->targetTenantId)->toBe((string) $targetTenant->getKey()) + ->and($page->selectedPolicyTypes)->toBe(['deviceConfiguration']) + ->and($page->navigationContextPayload)->toBe($query['nav']) + ->and($navigationContext?->backLinkLabel)->toBe('Back to tenant registry') + ->and($navigationContext?->backLinkUrl)->toBe($expectedBackUrl); + + Queue::assertPushed(CrossTenantPromotionExecutionJob::class, function (CrossTenantPromotionExecutionJob $job) use ($run): bool { + return $job->getOperationRun()?->is($run); + }); +}); + it('rejects the bulk compare action until exactly two active tenants are selected', function (): void { [$user, $anchorTenant] = $this->makePortfolioTriageActor('Anchor Tenant'); $targetTenant = $this->makePortfolioTriagePeer($user, $anchorTenant, 'Target Tenant'); diff --git a/apps/platform/tests/Feature/PortfolioCompare/CrossTenantComparePageTest.php b/apps/platform/tests/Feature/PortfolioCompare/CrossTenantComparePageTest.php index ddd5b709..f4a8629a 100644 --- a/apps/platform/tests/Feature/PortfolioCompare/CrossTenantComparePageTest.php +++ b/apps/platform/tests/Feature/PortfolioCompare/CrossTenantComparePageTest.php @@ -65,6 +65,32 @@ ->assertSee('Windows Compliance'); }); +it('shows only one dominant promotion action at a time on the compare page', function (): void { + $fixture = $this->makeCrossTenantCompareFixture(); + + $this->createPortfolioCompareSubject( + tenant: $fixture['sourceTenant'], + displayName: 'Promotable Policy', + snapshot: ['settings' => [['key' => 'wifi', 'value' => 1]]], + ); + + $this->setAdminWorkspaceContext($fixture['user'], $fixture['workspace']); + + Livewire::withQueryParams([ + 'source_tenant_id' => (int) $fixture['sourceTenant']->getKey(), + 'target_tenant_id' => (int) $fixture['targetTenant']->getKey(), + 'policy_type' => ['deviceConfiguration'], + ]) + ->actingAs($fixture['user']) + ->test(CrossTenantComparePage::class) + ->assertActionVisible('generatePromotionPreflight') + ->assertActionHidden('executePromotion') + ->call('generatePromotionPreflight') + ->assertDontSee('Generate promotion preflight') + ->assertSee('Execute promotion') + ->assertActionVisible('executePromotion'); +}); + it('rejects the same tenant as source and target without rendering compare results', function (): void { $fixture = $this->makeCrossTenantCompareFixture(); diff --git a/apps/platform/tests/Feature/PortfolioCompare/CrossTenantPromotionExecutionActionTest.php b/apps/platform/tests/Feature/PortfolioCompare/CrossTenantPromotionExecutionActionTest.php new file mode 100644 index 00000000..f6c1648b --- /dev/null +++ b/apps/platform/tests/Feature/PortfolioCompare/CrossTenantPromotionExecutionActionTest.php @@ -0,0 +1,119 @@ +makeCrossTenantCompareFixture(); + + $this->createPortfolioCompareSubject( + tenant: $fixture['sourceTenant'], + displayName: 'Promotable Policy', + snapshot: ['settings' => [['key' => 'wifi', 'value' => 1]]], + ); + $blocked = $this->createPortfolioCompareSubject( + tenant: $fixture['sourceTenant'], + displayName: 'Blocked Policy', + snapshot: ['settings' => [['key' => 'blocked', 'value' => 1]]], + ); + $blocked['version']->delete(); + $this->createPortfolioCompareSubject( + tenant: $fixture['sourceTenant'], + displayName: 'Manual Policy', + snapshot: ['settings' => [['key' => 'manual', 'value' => 1]]], + ); + $this->createPortfolioCompareSubject( + tenant: $fixture['targetTenant'], + displayName: 'Manual Policy', + snapshot: ['settings' => [['key' => 'manual', 'value' => 1]]], + ); + $this->createPortfolioCompareSubject( + tenant: $fixture['targetTenant'], + displayName: 'Manual Policy', + snapshot: ['settings' => [['key' => 'manual', 'value' => 2]]], + ); + + $this->setAdminWorkspaceContext($fixture['user'], $fixture['workspace']); + + $query = [ + 'source_tenant_id' => (int) $fixture['sourceTenant']->getKey(), + 'target_tenant_id' => (int) $fixture['targetTenant']->getKey(), + 'policy_type' => ['deviceConfiguration'], + ]; + + $component = Livewire::withQueryParams($query) + ->actingAs($fixture['user']) + ->test(CrossTenantComparePage::class) + ->call('executePromotion') + ->assertNotified('Promotion execution unavailable'); + + expect(OperationRun::query()->count())->toBe(0); + + $component + ->call('generatePromotionPreflight') + ->assertActionVisible('executePromotion') + ->mountAction('executePromotion') + ->callMountedAction() + ->assertHasNoActionErrors(); + + $run = OperationRun::query()->latest('id')->first(); + + expect($run) + ->not->toBeNull() + ->and($run?->type)->toBe('promotion.execute') + ->and(data_get($run?->context, 'promotion_execution.plan.summary.ready'))->toBe(1) + ->and(data_get($run?->context, 'promotion_execution.plan.summary.excluded'))->toBe(2) + ->and(data_get($run?->context, 'promotion_execution.plan.items.0.display_name'))->toBe('Promotable Policy') + ->and(data_get($run?->context, 'promotion_execution.plan.items.0.execution_action'))->toBe('create_missing') + ->and(collect(data_get($run?->context, 'promotion_execution.plan.excluded', []))->pluck('excluded_reason')->all()) + ->toEqualCanonicalizing(['blocked', 'manual_mapping_required']); + + Queue::assertPushed(CrossTenantPromotionExecutionJob::class, function (CrossTenantPromotionExecutionJob $job) use ($run): bool { + return $job->getOperationRun()?->is($run); + }); +}); + +it('does not queue a promotion run when the current preflight has no ready governed subjects', function (): void { + Queue::fake(); + + $fixture = $this->makeCrossTenantCompareFixture(); + + $blocked = $this->createPortfolioCompareSubject( + tenant: $fixture['sourceTenant'], + displayName: 'Blocked Policy', + snapshot: ['settings' => [['key' => 'blocked', 'value' => 1]]], + ); + $blocked['version']->delete(); + + $this->setAdminWorkspaceContext($fixture['user'], $fixture['workspace']); + + Livewire::withQueryParams([ + 'source_tenant_id' => (int) $fixture['sourceTenant']->getKey(), + 'target_tenant_id' => (int) $fixture['targetTenant']->getKey(), + 'policy_type' => ['deviceConfiguration'], + ]) + ->actingAs($fixture['user']) + ->test(CrossTenantComparePage::class) + ->call('generatePromotionPreflight') + ->assertActionVisible('executePromotion') + ->assertActionDisabled('executePromotion') + ->assertActionExists('executePromotion', fn (Action $action): bool => $action->getTooltip() === 'Current promotion preflight has no ready governed subjects to execute.') + ->call('executePromotion') + ->assertNotified('Promotion execution unavailable'); + + expect(OperationRun::query()->count())->toBe(0); + + Queue::assertNothingPushed(); +}); \ No newline at end of file diff --git a/apps/platform/tests/Feature/PortfolioCompare/CrossTenantPromotionExecutionAuditTest.php b/apps/platform/tests/Feature/PortfolioCompare/CrossTenantPromotionExecutionAuditTest.php new file mode 100644 index 00000000..8a536e75 --- /dev/null +++ b/apps/platform/tests/Feature/PortfolioCompare/CrossTenantPromotionExecutionAuditTest.php @@ -0,0 +1,156 @@ +makeCrossTenantCompareFixture(); + + $this->createPortfolioCompareSubject( + tenant: $fixture['sourceTenant'], + displayName: 'Audit Queue Policy', + snapshot: ['settings' => [['key' => 'audit-queue', 'value' => 1]]], + ); + + $this->setAdminWorkspaceContext($fixture['user'], $fixture['workspace']); + + $policyVersionCount = PolicyVersion::query()->count(); + + Livewire::withQueryParams([ + 'source_tenant_id' => (int) $fixture['sourceTenant']->getKey(), + 'target_tenant_id' => (int) $fixture['targetTenant']->getKey(), + 'policy_type' => ['deviceConfiguration'], + ]) + ->actingAs($fixture['user']) + ->test(CrossTenantComparePage::class) + ->call('generatePromotionPreflight') + ->mountAction('executePromotion') + ->callMountedAction() + ->assertHasNoActionErrors(); + + $run = OperationRun::query()->latest('id')->first(); + + $audit = AuditLog::query() + ->where('workspace_id', (int) $fixture['workspace']->getKey()) + ->where('action', AuditActionId::CrossTenantPromotionExecutionQueued->value) + ->latest('id') + ->first(); + + expect($run)->not->toBeNull() + ->and($audit)->not->toBeNull() + ->and($audit?->status)->toBe('info') + ->and($audit?->resource_type)->toBe('operation_run') + ->and((int) ($audit?->operation_run_id ?? 0))->toBe((int) $run?->getKey()) + ->and(data_get($audit?->metadata, 'source_tenant_id'))->toBe((int) $fixture['sourceTenant']->getKey()) + ->and(data_get($audit?->metadata, 'target_tenant_id'))->toBe((int) $fixture['targetTenant']->getKey()) + ->and(data_get($audit?->metadata, 'ready_count'))->toBe(1) + ->and(data_get($audit?->metadata, 'excluded_count'))->toBe(0) + ->and(BackupSet::query()->count())->toBe(0) + ->and(RestoreRun::query()->count())->toBe(0) + ->and(PolicyVersion::query()->count())->toBe($policyVersionCount); +}); + +it('audits terminal promotion execution truth after the queued worker completes', function (): void { + Queue::fake(); + + $fixture = $this->makeCrossTenantCompareFixture(); + + $this->createPortfolioCompareSubject( + tenant: $fixture['sourceTenant'], + displayName: 'Audit Completion Policy', + snapshot: ['settings' => [['key' => 'audit-complete', 'value' => 1]]], + ); + + $this->setAdminWorkspaceContext($fixture['user'], $fixture['workspace']); + + Livewire::withQueryParams([ + 'source_tenant_id' => (int) $fixture['sourceTenant']->getKey(), + 'target_tenant_id' => (int) $fixture['targetTenant']->getKey(), + 'policy_type' => ['deviceConfiguration'], + ]) + ->actingAs($fixture['user']) + ->test(CrossTenantComparePage::class) + ->call('generatePromotionPreflight') + ->mountAction('executePromotion') + ->callMountedAction() + ->assertHasNoActionErrors(); + + $run = OperationRun::query()->latest('id')->firstOrFail(); + + $restoreService = \Mockery::mock(RestoreService::class); + $restoreService->shouldReceive('execute') + ->once() + ->andReturnUsing(function ($tenant, $backupSet, array $selectedItemIds) { + return RestoreRun::factory()->create([ + 'tenant_id' => (int) $tenant->getKey(), + 'backup_set_id' => (int) $backupSet->getKey(), + 'requested_items' => $selectedItemIds, + 'results' => [ + 'items' => [[ + 'status' => 'applied', + 'policy_identifier' => 'audit-complete-policy', + ]], + ], + 'metadata' => [ + 'succeeded' => count($selectedItemIds), + 'failed' => 0, + 'partial' => 0, + 'skipped' => 0, + ], + ]); + }); + + app()->instance(RestoreService::class, $restoreService); + + $job = new CrossTenantPromotionExecutionJob($run); + $job->handle( + app(OperationRunService::class), + $restoreService, + app(TargetScopeConcurrencyLimiter::class), + app(WorkspaceAuditLogger::class), + ); + + $completedAudit = AuditLog::query() + ->where('workspace_id', (int) $fixture['workspace']->getKey()) + ->where('action', AuditActionId::CrossTenantPromotionExecutionCompleted->value) + ->latest('id') + ->first(); + + $completedRun = $run->fresh(); + $restoreRun = RestoreRun::query()->latest('id')->first(); + + expect($completedRun)->not->toBeNull() + ->and($completedRun?->status)->toBe('completed') + ->and($completedRun?->outcome)->toBe('succeeded') + ->and($completedAudit)->not->toBeNull() + ->and($completedAudit?->status)->toBe('success') + ->and($completedAudit?->resource_type)->toBe('operation_run') + ->and((int) ($completedAudit?->operation_run_id ?? 0))->toBe((int) $completedRun?->getKey()) + ->and(data_get($completedAudit?->metadata, 'source_tenant_id'))->toBe((int) $fixture['sourceTenant']->getKey()) + ->and(data_get($completedAudit?->metadata, 'target_tenant_id'))->toBe((int) $fixture['targetTenant']->getKey()) + ->and(data_get($completedAudit?->metadata, 'summary_counts.created'))->toBe(1) + ->and(data_get($completedAudit?->metadata, 'summary_counts.succeeded'))->toBe(1) + ->and(data_get($completedAudit?->metadata, 'restore_run_id'))->toBe((int) $restoreRun?->getKey()) + ->and(BackupSet::query()->count())->toBe(1) + ->and(RestoreRun::query()->count())->toBe(1); +}); \ No newline at end of file diff --git a/apps/platform/tests/Feature/PortfolioCompare/CrossTenantPromotionExecutionAuthorizationTest.php b/apps/platform/tests/Feature/PortfolioCompare/CrossTenantPromotionExecutionAuthorizationTest.php new file mode 100644 index 00000000..b5dbea31 --- /dev/null +++ b/apps/platform/tests/Feature/PortfolioCompare/CrossTenantPromotionExecutionAuthorizationTest.php @@ -0,0 +1,182 @@ +makeCrossTenantCompareFixture(workspaceRole: 'owner', tenantRole: 'readonly'); + + $this->createPortfolioCompareSubject( + tenant: $fixture['sourceTenant'], + displayName: 'Compare Only Policy', + snapshot: ['settings' => [['key' => 'readonly', 'value' => 1]]], + ); + + $this->setAdminWorkspaceContext($fixture['user'], $fixture['workspace']); + + $query = [ + 'source_tenant_id' => (int) $fixture['sourceTenant']->getKey(), + 'target_tenant_id' => (int) $fixture['targetTenant']->getKey(), + 'policy_type' => ['deviceConfiguration'], + ]; + + Livewire::withQueryParams($query) + ->actingAs($fixture['user']) + ->test(CrossTenantComparePage::class) + ->call('generatePromotionPreflight') + ->assertActionVisible('executePromotion') + ->assertActionDisabled('executePromotion') + ->assertActionExists('executePromotion', fn (Action $action): bool => $action->getTooltip() === 'You need target tenant manage access to execute promotion.') + ->call('executePromotion') + ->assertForbidden(); +}); + +it('keeps execute promotion visible but disabled without workspace baseline manage access and forbids forced execution', function (): void { + $fixture = $this->makeCrossTenantCompareFixture(workspaceRole: 'readonly', tenantRole: 'owner'); + + $this->createPortfolioCompareSubject( + tenant: $fixture['sourceTenant'], + displayName: 'Workspace Gate Policy', + snapshot: ['settings' => [['key' => 'workspace', 'value' => 1]]], + ); + + $this->setAdminWorkspaceContext($fixture['user'], $fixture['workspace']); + + $selection = new CrossTenantCompareSelection( + $fixture['sourceTenant'], + $fixture['targetTenant'], + ['deviceConfiguration'], + ); + $preview = app(CrossTenantComparePreviewBuilder::class)->build($selection); + $preflight = app(CrossTenantPromotionPreflight::class)->build($preview); + + Livewire::withQueryParams([ + 'source_tenant_id' => (int) $fixture['sourceTenant']->getKey(), + 'target_tenant_id' => (int) $fixture['targetTenant']->getKey(), + 'policy_type' => ['deviceConfiguration'], + ]) + ->actingAs($fixture['user']) + ->test(CrossTenantComparePage::class) + ->set('preview', $preview) + ->set('preflight', $preflight) + ->assertActionVisible('executePromotion') + ->assertActionDisabled('executePromotion') + ->assertActionExists('executePromotion', fn (Action $action): bool => $action->getTooltip() === 'You need workspace baseline manage access to execute promotion.') + ->call('executePromotion') + ->assertForbidden(); +}); + +it('does not queue a promotion run when the current preflight is stale for the selected target tenant', function (): void { + $fixture = $this->makeCrossTenantCompareFixture(); + + $this->createPortfolioCompareSubject( + tenant: $fixture['sourceTenant'], + displayName: 'Stale Policy', + snapshot: ['settings' => [['key' => 'stale', 'value' => 1]]], + ); + + $staleTarget = Tenant::factory()->create([ + 'workspace_id' => (int) $fixture['workspace']->getKey(), + 'name' => 'Stale Target', + ]); + + $fixture['user']->tenants()->syncWithoutDetaching([ + (int) $staleTarget->getKey() => ['role' => 'owner'], + ]); + app(CapabilityResolver::class)->clearCache(); + + $this->setAdminWorkspaceContext($fixture['user'], $fixture['workspace']); + + $staleSelection = new CrossTenantCompareSelection( + $fixture['sourceTenant'], + $staleTarget, + ['deviceConfiguration'], + ); + $currentSelection = new CrossTenantCompareSelection( + $fixture['sourceTenant'], + $fixture['targetTenant'], + ['deviceConfiguration'], + ); + $currentPreview = app(CrossTenantComparePreviewBuilder::class)->build($currentSelection); + $stalePreview = app(CrossTenantComparePreviewBuilder::class)->build($staleSelection); + $stalePreflight = app(CrossTenantPromotionPreflight::class)->build($stalePreview); + + Livewire::withQueryParams([ + 'source_tenant_id' => (int) $fixture['sourceTenant']->getKey(), + 'target_tenant_id' => (int) $fixture['targetTenant']->getKey(), + 'policy_type' => ['deviceConfiguration'], + ]) + ->actingAs($fixture['user']) + ->test(CrossTenantComparePage::class) + ->set('preview', $currentPreview) + ->set('preflight', $stalePreflight) + ->call('executePromotion') + ->assertNotified('Promotion execution unavailable'); + + expect(OperationRun::query()->count())->toBe(0); +}); + +it('does not queue a promotion run when promotion execution is paused by operational control', function (): void { + $fixture = $this->makeCrossTenantCompareFixture(); + + $this->createPortfolioCompareSubject( + tenant: $fixture['sourceTenant'], + displayName: 'Paused Policy', + snapshot: ['settings' => [['key' => 'paused', 'value' => 1]]], + ); + + $this->setAdminWorkspaceContext($fixture['user'], $fixture['workspace']); + + OperationalControlActivation::factory()->workspaceScoped()->create([ + 'control_key' => 'promotion.execute', + 'workspace_id' => (int) $fixture['workspace']->getKey(), + 'reason_text' => 'Paused during promotion review.', + ]); + + Livewire::withQueryParams([ + 'source_tenant_id' => (int) $fixture['sourceTenant']->getKey(), + 'target_tenant_id' => (int) $fixture['targetTenant']->getKey(), + 'policy_type' => ['deviceConfiguration'], + ]) + ->actingAs($fixture['user']) + ->test(CrossTenantComparePage::class) + ->call('generatePromotionPreflight') + ->call('executePromotion') + ->assertNotified('Promotion execution paused'); + + expect(OperationRun::query()->count())->toBe(0); +}); + +it('returns 404 and does not queue a promotion run when the requested target tenant is outside the actor scope', function (): void { + $fixture = $this->makeCrossTenantCompareFixture(); + $hiddenTarget = Tenant::factory()->create([ + 'workspace_id' => (int) $fixture['workspace']->getKey(), + 'name' => 'Hidden Promotion Target', + ]); + + $session = $this->setAdminWorkspaceContext($fixture['user'], $fixture['workspace']); + + $this->withSession($session) + ->get(CrossTenantComparePage::getUrl(parameters: [ + 'source_tenant_id' => (int) $fixture['sourceTenant']->getKey(), + 'target_tenant_id' => (int) $hiddenTarget->getKey(), + 'policy_type' => ['deviceConfiguration'], + ], panel: 'admin')) + ->assertNotFound(); + + expect(OperationRun::query()->count())->toBe(0); +}); \ No newline at end of file diff --git a/apps/platform/tests/Feature/PortfolioCompare/CrossTenantPromotionExecutionRunUxTest.php b/apps/platform/tests/Feature/PortfolioCompare/CrossTenantPromotionExecutionRunUxTest.php new file mode 100644 index 00000000..f8e98af8 --- /dev/null +++ b/apps/platform/tests/Feature/PortfolioCompare/CrossTenantPromotionExecutionRunUxTest.php @@ -0,0 +1,69 @@ +makeCrossTenantCompareFixture(); + + $this->createPortfolioCompareSubject( + tenant: $fixture['sourceTenant'], + displayName: 'Run UX Policy', + snapshot: ['settings' => [['key' => 'ux', 'value' => 1]]], + ); + + $this->setAdminWorkspaceContext($fixture['user'], $fixture['workspace']); + + $query = [ + 'source_tenant_id' => (int) $fixture['sourceTenant']->getKey(), + 'target_tenant_id' => (int) $fixture['targetTenant']->getKey(), + 'policy_type' => ['deviceConfiguration'], + ]; + + $component = Livewire::withQueryParams($query) + ->actingAs($fixture['user']) + ->test(CrossTenantComparePage::class) + ->call('generatePromotionPreflight') + ->mountAction('executePromotion') + ->callMountedAction() + ->assertHasNoActionErrors() + ->assertNotified('Promotion execution queued'); + + $run = OperationRun::query()->latest('id')->first(); + + expect($run)->not->toBeNull() + ->and($run?->type)->toBe('promotion.execute') + ->and(data_get($run?->context, 'required_capability'))->toBe('tenant.manage') + ->and(data_get($run?->context, 'workspace_required_capability'))->toBe('workspace_baselines.manage') + ->and(data_get($run?->context, 'target_scope.workspace_id'))->toBe((int) $fixture['workspace']->getKey()) + ->and(data_get($run?->context, 'selection.sourceTenantId'))->toBe((int) $fixture['sourceTenant']->getKey()) + ->and(data_get($run?->context, 'selection.targetTenantId'))->toBe((int) $fixture['targetTenant']->getKey()); + + $component + ->mountAction('executePromotion') + ->callMountedAction() + ->assertHasNoActionErrors() + ->assertNotified('Promotion execution already running'); + + expect(OperationRun::query()->where('type', 'promotion.execute')->count())->toBe(1); + + Queue::assertPushed(CrossTenantPromotionExecutionJob::class, 1); + + $this->actingAs($fixture['user']) + ->withSession([WorkspaceContext::SESSION_KEY => (int) $fixture['workspace']->getKey()]) + ->get(OperationRunLinks::tenantlessView($run)) + ->assertOk(); +}); \ No newline at end of file diff --git a/apps/platform/tests/Unit/Operations/OperationLifecyclePolicyValidatorTest.php b/apps/platform/tests/Unit/Operations/OperationLifecyclePolicyValidatorTest.php index 08b5bd98..83653922 100644 --- a/apps/platform/tests/Unit/Operations/OperationLifecyclePolicyValidatorTest.php +++ b/apps/platform/tests/Unit/Operations/OperationLifecyclePolicyValidatorTest.php @@ -18,6 +18,7 @@ 'backup_set.update', 'backup.schedule.execute', 'restore.execute', + 'promotion.execute', 'tenant.review_pack.generate', 'tenant.review.compose', 'tenant.evidence.snapshot.generate', @@ -43,5 +44,6 @@ ->and($validator->jobTimeoutSeconds('backup_set.update'))->toBe(240) ->and($validator->jobFailsOnTimeout('backup_set.update'))->toBeTrue() ->and($validator->jobTimeoutSeconds('restore.execute'))->toBe(420) + ->and($validator->jobTimeoutSeconds('promotion.execute'))->toBe(420) ->and($validator->jobFailsOnTimeout('restore.execute'))->toBeTrue(); }); diff --git a/apps/platform/tests/Unit/Operations/QueuedExecutionLegitimacyGateTest.php b/apps/platform/tests/Unit/Operations/QueuedExecutionLegitimacyGateTest.php index 3d28e404..1e6bc4d3 100644 --- a/apps/platform/tests/Unit/Operations/QueuedExecutionLegitimacyGateTest.php +++ b/apps/platform/tests/Unit/Operations/QueuedExecutionLegitimacyGateTest.php @@ -251,6 +251,36 @@ ->and($decision->checks['execution_prerequisites'])->toBe('failed'); }); +it('denies promotion execution when the initiator loses workspace baseline manage capability', function (): void { + [$user, $tenant] = createUserWithTenant(role: 'owner', workspaceRole: 'readonly'); + + $run = OperationRun::factory()->create([ + 'tenant_id' => (int) $tenant->getKey(), + 'workspace_id' => (int) $tenant->workspace_id, + 'user_id' => (int) $user->getKey(), + 'type' => 'promotion.execute', + 'status' => OperationRunStatus::Queued->value, + 'outcome' => OperationRunOutcome::Pending->value, + 'context' => [ + 'execution_authority_mode' => ExecutionAuthorityMode::ActorBound->value, + 'required_capability' => Capabilities::TENANT_MANAGE, + 'workspace_required_capability' => Capabilities::WORKSPACE_BASELINES_MANAGE, + ], + ]); + + $decision = app(QueuedExecutionLegitimacyGate::class)->evaluate($run); + + expect($decision->allowed)->toBeFalse() + ->and($decision->denialClass)->toBe(ExecutionDenialClass::CapabilityDenied) + ->and($decision->reasonCode)->toBe(ExecutionDenialReasonCode::MissingCapability) + ->and($decision->retryable)->toBeFalse() + ->and($decision->checks)->toMatchArray([ + 'workspace_scope' => 'passed', + 'tenant_scope' => 'passed', + 'capability' => 'failed', + ]); +}); + it('infers tenant sync capability for policy sync runs from the central resolver', function (): void { [$user, $tenant] = createUserWithTenant(role: 'owner'); @@ -294,3 +324,27 @@ 'user_id' => null, ]); }); + +it('builds promotion execution context with workspace reauthorization and write-gate prerequisites', function (): void { + [$user, $tenant] = createUserWithTenant(role: 'owner'); + + $run = OperationRun::factory()->create([ + 'tenant_id' => (int) $tenant->getKey(), + 'workspace_id' => (int) $tenant->workspace_id, + 'user_id' => (int) $user->getKey(), + 'type' => 'promotion.execute', + 'status' => OperationRunStatus::Queued->value, + 'outcome' => OperationRunOutcome::Pending->value, + 'context' => [ + 'execution_authority_mode' => ExecutionAuthorityMode::ActorBound->value, + 'required_capability' => Capabilities::TENANT_MANAGE, + 'workspace_required_capability' => Capabilities::WORKSPACE_BASELINES_MANAGE, + ], + ]); + + $context = app(QueuedExecutionLegitimacyGate::class)->buildContext($run); + + expect($context->requiredCapability)->toBe(Capabilities::TENANT_MANAGE) + ->and($context->workspaceRequiredCapability)->toBe(Capabilities::WORKSPACE_BASELINES_MANAGE) + ->and($context->prerequisiteClasses)->toContain('write_gate'); +}); diff --git a/apps/platform/tests/Unit/Support/OperationalControls/OperationalControlCatalogTest.php b/apps/platform/tests/Unit/Support/OperationalControls/OperationalControlCatalogTest.php index 3cf3fa81..d8d48634 100644 --- a/apps/platform/tests/Unit/Support/OperationalControls/OperationalControlCatalogTest.php +++ b/apps/platform/tests/Unit/Support/OperationalControls/OperationalControlCatalogTest.php @@ -7,12 +7,18 @@ it('exposes only active runtime controls in the bounded control catalog', function (): void { $catalog = app(OperationalControlCatalog::class); - expect($catalog->keys())->toBe(['restore.execute', 'ai.execution']) + expect($catalog->keys())->toBe(['restore.execute', 'promotion.execute', 'ai.execution']) ->and($catalog->definition('restore.execute'))->toMatchArray([ 'key' => 'restore.execute', 'label' => 'Restore execution', 'supported_scopes' => ['global', 'workspace'], 'operation_types' => ['restore.execute'], + ]) + ->and($catalog->definition('promotion.execute'))->toMatchArray([ + 'key' => 'promotion.execute', + 'label' => 'Promotion execution', + 'supported_scopes' => ['global', 'workspace'], + 'operation_types' => ['promotion.execute'], ]) ->and($catalog->definition('ai.execution'))->toMatchArray([ 'key' => 'ai.execution', diff --git a/apps/platform/tests/Unit/Support/PortfolioCompare/CrossTenantPromotionExecutionPlannerTest.php b/apps/platform/tests/Unit/Support/PortfolioCompare/CrossTenantPromotionExecutionPlannerTest.php new file mode 100644 index 00000000..bb1e37b4 --- /dev/null +++ b/apps/platform/tests/Unit/Support/PortfolioCompare/CrossTenantPromotionExecutionPlannerTest.php @@ -0,0 +1,150 @@ + [ + 'sourceTenantId' => 10, + 'targetTenantId' => 20, + 'policyTypes' => ['settingsCatalog', 'deviceConfiguration'], + ], + ]; + + $preflight = [ + 'selection' => [ + 'sourceTenantId' => 10, + 'targetTenantId' => 20, + 'policyTypes' => ['deviceConfiguration', 'settingsCatalog'], + ], + 'buckets' => [ + 'ready' => [ + plannerSubject('Aligned Policy', 'aligned-subject', 'deviceConfiguration', 'match', 10, 20, 101, 'hash-aligned'), + plannerSubject('Create Policy', 'create-subject', 'deviceConfiguration', 'missing', 10, 20, 102, 'hash-create'), + plannerSubject('Update Policy', 'update-subject', 'settingsCatalog', 'different', 10, 20, 103, 'hash-update'), + plannerSubject('Missing Version Policy', 'missing-version-subject', 'deviceConfiguration', 'missing', 10, 20, null, null), + ], + 'blocked' => [ + plannerSubject('Blocked Policy', 'blocked-subject', 'deviceConfiguration', 'different', 10, 20, 104, 'hash-blocked', ['write_gate_blocked']), + ], + 'manual_mapping_required' => [ + plannerSubject('Manual Policy', 'manual-subject', 'deviceConfiguration', 'different', 10, 20, 105, 'hash-manual', ['target_subject_ambiguous']), + ], + ], + ]; + + $plan = $planner->build($preview, $preflight); + + expect($plan['summary'])->toBe([ + 'total' => 6, + 'ready' => 3, + 'excluded' => 3, + 'skipped' => 1, + 'created' => 1, + 'updated' => 1, + ]) + ->and($plan['selection'])->toBe([ + 'sourceTenantId' => 10, + 'targetTenantId' => 20, + 'policyTypes' => ['deviceConfiguration', 'settingsCatalog'], + ]) + ->and(array_column($plan['items'], 'execution_action'))->toBe([ + 'skip_aligned', + 'create_missing', + 'update_existing', + ]) + ->and(array_column($plan['excluded'], 'excluded_reason'))->toContain('blocked', 'manual_mapping_required', 'source_policy_version_missing') + ->and($plan['identity'])->toMatchArray([ + 'source_tenant_id' => 10, + 'target_tenant_id' => 20, + 'policy_types' => ['deviceConfiguration', 'settingsCatalog'], + ]) + ->and($plan['identity']['subjects'])->toBe([ + [ + 'policy_type' => 'deviceConfiguration', + 'subject_key' => 'aligned-subject', + 'source_policy_version_id' => 101, + 'source_evidence_hash' => 'hash-aligned', + 'target_subject_external_id' => 'target-aligned-subject', + 'execution_action' => 'skip_aligned', + ], + [ + 'policy_type' => 'deviceConfiguration', + 'subject_key' => 'create-subject', + 'source_policy_version_id' => 102, + 'source_evidence_hash' => 'hash-create', + 'target_subject_external_id' => 'target-create-subject', + 'execution_action' => 'create_missing', + ], + [ + 'policy_type' => 'settingsCatalog', + 'subject_key' => 'update-subject', + 'source_policy_version_id' => 103, + 'source_evidence_hash' => 'hash-update', + 'target_subject_external_id' => 'target-update-subject', + 'execution_action' => 'update_existing', + ], + ]); +}); + +it('rejects stale promotion preflight selections', function (): void { + $planner = app(CrossTenantPromotionExecutionPlanner::class); + + expect(fn (): array => $planner->build( + ['selection' => ['sourceTenantId' => 10, 'targetTenantId' => 20, 'policyTypes' => ['deviceConfiguration']]], + ['selection' => ['sourceTenantId' => 10, 'targetTenantId' => 21, 'policyTypes' => ['deviceConfiguration']], 'buckets' => ['ready' => [], 'blocked' => [], 'manual_mapping_required' => []]], + ))->toThrow(InvalidArgumentException::class, 'Promotion preflight is stale. Regenerate the preflight before execution.'); +}); + +it('rejects promotion preflights with no executable ready subjects', function (): void { + $planner = app(CrossTenantPromotionExecutionPlanner::class); + + expect(fn (): array => $planner->build( + ['selection' => ['sourceTenantId' => 10, 'targetTenantId' => 20, 'policyTypes' => ['deviceConfiguration']]], + ['selection' => ['sourceTenantId' => 10, 'targetTenantId' => 20, 'policyTypes' => ['deviceConfiguration']], 'buckets' => ['ready' => [], 'blocked' => [plannerSubject('Blocked Policy', 'blocked-subject', 'deviceConfiguration', 'different', 10, 20, 104, 'hash-blocked')], 'manual_mapping_required' => []]], + ))->toThrow(DomainException::class, 'Promotion preflight has no executable ready subjects.'); +}); + +/** + * @param list $reasonCodes + * @return array + */ +function plannerSubject( + string $displayName, + string $subjectKey, + string $policyType, + string $state, + int $sourceTenantId, + int $targetTenantId, + ?int $policyVersionId, + ?string $evidenceHash, + array $reasonCodes = [], +): array { + return [ + 'displayName' => $displayName, + 'subjectKey' => $subjectKey, + 'policyType' => $policyType, + 'state' => $state, + 'source' => [ + 'tenantId' => $sourceTenantId, + 'inventoryItemId' => $policyVersionId !== null ? $policyVersionId + 1000 : null, + 'subjectExternalId' => 'source-'.$subjectKey, + 'evidence' => [ + 'policyVersionId' => $policyVersionId, + 'hash' => $evidenceHash, + ], + ], + 'target' => [ + 'tenantId' => $targetTenantId, + 'inventoryItemId' => $policyVersionId !== null ? $policyVersionId + 2000 : null, + 'subjectExternalId' => 'target-'.$subjectKey, + ], + 'preflight' => [ + 'reasonCodes' => $reasonCodes, + ], + ]; +} \ No newline at end of file diff --git a/docs/product/implementation-ledger.md b/docs/product/implementation-ledger.md index d762a0ab..2a244e4a 100644 --- a/docs/product/implementation-ledger.md +++ b/docs/product/implementation-ledger.md @@ -4,7 +4,7 @@ # TenantPilot Implementation Ledger > **Last reviewed:** 2026-05-02 > **Use for:** Repo-based implementation status and product-surface maturity assessment > **Do not use for:** Roadmap priority, spec priority, or proof that tests were executed in the current branch -> **Scoped maintenance:** 2026-05-02 ledger drift correction and alignment with `docs/product/roadmap.md` plus `docs/product/spec-candidates.md` after the repo-truth review of roadmap drift, manual-promotion backlog, and implementation maturity. +> **Scoped maintenance:** 2026-05-02 ledger drift correction and alignment with `docs/product/roadmap.md` plus `docs/product/spec-candidates.md` after the repo-truth review of roadmap drift, manual-promotion backlog, deep-research alignment, and current spec promotions. ## Purpose @@ -134,11 +134,13 @@ ## Not Implemented - Auditor Pack Delivery & Executive Export v1 - Cross-Tenant Promotion Execution v1 -- Governance Decision Pack & Approval Workflow v1 +- Decision Register & Approval Workflow v1 +- Governance Artifact Lifecycle & Retention v1 - Customer-Facing Localization Adoption v1 - Billing & Subscription Truth Layer v1 - Stored Reports Surface v1 - Workspace & Tenant Closure Lifecycle v1 +- Enterprise Access Boundary & Support Access Governance v1 - First Governed AI Runtime Consumer v1 - Human-in-the-Loop Autonomous Governance - Standardization & Policy Quality / Intune Linting @@ -217,22 +219,24 @@ ## Open Gaps & Blockers | Gap | Type | Impact | Roadmap Area | Recommended Spec | |---|---|---|---|---| | No safe automatic next-best-prep target is currently active | Planning boundary | `docs/product/spec-candidates.md` now keeps the active queue empty, so the next slice must be promoted deliberately instead of selected automatically | Product planning / queue hygiene | none - require explicit manual promotion | -| Auditor-ready executive export is still missing | Productization blocker | Review truth remains short of auditor-/executive-ready delivery without dedicated packaging | R2 review delivery | `Auditor Pack Delivery & Executive Export v1` | -| Cross-tenant promotion execution is still missing | Product blocker | Compare preview and preflight are repo-real, but the actual portfolio action remains absent | MSP Portfolio & Operations | `Cross-Tenant Promotion Execution v1` | -| Governance decision pack and approval workflow is still missing | Product blocker | Decision-based operating still lacks a bounded approval-ready action package with audit trail | Decision-based operating | `Governance Decision Pack & Approval Workflow v1` | +| Auditor-ready executive export is still missing | Productization blocker | Review truth remains short of auditor-/executive-ready delivery, even though the dedicated follow-through is now spec-backed | R2 review delivery | `specs/263-auditor-pack-executive-export/spec.md` | +| Cross-tenant promotion execution is still missing | Product blocker | Compare preview and preflight are repo-real, but the actual portfolio action remains absent even though the execution package is now spec-backed | MSP Portfolio & Operations | `specs/264-cross-tenant-promotion-execution/spec.md` | +| Decision register and approval workflow is still missing | Product blocker | Decision-based operating still lacks a bounded approval-ready closure and decision-record package with audit trail | Decision-based operating | `Decision Register & Approval Workflow v1` | +| Governance-artifact lifecycle runtime is still missing | Trust / auditability blocker | Lifecycle taxonomy and point retention rules exist, but governance artifacts still lack immutable-reference, hold, export, delete, and suspended/read-only runtime semantics | Lifecycle governance / enterprise trust | `Governance Artifact Lifecycle & Retention v1` | | Customer-facing localization adoption is incomplete | Productization blocker | Locale groundwork is repo-real, but customer-safe adoption remains incomplete | Localization / review productization | `Customer-Facing Localization Adoption v1` | | Billing and subscription truth is missing | Commercial blocker | Entitlements and lifecycle state handling stop short of a durable billing/subscription truth layer | Commercial readiness | `Billing & Subscription Truth Layer v1` | | Stored reports still lack a clear product surface | Product blocker | Retained evidence and review artifacts remain harder to consume than they should be | Reports / evidence consumption | `Stored Reports Surface v1` | | Workspace and tenant closure follow-through is not started | Strategic blocker | The taxonomy exists, but closure/runtime semantics are not yet productized | Lifecycle governance / enterprise trust | `Workspace & Tenant Closure Lifecycle v1` | +| Support-access governance is still missing | Access governance blocker | Break-glass and support access seams exist, but customer-visible TTL, reason, approval, and export semantics are not productized | Enterprise access boundary | `Enterprise Access Boundary & Support Access Governance v1` | | First governed AI runtime consumer is missing | Architecture blocker | The policy foundation exists, but there is no bounded runtime consumer proving the model end-to-end | Governed AI follow-through | `First Governed AI Runtime Consumer v1` | ## Recommended Manual Promotions -- `Auditor Pack Delivery & Executive Export v1` -> anchored by `specs/109-review-pack-export/spec.md`, `specs/153-evidence-domain-foundation/spec.md`, `specs/155-tenant-review-layer/spec.md`, `specs/258-customer-review-productization/spec.md`, `specs/259-compliance-evidence-mapping/spec.md`, and `specs/260-governance-service-packaging/spec.md` -- `Cross-Tenant Promotion Execution v1` -> anchored by `specs/043-cross-tenant-compare-and-promotion/spec.md` -- `Governance Decision Pack & Approval Workflow v1` -> anchored by `specs/257-governance-decision-convergence/spec.md` and `docs/product/roadmap.md` +- `Decision Register & Approval Workflow v1` -> anchored by `specs/250-decision-governance-inbox/spec.md`, `specs/257-governance-decision-convergence/spec.md`, and `docs/product/roadmap.md` +- `Governance Artifact Lifecycle & Retention v1` -> anchored by `specs/158-artifact-truth-semantics/spec.md`, `specs/262-lifecycle-governance-taxonomy/spec.md`, and `docs/product/standards/lifecycle-governance.md` - `Customer-Facing Localization Adoption v1` -> anchored by `specs/252-platform-localization-v1/spec.md`, `specs/258-customer-review-productization/spec.md`, and `specs/260-governance-service-packaging/spec.md` - `Billing & Subscription Truth Layer v1` -> anchored by `specs/247-plans-entitlements-billing-readiness/spec.md` and `specs/251-commercial-entitlements-billing-state/spec.md` +- `Enterprise Access Boundary & Support Access Governance v1` -> anchored by `docs/audits/2026-03-09-enterprise-rbac-scope-audit.md`, `docs/HANDOVER.md`, `specs/065-tenant-rbac-v1/spec.md`, and `specs/066-rbac-ui-enforcement-helper/spec.md` - `Stored Reports Surface v1` -> anchored by `specs/153-evidence-domain-foundation/spec.md`, `specs/155-tenant-review-layer/spec.md`, `specs/260-governance-service-packaging/spec.md`, and `docs/product/implementation-ledger.md` - `Workspace & Tenant Closure Lifecycle v1` -> anchored by `specs/262-lifecycle-governance-taxonomy/spec.md` - `First Governed AI Runtime Consumer v1` -> anchored by `specs/248-private-ai-policy-foundation/spec.md` diff --git a/docs/product/roadmap.md b/docs/product/roadmap.md index dd7ad1f0..e084f9da 100644 --- a/docs/product/roadmap.md +++ b/docs/product/roadmap.md @@ -4,7 +4,7 @@ # Product Roadmap > **Last reviewed:** 2026-05-02 > **Use for:** Current product roadmap, release themes, and prioritization context > **Do not use for:** Implementation truth, spec completion status, or delivery guarantees without repo verification -> **Scoped maintenance:** 2026-05-02 repo-based roadmap drift correction and queue/backlog alignment after the audit-derived product-truth review. +> **Scoped maintenance:** 2026-05-02 repo-based roadmap drift correction, manual-promotion backlog alignment, and enterprise-SaaS deep-research calibration against current specs, standards, and product-truth docs. > > Strategic thematic blocks and release trajectory. > This is the "big picture" — not individual specs. @@ -26,19 +26,73 @@ ## Current Productization & Moat Priorities This is the repo-based prioritization overlay for the next sellable lanes. The bottleneck is no longer raw backend truth alone. The next roadmap slices should make existing governance foundations customer-safe, decision-centered, auditable, and MSP-sellable before opening more backend-only islands. -| Order | Theme | Repo truth | Product posture | Why now | Candidate posture | +| Order | Theme | Alignment status | Repo truth | Why now | Queue posture | |---|---|---|---|---|---| -| 1 | Auditor Pack Delivery & Executive Export v1 | Review-pack export, evidence, tenant review, customer review productization, compliance mapping, and governance packaging foundations are already spec-backed and partially repo-real | fast sellable | clearest remaining step between repo-real governance truth and auditor-/executive-ready delivery | manual promotion only, not auto-prep | -| 2 | Customer Review Workspace Productization v1 | Reviews, Evidence Snapshots, Review Packs, Customer Review Workspace, and accepted-risk foundations are repo-real | fast sellable | clearest customer-safe governance-of-record surface gap | spec-backed follow-through, not active queue | -| 3 | Governance Decision Surface Convergence | Governance Inbox, My Findings, Intake, and Exception Queue are repo-real, but convergence remains incomplete | implemented but not productized | reduces admin-tool sprawl and turns multiple queue surfaces into calmer decision work | spec-backed follow-through, not active queue | -| 4 | Compliance Evidence Mapping v1 | Canonical controls, evidence, stored reports, reviews, and findings foundations are repo-real; customer-safe compliance mapping follow-through remains | implemented but not productized | strong governance moat for compliance-oriented MSP and Mittelstand reviews without certification claims | spec-backed follow-through, not active queue | -| 5 | Governance-as-a-Service Packaging v1 | Review packs, exports, evidence, and accepted-risk foundations are repo-real; recurring executive/MSP packaging follow-through remains | implemented but not productized | turns governance truth into a repeatable MSP deliverable instead of one-off manual reporting | spec-backed follow-through, not active queue | -| 6 | Cross-Tenant Promotion Execution v1 | Cross-tenant compare preview and promotion preflight are repo-real; execution is not | not implemented | strongest MSP multiplier after review and packaging lanes are calmer | manual promotion only, not auto-prep | -| 7 | Governance Decision Pack & Approval Workflow v1 | Decision convergence and decision-based operating framing are strong enough for a bounded human-in-the-loop slice, but the workflow itself is not implemented | not implemented | next narrow decision workflow without autonomous remediation | manual promotion only, not auto-prep | -| 8 | Customer-Facing Localization Adoption v1 | Locale foundation is repo-real; customer-facing adoption, glossary completion, and regression hardening remain | implemented but not productized | turns existing localization groundwork into customer-safe polish | manual promotion only, not auto-prep | -| 9 | Billing & Subscription Truth Layer v1 | Plans, entitlements, and commercial lifecycle are repo-real; billing/subscription truth is not | not implemented | closes the remaining commercial truth gap | manual promotion only, not auto-prep | -| 10 | Stored Reports Surface v1 | Stored-report substrate is repo-real through evidence and review foundations; product surface remains incomplete | foundation-only | makes retained governance artifacts usable without manual digging | manual promotion only, not auto-prep | -| 11 | First Governed AI Runtime Consumer v1 | Governed AI policy foundation is repo-real; no first governed runtime consumer is proven | not implemented | bounded post-foundation AI adoption after higher-priority sellability gaps | manual promotion only, not auto-prep | +| 1 | Customer Review Workspace Productization v1 | repo-verified, productization gap | Customer-safe review consumption is repo-real through Specs 249, 258, 259, 260, and 263, but the calm sellable surface still needs final productization discipline | clearest sellability lever for Governance-of-Record without creating a parallel customer portal | spec-backed follow-through | +| 2 | Decision-Based Governance Inbox + Decision Register v1 | repo-verified, productization gap, roadmap recommendation | Governance inbox, findings queues, alerts, review follow-up, and Specs 250/257 already anchor the decision surface; a bounded decision-register and approval follow-through is still open | biggest remaining operator workflow gap and the cleanest defense against admin-tool sprawl | manual promotion only for the decision-register follow-through | +| 3 | Governance Artifact Lifecycle & Retention v1 | foundation-only, roadmap recommendation, spec candidate | Spec 262 and the lifecycle-governance standard provide taxonomy-first guardrails, but governance-artifact runtime semantics are not yet productized | new trust, auditability, export, and retention lever for evidence snapshots, stored reports, review packs, and decision records | manual promotion only | +| 4 | Commercial Entitlements & Billing-State Lifecycle v1 | repo-verified, foundation-only, productization gap | Specs 247 and 251 already resolve plan and lifecycle posture, but broader commercial-state-to-artifact-access rules and later billing truth remain open | SaaS trust and lifecycle maturity matter before broader packaging, scale, or AI work | spec-backed follow-through plus narrower billing follow-through | +| 5 | External Support Desk / PSA Handoff v1 | repo-verified, productization gap | Spec 256 and the current bounded handoff service already exist, but the portfolio-safe handoff story is still not fully productized | MSP integration should compress follow-through work without turning TenantPilot into a helpdesk | spec-backed follow-through | +| 6 | Customer-Facing Localization v1 | foundation-only, productization gap | Spec 252 and current locale resolution are repo-real; glossary completion and customer-facing adoption remain incomplete | customer-safe DACH/EU review consumption should start at glossary, labels, packs, and notifications rather than full operator-UI translation | manual promotion only | +| 7 | Cross-Tenant Compare & Promotion with Lineage v1 | repo-verified, productization gap | Spec 043 is repo-real for compare and preflight, and Spec 264 now carries the bounded execution follow-through on this branch | portfolio action matters, but it must stay governance-first with lineage, evidence, approval, and rollback references rather than raw push mechanics | spec-backed follow-through | +| 8 | Governance Service Packaging v1 | repo-verified, productization gap | Spec 260 and current governance-package delivery cues are repo-real, but recurring service packaging remains calmer in documentation than in repeatable delivery workflows | MSP value comes from repeatable services and customer-safe consumption, not just more admin pages | spec-backed follow-through | +| 9 | Enterprise Access Boundary & Support Access Governance v1 | roadmap recommendation, spec candidate | Break-glass and system-access seams exist in audits and handover docs, but no bounded support-access request, TTL, approval, and customer-visible audit package exists | tighten support access before broad SSO/SCIM expansion; keep SCIM and group-provisioning later | manual promotion only | +| 10 | Advanced APIs / Webhooks | later scale-layer, not-now | no bounded repo-ready package yet | useful integration layer, but it trails review, decision, artifact, and commercial clarity | not-now | +| 11 | Private AI Execution Governance Foundation v1 / governed runtime follow-through | repo-verified, foundation-only, later scale-layer | Spec 248 is implemented as a governed foundation; visible runtime consumers and broader budget/result governance are still deferred | AI should remain governed foundation-first and provider-auditable before any visible feature island ships | manual promotion only for runtime follow-through | +| 12 | AI-assisted Review Summaries / Translation / Next Action Drafting | roadmap recommendation, later scale-layer, not-now | depends on governed AI, review truth, and customer-safe localization/productization | later visible AI lane after review, decision, artifact, and commercial maturity | not-now | + +## Deep-Research Roadmap Alignment + +This section is a deep-research-derived calibration layer. It sharpens roadmap language against current repo truth without reopening already-promoted specs or overstating sellability from foundations alone. + +### Confirmed priorities + +- Deep-Research-derived: Customer Review Workspace remains the primary sellability gap, but the repo already contains the foundational and productization specs. The roadmap priority is calmer customer-safe review consumption, not a second portal or a parallel reporting stack. +- Deep-Research-derived: Decision-centered operating remains the primary operator workflow gap. The repo already has governance inbox and convergence anchors, so the remaining roadmap work should narrow toward decision-register, ownership, closure, and approval semantics instead of launching more isolated admin surfaces. +- Deep-Research-derived: PSA/ITSM remains an integration lane, not a product-redefinition. The correct posture is handoff, reference continuity, and auditability rather than a TenantPilot-native helpdesk. + +### Newly elevated gaps + +- Deep-Research-derived: Governance Artifact Lifecycle & Retention v1 should be elevated into the now lane. Current repo truth covers lifecycle taxonomy, review-pack retention, and artifact-truth semantics in pieces, but not a unified governance-artifact lifecycle contract. +- Roadmap Recommendation: Enterprise Access Boundary & Support Access Governance v1 should exist as a narrow early access-governance slice built around support access request, reason, TTL, approval, banner, and exportable audit trail. Broad workspace SSO/OIDC/SCIM remains later. + +### Reordered priorities + +- Deep-Research-derived: Commercial lifecycle moves up. The commercial lane should be framed as SaaS trust, workspace read-only behavior, artifact access, and lifecycle semantics, not as a future billing engine. +- Deep-Research-derived: Cross-tenant compare and promotion remains important, but the roadmap should talk about lineage, approval, evidence, rollback references, and decision linkage before it talks about settings push. +- Deep-Research-derived: Auditor-ready delivery and broader governance packaging stay valuable, but they should follow calmer review consumption, decision routing, artifact lifecycle clarity, and commercial-state truth. + +### Deferred / not-now themes + +- Roadmap Recommendation: full operator-UI localization is not the v1 localization target; customer-facing glossary, review, pack, and notification surfaces come first. +- Roadmap Recommendation: broad workspace SSO/OIDC, SCIM, group-to-capability mapping, and automated provisioning stay out of P0 unless support-access risk turns acute. +- Roadmap Recommendation: advanced APIs/webhooks, visible AI runtime consumers, and AI-assisted drafting stay later scale layers. + +### Productization vs. Foundation distinction + +- Repo-verified foundations do not automatically mean sellable or customer-safe product slices. +- Specs 248, 249, 250, 251, 252, 256, 258, 260, 262, 263, and current-branch 264 prove real foundations or prepared follow-through, but the roadmap should still distinguish `foundation-only` from `productization gap`. +- Stored reports, localization, commercial lifecycle, governed AI, and governance packaging all already have some repo truth. The open work is mainly calmer consumption, lifecycle semantics, and repeatable product delivery. + +### Risks of admin-tool sprawl + +- Deep-Research-derived: TenantPilot loses focus when every new operator concern gets its own top-level page instead of feeding review, decision, evidence, and governance-package flows. +- Deep-Research-derived: More admin surfaces do not close the core gap. Decision records, accepted-risk visibility, evidence lifecycle, customer-safe review consumption, and portfolio-safe workflow continuity do. +- Roadmap Recommendation: prefer decision-first routing, diagnostics-second disclosure, and evidence-third drilldown over raw technical dashboards or isolated remediation consoles. + +## Deep-Research Anti-Patterns + +Do not prioritize these themes ahead of the aligned now and next lanes. + +- anti-pattern: generic M365 admin mirror +- anti-pattern: generic helpdesk or PSA replacement +- anti-pattern: device-action tooling without governance context +- anti-pattern: generic automation builder +- anti-pattern: raw technical dashboards as the primary product surface +- anti-pattern: AI copilot islands without AI governance +- anti-pattern: broad multi-cloud expansion before Microsoft governance is productized +- anti-pattern: eDiscovery or broad GRC-suite clone +- anti-pattern: a new top-level page for every technical state or exception Explicit anti-sprawl boundaries for this priority set: @@ -393,11 +447,13 @@ ## Infrastructure & Platform Debt |------|------|--------| | No explicit company automation roadmap linkage | Risk that sales, support, billing, legal, and customer communication become founder-only manual work | Covered by Solo-Founder SaaS Automation & Operating Readiness | | No shared lifecycle taxonomy for workspace, tenant, managed-object, retention, export, purge, and restoreability states | Local fixes such as ghost-policy handling, workspace deactivation, tenant removal, retention, or purge can create inconsistent deletion semantics and audit gaps | Covered by Workspace, Tenant & Managed Object Lifecycle Governance candidate | +| Governance-artifact lifecycle runtime is still missing | Lifecycle taxonomy and point retention rules exist, but governance artifacts still lack one runtime contract for immutable identity, hold, export, delete, and suspended/read-only behavior | Covered by Governance Artifact Lifecycle & Retention v1 | | No structured support diagnostic bundle yet | Support cases require manual context gathering across tenants, runs, findings, providers, and reports | Covered by Product Scalability & Self-Service Foundation | +| No bounded support-access governance package yet | Break-glass, system access, and future impersonation/support access could drift without customer-visible TTL, reason, approval, and export semantics | Covered by Enterprise Access Boundary & Support Access Governance v1 | | No formal security trust pack yet | Enterprise sales and customer security reviews require repeated manual explanations | Covered by Solo-Founder SaaS Automation & Operating Readiness | -| Auditor-ready executive export is not yet productized | Review truth still stops short of auditor-/executive-ready delivery without additional packaging | Covered by the manual-promotion backlog in `docs/product/spec-candidates.md` | -| Cross-tenant promotion execution is missing | Compare preview and preflight stop short of the actual portfolio action | Covered by the manual-promotion backlog in `docs/product/spec-candidates.md` | -| Governance decision pack and approval workflow is missing | Decision-based operating still lacks a bounded approval-ready action package with explicit audit trail | Covered by the manual-promotion backlog in `docs/product/spec-candidates.md` | +| Auditor-ready executive export is not yet productized | Review truth still stops short of calm auditor-/executive-ready delivery even though the spec package now exists | Covered by `specs/263-auditor-pack-executive-export/spec.md` | +| Cross-tenant promotion execution is missing | Compare preview and preflight stop short of the actual portfolio action even though the execution spec package now exists on this branch | Covered by `specs/264-cross-tenant-promotion-execution/spec.md` | +| Governance decision register and approval workflow is missing | Decision-based operating still lacks a bounded approval-ready closure and decision-record package with explicit audit trail | Covered by the manual-promotion backlog in `docs/product/spec-candidates.md` | | Customer-facing localization adoption is incomplete | Repo-real locale groundwork is not yet fully productized across customer-safe governance surfaces | Covered by the manual-promotion backlog in `docs/product/spec-candidates.md` | | Billing and subscription truth is missing | Commercial readiness still stops short of a durable billing/subscription truth layer | Covered by the manual-promotion backlog in `docs/product/spec-candidates.md` | | Stored reports still lack a clear product surface | Retained evidence and review artifacts remain harder to consume than they should be | Covered by the manual-promotion backlog in `docs/product/spec-candidates.md` | @@ -417,11 +473,13 @@ ## Infrastructure & Platform Debt ## Priority Ranking (Current Manual Promotion Order) -1. Auditor Pack Delivery & Executive Export v1 -2. Cross-Tenant Promotion Execution v1 -3. Governance Decision Pack & Approval Workflow v1 +This ranking applies only to still-unspecced or still-manual follow-through items. Auditor-ready delivery and cross-tenant promotion execution already have spec packages and therefore no longer belong in the manual-promotion ordering list. + +1. Decision Register & Approval Workflow v1 +2. Governance Artifact Lifecycle & Retention v1 +3. Billing & Subscription Truth Layer v1 4. Customer-Facing Localization Adoption v1 -5. Billing & Subscription Truth Layer v1 +5. Enterprise Access Boundary & Support Access Governance v1 6. Stored Reports Surface v1 7. Workspace & Tenant Closure Lifecycle v1 8. First Governed AI Runtime Consumer v1 diff --git a/docs/product/spec-candidates.md b/docs/product/spec-candidates.md index ce63fd1a..7f95a68a 100644 --- a/docs/product/spec-candidates.md +++ b/docs/product/spec-candidates.md @@ -1,10 +1,10 @@ # Spec Candidates > **Status:** Active -> **Last reviewed:** 2026-05-01 +> **Last reviewed:** 2026-05-02 > **Use for:** The active repo-based queue of spec candidates that may still need new or refreshed specs > **Do not use for:** Proof that a candidate is already specced, implemented, or prioritized above the roadmap without repo verification -> **Scoped maintenance:** 2026-05-01 repo-based queue re-audit against current `specs/` truth, including refreshed Spec 043 and Specs 251-260; stale active candidates were cleared and no new candidate was promoted. +> **Scoped maintenance:** 2026-05-02 repo-based queue re-audit plus enterprise-SaaS deep-research alignment against current `specs/` truth, including Specs 263 and current-branch 264. > > Repo-based next-spec queue for TenantPilot. > This file is not a wishlist. It tracks only open gaps that are still worth turning into new or refreshed specs. @@ -31,6 +31,101 @@ ## Current Source-Of-Truth Boundary - `discoveries.md` is a staging area for findings that may later be promoted here. - `implementation-ledger.md` is maturity evidence, not a prioritization queue. - Audit-derived candidate packages under `docs/audits/` are historical inputs only unless they are explicitly promoted into this file. +- The deep-research alignment reference below sharpens naming, status markers, and target sequencing without reopening already-promoted specs as active queue items. + +## Deep-Research Alignment Reference (non-queue) + +This section is a deep-research-derived calibration layer. It is intentionally not the active queue. Use it to keep candidate naming, scope, and status language aligned with current repo truth. + +### Customer Review Workspace v1 + +- **Status markers**: repo-verified, productization gap +- **Roadmap lane**: Now +- **Current repo truth**: Specs 249 and 258, plus the implementation ledger and current review surfaces, already prove the foundational and productization path for customer-safe review consumption. +- **Problem**: Customer-safe review consumption remains the clearest sellability gap whenever review truth, accepted risks, evidence, and package delivery still require operator translation. +- **Deep-Research-derived sharpening**: Keep the lane focused on one customer-safe read-only review surface, findings summary, accepted-risk visibility, evidence viewer, review-pack download, management summary, RBAC/capability enforcement, and audit trail. +- **Non-goals**: no generic customer portal, no helpdesk surface, no raw diagnostics by default, no admin mirror. + +### Decision-Based Governance Inbox + Decision Register v1 + +- **Status markers**: repo-verified, productization gap, roadmap recommendation +- **Roadmap lane**: Now +- **Current repo truth**: governance inbox, findings queues, operations attention, review follow-up, and Specs 250 and 257 already anchor the decision surface, but there is still no bounded decision-register follow-through. +- **Problem**: Findings, alerts, runs, and reviews still need one calmer decision layer with ownership, due state, reason, impact, next action, linked evidence, linked `OperationRun`, accepted-risk path, closure reason, and escalation hooks. +- **Deep-Research-derived sharpening**: treat the missing slice as decision-register and approval/closure follow-through over current inbox foundations, not as another queue page. +- **Non-goals**: no generic Kanban board, no PSA clone, no XDR incident console. + +### Governance Artifact Lifecycle & Retention v1 + +- **Status markers**: foundation-only, roadmap recommendation, spec candidate +- **Roadmap lane**: Now +- **Current repo truth**: Spec 262, the lifecycle-governance standard, review-pack retention, and artifact-truth semantics provide taxonomy-first foundations, but not a productized governance-artifact lifecycle. +- **Problem**: Evidence snapshots, stored reports, review packs, and future decision records are governance artifacts and must not be treated like short-lived operational rows. +- **Roadmap Recommendation**: v1 should cover artifact-type registry, immutable artifact reference, artifact state, retention state, export bundle, preserve or hold state, soft delete or hard delete semantics, suspended/read-only workspace behavior, and audit trail for export/delete/hold. +- **Non-goals**: no legal case management, no full eDiscovery system, no Purview clone. + +### Commercial Entitlements & Billing-State Lifecycle v1 + +- **Status markers**: repo-verified, foundation-only, productization gap +- **Roadmap lane**: Now +- **Current repo truth**: Specs 247 and 251 already ground workspace entitlements, lifecycle state handling, and read-only gating. +- **Problem**: Workspace, plan, and billing state still need clearer artifact-access, archive, scheduled-deletion, and customer-trust semantics. +- **Deep-Research-derived sharpening**: keep the lane framed as commercial lifecycle, workspace read-only behavior, artifact access by state, capability gating, and audited state change semantics rather than payment-engine work. +- **Non-goals**: no payment gateway, no invoicing engine, no tax engine. + +### External Support Desk / PSA Handoff v1 + +- **Status markers**: repo-verified, productization gap +- **Roadmap lane**: Next +- **Current repo truth**: Spec 256 and the current support-request handoff already prove a bounded create/link model. +- **Problem**: MSP governance work still needs cleaner PSA/ITSM handoff with external reference continuity, evidence/context transfer, due date/status mapping, closure sync or manual reconciliation, audit trail, and webhook-ready shape. +- **Deep-Research-derived sharpening**: keep PSA/ITSM as integration and handoff, not as a TenantPilot-native helpdesk. +- **Non-goals**: no PSA clone, no project-management suite, no generic helpdesk. + +### Customer-Facing Localization v1 + +- **Status markers**: foundation-only, productization gap +- **Roadmap lane**: Next +- **Current repo truth**: Spec 252 and the locale resolver already provide the foundation. +- **Problem**: DE/EN customer-facing review consumption still lacks full glossary discipline, customer-safe labels, review-pack templates, notification text, and fallback confidence. +- **Deep-Research-derived sharpening**: v1 means glossary, review-workspace strings, review-pack templates, evidence labels, status/reason/impact/next-action labels, locale-aware formatting, fallback behavior, and missing-key tests. +- **Non-goals**: no full operator-UI localization in v1, no marketing translation project, no uncontrolled string extraction. + +### Cross-Tenant Compare & Promotion with Lineage v1 + +- **Status markers**: repo-verified, productization gap +- **Roadmap lane**: Next +- **Current repo truth**: Spec 043 is already repo-real for compare and preflight, and Spec 264 now carries the execution follow-through on this branch. +- **Problem**: portfolio action still needs governance-first execution with impact preview, promotion proposal, approval, `OperationRun` trace, before/after evidence, baseline lineage, rollback reference, and decision-record linkage. +- **Deep-Research-derived sharpening**: keep this lane governance-first. It is not a generic policy-push or settings-sync surface. +- **Non-goals**: no unmanaged mass remediation, no generic settings push, no admin mirror. + +### Governance Service Packaging v1 + +- **Status markers**: repo-verified, productization gap +- **Roadmap lane**: Next +- **Current repo truth**: Spec 260 and current governance-package delivery cues already exist. +- **Problem**: MSPs need repeatable governance-service packages with review cadence, included controls/reports, stakeholder mapping, schedule, package-specific review-pack semantics, and entitlement binding. +- **Deep-Research-derived sharpening**: productize packaging as a repeatable governance service, not as one-off executive exports or bespoke customer projects. +- **Non-goals**: no CRM, no PSA, no billing system. + +### Enterprise Access Boundary & Support Access Governance v1 + +- **Status markers**: roadmap recommendation, spec candidate +- **Roadmap lane**: Next when support-access gaps turn operationally acute; otherwise Later +- **Current repo truth**: audit docs and handover material already show break-glass, system access, and platform support seams, but not a bounded support-access governance package. +- **Problem**: support access, delegated access, and future impersonation need customer-safe auditability, reason capture, TTL, approval, operator-context banner, and exportable access logs before broader SSO/SCIM work. +- **Roadmap Recommendation**: prioritize support-access request, reason required, time-limited access, capability-bound access, customer-visible audit trail, optional approval, break-glass separation, operator context banner, and exportable access log. +- **Non-goals**: no full IAM suite, no immediate SCIM requirement unless separately promoted, no unrestricted impersonation. + +### Private AI Execution Governance Foundation v1 + +- **Status markers**: repo-verified, foundation-only, later scale-layer +- **Roadmap lane**: Later +- **Current repo truth**: Spec 248 is already implemented as a governed AI foundation. +- **Problem**: visible AI work must still avoid feature-island drift and should only ship on top of governed use-case, provider-class, policy, audit, and approval boundaries. +- **Deep-Research-derived sharpening**: keep AI foundation-first. The next visible candidate is a bounded governed runtime consumer or AI-assisted review-drafting lane, not a new foundation reboot. +- **Non-goals**: no public LLM by default, no autonomous remediation, no chatbot over raw tenant data, no AI without audit and policy boundaries. ## Active Candidate Queue @@ -51,45 +146,49 @@ ## Active Candidate Queue These packages already provide the needed preparation surface, and several now carry completed task checklists or implementation close-out history. They must not be auto-selected again by `next-best-prep`. +Two manual-promotion items have since moved out of backlog status on the current repo state: + +- `Auditor Pack Delivery & Executive Export v1` -> Spec 263 +- `Cross-Tenant Promotion Execution v1` -> Spec 264 + The historical record for `Workspace, Tenant & Managed Object Lifecycle Governance v1` remains below because it was promoted intentionally, not automatically, into Spec 262 and must not re-enter the active auto-prep queue. ## Promotable Candidate Backlog **Boundary**: manual promotion only, not auto-prep. These items are intentionally outside `next-best-prep` and require an explicit product decision before any future spec refresh or follow-up work. -### Auditor Pack Delivery & Executive Export v1 +### Decision Register & Approval Workflow v1 - **Priority**: 1 -- **Repo truth**: review-pack export, evidence, tenant review, customer review productization, compliance mapping, and governance packaging foundations are already spec-backed. -- **Why promotable now**: this is the clearest remaining step between repo-real governance truth and auditor-/executive-ready delivery. -- **Why manual promotion only**: this is a bounded packaging and export decision, not an automatic next-best-prep foundation gap. -- **Anchors**: - - `specs/109-review-pack-export/spec.md` - - `specs/153-evidence-domain-foundation/spec.md` - - `specs/155-tenant-review-layer/spec.md` - - `specs/258-customer-review-productization/spec.md` - - `specs/259-compliance-evidence-mapping/spec.md` - - `specs/260-governance-service-packaging/spec.md` - -### Cross-Tenant Promotion Execution v1 - -- **Priority**: 2 -- **Repo truth**: cross-tenant compare preview and promotion preflight are already prepared, but execution is still the missing product action. -- **Why promotable now**: this is the next MSP-multiplier after customer-safe review and packaging follow-through. -- **Why manual promotion only**: the repo already has the compare/preflight preparation package, so only the narrower execution slice should be promoted deliberately. -- **Anchors**: - - `specs/043-cross-tenant-compare-and-promotion/spec.md` - -### Governance Decision Pack & Approval Workflow v1 - -- **Priority**: 3 -- **Repo truth**: governance convergence is spec-backed, but the bounded human-in-the-loop decision-pack workflow is still a distinct follow-up gap. -- **Why promotable now**: it is the next decision-based operating slice after convergence, without expanding into autonomous remediation. -- **Why manual promotion only**: this must stay an explicit product choice because the v1 scope is intentionally narrow: decision pack, reason, impact, evidence, recommended action, approve, reject, snooze, assign, audit trail, optional OperationRun link, no autonomous remediation in v1. +- **Repo truth**: governance inbox and convergence are spec-backed and partly repo-real, but the bounded decision-register and approval workflow is still a distinct follow-up gap. +- **Why promotable now**: it is the highest-value unspecced operator follow-through once inbox and review consumption are already grounded. +- **Why manual promotion only**: this must stay a deliberate product choice because v1 should remain narrow: owner, due date, status, reason, impact, next action, linked evidence, linked `OperationRun`, accepted-risk path, closure reason, escalation hook, and optional approval or closure semantics without autonomous remediation. - **Anchors**: + - `specs/250-decision-governance-inbox/spec.md` - `specs/257-governance-decision-convergence/spec.md` - `docs/product/roadmap.md` +### Governance Artifact Lifecycle & Retention v1 + +- **Priority**: 2 +- **Repo truth**: lifecycle taxonomy, artifact-truth semantics, and point retention rules already exist, but governance artifacts still lack one productized lifecycle runtime contract. +- **Why promotable now**: this is the clearest new trust and auditability gap highlighted by the deep research. +- **Why manual promotion only**: it crosses lifecycle, artifact, export, retention, and suspension semantics and therefore needs an explicit product boundary rather than automatic prep. +- **Anchors**: + - `specs/158-artifact-truth-semantics/spec.md` + - `specs/262-lifecycle-governance-taxonomy/spec.md` + - `docs/product/standards/lifecycle-governance.md` + +### Billing & Subscription Truth Layer v1 + +- **Priority**: 3 +- **Repo truth**: plans, entitlements, and commercial lifecycle maturity are already spec-backed, but the billing/subscription truth layer is still missing. +- **Why promotable now**: deep-research alignment moves commercial trust and lifecycle closer to the now lane, even though the remaining unspecced slice is still the narrower billing/subscription follow-through. +- **Why manual promotion only**: the broad readiness work is already covered, so this should not reappear as an automatic foundation candidate. +- **Anchors**: + - `specs/247-plans-entitlements-billing-readiness/spec.md` + - `specs/251-commercial-entitlements-billing-state/spec.md` + ### Customer-Facing Localization Adoption v1 - **Priority**: 4 @@ -101,15 +200,17 @@ ### Customer-Facing Localization Adoption v1 - `specs/258-customer-review-productization/spec.md` - `specs/260-governance-service-packaging/spec.md` -### Billing & Subscription Truth Layer v1 +### Enterprise Access Boundary & Support Access Governance v1 - **Priority**: 5 -- **Repo truth**: plans, entitlements, and commercial lifecycle maturity are already spec-backed, but the billing/subscription truth layer is still missing. -- **Why promotable now**: this is the remaining commercial truth gap after entitlements and lifecycle groundwork. -- **Why manual promotion only**: the broad readiness work is already covered, so this should not be reintroduced as an automatic foundation candidate. +- **Repo truth**: break-glass and platform access seams are documented, but no bounded support-access governance package currently exists. +- **Why promotable now**: this is the narrow early access-governance slice that should happen before broad SSO/SCIM ambitions if support access and customer-visible auditability become pressing. +- **Why manual promotion only**: the right cut is product-sensitive and must stay tightly bounded around support access, delegated access, TTL, approval, audit trail, and operator-context visibility. - **Anchors**: - - `specs/247-plans-entitlements-billing-readiness/spec.md` - - `specs/251-commercial-entitlements-billing-state/spec.md` + - `docs/audits/2026-03-09-enterprise-rbac-scope-audit.md` + - `docs/HANDOVER.md` + - `specs/065-tenant-rbac-v1/spec.md` + - `specs/066-rbac-ui-enforcement-helper/spec.md` ### Stored Reports Surface v1 @@ -230,6 +331,8 @@ ## Promoted to Spec - Governance-as-a-Service Packaging v1 -> Spec 260 (`governance-service-packaging`) - Provider-Missing Policy Visibility & Restore Continuity v1 -> Spec 261 (`provider-missing-policy-visibility`) - Workspace, Tenant & Managed Object Lifecycle Governance v1 -> Spec 262 (`lifecycle-governance-taxonomy`) +- Auditor Pack Delivery & Executive Export v1 -> Spec 263 (`auditor-pack-executive-export`) +- Cross-Tenant Promotion Execution v1 -> Spec 264 (`cross-tenant-promotion-execution`) - Queued Execution Reauthorization and Scope Continuity -> Spec 149 (`queued-execution-reauthorization`) - Livewire Context Locking and Trusted-State Reduction -> Spec 152 (`livewire-context-locking`) - Evidence Domain Foundation -> Spec 153 (`evidence-domain-foundation`) diff --git a/specs/264-cross-tenant-promotion-execution/checklists/requirements.md b/specs/264-cross-tenant-promotion-execution/checklists/requirements.md new file mode 100644 index 00000000..ce38376d --- /dev/null +++ b/specs/264-cross-tenant-promotion-execution/checklists/requirements.md @@ -0,0 +1,53 @@ +# Specification Quality Checklist: Cross-Tenant Promotion Execution v1 + +**Purpose**: Validate specification completeness and repo fit before implementation +**Created**: 2026-05-02 +**Feature**: [spec.md](../spec.md) + +## Content Quality + +- [x] The spec stays on one bounded follow-up over the already-implemented read-only compare and preflight slice instead of reopening portfolio compare foundations. +- [x] The spec is product- and behavior-oriented and does not read like an implementation diff. +- [x] The spec explicitly names the current repo-real foundations it builds on: `CrossTenantComparePage`, compare preview, promotion preflight, launch context, shared `OperationRun` UX, and current provider-write seams. +- [x] Mandatory repo sections for scope, RBAC, disclosure, testing, operation UX, and proportionality are completed. + +## Requirement Completeness + +- [x] No `[NEEDS CLARIFICATION]` markers remain. +- [x] Requirements are testable and bounded to one compare-page execution path. +- [x] The spec explains what remains in scope versus what is intentionally deferred. +- [x] Acceptance scenarios cover queueing execution, Monitoring continuity, and target-safe authorization behavior. +- [x] Edge cases cover stale preflight, no-ready execution, active-run dedupe, inaccessible tenants, and operational-control blocking. + +## Candidate Selection Gate + +- [x] The selected candidate exists in `docs/product/spec-candidates.md` and `docs/product/roadmap.md`. +- [x] `docs/product/implementation-ledger.md` still records promotion execution as the missing delta after compare and preflight. +- [x] Existing Spec 043 was checked for completion and treated as inherited context only; this package is explicitly the execution follow-up, not a rewrite of the read-only slice. +- [x] The chosen slice is smaller and higher-priority than deferred alternatives such as batch promotion, approvals, rollback, or mapping automation. + +## Feature Readiness + +- [x] The slice is an explicit delta follow-up over Spec 043 and current code, centered on one canonical compare page and one queued run path. +- [x] The spec explicitly forbids new panel or Laravel or Filament service-provider registration changes, new global-search scope, new asset strategy, and new promotion-draft persistence. +- [x] The spec explicitly records one canonical `promotion.execute` `OperationRun` path and shared Monitoring continuity. +- [x] The artifacts explicitly distinguish conditional `ProviderOperationRegistry` wiring from Laravel or Filament service-provider registration. +- [x] The spec acknowledges the current repo constraint that foreign-tenant `PolicyVersion` execution is not directly allowed and therefore requires a bounded bridge instead of a fake direct reuse claim. + +## Test Governance + +- [x] Planned validation stays bounded to `PortfolioCompare` unit and feature families plus one new bounded browser smoke file. +- [x] One new browser smoke file is explicitly justified because the feature adds a confirmation modal and compare-to-Monitoring handoff on a live Filament page. +- [x] The runtime proof commands stay consistent across spec, plan, and tasks, while Pint remains standard implementation hygiene. + +## Notes + +- Reviewed against `docs/product/spec-candidates.md`, `docs/product/roadmap.md`, `docs/product/implementation-ledger.md`, `specs/043-cross-tenant-compare-and-promotion/spec.md`, current PortfolioCompare code and tests under `apps/platform`, current `OperationRun` UX seams, current restore or policy-version write seams, and `.specify/memory/constitution.md` on 2026-05-02. +- No application implementation was performed while preparing this package. + +## Review Outcome + +- **Outcome class**: `acceptable-special-case` +- **Outcome**: `keep` +- **Reason**: The spec promotes the next real manual backlog item after the implemented read-only compare slice, stays on one canonical compare page plus one run type, explicitly rejects draft persistence and batch drift, and only introduces one justified browser smoke addition. +- **Workflow result**: Ready for implementation. \ No newline at end of file diff --git a/specs/264-cross-tenant-promotion-execution/plan.md b/specs/264-cross-tenant-promotion-execution/plan.md new file mode 100644 index 00000000..6002169c --- /dev/null +++ b/specs/264-cross-tenant-promotion-execution/plan.md @@ -0,0 +1,230 @@ +# Implementation Plan: Cross-Tenant Promotion Execution v1 + +**Branch**: `264-cross-tenant-promotion-execution` | **Date**: 2026-05-02 | **Spec**: [spec.md](./spec.md) +**Input**: Feature specification from `/specs/264-cross-tenant-promotion-execution/spec.md` + +## Summary + +This plan is the execution delta over Spec 043 and the current cross-tenant compare code path. The existing compare page, compare preview builder, promotion preflight, launch context, and preflight audit are inherited. The implementation scope is only to add one bounded `Execute promotion` path that reuses the current compare and preflight truth, requires explicit confirmation, queues exactly one canonical `promotion.execute` `OperationRun`, and keeps result truth on the shared Monitoring path. The implementation must not add a persisted promotion-draft or compare-snapshot entity. + +The most important repo-truth constraint is already visible in current code: `RestoreService::executeFromPolicyVersion()` rejects foreign-tenant `PolicyVersion` records, so the execution path cannot be implemented as a naive direct call to the existing policy-version restore job. v1 therefore needs one bounded promotion execution planner or bridge that translates source-tenant content into target-safe write inputs while still delegating the actual target mutation through current provider-write seams. + +## Inherited Baseline / Explicit Delta + +### Inherited baseline + +- `CrossTenantComparePage` already owns the canonical `/admin/cross-tenant-compare` selection and preflight surface. +- `CrossTenantCompareSelection`, `CrossTenantComparePreviewBuilder`, and `CrossTenantPromotionPreflight` already provide reproducible compare and readiness truth. +- tenant-registry and portfolio launch context plus return-state continuity already work. +- preflight audit already uses the current workspace audit pipeline. +- current tests already prove compare preview, preflight, authorization, audit, and launch continuity. + +### Explicit delta in this plan + +- add one queued `promotion.execute` run type and the smallest supporting control, capability, and audit wiring required for it +- add one bounded promotion execution planner or bridge that consumes the current compare and preflight truth +- wire `Execute promotion` onto the current compare page with explicit confirmation and shared start-result UX +- keep Monitoring continuity on the existing `OperationRun` viewer and current run-link contract + +## Technical Context + +**Language/Version**: PHP 8.4, Laravel 12, Filament v5, Livewire v4 +**Primary Dependencies**: `CrossTenantComparePage`, `CrossTenantComparePreviewBuilder`, `CrossTenantPromotionPreflight`, `OperationRunService`, `OperationCatalog`, `OperationRunLinks`, `OperationUxPresenter`, `ProviderOperationStartResultPresenter`, `WorkspaceAuditLogger`, `AuditActionId`, and the current policy-version or restore write seam +**Storage**: PostgreSQL tables already in use for `OperationRun`, audit logs, policy versions, inventory, and existing provider-write artifacts +**Testing**: Pest unit and feature tests plus one bounded browser smoke +**Validation Lanes**: `fast-feedback`, `confidence`, `browser` +**Target Platform**: existing Laravel admin runtime under `apps/platform` +**Project Type**: Laravel monolith with Filament admin surfaces +**Performance Goals**: no synchronous provider mutation from the compare page, no second queue family, and no new heavy browser or provider fixture domain +**Constraints**: no promotion-draft table, no compare-snapshot table, no direct foreign-tenant `PolicyVersion` execution, no multi-target batch, no approval chain, no rollback engine +**Scale/Scope**: one source tenant, one target tenant, one current compare scope, one queued run + +## UI / Surface Guardrail Plan + +- **Guardrail scope**: changed surfaces +- **Native vs custom classification summary**: native Filament compare page plus shared `OperationRun` UX only +- **Shared-family relevance**: compare-page header actions, confirmation modal, queued start feedback, run links, Monitoring continuity +- **State layers in scope**: page state, query state, preflight state, action state, confirmation modal state, and existing run-link state +- **Audience modes in scope**: operator-MSP only +- **Decision/diagnostic/raw hierarchy plan**: decision-first compare summary and mutation scope first, subject-level execution diagnostics second, raw provider detail last and kept off the compare page +- **Raw/support gating plan**: raw provider payloads, provider IDs, and worker detail remain behind existing tenant or Monitoring detail surfaces +- **One-primary-action / duplicate-truth control**: the compare page keeps exactly one dominant next action at a time, and Monitoring remains the only progress detail surface after queueing +- **Handling modes by drift class or surface**: `review-mandatory` for any drift toward a second promotion surface, draft persistence, or local notification flow +- **Repository-signal treatment**: `review-mandatory` +- **Special surface test profiles**: `standard-native-filament` +- **Required tests or manual smoke**: focused PortfolioCompare feature coverage plus one bounded browser smoke for compare-to-operation handoff +- **Exception path and spread control**: none; any proposal for draft persistence, approvals, rollback, or batch execution is a scope split, not an in-feature exception +- **Active feature PR close-out entry**: Smoke Coverage + +## Shared Pattern & System Fit + +- **Cross-cutting feature marker**: yes +- **Systems touched**: `CrossTenantComparePage`, compare preview and preflight services, operation catalog and capability wiring, current Monitoring links, audit logging, and the bounded provider-write bridge for target mutation +- **Shared abstractions reused**: `OperationRunService`, `OperationRunLinks`, `OperationUxPresenter`, `ProviderOperationStartResultPresenter`, `WorkspaceUiEnforcement`, `WorkspaceAuditLogger`, and current compare and preflight services +- **New abstraction introduced? why?**: yes. One bounded `PortfolioCompare` execution planner or bridge is required because current restore helpers are tenant-owned and reject foreign-tenant policy versions. One queued promotion job is required because target mutation must not run synchronously on the compare page. +- **Why the existing abstraction was sufficient or insufficient**: the current compare and preflight seam is sufficient for readiness and exclusion truth. It is insufficient for execution because it does not generate a target-safe mutation plan or a queued run identity. +- **Bounded deviation / spread control**: keep the new logic local to the `PortfolioCompare` domain and current run UX seams. Do not create a new promotion module, workspace, or dashboard family. + +## OperationRun UX Impact + +- **Touches OperationRun start/completion/link UX?**: yes +- **Central contract reused**: `OperationRunService`, `OperationRunLinks`, `OperationUxPresenter`, `ProviderOperationStartResultPresenter`, and `OpsUxBrowserEvents` +- **Delegated UX behaviors**: queued or deduped start messaging, blocked or scope-busy messaging, Monitoring link generation, run-enqueued browser event, and terminal notification stay on the shared run UX layer +- **Surface-owned behavior kept local**: compare-page confirmation copy, excluded-subject explanation, and current launch or return-state preservation remain local to the compare page +- **Queued DB-notification policy**: no new queued-only DB notification path +- **Terminal notification path**: existing `OperationRun` completion path remains authoritative +- **Exception path**: none + +## Provider Boundary & Portability Fit + +- **Shared provider/platform boundary touched?**: yes +- **Provider-owned seams**: current policy-version capture, restore semantics, assignment or scope-tag handling, and provider write execution stay provider-owned +- **Platform-core seams**: compare-page wording, run vocabulary, authorization, operational control labels, and Monitoring continuity stay platform-owned +- **Neutral platform terms / contracts preserved**: source tenant, target tenant, governed subject, promotion execution, ready subject, blocked reason, manual mapping required +- **Retained provider-specific semantics and why**: Microsoft-specific payload and target mutation semantics remain inside the existing provider-write seam because the repo currently has one real provider +- **Bounded extraction or follow-up path**: if current provider-write seams cannot support a bounded bridge without widening persistence, stop and split before introducing a second promotion truth + +## Constitution Check + +*GATE: Must pass before implementation begins and again before merge.* + +- Inventory-first: compare preview and preflight remain derived from current tenant-owned inventory or captured content; execution consumes that truth instead of inventing a parallel catalog +- Read/write separation: preflight stays read-only; mutation begins only after explicit confirmation and queued execution +- Graph contract path: direct Graph or provider writes must stay behind the current provider-write seam, never in the Filament page action itself +- Deterministic capabilities: execution stays on existing capability registries and current workspace or tenant isolation rules +- RBAC-UX: inaccessible tenants remain `404`; execution denials remain explicit `403` only for in-scope actors forcing a blocked mutation path +- Workspace isolation: unchanged +- Tenant isolation: source tenant stays read-only; target tenant owns the mutation boundary +- Run observability: exactly one canonical `promotion.execute` `OperationRun` is required +- OperationRun start UX: shared start UX remains authoritative +- Ops-UX lifecycle and summary counts: stay on existing `OperationSummaryKeys`; no new summary-key family +- Test governance: keep proof bounded to PortfolioCompare unit, feature, and one browser smoke file only +- Proportionality / persistence / bloat: no new table, no approval chain, no rollback, no draft persistence +- Shared pattern first: current compare page and run UX must be extended, not bypassed +- Provider boundary: use the bounded bridge only to cross the tenant-owned restore restriction; do not generalize it into a second provider abstraction layer +- V1 explicitness / few layers: prefer one planner or bridge plus one job over a subsystem of drafts, approvals, and orchestration records +- Filament-native UI: keep the current compare page as the only operator surface; no new panel or Laravel or Filament service-provider registration work is needed +- UI or UX surface taxonomy and decision-first operating model: compare remains the decision surface and Monitoring remains the run viewer +- Audience-aware disclosure: operators see readiness, mutation scope, and run link first; raw provider detail stays hidden by default +- Action-surface discipline: the compare page keeps one dominant next action at a time + +## Test Governance Check + +- **Test purpose / classification by changed surface**: Unit, Feature, Browser +- **Affected validation lanes**: `fast-feedback`, `confidence`, `browser` +- **Why this lane mix is the narrowest sufficient proof**: unit coverage proves plan derivation and ready-only filtering, feature coverage proves Filament action, auth, and audit behavior, and one browser smoke proves the confirmation modal plus Monitoring handoff on the real compare page +- **Narrowest proving command(s)**: + - `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Unit/Support/PortfolioCompare/CrossTenantPromotionExecutionPlannerTest.php` + - `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/PortfolioCompare/CrossTenantPromotionExecutionActionTest.php tests/Feature/PortfolioCompare/CrossTenantPromotionExecutionAuthorizationTest.php tests/Feature/PortfolioCompare/CrossTenantPromotionExecutionAuditTest.php tests/Feature/PortfolioCompare/CrossTenantPromotionExecutionRunUxTest.php tests/Feature/PortfolioCompare/CrossTenantComparePageTest.php tests/Feature/PortfolioCompare/CrossTenantCompareLaunchContextTest.php` + - `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Browser/PortfolioCompare/CrossTenantPromotionExecutionSmokeTest.php` + - `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail bin pint --dirty --format agent` +- **Fixture / helper / factory / seed / context cost risks**: reuse current portfolio-compare fixtures and current `OperationRun` assertions; avoid new provider seed domains or broad Monitoring fixtures +- **Expensive defaults or shared helper growth introduced?**: no +- **Heavy-family additions, promotions, or visibility changes**: one new bounded browser smoke file only +- **Surface-class relief / special coverage rule**: `standard-native-filament` with required real-browser confirmation coverage +- **Closing validation and reviewer handoff**: reviewers should confirm that the queued path, audit trail, and Monitoring handoff all stay on shared seams with no draft persistence +- **Budget / baseline / trend follow-up**: none +- **Review-stop questions**: lane fit, no-draft persistence, run type or control naming, and bounded target-write bridge only +- **Escalation path**: none +- **Active feature PR close-out entry**: Smoke Coverage + +## Project Structure + +### Documentation (this feature) + +```text +specs/264-cross-tenant-promotion-execution/ +├── spec.md +├── plan.md +├── tasks.md +└── checklists/ + └── requirements.md +``` + +### Source Code (expected implementation surfaces) + +```text +apps/platform/app/Filament/Pages/CrossTenantComparePage.php +apps/platform/app/Support/PortfolioCompare/CrossTenantComparePreviewBuilder.php +apps/platform/app/Support/PortfolioCompare/CrossTenantPromotionPreflight.php +apps/platform/app/Support/PortfolioCompare/CrossTenantPromotionExecutionPlanner.php +apps/platform/app/Services/PortfolioCompare/CrossTenantPromotionExecutionService.php +apps/platform/app/Jobs/Operations/CrossTenantPromotionExecutionJob.php +apps/platform/app/Support/OperationCatalog.php +apps/platform/app/Support/OperationRunType.php +apps/platform/app/Support/Operations/OperationRunCapabilityResolver.php +apps/platform/app/Support/OperationalControls/OperationalControlCatalog.php +apps/platform/app/Services/Providers/ProviderOperationRegistry.php +apps/platform/app/Support/Audit/AuditActionId.php +apps/platform/app/Services/Audit/WorkspaceAuditLogger.php +apps/platform/app/Support/OperationRunLinks.php +apps/platform/app/Support/Navigation/RelatedNavigationResolver.php +apps/platform/app/Services/OperationRunService.php +apps/platform/app/Services/Intune/RestoreService.php +apps/platform/tests/Unit/Support/PortfolioCompare/CrossTenantPromotionExecutionPlannerTest.php +apps/platform/tests/Feature/PortfolioCompare/CrossTenantPromotionExecutionActionTest.php +apps/platform/tests/Feature/PortfolioCompare/CrossTenantPromotionExecutionAuthorizationTest.php +apps/platform/tests/Feature/PortfolioCompare/CrossTenantPromotionExecutionAuditTest.php +apps/platform/tests/Feature/PortfolioCompare/CrossTenantPromotionExecutionRunUxTest.php +apps/platform/tests/Feature/PortfolioCompare/CrossTenantCompareLaunchContextTest.php +apps/platform/tests/Browser/PortfolioCompare/CrossTenantPromotionExecutionSmokeTest.php +``` + +**Structure Decision**: keep the implementation inside the current compare page, `PortfolioCompare` support or service layer, and current `OperationRun` UX seams. Add at most one bounded planner or bridge, one execution service, and one queued job. + +## Data / Migration Implications + +- Prefer existing `OperationRun.context`, `OperationRun.summary_counts`, and audit metadata over new persistence. +- No new table or migration is expected for v1. +- If operation or control naming needs only PHP registry changes, keep it there and avoid schema work. +- If implementation cannot bridge source content into target-safe mutation inputs without a new persisted promotion-draft or snapshot entity, stop and split the feature rather than widening the current package. + +## Rollout Considerations + +- Filament remains v5 on Livewire v4. No new Laravel or Filament service-provider registration change is required, and service-provider registration remains in `apps/platform/bootstrap/providers.php`. +- No global search change is required because the affected surface is an existing page and the Monitoring viewer is already in place. +- No new asset registration is expected. +- The new mutating action is not destructive in the delete sense, but it must still use explicit confirmation on the compare page. +- Existing queue workers remain the deployment requirement for the new run path. + +## Risk Controls + +- Reject any implementation that persists promotion drafts, compare snapshots, or approval records. +- Reject any implementation that reuses `RestoreService::executeFromPolicyVersion()` by pretending the source version belongs to the target tenant. +- Reject any implementation that introduces a second promotion surface, a second queue domain, or a promotion-specific dashboard. +- Reject any implementation that invents new run-summary keys instead of staying on canonical keys. +- Keep blocked and manual-mapping-required subjects excluded from target mutation in all paths. + +## Implementation Phases + +### Phase 0 - Confirm the bounded execution seam + +- Reconfirm the current compare and preflight truth plus the current restore restriction that foreign-tenant `PolicyVersion` execution is not directly allowed. +- Decide the smallest target-safe bridge from source content to target write inputs without adding persistence. + +### Phase 1 - Add operation vocabulary and bounded execution planning + +- Add one canonical `promotion.execute` operation type, its control key, run capability mapping, any `ProviderOperationRegistry` entry only if the chosen shared start-result seam requires it, and audit action IDs. +- Add one bounded planner or bridge that derives ready-only execution inputs and stable run identity values from the current compare and preflight truth. + +`ProviderOperationRegistry` remains application operation-vocabulary wiring only. It is not Laravel or Filament service-provider registration. + +### Phase 2 - Wire the compare page action and shared start UX + +- Add `Execute promotion` to the current compare page with explicit confirmation, single-primary-action discipline, and preserved source, target, and return-state context. +- Reuse shared start-result UX for queued, deduped, blocked, and Monitoring-link behaviors. + +### Phase 3 - Execute the target mutation through one queued run + +- Add one queued promotion execution job that consumes `OperationRun.context`, delegates actual writes through current provider-write seams, and records summary counts using canonical keys only. +- Keep the compare page free of synchronous target mutation. + +### Phase 4 - Audit, Monitoring continuity, and stop + +- Record start and terminal audit metadata for promotion execution. +- Ensure Monitoring links, related navigation, and labels remain canonical for the new run type. +- Run the planned validation commands and stop without widening into drafts, approval flow, rollback, or batch execution. + +## Why This Plan Is Narrow Enough + +The repo already has the compare page, preflight truth, audit path, queue infrastructure, and Monitoring viewer. This plan adds only the missing bounded execution seam: one confirmed page action, one run type, one ready-only execution planner or bridge, and one queued worker. Everything larger stays explicitly deferred. \ No newline at end of file diff --git a/specs/264-cross-tenant-promotion-execution/spec.md b/specs/264-cross-tenant-promotion-execution/spec.md new file mode 100644 index 00000000..2f290503 --- /dev/null +++ b/specs/264-cross-tenant-promotion-execution/spec.md @@ -0,0 +1,311 @@ +# Feature Specification: Cross-Tenant Promotion Execution v1 + +**Feature Branch**: `264-cross-tenant-promotion-execution` +**Created**: 2026-05-02 +**Status**: Ready for implementation +**Input**: User description: "Cross-Tenant Promotion Execution v1" + +## Inherited Baseline / Explicit Delta + +This package is an explicit delta follow-up over Spec 043 and the current cross-tenant compare code path. + +### Inherited baseline + +- `CrossTenantComparePage` already owns the canonical `/admin/cross-tenant-compare` decision surface. +- `CrossTenantCompareSelection`, `CrossTenantComparePreviewBuilder`, and `CrossTenantPromotionPreflight` already produce reproducible read-only compare and readiness truth. +- tenant-registry launch context, exact-two compare launch, and return-state preservation already exist. +- preflight audit already lands on the existing workspace audit pipeline through `AuditActionId::CrossTenantPromotionPreflightGenerated`. +- the current slice is intentionally read-only and explicitly deferred actual promotion execution, queueing, `OperationRun`, persisted compare snapshots, persisted promotion drafts, mapping automation, and customer-facing compare. + +### Explicit delta in this spec + +- add one bounded `Execute promotion` action from the current compare and preflight context +- require explicit confirmation before any target mutation starts +- queue exactly one canonical `OperationRun` for promotion execution and reuse the shared start/result UX +- mutate the target tenant for `ready` subjects only while keeping `blocked` and `manual_mapping_required` subjects excluded and visible +- keep execution truth on existing `OperationRun` and audit records instead of adding a new promotion-draft or compare-snapshot table + +Everything broader remains inherited or explicitly out of scope. + +## Spec Candidate Check *(mandatory - SPEC-GATE-001)* + +- **Problem**: The product can already compare tenants and generate a read-only promotion preflight, but operators still cannot execute the bounded promotion step that the page recommends. +- **Today's failure**: Cross-tenant compare ends at advice. Operators still need manual, off-platform steps to apply source settings to the target tenant and then separately reconstruct whether the action actually ran. +- **User-visible improvement**: An authorized workspace operator can review a current compare preflight, confirm the scope, queue one cross-tenant promotion run, and follow that run through the existing Monitoring flow without leaving the canonical compare page. +- **Smallest enterprise-capable version**: One compare-page execution action, one confirmation modal, one queued `OperationRun`, one bounded promotion execution planner and worker, one truthful Monitoring handoff, and explicit audit metadata. No draft persistence, no batch execution, and no rollback workflow ship in v1. +- **Explicit non-goals**: No persisted promotion draft entity, no scheduled or recurring promotion, no multi-target batch execution, no approval workflow, no rollback engine, no customer-facing promotion, no cross-workspace execution, and no multi-provider abstraction expansion. +- **Permanent complexity imported**: One bounded execution planner or bridge, one queued promotion job, one new canonical operation type plus control key, a small audit extension, and focused unit, feature, and browser proof. +- **Why now**: `docs/product/spec-candidates.md`, `docs/product/roadmap.md`, and `docs/product/implementation-ledger.md` all still identify execution as the missing follow-up after the already-implemented read-only compare and preflight slice. +- **Why not local**: A page-local mutation button without a shared run contract would bypass the current compare truth, safety gates, audit seams, and Monitoring continuity. +- **Approval class**: Core Enterprise +- **Red flags triggered**: New mutating action, new queued run type, new write bridge over an existing read-only workflow. Defense: the slice stays on one compare page, one target tenant, one run type, zero new tables, and existing provider write seams. +- **Score**: Nutzen: 2 | Dringlichkeit: 2 | Scope: 1 | Komplexitaet: 1 | Produktnaehe: 2 | Wiederverwendung: 2 | **Gesamt: 10/12** +- **Decision**: approve + +## Spec Scope Fields *(mandatory)* + +- **Scope**: canonical-view +- **Primary Routes**: + - existing canonical `/admin/cross-tenant-compare` page for selection, preflight review, and execution confirmation + - existing `/admin/tenants` registry or portfolio-triage launch surfaces as the compare entrypoint and return context + - existing Monitoring operation-run detail flow as the canonical follow-up after a promotion run is queued +- **Data Ownership**: + - source-of-truth remains the current compare preview, preflight result, source-tenant `PolicyVersion` or equivalent captured content, and target-tenant inventory or restore truth + - runtime truth remains on `OperationRun`, current audit logs, and existing provider-write artifacts; no `CrossTenantPromotionDraft`, compare snapshot, or promotion-plan table is introduced + - any execution context must live in `OperationRun.context`, `OperationRun.summary_counts`, and audit metadata only +- **RBAC**: + - workspace and tenant scoping stay deny-as-not-found first for out-of-scope actors or tenants + - compare viewing keeps the existing 043 requirements: workspace baselines view plus tenant view on both source and target + - execution requires the compare-view permissions plus `Capabilities::WORKSPACE_BASELINES_MANAGE` on the workspace and `Capabilities::TENANT_MANAGE` on the target tenant + - source-tenant access stays read-only (`Capabilities::TENANT_VIEW`); the mutation boundary is the target tenant only + - members who can view compare but cannot execute still see the execution affordance only where the page already stays decision-oriented, and that affordance must be disabled with explicit permission guidance while forced execution attempts return `403` + - no new promotion-specific capability family may be introduced in v1 + +For canonical-view specs, the spec MUST define: + +- **Default filter behavior when tenant-context is active**: registry and portfolio launches continue to prefill the launched tenant as the `target tenant`, preserve the return-state token, and keep the `source tenant` intentionally chosen by the operator unless the current exact-two launch path already supplies both tenants. +- **Explicit entitlement checks preventing cross-tenant leakage**: the page must re-resolve workspace membership, source tenant scope, target tenant scope, and execution capability before preflight or execution logic runs. Any inaccessible tenant input is treated as not found, and no `OperationRun` may be created from inaccessible or stale selection input. + +## Cross-Cutting / Shared Pattern Reuse *(mandatory when the feature touches notifications, status messaging, action links, header actions, dashboard signals/cards, navigation entry points, evidence/report viewers, or any other existing shared operator interaction family; otherwise write `N/A - no shared interaction family touched`)* + +- **Cross-cutting feature?**: yes +- **Interaction class(es)**: compare-page header actions, confirmation modal copy, queued start notifications, Monitoring links, run status messaging, audit metadata, and return-state continuity +- **Systems touched**: `CrossTenantComparePage`, tenant-registry launch context, `CrossTenantCompareSelection`, `CrossTenantComparePreviewBuilder`, `CrossTenantPromotionPreflight`, `OperationRunService`, `OperationCatalog`, `OperationRunType`, `OperationRunLinks`, `OperationUxPresenter`, `ProviderOperationStartResultPresenter`, `OpsUxBrowserEvents`, `WorkspaceAuditLogger`, `AuditActionId`, `OperationalControlCatalog`, and the current policy-version or restore write seam +- **Existing pattern(s) to extend**: canonical compare page, current portfolio launch and return-state contract, shared `OperationRun` start UX, and existing Monitoring deep-link semantics +- **Shared contract / presenter / builder / renderer to reuse**: `CanonicalNavigationContext`, `WorkspaceUiEnforcement`, `OperationRunService`, `OperationRunLinks`, `OperationUxPresenter`, `ProviderOperationStartResultPresenter`, `OpsUxBrowserEvents`, and the existing compare preview and preflight builders +- **Operation-registry distinction**: any `ProviderOperationRegistry` update in v1 is app-level operation-vocabulary wiring only, and only if the chosen shared start-result seam requires it. It is not Laravel or Filament service-provider registration. +- **Why the existing shared path is sufficient or insufficient**: compare and preflight already solve the selection and blocked-reason truth. They are insufficient for v1 execution because they do not produce a queued run, start/result UX, or target mutation plan. The current restore and policy-version seams solve provider write behavior and run lifecycle, but they are tenant-owned and cannot be pointed at a foreign tenant without a bounded promotion bridge. +- **Allowed deviation and why**: none. The feature must extend the current compare page and shared run UX instead of creating a second promotion console or a promotion-specific dashboard. +- **Consistency impact**: the terms `source tenant`, `target tenant`, `promotion preflight`, `Execute promotion`, `ready`, `blocked`, `manual mapping`, and `Open operation` must stay consistent across compare copy, confirmation copy, audit summaries, notifications, and Monitoring labels. +- **Review focus**: reviewers must block any persisted draft entity, direct Graph write from the page action, or local notification/run-link flow that bypasses the shared `OperationRun` UX contract. + +## OperationRun UX Impact *(mandatory when the feature creates, queues, deduplicates, resumes, blocks, completes, or deep-links to an `OperationRun`; otherwise write `N/A - no OperationRun start or link semantics touched`)* + +- **Touches OperationRun start/completion/link UX?**: yes +- **Shared OperationRun UX contract/layer reused**: `OperationRunService`, `OperationRunLinks`, `OperationUxPresenter`, `ProviderOperationStartResultPresenter`, and `OpsUxBrowserEvents` +- **Delegated start/completion UX behaviors**: queued toast, deduped-or-already-running messaging, blocked or scope-busy result messaging, canonical Monitoring link generation, run-enqueued browser event dispatch, and normal terminal notification handling stay delegated to the shared run UX layer +- **Local surface-owned behavior that remains**: compare selection state, preflight summary, excluded-subject explanation, and confirmation-modal wording remain owned by `CrossTenantComparePage` +- **Queued DB-notification policy**: no new queued-only database-notification policy is introduced; v1 relies on the compare-page start result plus the existing terminal notification path +- **Terminal notification path**: keep the existing initiator-aware `OperationRun` completion path +- **Exception required?**: none + +## Provider Boundary / Platform Core Check *(mandatory when the feature changes shared provider/platform seams, identity scope, governed-subject taxonomy, compare strategy selection, provider connection descriptors, or operator vocabulary that may leak provider-specific semantics into platform-core truth; otherwise write `N/A - no shared provider/platform boundary touched`)* + +- **Shared provider/platform boundary touched?**: yes +- **Boundary classification**: mixed +- **Seams affected**: compare selection to execution planning, source content resolution, target mutation bridge, operation vocabulary, and provider-backed write delegation +- **Neutral platform terms preserved or introduced**: `source tenant`, `target tenant`, `governed subject`, `promotion execution`, `ready subject`, `blocked reason`, and `manual mapping required` +- **Provider-specific semantics retained and why**: Microsoft-first policy types, assignment semantics, scope tags, and provider payload translation remain inside the current policy-version, restore, and provider write seams because the repo currently has one real provider domain +- **Why this does not deepen provider coupling accidentally**: the page contract stays anchored on compare and preflight truth, and the queued mutation delegates into existing provider-write paths instead of exposing Graph-specific inputs or raw payload editing in the page surface +- **Follow-up path**: multi-provider promotion remains a separate follow-up if it ever becomes current-release truth + +## UI / Surface Guardrail Impact *(mandatory when operator-facing surfaces are changed; otherwise write `N/A`)* + +| Surface / Change | Operator-facing surface change? | Native vs Custom | Shared-Family Relevance | State Layers Touched | Exception Needed? | Low-Impact / `N/A` Note | +|---|---|---|---|---|---|---| +| Canonical cross-tenant compare page | yes | Native Filament page plus shared compare and ops-ux primitives | compare summary, confirmation modal, run-start UX, Monitoring link continuity | page, query state, preflight state, action state, confirmation modal state | no | Reuses the current compare page; no second promotion surface is allowed | +| Tenant registry / portfolio launch action | no | N/A | current launch context only | none beyond existing deep-link state | no | Launch behavior remains inherited from Spec 043 | +| Monitoring operation-run detail | no | N/A | existing shared operation-run viewer | none beyond normal new-type rendering | no | v1 reuses the existing Monitoring viewer rather than introducing a promotion-specific detail screen | + +## Decision-First Surface Role *(mandatory when operator-facing surfaces are changed)* + +| Surface | Decision Role | Human-in-the-loop Moment | Immediately Visible for First Decision | On-Demand Detail / Evidence | Why This Is Primary or Why Not | Workflow Alignment | Attention-load Reduction | +|---|---|---|---|---|---|---|---| +| Canonical cross-tenant compare page | Primary Decision Surface | Operator decides whether the ready portion of the current source selection should mutate the target tenant now | source and target summary, ready or blocked counts, exclusion counts, mutation scope, and the single next action | subject-level execution plan, mapping gaps, target exclusions, source and target drill-down links, and current run link after queueing | Primary because the compare page already owns the trusted preflight truth and can keep the execution decision tied to that truth | Moves directly from compare to confirmation to queued Monitoring handoff without a second workspace | Replaces off-platform handoff and manual note-taking with one bounded action path | + +## Audience-Aware Disclosure *(mandatory when operator-facing surfaces are changed)* + +| Surface | Audience Modes In Scope | Decision-First Default-Visible Content | Operator Diagnostics | Support / Raw Evidence | One Dominant Next Action | Hidden / Gated By Default | Duplicate-Truth Prevention | +|---|---|---|---|---|---|---|---| +| Canonical cross-tenant compare page | operator-MSP | source and target summary, preflight counts, excluded-subject totals, mutation scope, and current run link if a run exists | subject-level ready plan, mapping gaps, evidence freshness, and target-safe exclusions | raw provider payloads and deep provider diagnostics stay behind existing tenant, inventory, or Monitoring detail surfaces | `Generate promotion preflight` before eligibility, then `Execute promotion` after a current executable preflight exists | raw JSON, provider IDs, worker internals, and low-level payload diffs | the compare page owns readiness truth once; the confirmation modal restates only the mutation scope, and Monitoring owns run progress after queueing | + +## UI/UX Surface Classification *(mandatory when operator-facing surfaces are changed)* + +| Surface | Action Surface Class | Surface Type | Likely Next Operator Action | Primary Inspect/Open Model | Row Click | Secondary Actions Placement | Destructive Actions Placement | Canonical Collection Route | Canonical Detail Route | Scope Signals | Canonical Noun | Critical Truth Visible by Default | Exception Type / Justification | +|---|---|---|---|---|---|---|---|---|---|---|---|---|---| +| Canonical cross-tenant compare page | Utility / Workspace Decision | Queued execution decision | Execute promotion | explicit selectors plus focused compare or preflight panels and confirmation modal | forbidden | open-source, open-target, return, and open-operation links stay secondary | none; `Execute promotion` is mutating but not destructive and must still use confirmation | `/admin/cross-tenant-compare` | same page with shareable query state and current run handoff | workspace context plus source and target tenant chips | Cross-tenant compare | whether the ready part of the current source selection can be promoted now and what will be excluded | none | + +## Operator Surface Contract *(mandatory when operator-facing surfaces are changed)* + +| Surface | Primary Persona | Decision / Operator Action Supported | Surface Type | Primary Operator Question | Default-visible Information | Diagnostics-only Information | Status Dimensions Used | Mutation Scope | Primary Actions | Dangerous Actions | +|---|---|---|---|---|---|---|---|---|---|---| +| Canonical cross-tenant compare page | Workspace operator / MSP operator | Decide whether to queue a bounded promotion run against the target tenant | Canonical decision page | Can I safely apply the ready part of this source selection to the target tenant now, and what will be left out? | source and target summary, ready or blocked counts, manual-mapping and blocked totals, mutation scope, confirmation summary, and latest run handoff | subject-level execution plan, mapping gaps, source and target drill-downs, and follow-up hints | compare readiness, execution availability, and run state | Microsoft tenant target only | Generate promotion preflight, Execute promotion, Open operation, Open source tenant, Open target tenant | Execute promotion (requires confirmation) | + +## Proportionality Review *(mandatory when structural complexity is introduced)* + +- **New source of truth?**: no +- **New persisted entity/table/artifact?**: no +- **New abstraction?**: yes - one bounded promotion execution planner or bridge plus one queued promotion job +- **New enum/state/reason family?**: yes - one new canonical operation type and one operational control key, but no new persisted lifecycle family or draft state family +- **New cross-domain UI framework/taxonomy?**: no +- **Current operator problem**: compare and preflight prove readiness, but operators still cannot execute the bounded next step or observe the result from the same truth. +- **Existing structure is insufficient because**: the current compare slice stops before any `OperationRun`, and the current `RestoreService::executeFromPolicyVersion()` explicitly rejects foreign-tenant versions, so the execution path cannot be implemented as a naive direct call. +- **Narrowest correct implementation**: derive a ready-only execution plan from the current compare and preflight truth, queue one target-tenant-scoped `promotion.execute` run, and translate source content into target-safe write inputs through an existing provider-write seam without introducing draft persistence. +- **Ownership cost**: maintain one new operation vocabulary entry, one bounded execution bridge, one queued worker, small audit metadata expansion, and focused tests. +- **Alternative intentionally rejected**: persisted promotion drafts, scheduled promotions, approval chains, and cross-tenant batch execution are intentionally rejected because they import new truth and operator workflow before the bounded execution seam is proven. +- **Release truth**: current-release workflow gap, not a future-state platform ambition + +### Compatibility posture + +This feature assumes a pre-production environment. + +Backward compatibility, migration shims, and legacy workflow preservation remain out of scope unless the implementation discovers a concrete repo-owned compatibility contract that the current spec missed. + +Canonical replacement is preferred over parallel promotion workflows. + +## Testing / Lane / Runtime Impact *(mandatory for runtime behavior changes)* + +- **Test purpose / classification**: Unit, Feature, Browser +- **Validation lane(s)**: fast-feedback, confidence, browser +- **Why this classification and these lanes are sufficient**: unit coverage proves ready-only execution planning and no-draft behavior, focused feature coverage proves page action, authorization, audit, and Monitoring handoff, and one bounded browser smoke proves the confirmation modal and compare-to-operation handoff on the real Filament surface. +- **New or expanded test families**: `tests/Unit/Support/PortfolioCompare/`, `tests/Feature/PortfolioCompare/`, and one new bounded `tests/Browser/PortfolioCompare/` smoke file +- **Fixture / helper cost impact**: moderate; reuse the existing portfolio-compare fixtures, current workspace and tenant setup, and current `OperationRun` assertions rather than introducing a new provider or browser fixture domain +- **Heavy-family visibility / justification**: one new browser smoke file is justified because the feature adds a confirmation modal and Monitoring handoff on a live Filament page +- **Special surface test profile**: standard-native-filament +- **Standard-native relief or required special coverage**: ordinary feature coverage is sufficient for the compare and run-start contract, but the final compare-page handoff to Monitoring must be proven once in the browser +- **Reviewer handoff**: reviewers must confirm the slice stays on one compare page, one run type, zero new draft tables, and shared run UX only +- **Budget / baseline / trend impact**: low to moderate; one new browser smoke file and one new operation type only +- **Escalation needed**: none +- **Active feature PR close-out entry**: Smoke Coverage +- **Planned validation commands**: + - `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Unit/Support/PortfolioCompare/CrossTenantPromotionExecutionPlannerTest.php` + - `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/PortfolioCompare/CrossTenantPromotionExecutionActionTest.php tests/Feature/PortfolioCompare/CrossTenantPromotionExecutionAuthorizationTest.php tests/Feature/PortfolioCompare/CrossTenantPromotionExecutionAuditTest.php tests/Feature/PortfolioCompare/CrossTenantPromotionExecutionRunUxTest.php tests/Feature/PortfolioCompare/CrossTenantComparePageTest.php tests/Feature/PortfolioCompare/CrossTenantCompareLaunchContextTest.php` + - `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Browser/PortfolioCompare/CrossTenantPromotionExecutionSmokeTest.php` + - `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail bin pint --dirty --format agent` + +## Scope Boundaries + +### In Scope + +- one `Execute promotion` action on the canonical compare page after a current preflight exists +- one explicit confirmation modal that names the source tenant, target tenant, ready counts, excluded counts, and mutation scope +- one queued `promotion.execute` `OperationRun` +- one bounded execution planner or bridge that translates ready subjects into target-safe write inputs +- one truthful Monitoring handoff and run-link continuity +- start and terminal audit metadata for the promotion execution path +- compare-page continuity after queueing, including preserved source, target, and return-state context + +### Non-Goals + +- persisted compare snapshots or promotion-draft tables +- scheduled or recurring promotion execution +- approval workflows or multi-actor sign-off +- rollback or undo workflows +- multi-target or batch promotion +- mapping automation for manual-mapping-required subjects +- customer-facing promotion surfaces +- cross-workspace execution +- multi-provider execution frameworks + +## Assumptions + +- the current compare and preflight contracts expose enough stable subject identity to resolve a bounded ready-only execution plan +- the current provider-write seams can accept a target-safe execution payload once the source content is normalized through a bounded bridge +- the existing Monitoring viewer can represent the new run type without a dedicated promotion detail resource +- current queue workers already provide the runtime model needed for one more `OperationRun`-backed promotion job + +## Risks + +- some subjects marked `ready` by preflight may still fail at execution time because the current target write seam discovers provider constraints later than the read-only preflight can +- the promotion bridge may be tempted to persist a new draft or snapshot entity; that must be rejected unless a separate follow-up spec is created +- run-summary truth can drift if the implementation invents non-canonical summary keys instead of staying on the current `OperationSummaryKeys` set +- a future implementation could try to broaden the slice into approval, rollback, or batch execution; that is out of scope for this spec + +## Follow-up Candidates + +- manual mapping workflow for `manual_mapping_required` subjects +- portfolio-level batch promotion across many targets +- approval or sign-off gating before execution +- rollback or replay workflow after a promotion run completes + +## User Scenarios & Testing *(mandatory)* + +### User Story 1 - Queue one bounded promotion from the current preflight (Priority: P1) + +As a workspace operator, I want to confirm and queue one bounded promotion from the current compare and preflight context so I can apply only the ready subjects to the target tenant without manual off-platform execution. + +**Why this priority**: This is the core value gap left by Spec 043. Without queueable execution, compare remains advisory only. + +**Independent Test**: Open the compare page with an executable preflight, confirm the action, and verify that one `promotion.execute` run is queued with ready subjects only. + +**Acceptance Scenarios**: + +1. **Given** the current preflight contains a mix of `ready`, `blocked`, and `manual_mapping_required` subjects, **When** the operator confirms `Execute promotion`, **Then** only the `ready` subjects enter the queued run context and the excluded subjects remain visible on the compare page. +2. **Given** the current selection and preflight produce no `ready` subjects, **When** the operator tries to execute promotion, **Then** the action is blocked and no `OperationRun` is created. +3. **Given** an active run already exists for the same source, target, subject scope, and executable plan, **When** the operator tries to execute again, **Then** the shared start UX dedupes or reuses the current run instead of creating a second active run. + +--- + +### User Story 2 - Follow the queued promotion through Monitoring with truthful result summary (Priority: P1) + +As a workspace operator, I want a canonical run link and truthful result summary after I queue a promotion so I can monitor completion and know what happened without leaving the existing Monitoring path. + +**Why this priority**: Execution is unsafe if the operator cannot observe start, progress, and terminal outcome on the existing shared Monitoring path. + +**Independent Test**: Queue a promotion run from the compare page and verify that the start result, Monitoring link, and run-summary truth all stay on the shared `OperationRun` contract. + +**Acceptance Scenarios**: + +1. **Given** a promotion run is successfully queued, **When** the compare page receives the start result, **Then** the operator sees the shared queued feedback and one canonical `Open operation` link. +2. **Given** the promotion run reaches a terminal state, **When** the operator opens the Monitoring detail, **Then** the run summary clearly distinguishes processed, succeeded, failed, and skipped work using the shared summary-key vocabulary. +3. **Given** the run start is blocked by operational control, legitimacy, or target-scoped gating, **When** the operator attempts execution, **Then** they receive the shared blocked feedback and no target mutation begins. + +--- + +### User Story 3 - Preserve portfolio context and execution safety boundaries (Priority: P2) + +As a workspace operator, I want the execution path to preserve my compare and return-state context while still enforcing target-safe capability and control boundaries so the workflow stays usable and safe inside the portfolio review loop. + +**Why this priority**: The workflow loses value if the operator must rebuild compare context after queueing or if the page hides the reasons why execution is unavailable. + +**Independent Test**: Launch compare from the tenant registry, queue or attempt to queue promotion, and verify that source, target, return-state, and safety boundaries all remain intact. + +**Acceptance Scenarios**: + +1. **Given** the operator launched compare from a registry or portfolio context, **When** they queue a promotion, **Then** the compare page preserves the source tenant, target tenant, governed-subject filters, and return-state continuity. +2. **Given** the operator can view compare but lacks target-tenant manage or workspace baseline-manage access, **When** they reach an otherwise executable compare state, **Then** execution stays visible only as a disabled safety-gated affordance and any forced execution attempt returns `403`. +3. **Given** the source or target tenant becomes inaccessible or stale between preflight and confirmation, **When** the operator attempts execution, **Then** the request is rejected and no `OperationRun` is created. + +### Edge Cases + +- source and target tenant are the same tenant: reject as invalid input and do not queue a run +- preflight is stale because the compare selection changed after it was generated: require regeneration and do not queue a run +- no ready subjects remain after current truth is re-evaluated at execution time: fail safe and do not queue or start target mutation +- the existing provider-write seam cannot resolve a target-safe payload from the source subject: mark the item failed or skipped inside the run; do not invent a draft state family +- operational control or legitimacy gate blocks `promotion.execute`: surface the shared blocked message and keep the compare state intact + +## Requirements *(mandatory)* + +### Functional Requirements + +- **FR-001**: The canonical `/admin/cross-tenant-compare` page must remain the only operator entrypoint for v1 promotion execution. +- **FR-002**: Promotion execution must require a current compare preview and a current promotion preflight before queueing any target mutation. +- **FR-003**: The page must expose exactly one dominant next action at a time: `Generate promotion preflight` before readiness exists, then `Execute promotion` once the current preflight is executable. +- **FR-004**: `Execute promotion` must require explicit confirmation and must name the source tenant, target tenant, count of ready subjects, count of excluded subjects, and the target-only mutation scope. +- **FR-005**: Only `ready` subjects from the current preflight may enter the queued execution plan; `blocked` and `manual_mapping_required` subjects must stay excluded from the run and visible to the operator. +- **FR-006**: The execution path must create or reuse exactly one canonical `OperationRun` for the current executable plan, using one new canonical operation type for promotion execution. +- **FR-007**: The feature must keep execution truth on existing `OperationRun` state, summary counts, and audit metadata only. No persisted promotion-draft, compare-snapshot, or approval entity may be added. +- **FR-008**: The run-start path must reuse the shared `OperationRun` start UX contract for queued, deduped, blocked, and Monitoring-link behaviors. +- **FR-009**: The implementation must not pretend that a source-tenant `PolicyVersion` belongs to the target tenant. Any bridge from source content to target mutation must preserve tenant-owned truth while still reusing existing provider-write semantics. +- **FR-010**: Run summary counts must stay on canonical summary keys only. Promotion-specific meaning must be expressed through page or Monitoring copy, not a new summary-key family. +- **FR-011**: The feature must record start and terminal audit events for promotion execution with source-tenant, target-tenant, selection, and outcome context. +- **FR-012**: Compare and launch context must remain preserved after queueing, including source tenant, target tenant, governed-subject filters, and return-state payload. +- **FR-013**: Execution authorization must require workspace baseline-manage and target-tenant manage in addition to the existing compare-view permissions. +- **FR-014**: Filament must remain v5 on Livewire v4, and Laravel or Filament service-provider registration must remain unchanged in `apps/platform/bootstrap/providers.php`. +- **FR-015**: No new panel, no new asset registration, no new Laravel or Filament service-provider registration work, and no new globally searchable resource may be introduced for this slice. + +### Key Entities *(include if feature involves data)* + +- **Cross-tenant compare selection**: existing derived scope of source tenant, target tenant, and governed-subject filters; remains read-only input truth. +- **Promotion preflight**: existing derived readiness artifact grouping subjects into `ready`, `blocked`, and `manual_mapping_required`; becomes the only allowed execution source. +- **Promotion execution plan**: new bounded in-memory or `OperationRun.context` representation of the ready-only mutation plan; not a persisted standalone entity. +- **Promotion execution run**: one canonical `OperationRun` using the new operation type and existing Monitoring viewer. + +## Success Criteria *(mandatory)* + +### Measurable Outcomes + +- **SC-001**: An authorized operator can move from current compare preflight to a queued `promotion.execute` run without leaving the canonical compare page. +- **SC-002**: The compare page never queues blocked or manual-mapping-required subjects for target mutation. +- **SC-003**: The queued run can be opened through the shared Monitoring path and exposes truthful processed, succeeded, failed, and skipped counts. +- **SC-004**: The implementation ships without a persisted promotion-draft table, second promotion surface, or second queue family. \ No newline at end of file diff --git a/specs/264-cross-tenant-promotion-execution/tasks.md b/specs/264-cross-tenant-promotion-execution/tasks.md new file mode 100644 index 00000000..24b45d59 --- /dev/null +++ b/specs/264-cross-tenant-promotion-execution/tasks.md @@ -0,0 +1,184 @@ +--- +description: "Task list for Cross-Tenant Promotion Execution v1" +--- + +# Tasks: Cross-Tenant Promotion Execution v1 + +**Input**: Design documents from `specs/264-cross-tenant-promotion-execution/` +**Prerequisites**: `specs/264-cross-tenant-promotion-execution/spec.md`, `specs/264-cross-tenant-promotion-execution/plan.md`, `specs/264-cross-tenant-promotion-execution/checklists/requirements.md` + +**Tests**: REQUIRED (Pest plus one bounded Browser smoke). Keep proof bounded to `PortfolioCompare` unit and feature families plus one new `Browser/PortfolioCompare` smoke file only. +**Operations**: Introduce one canonical `promotion.execute` `OperationRun` type and reuse the shared `OperationRun` start UX, Monitoring links, and current provider-write seams. No promotion-draft table, no compare-snapshot table, no second queue family, and no second promotion dashboard are allowed. +**RBAC**: Non-members and out-of-scope tenants remain `404`. Compare-view permissions stay inherited from Spec 043. Execution adds target-tenant manage plus workspace baseline-manage enforcement, with disabled affordance guidance and server-side `403` on forced execution. No new capability family may be introduced. +**Shared Pattern Reuse**: Reuse `CrossTenantComparePage`, current launch and return context, `CrossTenantComparePreviewBuilder`, `CrossTenantPromotionPreflight`, `OperationRunService`, `OperationUxPresenter`, `ProviderOperationStartResultPresenter`, `OperationRunLinks`, `OpsUxBrowserEvents`, `WorkspaceAuditLogger`, `AuditActionId`, and current provider-write seams. Do not create persisted promotion drafts or a local run-start UX. +**Filament / Panel Guardrails**: Filament remains v5 on Livewire v4. Laravel or Filament service-provider registration remains unchanged in `apps/platform/bootstrap/providers.php`. No new panel, no new globally searchable resource, and no new asset strategy are allowed. +**Organization**: Tasks are grouped by user story so the execution contract, Monitoring continuity, and safety boundaries remain independently testable and implementable. This package is an explicit delta follow-up over Spec 043 and current code. + +## Test Governance Checklist + +- [x] Lane assignment stays `fast-feedback`, `confidence`, plus one bounded `browser` smoke and remains the narrowest sufficient proof. +- [x] New or changed tests stay in `apps/platform/tests/Unit/Support/PortfolioCompare/`, `apps/platform/tests/Feature/PortfolioCompare/`, and one new `apps/platform/tests/Browser/PortfolioCompare/` smoke file only. +- [x] Existing portfolio-compare fixtures and current `OperationRun` assertions are reused; no new heavy provider or browser fixture domain is introduced. +- [x] Planned validation commands stay consistent across spec, plan, and tasks. +- [x] The declared surface test profile remains `standard-native-filament` with explicit real-browser confirmation coverage. +- [x] Any drift toward persisted drafts, approvals, rollback, or batch promotion is handled as `reject-or-split` rather than hidden inside this feature. + +## Phase 1: Setup (Shared Context) + +**Purpose**: Confirm the current compare, preflight, run UX, and provider-write seams before any implementation change. + +- [x] T001 Review `specs/264-cross-tenant-promotion-execution/spec.md`, `specs/264-cross-tenant-promotion-execution/plan.md`, `specs/264-cross-tenant-promotion-execution/checklists/requirements.md`, `specs/043-cross-tenant-compare-and-promotion/spec.md`, `docs/product/spec-candidates.md`, `docs/product/roadmap.md`, and `docs/product/implementation-ledger.md` together so the slice stays on the current execution gap only. +- [x] T002 [P] Confirm the current compare-page and launch-context seams in `apps/platform/app/Filament/Pages/CrossTenantComparePage.php`, `apps/platform/tests/Feature/PortfolioCompare/CrossTenantComparePageTest.php`, and `apps/platform/tests/Feature/PortfolioCompare/CrossTenantCompareLaunchContextTest.php`. +- [x] T003 [P] Confirm the current compare-preview and preflight seams in `apps/platform/app/Support/PortfolioCompare/CrossTenantComparePreviewBuilder.php`, `apps/platform/app/Support/PortfolioCompare/CrossTenantPromotionPreflight.php`, and the current PortfolioCompare test coverage. +- [x] T004 [P] Confirm the current shared run-start, Monitoring-link, and operation-vocabulary seams in `apps/platform/app/Services/OperationRunService.php`, `apps/platform/app/Support/OperationCatalog.php`, `apps/platform/app/Support/OperationRunType.php`, `apps/platform/app/Support/OperationRunLinks.php`, `apps/platform/app/Support/Operations/OperationRunCapabilityResolver.php`, `apps/platform/app/Support/OperationalControls/OperationalControlCatalog.php`, and `apps/platform/app/Services/Providers/ProviderOperationRegistry.php` only to determine whether app-level operation-registry wiring is required by the chosen shared start-result seam. +- [x] T005 [P] Confirm the current target-write seam and its constraints in `apps/platform/app/Services/Intune/RestoreService.php`, `apps/platform/app/Jobs/BulkPolicyVersionRestoreJob.php`, and `apps/platform/app/Jobs/Operations/PolicyVersionRestoreWorkerJob.php`, especially the current rule that foreign-tenant policy versions cannot be executed directly. + +--- + +## Phase 2: Foundational (Blocking Prerequisites) + +**Purpose**: Lock the bounded execution contract before surface-level implementation begins. + +**Critical**: No user-story work should begin until this phase is complete. + +- [x] T006 [P] Add `apps/platform/tests/Unit/Support/PortfolioCompare/CrossTenantPromotionExecutionPlannerTest.php` to require ready-only execution planning, blocked and manual exclusion, stable run-identity inputs, and no-ready rejection. +- [x] T007 [P] Add `apps/platform/tests/Feature/PortfolioCompare/CrossTenantPromotionExecutionAuthorizationTest.php` to require `404` for out-of-scope tenants, disabled execution affordance for compare-only actors, and `403` for forced execution without target-manage or workspace-manage access. +- [x] T008 [P] Add `apps/platform/tests/Feature/PortfolioCompare/CrossTenantPromotionExecutionRunUxTest.php` to require one canonical `promotion.execute` run type, shared start-result statuses, and active-run dedupe for the same executable plan. +- [x] T009 Implement the foundational execution contract in `apps/platform/app/Support/OperationCatalog.php`, `apps/platform/app/Support/OperationRunType.php`, `apps/platform/app/Support/Operations/OperationRunCapabilityResolver.php`, `apps/platform/app/Support/OperationalControls/OperationalControlCatalog.php`, `apps/platform/app/Services/Providers/ProviderOperationRegistry.php` only if the shared start-result seam requires app-level operation-registry wiring, `apps/platform/app/Support/Audit/AuditActionId.php`, and the bounded `PortfolioCompare` execution planner or bridge chosen for v1 so the feature has one canonical operation vocabulary and no draft persistence. No `ProviderOperationRegistry` change was required because the shared start-result seam worked through the existing presenter/link contract. + +**Checkpoint**: The canonical run vocabulary, control wiring, audit ids, and ready-only execution plan are locked before page and job work begins. + +--- + +## Phase 3: User Story 1 - Queue one bounded promotion from the current preflight (Priority: P1) + +**Goal**: An authorized operator can confirm and queue a target mutation for ready subjects only from the current compare and preflight context. + +**Independent Test**: Open the compare page, generate a preflight with ready work, confirm `Execute promotion`, and verify that one `promotion.execute` run is queued with ready subjects only. + +### Tests for User Story 1 + +- [x] T010 [P] [US1] Add `apps/platform/tests/Feature/PortfolioCompare/CrossTenantPromotionExecutionActionTest.php` to assert that preflight is required, only ready subjects enter the queued run context, blocked and manual subjects stay excluded, and no-ready execution is blocked. +- [x] T011 [P] [US1] Extend `apps/platform/tests/Feature/PortfolioCompare/CrossTenantComparePageTest.php` only where needed to prove the compare page keeps one dominant next action at a time and does not regress the current same-tenant or stale-selection safeguards once execution exists. + +### Implementation for User Story 1 + +- [x] T012 [US1] Add one bounded `PortfolioCompare` execution planner or bridge plus one execution service in `apps/platform/app/Support/PortfolioCompare/` and or `apps/platform/app/Services/PortfolioCompare/` that translates the current ready subjects into target-safe mutation inputs without creating a persisted promotion draft. +- [x] T013 [US1] Update `apps/platform/app/Filament/Pages/CrossTenantComparePage.php` so the page exposes `Execute promotion` with explicit confirmation, preserves one-primary-action discipline, and keeps source, target, governed-subject filters, and return-state context intact after queueing. +- [x] T014 [US1] Add one queued promotion execution job under `apps/platform/app/Jobs/Operations/` that consumes `OperationRun.context`, performs target mutation for ready subjects only, and records canonical summary counts (`total`, `processed`, `succeeded`, `failed`, `skipped`, `created`, `updated`) instead of inventing a new summary-key family. + +**Checkpoint**: The canonical compare page can queue one bounded target mutation without persisting a draft or mutating blocked subjects. + +--- + +## Phase 4: User Story 2 - Follow the queued promotion through Monitoring with truthful run UX (Priority: P1) + +**Goal**: The operator receives shared queued feedback, one canonical Monitoring link, and truthful run summary after starting a promotion. + +**Independent Test**: Queue a promotion run from the compare page and verify that the start result, Monitoring link, and run-summary truth all stay on the shared `OperationRun` contract. + +### Tests for User Story 2 + +- [x] T015 [P] [US2] Extend `apps/platform/tests/Feature/PortfolioCompare/CrossTenantPromotionExecutionRunUxTest.php` to assert queued, deduped, blocked, and Monitoring-link outcomes plus canonical run-context fields for the new operation type. +- [x] T016 [P] [US2] Add `apps/platform/tests/Feature/PortfolioCompare/CrossTenantPromotionExecutionAuditTest.php` to assert queued and terminal audit metadata and the absence of any persisted promotion-draft or compare-snapshot writes. +- [x] T017 [P] [US2] Add `apps/platform/tests/Browser/PortfolioCompare/CrossTenantPromotionExecutionSmokeTest.php` to prove the live compare page can move from generated preflight to confirmation to queued `Open operation` handoff without breaking the existing action hierarchy. + +### Implementation for User Story 2 + +- [x] T018 [US2] Wire the shared start-result UX through `apps/platform/app/Services/OperationRunService.php`, `apps/platform/app/Support/OperationRunLinks.php`, `apps/platform/app/Support/OpsUx/OperationUxPresenter.php`, `apps/platform/app/Services/Providers/ProviderOperationStartResultPresenter.php`, and `apps/platform/app/Support/OpsUx/OpsUxBrowserEvents.php` so compare-page execution uses canonical queued, deduped, blocked, and run-link messaging. No additional `OperationRunService` or `OperationUxPresenter` changes were required beyond compare-page reuse of the shared presenter, link, and browser-event seams. +- [x] T019 [US2] Extend `apps/platform/app/Support/OperationRunLinks.php`, `apps/platform/app/Support/Navigation/RelatedNavigationResolver.php`, and any minimal Monitoring label surface only as needed so `promotion.execute` opens the current Monitoring viewer without a promotion-specific detail screen. Existing `OperationRunLinks::tenantlessView()` continuity already satisfied this path without further runtime changes. +- [x] T020 [US2] Extend `apps/platform/app/Services/Audit/WorkspaceAuditLogger.php` and `apps/platform/app/Support/Audit/AuditActionId.php` only as needed so promotion execution records start and terminal audit truth with source-tenant, target-tenant, selection, and outcome metadata. + +**Checkpoint**: The execution path starts, dedupes, blocks, links, and audits on the shared `OperationRun` seams only. + +--- + +## Phase 5: User Story 3 - Preserve portfolio context and safety boundaries (Priority: P2) + +**Goal**: Execution preserves compare and return-state context while enforcing target-safe control and capability boundaries. + +**Independent Test**: Launch compare from the tenant registry, queue or attempt to queue a promotion, and verify that source, target, return-state, and safety boundaries all remain intact. + +### Tests for User Story 3 + +- [x] T021 [P] [US3] Extend `apps/platform/tests/Feature/PortfolioCompare/CrossTenantCompareLaunchContextTest.php` to prove queued promotion keeps source-tenant, target-tenant, governed-subject, and return-state continuity. The stable proof now uses the mounted compare-page instance on the exact-two registry launch path, which avoids the flaky secondary Livewire snapshot hop while still exercising the same launched page state and queued promotion contract. +- [x] T022 [P] [US3] Extend `apps/platform/tests/Feature/PortfolioCompare/CrossTenantPromotionExecutionAuthorizationTest.php` only where needed to prove stale-preflight, operational-control, and inaccessible-tenant execution attempts never create a run. + +### Implementation for User Story 3 + +- [x] T023 [US3] Preserve compare-page launch and return context after queueing, and add any secondary `Open operation` continuity only where it does not compete with the primary action. No additional runtime change was required in the bounded slice beyond the existing compare-page state handling and shared operation link seams; T021 now provides the explicit queued launch-context continuity proof. +- [x] T024 [US3] Integrate operational-control and legitimacy blocking for `promotion.execute` so blocked, stale, or inaccessible execution attempts fail safely without widening into approvals, rollback, or batch controls. + +**Checkpoint**: The execution path stays usable inside the portfolio workflow and still fails closed at every safety boundary. + +--- + +## Phase 6: Polish & Cross-Cutting Validation + +**Purpose**: Validate the bounded slice and stop without widening scope. + +- [x] T025 [P] Run `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Unit/Support/PortfolioCompare/CrossTenantPromotionExecutionPlannerTest.php`. +- [x] T026 [P] Run `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/PortfolioCompare/CrossTenantPromotionExecutionActionTest.php tests/Feature/PortfolioCompare/CrossTenantPromotionExecutionAuthorizationTest.php tests/Feature/PortfolioCompare/CrossTenantPromotionExecutionAuditTest.php tests/Feature/PortfolioCompare/CrossTenantPromotionExecutionRunUxTest.php tests/Feature/PortfolioCompare/CrossTenantComparePageTest.php tests/Feature/PortfolioCompare/CrossTenantCompareLaunchContextTest.php`. +- [x] T027 [P] Run `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Browser/PortfolioCompare/CrossTenantPromotionExecutionSmokeTest.php`. +- [x] T028 [P] Run `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail bin pint --dirty --format agent`. +- [x] T029 [P] Review touched code to confirm Filament stays on Livewire v4, Laravel or Filament service-provider registration remains unchanged in `apps/platform/bootstrap/providers.php`, no globally searchable resource contract changes appear, and the mutating compare-page action uses explicit confirmation. +- [x] T030 [P] Review touched code to confirm the feature introduces no promotion-draft persistence, no compare-snapshot persistence, no second queue family, no second promotion surface, no approval workflow, and no rollback workflow. +- [x] T031 [P] Record the final guardrail, smoke, and scope-boundary outcomes in the active feature close-out without reopening batch promotion, approvals, rollback, or multi-provider follow-up work. + +--- + +## Dependencies & Execution Order + +### Phase Dependencies + +- **Phase 1 (Setup)**: no dependencies; start immediately. +- **Phase 2 (Foundational)**: depends on Phase 1 and blocks all user stories. +- **Phase 3 (US1)**: depends on Phase 2 and establishes the bounded execution path. +- **Phase 4 (US2)**: depends on Phase 2 and should land with US1 so the start-result UX and Monitoring truth do not drift from the queued path. +- **Phase 5 (US3)**: depends on Phase 2 and hardens context and safety behavior after the execution path exists. +- **Phase 6 (Polish)**: depends on all desired user stories being complete. + +### User Story Dependencies + +- **US1 (P1)**: independently testable after Phase 2 and delivers the core execution value. +- **US2 (P1)**: independently testable after Phase 2 and should ship with US1 so the operator gets truthful Monitoring handoff. +- **US3 (P2)**: independently testable after Phase 2 and hardens the bounded execution path. + +### Within Each User Story + +- Write the listed Pest coverage first and make it fail for the intended gap. +- Keep implementation inside the compare page, bounded `PortfolioCompare` execution seam, shared `OperationRun` UX, and current provider-write paths named above. +- Re-run the narrowest relevant validation command after each story checkpoint before moving on. + +--- + +## Implementation Strategy + +### Suggested MVP Scope + +- MVP = **US1 + US2 together**. The feature is only useful when the compare page can both queue promotion and hand the operator into truthful Monitoring continuity. + +### Incremental Delivery + +1. Complete Phase 1 and Phase 2. +2. Deliver US1 and US2 together on the current compare page and shared run UX. +3. Add US3 to keep return-state and safety boundaries intact. +4. Finish with the focused validation and guardrail review tasks in Phase 6. + +### Team Strategy + +1. Settle the bounded execution bridge first. +2. Parallelize failing tests within each story before runtime edits. +3. Serialize merges around `CrossTenantComparePage`, operation-catalog wiring, and shared Monitoring links so operator vocabulary stays coherent. + +--- + +## Deferred Follow-Ups / Non-Goals + +- persisted promotion drafts or compare snapshots +- approval workflow before execution +- rollback or replay workflow after execution +- batch or multi-target promotion +- automated manual-mapping resolution +- customer-facing promotion surfaces +- multi-provider execution framework \ No newline at end of file