Add cross-tenant promotion execution (spec 264) #320

Merged
ahmido merged 5 commits from 264-cross-tenant-promotion-execution into platform-dev 2026-05-02 14:38:25 +00:00
32 changed files with 3067 additions and 70 deletions

View File

@ -12,8 +12,13 @@
use App\Services\Audit\WorkspaceAuditLogger; use App\Services\Audit\WorkspaceAuditLogger;
use App\Services\Auth\CapabilityResolver; use App\Services\Auth\CapabilityResolver;
use App\Services\Auth\WorkspaceCapabilityResolver; use App\Services\Auth\WorkspaceCapabilityResolver;
use App\Services\PortfolioCompare\CrossTenantPromotionExecutionService;
use App\Support\Auth\Capabilities; use App\Support\Auth\Capabilities;
use App\Support\Navigation\CanonicalNavigationContext; 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\CrossTenantComparePreviewBuilder;
use App\Support\PortfolioCompare\CrossTenantCompareSelection; use App\Support\PortfolioCompare\CrossTenantCompareSelection;
use App\Support\PortfolioCompare\CrossTenantPromotionPreflight; use App\Support\PortfolioCompare\CrossTenantPromotionPreflight;
@ -23,13 +28,16 @@
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceSlot; use App\Support\Ui\ActionSurface\Enums\ActionSurfaceSlot;
use App\Support\Workspaces\WorkspaceContext; use App\Support\Workspaces\WorkspaceContext;
use BackedEnum; use BackedEnum;
use DomainException;
use Filament\Actions\Action; use Filament\Actions\Action;
use Filament\Notifications\Notification;
use Filament\Forms\Components\Select; use Filament\Forms\Components\Select;
use Filament\Forms\Concerns\InteractsWithForms; use Filament\Forms\Concerns\InteractsWithForms;
use Filament\Forms\Contracts\HasForms; use Filament\Forms\Contracts\HasForms;
use Filament\Pages\Page; use Filament\Pages\Page;
use Filament\Schemas\Components\Grid; use Filament\Schemas\Components\Grid;
use Filament\Schemas\Schema; use Filament\Schemas\Schema;
use InvalidArgumentException;
use Illuminate\Support\Collection; use Illuminate\Support\Collection;
use Illuminate\Support\Str; use Illuminate\Support\Str;
use UnitEnum; use UnitEnum;
@ -192,6 +200,7 @@ protected function getHeaderActions(): array
->label('Generate promotion preflight') ->label('Generate promotion preflight')
->icon('heroicon-o-sparkles') ->icon('heroicon-o-sparkles')
->color('primary') ->color('primary')
->visible(fn (): bool => ! is_array($this->preflight))
->disabled(fn (): bool => $this->preflightDisabledReason() !== null) ->disabled(fn (): bool => $this->preflightDisabledReason() !== null)
->tooltip(fn (): ?string => $this->preflightDisabledReason()) ->tooltip(fn (): ?string => $this->preflightDisabledReason())
->action(fn (): mixed => $this->generatePromotionPreflight()); ->action(fn (): mixed => $this->generatePromotionPreflight());
@ -201,6 +210,7 @@ protected function getHeaderActions(): array
fn (): ?Workspace => $this->workspace(), fn (): ?Workspace => $this->workspace(),
) )
->requireCapability(Capabilities::WORKSPACE_BASELINES_MANAGE) ->requireCapability(Capabilities::WORKSPACE_BASELINES_MANAGE)
->preserveVisibility()
->preserveDisabled() ->preserveDisabled()
->tooltip('You need workspace baseline manage access to generate a promotion preflight.') ->tooltip('You need workspace baseline manage access to generate a promotion preflight.')
->apply() ->apply()
@ -223,6 +233,19 @@ protected function getHeaderActions(): array
$actions[] = $preflightAction; $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; 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 public function clearSelectionUrl(): string
{ {
return static::getUrl($this->routeParameters([ 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 private function compareSelection(): ?CrossTenantCompareSelection
{ {
$sourceTenant = $this->selectedSourceTenant(); $sourceTenant = $this->selectedSourceTenant();
@ -593,6 +708,73 @@ private function preflightDisabledReason(): ?string
return null; 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 * @param mixed $value
*/ */

View File

@ -0,0 +1,393 @@
<?php
declare(strict_types=1);
namespace App\Jobs\Operations;
use App\Jobs\Middleware\EnsureQueuedExecutionLegitimate;
use App\Jobs\Middleware\TrackOperationRun;
use App\Models\BackupItem;
use App\Models\BackupSet;
use App\Models\OperationRun;
use App\Models\Policy;
use App\Models\PolicyVersion;
use App\Models\RestoreRun;
use App\Models\Tenant;
use App\Services\Audit\WorkspaceAuditLogger;
use App\Services\Intune\RestoreService;
use App\Services\OperationRunService;
use App\Services\Operations\TargetScopeConcurrencyLimiter;
use App\Support\OperationRunOutcome;
use App\Support\OperationRunStatus;
use Carbon\CarbonImmutable;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use RuntimeException;
use Throwable;
final class CrossTenantPromotionExecutionJob implements ShouldQueue
{
use Dispatchable;
use InteractsWithQueue;
use Queueable;
use SerializesModels;
public int $timeout = 420;
public bool $failOnTimeout = true;
public ?OperationRun $operationRun = null;
public function __construct(OperationRun $operationRun)
{
$this->operationRun = $operationRun;
}
/**
* @return array<int, object>
*/
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<array<string, mixed>> $items
* @return array{0: ?BackupSet, 1: list<int>, 2: array<string, int>, 3: list<array{code: string, message: string}>}
*/
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<array<string, mixed>> $items
* @return array{0: array<string, int>, 1: list<array{code: string, message: string}>}
*/
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<string, int> $left
* @param array<string, int> $right
* @return array<string, int>
*/
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<string, int> $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<string, mixed> $item
*/
private function itemLabel(array $item): string
{
$displayName = (string) ($item['display_name'] ?? '');
if ($displayName !== '') {
return $displayName;
}
return (string) ($item['subject_key'] ?? 'unknown subject');
}
}

View File

@ -6,6 +6,7 @@
use App\Models\OperationRun; use App\Models\OperationRun;
use App\Models\PlatformUser; use App\Models\PlatformUser;
use App\Models\RestoreRun;
use App\Models\SupportRequest; use App\Models\SupportRequest;
use App\Models\Tenant; use App\Models\Tenant;
use App\Models\User; use App\Models\User;
@ -176,6 +177,85 @@ public function logCrossTenantPromotionPreflightGenerated(
); );
} }
/**
* @param array<string, mixed> $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<string, int> $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( public function logSupportRequestCreated(
SupportRequest $supportRequest, SupportRequest $supportRequest,
User|PlatformUser|null $actor = null, User|PlatformUser|null $actor = null,

View File

@ -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 { } else {
if (! $this->isSystemAuthorityAllowed($context->operationType)) { if (! $this->isSystemAuthorityAllowed($context->operationType)) {
$checks['execution_prerequisites'] = 'failed'; $checks['execution_prerequisites'] = 'failed';
@ -151,6 +164,9 @@ public function buildContext(OperationRun $run): QueuedExecutionContext
requiredCapability: is_string($context['required_capability'] ?? null) requiredCapability: is_string($context['required_capability'] ?? null)
? $context['required_capability'] ? $context['required_capability']
: $this->operationRunCapabilityResolver->requiredExecutionCapabilityForType($operationType), : $this->operationRunCapabilityResolver->requiredExecutionCapabilityForType($operationType),
workspaceRequiredCapability: is_string($context['workspace_required_capability'] ?? null)
? $context['workspace_required_capability']
: null,
providerConnectionId: $providerConnectionId, providerConnectionId: $providerConnectionId,
targetScope: [ targetScope: [
'workspace_id' => $workspaceId, '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<string> * @return list<string>
*/ */
@ -270,7 +309,7 @@ private function prerequisiteClassesFor(string $operationType, ?int $providerCon
$prerequisites[] = 'provider_connection'; $prerequisites[] = 'provider_connection';
} }
if (str_starts_with($operationType, 'restore.')) { if (str_starts_with($operationType, 'restore.') || $operationType === 'promotion.execute') {
$prerequisites[] = 'write_gate'; $prerequisites[] = 'write_gate';
} }
@ -279,6 +318,10 @@ private function prerequisiteClassesFor(string $operationType, ?int $providerCon
private function questionForContext(QueuedExecutionContext $context): TenantOperabilityQuestion 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)) { if ($context->providerConnectionId !== null || in_array($context->operationType, ['provider.connection.check', 'compliance.snapshot', 'provider.compliance.snapshot'], true)) {
return TenantOperabilityQuestion::VerificationReadinessEligibility; return TenantOperabilityQuestion::VerificationReadinessEligibility;
} }

View File

@ -0,0 +1,161 @@
<?php
declare(strict_types=1);
namespace App\Services\PortfolioCompare;
use App\Jobs\Operations\CrossTenantPromotionExecutionJob;
use App\Models\OperationRun;
use App\Models\ProviderConnection;
use App\Models\User;
use App\Models\Workspace;
use App\Services\Audit\WorkspaceAuditLogger;
use App\Services\OperationRunService;
use App\Services\Providers\ProviderOperationStartResult;
use App\Support\Audit\AuditActionId;
use App\Support\Auth\Capabilities;
use App\Support\OperationalControls\OperationalControlBlockedException;
use App\Support\OperationalControls\OperationalControlEvaluator;
use App\Support\OperationRunStatus;
use App\Support\PortfolioCompare\CrossTenantCompareSelection;
use App\Support\PortfolioCompare\CrossTenantPromotionExecutionPlanner;
use Carbon\CarbonImmutable;
final class CrossTenantPromotionExecutionService
{
public function __construct(
private readonly CrossTenantPromotionExecutionPlanner $planner,
private readonly OperationRunService $operationRuns,
private readonly WorkspaceAuditLogger $auditLogger,
private readonly OperationalControlEvaluator $operationalControls,
) {}
/**
* @param array<string, mixed> $preview
* @param array<string, mixed> $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();
}
}

View File

@ -73,6 +73,8 @@ enum AuditActionId: string
case BaselineCompareCompleted = 'baseline_compare.completed'; case BaselineCompareCompleted = 'baseline_compare.completed';
case BaselineCompareFailed = 'baseline_compare.failed'; case BaselineCompareFailed = 'baseline_compare.failed';
case CrossTenantPromotionPreflightGenerated = 'cross_tenant_promotion_preflight.generated'; 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 BaselineAssignmentCreated = 'baseline_assignment.created';
case BaselineAssignmentUpdated = 'baseline_assignment.updated'; case BaselineAssignmentUpdated = 'baseline_assignment.updated';
case BaselineAssignmentDeleted = 'baseline_assignment.deleted'; case BaselineAssignmentDeleted = 'baseline_assignment.deleted';
@ -227,6 +229,8 @@ private static function labels(): array
self::BaselineCompareCompleted->value => 'Baseline compare completed', self::BaselineCompareCompleted->value => 'Baseline compare completed',
self::BaselineCompareFailed->value => 'Baseline compare failed', self::BaselineCompareFailed->value => 'Baseline compare failed',
self::CrossTenantPromotionPreflightGenerated->value => 'Cross-tenant promotion preflight generated', 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::BaselineAssignmentCreated->value => 'Baseline assignment created',
self::BaselineAssignmentUpdated->value => 'Baseline assignment updated', self::BaselineAssignmentUpdated->value => 'Baseline assignment updated',
self::BaselineAssignmentDeleted->value => 'Baseline assignment deleted', self::BaselineAssignmentDeleted->value => 'Baseline assignment deleted',
@ -326,6 +330,8 @@ private static function summaries(): array
self::BaselineProfileArchived->value => 'Baseline profile archived', self::BaselineProfileArchived->value => 'Baseline profile archived',
self::BaselineProfileScopeBackfilled->value => 'Baseline profile scope backfilled', self::BaselineProfileScopeBackfilled->value => 'Baseline profile scope backfilled',
self::CrossTenantPromotionPreflightGenerated->value => 'Cross-tenant promotion preflight generated', 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::AlertDestinationCreated->value => 'Alert destination created',
self::AlertDestinationUpdated->value => 'Alert destination updated', self::AlertDestinationUpdated->value => 'Alert destination updated',
self::AlertDestinationDeleted->value => 'Alert destination deleted', self::AlertDestinationDeleted->value => 'Alert destination deleted',

View File

@ -257,6 +257,7 @@ private static function canonicalDefinitions(): array
'backup.schedule.retention' => new CanonicalOperationType('backup.schedule.retention', 'intune', null, 'Backup schedule retention'), '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'), 'backup.schedule.purge' => new CanonicalOperationType('backup.schedule.purge', 'intune', null, 'Backup schedule purge'),
'restore.execute' => new CanonicalOperationType('restore.execute', 'intune', null, 'Restore execution'), '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.fetch' => new CanonicalOperationType('assignments.fetch', 'intune', null, 'Assignment fetch', false, 60),
'assignments.restore' => new CanonicalOperationType('assignments.restore', 'intune', null, 'Assignment restore', 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), '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', '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('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('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.fetch', 'assignments.fetch', 'canonical', true),
new OperationTypeAlias('assignments.restore', 'assignments.restore', 'canonical', true), new OperationTypeAlias('assignments.restore', 'assignments.restore', 'canonical', true),
new OperationTypeAlias('ops.reconcile_adapter_runs', 'ops.reconcile_adapter_runs', 'canonical', true), new OperationTypeAlias('ops.reconcile_adapter_runs', 'ops.reconcile_adapter_runs', 'canonical', true),

View File

@ -15,6 +15,7 @@ enum OperationRunType: string
case BackupSchedulePurge = 'backup.schedule.purge'; case BackupSchedulePurge = 'backup.schedule.purge';
case DirectoryRoleDefinitionsSync = 'directory.role_definitions.sync'; case DirectoryRoleDefinitionsSync = 'directory.role_definitions.sync';
case RestoreExecute = 'restore.execute'; case RestoreExecute = 'restore.execute';
case PromotionExecute = 'promotion.execute';
case EntraAdminRolesScan = 'entra.admin_roles.scan'; case EntraAdminRolesScan = 'entra.admin_roles.scan';
case ReviewPackGenerate = 'tenant.review_pack.generate'; case ReviewPackGenerate = 'tenant.review_pack.generate';
case TenantReviewCompose = 'tenant.review.compose'; case TenantReviewCompose = 'tenant.review.compose';

View File

@ -17,6 +17,13 @@ final class OperationalControlCatalog
'operation_types' => ['restore.execute'], 'operation_types' => ['restore.execute'],
'affected_surfaces' => ['tenant.restore_runs.create'], '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' => [ 'ai.execution' => [
'key' => 'ai.execution', 'key' => 'ai.execution',
'label' => 'AI execution', 'label' => 'AI execution',

View File

@ -25,7 +25,7 @@ public function requiredCapabilityForType(string $operationType): ?string
'inventory.sync' => Capabilities::TENANT_INVENTORY_SYNC_RUN, 'inventory.sync' => Capabilities::TENANT_INVENTORY_SYNC_RUN,
'directory.groups.sync' => Capabilities::TENANT_SYNC, 'directory.groups.sync' => Capabilities::TENANT_SYNC,
'backup.schedule.execute', 'backup.schedule.retention', 'backup.schedule.purge' => Capabilities::TENANT_BACKUP_SCHEDULES_RUN, '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, 'directory.role_definitions.sync' => Capabilities::TENANT_MANAGE,
'alerts.evaluate', 'alerts.deliver' => Capabilities::ALERTS_VIEW, 'alerts.evaluate', 'alerts.deliver' => Capabilities::ALERTS_VIEW,
'tenant.review.compose' => Capabilities::TENANT_REVIEW_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, 'provider.connection.check', 'inventory.sync', 'compliance.snapshot' => Capabilities::PROVIDER_RUN,
'policy.sync', 'tenant.sync' => Capabilities::TENANT_SYNC, 'policy.sync', 'tenant.sync' => Capabilities::TENANT_SYNC,
'policy.delete' => Capabilities::TENANT_MANAGE, '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, 'tenant.review.compose' => Capabilities::TENANT_REVIEW_MANAGE,
default => $this->requiredCapabilityForType($operationType), default => $this->requiredCapabilityForType($operationType),
}; };

View File

@ -23,6 +23,7 @@ public function __construct(
public ?User $initiator, public ?User $initiator,
public ExecutionAuthorityMode $authorityMode, public ExecutionAuthorityMode $authorityMode,
public ?string $requiredCapability, public ?string $requiredCapability,
public ?string $workspaceRequiredCapability,
public ?int $providerConnectionId, public ?int $providerConnectionId,
public array $targetScope, public array $targetScope,
public array $prerequisiteClasses = [], public array $prerequisiteClasses = [],

View File

@ -0,0 +1,271 @@
<?php
declare(strict_types=1);
namespace App\Support\PortfolioCompare;
use DomainException;
use InvalidArgumentException;
final class CrossTenantPromotionExecutionPlanner
{
/**
* @param array<string, mixed> $preview
* @param array<string, mixed> $preflight
* @return array{
* selection: array<string, mixed>,
* summary: array{total: int, ready: int, excluded: int, skipped: int, created: int, updated: int},
* items: list<array<string, mixed>>,
* excluded: list<array<string, mixed>>,
* identity: array<string, mixed>
* }
*/
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<string, mixed> $payload
* @return array{sourceTenantId: ?int, targetTenantId: ?int, policyTypes: list<string>}
*/
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<string, mixed> $preflight
* @return list<array<string, mixed>>
*/
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<string, mixed> $preflight
* @return list<array<string, mixed>>
*/
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<string, mixed> $subject
* @return array<string, mixed>|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<string, mixed> $subject
* @return array<string, mixed>
*/
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<array<string, mixed>> $items
* @return list<array<string, mixed>>
*/
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<string, mixed> $selection
* @param list<array<string, mixed>> $items
* @return array<string, mixed>
*/
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<string, mixed> $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<string>
*/
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 !== ''));
}
}

