Compare commits
3 Commits
da5b12aecd
...
983abb18a1
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
983abb18a1 | ||
|
|
10b542fa98 | ||
| b05d5c52d4 |
@ -12,8 +12,13 @@
|
||||
use App\Services\Audit\WorkspaceAuditLogger;
|
||||
use App\Services\Auth\CapabilityResolver;
|
||||
use App\Services\Auth\WorkspaceCapabilityResolver;
|
||||
use App\Services\PortfolioCompare\CrossTenantPromotionExecutionService;
|
||||
use App\Support\Auth\Capabilities;
|
||||
use App\Support\Navigation\CanonicalNavigationContext;
|
||||
use App\Support\OperationalControls\OperationalControlBlockedException;
|
||||
use App\Support\OperationRunLinks;
|
||||
use App\Support\OpsUx\OpsUxBrowserEvents;
|
||||
use App\Support\OpsUx\ProviderOperationStartResultPresenter;
|
||||
use App\Support\PortfolioCompare\CrossTenantComparePreviewBuilder;
|
||||
use App\Support\PortfolioCompare\CrossTenantCompareSelection;
|
||||
use App\Support\PortfolioCompare\CrossTenantPromotionPreflight;
|
||||
@ -23,13 +28,16 @@
|
||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceSlot;
|
||||
use App\Support\Workspaces\WorkspaceContext;
|
||||
use BackedEnum;
|
||||
use DomainException;
|
||||
use Filament\Actions\Action;
|
||||
use Filament\Notifications\Notification;
|
||||
use Filament\Forms\Components\Select;
|
||||
use Filament\Forms\Concerns\InteractsWithForms;
|
||||
use Filament\Forms\Contracts\HasForms;
|
||||
use Filament\Pages\Page;
|
||||
use Filament\Schemas\Components\Grid;
|
||||
use Filament\Schemas\Schema;
|
||||
use InvalidArgumentException;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Str;
|
||||
use UnitEnum;
|
||||
@ -192,6 +200,7 @@ protected function getHeaderActions(): array
|
||||
->label('Generate promotion preflight')
|
||||
->icon('heroicon-o-sparkles')
|
||||
->color('primary')
|
||||
->visible(fn (): bool => ! is_array($this->preflight))
|
||||
->disabled(fn (): bool => $this->preflightDisabledReason() !== null)
|
||||
->tooltip(fn (): ?string => $this->preflightDisabledReason())
|
||||
->action(fn (): mixed => $this->generatePromotionPreflight());
|
||||
@ -201,6 +210,7 @@ protected function getHeaderActions(): array
|
||||
fn (): ?Workspace => $this->workspace(),
|
||||
)
|
||||
->requireCapability(Capabilities::WORKSPACE_BASELINES_MANAGE)
|
||||
->preserveVisibility()
|
||||
->preserveDisabled()
|
||||
->tooltip('You need workspace baseline manage access to generate a promotion preflight.')
|
||||
->apply()
|
||||
@ -223,6 +233,19 @@ protected function getHeaderActions(): array
|
||||
|
||||
$actions[] = $preflightAction;
|
||||
|
||||
$actions[] = Action::make('executePromotion')
|
||||
->label('Execute promotion')
|
||||
->icon('heroicon-o-play')
|
||||
->color('warning')
|
||||
->visible(fn (): bool => is_array($this->preflight))
|
||||
->requiresConfirmation()
|
||||
->modalHeading('Execute promotion')
|
||||
->modalDescription(fn (): string => $this->executePromotionConfirmationDescription())
|
||||
->modalSubmitActionLabel('Queue promotion')
|
||||
->disabled(fn (): bool => $this->executePromotionDisabledReason() !== null)
|
||||
->tooltip(fn (): ?string => $this->executePromotionDisabledReason())
|
||||
->action(fn (): mixed => $this->executePromotion());
|
||||
|
||||
return $actions;
|
||||
}
|
||||
|
||||
@ -282,6 +305,74 @@ public function generatePromotionPreflight(): void
|
||||
}
|
||||
}
|
||||
|
||||
public function executePromotion(): void
|
||||
{
|
||||
$this->authorizePageAccess();
|
||||
$this->authorizePromotionExecution();
|
||||
|
||||
if (! is_array($this->preview) || ! is_array($this->preflight)) {
|
||||
Notification::make()
|
||||
->title('Promotion execution unavailable')
|
||||
->body('Generate a current promotion preflight before executing promotion.')
|
||||
->warning()
|
||||
->send();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$selection = $this->compareSelection();
|
||||
$user = auth()->user();
|
||||
|
||||
if (! $selection instanceof CrossTenantCompareSelection || ! $user instanceof User) {
|
||||
Notification::make()
|
||||
->title('Promotion execution unavailable')
|
||||
->body('Refresh the compare selection before executing promotion.')
|
||||
->warning()
|
||||
->send();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
$result = app(CrossTenantPromotionExecutionService::class)->start(
|
||||
selection: $selection,
|
||||
preview: $this->preview,
|
||||
preflight: $this->preflight,
|
||||
actor: $user,
|
||||
);
|
||||
} catch (OperationalControlBlockedException $exception) {
|
||||
Notification::make()
|
||||
->title($exception->title())
|
||||
->body($exception->getMessage())
|
||||
->warning()
|
||||
->send();
|
||||
|
||||
return;
|
||||
} catch (DomainException|InvalidArgumentException $exception) {
|
||||
Notification::make()
|
||||
->title('Promotion execution unavailable')
|
||||
->body($exception->getMessage())
|
||||
->warning()
|
||||
->send();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$notification = app(ProviderOperationStartResultPresenter::class)->notification(
|
||||
result: $result,
|
||||
blockedTitle: 'Promotion execution blocked',
|
||||
runUrl: OperationRunLinks::tenantlessView($result->run),
|
||||
scopeBusyTitle: 'Promotion scope busy',
|
||||
scopeBusyBody: 'Another promotion or restore operation is already active for this target scope. Open the active operation for progress and next steps.',
|
||||
);
|
||||
|
||||
if (in_array($result->status, ['started', 'deduped', 'scope_busy'], true)) {
|
||||
OpsUxBrowserEvents::dispatchRunEnqueued($this);
|
||||
}
|
||||
|
||||
$notification->send();
|
||||
}
|
||||
|
||||
public function clearSelectionUrl(): string
|
||||
{
|
||||
return static::getUrl($this->routeParameters([
|
||||
@ -453,6 +544,30 @@ private function authorizePreflightExecution(): void
|
||||
}
|
||||
}
|
||||
|
||||
private function authorizePromotionExecution(): void
|
||||
{
|
||||
$this->authorizePreflightExecution();
|
||||
|
||||
$user = auth()->user();
|
||||
|
||||
if (! $user instanceof User) {
|
||||
abort(403);
|
||||
}
|
||||
|
||||
$targetTenant = $this->selectedTargetTenant();
|
||||
|
||||
if (! $targetTenant instanceof Tenant) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
/** @var CapabilityResolver $resolver */
|
||||
$resolver = app(CapabilityResolver::class);
|
||||
|
||||
if (! $resolver->can($user, $targetTenant, Capabilities::TENANT_MANAGE)) {
|
||||
abort(403);
|
||||
}
|
||||
}
|
||||
|
||||
private function compareSelection(): ?CrossTenantCompareSelection
|
||||
{
|
||||
$sourceTenant = $this->selectedSourceTenant();
|
||||
@ -593,6 +708,73 @@ private function preflightDisabledReason(): ?string
|
||||
return null;
|
||||
}
|
||||
|
||||
private function executePromotionDisabledReason(): ?string
|
||||
{
|
||||
if ($this->selectionMessage !== null) {
|
||||
return $this->selectionMessage;
|
||||
}
|
||||
|
||||
if (! is_array($this->preview)) {
|
||||
return 'Run compare preview before executing promotion.';
|
||||
}
|
||||
|
||||
if (! is_array($this->preflight)) {
|
||||
return 'Generate a current promotion preflight before executing promotion.';
|
||||
}
|
||||
|
||||
if ((int) data_get($this->preflight, 'summary.ready', 0) <= 0) {
|
||||
return 'Current promotion preflight has no ready governed subjects to execute.';
|
||||
}
|
||||
|
||||
$user = auth()->user();
|
||||
$workspace = $this->workspace();
|
||||
|
||||
if ($user instanceof User && $workspace instanceof Workspace) {
|
||||
/** @var WorkspaceCapabilityResolver $workspaceResolver */
|
||||
$workspaceResolver = app(WorkspaceCapabilityResolver::class);
|
||||
|
||||
if ($workspaceResolver->isMember($user, $workspace)
|
||||
&& ! $workspaceResolver->can($user, $workspace, Capabilities::WORKSPACE_BASELINES_MANAGE)) {
|
||||
return 'You need workspace baseline manage access to execute promotion.';
|
||||
}
|
||||
|
||||
$targetTenant = $this->selectedTargetTenant();
|
||||
|
||||
if ($targetTenant instanceof Tenant) {
|
||||
/** @var CapabilityResolver $resolver */
|
||||
$resolver = app(CapabilityResolver::class);
|
||||
|
||||
if (! $resolver->can($user, $targetTenant, Capabilities::TENANT_MANAGE)) {
|
||||
return 'You need target tenant manage access to execute promotion.';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private function executePromotionConfirmationDescription(): string
|
||||
{
|
||||
$selection = $this->compareSelection();
|
||||
$ready = (int) data_get($this->preflight, 'summary.ready', 0);
|
||||
$blocked = (int) data_get($this->preflight, 'summary.blocked', 0);
|
||||
$manualMappingRequired = (int) data_get($this->preflight, 'summary.manual_mapping_required', 0);
|
||||
$excluded = $blocked + $manualMappingRequired;
|
||||
|
||||
$sourceTenantName = $selection?->sourceTenant->name ?? 'Source tenant';
|
||||
$targetTenantName = $selection?->targetTenant->name ?? 'Target tenant';
|
||||
|
||||
return sprintf(
|
||||
'Queue one promotion run from %s to %s for %d ready governed subject%s. %d subject%s remain excluded on the compare page.',
|
||||
$sourceTenantName,
|
||||
$targetTenantName,
|
||||
$ready,
|
||||
$ready === 1 ? '' : 's',
|
||||
$excluded,
|
||||
$excluded === 1 ? '' : 's',
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param mixed $value
|
||||
*/
|
||||
|
||||
@ -199,13 +199,16 @@ private function executeReviewDerivedGeneration(
|
||||
$options = $reviewPack->options ?? [];
|
||||
$includePii = (bool) ($options['include_pii'] ?? true);
|
||||
$includeOperations = (bool) ($options['include_operations'] ?? true);
|
||||
$generatedAt = now();
|
||||
|
||||
$fileMap = $this->buildReviewDerivedFileMap(
|
||||
reviewPack: $reviewPack,
|
||||
review: $review,
|
||||
tenant: $tenant,
|
||||
snapshot: $snapshot,
|
||||
includePii: $includePii,
|
||||
includeOperations: $includeOperations,
|
||||
generatedAt: $generatedAt,
|
||||
);
|
||||
|
||||
$tempFile = tempnam(sys_get_temp_dir(), 'review-pack-');
|
||||
@ -219,7 +222,7 @@ private function executeReviewDerivedGeneration(
|
||||
'review-packs/%s/review-%d-%s.zip',
|
||||
$tenant->external_id,
|
||||
(int) $review->getKey(),
|
||||
now()->format('Y-m-d-His'),
|
||||
$generatedAt->format('Y-m-d-His'),
|
||||
);
|
||||
|
||||
Storage::disk('exports')->put($filePath, file_get_contents($tempFile));
|
||||
@ -241,6 +244,7 @@ private function executeReviewDerivedGeneration(
|
||||
'operation_count' => $includeOperations ? (int) ($reviewSummary['operation_count'] ?? 0) : 0,
|
||||
'highlights' => is_array($reviewSummary['highlights'] ?? null) ? $reviewSummary['highlights'] : [],
|
||||
'publish_blockers' => is_array($reviewSummary['publish_blockers'] ?? null) ? $reviewSummary['publish_blockers'] : [],
|
||||
'delivery_bundle' => $this->deliveryBundleSummary($review),
|
||||
'evidence_resolution' => [
|
||||
'outcome' => 'resolved',
|
||||
'snapshot_id' => (int) $snapshot->getKey(),
|
||||
@ -258,8 +262,8 @@ private function executeReviewDerivedGeneration(
|
||||
'file_size' => $fileSize,
|
||||
'file_path' => $filePath,
|
||||
'file_disk' => 'exports',
|
||||
'generated_at' => now(),
|
||||
'expires_at' => now()->addDays($retentionDays),
|
||||
'generated_at' => $generatedAt,
|
||||
'expires_at' => $generatedAt->copy()->addDays($retentionDays),
|
||||
'summary' => $summary,
|
||||
]);
|
||||
|
||||
@ -582,13 +586,21 @@ private function assembleZip(string $tempFile, array $fileMap): void
|
||||
* @return array<string, string>
|
||||
*/
|
||||
private function buildReviewDerivedFileMap(
|
||||
ReviewPack $reviewPack,
|
||||
TenantReview $review,
|
||||
Tenant $tenant,
|
||||
EvidenceSnapshot $snapshot,
|
||||
bool $includePii,
|
||||
bool $includeOperations,
|
||||
\Carbon\CarbonInterface $generatedAt,
|
||||
): array {
|
||||
$reviewSummary = is_array($review->summary) ? $review->summary : [];
|
||||
$deliveryMetadata = $this->deliveryBundleMetadata(
|
||||
reviewPack: $reviewPack,
|
||||
review: $review,
|
||||
snapshot: $snapshot,
|
||||
generatedAt: $generatedAt,
|
||||
);
|
||||
|
||||
$sections = $review->sections
|
||||
->filter(fn (mixed $section): bool => $includeOperations || $section->section_key !== 'operations_health')
|
||||
@ -599,7 +611,8 @@ private function buildReviewDerivedFileMap(
|
||||
'version' => '1.0',
|
||||
'tenant_id' => $tenant->external_id,
|
||||
'tenant_name' => $includePii ? $tenant->name : '[REDACTED]',
|
||||
'generated_at' => now()->toIso8601String(),
|
||||
'generated_at' => $generatedAt->toIso8601String(),
|
||||
'delivery_bundle' => $deliveryMetadata,
|
||||
'tenant_review' => [
|
||||
'id' => (int) $review->getKey(),
|
||||
'status' => (string) $review->status,
|
||||
@ -622,11 +635,17 @@ private function buildReviewDerivedFileMap(
|
||||
'note' => RedactionIntegrity::protectedValueNote(),
|
||||
],
|
||||
], JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR),
|
||||
'summary.json' => json_encode($this->redactReportPayload(array_merge([
|
||||
'tenant_review_id' => (int) $review->getKey(),
|
||||
'review_status' => (string) $review->status,
|
||||
'review_completeness_state' => (string) $review->completeness_state,
|
||||
], $reviewSummary), $includePii), JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR),
|
||||
'summary.json' => json_encode($this->redactReportPayload(array_merge(
|
||||
[
|
||||
'tenant_review_id' => (int) $review->getKey(),
|
||||
'review_status' => (string) $review->status,
|
||||
'review_completeness_state' => (string) $review->completeness_state,
|
||||
],
|
||||
$reviewSummary,
|
||||
[
|
||||
'delivery_bundle' => $this->deliveryBundleSummary($review),
|
||||
],
|
||||
), $includePii), JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR),
|
||||
'sections.json' => json_encode($sections->map(function ($section) use ($includePii): array {
|
||||
$summaryPayload = is_array($section->summary_payload) ? $section->summary_payload : [];
|
||||
$renderPayload = is_array($section->render_payload) ? $section->render_payload : [];
|
||||
@ -641,6 +660,14 @@ private function buildReviewDerivedFileMap(
|
||||
'render_payload' => $this->redactReportPayload($renderPayload, $includePii),
|
||||
];
|
||||
})->all(), JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR),
|
||||
ReviewPackService::EXECUTIVE_ENTRYPOINT_FILENAME => $this->buildExecutiveEntrypoint(
|
||||
review: $review,
|
||||
tenant: $tenant,
|
||||
snapshot: $snapshot,
|
||||
reviewSummary: $reviewSummary,
|
||||
includePii: $includePii,
|
||||
generatedAt: $generatedAt,
|
||||
),
|
||||
];
|
||||
|
||||
foreach ($sections as $section) {
|
||||
@ -659,6 +686,195 @@ private function buildReviewDerivedFileMap(
|
||||
return $files;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function deliveryBundleSummary(TenantReview $review): array
|
||||
{
|
||||
return [
|
||||
'contract' => ReviewPackService::REVIEW_DERIVED_DELIVERY_CONTRACT,
|
||||
'executive_entrypoint_file' => ReviewPackService::EXECUTIVE_ENTRYPOINT_FILENAME,
|
||||
'appendix_files' => ['metadata.json', 'summary.json', 'sections.json'],
|
||||
'interpretation_version' => $review->controlInterpretationVersion(),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function deliveryBundleMetadata(
|
||||
ReviewPack $reviewPack,
|
||||
TenantReview $review,
|
||||
EvidenceSnapshot $snapshot,
|
||||
\Carbon\CarbonInterface $generatedAt,
|
||||
): array {
|
||||
return [
|
||||
'contract' => ReviewPackService::REVIEW_DERIVED_DELIVERY_CONTRACT,
|
||||
'artifact_family' => 'review_pack',
|
||||
'review_pack_id' => (int) $reviewPack->getKey(),
|
||||
'generated_at' => $generatedAt->toIso8601String(),
|
||||
'released_review' => [
|
||||
'id' => (int) $review->getKey(),
|
||||
'status' => (string) $review->status,
|
||||
'completeness_state' => (string) $review->completeness_state,
|
||||
'published_at' => $review->published_at?->toIso8601String(),
|
||||
],
|
||||
'interpretation_version' => $review->controlInterpretationVersion(),
|
||||
'evidence_basis' => [
|
||||
'snapshot_id' => (int) $snapshot->getKey(),
|
||||
'snapshot_fingerprint' => (string) $snapshot->fingerprint,
|
||||
'completeness_state' => (string) $snapshot->completeness_state,
|
||||
'generated_at' => $snapshot->generated_at?->toIso8601String(),
|
||||
],
|
||||
'entrypoint' => [
|
||||
'file' => ReviewPackService::EXECUTIVE_ENTRYPOINT_FILENAME,
|
||||
'role' => 'executive_entrypoint',
|
||||
'audience' => 'executive',
|
||||
'format' => 'text/markdown',
|
||||
],
|
||||
'appendix' => [
|
||||
[
|
||||
'file' => 'metadata.json',
|
||||
'role' => 'bundle_metadata',
|
||||
'description' => 'Structured delivery metadata and artifact role map.',
|
||||
],
|
||||
[
|
||||
'file' => 'summary.json',
|
||||
'role' => 'review_summary_appendix',
|
||||
'description' => 'Structured released-review summary truth.',
|
||||
],
|
||||
[
|
||||
'file' => 'sections.json',
|
||||
'role' => 'section_detail_appendix',
|
||||
'description' => 'Structured released-review section detail.',
|
||||
],
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $reviewSummary
|
||||
*/
|
||||
private function buildExecutiveEntrypoint(
|
||||
TenantReview $review,
|
||||
Tenant $tenant,
|
||||
EvidenceSnapshot $snapshot,
|
||||
array $reviewSummary,
|
||||
bool $includePii,
|
||||
\Carbon\CarbonInterface $generatedAt,
|
||||
): string {
|
||||
$package = is_array($reviewSummary['governance_package'] ?? null)
|
||||
? $this->redactReportPayload($reviewSummary['governance_package'], $includePii)
|
||||
: [];
|
||||
$controlInterpretation = is_array($reviewSummary['control_interpretation'] ?? null)
|
||||
? $reviewSummary['control_interpretation']
|
||||
: [];
|
||||
$nonCertificationDisclosure = $this->plainText(
|
||||
$controlInterpretation['non_certification_disclosure'] ?? null,
|
||||
'TenantPilot interprets available evidence for review readiness. This is not a certification, legal attestation, or compliance guarantee.',
|
||||
);
|
||||
$tenantName = $includePii ? $tenant->name : '[REDACTED]';
|
||||
$topFindings = is_array($package['top_findings'] ?? null) ? $package['top_findings'] : [];
|
||||
$acceptedRisks = is_array($package['accepted_risks'] ?? null) ? $package['accepted_risks'] : [];
|
||||
$governanceDecisions = is_array($package['governance_decisions'] ?? null) ? $package['governance_decisions'] : [];
|
||||
$nextActions = is_array($reviewSummary['recommended_next_actions'] ?? null) ? $reviewSummary['recommended_next_actions'] : [];
|
||||
|
||||
$lines = [
|
||||
'# Executive summary',
|
||||
'',
|
||||
'Tenant: '.$this->plainText($tenantName, '[REDACTED]'),
|
||||
'Released review: #'.((int) $review->getKey()),
|
||||
'Review status: '.$this->plainText($review->status, 'unknown'),
|
||||
'Generated at: '.$generatedAt->toIso8601String(),
|
||||
'',
|
||||
'## Executive story',
|
||||
'',
|
||||
$this->plainText($package['executive_summary'] ?? null, 'No executive summary is available for this released review.'),
|
||||
'',
|
||||
'## Evidence basis',
|
||||
'',
|
||||
$this->plainText(
|
||||
$package['evidence_basis_summary'] ?? null,
|
||||
sprintf('Anchored to evidence snapshot #%d with %s completeness.', (int) $snapshot->getKey(), (string) $snapshot->completeness_state),
|
||||
),
|
||||
'',
|
||||
'## Key findings',
|
||||
'',
|
||||
...$this->entryBullets($topFindings, 'No key findings are listed for this released review.'),
|
||||
'',
|
||||
'## Accepted risks',
|
||||
'',
|
||||
...$this->entryBullets($acceptedRisks, 'No accepted risks are listed for this released review.'),
|
||||
'',
|
||||
'## Governance decisions requiring awareness',
|
||||
'',
|
||||
...$this->entryBullets($governanceDecisions, 'No governance decisions require awareness in this released review.'),
|
||||
'',
|
||||
'## Next actions',
|
||||
'',
|
||||
...$this->textBullets($nextActions, 'No next action is listed for this released review.'),
|
||||
'',
|
||||
'## Non-certification disclosure',
|
||||
'',
|
||||
$nonCertificationDisclosure,
|
||||
'',
|
||||
'## Structured auditor appendix',
|
||||
'',
|
||||
'This executive entrypoint is the first file to read. The structured auditor appendix remains available in metadata.json, summary.json, and sections.json.',
|
||||
'',
|
||||
];
|
||||
|
||||
return implode("\n", $lines);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, mixed> $entries
|
||||
* @return list<string>
|
||||
*/
|
||||
private function entryBullets(array $entries, string $emptyText): array
|
||||
{
|
||||
if ($entries === []) {
|
||||
return ['- '.$emptyText];
|
||||
}
|
||||
|
||||
return collect($entries)
|
||||
->filter(static fn (mixed $entry): bool => is_array($entry))
|
||||
->map(function (array $entry): string {
|
||||
$title = $this->plainText($entry['title'] ?? null, 'Entry');
|
||||
$summary = $this->plainText($entry['summary'] ?? null, '');
|
||||
|
||||
return $summary === '' ? '- '.$title : '- '.$title.' - '.$summary;
|
||||
})
|
||||
->values()
|
||||
->all();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, mixed> $entries
|
||||
* @return list<string>
|
||||
*/
|
||||
private function textBullets(array $entries, string $emptyText): array
|
||||
{
|
||||
$bullets = collect($entries)
|
||||
->filter(static fn (mixed $entry): bool => is_string($entry) && trim($entry) !== '')
|
||||
->map(fn (string $entry): string => '- '.$this->plainText($entry, ''))
|
||||
->values()
|
||||
->all();
|
||||
|
||||
return $bullets === [] ? ['- '.$emptyText] : $bullets;
|
||||
}
|
||||
|
||||
private function plainText(mixed $value, string $fallback): string
|
||||
{
|
||||
if (! is_scalar($value) && $value !== null) {
|
||||
return $fallback;
|
||||
}
|
||||
|
||||
$text = preg_replace('/\s+/', ' ', trim((string) $value));
|
||||
|
||||
return is_string($text) && $text !== '' ? $text : $fallback;
|
||||
}
|
||||
|
||||
private function markFailed(ReviewPack $reviewPack, OperationRun $operationRun, OperationRunService $operationRunService, string $reasonCode, string $errorMessage): void
|
||||
{
|
||||
$reviewPack->update([
|
||||
|
||||
@ -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');
|
||||
}
|
||||
}
|
||||
@ -6,6 +6,7 @@
|
||||
|
||||
use App\Models\OperationRun;
|
||||
use App\Models\PlatformUser;
|
||||
use App\Models\RestoreRun;
|
||||
use App\Models\SupportRequest;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
@ -176,6 +177,85 @@ public function logCrossTenantPromotionPreflightGenerated(
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<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(
|
||||
SupportRequest $supportRequest,
|
||||
User|PlatformUser|null $actor = null,
|
||||
|
||||
@ -86,6 +86,19 @@ public function evaluate(OperationRun $run): QueuedExecutionLegitimacyDecision
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if ($context->workspaceRequiredCapability !== null) {
|
||||
$checks['capability'] = $this->initiatorHasRequiredWorkspaceCapability($context) ? 'passed' : 'failed';
|
||||
|
||||
if ($checks['capability'] === 'failed') {
|
||||
return QueuedExecutionLegitimacyDecision::deny(
|
||||
$context,
|
||||
$checks,
|
||||
ExecutionDenialReasonCode::MissingCapability,
|
||||
['required_capability' => $context->workspaceRequiredCapability],
|
||||
);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if (! $this->isSystemAuthorityAllowed($context->operationType)) {
|
||||
$checks['execution_prerequisites'] = 'failed';
|
||||
@ -151,6 +164,9 @@ public function buildContext(OperationRun $run): QueuedExecutionContext
|
||||
requiredCapability: is_string($context['required_capability'] ?? null)
|
||||
? $context['required_capability']
|
||||
: $this->operationRunCapabilityResolver->requiredExecutionCapabilityForType($operationType),
|
||||
workspaceRequiredCapability: is_string($context['workspace_required_capability'] ?? null)
|
||||
? $context['workspace_required_capability']
|
||||
: null,
|
||||
providerConnectionId: $providerConnectionId,
|
||||
targetScope: [
|
||||
'workspace_id' => $workspaceId,
|
||||
@ -259,6 +275,29 @@ private function initiatorHasRequiredCapability(QueuedExecutionContext $context)
|
||||
);
|
||||
}
|
||||
|
||||
private function initiatorHasRequiredWorkspaceCapability(QueuedExecutionContext $context): bool
|
||||
{
|
||||
if (! $context->initiator instanceof User || ! is_string($context->workspaceRequiredCapability) || $context->workspaceRequiredCapability === '') {
|
||||
return false;
|
||||
}
|
||||
|
||||
if ($context->workspaceId <= 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$workspace = $context->run->tenant?->workspace ?? $context->run->workspace()->first();
|
||||
|
||||
if ($workspace === null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return $this->workspaceCapabilityResolver->can(
|
||||
$context->initiator,
|
||||
$workspace,
|
||||
$context->workspaceRequiredCapability,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<string>
|
||||
*/
|
||||
@ -270,7 +309,7 @@ private function prerequisiteClassesFor(string $operationType, ?int $providerCon
|
||||
$prerequisites[] = 'provider_connection';
|
||||
}
|
||||
|
||||
if (str_starts_with($operationType, 'restore.')) {
|
||||
if (str_starts_with($operationType, 'restore.') || $operationType === 'promotion.execute') {
|
||||
$prerequisites[] = 'write_gate';
|
||||
}
|
||||
|
||||
@ -279,6 +318,10 @@ private function prerequisiteClassesFor(string $operationType, ?int $providerCon
|
||||
|
||||
private function questionForContext(QueuedExecutionContext $context): TenantOperabilityQuestion
|
||||
{
|
||||
if ($context->operationType === 'promotion.execute') {
|
||||
return TenantOperabilityQuestion::RestoreEligibility;
|
||||
}
|
||||
|
||||
if ($context->providerConnectionId !== null || in_array($context->operationType, ['provider.connection.check', 'compliance.snapshot', 'provider.compliance.snapshot'], true)) {
|
||||
return TenantOperabilityQuestion::VerificationReadinessEligibility;
|
||||
}
|
||||
|
||||
@ -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();
|
||||
}
|
||||
}
|
||||
@ -26,6 +26,10 @@
|
||||
|
||||
class ReviewPackService
|
||||
{
|
||||
public const string REVIEW_DERIVED_DELIVERY_CONTRACT = 'auditor_ready_executive_export.v1';
|
||||
|
||||
public const string EXECUTIVE_ENTRYPOINT_FILENAME = 'executive-summary.md';
|
||||
|
||||
public function __construct(
|
||||
private OperationRunService $operationRunService,
|
||||
private EvidenceSnapshotResolver $snapshotResolver,
|
||||
@ -193,6 +197,11 @@ public function generateFromReview(TenantReview $review, User $user, array $opti
|
||||
'review_status' => (string) $review->status,
|
||||
'review_completeness_state' => (string) $review->completeness_state,
|
||||
'section_count' => $review->sections->count(),
|
||||
'delivery_bundle' => [
|
||||
'contract' => self::REVIEW_DERIVED_DELIVERY_CONTRACT,
|
||||
'executive_entrypoint_file' => self::EXECUTIVE_ENTRYPOINT_FILENAME,
|
||||
'appendix_files' => ['metadata.json', 'summary.json', 'sections.json'],
|
||||
],
|
||||
'finding_outcomes' => is_array($review->summary['finding_outcomes'] ?? null)
|
||||
? $review->summary['finding_outcomes']
|
||||
: [],
|
||||
@ -376,6 +385,7 @@ public function computeFingerprintForReview(TenantReview $review, array $options
|
||||
'tenant_review_id' => (int) $review->getKey(),
|
||||
'review_fingerprint' => (string) $review->fingerprint,
|
||||
'review_status' => (string) $review->status,
|
||||
'delivery_contract' => self::REVIEW_DERIVED_DELIVERY_CONTRACT,
|
||||
'include_pii' => (bool) ($options['include_pii'] ?? true),
|
||||
'include_operations' => (bool) ($options['include_operations'] ?? true),
|
||||
];
|
||||
|
||||
@ -73,6 +73,8 @@ enum AuditActionId: string
|
||||
case BaselineCompareCompleted = 'baseline_compare.completed';
|
||||
case BaselineCompareFailed = 'baseline_compare.failed';
|
||||
case CrossTenantPromotionPreflightGenerated = 'cross_tenant_promotion_preflight.generated';
|
||||
case CrossTenantPromotionExecutionQueued = 'cross_tenant_promotion_execution.queued';
|
||||
case CrossTenantPromotionExecutionCompleted = 'cross_tenant_promotion_execution.completed';
|
||||
case BaselineAssignmentCreated = 'baseline_assignment.created';
|
||||
case BaselineAssignmentUpdated = 'baseline_assignment.updated';
|
||||
case BaselineAssignmentDeleted = 'baseline_assignment.deleted';
|
||||
@ -227,6 +229,8 @@ private static function labels(): array
|
||||
self::BaselineCompareCompleted->value => 'Baseline compare completed',
|
||||
self::BaselineCompareFailed->value => 'Baseline compare failed',
|
||||
self::CrossTenantPromotionPreflightGenerated->value => 'Cross-tenant promotion preflight generated',
|
||||
self::CrossTenantPromotionExecutionQueued->value => 'Cross-tenant promotion execution queued',
|
||||
self::CrossTenantPromotionExecutionCompleted->value => 'Cross-tenant promotion execution completed',
|
||||
self::BaselineAssignmentCreated->value => 'Baseline assignment created',
|
||||
self::BaselineAssignmentUpdated->value => 'Baseline assignment updated',
|
||||
self::BaselineAssignmentDeleted->value => 'Baseline assignment deleted',
|
||||
@ -326,6 +330,8 @@ private static function summaries(): array
|
||||
self::BaselineProfileArchived->value => 'Baseline profile archived',
|
||||
self::BaselineProfileScopeBackfilled->value => 'Baseline profile scope backfilled',
|
||||
self::CrossTenantPromotionPreflightGenerated->value => 'Cross-tenant promotion preflight generated',
|
||||
self::CrossTenantPromotionExecutionQueued->value => 'Cross-tenant promotion execution queued',
|
||||
self::CrossTenantPromotionExecutionCompleted->value => 'Cross-tenant promotion execution completed',
|
||||
self::AlertDestinationCreated->value => 'Alert destination created',
|
||||
self::AlertDestinationUpdated->value => 'Alert destination updated',
|
||||
self::AlertDestinationDeleted->value => 'Alert destination deleted',
|
||||
|
||||
@ -257,6 +257,7 @@ private static function canonicalDefinitions(): array
|
||||
'backup.schedule.retention' => new CanonicalOperationType('backup.schedule.retention', 'intune', null, 'Backup schedule retention'),
|
||||
'backup.schedule.purge' => new CanonicalOperationType('backup.schedule.purge', 'intune', null, 'Backup schedule purge'),
|
||||
'restore.execute' => new CanonicalOperationType('restore.execute', 'intune', null, 'Restore execution'),
|
||||
'promotion.execute' => new CanonicalOperationType('promotion.execute', 'platform_foundation', null, 'Promotion execution', true, 120),
|
||||
'assignments.fetch' => new CanonicalOperationType('assignments.fetch', 'intune', null, 'Assignment fetch', false, 60),
|
||||
'assignments.restore' => new CanonicalOperationType('assignments.restore', 'intune', null, 'Assignment restore', false, 60),
|
||||
'ops.reconcile_adapter_runs' => new CanonicalOperationType('ops.reconcile_adapter_runs', 'platform_foundation', null, 'Reconcile adapter runs', false, 120),
|
||||
@ -315,6 +316,7 @@ private static function operationAliases(): array
|
||||
new OperationTypeAlias('backup.schedule.purge', 'backup.schedule.purge', 'canonical', true),
|
||||
new OperationTypeAlias('backup_schedule_purge', 'backup.schedule.purge', 'legacy_alias', false, 'Legacy backup schedule purge values resolve to backup.schedule.purge.', 'Prefer dotted canonical backup schedule naming on new read paths.'),
|
||||
new OperationTypeAlias('restore.execute', 'restore.execute', 'canonical', true),
|
||||
new OperationTypeAlias('promotion.execute', 'promotion.execute', 'canonical', true),
|
||||
new OperationTypeAlias('assignments.fetch', 'assignments.fetch', 'canonical', true),
|
||||
new OperationTypeAlias('assignments.restore', 'assignments.restore', 'canonical', true),
|
||||
new OperationTypeAlias('ops.reconcile_adapter_runs', 'ops.reconcile_adapter_runs', 'canonical', true),
|
||||
|
||||
@ -15,6 +15,7 @@ enum OperationRunType: string
|
||||
case BackupSchedulePurge = 'backup.schedule.purge';
|
||||
case DirectoryRoleDefinitionsSync = 'directory.role_definitions.sync';
|
||||
case RestoreExecute = 'restore.execute';
|
||||
case PromotionExecute = 'promotion.execute';
|
||||
case EntraAdminRolesScan = 'entra.admin_roles.scan';
|
||||
case ReviewPackGenerate = 'tenant.review_pack.generate';
|
||||
case TenantReviewCompose = 'tenant.review.compose';
|
||||
|
||||
@ -17,6 +17,13 @@ final class OperationalControlCatalog
|
||||
'operation_types' => ['restore.execute'],
|
||||
'affected_surfaces' => ['tenant.restore_runs.create'],
|
||||
],
|
||||
'promotion.execute' => [
|
||||
'key' => 'promotion.execute',
|
||||
'label' => 'Promotion execution',
|
||||
'supported_scopes' => ['global', 'workspace'],
|
||||
'operation_types' => ['promotion.execute'],
|
||||
'affected_surfaces' => ['admin.cross_tenant_compare.execute'],
|
||||
],
|
||||
'ai.execution' => [
|
||||
'key' => 'ai.execution',
|
||||
'label' => 'AI execution',
|
||||
|
||||
@ -25,7 +25,7 @@ public function requiredCapabilityForType(string $operationType): ?string
|
||||
'inventory.sync' => Capabilities::TENANT_INVENTORY_SYNC_RUN,
|
||||
'directory.groups.sync' => Capabilities::TENANT_SYNC,
|
||||
'backup.schedule.execute', 'backup.schedule.retention', 'backup.schedule.purge' => Capabilities::TENANT_BACKUP_SCHEDULES_RUN,
|
||||
'restore.execute' => Capabilities::TENANT_MANAGE,
|
||||
'restore.execute', 'promotion.execute' => Capabilities::TENANT_MANAGE,
|
||||
'directory.role_definitions.sync' => Capabilities::TENANT_MANAGE,
|
||||
'alerts.evaluate', 'alerts.deliver' => Capabilities::ALERTS_VIEW,
|
||||
'tenant.review.compose' => Capabilities::TENANT_REVIEW_VIEW,
|
||||
@ -51,7 +51,7 @@ public function requiredExecutionCapabilityForType(string $operationType): ?stri
|
||||
'provider.connection.check', 'inventory.sync', 'compliance.snapshot' => Capabilities::PROVIDER_RUN,
|
||||
'policy.sync', 'tenant.sync' => Capabilities::TENANT_SYNC,
|
||||
'policy.delete' => Capabilities::TENANT_MANAGE,
|
||||
'assignments.restore', 'restore.execute' => Capabilities::TENANT_MANAGE,
|
||||
'assignments.restore', 'restore.execute', 'promotion.execute' => Capabilities::TENANT_MANAGE,
|
||||
'tenant.review.compose' => Capabilities::TENANT_REVIEW_MANAGE,
|
||||
default => $this->requiredCapabilityForType($operationType),
|
||||
};
|
||||
|
||||
@ -23,6 +23,7 @@ public function __construct(
|
||||
public ?User $initiator,
|
||||
public ExecutionAuthorityMode $authorityMode,
|
||||
public ?string $requiredCapability,
|
||||
public ?string $workspaceRequiredCapability,
|
||||
public ?int $providerConnectionId,
|
||||
public array $targetScope,
|
||||
public array $prerequisiteClasses = [],
|
||||
|
||||
@ -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 !== ''));
|
||||
}
|
||||
}
|
||||
@ -92,6 +92,14 @@
|
||||
'direct_failed_bridge' => false,
|
||||
'scheduled_reconciliation' => true,
|
||||
],
|
||||
'promotion.execute' => [
|
||||
'job_class' => \App\Jobs\Operations\CrossTenantPromotionExecutionJob::class,
|
||||
'queued_stale_after_seconds' => 300,
|
||||
'running_stale_after_seconds' => 1500,
|
||||
'expected_max_runtime_seconds' => 420,
|
||||
'direct_failed_bridge' => false,
|
||||
'scheduled_reconciliation' => true,
|
||||
],
|
||||
'tenant.review_pack.generate' => [
|
||||
'job_class' => \App\Jobs\GenerateReviewPackJob::class,
|
||||
'queued_stale_after_seconds' => 300,
|
||||
|
||||
@ -130,8 +130,8 @@
|
||||
'customer_reviews' => 'Kundenreviews',
|
||||
'customer_review_workspace' => 'Kundenreview-Workspace',
|
||||
'customer_safe_review_workspace' => 'Kundensicherer Governance-Paket-Index',
|
||||
'customer_workspace_intro' => 'Prüfen Sie für jeden berechtigten Tenant den aktuellen Status des Governance-Pakets und öffnen Sie bei Bedarf die kundensichere Detailansicht.',
|
||||
'customer_workspace_canonical_note' => 'Jede Zeile ist ein Einstieg in die Detailansicht: Dort sehen Sie Paketstatus, Nachweise, aktuelle Risiken und den nächsten kundensicheren Schritt.',
|
||||
'customer_workspace_intro' => 'Prüfen Sie für jeden berechtigten Tenant den executive-fähigen Status des Governance-Pakets und öffnen Sie bei Bedarf die kundensichere Detailansicht.',
|
||||
'customer_workspace_canonical_note' => 'Jede Zeile ist ein Einstieg in die Detailansicht: Dort sehen Sie Paketstatus, Executive-Einstieg, Nachweise, aktuelle Risiken und den nächsten kundensicheren Schritt.',
|
||||
'customer_workspace_mapping_version' => 'Die Control-Readiness-Interpretation verwendet :version für diesen Workspace.',
|
||||
'customer_workspace_non_certification_disclosure' => 'Dieser Workspace fasst die aktuelle Review- und Nachweislage für die Service-Auslieferung zusammen. Er ersetzt weder ein formales Auditurteil noch eine Zertifizierung oder rechtliche Attestierung.',
|
||||
'reviews' => 'Reviews',
|
||||
@ -173,6 +173,10 @@
|
||||
'governance_package' => 'Governance-Paket',
|
||||
'governance_decisions' => 'Governance-Entscheidungen',
|
||||
'governance_package_delivery_note' => 'Dieses Governance-Paket wird über das aktuelle Export-Review-Pack des veröffentlichten Reviews ausgeliefert.',
|
||||
'executive_entrypoint' => 'Executive-Einstieg',
|
||||
'executive_entrypoint_description' => 'Beginnen Sie im heruntergeladenen Paket mit executive-summary.md.',
|
||||
'auditor_appendix' => 'Strukturierter Auditor-Anhang',
|
||||
'auditor_appendix_description' => 'metadata.json, summary.json und sections.json bleiben als sekundärer strukturierter Anhang enthalten.',
|
||||
'governance_package_available' => 'Governance-Paket verfügbar',
|
||||
'governance_package_available_description' => 'Das aktuelle Export-Review-Pack ist aus diesem veröffentlichten Review für die Stakeholder-Auslieferung bereit.',
|
||||
'governance_package_partial' => 'Governance-Paket partiell',
|
||||
|
||||
@ -130,8 +130,8 @@
|
||||
'customer_reviews' => 'Customer reviews',
|
||||
'customer_review_workspace' => 'Customer Review Workspace',
|
||||
'customer_safe_review_workspace' => 'Customer-safe governance package index',
|
||||
'customer_workspace_intro' => 'Review the current governance package status for each entitled tenant and open the customer-safe detail when follow-up is needed.',
|
||||
'customer_workspace_canonical_note' => 'Each row is an index entry: open the review detail to inspect package status, supporting evidence, current risks, and the next customer-safe action.',
|
||||
'customer_workspace_intro' => 'Review the executive-ready governance package status for each entitled tenant and open the customer-safe detail when follow-up is needed.',
|
||||
'customer_workspace_canonical_note' => 'Each row is an index entry: open the review detail to inspect package status, the executive entrypoint, supporting evidence, current risks, and the next customer-safe action.',
|
||||
'customer_workspace_mapping_version' => 'Control readiness interpretation uses :version for this workspace.',
|
||||
'customer_workspace_non_certification_disclosure' => 'This workspace summarizes current review evidence for service delivery. It does not replace a formal audit opinion, certification, or legal attestation.',
|
||||
'reviews' => 'Reviews',
|
||||
@ -173,6 +173,10 @@
|
||||
'governance_package' => 'Governance package',
|
||||
'governance_decisions' => 'Governance decisions',
|
||||
'governance_package_delivery_note' => 'This governance package is delivered through the current export review pack for the released review.',
|
||||
'executive_entrypoint' => 'Executive entrypoint',
|
||||
'executive_entrypoint_description' => 'Start with executive-summary.md in the downloaded package.',
|
||||
'auditor_appendix' => 'Structured auditor appendix',
|
||||
'auditor_appendix_description' => 'metadata.json, summary.json, and sections.json remain included as the secondary structured appendix.',
|
||||
'governance_package_available' => 'Governance package available',
|
||||
'governance_package_available_description' => 'The current export review pack is ready for stakeholder delivery from this released review.',
|
||||
'governance_package_partial' => 'Governance package partial',
|
||||
|
||||
@ -126,12 +126,22 @@
|
||||
<div class="mt-1 text-sm font-medium text-gray-900 dark:text-gray-100">{{ __('localization.review.download_governance_package') }}</div>
|
||||
</div>
|
||||
|
||||
<div class="rounded-md border border-gray-100 bg-gray-50 px-3 py-2 dark:border-gray-800 dark:bg-gray-950/60">
|
||||
<div class="text-[11px] font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">{{ __('localization.review.executive_entrypoint') }}</div>
|
||||
<div class="mt-1 text-sm text-gray-900 dark:text-gray-100">{{ __('localization.review.executive_entrypoint_description') }}</div>
|
||||
</div>
|
||||
|
||||
@if ($packageNextStep !== null)
|
||||
<div class="rounded-md border border-primary-100 bg-primary-50 px-3 py-2 dark:border-primary-900/40 dark:bg-primary-950/30">
|
||||
<div class="text-[11px] font-semibold uppercase tracking-wide text-primary-700 dark:text-primary-200">{{ __('localization.review.next_step') }}</div>
|
||||
<div class="mt-1 text-sm text-primary-900 dark:text-primary-100">{{ $packageNextStep }}</div>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<div class="rounded-md border border-gray-100 bg-gray-50 px-3 py-2 dark:border-gray-800 dark:bg-gray-950/60">
|
||||
<div class="text-[11px] font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">{{ __('localization.review.auditor_appendix') }}</div>
|
||||
<div class="mt-1 text-sm text-gray-700 dark:text-gray-300">{{ __('localization.review.auditor_appendix_description') }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (filled($governancePackage['executive_summary'] ?? null))
|
||||
|
||||
@ -12,7 +12,7 @@
|
||||
|
||||
<x-filament::section heading="Cross-tenant compare">
|
||||
<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>
|
||||
|
||||
<div class="space-y-4">
|
||||
@ -147,7 +147,7 @@
|
||||
@if ($preflight !== null)
|
||||
<x-filament::section heading="Promotion preflight">
|
||||
<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>
|
||||
|
||||
<div class="space-y-4" data-testid="cross-tenant-preflight">
|
||||
|
||||
@ -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()));
|
||||
});
|
||||
@ -88,6 +88,7 @@
|
||||
->assertSee('Governance package')
|
||||
->assertSee('Status')
|
||||
->assertSee('Evidence')
|
||||
->assertSee('Review the executive-ready governance package status')
|
||||
->assertSee('This workspace summarizes current review evidence for service delivery. It does not replace a formal audit opinion, certification, or legal attestation.')
|
||||
->assertSee('Partial')
|
||||
->assertSee('Review required')
|
||||
@ -109,6 +110,8 @@
|
||||
->assertSee('Released governance record')
|
||||
->assertSee('Review status')
|
||||
->assertSee('Primary action')
|
||||
->assertSee('Executive entrypoint')
|
||||
->assertSee('Structured auditor appendix')
|
||||
->assertSee('Assessment basis')
|
||||
->assertDontSee('Control readiness interpretation')
|
||||
->assertDontSee('Compliance evidence mapping v1')
|
||||
|
||||
@ -4,19 +4,24 @@
|
||||
|
||||
use App\Filament\Pages\CrossTenantComparePage;
|
||||
use App\Filament\Resources\TenantResource;
|
||||
use App\Jobs\Operations\CrossTenantPromotionExecutionJob;
|
||||
use App\Models\OperationRun;
|
||||
use App\Models\Tenant;
|
||||
use App\Services\Auth\CapabilityResolver;
|
||||
use App\Services\Auth\WorkspaceCapabilityResolver;
|
||||
use App\Support\Auth\Capabilities;
|
||||
use App\Support\BackupHealth\TenantBackupHealthAssessment;
|
||||
use App\Support\Navigation\CanonicalNavigationContext;
|
||||
use App\Support\RestoreSafety\RestoreResultAttention;
|
||||
use App\Support\Tenants\TenantRecoveryTriagePresentation;
|
||||
use Filament\Actions\Action;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Queue;
|
||||
use Livewire\Livewire;
|
||||
use Tests\Feature\Concerns\BuildsPortfolioCompareFixtures;
|
||||
use Tests\Feature\Concerns\BuildsPortfolioTriageFixtures;
|
||||
|
||||
uses(RefreshDatabase::class, BuildsPortfolioTriageFixtures::class);
|
||||
uses(RefreshDatabase::class, BuildsPortfolioTriageFixtures::class, BuildsPortfolioCompareFixtures::class);
|
||||
|
||||
function crossTenantCompareLaunchQuery(string $url): array
|
||||
{
|
||||
@ -119,6 +124,80 @@ function crossTenantCompareLaunchQuery(string $url): array
|
||||
->assertActionVisible('return_to_origin');
|
||||
});
|
||||
|
||||
it('keeps launch context after queueing promotion from an exact-two registry launch', function (): void {
|
||||
Queue::fake();
|
||||
|
||||
[$user, $anchorTenant] = $this->makePortfolioTriageActor(
|
||||
tenantName: 'Anchor Tenant',
|
||||
workspaceRole: 'owner',
|
||||
);
|
||||
$targetTenant = $this->makePortfolioTriagePeer($user, $anchorTenant, 'Target Tenant');
|
||||
createMinimalUserWithTenant(
|
||||
tenant: $targetTenant,
|
||||
user: $user,
|
||||
role: 'owner',
|
||||
workspaceRole: 'owner',
|
||||
);
|
||||
|
||||
$this->createPortfolioCompareSubject(
|
||||
tenant: $anchorTenant,
|
||||
displayName: 'Queued Launch Context Policy',
|
||||
snapshot: ['settings' => [['key' => 'launch-context', 'value' => 1]]],
|
||||
);
|
||||
|
||||
$triageState = $this->portfolioReturnFilters(
|
||||
[TenantBackupHealthAssessment::POSTURE_STALE],
|
||||
[TenantRecoveryTriagePresentation::RECOVERY_EVIDENCE_WEAKENED],
|
||||
[],
|
||||
TenantRecoveryTriagePresentation::TRIAGE_SORT_WORST_FIRST,
|
||||
);
|
||||
|
||||
$expectedUrl = TenantResource::crossTenantCompareOpenUrlForSelection(
|
||||
targetTenant: $targetTenant,
|
||||
triageState: $triageState,
|
||||
sourceTenant: $anchorTenant,
|
||||
);
|
||||
$expectedBackUrl = TenantResource::getUrl(panel: 'admin', parameters: $triageState);
|
||||
$query = crossTenantCompareLaunchQuery($expectedUrl);
|
||||
$query['policy_type'] = ['deviceConfiguration'];
|
||||
|
||||
$this->usePortfolioTriageWorkspace($user, $anchorTenant);
|
||||
|
||||
$component = Livewire::withQueryParams($query)
|
||||
->actingAs($user)
|
||||
->test(CrossTenantComparePage::class)
|
||||
->assertSet('sourceTenantId', (string) $anchorTenant->getKey())
|
||||
->assertSet('targetTenantId', (string) $targetTenant->getKey())
|
||||
->assertSet('selectedPolicyTypes', ['deviceConfiguration'])
|
||||
->assertActionVisible('return_to_origin')
|
||||
->assertActionExists('return_to_origin', fn (Action $action): bool => $action->getLabel() === 'Back to tenant registry'
|
||||
&& $action->getUrl() === $expectedBackUrl);
|
||||
|
||||
$page = $component->instance();
|
||||
$page->generatePromotionPreflight();
|
||||
$page->executePromotion();
|
||||
|
||||
$run = OperationRun::query()->latest('id')->first();
|
||||
$navigationContext = CanonicalNavigationContext::fromPayload($page->navigationContextPayload);
|
||||
|
||||
expect($run)
|
||||
->not->toBeNull()
|
||||
->and($run?->type)->toBe('promotion.execute')
|
||||
->and(data_get($run?->context, 'selection.sourceTenantId'))->toBe((int) $anchorTenant->getKey())
|
||||
->and(data_get($run?->context, 'selection.targetTenantId'))->toBe((int) $targetTenant->getKey())
|
||||
->and(data_get($run?->context, 'selection.policyTypes'))->toBe(['deviceConfiguration'])
|
||||
->and($page->sourceTenantId)->toBe((string) $anchorTenant->getKey())
|
||||
->and($page->targetTenantId)->toBe((string) $targetTenant->getKey())
|
||||
->and($page->selectedPolicyTypes)->toBe(['deviceConfiguration'])
|
||||
->and($page->navigationContextPayload)->toBe($query['nav'])
|
||||
->and($navigationContext?->backLinkLabel)->toBe('Back to tenant registry')
|
||||
->and($navigationContext?->backLinkUrl)->toBe($expectedBackUrl);
|
||||
|
||||
Queue::assertPushed(CrossTenantPromotionExecutionJob::class, function (CrossTenantPromotionExecutionJob $job) use ($run): bool {
|
||||
return $job->getOperationRun()?->is($run);
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects the bulk compare action until exactly two active tenants are selected', function (): void {
|
||||
[$user, $anchorTenant] = $this->makePortfolioTriageActor('Anchor Tenant');
|
||||
$targetTenant = $this->makePortfolioTriagePeer($user, $anchorTenant, 'Target Tenant');
|
||||
|
||||
@ -65,6 +65,32 @@
|
||||
->assertSee('Windows Compliance');
|
||||
});
|
||||
|
||||
it('shows only one dominant promotion action at a time on the compare page', function (): void {
|
||||
$fixture = $this->makeCrossTenantCompareFixture();
|
||||
|
||||
$this->createPortfolioCompareSubject(
|
||||
tenant: $fixture['sourceTenant'],
|
||||
displayName: 'Promotable Policy',
|
||||
snapshot: ['settings' => [['key' => 'wifi', 'value' => 1]]],
|
||||
);
|
||||
|
||||
$this->setAdminWorkspaceContext($fixture['user'], $fixture['workspace']);
|
||||
|
||||
Livewire::withQueryParams([
|
||||
'source_tenant_id' => (int) $fixture['sourceTenant']->getKey(),
|
||||
'target_tenant_id' => (int) $fixture['targetTenant']->getKey(),
|
||||
'policy_type' => ['deviceConfiguration'],
|
||||
])
|
||||
->actingAs($fixture['user'])
|
||||
->test(CrossTenantComparePage::class)
|
||||
->assertActionVisible('generatePromotionPreflight')
|
||||
->assertActionHidden('executePromotion')
|
||||
->call('generatePromotionPreflight')
|
||||
->assertDontSee('Generate promotion preflight')
|
||||
->assertSee('Execute promotion')
|
||||
->assertActionVisible('executePromotion');
|
||||
});
|
||||
|
||||
it('rejects the same tenant as source and target without rendering compare results', function (): void {
|
||||
$fixture = $this->makeCrossTenantCompareFixture();
|
||||
|
||||
|
||||
@ -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();
|
||||
});
|
||||
@ -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);
|
||||
});
|
||||
@ -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);
|
||||
});
|
||||
@ -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();
|
||||
});
|
||||
@ -28,7 +28,7 @@
|
||||
app()->call([$job, 'handle']);
|
||||
|
||||
$pack->refresh();
|
||||
$review->refresh();
|
||||
$review->refresh()->load('evidenceSnapshot');
|
||||
|
||||
expect($pack->tenant_review_id)->toBe((int) $review->getKey())
|
||||
->and($pack->status)->toBe(ReviewPackStatus::Ready->value)
|
||||
@ -47,11 +47,22 @@
|
||||
$metadata = json_decode((string) $zip->getFromName('metadata.json'), true, 512, JSON_THROW_ON_ERROR);
|
||||
$summary = json_decode((string) $zip->getFromName('summary.json'), true, 512, JSON_THROW_ON_ERROR);
|
||||
$sections = json_decode((string) $zip->getFromName('sections.json'), true, 512, JSON_THROW_ON_ERROR);
|
||||
$executiveEntrypoint = (string) $zip->getFromName(ReviewPackService::EXECUTIVE_ENTRYPOINT_FILENAME);
|
||||
|
||||
expect(data_get($metadata, 'tenant_name'))->toBe('[REDACTED]')
|
||||
->and(data_get($metadata, 'delivery_bundle.entrypoint.file'))->toBe(ReviewPackService::EXECUTIVE_ENTRYPOINT_FILENAME)
|
||||
->and(data_get($metadata, 'delivery_bundle.entrypoint.role'))->toBe('executive_entrypoint')
|
||||
->and(data_get($metadata, 'delivery_bundle.appendix.0.file'))->toBe('metadata.json')
|
||||
->and(data_get($metadata, 'delivery_bundle.appendix.1.file'))->toBe('summary.json')
|
||||
->and(data_get($metadata, 'delivery_bundle.appendix.2.file'))->toBe('sections.json')
|
||||
->and(data_get($metadata, 'options.include_operations'))->toBeFalse()
|
||||
->and(data_get($summary, 'delivery_bundle.executive_entrypoint_file'))->toBe(ReviewPackService::EXECUTIVE_ENTRYPOINT_FILENAME)
|
||||
->and(data_get($summary, 'tenant_review_id'))->toBe((int) $review->getKey())
|
||||
->and(collect($sections)->pluck('section_key')->all())->not->toContain('operations_health');
|
||||
->and(collect($sections)->pluck('section_key')->all())->not->toContain('operations_health')
|
||||
->and($executiveEntrypoint)->toContain('Tenant: [REDACTED]')
|
||||
->and($executiveEntrypoint)->toContain('This executive entrypoint is the first file to read')
|
||||
->and($executiveEntrypoint)->not->toContain((string) $review->fingerprint)
|
||||
->and($executiveEntrypoint)->not->toContain((string) $review->evidenceSnapshot?->fingerprint);
|
||||
|
||||
$zip->close();
|
||||
unlink($tempFile);
|
||||
|
||||
@ -92,7 +92,8 @@
|
||||
->assertCanSeeTableRecords([$tenantA->fresh(), $tenantB->fresh()])
|
||||
->assertCanNotSeeTableRecords([$tenantDenied->fresh()])
|
||||
->assertSee(TenantReviewResource::tenantScopedUrl('view', ['record' => $latestPublishedReview->fresh()], $tenantA), false)
|
||||
->assertSee('Review the current governance package status for each entitled tenant and open the customer-safe detail when follow-up is needed.')
|
||||
->assertSee('Review the executive-ready governance package status for each entitled tenant and open the customer-safe detail when follow-up is needed.')
|
||||
->assertSee('Each row is an index entry: open the review detail to inspect package status, the executive entrypoint, supporting evidence, current risks, and the next customer-safe action.')
|
||||
->assertSee('This workspace summarizes current review evidence for service delivery. It does not replace a formal audit opinion, certification, or legal attestation.')
|
||||
->assertSee('Governance package')
|
||||
->assertSee('Status')
|
||||
|
||||
@ -38,7 +38,7 @@
|
||||
app()->call([$job, 'handle']);
|
||||
|
||||
$pack->refresh();
|
||||
$review->refresh()->load('sections');
|
||||
$review->refresh()->load(['sections', 'evidenceSnapshot']);
|
||||
|
||||
$zipContent = Storage::disk('exports')->get((string) $pack->file_path);
|
||||
$tempFile = tempnam(sys_get_temp_dir(), 'tenant-review-pack-');
|
||||
@ -47,13 +47,37 @@
|
||||
$zip = new ZipArchive;
|
||||
$zip->open($tempFile);
|
||||
|
||||
$metadata = json_decode((string) $zip->getFromName('metadata.json'), true, 512, JSON_THROW_ON_ERROR);
|
||||
$summary = json_decode((string) $zip->getFromName('summary.json'), true, 512, JSON_THROW_ON_ERROR);
|
||||
$sections = json_decode((string) $zip->getFromName('sections.json'), true, 512, JSON_THROW_ON_ERROR);
|
||||
$executiveEntrypoint = (string) $zip->getFromName(ReviewPackService::EXECUTIVE_ENTRYPOINT_FILENAME);
|
||||
$filenames = collect(range(0, $zip->numFiles - 1))
|
||||
->map(fn (int $index): string => (string) $zip->getNameIndex($index))
|
||||
->values()
|
||||
->all();
|
||||
|
||||
expect(array_column($sections, 'section_key'))
|
||||
->toBe($review->sections->pluck('section_key')->values()->all())
|
||||
->and($summary['highlights'] ?? null)->toBe($review->summary['highlights'] ?? [])
|
||||
->and($summary['recommended_next_actions'] ?? null)->toBe($review->summary['recommended_next_actions'] ?? []);
|
||||
->and($summary['recommended_next_actions'] ?? null)->toBe($review->summary['recommended_next_actions'] ?? [])
|
||||
->and($summary['delivery_bundle']['contract'] ?? null)->toBe(ReviewPackService::REVIEW_DERIVED_DELIVERY_CONTRACT)
|
||||
->and($summary['delivery_bundle']['executive_entrypoint_file'] ?? null)->toBe(ReviewPackService::EXECUTIVE_ENTRYPOINT_FILENAME)
|
||||
->and($metadata['delivery_bundle']['contract'] ?? null)->toBe(ReviewPackService::REVIEW_DERIVED_DELIVERY_CONTRACT)
|
||||
->and($metadata['delivery_bundle']['review_pack_id'] ?? null)->toBe((int) $pack->getKey())
|
||||
->and($metadata['delivery_bundle']['released_review']['id'] ?? null)->toBe((int) $review->getKey())
|
||||
->and($metadata['delivery_bundle']['interpretation_version'] ?? null)->toBe($review->controlInterpretationVersion())
|
||||
->and($metadata['delivery_bundle']['entrypoint']['file'] ?? null)->toBe(ReviewPackService::EXECUTIVE_ENTRYPOINT_FILENAME)
|
||||
->and(collect($metadata['delivery_bundle']['appendix'] ?? [])->pluck('file')->all())->toBe(['metadata.json', 'summary.json', 'sections.json'])
|
||||
->and($filenames)->toContain('metadata.json', 'summary.json', 'sections.json', ReviewPackService::EXECUTIVE_ENTRYPOINT_FILENAME)
|
||||
->and(collect($filenames)->filter(fn (string $filename): bool => str_starts_with($filename, 'executive-'))->values()->all())->toBe([ReviewPackService::EXECUTIVE_ENTRYPOINT_FILENAME])
|
||||
->and($executiveEntrypoint)->toContain('# Executive summary')
|
||||
->and($executiveEntrypoint)->toContain('## Executive story')
|
||||
->and($executiveEntrypoint)->toContain('## Structured auditor appendix')
|
||||
->and($executiveEntrypoint)->toContain('metadata.json, summary.json, and sections.json')
|
||||
->and($executiveEntrypoint)->not->toContain((string) $review->fingerprint)
|
||||
->and($executiveEntrypoint)->not->toContain((string) $review->evidenceSnapshot?->fingerprint)
|
||||
->and($executiveEntrypoint)->not->toContain('Reason owner')
|
||||
->and($executiveEntrypoint)->not->toContain('Platform reason family');
|
||||
|
||||
$zip->close();
|
||||
unlink($tempFile);
|
||||
|
||||
@ -98,6 +98,10 @@
|
||||
->assertSee('Last review')
|
||||
->assertSee('Primary action')
|
||||
->assertSee('Download governance package')
|
||||
->assertSee('Executive entrypoint')
|
||||
->assertSee('Start with executive-summary.md in the downloaded package.')
|
||||
->assertSee('Structured auditor appendix')
|
||||
->assertSee('metadata.json, summary.json, and sections.json remain included as the secondary structured appendix.')
|
||||
->assertSee('Anchored to evidence snapshot #')
|
||||
->assertSee('Assessment basis')
|
||||
->assertSee('Evidence basis')
|
||||
|
||||
@ -18,6 +18,7 @@
|
||||
'backup_set.update',
|
||||
'backup.schedule.execute',
|
||||
'restore.execute',
|
||||
'promotion.execute',
|
||||
'tenant.review_pack.generate',
|
||||
'tenant.review.compose',
|
||||
'tenant.evidence.snapshot.generate',
|
||||
@ -43,5 +44,6 @@
|
||||
->and($validator->jobTimeoutSeconds('backup_set.update'))->toBe(240)
|
||||
->and($validator->jobFailsOnTimeout('backup_set.update'))->toBeTrue()
|
||||
->and($validator->jobTimeoutSeconds('restore.execute'))->toBe(420)
|
||||
->and($validator->jobTimeoutSeconds('promotion.execute'))->toBe(420)
|
||||
->and($validator->jobFailsOnTimeout('restore.execute'))->toBeTrue();
|
||||
});
|
||||
|
||||
@ -251,6 +251,36 @@
|
||||
->and($decision->checks['execution_prerequisites'])->toBe('failed');
|
||||
});
|
||||
|
||||
it('denies promotion execution when the initiator loses workspace baseline manage capability', function (): void {
|
||||
[$user, $tenant] = createUserWithTenant(role: 'owner', workspaceRole: 'readonly');
|
||||
|
||||
$run = OperationRun::factory()->create([
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'user_id' => (int) $user->getKey(),
|
||||
'type' => 'promotion.execute',
|
||||
'status' => OperationRunStatus::Queued->value,
|
||||
'outcome' => OperationRunOutcome::Pending->value,
|
||||
'context' => [
|
||||
'execution_authority_mode' => ExecutionAuthorityMode::ActorBound->value,
|
||||
'required_capability' => Capabilities::TENANT_MANAGE,
|
||||
'workspace_required_capability' => Capabilities::WORKSPACE_BASELINES_MANAGE,
|
||||
],
|
||||
]);
|
||||
|
||||
$decision = app(QueuedExecutionLegitimacyGate::class)->evaluate($run);
|
||||
|
||||
expect($decision->allowed)->toBeFalse()
|
||||
->and($decision->denialClass)->toBe(ExecutionDenialClass::CapabilityDenied)
|
||||
->and($decision->reasonCode)->toBe(ExecutionDenialReasonCode::MissingCapability)
|
||||
->and($decision->retryable)->toBeFalse()
|
||||
->and($decision->checks)->toMatchArray([
|
||||
'workspace_scope' => 'passed',
|
||||
'tenant_scope' => 'passed',
|
||||
'capability' => 'failed',
|
||||
]);
|
||||
});
|
||||
|
||||
it('infers tenant sync capability for policy sync runs from the central resolver', function (): void {
|
||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||
|
||||
@ -294,3 +324,27 @@
|
||||
'user_id' => null,
|
||||
]);
|
||||
});
|
||||
|
||||
it('builds promotion execution context with workspace reauthorization and write-gate prerequisites', function (): void {
|
||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||
|
||||
$run = OperationRun::factory()->create([
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'user_id' => (int) $user->getKey(),
|
||||
'type' => 'promotion.execute',
|
||||
'status' => OperationRunStatus::Queued->value,
|
||||
'outcome' => OperationRunOutcome::Pending->value,
|
||||
'context' => [
|
||||
'execution_authority_mode' => ExecutionAuthorityMode::ActorBound->value,
|
||||
'required_capability' => Capabilities::TENANT_MANAGE,
|
||||
'workspace_required_capability' => Capabilities::WORKSPACE_BASELINES_MANAGE,
|
||||
],
|
||||
]);
|
||||
|
||||
$context = app(QueuedExecutionLegitimacyGate::class)->buildContext($run);
|
||||
|
||||
expect($context->requiredCapability)->toBe(Capabilities::TENANT_MANAGE)
|
||||
->and($context->workspaceRequiredCapability)->toBe(Capabilities::WORKSPACE_BASELINES_MANAGE)
|
||||
->and($context->prerequisiteClasses)->toContain('write_gate');
|
||||
});
|
||||
|
||||
@ -7,12 +7,18 @@
|
||||
it('exposes only active runtime controls in the bounded control catalog', function (): void {
|
||||
$catalog = app(OperationalControlCatalog::class);
|
||||
|
||||
expect($catalog->keys())->toBe(['restore.execute', 'ai.execution'])
|
||||
expect($catalog->keys())->toBe(['restore.execute', 'promotion.execute', 'ai.execution'])
|
||||
->and($catalog->definition('restore.execute'))->toMatchArray([
|
||||
'key' => 'restore.execute',
|
||||
'label' => 'Restore execution',
|
||||
'supported_scopes' => ['global', 'workspace'],
|
||||
'operation_types' => ['restore.execute'],
|
||||
])
|
||||
->and($catalog->definition('promotion.execute'))->toMatchArray([
|
||||
'key' => 'promotion.execute',
|
||||
'label' => 'Promotion execution',
|
||||
'supported_scopes' => ['global', 'workspace'],
|
||||
'operation_types' => ['promotion.execute'],
|
||||
])
|
||||
->and($catalog->definition('ai.execution'))->toMatchArray([
|
||||
'key' => 'ai.execution',
|
||||
|
||||
@ -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,
|
||||
],
|
||||
];
|
||||
}
|
||||
@ -4,7 +4,7 @@ # TenantPilot Implementation Ledger
|
||||
> **Last reviewed:** 2026-05-02
|
||||
> **Use for:** Repo-based implementation status and product-surface maturity assessment
|
||||
> **Do not use for:** Roadmap priority, spec priority, or proof that tests were executed in the current branch
|
||||
> **Scoped maintenance:** 2026-05-02 ledger drift correction and alignment with `docs/product/roadmap.md` plus `docs/product/spec-candidates.md` after the repo-truth review of roadmap drift, manual-promotion backlog, and implementation maturity.
|
||||
> **Scoped maintenance:** 2026-05-02 ledger drift correction and alignment with `docs/product/roadmap.md` plus `docs/product/spec-candidates.md` after the repo-truth review of roadmap drift, manual-promotion backlog, deep-research alignment, and current spec promotions.
|
||||
|
||||
## Purpose
|
||||
|
||||
@ -134,11 +134,13 @@ ## Not Implemented
|
||||
|
||||
- Auditor Pack Delivery & Executive Export v1
|
||||
- Cross-Tenant Promotion Execution v1
|
||||
- Governance Decision Pack & Approval Workflow v1
|
||||
- Decision Register & Approval Workflow v1
|
||||
- Governance Artifact Lifecycle & Retention v1
|
||||
- Customer-Facing Localization Adoption v1
|
||||
- Billing & Subscription Truth Layer v1
|
||||
- Stored Reports Surface v1
|
||||
- Workspace & Tenant Closure Lifecycle v1
|
||||
- Enterprise Access Boundary & Support Access Governance v1
|
||||
- First Governed AI Runtime Consumer v1
|
||||
- Human-in-the-Loop Autonomous Governance
|
||||
- Standardization & Policy Quality / Intune Linting
|
||||
@ -217,22 +219,24 @@ ## Open Gaps & Blockers
|
||||
| Gap | Type | Impact | Roadmap Area | Recommended Spec |
|
||||
|---|---|---|---|---|
|
||||
| No safe automatic next-best-prep target is currently active | Planning boundary | `docs/product/spec-candidates.md` now keeps the active queue empty, so the next slice must be promoted deliberately instead of selected automatically | Product planning / queue hygiene | none - require explicit manual promotion |
|
||||
| Auditor-ready executive export is still missing | Productization blocker | Review truth remains short of auditor-/executive-ready delivery without dedicated packaging | R2 review delivery | `Auditor Pack Delivery & Executive Export v1` |
|
||||
| Cross-tenant promotion execution is still missing | Product blocker | Compare preview and preflight are repo-real, but the actual portfolio action remains absent | MSP Portfolio & Operations | `Cross-Tenant Promotion Execution v1` |
|
||||
| Governance decision pack and approval workflow is still missing | Product blocker | Decision-based operating still lacks a bounded approval-ready action package with audit trail | Decision-based operating | `Governance Decision Pack & Approval Workflow v1` |
|
||||
| Auditor-ready executive export is still missing | Productization blocker | Review truth remains short of auditor-/executive-ready delivery, even though the dedicated follow-through is now spec-backed | R2 review delivery | `specs/263-auditor-pack-executive-export/spec.md` |
|
||||
| Cross-tenant promotion execution is still missing | Product blocker | Compare preview and preflight are repo-real, but the actual portfolio action remains absent even though the execution package is now spec-backed | MSP Portfolio & Operations | `specs/264-cross-tenant-promotion-execution/spec.md` |
|
||||
| Decision register and approval workflow is still missing | Product blocker | Decision-based operating still lacks a bounded approval-ready closure and decision-record package with audit trail | Decision-based operating | `Decision Register & Approval Workflow v1` |
|
||||
| Governance-artifact lifecycle runtime is still missing | Trust / auditability blocker | Lifecycle taxonomy and point retention rules exist, but governance artifacts still lack immutable-reference, hold, export, delete, and suspended/read-only runtime semantics | Lifecycle governance / enterprise trust | `Governance Artifact Lifecycle & Retention v1` |
|
||||
| Customer-facing localization adoption is incomplete | Productization blocker | Locale groundwork is repo-real, but customer-safe adoption remains incomplete | Localization / review productization | `Customer-Facing Localization Adoption v1` |
|
||||
| Billing and subscription truth is missing | Commercial blocker | Entitlements and lifecycle state handling stop short of a durable billing/subscription truth layer | Commercial readiness | `Billing & Subscription Truth Layer v1` |
|
||||
| Stored reports still lack a clear product surface | Product blocker | Retained evidence and review artifacts remain harder to consume than they should be | Reports / evidence consumption | `Stored Reports Surface v1` |
|
||||
| Workspace and tenant closure follow-through is not started | Strategic blocker | The taxonomy exists, but closure/runtime semantics are not yet productized | Lifecycle governance / enterprise trust | `Workspace & Tenant Closure Lifecycle v1` |
|
||||
| Support-access governance is still missing | Access governance blocker | Break-glass and support access seams exist, but customer-visible TTL, reason, approval, and export semantics are not productized | Enterprise access boundary | `Enterprise Access Boundary & Support Access Governance v1` |
|
||||
| First governed AI runtime consumer is missing | Architecture blocker | The policy foundation exists, but there is no bounded runtime consumer proving the model end-to-end | Governed AI follow-through | `First Governed AI Runtime Consumer v1` |
|
||||
|
||||
## Recommended Manual Promotions
|
||||
|
||||
- `Auditor Pack Delivery & Executive Export v1` -> anchored by `specs/109-review-pack-export/spec.md`, `specs/153-evidence-domain-foundation/spec.md`, `specs/155-tenant-review-layer/spec.md`, `specs/258-customer-review-productization/spec.md`, `specs/259-compliance-evidence-mapping/spec.md`, and `specs/260-governance-service-packaging/spec.md`
|
||||
- `Cross-Tenant Promotion Execution v1` -> anchored by `specs/043-cross-tenant-compare-and-promotion/spec.md`
|
||||
- `Governance Decision Pack & Approval Workflow v1` -> anchored by `specs/257-governance-decision-convergence/spec.md` and `docs/product/roadmap.md`
|
||||
- `Decision Register & Approval Workflow v1` -> anchored by `specs/250-decision-governance-inbox/spec.md`, `specs/257-governance-decision-convergence/spec.md`, and `docs/product/roadmap.md`
|
||||
- `Governance Artifact Lifecycle & Retention v1` -> anchored by `specs/158-artifact-truth-semantics/spec.md`, `specs/262-lifecycle-governance-taxonomy/spec.md`, and `docs/product/standards/lifecycle-governance.md`
|
||||
- `Customer-Facing Localization Adoption v1` -> anchored by `specs/252-platform-localization-v1/spec.md`, `specs/258-customer-review-productization/spec.md`, and `specs/260-governance-service-packaging/spec.md`
|
||||
- `Billing & Subscription Truth Layer v1` -> anchored by `specs/247-plans-entitlements-billing-readiness/spec.md` and `specs/251-commercial-entitlements-billing-state/spec.md`
|
||||
- `Enterprise Access Boundary & Support Access Governance v1` -> anchored by `docs/audits/2026-03-09-enterprise-rbac-scope-audit.md`, `docs/HANDOVER.md`, `specs/065-tenant-rbac-v1/spec.md`, and `specs/066-rbac-ui-enforcement-helper/spec.md`
|
||||
- `Stored Reports Surface v1` -> anchored by `specs/153-evidence-domain-foundation/spec.md`, `specs/155-tenant-review-layer/spec.md`, `specs/260-governance-service-packaging/spec.md`, and `docs/product/implementation-ledger.md`
|
||||
- `Workspace & Tenant Closure Lifecycle v1` -> anchored by `specs/262-lifecycle-governance-taxonomy/spec.md`
|
||||
- `First Governed AI Runtime Consumer v1` -> anchored by `specs/248-private-ai-policy-foundation/spec.md`
|
||||
|
||||
@ -4,7 +4,7 @@ # Product Roadmap
|
||||
> **Last reviewed:** 2026-05-02
|
||||
> **Use for:** Current product roadmap, release themes, and prioritization context
|
||||
> **Do not use for:** Implementation truth, spec completion status, or delivery guarantees without repo verification
|
||||
> **Scoped maintenance:** 2026-05-02 repo-based roadmap drift correction and queue/backlog alignment after the audit-derived product-truth review.
|
||||
> **Scoped maintenance:** 2026-05-02 repo-based roadmap drift correction, manual-promotion backlog alignment, and enterprise-SaaS deep-research calibration against current specs, standards, and product-truth docs.
|
||||
>
|
||||
> Strategic thematic blocks and release trajectory.
|
||||
> This is the "big picture" — not individual specs.
|
||||
@ -26,19 +26,73 @@ ## Current Productization & Moat Priorities
|
||||
|
||||
This is the repo-based prioritization overlay for the next sellable lanes. The bottleneck is no longer raw backend truth alone. The next roadmap slices should make existing governance foundations customer-safe, decision-centered, auditable, and MSP-sellable before opening more backend-only islands.
|
||||
|
||||
| Order | Theme | Repo truth | Product posture | Why now | Candidate posture |
|
||||
| Order | Theme | Alignment status | Repo truth | Why now | Queue posture |
|
||||
|---|---|---|---|---|---|
|
||||
| 1 | Auditor Pack Delivery & Executive Export v1 | Review-pack export, evidence, tenant review, customer review productization, compliance mapping, and governance packaging foundations are already spec-backed and partially repo-real | fast sellable | clearest remaining step between repo-real governance truth and auditor-/executive-ready delivery | manual promotion only, not auto-prep |
|
||||
| 2 | Customer Review Workspace Productization v1 | Reviews, Evidence Snapshots, Review Packs, Customer Review Workspace, and accepted-risk foundations are repo-real | fast sellable | clearest customer-safe governance-of-record surface gap | spec-backed follow-through, not active queue |
|
||||
| 3 | Governance Decision Surface Convergence | Governance Inbox, My Findings, Intake, and Exception Queue are repo-real, but convergence remains incomplete | implemented but not productized | reduces admin-tool sprawl and turns multiple queue surfaces into calmer decision work | spec-backed follow-through, not active queue |
|
||||
| 4 | Compliance Evidence Mapping v1 | Canonical controls, evidence, stored reports, reviews, and findings foundations are repo-real; customer-safe compliance mapping follow-through remains | implemented but not productized | strong governance moat for compliance-oriented MSP and Mittelstand reviews without certification claims | spec-backed follow-through, not active queue |
|
||||
| 5 | Governance-as-a-Service Packaging v1 | Review packs, exports, evidence, and accepted-risk foundations are repo-real; recurring executive/MSP packaging follow-through remains | implemented but not productized | turns governance truth into a repeatable MSP deliverable instead of one-off manual reporting | spec-backed follow-through, not active queue |
|
||||
| 6 | Cross-Tenant Promotion Execution v1 | Cross-tenant compare preview and promotion preflight are repo-real; execution is not | not implemented | strongest MSP multiplier after review and packaging lanes are calmer | manual promotion only, not auto-prep |
|
||||
| 7 | Governance Decision Pack & Approval Workflow v1 | Decision convergence and decision-based operating framing are strong enough for a bounded human-in-the-loop slice, but the workflow itself is not implemented | not implemented | next narrow decision workflow without autonomous remediation | manual promotion only, not auto-prep |
|
||||
| 8 | Customer-Facing Localization Adoption v1 | Locale foundation is repo-real; customer-facing adoption, glossary completion, and regression hardening remain | implemented but not productized | turns existing localization groundwork into customer-safe polish | manual promotion only, not auto-prep |
|
||||
| 9 | Billing & Subscription Truth Layer v1 | Plans, entitlements, and commercial lifecycle are repo-real; billing/subscription truth is not | not implemented | closes the remaining commercial truth gap | manual promotion only, not auto-prep |
|
||||
| 10 | Stored Reports Surface v1 | Stored-report substrate is repo-real through evidence and review foundations; product surface remains incomplete | foundation-only | makes retained governance artifacts usable without manual digging | manual promotion only, not auto-prep |
|
||||
| 11 | First Governed AI Runtime Consumer v1 | Governed AI policy foundation is repo-real; no first governed runtime consumer is proven | not implemented | bounded post-foundation AI adoption after higher-priority sellability gaps | manual promotion only, not auto-prep |
|
||||
| 1 | Customer Review Workspace Productization v1 | repo-verified, productization gap | Customer-safe review consumption is repo-real through Specs 249, 258, 259, 260, and 263, but the calm sellable surface still needs final productization discipline | clearest sellability lever for Governance-of-Record without creating a parallel customer portal | spec-backed follow-through |
|
||||
| 2 | Decision-Based Governance Inbox + Decision Register v1 | repo-verified, productization gap, roadmap recommendation | Governance inbox, findings queues, alerts, review follow-up, and Specs 250/257 already anchor the decision surface; a bounded decision-register and approval follow-through is still open | biggest remaining operator workflow gap and the cleanest defense against admin-tool sprawl | manual promotion only for the decision-register follow-through |
|
||||
| 3 | Governance Artifact Lifecycle & Retention v1 | foundation-only, roadmap recommendation, spec candidate | Spec 262 and the lifecycle-governance standard provide taxonomy-first guardrails, but governance-artifact runtime semantics are not yet productized | new trust, auditability, export, and retention lever for evidence snapshots, stored reports, review packs, and decision records | manual promotion only |
|
||||
| 4 | Commercial Entitlements & Billing-State Lifecycle v1 | repo-verified, foundation-only, productization gap | Specs 247 and 251 already resolve plan and lifecycle posture, but broader commercial-state-to-artifact-access rules and later billing truth remain open | SaaS trust and lifecycle maturity matter before broader packaging, scale, or AI work | spec-backed follow-through plus narrower billing follow-through |
|
||||
| 5 | External Support Desk / PSA Handoff v1 | repo-verified, productization gap | Spec 256 and the current bounded handoff service already exist, but the portfolio-safe handoff story is still not fully productized | MSP integration should compress follow-through work without turning TenantPilot into a helpdesk | spec-backed follow-through |
|
||||
| 6 | Customer-Facing Localization v1 | foundation-only, productization gap | Spec 252 and current locale resolution are repo-real; glossary completion and customer-facing adoption remain incomplete | customer-safe DACH/EU review consumption should start at glossary, labels, packs, and notifications rather than full operator-UI translation | manual promotion only |
|
||||
| 7 | Cross-Tenant Compare & Promotion with Lineage v1 | repo-verified, productization gap | Spec 043 is repo-real for compare and preflight, and Spec 264 now carries the bounded execution follow-through on this branch | portfolio action matters, but it must stay governance-first with lineage, evidence, approval, and rollback references rather than raw push mechanics | spec-backed follow-through |
|
||||
| 8 | Governance Service Packaging v1 | repo-verified, productization gap | Spec 260 and current governance-package delivery cues are repo-real, but recurring service packaging remains calmer in documentation than in repeatable delivery workflows | MSP value comes from repeatable services and customer-safe consumption, not just more admin pages | spec-backed follow-through |
|
||||
| 9 | Enterprise Access Boundary & Support Access Governance v1 | roadmap recommendation, spec candidate | Break-glass and system-access seams exist in audits and handover docs, but no bounded support-access request, TTL, approval, and customer-visible audit package exists | tighten support access before broad SSO/SCIM expansion; keep SCIM and group-provisioning later | manual promotion only |
|
||||
| 10 | Advanced APIs / Webhooks | later scale-layer, not-now | no bounded repo-ready package yet | useful integration layer, but it trails review, decision, artifact, and commercial clarity | not-now |
|
||||
| 11 | Private AI Execution Governance Foundation v1 / governed runtime follow-through | repo-verified, foundation-only, later scale-layer | Spec 248 is implemented as a governed foundation; visible runtime consumers and broader budget/result governance are still deferred | AI should remain governed foundation-first and provider-auditable before any visible feature island ships | manual promotion only for runtime follow-through |
|
||||
| 12 | AI-assisted Review Summaries / Translation / Next Action Drafting | roadmap recommendation, later scale-layer, not-now | depends on governed AI, review truth, and customer-safe localization/productization | later visible AI lane after review, decision, artifact, and commercial maturity | not-now |
|
||||
|
||||
## Deep-Research Roadmap Alignment
|
||||
|
||||
This section is a deep-research-derived calibration layer. It sharpens roadmap language against current repo truth without reopening already-promoted specs or overstating sellability from foundations alone.
|
||||
|
||||
### Confirmed priorities
|
||||
|
||||
- Deep-Research-derived: Customer Review Workspace remains the primary sellability gap, but the repo already contains the foundational and productization specs. The roadmap priority is calmer customer-safe review consumption, not a second portal or a parallel reporting stack.
|
||||
- Deep-Research-derived: Decision-centered operating remains the primary operator workflow gap. The repo already has governance inbox and convergence anchors, so the remaining roadmap work should narrow toward decision-register, ownership, closure, and approval semantics instead of launching more isolated admin surfaces.
|
||||
- Deep-Research-derived: PSA/ITSM remains an integration lane, not a product-redefinition. The correct posture is handoff, reference continuity, and auditability rather than a TenantPilot-native helpdesk.
|
||||
|
||||
### Newly elevated gaps
|
||||
|
||||
- Deep-Research-derived: Governance Artifact Lifecycle & Retention v1 should be elevated into the now lane. Current repo truth covers lifecycle taxonomy, review-pack retention, and artifact-truth semantics in pieces, but not a unified governance-artifact lifecycle contract.
|
||||
- Roadmap Recommendation: Enterprise Access Boundary & Support Access Governance v1 should exist as a narrow early access-governance slice built around support access request, reason, TTL, approval, banner, and exportable audit trail. Broad workspace SSO/OIDC/SCIM remains later.
|
||||
|
||||
### Reordered priorities
|
||||
|
||||
- Deep-Research-derived: Commercial lifecycle moves up. The commercial lane should be framed as SaaS trust, workspace read-only behavior, artifact access, and lifecycle semantics, not as a future billing engine.
|
||||
- Deep-Research-derived: Cross-tenant compare and promotion remains important, but the roadmap should talk about lineage, approval, evidence, rollback references, and decision linkage before it talks about settings push.
|
||||
- Deep-Research-derived: Auditor-ready delivery and broader governance packaging stay valuable, but they should follow calmer review consumption, decision routing, artifact lifecycle clarity, and commercial-state truth.
|
||||
|
||||
### Deferred / not-now themes
|
||||
|
||||
- Roadmap Recommendation: full operator-UI localization is not the v1 localization target; customer-facing glossary, review, pack, and notification surfaces come first.
|
||||
- Roadmap Recommendation: broad workspace SSO/OIDC, SCIM, group-to-capability mapping, and automated provisioning stay out of P0 unless support-access risk turns acute.
|
||||
- Roadmap Recommendation: advanced APIs/webhooks, visible AI runtime consumers, and AI-assisted drafting stay later scale layers.
|
||||
|
||||
### Productization vs. Foundation distinction
|
||||
|
||||
- Repo-verified foundations do not automatically mean sellable or customer-safe product slices.
|
||||
- Specs 248, 249, 250, 251, 252, 256, 258, 260, 262, 263, and current-branch 264 prove real foundations or prepared follow-through, but the roadmap should still distinguish `foundation-only` from `productization gap`.
|
||||
- Stored reports, localization, commercial lifecycle, governed AI, and governance packaging all already have some repo truth. The open work is mainly calmer consumption, lifecycle semantics, and repeatable product delivery.
|
||||
|
||||
### Risks of admin-tool sprawl
|
||||
|
||||
- Deep-Research-derived: TenantPilot loses focus when every new operator concern gets its own top-level page instead of feeding review, decision, evidence, and governance-package flows.
|
||||
- Deep-Research-derived: More admin surfaces do not close the core gap. Decision records, accepted-risk visibility, evidence lifecycle, customer-safe review consumption, and portfolio-safe workflow continuity do.
|
||||
- Roadmap Recommendation: prefer decision-first routing, diagnostics-second disclosure, and evidence-third drilldown over raw technical dashboards or isolated remediation consoles.
|
||||
|
||||
## Deep-Research Anti-Patterns
|
||||
|
||||
Do not prioritize these themes ahead of the aligned now and next lanes.
|
||||
|
||||
- anti-pattern: generic M365 admin mirror
|
||||
- anti-pattern: generic helpdesk or PSA replacement
|
||||
- anti-pattern: device-action tooling without governance context
|
||||
- anti-pattern: generic automation builder
|
||||
- anti-pattern: raw technical dashboards as the primary product surface
|
||||
- anti-pattern: AI copilot islands without AI governance
|
||||
- anti-pattern: broad multi-cloud expansion before Microsoft governance is productized
|
||||
- anti-pattern: eDiscovery or broad GRC-suite clone
|
||||
- anti-pattern: a new top-level page for every technical state or exception
|
||||
|
||||
Explicit anti-sprawl boundaries for this priority set:
|
||||
|
||||
@ -393,11 +447,13 @@ ## Infrastructure & Platform Debt
|
||||
|------|------|--------|
|
||||
| No explicit company automation roadmap linkage | Risk that sales, support, billing, legal, and customer communication become founder-only manual work | Covered by Solo-Founder SaaS Automation & Operating Readiness |
|
||||
| No shared lifecycle taxonomy for workspace, tenant, managed-object, retention, export, purge, and restoreability states | Local fixes such as ghost-policy handling, workspace deactivation, tenant removal, retention, or purge can create inconsistent deletion semantics and audit gaps | Covered by Workspace, Tenant & Managed Object Lifecycle Governance candidate |
|
||||
| Governance-artifact lifecycle runtime is still missing | Lifecycle taxonomy and point retention rules exist, but governance artifacts still lack one runtime contract for immutable identity, hold, export, delete, and suspended/read-only behavior | Covered by Governance Artifact Lifecycle & Retention v1 |
|
||||
| No structured support diagnostic bundle yet | Support cases require manual context gathering across tenants, runs, findings, providers, and reports | Covered by Product Scalability & Self-Service Foundation |
|
||||
| No bounded support-access governance package yet | Break-glass, system access, and future impersonation/support access could drift without customer-visible TTL, reason, approval, and export semantics | Covered by Enterprise Access Boundary & Support Access Governance v1 |
|
||||
| No formal security trust pack yet | Enterprise sales and customer security reviews require repeated manual explanations | Covered by Solo-Founder SaaS Automation & Operating Readiness |
|
||||
| Auditor-ready executive export is not yet productized | Review truth still stops short of auditor-/executive-ready delivery without additional packaging | Covered by the manual-promotion backlog in `docs/product/spec-candidates.md` |
|
||||
| Cross-tenant promotion execution is missing | Compare preview and preflight stop short of the actual portfolio action | Covered by the manual-promotion backlog in `docs/product/spec-candidates.md` |
|
||||
| Governance decision pack and approval workflow is missing | Decision-based operating still lacks a bounded approval-ready action package with explicit audit trail | Covered by the manual-promotion backlog in `docs/product/spec-candidates.md` |
|
||||
| Auditor-ready executive export is not yet productized | Review truth still stops short of calm auditor-/executive-ready delivery even though the spec package now exists | Covered by `specs/263-auditor-pack-executive-export/spec.md` |
|
||||
| Cross-tenant promotion execution is missing | Compare preview and preflight stop short of the actual portfolio action even though the execution spec package now exists on this branch | Covered by `specs/264-cross-tenant-promotion-execution/spec.md` |
|
||||
| Governance decision register and approval workflow is missing | Decision-based operating still lacks a bounded approval-ready closure and decision-record package with explicit audit trail | Covered by the manual-promotion backlog in `docs/product/spec-candidates.md` |
|
||||
| Customer-facing localization adoption is incomplete | Repo-real locale groundwork is not yet fully productized across customer-safe governance surfaces | Covered by the manual-promotion backlog in `docs/product/spec-candidates.md` |
|
||||
| Billing and subscription truth is missing | Commercial readiness still stops short of a durable billing/subscription truth layer | Covered by the manual-promotion backlog in `docs/product/spec-candidates.md` |
|
||||
| Stored reports still lack a clear product surface | Retained evidence and review artifacts remain harder to consume than they should be | Covered by the manual-promotion backlog in `docs/product/spec-candidates.md` |
|
||||
@ -417,11 +473,13 @@ ## Infrastructure & Platform Debt
|
||||
|
||||
## Priority Ranking (Current Manual Promotion Order)
|
||||
|
||||
1. Auditor Pack Delivery & Executive Export v1
|
||||
2. Cross-Tenant Promotion Execution v1
|
||||
3. Governance Decision Pack & Approval Workflow v1
|
||||
This ranking applies only to still-unspecced or still-manual follow-through items. Auditor-ready delivery and cross-tenant promotion execution already have spec packages and therefore no longer belong in the manual-promotion ordering list.
|
||||
|
||||
1. Decision Register & Approval Workflow v1
|
||||
2. Governance Artifact Lifecycle & Retention v1
|
||||
3. Billing & Subscription Truth Layer v1
|
||||
4. Customer-Facing Localization Adoption v1
|
||||
5. Billing & Subscription Truth Layer v1
|
||||
5. Enterprise Access Boundary & Support Access Governance v1
|
||||
6. Stored Reports Surface v1
|
||||
7. Workspace & Tenant Closure Lifecycle v1
|
||||
8. First Governed AI Runtime Consumer v1
|
||||
|
||||
@ -1,10 +1,10 @@
|
||||
# Spec Candidates
|
||||
|
||||
> **Status:** Active
|
||||
> **Last reviewed:** 2026-05-01
|
||||
> **Last reviewed:** 2026-05-02
|
||||
> **Use for:** The active repo-based queue of spec candidates that may still need new or refreshed specs
|
||||
> **Do not use for:** Proof that a candidate is already specced, implemented, or prioritized above the roadmap without repo verification
|
||||
> **Scoped maintenance:** 2026-05-01 repo-based queue re-audit against current `specs/` truth, including refreshed Spec 043 and Specs 251-260; stale active candidates were cleared and no new candidate was promoted.
|
||||
> **Scoped maintenance:** 2026-05-02 repo-based queue re-audit plus enterprise-SaaS deep-research alignment against current `specs/` truth, including Specs 263 and current-branch 264.
|
||||
>
|
||||
> Repo-based next-spec queue for TenantPilot.
|
||||
> This file is not a wishlist. It tracks only open gaps that are still worth turning into new or refreshed specs.
|
||||
@ -31,6 +31,101 @@ ## Current Source-Of-Truth Boundary
|
||||
- `discoveries.md` is a staging area for findings that may later be promoted here.
|
||||
- `implementation-ledger.md` is maturity evidence, not a prioritization queue.
|
||||
- Audit-derived candidate packages under `docs/audits/` are historical inputs only unless they are explicitly promoted into this file.
|
||||
- The deep-research alignment reference below sharpens naming, status markers, and target sequencing without reopening already-promoted specs as active queue items.
|
||||
|
||||
## Deep-Research Alignment Reference (non-queue)
|
||||
|
||||
This section is a deep-research-derived calibration layer. It is intentionally not the active queue. Use it to keep candidate naming, scope, and status language aligned with current repo truth.
|
||||
|
||||
### Customer Review Workspace v1
|
||||
|
||||
- **Status markers**: repo-verified, productization gap
|
||||
- **Roadmap lane**: Now
|
||||
- **Current repo truth**: Specs 249 and 258, plus the implementation ledger and current review surfaces, already prove the foundational and productization path for customer-safe review consumption.
|
||||
- **Problem**: Customer-safe review consumption remains the clearest sellability gap whenever review truth, accepted risks, evidence, and package delivery still require operator translation.
|
||||
- **Deep-Research-derived sharpening**: Keep the lane focused on one customer-safe read-only review surface, findings summary, accepted-risk visibility, evidence viewer, review-pack download, management summary, RBAC/capability enforcement, and audit trail.
|
||||
- **Non-goals**: no generic customer portal, no helpdesk surface, no raw diagnostics by default, no admin mirror.
|
||||
|
||||
### Decision-Based Governance Inbox + Decision Register v1
|
||||
|
||||
- **Status markers**: repo-verified, productization gap, roadmap recommendation
|
||||
- **Roadmap lane**: Now
|
||||
- **Current repo truth**: governance inbox, findings queues, operations attention, review follow-up, and Specs 250 and 257 already anchor the decision surface, but there is still no bounded decision-register follow-through.
|
||||
- **Problem**: Findings, alerts, runs, and reviews still need one calmer decision layer with ownership, due state, reason, impact, next action, linked evidence, linked `OperationRun`, accepted-risk path, closure reason, and escalation hooks.
|
||||
- **Deep-Research-derived sharpening**: treat the missing slice as decision-register and approval/closure follow-through over current inbox foundations, not as another queue page.
|
||||
- **Non-goals**: no generic Kanban board, no PSA clone, no XDR incident console.
|
||||
|
||||
### Governance Artifact Lifecycle & Retention v1
|
||||
|
||||
- **Status markers**: foundation-only, roadmap recommendation, spec candidate
|
||||
- **Roadmap lane**: Now
|
||||
- **Current repo truth**: Spec 262, the lifecycle-governance standard, review-pack retention, and artifact-truth semantics provide taxonomy-first foundations, but not a productized governance-artifact lifecycle.
|
||||
- **Problem**: Evidence snapshots, stored reports, review packs, and future decision records are governance artifacts and must not be treated like short-lived operational rows.
|
||||
- **Roadmap Recommendation**: v1 should cover artifact-type registry, immutable artifact reference, artifact state, retention state, export bundle, preserve or hold state, soft delete or hard delete semantics, suspended/read-only workspace behavior, and audit trail for export/delete/hold.
|
||||
- **Non-goals**: no legal case management, no full eDiscovery system, no Purview clone.
|
||||
|
||||
### Commercial Entitlements & Billing-State Lifecycle v1
|
||||
|
||||
- **Status markers**: repo-verified, foundation-only, productization gap
|
||||
- **Roadmap lane**: Now
|
||||
- **Current repo truth**: Specs 247 and 251 already ground workspace entitlements, lifecycle state handling, and read-only gating.
|
||||
- **Problem**: Workspace, plan, and billing state still need clearer artifact-access, archive, scheduled-deletion, and customer-trust semantics.
|
||||
- **Deep-Research-derived sharpening**: keep the lane framed as commercial lifecycle, workspace read-only behavior, artifact access by state, capability gating, and audited state change semantics rather than payment-engine work.
|
||||
- **Non-goals**: no payment gateway, no invoicing engine, no tax engine.
|
||||
|
||||
### External Support Desk / PSA Handoff v1
|
||||
|
||||
- **Status markers**: repo-verified, productization gap
|
||||
- **Roadmap lane**: Next
|
||||
- **Current repo truth**: Spec 256 and the current support-request handoff already prove a bounded create/link model.
|
||||
- **Problem**: MSP governance work still needs cleaner PSA/ITSM handoff with external reference continuity, evidence/context transfer, due date/status mapping, closure sync or manual reconciliation, audit trail, and webhook-ready shape.
|
||||
- **Deep-Research-derived sharpening**: keep PSA/ITSM as integration and handoff, not as a TenantPilot-native helpdesk.
|
||||
- **Non-goals**: no PSA clone, no project-management suite, no generic helpdesk.
|
||||
|
||||
### Customer-Facing Localization v1
|
||||
|
||||
- **Status markers**: foundation-only, productization gap
|
||||
- **Roadmap lane**: Next
|
||||
- **Current repo truth**: Spec 252 and the locale resolver already provide the foundation.
|
||||
- **Problem**: DE/EN customer-facing review consumption still lacks full glossary discipline, customer-safe labels, review-pack templates, notification text, and fallback confidence.
|
||||
- **Deep-Research-derived sharpening**: v1 means glossary, review-workspace strings, review-pack templates, evidence labels, status/reason/impact/next-action labels, locale-aware formatting, fallback behavior, and missing-key tests.
|
||||
- **Non-goals**: no full operator-UI localization in v1, no marketing translation project, no uncontrolled string extraction.
|
||||
|
||||
### Cross-Tenant Compare & Promotion with Lineage v1
|
||||
|
||||
- **Status markers**: repo-verified, productization gap
|
||||
- **Roadmap lane**: Next
|
||||
- **Current repo truth**: Spec 043 is already repo-real for compare and preflight, and Spec 264 now carries the execution follow-through on this branch.
|
||||
- **Problem**: portfolio action still needs governance-first execution with impact preview, promotion proposal, approval, `OperationRun` trace, before/after evidence, baseline lineage, rollback reference, and decision-record linkage.
|
||||
- **Deep-Research-derived sharpening**: keep this lane governance-first. It is not a generic policy-push or settings-sync surface.
|
||||
- **Non-goals**: no unmanaged mass remediation, no generic settings push, no admin mirror.
|
||||
|
||||
### Governance Service Packaging v1
|
||||
|
||||
- **Status markers**: repo-verified, productization gap
|
||||
- **Roadmap lane**: Next
|
||||
- **Current repo truth**: Spec 260 and current governance-package delivery cues already exist.
|
||||
- **Problem**: MSPs need repeatable governance-service packages with review cadence, included controls/reports, stakeholder mapping, schedule, package-specific review-pack semantics, and entitlement binding.
|
||||
- **Deep-Research-derived sharpening**: productize packaging as a repeatable governance service, not as one-off executive exports or bespoke customer projects.
|
||||
- **Non-goals**: no CRM, no PSA, no billing system.
|
||||
|
||||
### Enterprise Access Boundary & Support Access Governance v1
|
||||
|
||||
- **Status markers**: roadmap recommendation, spec candidate
|
||||
- **Roadmap lane**: Next when support-access gaps turn operationally acute; otherwise Later
|
||||
- **Current repo truth**: audit docs and handover material already show break-glass, system access, and platform support seams, but not a bounded support-access governance package.
|
||||
- **Problem**: support access, delegated access, and future impersonation need customer-safe auditability, reason capture, TTL, approval, operator-context banner, and exportable access logs before broader SSO/SCIM work.
|
||||
- **Roadmap Recommendation**: prioritize support-access request, reason required, time-limited access, capability-bound access, customer-visible audit trail, optional approval, break-glass separation, operator context banner, and exportable access log.
|
||||
- **Non-goals**: no full IAM suite, no immediate SCIM requirement unless separately promoted, no unrestricted impersonation.
|
||||
|
||||
### Private AI Execution Governance Foundation v1
|
||||
|
||||
- **Status markers**: repo-verified, foundation-only, later scale-layer
|
||||
- **Roadmap lane**: Later
|
||||
- **Current repo truth**: Spec 248 is already implemented as a governed AI foundation.
|
||||
- **Problem**: visible AI work must still avoid feature-island drift and should only ship on top of governed use-case, provider-class, policy, audit, and approval boundaries.
|
||||
- **Deep-Research-derived sharpening**: keep AI foundation-first. The next visible candidate is a bounded governed runtime consumer or AI-assisted review-drafting lane, not a new foundation reboot.
|
||||
- **Non-goals**: no public LLM by default, no autonomous remediation, no chatbot over raw tenant data, no AI without audit and policy boundaries.
|
||||
|
||||
## Active Candidate Queue
|
||||
|
||||
@ -51,45 +146,49 @@ ## Active Candidate Queue
|
||||
|
||||
These packages already provide the needed preparation surface, and several now carry completed task checklists or implementation close-out history. They must not be auto-selected again by `next-best-prep`.
|
||||
|
||||
Two manual-promotion items have since moved out of backlog status on the current repo state:
|
||||
|
||||
- `Auditor Pack Delivery & Executive Export v1` -> Spec 263
|
||||
- `Cross-Tenant Promotion Execution v1` -> Spec 264
|
||||
|
||||
The historical record for `Workspace, Tenant & Managed Object Lifecycle Governance v1` remains below because it was promoted intentionally, not automatically, into Spec 262 and must not re-enter the active auto-prep queue.
|
||||
|
||||
## Promotable Candidate Backlog
|
||||
|
||||
**Boundary**: manual promotion only, not auto-prep. These items are intentionally outside `next-best-prep` and require an explicit product decision before any future spec refresh or follow-up work.
|
||||
|
||||
### Auditor Pack Delivery & Executive Export v1
|
||||
### Decision Register & Approval Workflow v1
|
||||
|
||||
- **Priority**: 1
|
||||
- **Repo truth**: review-pack export, evidence, tenant review, customer review productization, compliance mapping, and governance packaging foundations are already spec-backed.
|
||||
- **Why promotable now**: this is the clearest remaining step between repo-real governance truth and auditor-/executive-ready delivery.
|
||||
- **Why manual promotion only**: this is a bounded packaging and export decision, not an automatic next-best-prep foundation gap.
|
||||
- **Anchors**:
|
||||
- `specs/109-review-pack-export/spec.md`
|
||||
- `specs/153-evidence-domain-foundation/spec.md`
|
||||
- `specs/155-tenant-review-layer/spec.md`
|
||||
- `specs/258-customer-review-productization/spec.md`
|
||||
- `specs/259-compliance-evidence-mapping/spec.md`
|
||||
- `specs/260-governance-service-packaging/spec.md`
|
||||
|
||||
### Cross-Tenant Promotion Execution v1
|
||||
|
||||
- **Priority**: 2
|
||||
- **Repo truth**: cross-tenant compare preview and promotion preflight are already prepared, but execution is still the missing product action.
|
||||
- **Why promotable now**: this is the next MSP-multiplier after customer-safe review and packaging follow-through.
|
||||
- **Why manual promotion only**: the repo already has the compare/preflight preparation package, so only the narrower execution slice should be promoted deliberately.
|
||||
- **Anchors**:
|
||||
- `specs/043-cross-tenant-compare-and-promotion/spec.md`
|
||||
|
||||
### Governance Decision Pack & Approval Workflow v1
|
||||
|
||||
- **Priority**: 3
|
||||
- **Repo truth**: governance convergence is spec-backed, but the bounded human-in-the-loop decision-pack workflow is still a distinct follow-up gap.
|
||||
- **Why promotable now**: it is the next decision-based operating slice after convergence, without expanding into autonomous remediation.
|
||||
- **Why manual promotion only**: this must stay an explicit product choice because the v1 scope is intentionally narrow: decision pack, reason, impact, evidence, recommended action, approve, reject, snooze, assign, audit trail, optional OperationRun link, no autonomous remediation in v1.
|
||||
- **Repo truth**: governance inbox and convergence are spec-backed and partly repo-real, but the bounded decision-register and approval workflow is still a distinct follow-up gap.
|
||||
- **Why promotable now**: it is the highest-value unspecced operator follow-through once inbox and review consumption are already grounded.
|
||||
- **Why manual promotion only**: this must stay a deliberate product choice because v1 should remain narrow: owner, due date, status, reason, impact, next action, linked evidence, linked `OperationRun`, accepted-risk path, closure reason, escalation hook, and optional approval or closure semantics without autonomous remediation.
|
||||
- **Anchors**:
|
||||
- `specs/250-decision-governance-inbox/spec.md`
|
||||
- `specs/257-governance-decision-convergence/spec.md`
|
||||
- `docs/product/roadmap.md`
|
||||
|
||||
### Governance Artifact Lifecycle & Retention v1
|
||||
|
||||
- **Priority**: 2
|
||||
- **Repo truth**: lifecycle taxonomy, artifact-truth semantics, and point retention rules already exist, but governance artifacts still lack one productized lifecycle runtime contract.
|
||||
- **Why promotable now**: this is the clearest new trust and auditability gap highlighted by the deep research.
|
||||
- **Why manual promotion only**: it crosses lifecycle, artifact, export, retention, and suspension semantics and therefore needs an explicit product boundary rather than automatic prep.
|
||||
- **Anchors**:
|
||||
- `specs/158-artifact-truth-semantics/spec.md`
|
||||
- `specs/262-lifecycle-governance-taxonomy/spec.md`
|
||||
- `docs/product/standards/lifecycle-governance.md`
|
||||
|
||||
### Billing & Subscription Truth Layer v1
|
||||
|
||||
- **Priority**: 3
|
||||
- **Repo truth**: plans, entitlements, and commercial lifecycle maturity are already spec-backed, but the billing/subscription truth layer is still missing.
|
||||
- **Why promotable now**: deep-research alignment moves commercial trust and lifecycle closer to the now lane, even though the remaining unspecced slice is still the narrower billing/subscription follow-through.
|
||||
- **Why manual promotion only**: the broad readiness work is already covered, so this should not reappear as an automatic foundation candidate.
|
||||
- **Anchors**:
|
||||
- `specs/247-plans-entitlements-billing-readiness/spec.md`
|
||||
- `specs/251-commercial-entitlements-billing-state/spec.md`
|
||||
|
||||
### Customer-Facing Localization Adoption v1
|
||||
|
||||
- **Priority**: 4
|
||||
@ -101,15 +200,17 @@ ### Customer-Facing Localization Adoption v1
|
||||
- `specs/258-customer-review-productization/spec.md`
|
||||
- `specs/260-governance-service-packaging/spec.md`
|
||||
|
||||
### Billing & Subscription Truth Layer v1
|
||||
### Enterprise Access Boundary & Support Access Governance v1
|
||||
|
||||
- **Priority**: 5
|
||||
- **Repo truth**: plans, entitlements, and commercial lifecycle maturity are already spec-backed, but the billing/subscription truth layer is still missing.
|
||||
- **Why promotable now**: this is the remaining commercial truth gap after entitlements and lifecycle groundwork.
|
||||
- **Why manual promotion only**: the broad readiness work is already covered, so this should not be reintroduced as an automatic foundation candidate.
|
||||
- **Repo truth**: break-glass and platform access seams are documented, but no bounded support-access governance package currently exists.
|
||||
- **Why promotable now**: this is the narrow early access-governance slice that should happen before broad SSO/SCIM ambitions if support access and customer-visible auditability become pressing.
|
||||
- **Why manual promotion only**: the right cut is product-sensitive and must stay tightly bounded around support access, delegated access, TTL, approval, audit trail, and operator-context visibility.
|
||||
- **Anchors**:
|
||||
- `specs/247-plans-entitlements-billing-readiness/spec.md`
|
||||
- `specs/251-commercial-entitlements-billing-state/spec.md`
|
||||
- `docs/audits/2026-03-09-enterprise-rbac-scope-audit.md`
|
||||
- `docs/HANDOVER.md`
|
||||
- `specs/065-tenant-rbac-v1/spec.md`
|
||||
- `specs/066-rbac-ui-enforcement-helper/spec.md`
|
||||
|
||||
### Stored Reports Surface v1
|
||||
|
||||
@ -230,6 +331,8 @@ ## Promoted to Spec
|
||||
- Governance-as-a-Service Packaging v1 -> Spec 260 (`governance-service-packaging`)
|
||||
- Provider-Missing Policy Visibility & Restore Continuity v1 -> Spec 261 (`provider-missing-policy-visibility`)
|
||||
- Workspace, Tenant & Managed Object Lifecycle Governance v1 -> Spec 262 (`lifecycle-governance-taxonomy`)
|
||||
- Auditor Pack Delivery & Executive Export v1 -> Spec 263 (`auditor-pack-executive-export`)
|
||||
- Cross-Tenant Promotion Execution v1 -> Spec 264 (`cross-tenant-promotion-execution`)
|
||||
- Queued Execution Reauthorization and Scope Continuity -> Spec 149 (`queued-execution-reauthorization`)
|
||||
- Livewire Context Locking and Trusted-State Reduction -> Spec 152 (`livewire-context-locking`)
|
||||
- Evidence Domain Foundation -> Spec 153 (`evidence-domain-foundation`)
|
||||
|
||||
@ -0,0 +1,52 @@
|
||||
# Specification Quality Checklist: Auditor Pack Delivery & Executive Export 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 current `ReviewPack` family instead of inventing a second package domain.
|
||||
- [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: review-pack export, customer review workspace, compliance interpretation, and governance-package delivery.
|
||||
- [x] Mandatory repo sections for scope, RBAC, disclosure, testing, and proportionality are completed.
|
||||
|
||||
## Requirement Completeness
|
||||
|
||||
- [x] No `[NEEDS CLARIFICATION]` markers remain.
|
||||
- [x] Requirements are testable and bounded to one released-review delivery contract.
|
||||
- [x] The spec explains what remains in scope versus what is intentionally deferred.
|
||||
- [x] Acceptance scenarios cover export initiation, read-only delivery, and tenant-safe audit or entitlement behavior.
|
||||
- [x] Edge cases cover missing packs, expired packs, partial evidence, and blocked commercial lifecycle states.
|
||||
|
||||
## Candidate Selection Gate
|
||||
|
||||
- [x] The selected candidate exists in `docs/product/spec-candidates.md` and `docs/product/roadmap.md`.
|
||||
- [x] No existing spec package already covers `Auditor Pack Delivery & Executive Export v1` as its own bounded slice.
|
||||
- [x] Related anchor specs were checked for completion or close-out signals and treated as context only: `109`, `153`, `155`, `258`, `259`, and `260`, with current repo code and tests used as implementation-truth validation for the delta.
|
||||
- [x] The chosen slice is smaller and higher-priority than the deferred alternatives from the manual-promotion backlog.
|
||||
|
||||
## Feature Readiness
|
||||
|
||||
- [x] The slice is an explicit delta follow-up over already prepared customer-safe review/package work and stays on one current `ReviewPack` bundle instead of reopening those inherited surfaces wholesale.
|
||||
- [x] The spec explicitly reuses the current `ReviewPackGenerate` and signed download seams.
|
||||
- [x] The spec forbids new panel/provider changes, new global-search scope, and new asset strategy.
|
||||
- [x] The spec keeps one dominant action per current operator/customer surface and does not reopen broader packaging, branding, or recurring-delivery scope.
|
||||
|
||||
## Test Governance
|
||||
|
||||
- [x] Planned validation stays bounded to existing `TenantReview`, `Reviews`, and `ReviewPack` feature families plus the current customer-review browser smoke.
|
||||
- [x] No new heavy-governance or new browser family is introduced.
|
||||
- [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/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`, current review/review-pack code and tests under `apps/platform`, 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 highest-priority manual backlog item, now records itself as a delta follow-up over the already prepared review/package foundations, stays on one current artifact family, and narrows the sellability gap to one externally deliverable bundle.
|
||||
- **Workflow result**: Ready for implementation.
|
||||
215
specs/263-auditor-pack-executive-export/plan.md
Normal file
215
specs/263-auditor-pack-executive-export/plan.md
Normal file
@ -0,0 +1,215 @@
|
||||
# Implementation Plan: Auditor Pack Delivery & Executive Export v1
|
||||
|
||||
**Branch**: `263-auditor-pack-executive-export` | **Date**: 2026-05-02 | **Spec**: [spec.md](./spec.md)
|
||||
**Input**: Feature specification from `/specs/263-auditor-pack-executive-export/spec.md`
|
||||
|
||||
## Summary
|
||||
|
||||
This is an explicit delta follow-up over Specs 258-260 and the current review-package code path. The existing customer-safe workspace/detail delivery semantics, current operator export initiation, current signed download route, and current governance-package availability states are inherited. The implementation scope is only to make the existing current bundle externally deliverable by adding one human-readable executive entrypoint inside that bundle, making appendix roles explicit, and applying the minimal wording changes needed to explain that new bundle contract. The implementation must stay on current released-review, review-pack, evidence, and interpretation truth with no new artifact family, no new panel, and no new recurring-delivery workflow.
|
||||
|
||||
## Inherited Baseline / Explicit Delta
|
||||
|
||||
### Inherited baseline
|
||||
|
||||
- `CustomerReviewWorkspace` already owns the calm workspace delivery-selection surface.
|
||||
- released-review detail already owns the customer-safe governance-package summary and signed download action.
|
||||
- published operator review detail already owns `export_executive_pack` and current `ReviewPackGenerate` initiation.
|
||||
- the current review-derived ZIP baseline already contains `metadata.json`, `summary.json`, and `sections.json`.
|
||||
|
||||
### Explicit delta in this plan
|
||||
|
||||
- preserve the existing ZIP baseline entries and add one executive-first entrypoint file
|
||||
- extend delivery metadata so the preserved ZIP entries are explicitly framed as the structured appendix
|
||||
- update only the wording on the inherited workspace/detail surfaces that is required to describe the new bundle contract
|
||||
|
||||
## Technical Context
|
||||
|
||||
**Language/Version**: PHP 8.4, Laravel 12, Filament v5, Livewire v4
|
||||
**Primary Dependencies**: Filament admin surfaces, current `ReviewPackService`, `GenerateReviewPackJob`, `ReviewPackDownloadController`, `TenantReviewComposer`, `ArtifactTruthPresenter`
|
||||
**Storage**: PostgreSQL plus private `exports` disk for the existing `ReviewPack` ZIP artifact
|
||||
**Testing**: Pest feature tests plus one bounded browser smoke
|
||||
**Validation Lanes**: `confidence`, `browser`
|
||||
**Target Platform**: Existing Laravel admin runtime under `apps/platform`
|
||||
**Project Type**: Laravel monolith with Filament admin surfaces
|
||||
**Performance Goals**: No new provider calls, no second generation flow, and no additional queue family beyond the current `ReviewPackGenerate` run
|
||||
**Constraints**: No new persisted delivery domain, no new panel/provider/assets, no raw internal diagnostics in the executive entrypoint, preserve current `404`/`403` semantics, preserve current signed download path
|
||||
**Scale/Scope**: One released review at a time, one current export bundle per released review
|
||||
|
||||
## UI / Surface Guardrail Plan
|
||||
|
||||
- **Guardrail scope**: changed surfaces
|
||||
- **Native vs custom classification summary**: native Filament surfaces plus one bounded export entrypoint rendered from existing product truth
|
||||
- **Shared-family relevance**: review-pack delivery, governance-package wording, detail-summary disclosure, download actions
|
||||
- **State layers in scope**: page, detail, disclosure state
|
||||
- **Audience modes in scope**: operator-MSP, customer-admin, customer-read-only, auditor-read-only
|
||||
- **Decision/diagnostic/raw hierarchy plan**: decision-first delivery readiness and executive summary first, diagnostics second, raw/support detail last
|
||||
- **Raw/support gating plan**: raw provider payloads, fingerprints, and internal reason semantics stay hidden from the executive entrypoint and customer-safe default surfaces
|
||||
- **One-primary-action / duplicate-truth control**: workspace rows keep `Open review` only; released-review detail keeps one dominant safe download action; operator detail keeps the existing export initiation action
|
||||
- **Handling modes by drift class or surface**: `report-only` for unchanged operator-only pack detail surfaces; `review-mandatory` for any change that would add a second delivery action or a second package domain
|
||||
- **Repository-signal treatment**: `review-mandatory`
|
||||
- **Special surface test profiles**: `shared-detail-family`
|
||||
- **Required tests or manual smoke**: focused feature coverage plus the existing bounded browser smoke for `CustomerReviewWorkspace`
|
||||
- **Exception path and spread control**: none; any proposal for a new artifact family, new panel, or PDF engine 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**: `CustomerReviewWorkspace`, `TenantReviewResource`, `ViewTenantReview`, `ReviewPackService`, `GenerateReviewPackJob`, `ReviewPackDownloadController`, `TenantReviewComposer`, `TenantReviewSectionFactory`, `ArtifactTruthPresenter`, localization files, and current audit IDs
|
||||
- **Shared abstractions reused**: `ReviewPackService`, current `ReviewPackGenerate` `OperationRun` contract, current signed download controller, `ArtifactTruthPresenter`, `TenantReviewComposer`, `TenantReviewSectionFactory`, and `WorkspaceAuditLogger`
|
||||
- **New abstraction introduced? why?**: none required by default. If implementation needs one helper to assemble delivery metadata or executive-export markup, it must stay local to current review-pack generation and not become a new export framework.
|
||||
- **Why the existing abstraction was sufficient or insufficient**: current seams already solve entitlement, review anchoring, and bundle generation. They are only missing an explicit stakeholder-ready entrypoint and explicit appendix framing.
|
||||
- **Bounded deviation / spread control**: none
|
||||
|
||||
## OperationRun UX Impact
|
||||
|
||||
- **Touches OperationRun start/completion/link UX?**: yes
|
||||
- **Central contract reused**: existing `ReviewPackGenerate` start UX and terminal notification flow
|
||||
- **Delegated UX behaviors**: queued toast, dedupe-or-reuse handling, current run completion semantics, and current signed download follow-up stay delegated to existing review-pack infrastructure
|
||||
- **Surface-owned behavior kept local**: internal published review detail decides when the export action is offered; customer-safe released-review detail remains download-only
|
||||
- **Queued DB-notification policy**: unchanged from the current review-pack contract
|
||||
- **Terminal notification path**: unchanged
|
||||
- **Exception path**: none
|
||||
|
||||
## Provider Boundary & Portability Fit
|
||||
|
||||
- **Shared provider/platform boundary touched?**: no
|
||||
- **Provider-owned seams**: existing provider-specific report names remain appendix-only and secondary
|
||||
- **Platform-core seams**: delivery wording, evidence-basis wording, and customer-safe summary semantics remain platform-owned
|
||||
- **Neutral platform terms / contracts preserved**: governance package, released review, evidence basis, delivery readiness, accepted risks, governance decisions
|
||||
- **Retained provider-specific semantics and why**: provider-specific report or evidence names can remain in the appendix because the appendix is secondary and evidence-oriented, not the primary executive narrative
|
||||
- **Bounded extraction or follow-up path**: none
|
||||
|
||||
## Constitution Check
|
||||
|
||||
*GATE: Must pass before implementation begins and again before merge.*
|
||||
|
||||
- Inventory-first: unchanged; all delivery content stays derived from current review, evidence snapshot, stored reports, and current review-pack truth
|
||||
- Read/write separation: write path remains only the current review-pack generation flow; customer-safe delivery remains read-only
|
||||
- Graph contract path: no Graph calls are added
|
||||
- Deterministic capabilities: current review and review-pack capability derivation stays authoritative
|
||||
- RBAC-UX: workspace membership and tenant/review entitlement remain `404` boundaries; current in-scope capability denials remain `403`
|
||||
- Workspace isolation: unchanged
|
||||
- Tenant isolation: unchanged
|
||||
- Run observability: current `ReviewPackGenerate` `OperationRun` path remains the only generation run path; no new run type or queue family is introduced
|
||||
- OperationRun start UX: existing shared review-pack start UX remains authoritative
|
||||
- Ops-UX lifecycle and summary counts: unchanged
|
||||
- Test governance: keep proof bounded to current review, review-pack, and customer-workspace test families plus one existing browser smoke
|
||||
- Proportionality / persistence / bloat: no new table, new artifact family, or delivery workflow state is allowed
|
||||
- Shared pattern first: current review-pack export and download paths must be extended instead of bypassed
|
||||
- Provider boundary: unchanged
|
||||
- V1 explicitness / few layers: prefer direct extension of current bundle generation and current UI disclosure
|
||||
- Badge semantics: reuse the current governance-package availability and artifact-truth badge mapping
|
||||
- Filament-native UI: keep current native Filament pages and detail surfaces; no new custom dashboard shell
|
||||
- UI/UX surface taxonomy and decision-first operating model: workspace remains registry-first; released-review detail remains package-owning context
|
||||
- Audience-aware disclosure: executive-ready summary first, appendix second, raw/internal detail hidden by default
|
||||
- Action-surface discipline: workspace rows keep one open action, released-review detail keeps one dominant download action, operator detail keeps one dominant export action
|
||||
|
||||
## Test Governance Check
|
||||
|
||||
- **Test purpose / classification by changed surface**: Feature, Browser
|
||||
- **Affected validation lanes**: `confidence`, `browser`
|
||||
- **Why this lane mix is the narrowest sufficient proof**: the slice changes bundle contents, delivery wording, and existing actions on current review surfaces. Focused feature coverage plus the current browser smoke are sufficient without widening into heavy-governance or new browser families.
|
||||
- **Narrowest proving command(s)**:
|
||||
- `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/TenantReview/TenantReviewExecutivePackTest.php tests/Feature/TenantReview/TenantReviewExportOperationsUxTest.php tests/Feature/TenantReview/TenantReviewExplanationSurfaceTest.php tests/Feature/TenantReview/TenantReviewUiContractTest.php tests/Feature/TenantReview/TenantReviewAuditLogTest.php tests/Feature/Reviews/CustomerReviewWorkspacePageTest.php tests/Feature/Reviews/CustomerReviewWorkspacePackAccessTest.php tests/Feature/Reviews/CustomerReviewWorkspaceAuthorizationTest.php tests/Feature/ReviewPack/TenantReviewDerivedReviewPackTest.php tests/Feature/ReviewPack/ReviewPackDownloadTest.php`
|
||||
- `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Browser/Reviews/CustomerReviewWorkspaceSmokeTest.php`
|
||||
- **Fixture / helper / factory / seed / context cost risks**: reuse current released-review, evidence, and review-pack fixtures only; avoid new seeded report families or provider setup
|
||||
- **Expensive defaults or shared helper growth introduced?**: no
|
||||
- **Heavy-family additions, promotions, or visibility changes**: none
|
||||
- **Surface-class relief / special coverage rule**: `shared-detail-family`
|
||||
- **Closing validation and reviewer handoff**: reviewers should confirm that one current `ReviewPack` bundle still drives the entire delivery path and that the browser smoke remains bounded to the existing workspace flow
|
||||
- **Budget / baseline / trend follow-up**: none
|
||||
- **Review-stop questions**: lane fit, bundle truth staying on current artifact family, raw-detail leakage, and accidental second-delivery-domain drift
|
||||
- **Escalation path**: none
|
||||
- **Active feature PR close-out entry**: Smoke Coverage
|
||||
|
||||
## Project Structure
|
||||
|
||||
### Documentation (this feature)
|
||||
|
||||
```text
|
||||
specs/263-auditor-pack-executive-export/
|
||||
├── spec.md
|
||||
├── plan.md
|
||||
├── tasks.md
|
||||
└── checklists/
|
||||
└── requirements.md
|
||||
```
|
||||
|
||||
### Source Code (expected implementation surfaces)
|
||||
|
||||
```text
|
||||
apps/platform/app/Filament/Pages/Reviews/CustomerReviewWorkspace.php
|
||||
apps/platform/app/Filament/Resources/TenantReviewResource.php
|
||||
apps/platform/app/Filament/Resources/TenantReviewResource/Pages/ViewTenantReview.php
|
||||
apps/platform/app/Services/ReviewPackService.php
|
||||
apps/platform/app/Jobs/GenerateReviewPackJob.php
|
||||
apps/platform/app/Http/Controllers/ReviewPackDownloadController.php
|
||||
apps/platform/app/Support/Ui/GovernanceArtifactTruth/ArtifactTruthPresenter.php
|
||||
apps/platform/app/Services/TenantReviews/TenantReviewComposer.php
|
||||
apps/platform/app/Services/TenantReviews/TenantReviewSectionFactory.php
|
||||
apps/platform/resources/views/filament/pages/reviews/customer-review-workspace.blade.php
|
||||
apps/platform/resources/views/filament/infolists/entries/tenant-review-summary.blade.php
|
||||
apps/platform/resources/views/review-packs/...
|
||||
apps/platform/lang/en/localization.php
|
||||
apps/platform/lang/de/localization.php
|
||||
apps/platform/tests/Feature/Reviews/...
|
||||
apps/platform/tests/Feature/TenantReview/...
|
||||
apps/platform/tests/Feature/ReviewPack/...
|
||||
apps/platform/tests/Browser/Reviews/CustomerReviewWorkspaceSmokeTest.php
|
||||
```
|
||||
|
||||
**Structure Decision**: keep the implementation inside the existing admin-plane review and review-pack surfaces. If a dedicated executive-export Blade template is needed, add it under `apps/platform/resources/views/review-packs/` and keep it local to current `ReviewPack` generation.
|
||||
|
||||
## Data / Migration Implications
|
||||
|
||||
- Prefer extending current bundle contents and current JSON metadata over schema changes.
|
||||
- Preserve the current review-derived ZIP baseline entries `metadata.json`, `summary.json`, and `sections.json`; the new contract adds one executive-first entrypoint and explicit appendix-role metadata over that baseline.
|
||||
- No new `ReviewPack` table columns, no new delivery table, and no new artifact registry should be required for v1.
|
||||
- If implementation cannot express delivery metadata inside the current pack contents or current JSON summary surfaces, stop and split rather than widening persistence scope.
|
||||
|
||||
## Rollout Considerations
|
||||
|
||||
- Filament remains v5 on Livewire v4. No panel-provider change is required, and provider registration remains in `apps/platform/bootstrap/providers.php`.
|
||||
- No global search change is required because the affected surfaces stay on existing pages and non-globally-searchable resources.
|
||||
- No destructive action is added. Existing export generation and download paths remain the only user-triggered flows.
|
||||
- No new asset registration is expected; any human-readable executive entrypoint should be rendered from existing server-side view capabilities and included in the current bundle.
|
||||
|
||||
## Risk Controls
|
||||
|
||||
- Reject any implementation that introduces a second delivery artifact family or a new export registry.
|
||||
- Reject any implementation that adds PDF/report-engine infrastructure or recurring delivery automation in this slice.
|
||||
- Reject any implementation that exposes raw provider payloads or internal reason ownership in the executive entrypoint.
|
||||
- Keep customer-safe read-only download semantics and current operator-side export initiation separate.
|
||||
|
||||
## Implementation Phases
|
||||
|
||||
### Phase 0 - Confirm Current Delivery Truth
|
||||
|
||||
- Verify the current review-derived pack contents and metadata contract in `ReviewPackService`, `GenerateReviewPackJob`, and current review-pack tests.
|
||||
- Verify the current customer-workspace and released-review detail delivery semantics in `CustomerReviewWorkspace`, `ViewTenantReview`, and their tests.
|
||||
|
||||
### Phase 1 - Extend The Current Bundle
|
||||
|
||||
- Add one human-readable executive entrypoint file to the current review-derived bundle.
|
||||
- Extend bundle metadata so the executive entrypoint and appendix roles are explicit.
|
||||
- Keep the current structured appendix files intact and secondary.
|
||||
|
||||
### Phase 2 - Align Surfaces With The Delivery Contract
|
||||
|
||||
- Update workspace and released-review detail copy so readiness, evidence basis, and delivery wording reflect the new bundle contract.
|
||||
- Keep `Open review` and `Download governance package` as the dominant safe actions in their existing contexts.
|
||||
|
||||
### Phase 3 - Harden Audit, Permissions, And Download Continuity
|
||||
|
||||
- Reuse current audit events and signed download controller.
|
||||
- Confirm export initiation and ready-pack download continue to follow current capability and entitlement rules.
|
||||
|
||||
### Phase 4 - Validate And Stop
|
||||
|
||||
- Run the planned `confidence` proof and the existing browser smoke.
|
||||
- Verify no new run type, no new artifact family, no new panel/provider/assets, and no raw-detail leakage.
|
||||
|
||||
## Why This Plan Is Narrow Enough
|
||||
|
||||
The repo already has a generated review-pack artifact, an operator export action, a customer-safe download action, and shared governance-package meaning. This plan changes only the delivery contract of that existing artifact and the wording on the two existing review surfaces that expose it. Everything broader stays explicitly deferred.
|
||||
308
specs/263-auditor-pack-executive-export/spec.md
Normal file
308
specs/263-auditor-pack-executive-export/spec.md
Normal file
@ -0,0 +1,308 @@
|
||||
# Feature Specification: Auditor Pack Delivery & Executive Export v1
|
||||
|
||||
**Feature Branch**: `263-auditor-pack-executive-export`
|
||||
**Created**: 2026-05-02
|
||||
**Status**: Ready for implementation
|
||||
**Input**: User description: "Promote the top manual backlog item from `docs/product/spec-candidates.md` and `docs/product/roadmap.md` into a new Spec Kit package that turns current review-pack and governance-package truth into one auditor-ready and executive-readable delivery bundle without reopening the existing review, evidence, compliance-mapping, or governance-packaging foundations."
|
||||
|
||||
## Inherited Baseline / Explicit Delta
|
||||
|
||||
This package is an explicit delta follow-up over already prepared and partly repo-real foundations, especially `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`.
|
||||
|
||||
Inherited baseline for 263:
|
||||
|
||||
- current customer-safe workspace and released-review delivery semantics remain owned by Specs 258-260 and current code
|
||||
- current operator-side `export_executive_pack` generation path remains owned by the existing review-pack export contract
|
||||
- current signed review-pack download route remains the only download seam
|
||||
- current governance-package availability states remain the baseline delivery-state model
|
||||
|
||||
New delta introduced by 263 only:
|
||||
|
||||
- add one explicit executive-first entrypoint inside the current review-derived pack
|
||||
- add explicit bundle metadata clarifying which current files form the structured auditor appendix
|
||||
- make only the minimal workspace/detail wording changes required to describe that new bundle contract
|
||||
|
||||
Everything broader stays inherited and out of scope unless it must change solely to explain the new bundle artifact.
|
||||
|
||||
## Spec Candidate Check *(mandatory - SPEC-GATE-001)*
|
||||
|
||||
- **Problem**: TenantPilot already has a review-derived export path (`export_executive_pack`), a ready or partial governance-package surface in the customer review workspace, current signed review-pack downloads, and shared compliance interpretation. What is still missing is one explicitly audience-ordered delivery artifact that an operator can hand to executives or auditors without manually explaining which parts of the current review pack are safe for whom.
|
||||
- **Today's failure**: Published reviews can already produce or reuse a review-derived pack, but the exported artifact still reads like a product-internal review pack rather than an externally deliverable package. Operators still need ad-hoc guidance outside the product to explain the executive story, the evidence basis, and which files form the auditor appendix.
|
||||
- **User-visible improvement**: An entitled actor can open one released review context and download one clearly packaged bundle whose default entrypoint is executive-readable while the structured appendix remains available for auditor follow-up inside the same delivery path.
|
||||
- **Smallest enterprise-capable version**: Keep one released-review-bound `ReviewPack` artifact and the current signed download seam. Extend that existing bundle with one human-readable executive export entrypoint plus explicit delivery metadata that explains the included auditor appendix files. No new package domain, no new panel, no new scheduling, no new customer portal, and no second artifact family ship in v1.
|
||||
- **Explicit non-goals**: No standalone `AuditorPack` model or table, no new report engine, no PDF renderer requirement, no campaign or recurring delivery workflow, no email delivery automation, no branding or white-label system, no multi-review or multi-tenant package batch, no new review publication workflow, and no raw provider-payload dump as the default human-readable export.
|
||||
- **Permanent complexity imported**: One bounded delivery-bundle contract over the existing `ReviewPack` artifact, one export-ready summary template or equivalent presentation layer for the executive entrypoint, localized copy updates, and focused feature plus browser coverage. No new persisted workflow state is introduced.
|
||||
- **Why now**: `docs/product/roadmap.md` and `docs/product/spec-candidates.md` both rank this as the highest-priority manual-promotion gap, and `docs/product/implementation-ledger.md` still marks auditor-ready executive delivery as the clearest remaining blocker between repo-real review truth and a repeatable sellable outcome.
|
||||
- **Why not local**: A local copy tweak on the customer review workspace or a one-off download rename would not turn the current artifact into a credible external deliverable. A larger portal or reporting engine would overshoot the repo's current need.
|
||||
- **Approval class**: Core Enterprise
|
||||
- **Red flags triggered**: New delivery vocabulary and export-bundle expectations over an existing artifact family. Defense: the slice stays fully derived from `TenantReview`, `ReviewPack`, `EvidenceSnapshot`, and shared interpretation truth; it does not add new persistence, new workflow state, or a second export subsystem.
|
||||
- **Score**: Nutzen: 2 | Dringlichkeit: 2 | Scope: 2 | Komplexitaet: 1 | Produktnaehe: 2 | Wiederverwendung: 2 | **Gesamt: 11/12**
|
||||
- **Decision**: approve
|
||||
|
||||
## Spec Scope Fields *(mandatory)*
|
||||
|
||||
- **Scope**: canonical-view
|
||||
- **Primary Routes**:
|
||||
- existing workspace customer review registry at `/admin/reviews/workspace`
|
||||
- existing tenant-scoped released review detail route under `TenantReviewResource`
|
||||
- existing signed review-pack download route used by the current governance-package download path
|
||||
- existing operator-side published review detail export action on `ViewTenantReview`
|
||||
- **Data Ownership**:
|
||||
- `TenantReview`, `ReviewPack`, `EvidenceSnapshot`, `StoredReport`, `Finding`, `FindingException`, `FindingExceptionDecision`, and `AuditLog` remain the only persisted truth used by this slice
|
||||
- the delivered auditor-ready bundle remains one `ReviewPack` artifact tied to one released review; no new `AuditorPack`, `ExecutiveExport`, or delivery-campaign persistence family is introduced
|
||||
- v1 should prefer extending bundle contents and current `metadata.json` / `summary` data over adding new database columns; if implementation cannot stay inside existing artifact and JSON surfaces, it must stop and split scope
|
||||
- **RBAC**:
|
||||
- workspace membership remains the first isolation boundary and stays `404` for non-members or out-of-scope tenant or review targets
|
||||
- operator-side export initiation on published review detail continues to require the current review-manage capability path used by `export_executive_pack`
|
||||
- read-only delivery download continues to reuse current released-review and review-pack visibility rules on the customer-safe detail route and signed download endpoint
|
||||
- in-scope capability denials remain `403` only where the current review or review-pack contract already does so; this slice must not invent a new capability family for auditor or executive delivery
|
||||
- the slice remains read-only for the customer-safe flow and does not introduce destructive actions
|
||||
|
||||
For canonical-view specs, the spec MUST define:
|
||||
|
||||
- **Default filter behavior when tenant-context is active**: `CustomerReviewWorkspace` keeps the current tenant-prefilter launch behavior. When launched from a tenant-context surface, the workspace prefilters to that tenant and opens the latest released review as the delivery context.
|
||||
- **Explicit entitlement checks preventing cross-tenant leakage**: package availability, executive-export readiness, and review-pack access signals remain derived only for entitled tenants and released reviews. Inaccessible targets are omitted from aggregate lists and direct targeting resolves as not found.
|
||||
|
||||
## Cross-Cutting / Shared Pattern Reuse *(mandatory when the feature touches notifications, status messaging, action links, header actions, dashboard signals/cards, alerts, navigation entry points, evidence/report viewers, or any other existing shared operator interaction family; otherwise write `N/A - no shared interaction family touched`)*
|
||||
|
||||
- **Cross-cutting feature?**: yes
|
||||
- **Interaction class(es)**: download actions, delivery-status messaging, management-summary disclosure, review-pack delivery framing, navigation entry points, and audit-backed export/download flows
|
||||
- **Systems touched**: `CustomerReviewWorkspace`, `TenantReviewResource`, `ViewTenantReview`, `ReviewPackService`, `ReviewPackDownloadController`, `GenerateReviewPackJob`, `TenantReviewComposer`, `TenantReviewSectionFactory`, `ArtifactTruthPresenter`, localization files, and existing audit infrastructure
|
||||
- **Existing pattern(s) to extend**: current `export_executive_pack` generation path, current `download_current_review_pack` customer-safe action, current governance-package availability states, current signed download route, and the existing released-review detail summary contract
|
||||
- **Shared contract / presenter / builder / renderer to reuse**: `ReviewPackService`, `ArtifactTruthPresenter`, `TenantReviewComposer`, `TenantReviewSectionFactory`, `ReviewPackDownloadController`, `WorkspaceAuditLogger`, and the current review-pack action and resource seams
|
||||
- **Why the existing shared path is sufficient or insufficient**: the existing path is already sufficient for review anchoring, entitlement enforcement, current package readiness states, and signed download handling. It is insufficient only because the bundle itself still lacks an explicit executive entrypoint and explicit explanation of which files form the auditor appendix.
|
||||
- **Allowed deviation and why**: none. The slice must extend the current review-pack family and current released-review surfaces instead of introducing a second package domain or a new export framework.
|
||||
- **Consistency impact**: `Governance package`, `Export executive pack`, delivery availability labels, evidence-basis wording, and non-certification disclosure must stay aligned across workspace rows, released review detail, exported bundle metadata, and signed download copy.
|
||||
- **Review focus**: reviewers must block any second artifact family, any direct raw-report export that bypasses released-review truth, any portal-specific duplication of package status, or any scope growth into recurring delivery automation.
|
||||
|
||||
## 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**: the existing `ReviewPackGenerate` start and completion flow reused by `TenantReviewResource::executeExport()` remains the only run path
|
||||
- **Delegated start/completion UX behaviors**: queued toast, already-available pack reuse, active-run dedupe messaging, existing terminal `OperationRunCompleted` delivery, and current signed-download follow-up stay on the shared review-pack export path
|
||||
- **Local surface-owned behavior that remains**: the internal published review detail continues to decide when `export_executive_pack` is visible or primary; the customer-safe released-review detail continues to expose download only when a ready pack already exists
|
||||
- **Queued DB-notification policy**: unchanged from the current review-pack export contract
|
||||
- **Terminal notification path**: unchanged; the current `OperationRunCompleted` path remains authoritative
|
||||
- **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`)*
|
||||
|
||||
N/A - no shared provider/platform seam is widened. Provider-specific report names and evidence details remain secondary appendix content and do not become the primary delivery language.
|
||||
|
||||
## 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 |
|
||||
|---|---|---|---|---|---|---|
|
||||
| Customer Review Workspace page | yes | Native Filament page plus shared review-package primitives | delivery availability, package wording, navigation entry points | page, table, disclosure state | no | Existing row action remains `Open review`; delivery state stays informational on the workspace surface |
|
||||
| Released review detail in customer-workspace mode | yes | Native Filament detail surface plus shared summary and artifact-truth primitives | delivery summary, package entrypoint, appendix disclosure | detail sections, disclosure state | no | Existing `Download governance package` remains the dominant safe action |
|
||||
| Published review detail in operator mode | yes | Native Filament detail surface | export initiation, queued/reused package messaging | header actions, detail context | no | Existing `export_executive_pack` initiation path remains the only generation entrypoint |
|
||||
|
||||
## 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 |
|
||||
|---|---|---|---|---|---|---|---|
|
||||
| Customer Review Workspace page | Primary Decision Surface | Operator decides which released review is ready for external delivery | released review state, package readiness, evidence-basis presence, and current next step | released review detail and appendix context after explicit open | Primary because it remains the calm workspace selection surface for customer-safe delivery | Keeps delivery anchored to the existing review-consumption workflow | Removes the need to inspect operator-only review-pack pages before the first decision |
|
||||
| Released review detail in customer-workspace mode | Secondary Context | Operator or entitled reader confirms what the delivered bundle means and downloads it | executive-ready summary, evidence basis, package readiness, and the dominant download action | appendix semantics, deeper proof links, and review lineage after explicit drilldown | Secondary because the review has already been selected on the workspace surface | Keeps delivery centered on one released review | Avoids duplicate summaries across workspace and detail surfaces |
|
||||
| Published review detail in operator mode | Secondary Context | Operator decides whether to generate or reuse the current export bundle | current review status, export readiness, and queued/reused package outcome | review-pack detail or run detail only after explicit follow-up | Secondary because this is the operator-only generation context, not the customer-safe delivery registry | Preserves the current operator workflow while clarifying the final bundle purpose | Prevents the operator from bouncing between review detail and review-pack resource just to know what will be delivered |
|
||||
|
||||
## 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 |
|
||||
|---|---|---|---|---|---|---|---|
|
||||
| Customer Review Workspace page | operator-MSP, customer-admin, auditor-read-only, customer-read-only | delivery availability, released review state, executive-ready teaser, and evidence-basis presence | none beyond current review state and next-step copy | raw appendix files, provider payloads, fingerprints, and operator-only diagnostics stay off the list surface | `Open review` | raw/support detail stays off the workspace page by default | workspace rows say only whether delivery is ready; detail owns the actual delivery summary |
|
||||
| Released review detail in customer-workspace mode | operator-MSP, customer-admin, auditor-read-only, customer-read-only | executive summary, evidence basis, top findings, accepted risks, governance decisions requiring awareness, and current delivery readiness | review lineage, interpretation freshness, and artifact availability descriptions in secondary sections only | raw provider payloads, fingerprints, internal reason ownership, and platform reason families stay hidden or gated | `Download governance package` | appendix file structure and operator-only proof links remain secondary | the delivery summary appears once in the governance-package block; later sections add evidence rather than restating the same outcome |
|
||||
| Published review detail in operator mode | operator-MSP | export readiness, existing package state, and bundle purpose | run details and review-pack detail remain secondary | raw appendix files remain on the pack resource, not the primary review detail block | `Export executive pack` | customer-facing download copy stays off operator-only generation states until a pack is ready | generation context explains bundle purpose once and defers proof to the pack detail or download |
|
||||
|
||||
## 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 |
|
||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
||||
| Customer Review Workspace page | List / Table / Read-only workspace report | Read-only registry report | Open the released review that is ready for delivery | full-row open via the existing `Open review` link | required | no peer download action on the list surface | none | `/admin/reviews/workspace` | existing tenant review detail route | workspace context, tenant prefilter, released review state, delivery availability | Customer review | whether a released review is delivery-ready | none |
|
||||
| Released review detail in customer-workspace mode | Detail / Report / Management export | Read-only detail report | Download the current governance package | sectioned detail with one dominant safe header action | forbidden | appendix descriptions and proof links remain secondary in-body | none | `/admin/reviews/workspace` | existing tenant review detail route | workspace, tenant, release state, evidence basis, delivery availability | Governance package | what the exported bundle means and whether it is ready | none |
|
||||
| Published review detail in operator mode | Detail / Report / Export initiation | Operator export surface | Start or reuse the review-derived export bundle | sectioned detail page with one primary export action | forbidden | review-pack detail or run links remain secondary | none | existing tenant review collection route | existing tenant review detail route | tenant context, review status, export readiness | Executive pack export | whether an export can start or be reused now | 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 |
|
||||
|---|---|---|---|---|---|---|---|---|---|---|
|
||||
| Customer Review Workspace page | MSP operator or entitled reviewer | Decide which released review is ready for stakeholder delivery | Read-only workspace review overview | Which released review can I safely deliver right now? | released review state, delivery availability, evidence-basis presence, and next step | none beyond secondary review detail | release state, delivery readiness, evidence sufficiency | none | Open review | none |
|
||||
| Released review detail in customer-workspace mode | MSP operator, customer admin, auditor with read access | Understand the delivery package and download it | Read-only detail report | What will this delivery bundle say, and is it ready? | executive summary, evidence basis, top findings, accepted risks, governance decisions, and delivery status | appendix structure, deeper evidence links, and lineage | delivery readiness, evidence sufficiency, released review status | none | Download governance package | none |
|
||||
| Published review detail in operator mode | MSP operator | Generate or reuse the review-derived delivery bundle | Detail/export initiation surface | Can I prepare the current delivery bundle now, or is one already available? | review status, export readiness, and package reuse status | run detail and review-pack detail after explicit follow-up | review status, generation readiness, package availability | current `ReviewPackGenerate` run only | Export executive pack | none |
|
||||
|
||||
## Proportionality Review *(mandatory when structural complexity is introduced)*
|
||||
|
||||
- **New source of truth?**: no
|
||||
- **New persisted entity/table/artifact?**: no new persisted family; the existing `ReviewPack` artifact remains the delivery artifact
|
||||
- **New abstraction?**: no new cross-domain abstraction by default; any implementation helper must stay local to current review-pack generation and delivery seams
|
||||
- **New enum/state/reason family?**: no
|
||||
- **New cross-domain UI framework/taxonomy?**: no
|
||||
- **Current operator problem**: the repo can already generate a review-derived export and show a customer-safe governance-package summary, but it still cannot hand over one clearly audience-ordered delivery artifact without manual explanation.
|
||||
- **Existing structure is insufficient because**: the current bundle is machine-oriented and the current UI summary is in-app only. Neither alone gives a stakeholder-ready delivery contract.
|
||||
- **Narrowest correct implementation**: extend the existing review-derived `ReviewPack` bundle and current signed download path with one human-readable executive entrypoint and explicit delivery metadata instead of creating a second package family.
|
||||
- **Ownership cost**: maintain one additional exported entrypoint, one bundle metadata contract, a small amount of localized copy, and focused delivery tests.
|
||||
- **Alternative intentionally rejected**: a standalone `AuditorPack` domain, a PDF/reporting engine, or recurring delivery automation were rejected as broader than current-release truth requires.
|
||||
- **Release truth**: current-release sellability follow-through, not future-release delivery automation.
|
||||
|
||||
### Compatibility posture
|
||||
|
||||
This feature assumes a pre-production environment.
|
||||
|
||||
Backward compatibility, legacy aliases, migration shims, historical fixtures, and compatibility-specific tests are out of scope unless explicitly required by this spec.
|
||||
|
||||
Canonical extension of the current `ReviewPack` bundle is preferred over adding a new delivery artifact family.
|
||||
|
||||
## Testing / Lane / Runtime Impact *(mandatory for runtime behavior changes)*
|
||||
|
||||
- **Test purpose / classification**: Feature, Browser
|
||||
- **Validation lane(s)**: confidence, browser
|
||||
- **Why this classification and these lanes are sufficient**: focused feature tests prove bundle contents, current export reuse, signed-download continuity, customer-safe disclosure, and entitlement boundaries on the current review and review-pack surfaces. One bounded browser smoke remains justified because the customer-facing detail copy and dominant action are user-visible delivery surfaces.
|
||||
- **New or expanded test families**: extend existing `apps/platform/tests/Feature/TenantReview/`, `apps/platform/tests/Feature/Reviews/`, and `apps/platform/tests/Feature/ReviewPack/` families plus the existing `apps/platform/tests/Browser/Reviews/CustomerReviewWorkspaceSmokeTest.php`
|
||||
- **Fixture / helper cost impact**: low to moderate; reuse current released-review, evidence-snapshot, review-pack, and entitlement fixtures. No provider-sync, no new queue family, and no heavy-governance lane are needed.
|
||||
- **Heavy-family visibility / justification**: none
|
||||
- **Special surface test profile**: shared-detail-family
|
||||
- **Standard-native relief or required special coverage**: customer-workspace list behavior keeps standard native coverage; the released-review detail and bundle contract need explicit shared-detail coverage plus the existing browser smoke
|
||||
- **Reviewer handoff**: reviewers must confirm that the exported bundle stays on the current `ReviewPack` family, the executive entrypoint is human-readable and customer-safe, the appendix stays clearly secondary, the workspace surface keeps `Open review` as the only dominant row action, and no second delivery domain or new run type appears
|
||||
- **Budget / baseline / trend impact**: low feature-local increase 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/Feature/TenantReview/TenantReviewExecutivePackTest.php tests/Feature/TenantReview/TenantReviewExportOperationsUxTest.php tests/Feature/TenantReview/TenantReviewExplanationSurfaceTest.php tests/Feature/TenantReview/TenantReviewUiContractTest.php tests/Feature/TenantReview/TenantReviewAuditLogTest.php tests/Feature/Reviews/CustomerReviewWorkspacePageTest.php tests/Feature/Reviews/CustomerReviewWorkspacePackAccessTest.php tests/Feature/Reviews/CustomerReviewWorkspaceAuthorizationTest.php tests/Feature/ReviewPack/TenantReviewDerivedReviewPackTest.php tests/Feature/ReviewPack/ReviewPackDownloadTest.php`
|
||||
- `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Browser/Reviews/CustomerReviewWorkspaceSmokeTest.php`
|
||||
|
||||
## User Scenarios & Testing *(mandatory)*
|
||||
|
||||
### User Story 1 - Deliver One Stakeholder-Ready Bundle From A Released Review (Priority: P1)
|
||||
|
||||
As an MSP operator, I want a published review to produce one delivery-ready package that I can hand to an executive or auditor without separately explaining which files matter.
|
||||
|
||||
**Why this priority**: This is the commercial core of the slice. Without one externally deliverable bundle, the feature remains an internal review surface only.
|
||||
|
||||
**Independent Test**: Trigger `export_executive_pack` for a published review, finish the existing review-pack generation job, download the resulting pack through the current signed download path, and verify that the bundle contains both the current structured appendix files and one human-readable executive entrypoint.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** a published review with an eligible evidence basis, **When** an entitled operator runs `export_executive_pack`, **Then** the existing `ReviewPackGenerate` flow starts or reuses the current pack and the finished bundle contains one executive entrypoint plus the existing structured appendix files.
|
||||
2. **Given** a ready current export pack for a released review, **When** an entitled reader opens the customer-safe released-review detail and chooses `Download governance package`, **Then** the same current pack is downloaded through the existing signed route rather than a second artifact family.
|
||||
3. **Given** the current export pack already exists and is still valid, **When** an operator repeats the export action, **Then** the system reuses the existing pack instead of creating a second delivery artifact.
|
||||
|
||||
---
|
||||
|
||||
### User Story 2 - Read The Executive Story First And The Auditor Appendix Second (Priority: P1)
|
||||
|
||||
As an entitled reviewer, I want the delivered bundle and released-review detail to make the executive summary obvious first while keeping the structured appendix clearly secondary, so the package is usable without exposing internal diagnostics by default.
|
||||
|
||||
**Why this priority**: The product already exposes governance-package meaning in-app. The missing value is making that meaning the default entrypoint of the delivered bundle.
|
||||
|
||||
**Independent Test**: Open a released review in customer-workspace mode, confirm the default-visible package block is customer-safe and executive-readable, then inspect the downloaded pack to verify the executive entrypoint and appendix roles are explicit.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** a released review in customer-workspace mode, **When** the detail page renders, **Then** it shows executive-ready summary, evidence basis, delivery readiness, and `Download governance package` without exposing fingerprints, raw payloads, internal reason ownership, or platform reason families by default.
|
||||
2. **Given** the delivered bundle is opened, **When** the recipient inspects the archive, **Then** the executive entrypoint is obvious and the structured appendix files remain present but secondary.
|
||||
3. **Given** delivery readiness is `partial`, `unavailable`, `expired`, or `blocked`, **When** the workspace row or detail page renders, **Then** the product explains that state truthfully without pretending a bundle is ready.
|
||||
|
||||
---
|
||||
|
||||
### User Story 3 - Keep Delivery Tenant-Safe, Auditable, And Bounded (Priority: P2)
|
||||
|
||||
As a platform owner, I want the new delivery bundle to stay on the current entitlement, audit, and review-pack seams so the sellability improvement does not create a second uncontrolled export surface.
|
||||
|
||||
**Why this priority**: The feature only improves trust if it stays on the current authorization and audit boundaries.
|
||||
|
||||
**Independent Test**: Verify non-members receive `404`, in-scope actors use the existing download/export capability paths, and export/download activity remains visible through the existing audit and `OperationRun` seams.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** a non-member or an out-of-scope tenant or review target, **When** they attempt to open the workspace, released-review detail, or signed download route, **Then** the response remains deny-as-not-found.
|
||||
2. **Given** an entitled read-only reviewer with access to the released review and current export pack, **When** they download the governance package, **Then** the bundle is accessible without exposing operator-only diagnostics in the default human-readable export.
|
||||
3. **Given** an entitled operator exports a published review, **When** the bundle is created or reused, **Then** the current audit and `ReviewPackGenerate` observability signals remain intact and no new delivery workflow state is introduced.
|
||||
|
||||
### Edge Cases
|
||||
|
||||
- What happens when a released review exists but no ready current export pack exists yet? The customer-safe detail stays truthful about unavailability and does not fabricate a download link.
|
||||
- What happens when the current export pack is expired? The workspace and detail surfaces show `expired`, and the reader must rely on the operator-side generation flow to prepare a new bundle.
|
||||
- What happens when the released review is customer-safe but some supporting evidence remains partial or stale? The executive entrypoint must say so explicitly, and the bundle stays `partial` rather than falsely `available`.
|
||||
- What happens when a workspace commercial lifecycle state blocks new export starts but an existing ready pack remains downloadable? The operator-side export action stays blocked while the current allowed download semantics remain intact.
|
||||
|
||||
## Requirements *(mandatory)*
|
||||
|
||||
**Constitution alignment:** This feature reuses the current `ReviewPackGenerate` `OperationRun` path and existing signed review-pack download route. It must not introduce a second export run type, a second artifact family, or any new write path outside current review-pack generation. UI visibility remains non-authoritative; current `404`/`403` semantics stay enforced server-side.
|
||||
|
||||
**Constitution alignment (PROP-001 / PERSIST-001 / BLOAT-001):** This feature must stay on the existing `ReviewPack` persistence family. If implementation requires a new table, new standalone package model, or new delivery workflow state, the scope fails readiness and must split.
|
||||
|
||||
**Constitution alignment (XCUT-001):** Delivery status messaging, download actions, and review-pack export reuse must extend the current review, review-pack, and artifact-truth paths rather than introducing a new package presenter stack or a parallel portal UX language.
|
||||
|
||||
### Functional Requirements
|
||||
|
||||
- **FR-001**: The existing review-derived `ReviewPack` bundle for a published review MUST become the auditor-ready delivery bundle for this slice; no second artifact family may be introduced.
|
||||
- **FR-002**: Operator-side export initiation for published reviews MUST continue to reuse the current `export_executive_pack` action and current `ReviewPackGenerate` `OperationRun` semantics.
|
||||
- **FR-003**: Customer-safe read-only download for a released review MUST continue to reuse the current signed review-pack download route reached from `download_current_review_pack`.
|
||||
- **FR-004**: The delivered bundle MUST preserve the current review-derived ZIP baseline entries `metadata.json`, `summary.json`, and `sections.json`, and MUST add exactly one human-readable executive entrypoint file suitable for executive-first consumption.
|
||||
- **FR-005**: The delivered bundle metadata MUST explicitly identify the released review context, the current export review pack identity, interpretation version, evidence basis, generation timestamps, and which file is the executive entrypoint versus the preserved appendix entries `metadata.json`, `summary.json`, and `sections.json`.
|
||||
- **FR-006**: The executive entrypoint MUST be derived from current released-review truth and shared interpretation truth, including executive summary, key findings, accepted risks, governance decisions requiring awareness, evidence basis, and explicit non-certification disclosure.
|
||||
- **FR-007**: The executive entrypoint MUST NOT expose raw provider payloads, fingerprints, internal reason ownership, platform reason families, or operator-only diagnostics by default.
|
||||
- **FR-008**: `CustomerReviewWorkspace` and released-review detail MUST keep package availability states truthful (`available`, `partial`, `unavailable`, `expired`, `blocked`) and must not imply delivery readiness when the current pack or evidence basis is not sufficient.
|
||||
- **FR-009**: The workspace surface MUST keep `Open review` as the only dominant row action. It may not add a peer download action or a second delivery-specific row action.
|
||||
- **FR-010**: The customer-safe released-review detail MUST keep one dominant safe download action and must present the auditor appendix as secondary explanatory context rather than a competing primary action.
|
||||
- **FR-011**: Existing export/download audit events and metadata MUST be reused or minimally extended. A new audit family specific to auditor packs is out of scope.
|
||||
- **FR-012**: This slice MUST NOT add new panel providers, new globally searchable resources, or new asset registration. Existing Filament v5 / Livewire v4 admin surfaces remain the only UI path.
|
||||
|
||||
## Scope Boundaries
|
||||
|
||||
### In Scope
|
||||
|
||||
- one auditor-ready delivery contract over the current review-derived `ReviewPack` bundle
|
||||
- one human-readable executive entrypoint added to that current bundle while preserving `metadata.json`, `summary.json`, and `sections.json`
|
||||
- delivery metadata clarifying executive entrypoint versus the preserved appendix roles
|
||||
- only the truthful package-readiness and delivery wording changes on `CustomerReviewWorkspace` and released-review detail that are required to explain the new bundle contract
|
||||
- reuse of current operator-side export initiation and current customer-safe signed download path
|
||||
- focused localization, entitlement, audit, and browser-smoke follow-through for that delivery bundle
|
||||
|
||||
### Non-Goals
|
||||
|
||||
- a new `AuditorPack` model, table, or standalone delivery registry
|
||||
- a new report engine, template system, or PDF-generation requirement
|
||||
- recurring delivery schedules, campaigns, email delivery, or workflow automation
|
||||
- white-label branding, customer-specific theming, or MSP profile variants
|
||||
- a portal, new panel, or external identity plane
|
||||
- multi-review or multi-tenant package batching
|
||||
- raw provider-payload export as the default executive output
|
||||
- any new destructive action or approval workflow
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- A published review can still generate or reuse one current export pack, and that pack now exposes a clear executive entrypoint plus a clearly secondary structured appendix.
|
||||
- `CustomerReviewWorkspace` remains a calm selection surface with `Open review` as the only dominant row action, while released-review detail remains the package-owning context.
|
||||
- Customer-safe delivery copy stays derived from existing review and interpretation truth and hides internal operator-only diagnostics by default.
|
||||
- Existing `ReviewPackGenerate` and signed-download seams remain authoritative; no second artifact family or new run type is introduced.
|
||||
|
||||
## Success Criteria
|
||||
|
||||
- An operator no longer needs external instructions to explain which file in the current bundle is the executive-first entrypoint.
|
||||
- An entitled customer-safe reader can download the same current bundle from released-review detail without seeing raw internal diagnostics by default.
|
||||
- Review-pack delivery becomes closer to auditor-/executive-ready packaging without reopening the already prepared customer-safe workspace/detail delivery semantics from Specs 258-260.
|
||||
|
||||
## Assumptions
|
||||
|
||||
- The current `ReviewPack` ZIP artifact remains the acceptable v1 delivery container.
|
||||
- A human-readable executive entrypoint can be produced inside the current export flow without introducing a new rendering subsystem.
|
||||
- Existing review, review-pack, evidence, and interpretation truth are sufficient for v1 delivery framing; missing or stale basis is handled through truthful readiness states rather than by generating new data.
|
||||
|
||||
## Risks
|
||||
|
||||
- The slice could drift into a second export domain if implementation treats auditor delivery as a new artifact family instead of extending the current `ReviewPack` bundle.
|
||||
- Scope pressure could try to add PDF tooling, branding, or recurring delivery automation even though those are explicitly deferred.
|
||||
- The executive entrypoint could accidentally mirror internal diagnostics unless the delivery copy stays on the existing customer-safe interpretation path.
|
||||
|
||||
## Candidate Selection Rationale
|
||||
|
||||
- **Selected candidate**: Auditor Pack Delivery & Executive Export v1
|
||||
- **Source locations**:
|
||||
- `docs/product/spec-candidates.md`
|
||||
- `docs/product/roadmap.md`
|
||||
- `docs/product/implementation-ledger.md`
|
||||
- **Why selected**: The active queue is intentionally empty, and this is the top-ranked manual-promotion backlog item with no existing spec package. The repo already contains the prerequisite review-pack export, customer review workspace, compliance interpretation, and governance-package delivery foundations.
|
||||
- **Why close alternatives were deferred**:
|
||||
- `Cross-Tenant Promotion Execution v1` already has Spec 043 for compare preview and preflight; the remaining gap is a later portfolio-action slice.
|
||||
- `Governance Decision Pack & Approval Workflow v1` depends on the broader decision-operating lane and is ranked behind auditor-ready delivery in current roadmap order.
|
||||
- `Customer-Facing Localization Adoption v1`, `Billing & Subscription Truth Layer v1`, `Stored Reports Surface v1`, `Workspace & Tenant Closure Lifecycle v1`, and `First Governed AI Runtime Consumer v1` remain valid manual promotions, but none is ranked above auditor-ready delivery in the current repo-backed backlog.
|
||||
184
specs/263-auditor-pack-executive-export/tasks.md
Normal file
184
specs/263-auditor-pack-executive-export/tasks.md
Normal file
@ -0,0 +1,184 @@
|
||||
---
|
||||
description: "Task list for Auditor Pack Delivery & Executive Export v1"
|
||||
---
|
||||
|
||||
# Tasks: Auditor Pack Delivery & Executive Export v1
|
||||
|
||||
**Input**: Design documents from `specs/263-auditor-pack-executive-export/`
|
||||
**Prerequisites**: `specs/263-auditor-pack-executive-export/spec.md`, `specs/263-auditor-pack-executive-export/plan.md`, `specs/263-auditor-pack-executive-export/checklists/requirements.md`
|
||||
|
||||
**Tests**: REQUIRED (Pest). Keep proof bounded to existing `Feature` families around `TenantReview`, `Reviews`, and `ReviewPack`, plus the current `CustomerReviewWorkspace` browser smoke only.
|
||||
**Operations**: Reuse the existing `ReviewPackGenerate` `OperationRun` path and signed review-pack download route. No new run type, no new queue family, and no new export artifact family are allowed.
|
||||
**RBAC**: Workspace or tenant non-members remain `404`; current in-scope review/export/download denials remain `403` where the existing review-pack contract already uses them. No new capability family may be introduced.
|
||||
**Shared Pattern Reuse**: Reuse `CustomerReviewWorkspace`, `TenantReviewResource`, `ViewTenantReview`, `ReviewPackService`, `GenerateReviewPackJob`, `ReviewPackDownloadController`, `TenantReviewComposer`, `TenantReviewSectionFactory`, `ArtifactTruthPresenter`, current localization files, and current audit IDs. Do not create a new `AuditorPack` or reporting subsystem.
|
||||
**Filament / Panel Guardrails**: Filament remains v5 on Livewire v4. 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 bundle contract, the delivery disclosure, and the entitlement/audit boundaries stay independently implementable and testable. This package is a delta follow-up over Specs 258-260 and current code; broader customer-safe workspace/detail behavior is inherited unless a task explicitly changes it to explain the new bundle contract.
|
||||
|
||||
## Test Governance Checklist
|
||||
|
||||
- [x] Lane assignment stays `confidence` plus the existing bounded `browser` smoke and remains the narrowest sufficient proof.
|
||||
- [x] New or changed tests stay in the existing `apps/platform/tests/Feature/TenantReview/`, `apps/platform/tests/Feature/Reviews/`, and `apps/platform/tests/Feature/ReviewPack/` families plus `apps/platform/tests/Browser/Reviews/CustomerReviewWorkspaceSmokeTest.php`.
|
||||
- [x] Shared helpers, released-review fixtures, review-pack fixtures, and evidence fixtures stay cheap by default.
|
||||
- [x] Planned validation commands cover bundle contents, disclosure, download continuity, and entitlement behavior without widening into unrelated lanes.
|
||||
- [x] The declared surface test profile remains `shared-detail-family`.
|
||||
- [x] Any drift toward a second artifact family, a PDF engine, or recurring delivery automation is handled as `reject-or-split` or `follow-up-spec`, not hidden inside this feature.
|
||||
|
||||
## Phase 1: Setup (Shared Context)
|
||||
|
||||
**Purpose**: Confirm the current review-pack bundle, delivery wording, and entitlement seams before any implementation change.
|
||||
|
||||
- [x] T001 Review `specs/263-auditor-pack-executive-export/spec.md`, `specs/263-auditor-pack-executive-export/plan.md`, `specs/263-auditor-pack-executive-export/checklists/requirements.md`, `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` together so the slice stays on the current bundle and delivery foundations.
|
||||
- [x] T002 [P] Confirm the current operator export initiation seam in `apps/platform/app/Filament/Resources/TenantReviewResource.php` and `apps/platform/app/Filament/Resources/TenantReviewResource/Pages/ViewTenantReview.php`.
|
||||
- [x] T003 [P] Confirm the current bundle generation and download seams in `apps/platform/app/Services/ReviewPackService.php`, `apps/platform/app/Jobs/GenerateReviewPackJob.php`, and `apps/platform/app/Http/Controllers/ReviewPackDownloadController.php`.
|
||||
- [x] T004 [P] Confirm the current customer-safe delivery surfaces in `apps/platform/app/Filament/Pages/Reviews/CustomerReviewWorkspace.php`, `apps/platform/resources/views/filament/pages/reviews/customer-review-workspace.blade.php`, and `apps/platform/resources/views/filament/infolists/entries/tenant-review-summary.blade.php`.
|
||||
|
||||
---
|
||||
|
||||
## Phase 2: Foundational (Blocking Prerequisites)
|
||||
|
||||
**Purpose**: Lock the bounded delivery contract before surface-level changes begin.
|
||||
|
||||
**Critical**: No user-story work should begin until this phase is complete.
|
||||
|
||||
- [x] T005 [P] Extend `apps/platform/tests/Feature/ReviewPack/TenantReviewDerivedReviewPackTest.php` and `apps/platform/tests/Feature/TenantReview/TenantReviewExecutivePackTest.php` to require one human-readable executive entrypoint plus explicit delivery metadata inside the current review-derived pack while preserving the current ZIP baseline entries `metadata.json`, `summary.json`, and `sections.json`.
|
||||
- [x] T006 [P] Extend `apps/platform/tests/Feature/TenantReview/TenantReviewExportOperationsUxTest.php` and `apps/platform/tests/Feature/ReviewPack/ReviewPackDownloadTest.php` to prove the feature still reuses the current `ReviewPackGenerate` path and the current signed download route rather than introducing a second artifact or download flow. Existing tests already covered this seam; the validation lane confirmed them unchanged.
|
||||
- [x] T007 [P] Extend `apps/platform/tests/Feature/Reviews/CustomerReviewWorkspacePageTest.php`, `apps/platform/tests/Feature/Reviews/CustomerReviewWorkspacePackAccessTest.php`, `apps/platform/tests/Feature/TenantReview/TenantReviewExplanationSurfaceTest.php`, and `apps/platform/tests/Feature/TenantReview/TenantReviewUiContractTest.php` to lock delivery-readiness wording, one dominant action per surface, and the absence of raw/internal detail in the customer-safe default path. New wording/default-disclosure assertions landed in `CustomerReviewWorkspacePageTest` and `TenantReviewExplanationSurfaceTest`; existing pack-access and UI-contract tests remained the action-hierarchy guard.
|
||||
- [x] T008 Implement the bundle-contract change in `apps/platform/app/Services/ReviewPackService.php` and `apps/platform/app/Jobs/GenerateReviewPackJob.php`, keeping the current `ReviewPack` family and the ZIP baseline entries `metadata.json`, `summary.json`, and `sections.json` intact while adding one executive entrypoint and explicit delivery metadata.
|
||||
- [x] T009 [P] Add or update the executive-entrypoint presentation layer under `apps/platform/resources/views/review-packs/` only if the current bundle generation cannot render the executive export cleanly from existing summary truth. Not needed: the current job renders a bounded Markdown entrypoint directly from existing review summary truth.
|
||||
|
||||
**Checkpoint**: The current bundle, current run path, and current customer-safe surfaces are all locked to the new delivery contract before broader wording changes begin.
|
||||
|
||||
---
|
||||
|
||||
## Phase 3: User Story 1 - Deliver One Stakeholder-Ready Bundle From A Released Review (Priority: P1)
|
||||
|
||||
**Goal**: A published review can generate or reuse one current export bundle that is ready to hand over externally.
|
||||
|
||||
**Independent Test**: Export a published review, complete the current generation job, and download the resulting current pack to verify that one executive entrypoint and the existing structured appendix coexist in the same bundle.
|
||||
|
||||
### Tests for User Story 1
|
||||
|
||||
- [x] T010 [P] [US1] Extend `apps/platform/tests/Feature/TenantReview/TenantReviewExecutivePackTest.php` to assert that the current pack remains review-anchored and now exposes the executive entrypoint plus delivery metadata.
|
||||
- [x] T011 [P] [US1] Extend `apps/platform/tests/Feature/TenantReview/TenantReviewExportOperationsUxTest.php` to assert that export initiation still uses the existing `ReviewPackGenerate` path, dedupes correctly, and stays on the current operator-side action. Existing coverage already proved the unchanged run path and dedupe behavior.
|
||||
- [x] T012 [P] [US1] Extend `apps/platform/tests/Feature/ReviewPack/TenantReviewDerivedReviewPackTest.php` and `apps/platform/tests/Feature/ReviewPack/ReviewPackDownloadTest.php` to verify the new bundle contents and signed-download continuity. New bundle assertions landed in `TenantReviewDerivedReviewPackTest`; existing download continuity coverage remained unchanged and passed.
|
||||
|
||||
### Implementation for User Story 1
|
||||
|
||||
- [x] T013 [US1] Update `apps/platform/app/Services/ReviewPackService.php` and `apps/platform/app/Jobs/GenerateReviewPackJob.php` so review-derived packs produce one executive entrypoint and explicit delivery metadata while preserving current appendix files and current `current_export_review_pack_id` behavior.
|
||||
- [x] T014 [US1] Update `apps/platform/app/Filament/Resources/TenantReviewResource.php` and `apps/platform/app/Filament/Resources/TenantReviewResource/Pages/ViewTenantReview.php` so published-review export continues to generate or reuse the current pack without introducing a second delivery action or a second artifact family. No code update was needed; repo truth already used the current action and run seam, and tests confirmed it.
|
||||
- [x] T015 [US1] Update `apps/platform/app/Http/Controllers/ReviewPackDownloadController.php` only as needed to carry the same current pack through the signed download path with delivery metadata intact. No controller update was needed; signed download continuity stayed on the existing pack file and passed validation.
|
||||
|
||||
**Checkpoint**: One released review can produce and deliver one stakeholder-ready current bundle without any second export system.
|
||||
|
||||
---
|
||||
|
||||
## Phase 4: User Story 2 - Show The Executive Story First And The Appendix Second (Priority: P1)
|
||||
|
||||
**Goal**: The in-app delivery surfaces and the exported bundle both make the executive narrative the default entrypoint while keeping appendix detail secondary.
|
||||
|
||||
**Independent Test**: Open a released review in customer-workspace mode and confirm that the default visible package block and the downloaded current bundle both present executive-first delivery framing without raw internal diagnostics.
|
||||
|
||||
### Tests for User Story 2
|
||||
|
||||
- [x] T016 [P] [US2] Extend `apps/platform/tests/Feature/Reviews/CustomerReviewWorkspacePageTest.php` and `apps/platform/tests/Feature/Reviews/CustomerReviewWorkspacePackAccessTest.php` to cover only the delivery-readiness wording changes required by the new bundle contract, evidence-basis messaging, and the absence of peer download actions on the workspace list. New wording assertions landed in `CustomerReviewWorkspacePageTest`; existing pack-access tests remained the peer-action guard.
|
||||
- [x] T017 [P] [US2] Extend `apps/platform/tests/Feature/TenantReview/TenantReviewExplanationSurfaceTest.php` and `apps/platform/tests/Feature/TenantReview/TenantReviewUiContractTest.php` to cover executive-first default content, appendix-secondary wording, and hidden raw/internal detail by default. New disclosure assertions landed in `TenantReviewExplanationSurfaceTest`; existing UI-contract tests remained the one-action guard.
|
||||
- [x] T018 [P] [US2] Extend `apps/platform/tests/Browser/Reviews/CustomerReviewWorkspaceSmokeTest.php` to prove the released-review path still centers the customer-safe package summary and dominant download action after the wording changes.
|
||||
|
||||
### Implementation for User Story 2
|
||||
|
||||
- [x] T019 [US2] Update `apps/platform/app/Filament/Pages/Reviews/CustomerReviewWorkspace.php` and `apps/platform/resources/views/filament/pages/reviews/customer-review-workspace.blade.php` only where needed so workspace rows keep delivery readiness informational and `Open review` remains the only dominant row action. No PHP/Blade structure change was needed on the workspace list; localized intro copy now frames executive-ready package status while existing row action tests guard `Open review`.
|
||||
- [x] T020 [US2] Update `apps/platform/app/Services/TenantReviews/TenantReviewComposer.php`, `apps/platform/app/Services/TenantReviews/TenantReviewSectionFactory.php`, and `apps/platform/resources/views/filament/infolists/entries/tenant-review-summary.blade.php` only where needed so the released-review detail block explains executive-first delivery, evidence basis, and appendix-secondary meaning without reopening broader customer-safe package semantics already owned by Spec 260. Composer/factory already exposed the required truth; the detail entry now presents entrypoint and appendix wording.
|
||||
- [x] T021 [US2] Update `apps/platform/lang/en/localization.php` and `apps/platform/lang/de/localization.php` so delivery-readiness, executive-entrypoint, appendix, and non-certification copy stay consistent across workspace, detail, and download paths.
|
||||
|
||||
**Checkpoint**: The delivery story is obvious and customer-safe before the bundle is opened, and the workspace/detail surfaces stay calm and non-duplicative.
|
||||
|
||||
---
|
||||
|
||||
## Phase 5: User Story 3 - Keep Delivery Tenant-Safe, Auditable, And Bounded (Priority: P2)
|
||||
|
||||
**Goal**: The sellability improvement remains on the current entitlement, audit, and observability seams.
|
||||
|
||||
**Independent Test**: Verify that export and download stay tenant-safe, audit-visible, and free of any second package domain or new delivery workflow state.
|
||||
|
||||
### Tests for User Story 3
|
||||
|
||||
- [x] T022 [P] [US3] Extend `apps/platform/tests/Feature/Reviews/CustomerReviewWorkspaceAuthorizationTest.php` and `apps/platform/tests/Feature/ReviewPack/ReviewPackDownloadTest.php` to confirm non-members remain `404` and current in-scope download permissions remain authoritative. Existing authorization/download coverage remained valid and passed.
|
||||
- [x] T023 [P] [US3] Extend `apps/platform/tests/Feature/TenantReview/TenantReviewExportOperationsUxTest.php` and `apps/platform/tests/Feature/TenantReview/TenantReviewUiContractTest.php` to confirm operator export generation remains the only current initiation path and no competing customer-surface generation action appears. Existing UX-contract coverage remained valid and passed.
|
||||
- [x] T024 [P] [US3] Extend `apps/platform/tests/Feature/TenantReview/TenantReviewAuditLogTest.php` and `apps/platform/tests/Feature/ReviewPack/ReviewPackDownloadTest.php` to confirm current audit metadata still records export and download activity without a new audit family. Existing audit/download coverage remained valid and passed.
|
||||
|
||||
### Implementation for User Story 3
|
||||
|
||||
- [x] T025 [US3] Reuse or minimally extend current audit metadata in `apps/platform/app/Services/Audit/WorkspaceAuditLogger.php` and `apps/platform/app/Support/Audit/AuditActionId.php` only if the current export/download events need explicit delivery-role metadata. No audit-family or action-id change was needed; existing metadata remains authoritative.
|
||||
- [x] T026 [US3] Review `apps/platform/app/Support/Ui/GovernanceArtifactTruth/ArtifactTruthPresenter.php` and current delivery availability mapping so `available`, `partial`, `unavailable`, `expired`, and `blocked` remain truthful after the new bundle entrypoint is added.
|
||||
- [x] T027 [US3] Confirm the implementation does not add a new panel, new global search entry, new asset registration, second artifact family, or recurring delivery workflow. If any of those become necessary, stop and split the scope.
|
||||
|
||||
**Checkpoint**: Delivery remains attributable, tenant-safe, and bounded to the current export/download seams.
|
||||
|
||||
---
|
||||
|
||||
## Phase 6: Polish & Cross-Cutting Validation
|
||||
|
||||
**Purpose**: Validate the bounded slice and stop without widening scope.
|
||||
|
||||
- [x] T028 [P] Run `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/TenantReview/TenantReviewExecutivePackTest.php tests/Feature/TenantReview/TenantReviewExportOperationsUxTest.php tests/Feature/TenantReview/TenantReviewExplanationSurfaceTest.php tests/Feature/TenantReview/TenantReviewUiContractTest.php tests/Feature/TenantReview/TenantReviewAuditLogTest.php tests/Feature/Reviews/CustomerReviewWorkspacePageTest.php tests/Feature/Reviews/CustomerReviewWorkspacePackAccessTest.php tests/Feature/Reviews/CustomerReviewWorkspaceAuthorizationTest.php tests/Feature/ReviewPack/TenantReviewDerivedReviewPackTest.php tests/Feature/ReviewPack/ReviewPackDownloadTest.php` - passed, 41 tests / 326 assertions.
|
||||
- [x] T029 [P] Run `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Browser/Reviews/CustomerReviewWorkspaceSmokeTest.php` - passed, 1 test / 42 assertions.
|
||||
- [x] T030 [P] Run `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail bin pint --dirty --format agent` - passed.
|
||||
- [x] T031 [P] Review touched code to confirm Filament stays on Livewire v4, provider registration remains unchanged in `apps/platform/bootstrap/providers.php`, no globally searchable resource contract changes, and no new asset strategy appears.
|
||||
- [x] T032 [P] Review touched code to confirm the bundle stays on the current `ReviewPack` family and the current `ReviewPackGenerate` run path.
|
||||
- [x] T033 [P] Record the final guardrail, smoke, and scope-boundary outcomes in the active feature close-out without reopening branding, PDF, scheduling, or second-artifact follow-up work. Outcome: no new panel, provider, global search, asset strategy, run type, artifact family, PDF/reporting engine, branding, scheduling, or second delivery workflow; browser smoke passed on the existing Customer Review Workspace handoff.
|
||||
|
||||
---
|
||||
|
||||
## 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 current bundle contract.
|
||||
- **Phase 4 (US2)**: depends on Phase 2 and should land with US1 so the new bundle contract and the in-app delivery language stay aligned.
|
||||
- **Phase 5 (US3)**: depends on Phase 2 and hardens audit and entitlement behavior after the bundle contract 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 stakeholder-ready bundle.
|
||||
- **US2 (P1)**: independently testable after Phase 2 and should ship with US1 so the delivered bundle and in-app delivery language do not drift apart.
|
||||
- **US3 (P2)**: independently testable after Phase 2 and hardens the bounded delivery path.
|
||||
|
||||
### Within Each User Story
|
||||
|
||||
- Write the listed Pest coverage first and make it fail for the intended gap.
|
||||
- Keep implementation inside the current review-pack, review, download, localization, and audit seams 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 current bundle becomes stakeholder-ready and the current in-app delivery surfaces explain it correctly.
|
||||
|
||||
### Incremental Delivery
|
||||
|
||||
1. Complete Phase 1 and Phase 2.
|
||||
2. Deliver US1 and US2 together on the current `ReviewPack` family.
|
||||
3. Add US3 to confirm audit and entitlement continuity.
|
||||
4. Finish with the focused validation and drift-review tasks in Phase 6.
|
||||
|
||||
### Team Strategy
|
||||
|
||||
1. Settle the bundle contract first.
|
||||
2. Parallelize failing tests within each story before runtime edits.
|
||||
3. Serialize merges around `ViewTenantReview`, `CustomerReviewWorkspace`, and shared localization keys so delivery wording stays coherent.
|
||||
|
||||
---
|
||||
|
||||
## Deferred Follow-Ups / Non-Goals
|
||||
|
||||
- PDF tooling or richer print/export rendering
|
||||
- recurring delivery or scheduled distribution
|
||||
- branded or customer-specific delivery variants
|
||||
- multi-review or multi-tenant delivery batches
|
||||
- a second artifact family or a standalone auditor-portal surface
|
||||
@ -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.
|
||||
230
specs/264-cross-tenant-promotion-execution/plan.md
Normal file
230
specs/264-cross-tenant-promotion-execution/plan.md
Normal 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.
|
||||
311
specs/264-cross-tenant-promotion-execution/spec.md
Normal file
311
specs/264-cross-tenant-promotion-execution/spec.md
Normal 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.
|
||||
184
specs/264-cross-tenant-promotion-execution/tasks.md
Normal file
184
specs/264-cross-tenant-promotion-execution/tasks.md
Normal 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
|
||||
Loading…
Reference in New Issue
Block a user