View File

@ -92,6 +92,14 @@
'direct_failed_bridge' => false, 'direct_failed_bridge' => false,
'scheduled_reconciliation' => true, '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' => [ 'tenant.review_pack.generate' => [
'job_class' => \App\Jobs\GenerateReviewPackJob::class, 'job_class' => \App\Jobs\GenerateReviewPackJob::class,
'queued_stale_after_seconds' => 300, 'queued_stale_after_seconds' => 300,

View File

@ -12,7 +12,7 @@
<x-filament::section heading="Cross-tenant compare"> <x-filament::section heading="Cross-tenant compare">
<x-slot name="description"> <x-slot name="description">
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.
</x-slot> </x-slot>
<div class="space-y-4"> <div class="space-y-4">
@ -147,7 +147,7 @@
@if ($preflight !== null) @if ($preflight !== null)
<x-filament::section heading="Promotion preflight"> <x-filament::section heading="Promotion preflight">
<x-slot name="description"> <x-slot name="description">
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.
</x-slot> </x-slot>
<div class="space-y-4" data-testid="cross-tenant-preflight"> <div class="space-y-4" data-testid="cross-tenant-preflight">

View File

@ -0,0 +1,56 @@
<?php
declare(strict_types=1);
use App\Filament\Pages\CrossTenantComparePage;
use App\Models\OperationRun;
use App\Support\OperationRunLinks;
use App\Support\Workspaces\WorkspaceContext;
use Tests\Feature\Concerns\BuildsPortfolioCompareFixtures;
uses(BuildsPortfolioCompareFixtures::class);
pest()->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()));
});

View File

@ -4,19 +4,24 @@
use App\Filament\Pages\CrossTenantComparePage; use App\Filament\Pages\CrossTenantComparePage;
use App\Filament\Resources\TenantResource; use App\Filament\Resources\TenantResource;
use App\Jobs\Operations\CrossTenantPromotionExecutionJob;
use App\Models\OperationRun;
use App\Models\Tenant; use App\Models\Tenant;
use App\Services\Auth\CapabilityResolver; use App\Services\Auth\CapabilityResolver;
use App\Services\Auth\WorkspaceCapabilityResolver; use App\Services\Auth\WorkspaceCapabilityResolver;
use App\Support\Auth\Capabilities; use App\Support\Auth\Capabilities;
use App\Support\BackupHealth\TenantBackupHealthAssessment; use App\Support\BackupHealth\TenantBackupHealthAssessment;
use App\Support\Navigation\CanonicalNavigationContext;
use App\Support\RestoreSafety\RestoreResultAttention; use App\Support\RestoreSafety\RestoreResultAttention;
use App\Support\Tenants\TenantRecoveryTriagePresentation; use App\Support\Tenants\TenantRecoveryTriagePresentation;
use Filament\Actions\Action; use Filament\Actions\Action;
use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Queue;
use Livewire\Livewire; use Livewire\Livewire;
use Tests\Feature\Concerns\BuildsPortfolioCompareFixtures;
use Tests\Feature\Concerns\BuildsPortfolioTriageFixtures; use Tests\Feature\Concerns\BuildsPortfolioTriageFixtures;
uses(RefreshDatabase::class, BuildsPortfolioTriageFixtures::class); uses(RefreshDatabase::class, BuildsPortfolioTriageFixtures::class, BuildsPortfolioCompareFixtures::class);
function crossTenantCompareLaunchQuery(string $url): array function crossTenantCompareLaunchQuery(string $url): array
{ {
@ -119,6 +124,80 @@ function crossTenantCompareLaunchQuery(string $url): array
->assertActionVisible('return_to_origin'); ->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 { it('rejects the bulk compare action until exactly two active tenants are selected', function (): void {
[$user, $anchorTenant] = $this->makePortfolioTriageActor('Anchor Tenant'); [$user, $anchorTenant] = $this->makePortfolioTriageActor('Anchor Tenant');
$targetTenant = $this->makePortfolioTriagePeer($user, $anchorTenant, 'Target Tenant'); $targetTenant = $this->makePortfolioTriagePeer($user, $anchorTenant, 'Target Tenant');

View File

@ -65,6 +65,32 @@
->assertSee('Windows Compliance'); ->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 { it('rejects the same tenant as source and target without rendering compare results', function (): void {
$fixture = $this->makeCrossTenantCompareFixture(); $fixture = $this->makeCrossTenantCompareFixture();

View File

@ -0,0 +1,119 @@
<?php
declare(strict_types=1);
use App\Filament\Pages\CrossTenantComparePage;
use App\Jobs\Operations\CrossTenantPromotionExecutionJob;
use App\Models\OperationRun;
use Filament\Actions\Action;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Queue;
use Livewire\Livewire;
use Tests\Feature\Concerns\BuildsPortfolioCompareFixtures;
uses(RefreshDatabase::class, BuildsPortfolioCompareFixtures::class);
it('requires a current preflight and queues only ready subjects into the promotion run', function (): void {
Queue::fake();
$fixture = $this->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();
});

View File

@ -0,0 +1,156 @@
<?php
declare(strict_types=1);
use App\Filament\Pages\CrossTenantComparePage;
use App\Jobs\Operations\CrossTenantPromotionExecutionJob;
use App\Models\AuditLog;
use App\Models\BackupSet;
use App\Models\OperationRun;
use App\Models\PolicyVersion;
use App\Models\RestoreRun;
use App\Services\Audit\WorkspaceAuditLogger;
use App\Services\Intune\RestoreService;
use App\Services\OperationRunService;
use App\Services\Operations\TargetScopeConcurrencyLimiter;
use App\Support\Audit\AuditActionId;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Queue;
use Livewire\Livewire;
use Tests\Feature\Concerns\BuildsPortfolioCompareFixtures;
uses(RefreshDatabase::class, BuildsPortfolioCompareFixtures::class);
it('audits queued promotion execution without creating restore-side writes before the worker starts', function (): void {
Queue::fake();
$fixture = $this->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);
});

View File

@ -0,0 +1,182 @@
<?php
declare(strict_types=1);
use App\Filament\Pages\CrossTenantComparePage;
use App\Models\OperationRun;
use App\Models\OperationalControlActivation;
use App\Models\Tenant;
use App\Services\Auth\CapabilityResolver;
use App\Support\PortfolioCompare\CrossTenantComparePreviewBuilder;
use App\Support\PortfolioCompare\CrossTenantCompareSelection;
use App\Support\PortfolioCompare\CrossTenantPromotionPreflight;
use Filament\Actions\Action;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Livewire\Livewire;
use Tests\Feature\Concerns\BuildsPortfolioCompareFixtures;
uses(RefreshDatabase::class, BuildsPortfolioCompareFixtures::class);
it('keeps execute promotion visible but disabled for compare-only actors without target manage access', function (): void {
$fixture = $this->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);
});

View File

@ -0,0 +1,69 @@
<?php
declare(strict_types=1);
use App\Filament\Pages\CrossTenantComparePage;
use App\Jobs\Operations\CrossTenantPromotionExecutionJob;
use App\Models\OperationRun;
use App\Support\OperationRunLinks;
use App\Support\Workspaces\WorkspaceContext;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Queue;
use Livewire\Livewire;
use Tests\Feature\Concerns\BuildsPortfolioCompareFixtures;
uses(RefreshDatabase::class, BuildsPortfolioCompareFixtures::class);
it('uses the shared queued and deduped start-result ux for promotion execution', function (): void {
Queue::fake();
$fixture = $this->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();
});

View File

@ -18,6 +18,7 @@
'backup_set.update', 'backup_set.update',
'backup.schedule.execute', 'backup.schedule.execute',
'restore.execute', 'restore.execute',
'promotion.execute',
'tenant.review_pack.generate', 'tenant.review_pack.generate',
'tenant.review.compose', 'tenant.review.compose',
'tenant.evidence.snapshot.generate', 'tenant.evidence.snapshot.generate',
@ -43,5 +44,6 @@
->and($validator->jobTimeoutSeconds('backup_set.update'))->toBe(240) ->and($validator->jobTimeoutSeconds('backup_set.update'))->toBe(240)
->and($validator->jobFailsOnTimeout('backup_set.update'))->toBeTrue() ->and($validator->jobFailsOnTimeout('backup_set.update'))->toBeTrue()
->and($validator->jobTimeoutSeconds('restore.execute'))->toBe(420) ->and($validator->jobTimeoutSeconds('restore.execute'))->toBe(420)
->and($validator->jobTimeoutSeconds('promotion.execute'))->toBe(420)
->and($validator->jobFailsOnTimeout('restore.execute'))->toBeTrue(); ->and($validator->jobFailsOnTimeout('restore.execute'))->toBeTrue();
}); });

View File

@ -251,6 +251,36 @@
->and($decision->checks['execution_prerequisites'])->toBe('failed'); ->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 { it('infers tenant sync capability for policy sync runs from the central resolver', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'owner'); [$user, $tenant] = createUserWithTenant(role: 'owner');
@ -294,3 +324,27 @@
'user_id' => null, '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');
});

View File

@ -7,12 +7,18 @@
it('exposes only active runtime controls in the bounded control catalog', function (): void { it('exposes only active runtime controls in the bounded control catalog', function (): void {
$catalog = app(OperationalControlCatalog::class); $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([ ->and($catalog->definition('restore.execute'))->toMatchArray([
'key' => 'restore.execute', 'key' => 'restore.execute',
'label' => 'Restore execution', 'label' => 'Restore execution',
'supported_scopes' => ['global', 'workspace'], 'supported_scopes' => ['global', 'workspace'],
'operation_types' => ['restore.execute'], '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([ ->and($catalog->definition('ai.execution'))->toMatchArray([
'key' => 'ai.execution', 'key' => 'ai.execution',

View File

@ -0,0 +1,150 @@
<?php
declare(strict_types=1);
use App\Support\PortfolioCompare\CrossTenantPromotionExecutionPlanner;
it('builds a ready-only promotion execution plan with stable identity inputs', function (): void {
$planner = app(CrossTenantPromotionExecutionPlanner::class);
$preview = [
'selection' => [
'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<string> $reasonCodes
* @return array<string, mixed>
*/
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,
],
];
}

View File

@ -4,7 +4,7 @@ # TenantPilot Implementation Ledger
> **Last reviewed:** 2026-05-02 > **Last reviewed:** 2026-05-02
> **Use for:** Repo-based implementation status and product-surface maturity assessment > **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 > **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 ## Purpose
@ -134,11 +134,13 @@ ## Not Implemented
- Auditor Pack Delivery & Executive Export v1 - Auditor Pack Delivery & Executive Export v1
- Cross-Tenant Promotion Execution 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 - Customer-Facing Localization Adoption v1
- Billing & Subscription Truth Layer v1 - Billing & Subscription Truth Layer v1
- Stored Reports Surface v1 - Stored Reports Surface v1
- Workspace & Tenant Closure Lifecycle v1 - Workspace & Tenant Closure Lifecycle v1
- Enterprise Access Boundary & Support Access Governance v1
- First Governed AI Runtime Consumer v1 - First Governed AI Runtime Consumer v1
- Human-in-the-Loop Autonomous Governance - Human-in-the-Loop Autonomous Governance
- Standardization & Policy Quality / Intune Linting - Standardization & Policy Quality / Intune Linting
@ -217,22 +219,24 @@ ## Open Gaps & Blockers
| Gap | Type | Impact | Roadmap Area | Recommended Spec | | 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 | | 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` | | 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 | MSP Portfolio & Operations | `Cross-Tenant Promotion Execution v1` | | 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` |
| 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` | | 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` | | 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` | | 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` | | 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` | | 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` | | 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 ## 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` - `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`
- `Cross-Tenant Promotion Execution v1` -> anchored by `specs/043-cross-tenant-compare-and-promotion/spec.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`
- `Governance Decision Pack & Approval Workflow v1` -> anchored by `specs/257-governance-decision-convergence/spec.md` and `docs/product/roadmap.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` - `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` - `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` - `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` - `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` - `First Governed AI Runtime Consumer v1` -> anchored by `specs/248-private-ai-policy-foundation/spec.md`

View File

@ -4,7 +4,7 @@ # Product Roadmap
> **Last reviewed:** 2026-05-02 > **Last reviewed:** 2026-05-02
> **Use for:** Current product roadmap, release themes, and prioritization context > **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 > **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. > Strategic thematic blocks and release trajectory.
> This is the "big picture" — not individual specs. > 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. 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 | | 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 | 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 | | 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 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 | | 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 | 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 | | 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 | 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 | | 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 | 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 | | 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 | 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 | | 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 | 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 | | 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 | 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 | | 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 | 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 | | 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 | 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 | | 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: 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 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 | | 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 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 | | 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` | | 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 | 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 even though the execution spec package now exists on this branch | Covered by `specs/264-cross-tenant-promotion-execution/spec.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` | | 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` | | 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` | | 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` | | 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) ## Priority Ranking (Current Manual Promotion Order)
1. Auditor Pack Delivery & Executive Export 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.
2. Cross-Tenant Promotion Execution v1
3. Governance Decision Pack & Approval Workflow v1 1. Decision Register & Approval Workflow v1
2. Governance Artifact Lifecycle & Retention v1
3. Billing & Subscription Truth Layer v1
4. Customer-Facing Localization Adoption 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 6. Stored Reports Surface v1
7. Workspace & Tenant Closure Lifecycle v1 7. Workspace & Tenant Closure Lifecycle v1
8. First Governed AI Runtime Consumer v1 8. First Governed AI Runtime Consumer v1

View File

@ -1,10 +1,10 @@
# Spec Candidates # Spec Candidates
> **Status:** Active > **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 > **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 > **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. > 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. > 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. - `discoveries.md` is a staging area for findings that may later be promoted here.
- `implementation-ledger.md` is maturity evidence, not a prioritization queue. - `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. - 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 ## 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`. 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. 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 ## 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. **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 - **Priority**: 1
- **Repo truth**: review-pack export, evidence, tenant review, customer review productization, compliance mapping, and governance packaging foundations are already spec-backed. - **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**: this is the clearest remaining step between repo-real governance truth and auditor-/executive-ready delivery. - **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 is a bounded packaging and export decision, not an automatic next-best-prep foundation gap. - **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/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.
- **Anchors**: - **Anchors**:
- `specs/250-decision-governance-inbox/spec.md`
- `specs/257-governance-decision-convergence/spec.md` - `specs/257-governance-decision-convergence/spec.md`
- `docs/product/roadmap.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 ### Customer-Facing Localization Adoption v1
- **Priority**: 4 - **Priority**: 4
@ -101,15 +200,17 @@ ### Customer-Facing Localization Adoption v1
- `specs/258-customer-review-productization/spec.md` - `specs/258-customer-review-productization/spec.md`
- `specs/260-governance-service-packaging/spec.md` - `specs/260-governance-service-packaging/spec.md`
### Billing & Subscription Truth Layer v1 ### Enterprise Access Boundary & Support Access Governance v1
- **Priority**: 5 - **Priority**: 5
- **Repo truth**: plans, entitlements, and commercial lifecycle maturity are already spec-backed, but the billing/subscription truth layer is still missing. - **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 remaining commercial truth gap after entitlements and lifecycle groundwork. - **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 broad readiness work is already covered, so this should not be reintroduced as an automatic foundation candidate. - **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**: - **Anchors**:
- `specs/247-plans-entitlements-billing-readiness/spec.md` - `docs/audits/2026-03-09-enterprise-rbac-scope-audit.md`
- `specs/251-commercial-entitlements-billing-state/spec.md` - `docs/HANDOVER.md`
- `specs/065-tenant-rbac-v1/spec.md`
- `specs/066-rbac-ui-enforcement-helper/spec.md`
### Stored Reports Surface v1 ### Stored Reports Surface v1
@ -230,6 +331,8 @@ ## Promoted to Spec
- Governance-as-a-Service Packaging v1 -> Spec 260 (`governance-service-packaging`) - Governance-as-a-Service Packaging v1 -> Spec 260 (`governance-service-packaging`)
- Provider-Missing Policy Visibility & Restore Continuity v1 -> Spec 261 (`provider-missing-policy-visibility`) - 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`) - 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`) - Queued Execution Reauthorization and Scope Continuity -> Spec 149 (`queued-execution-reauthorization`)
- Livewire Context Locking and Trusted-State Reduction -> Spec 152 (`livewire-context-locking`) - Livewire Context Locking and Trusted-State Reduction -> Spec 152 (`livewire-context-locking`)
- Evidence Domain Foundation -> Spec 153 (`evidence-domain-foundation`) - Evidence Domain Foundation -> Spec 153 (`evidence-domain-foundation`)

View File

@ -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.

View File

@ -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.

View File

@ -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.

View File

@ -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