diff --git a/apps/platform/app/Filament/Pages/BaselineCompareLanding.php b/apps/platform/app/Filament/Pages/BaselineCompareLanding.php index c9d29629..f6cfea3f 100644 --- a/apps/platform/app/Filament/Pages/BaselineCompareLanding.php +++ b/apps/platform/app/Filament/Pages/BaselineCompareLanding.php @@ -14,6 +14,7 @@ use App\Models\Workspace; use App\Services\Auth\CapabilityResolver; use App\Services\Baselines\BaselineCompareService; +use App\Services\Baselines\BaselineSubjectResolutionQuery; use App\Support\Auth\Capabilities; use App\Support\Baselines\BaselineCaptureMode; use App\Support\Baselines\BaselineCompareEvidenceGapDetails; @@ -26,6 +27,7 @@ use App\Support\OperationRunLinks; use App\Support\OperationRunStatus; use App\Support\OperationRunType; +use App\Support\ManagedEnvironmentLinks; use App\Support\OpsUx\OperationUxPresenter; use App\Support\OpsUx\OpsUxBrowserEvents; use App\Support\Rbac\UiEnforcement; @@ -192,6 +194,8 @@ public static function shouldRegisterNavigation(): bool /** @var array|null */ public ?array $summaryAssessment = null; + public ?int $subjectResolutionActionCount = null; + /** @var array|null */ public ?array $navigationContextPayload = null; @@ -312,6 +316,13 @@ private function refreshStatsForEnvironment(?ManagedEnvironment $tenant): void $this->rbacRoleDefinitionSummary = $stats->rbacRoleDefinitionSummary !== [] ? $stats->rbacRoleDefinitionSummary : null; $this->operatorExplanation = $stats->operatorExplanation()->toArray(); $this->summaryAssessment = $aggregate?->summaryAssessment->toArray(); + + $subjectResolutionSummary = $tenant instanceof ManagedEnvironment + ? app(BaselineSubjectResolutionQuery::class)->summary($tenant, $stats->operationRunId) + : null; + $this->subjectResolutionActionCount = is_array($subjectResolutionSummary) + ? (int) ($subjectResolutionSummary['actionable_count'] ?? 0) + : null; } /** @@ -411,6 +422,8 @@ protected function getViewData(): array 'whyNoFindingsColor' => $whyNoFindingsColor, 'summaryAssessment' => is_array($this->summaryAssessment) ? $this->summaryAssessment : null, 'reasonSemantics' => $reasonSemantics, + 'subjectResolutionActionCount' => (int) ($this->subjectResolutionActionCount ?? 0), + 'subjectResolutionUrl' => $this->subjectResolutionUrl(), ]; } @@ -1295,6 +1308,19 @@ public function getRunUrl(): ?string return OperationRunLinks::view($this->operationRunId, $tenant); } + public function subjectResolutionUrl(): ?string + { + $tenant = $this->currentEnvironment(); + + if (! $tenant instanceof ManagedEnvironment) { + return null; + } + + return ManagedEnvironmentLinks::baselineSubjectResolutionUrl($tenant, array_filter([ + 'operation_run_id' => $this->operationRunId, + ], static fn (mixed $value): bool => $value !== null && $value !== '')); + } + public function openCompareMatrixUrl(): ?string { $profile = $this->resolveCompareMatrixProfile(); diff --git a/apps/platform/app/Filament/Pages/BaselineSubjectResolution.php b/apps/platform/app/Filament/Pages/BaselineSubjectResolution.php new file mode 100644 index 00000000..f3cd06dc --- /dev/null +++ b/apps/platform/app/Filament/Pages/BaselineSubjectResolution.php @@ -0,0 +1,1071 @@ + $parameters + */ + public static function getUrl(array $parameters = [], bool $isAbsolute = true, ?string $panel = null, ?Model $tenant = null): string + { + $panelId = $panel ?? Filament::getCurrentOrDefaultPanel()?->getId() ?? 'admin'; + + if ($panelId !== 'admin') { + return parent::getUrl($parameters, $isAbsolute, $panelId, $tenant); + } + + $environment = static::resolveAdminUrlEnvironment($parameters, $tenant); + + if (! $environment instanceof ManagedEnvironment) { + return url('/admin'); + } + + $workspace = static::resolveAdminUrlWorkspace($environment, $parameters); + + if (! $workspace instanceof Workspace && ! is_string($workspace) && ! is_int($workspace)) { + return url('/admin'); + } + + $parameters = static::withoutLegacyScopeQuery($parameters); + $parameters['environment'] = $environment; + $parameters['workspace'] = $workspace instanceof Workspace + ? static::workspaceRouteKey($workspace) + : $workspace; + + return parent::getUrl($parameters, $isAbsolute, $panelId, null); + } + + public static function canAccess(): bool + { + $environment = static::resolveRouteOwnedEnvironment(); + $user = auth()->user(); + + if (! $environment instanceof ManagedEnvironment || ! $user instanceof User) { + return false; + } + + if (! static::routeWorkspaceMatchesEnvironment($environment)) { + return false; + } + + return app(ManagedEnvironmentAccessScopeResolver::class) + ->decision($user, $environment, Capabilities::WORKSPACE_BASELINES_VIEW) + ->allowed(); + } + + public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration + { + return ActionSurfaceDeclaration::forPage(ActionSurfaceProfile::CrudListAndView) + ->withListRowPrimaryActionLimit(1) + ->satisfy(ActionSurfaceSlot::ListHeader, 'The page header exposes only the delegated compare rerun action and neutral navigation back to compare.') + ->satisfy(ActionSurfaceSlot::ListRowMoreMenu, 'Row decisions are constrained to one primary binding action plus confirmed secondary decision/revoke actions.') + ->satisfy(ActionSurfaceSlot::ListEmptyState, 'The table distinguishes no compare context from no decisions required.') + ->exempt(ActionSurfaceSlot::DetailHeader, 'Spec 384 V1 does not add a separate detail route; subject context is shown in the row and confirmed action modals.') + ->exempt(ActionSurfaceSlot::InspectAffordance, 'Rows expose compact candidate and decision context inline through the table and modal copy instead of a second detail route in v1.') + ->exempt(ActionSurfaceSlot::ListBulkMoreGroup, 'Baseline subject decisions are high-impact and intentionally have no bulk action in v1.'); + } + + public function mount(ManagedEnvironment|string|null $environment = null): void + { + $tenant = static::resolveRouteOwnedEnvironment($environment); + $this->authorizeEnvironmentOrAbort($tenant, Capabilities::WORKSPACE_BASELINES_VIEW); + + $this->scopedEnvironmentId = (int) $tenant->getKey(); + $this->focusedOperationRunId = $this->scopedOperationRunIdFromQuery($tenant); + $this->heading = $tenant->getFilamentName(); + $this->subheading = 'Baseline subject resolution'; + + $this->mountInteractsWithTable(); + } + + public function table(Table $table): Table + { + return $table + ->queryStringIdentifier('baselineSubjectResolution') + ->defaultSort('readiness_impact') + ->defaultPaginationPageOption(25) + ->paginated(TablePaginationProfiles::customPage()) + ->searchable() + ->searchPlaceholder('Search subject, reason, provider, decision') + ->records(function ( + ?string $sortColumn, + ?string $sortDirection, + ?string $search, + array $filters, + int $page, + int $recordsPerPage + ): LengthAwarePaginator { + $rows = $this->filteredRows($filters, $search); + $rows = $this->sortRows($rows, $sortColumn, $sortDirection); + + return $this->paginateRows($rows, $page, $recordsPerPage); + }) + ->filters([ + SelectFilter::make('provider') + ->label('Provider') + ->options(fn (): array => $this->filterOptions('provider')), + SelectFilter::make('subject_class') + ->label('Class') + ->options(fn (): array => $this->filterOptions('subject_class')), + SelectFilter::make('resource_type') + ->label('Resource type') + ->options(fn (): array => $this->filterOptions('resource_type')), + SelectFilter::make('actionability') + ->label('Actionability') + ->options(fn (): array => $this->filterOptions('actionability')), + SelectFilter::make('readiness_impact') + ->label('Readiness') + ->options(fn (): array => $this->filterOptions('readiness_impact')), + SelectFilter::make('reason') + ->label('Reason') + ->options(fn (): array => $this->filterOptions('reason')), + SelectFilter::make('active_binding') + ->label('Decision') + ->options([ + 'yes' => 'Decision recorded', + 'no' => 'No decision', + ]), + SelectFilter::make('candidates') + ->label('Candidates') + ->options([ + 'yes' => 'Has candidates', + 'no' => 'No candidates', + ]), + ]) + ->columns([ + TextColumn::make('subject_label') + ->label('Subject') + ->description(fn (Model $record): string => (string) $record->getAttribute('resource_type_label')) + ->searchable() + ->sortable() + ->wrap(), + TextColumn::make('reason_label') + ->label('Problem') + ->badge() + ->color('warning') + ->searchable() + ->sortable() + ->wrap(), + TextColumn::make('readiness_label') + ->label('Readiness') + ->badge() + ->color(fn (Model $record): string => $this->readinessColor((string) $record->getAttribute('readiness_impact'))) + ->sortable() + ->wrap(), + TextColumn::make('actionability_label') + ->label('Actionability') + ->badge() + ->color('gray') + ->sortable() + ->wrap(), + TextColumn::make('candidate_count') + ->label('Candidates') + ->numeric() + ->sortable(), + TextColumn::make('current_decision_label') + ->label('Current decision') + ->badge() + ->color(fn (Model $record): string => $record->getAttribute('active_binding_id') === null ? 'gray' : 'success') + ->sortable() + ->wrap(), + TextColumn::make('source_operation_run_id') + ->label('Source') + ->formatStateUsing(fn (mixed $state): string => is_numeric($state) ? 'Operation #'.(int) $state : 'Latest compare') + ->sortable(), + TextColumn::make('provider_label') + ->label('Provider') + ->toggleable(isToggledHiddenByDefault: true) + ->sortable(), + TextColumn::make('subject_class_label') + ->label('Class') + ->toggleable(isToggledHiddenByDefault: true) + ->sortable(), + TextColumn::make('trust_level') + ->label('Trust') + ->toggleable(isToggledHiddenByDefault: true) + ->formatStateUsing(fn (?string $state): string => $state !== null ? Str::of($state)->replace('_', ' ')->headline()->toString() : 'Unknown'), + ]) + ->actions([ + $this->bindSubjectAction(), + $this->recordDecisionAction(), + $this->revokeDecisionAction(), + ]) + ->bulkActions([]) + ->emptyStateHeading(fn (): string => $this->emptyStateHeading()) + ->emptyStateDescription(fn (): string => $this->emptyStateDescription()) + ->emptyStateActions([ + $this->runCompareEmptyStateAction(), + ]); + } + + /** + * @return array + */ + protected function getViewData(): array + { + $tenant = $this->currentEnvironment(); + $summary = $tenant instanceof ManagedEnvironment + ? $this->query()->summary($tenant, $this->focusedOperationRunId) + : [ + 'has_run' => false, + 'actionable_count' => 0, + 'source_operation_run_id' => null, + 'by_actionability' => [], + 'by_readiness_impact' => [], + 'by_reason' => [], + 'legacy_payload_only' => false, + ]; + + return [ + 'summary' => $summary, + 'compareUrl' => $tenant instanceof ManagedEnvironment ? ManagedEnvironmentLinks::baselineCompareUrl($tenant) : null, + 'sourceRunUrl' => $tenant instanceof ManagedEnvironment && is_numeric($summary['source_operation_run_id'] ?? null) + ? OperationRunLinks::view((int) $summary['source_operation_run_id'], $tenant) + : null, + ]; + } + + /** + * @return array + */ + protected function getHeaderActions(): array + { + $actions = []; + $tenant = $this->currentEnvironment(); + + if ($tenant instanceof ManagedEnvironment) { + $actions[] = Action::make('openBaselineCompare') + ->label('Open baseline compare') + ->icon('heroicon-o-arrow-left') + ->color('gray') + ->url(ManagedEnvironmentLinks::baselineCompareUrl($tenant)); + } + + $actions[] = $this->runCompareAction('runComparisonAgain'); + + return $actions; + } + + public function currentEnvironment(): ?ManagedEnvironment + { + $tenant = $this->scopedEnvironmentId === null + ? static::resolveRouteOwnedEnvironment() + : ManagedEnvironment::query() + ->withTrashed() + ->whereKey($this->scopedEnvironmentId) + ->first(); + + if (! $tenant instanceof ManagedEnvironment) { + return null; + } + + $this->authorizeEnvironmentOrAbort($tenant, Capabilities::WORKSPACE_BASELINES_VIEW); + + return $tenant; + } + + private function bindSubjectAction(): Action + { + $action = Action::make('bindSubject') + ->label('Bind subject') + ->icon('heroicon-o-link') + ->visible(fn (Model $record): bool => (int) $record->getAttribute('candidate_count') > 0) + ->form([ + Select::make('candidate_key') + ->label('Provider resource') + ->required() + ->native(false) + ->options(fn (Model $record): array => collect($record->getAttribute('candidates') ?? []) + ->mapWithKeys(fn (array $candidate): array => [ + (string) $candidate['candidate_key'] => trim(implode(' - ', array_filter([ + (string) ($candidate['display_label'] ?? 'Provider resource'), + (string) ($candidate['provider_label'] ?? ''), + (string) ($candidate['stable_identity_preview'] ?? ''), + ]))), + ]) + ->all()), + Textarea::make('operator_note') + ->label('Operator note') + ->required() + ->minLength(8) + ->rows(4) + ->helperText('TenantPilot decision only. This does not mutate the provider tenant.'), + ]) + ->requiresConfirmation() + ->modalHeading('Bind subject') + ->modalDescription('Record a TenantPilot-only subject binding. Future baseline compares can consume this active decision.') + ->modalSubmitActionLabel('Bind subject') + ->action(function (Model $record, array $data): void { + $tenant = $this->authorizedMutationEnvironment(); + $actor = $this->actorOrAbort(); + $row = $this->freshRowOrAbort($record); + $candidate = $this->candidateFromRow($row, (string) ($data['candidate_key'] ?? '')); + + app(ProviderResourceBindingService::class)->createManualBinding( + actor: $actor, + environment: $tenant, + identity: ResourceIdentity::fromArray($candidate['identity']), + attributes: $this->bindingAttributes($row, $data, $candidate), + ); + + Notification::make() + ->success() + ->title('Subject binding recorded') + ->body('Run baseline compare again to validate the decision against current evidence.') + ->send(); + + $this->resetTable(); + }); + + return UiEnforcement::forTableAction($action, fn (): ?ManagedEnvironment => $this->currentEnvironment()) + ->requireCapability(Capabilities::WORKSPACE_BASELINES_MANAGE) + ->preserveVisibility() + ->apply(); + } + + private function recordDecisionAction(): Action + { + $action = Action::make('recordDecision') + ->label('Record decision') + ->icon('heroicon-o-check-circle') + ->color('gray') + ->visible(fn (Model $record): bool => is_array($record->getAttribute('decision_identity')) && $record->getAttribute('active_binding_id') === null) + ->form([ + Select::make('decision') + ->label('Decision') + ->required() + ->native(false) + ->options([ + 'excluded_non_governed' => 'Exclude subject', + 'accepted_limitation' => 'Accept limitation', + 'unsupported_coverage' => 'Mark unsupported', + 'missing_expected' => 'Mark missing expected', + ]), + Textarea::make('operator_note') + ->label('Operator note') + ->required() + ->minLength(8) + ->rows(4) + ->helperText('TenantPilot decision only. This does not mutate the provider tenant.'), + ]) + ->requiresConfirmation() + ->modalHeading('Record subject decision') + ->modalDescription('Record a TenantPilot-only decision for this baseline subject. Future baseline compares can consume the active decision.') + ->modalSubmitActionLabel('Record decision') + ->action(function (Model $record, array $data): void { + $tenant = $this->authorizedMutationEnvironment(); + $actor = $this->actorOrAbort(); + $row = $this->freshRowOrAbort($record); + $identityPayload = $row['decision_identity'] ?? null; + + if (! is_array($identityPayload)) { + throw new InvalidArgumentException('A valid provider resource identity is required for this decision.'); + } + + $identity = ResourceIdentity::fromArray($identityPayload); + $attributes = $this->bindingAttributes($row, $data); + + match ((string) ($data['decision'] ?? '')) { + 'excluded_non_governed' => app(ProviderResourceBindingService::class)->createExclusion($actor, $tenant, $identity, $attributes), + 'accepted_limitation' => app(ProviderResourceBindingService::class)->createAcceptedLimitation($actor, $tenant, $identity, $attributes), + 'unsupported_coverage' => app(ProviderResourceBindingService::class)->markUnsupported($actor, $tenant, $identity, $attributes), + 'missing_expected' => app(ProviderResourceBindingService::class)->markMissingExpected($actor, $tenant, $identity, $attributes), + default => throw new InvalidArgumentException('Unsupported baseline subject decision.'), + }; + + Notification::make() + ->success() + ->title('Subject decision recorded') + ->body('Run baseline compare again to validate the decision against current evidence.') + ->send(); + + $this->resetTable(); + }); + + return UiEnforcement::forTableAction($action, fn (): ?ManagedEnvironment => $this->currentEnvironment()) + ->requireCapability(Capabilities::WORKSPACE_BASELINES_MANAGE) + ->preserveVisibility() + ->apply(); + } + + private function revokeDecisionAction(): Action + { + $action = Action::make('revokeDecision') + ->label('Revoke decision') + ->icon('heroicon-o-no-symbol') + ->color('danger') + ->visible(fn (Model $record): bool => is_numeric($record->getAttribute('active_binding_id'))) + ->form([ + Textarea::make('operator_note') + ->label('Operator note') + ->required() + ->minLength(8) + ->rows(4) + ->helperText('TenantPilot decision only. This does not mutate the provider tenant.'), + ]) + ->requiresConfirmation() + ->modalHeading('Revoke subject decision') + ->modalDescription('Revoke the active TenantPilot subject decision. Future baseline compares will no longer consume this decision.') + ->modalSubmitActionLabel('Revoke decision') + ->action(function (Model $record, array $data): void { + $this->authorizedMutationEnvironment(); + $actor = $this->actorOrAbort(); + $row = $this->freshRowOrAbort($record); + $bindingId = $row['active_binding_id'] ?? null; + + if (! is_numeric($bindingId)) { + throw new NotFoundHttpException; + } + + $binding = ProviderResourceBinding::query() + ->whereKey((int) $bindingId) + ->firstOrFail(); + + Gate::forUser($actor)->authorize('revoke', $binding); + + app(ProviderResourceBindingService::class)->revoke( + actor: $actor, + binding: $binding, + operatorNote: (string) ($data['operator_note'] ?? ''), + resolutionReason: 'baseline_subject_resolution_revoked', + ); + + Notification::make() + ->success() + ->title('Subject decision revoked') + ->body('Run baseline compare again to validate the updated decision state.') + ->send(); + + $this->resetTable(); + }); + + return UiEnforcement::forTableAction($action, fn (): ?ManagedEnvironment => $this->currentEnvironment()) + ->requireCapability(Capabilities::WORKSPACE_BASELINES_MANAGE) + ->destructive() + ->preserveVisibility() + ->apply(); + } + + private function runCompareAction(string $name): Action + { + $label = 'Run comparison again'; + + $action = Action::make($name) + ->label($label) + ->icon('heroicon-o-arrow-path') + ->requiresConfirmation() + ->modalHeading($label) + ->modalDescription('This delegates to the existing baseline compare start flow and queues a compare operation for the current environment.') + ->modalSubmitActionLabel($label) + ->action(function (): void { + $this->startBaselineCompare(); + }); + + return UiEnforcement::forAction($action, fn (): ?ManagedEnvironment => $this->currentEnvironment()) + ->requireCapability(Capabilities::TENANT_SYNC) + ->apply(); + } + + private function runCompareEmptyStateAction(): Action + { + return $this->runCompareAction('runComparisonAgainFromEmptyState') + ->visible(fn (): bool => ! $this->summary()['has_run']); + } + + private function startBaselineCompare(): void + { + $tenant = $this->currentEnvironment(); + $user = auth()->user(); + + if (! $tenant instanceof ManagedEnvironment) { + Notification::make()->title('Open an environment to compare baselines')->danger()->send(); + + return; + } + + if (! $user instanceof User) { + Notification::make()->title('Not authenticated')->danger()->send(); + + return; + } + + $result = app(BaselineCompareService::class)->startCompare($tenant, $user); + + if (! ($result['ok'] ?? false)) { + $reasonCode = is_string($result['reason_code'] ?? null) ? (string) $result['reason_code'] : 'unknown'; + $translation = is_array($result['reason_translation'] ?? null) ? $result['reason_translation'] : []; + $message = is_string($translation['short_explanation'] ?? null) && trim((string) $translation['short_explanation']) !== '' + ? trim((string) $translation['short_explanation']) + : 'Reason: '.$reasonCode; + + Notification::make() + ->title('Cannot start comparison') + ->body($message) + ->danger() + ->send(); + + return; + } + + $run = $result['run'] ?? null; + + if ($run instanceof OperationRun) { + $this->focusedOperationRunId = (int) $run->getKey(); + } + + OpsUxBrowserEvents::dispatchRunEnqueued($this); + + OperationUxPresenter::queuedToast($run instanceof OperationRun ? (string) $run->type : OperationRunType::BaselineCompare->value) + ->actions($run instanceof OperationRun ? [ + Action::make('view_run') + ->label('Open operation') + ->url(OperationRunLinks::view($run, $tenant)), + ] : []) + ->send(); + + $this->resetTable(); + } + + /** + * @return array + */ + private function bindingAttributes(array $row, array $data, ?array $candidate = null): array + { + return [ + 'subject_domain' => (string) ($row['subject_domain'] ?? 'baseline'), + 'subject_class' => (string) ($row['subject_class'] ?? 'policy_backed'), + 'subject_type_key' => (string) ($row['subject_type_key'] ?? 'unknown'), + 'canonical_subject_key' => is_string($row['canonical_subject_key'] ?? null) ? (string) $row['canonical_subject_key'] : null, + 'display_label' => is_string($candidate['display_label'] ?? null) + ? (string) $candidate['display_label'] + : (string) ($row['subject_label'] ?? ''), + 'resolution_reason' => (string) ($row['reason'] ?? 'baseline_subject_resolution'), + 'operator_note' => (string) ($data['operator_note'] ?? ''), + 'source_operation_run_id' => is_numeric($row['source_operation_run_id'] ?? null) ? (int) $row['source_operation_run_id'] : null, + 'source_baseline_snapshot_id' => is_numeric($row['source_baseline_snapshot_id'] ?? null) ? (int) $row['source_baseline_snapshot_id'] : null, + 'source_inventory_item_id' => is_numeric($candidate['source_inventory_item_id'] ?? $row['source_inventory_item_id'] ?? null) + ? (int) ($candidate['source_inventory_item_id'] ?? $row['source_inventory_item_id']) + : null, + 'source_policy_version_id' => is_numeric($candidate['source_policy_version_id'] ?? $row['source_policy_version_id'] ?? null) + ? (int) ($candidate['source_policy_version_id'] ?? $row['source_policy_version_id']) + : null, + ]; + } + + private function candidateFromRow(array $row, string $candidateKey): array + { + $candidate = collect($row['candidates'] ?? []) + ->first(fn (array $candidate): bool => (string) ($candidate['candidate_key'] ?? '') === $candidateKey); + + if (! is_array($candidate) || ! is_array($candidate['identity'] ?? null)) { + throw new InvalidArgumentException('Select a valid provider resource candidate.'); + } + + return $candidate; + } + + private function freshRowOrAbort(Model $record): array + { + $tenant = $this->currentEnvironment(); + + if (! $tenant instanceof ManagedEnvironment) { + throw new NotFoundHttpException; + } + + $row = $this->query()->row( + environment: $tenant, + rowId: (string) $record->getKey(), + filters: [ + 'operation_run_id' => $this->focusedOperationRunId, + 'include_resolved' => true, + ], + ); + + if (! is_array($row)) { + throw new NotFoundHttpException; + } + + return $row; + } + + private function authorizedMutationEnvironment(): ManagedEnvironment + { + $tenant = $this->currentEnvironment(); + $this->authorizeEnvironmentOrAbort($tenant, Capabilities::WORKSPACE_BASELINES_MANAGE); + + return $tenant; + } + + private function actorOrAbort(): User + { + $user = auth()->user(); + + if (! $user instanceof User) { + abort(403); + } + + return $user; + } + + private function authorizeEnvironmentOrAbort(?ManagedEnvironment $environment, string $capability): void + { + $user = auth()->user(); + + if (! $environment instanceof ManagedEnvironment || ! $user instanceof User) { + abort(404); + } + + if (! static::routeWorkspaceMatchesEnvironment($environment)) { + abort(404); + } + + $decision = app(ManagedEnvironmentAccessScopeResolver::class) + ->decision($user, $environment, $capability); + + if ($decision->shouldDenyAsNotFound()) { + abort(404); + } + + if ($decision->shouldDenyAsForbidden()) { + abort(403); + } + } + + /** + * @param array $filters + * @return Collection> + */ + private function filteredRows(array $filters, ?string $search): Collection + { + $tenant = $this->currentEnvironment(); + + if (! $tenant instanceof ManagedEnvironment) { + return collect(); + } + + $normalizedFilters = [ + 'operation_run_id' => $this->focusedOperationRunId, + 'provider' => data_get($filters, 'provider.value'), + 'subject_class' => data_get($filters, 'subject_class.value'), + 'resource_type' => data_get($filters, 'resource_type.value'), + 'actionability' => data_get($filters, 'actionability.value'), + 'readiness_impact' => data_get($filters, 'readiness_impact.value'), + 'reason' => data_get($filters, 'reason.value'), + 'active_binding' => data_get($filters, 'active_binding.value'), + 'candidates' => data_get($filters, 'candidates.value'), + ]; + + $rows = collect($this->query()->rows($tenant, $normalizedFilters)); + $normalizedSearch = Str::lower(trim((string) $search)); + + if ($normalizedSearch === '') { + return $rows; + } + + return $rows + ->filter(fn (array $row): bool => str_contains((string) ($row['search_text'] ?? ''), $normalizedSearch)) + ->values(); + } + + /** + * @param Collection> $rows + * @return Collection> + */ + private function sortRows(Collection $rows, ?string $sortColumn, ?string $sortDirection): Collection + { + $allowed = [ + 'subject_label', + 'reason_label', + 'readiness_label', + 'readiness_impact', + 'actionability_label', + 'candidate_count', + 'current_decision_label', + 'source_operation_run_id', + 'provider_label', + 'subject_class_label', + ]; + $sortColumn = in_array($sortColumn, $allowed, true) ? $sortColumn : 'readiness_impact'; + $descending = Str::lower((string) ($sortDirection ?? 'asc')) === 'desc'; + + return $rows->sortBy( + fn (array $row): string|int => $sortColumn === 'candidate_count' || $sortColumn === 'source_operation_run_id' + ? (int) ($row[$sortColumn] ?? 0) + : (string) ($row[$sortColumn] ?? ''), + SORT_NATURAL | SORT_FLAG_CASE, + $descending, + )->values(); + } + + /** + * @param Collection> $rows + */ + private function paginateRows(Collection $rows, int $page, int $recordsPerPage): LengthAwarePaginator + { + $perPage = max(1, $recordsPerPage); + $currentPage = max(1, $page); + $items = $rows->forPage($currentPage, $perPage) + ->values() + ->map(fn (array $row): Model => $this->toTableRecord($row)); + + return new LengthAwarePaginator( + $items, + $rows->count(), + $perPage, + $currentPage, + ); + } + + /** + * @param array $row + */ + private function toTableRecord(array $row): Model + { + $record = new class extends Model + { + public $timestamps = false; + + public $incrementing = false; + + protected $keyType = 'string'; + + protected $guarded = []; + + protected $table = 'baseline_subject_resolution_rows'; + }; + + $record->forceFill($row); + $record->exists = true; + + return $record; + } + + /** + * @return array + */ + private function filterOptions(string $key): array + { + $tenant = $this->currentEnvironment(); + + if (! $tenant instanceof ManagedEnvironment) { + return []; + } + + return $this->query()->filterOptions($tenant, $this->focusedOperationRunId, $key); + } + + private function emptyStateHeading(): string + { + $summary = $this->summary(); + + if (! $summary['has_run']) { + return 'Run baseline compare first'; + } + + if ($summary['legacy_payload_only']) { + return 'Structured subject decisions unavailable'; + } + + return 'No baseline subject decisions required'; + } + + private function emptyStateDescription(): string + { + return match ($this->emptyStateHeading()) { + 'Run baseline compare first' => 'No baseline compare run exists for this environment yet.', + 'Structured subject decisions unavailable' => 'The latest compare run does not contain Spec 383 subject outcome semantics. Run baseline compare again to refresh the decision worklist.', + default => 'The selected compare context has no unresolved or decision-required baseline subjects.', + }; + } + + /** + * @return array + */ + private function summary(): array + { + $tenant = $this->currentEnvironment(); + + if (! $tenant instanceof ManagedEnvironment) { + return ['has_run' => false, 'legacy_payload_only' => false, 'actionable_count' => 0]; + } + + return $this->query()->summary($tenant, $this->focusedOperationRunId); + } + + private function readinessColor(string $readinessImpact): string + { + return match ($readinessImpact) { + 'customer_blocker', + 'internal_blocker' => 'danger', + 'customer_limitation', + 'internal_limitation' => 'warning', + 'no_impact' => 'success', + default => 'gray', + }; + } + + private function scopedOperationRunIdFromQuery(ManagedEnvironment $tenant): ?int + { + $operationRunId = request()->query('operation_run_id'); + + if (! is_numeric($operationRunId)) { + return null; + } + + $run = $this->query()->resolveRun($tenant, (int) $operationRunId); + + return $run instanceof OperationRun ? (int) $run->getKey() : null; + } + + protected static function resolveRouteOwnedEnvironment(ManagedEnvironment|string|null $environment = null): ?ManagedEnvironment + { + if ($environment instanceof ManagedEnvironment) { + return $environment; + } + + if (is_string($environment) && $environment !== '') { + return ManagedEnvironment::query() + ->where('slug', $environment) + ->first(); + } + + $routeEnvironment = request()->route('environment') ?? request()->route('tenant'); + + if ($routeEnvironment instanceof ManagedEnvironment) { + return $routeEnvironment; + } + + if (is_string($routeEnvironment) && $routeEnvironment !== '') { + return ManagedEnvironment::query() + ->where('slug', $routeEnvironment) + ->first(); + } + + $refererEnvironment = static::resolveRefererOwnedEnvironment(); + + if ($refererEnvironment instanceof ManagedEnvironment) { + return $refererEnvironment; + } + + $filamentTenant = Filament::getTenant(); + + return $filamentTenant instanceof ManagedEnvironment ? $filamentTenant : null; + } + + private static function resolveRefererOwnedEnvironment(): ?ManagedEnvironment + { + $referer = request()->headers->get('referer'); + + if (! is_string($referer) || $referer === '') { + return null; + } + + $path = parse_url($referer, PHP_URL_PATH); + + if (! is_string($path)) { + return null; + } + + if (preg_match('#^/admin/workspaces/([^/]+)/environments/([^/]+)/baseline-subject-resolution$#', $path, $matches) !== 1) { + return null; + } + + $workspaceRouteKey = rawurldecode($matches[1]); + $environmentRouteKey = rawurldecode($matches[2]); + + $environment = ManagedEnvironment::query() + ->where('slug', $environmentRouteKey) + ->first(); + + if (! $environment instanceof ManagedEnvironment) { + return null; + } + + $workspace = $environment->workspace instanceof Workspace + ? $environment->workspace + : $environment->workspace()->first(); + + if (! $workspace instanceof Workspace) { + return null; + } + + if ($workspaceRouteKey !== static::workspaceRouteKey($workspace) && $workspaceRouteKey !== (string) $workspace->getKey()) { + return null; + } + + return $environment; + } + + private static function routeWorkspaceMatchesEnvironment(ManagedEnvironment $environment): bool + { + $routeWorkspace = request()->route('workspace'); + + if ($routeWorkspace instanceof Workspace) { + return (int) $routeWorkspace->getKey() === (int) $environment->workspace_id; + } + + if (! is_string($routeWorkspace) && ! is_int($routeWorkspace)) { + return true; + } + + $routeWorkspace = trim((string) $routeWorkspace); + + if ($routeWorkspace === '') { + return true; + } + + $workspace = $environment->workspace instanceof Workspace + ? $environment->workspace + : $environment->workspace()->first(); + + if (! $workspace instanceof Workspace) { + return false; + } + + return $routeWorkspace === static::workspaceRouteKey($workspace) + || $routeWorkspace === (string) $workspace->getKey(); + } + + /** + * @param array $parameters + */ + protected static function resolveAdminUrlEnvironment(array $parameters, ?Model $tenant = null): ?ManagedEnvironment + { + if ($tenant instanceof ManagedEnvironment) { + return $tenant; + } + + foreach (['environment', 'tenant', 'managed_environment_id', 'environment_id', 'tenant_id'] as $key) { + $value = $parameters[$key] ?? null; + + if ($value instanceof ManagedEnvironment) { + return $value; + } + + if (is_numeric($value)) { + return ManagedEnvironment::query()->whereKey((int) $value)->first(); + } + + if (is_string($value) && trim($value) !== '') { + return ManagedEnvironment::query()->where('slug', trim($value))->first(); + } + } + + return Filament::getTenant() instanceof ManagedEnvironment ? Filament::getTenant() : null; + } + + /** + * @param array $parameters + */ + protected static function resolveAdminUrlWorkspace(ManagedEnvironment $environment, array $parameters = []): Workspace|string|int|null + { + $workspace = $parameters['workspace'] ?? null; + + if ($workspace instanceof Workspace || is_string($workspace) || is_int($workspace)) { + return $workspace; + } + + return Workspace::query()->whereKey((int) $environment->workspace_id)->first(); + } + + protected static function workspaceRouteKey(Workspace $workspace): string + { + $slug = $workspace->getAttribute('slug'); + + return is_string($slug) && $slug !== '' + ? $slug + : (string) $workspace->getKey(); + } + + /** + * @param array $parameters + * @return array + */ + protected static function withoutLegacyScopeQuery(array $parameters): array + { + foreach (self::LEGACY_SCOPE_QUERY_KEYS as $key) { + unset($parameters[$key]); + } + + return $parameters; + } + + private function query(): BaselineSubjectResolutionQuery + { + return app(BaselineSubjectResolutionQuery::class); + } +} diff --git a/apps/platform/app/Providers/Filament/AdminPanelProvider.php b/apps/platform/app/Providers/Filament/AdminPanelProvider.php index fb68909f..9be44863 100644 --- a/apps/platform/app/Providers/Filament/AdminPanelProvider.php +++ b/apps/platform/app/Providers/Filament/AdminPanelProvider.php @@ -5,6 +5,7 @@ use App\Filament\Clusters\Inventory\InventoryCluster; use App\Filament\Pages\Auth\Login; use App\Filament\Pages\BaselineCompareLanding; +use App\Filament\Pages\BaselineSubjectResolution; use App\Filament\Pages\ChooseEnvironment; use App\Filament\Pages\ChooseWorkspace; use App\Filament\Pages\CrossEnvironmentComparePage; @@ -235,6 +236,7 @@ public function panel(Panel $panel): Panel ->discoverResources(in: app_path('Filament/Resources'), for: 'App\\Filament\\Resources') ->pages([ BaselineCompareLanding::class, + BaselineSubjectResolution::class, InventoryCoverage::class, EnvironmentRequiredPermissions::class, WorkspaceSettings::class, diff --git a/apps/platform/app/Services/Baselines/BaselineSubjectResolutionQuery.php b/apps/platform/app/Services/Baselines/BaselineSubjectResolutionQuery.php new file mode 100644 index 00000000..58eae1e7 --- /dev/null +++ b/apps/platform/app/Services/Baselines/BaselineSubjectResolutionQuery.php @@ -0,0 +1,770 @@ + $filters + * @return list> + */ + public function rows(ManagedEnvironment $environment, array $filters = []): array + { + $run = $this->resolveRun($environment, $this->intFilter($filters, 'operation_run_id')); + + if (! $run instanceof OperationRun) { + return []; + } + + $outcomes = $this->subjectOutcomes($run); + + if ($outcomes === []) { + return []; + } + + $activeBindings = $this->activeBindings($environment); + $inventoryDescriptors = $this->inventoryDescriptors($environment); + + $rows = collect($outcomes) + ->filter(fn (array $outcome): bool => $this->includeOutcome($outcome, $filters)) + ->values() + ->map(fn (array $outcome, int $index): array => $this->rowFromOutcome( + outcome: $outcome, + index: $index, + run: $run, + environment: $environment, + activeBindings: $activeBindings, + inventoryDescriptors: $inventoryDescriptors, + )) + ->filter(fn (array $row): bool => $this->matchesFilters($row, $filters)) + ->sortBy([ + fn (array $row): int => $this->readinessSortWeight((string) ($row['readiness_impact'] ?? '')), + fn (array $row): int => $row['active_binding_id'] !== null ? 1 : 0, + fn (array $row): string => (string) ($row['subject_label'] ?? ''), + ]) + ->values(); + + return $rows->all(); + } + + /** + * @param array $filters + */ + public function row(ManagedEnvironment $environment, string $rowId, array $filters = []): ?array + { + return collect($this->rows($environment, $filters)) + ->first(fn (array $row): bool => (string) ($row['id'] ?? '') === $rowId); + } + + /** + * @return array{ + * has_run: bool, + * source_operation_run_id: int|null, + * actionable_count: int, + * visible_count: int, + * by_actionability: array, + * by_readiness_impact: array, + * by_reason: array, + * legacy_payload_only: bool + * } + */ + public function summary(ManagedEnvironment $environment, ?int $operationRunId = null): array + { + $run = $this->resolveRun($environment, $operationRunId); + $rows = $run instanceof OperationRun + ? $this->rows($environment, ['operation_run_id' => (int) $run->getKey()]) + : []; + $outcomes = $run instanceof OperationRun ? $this->subjectOutcomes($run) : []; + + return [ + 'has_run' => $run instanceof OperationRun, + 'source_operation_run_id' => $run instanceof OperationRun ? (int) $run->getKey() : null, + 'actionable_count' => count($rows), + 'visible_count' => count($rows), + 'by_actionability' => $this->countBy($rows, 'actionability'), + 'by_readiness_impact' => $this->countBy($rows, 'readiness_impact'), + 'by_reason' => $this->countBy($rows, 'reason'), + 'legacy_payload_only' => $run instanceof OperationRun + && $outcomes === [] + && is_array(data_get($run->context, 'baseline_compare.evidence_gaps')), + ]; + } + + public function resolveRun(ManagedEnvironment $environment, ?int $operationRunId = null): ?OperationRun + { + $query = OperationRun::query() + ->where('workspace_id', (int) $environment->workspace_id) + ->where('managed_environment_id', (int) $environment->getKey()) + ->whereIn('type', OperationCatalog::rawValuesForCanonical(OperationRunType::BaselineCompare->value)); + + if ($operationRunId !== null && $operationRunId > 0) { + return $query->whereKey($operationRunId)->first(); + } + + return $query + ->latest('completed_at') + ->latest('id') + ->first(); + } + + /** + * @return array + */ + public function filterOptions(ManagedEnvironment $environment, ?int $operationRunId = null, string $key = 'all'): array + { + $rows = collect($this->rows($environment, array_filter([ + 'operation_run_id' => $operationRunId, + 'include_resolved' => true, + ], static fn (mixed $value): bool => $value !== null))); + + $map = match ($key) { + 'provider' => $rows->pluck('provider_label', 'provider_key'), + 'subject_class' => $rows->pluck('subject_class_label', 'subject_class'), + 'resource_type' => $rows->pluck('resource_type_label', 'subject_type_key'), + 'actionability' => $rows->pluck('actionability_label', 'actionability'), + 'readiness_impact' => $rows->pluck('readiness_label', 'readiness_impact'), + 'reason' => $rows->pluck('reason_label', 'reason'), + default => collect(), + }; + + return $map + ->filter(fn (mixed $label, mixed $value): bool => is_string($value) && $value !== '' && is_string($label) && $label !== '') + ->unique() + ->sort() + ->all(); + } + + /** + * @return list> + */ + private function subjectOutcomes(OperationRun $run): array + { + $outcomes = data_get($run->context, 'baseline_compare.result_semantics.subject_outcomes'); + + if (! is_array($outcomes)) { + return []; + } + + return collect($outcomes) + ->filter(fn (mixed $outcome): bool => is_array($outcome)) + ->values() + ->all(); + } + + /** + * @return EloquentCollection + */ + private function activeBindings(ManagedEnvironment $environment): EloquentCollection + { + return ProviderResourceBinding::query() + ->where('workspace_id', (int) $environment->workspace_id) + ->where('managed_environment_id', (int) $environment->getKey()) + ->where('binding_status', ProviderResourceBindingStatus::Active->value) + ->latest('decided_at') + ->get(); + } + + /** + * @return Collection + */ + private function inventoryDescriptors(ManagedEnvironment $environment): Collection + { + return InventoryItem::query() + ->where('workspace_id', (int) $environment->workspace_id) + ->where('managed_environment_id', (int) $environment->getKey()) + ->latest('last_seen_at') + ->get() + ->map(fn (InventoryItem $item): ?ProviderResourceDescriptor => $this->descriptorFromInventoryItem($item)) + ->filter() + ->values(); + } + + /** + * @param EloquentCollection $activeBindings + * @param Collection $inventoryDescriptors + * @return array + */ + private function rowFromOutcome( + array $outcome, + int $index, + OperationRun $run, + ManagedEnvironment $environment, + EloquentCollection $activeBindings, + Collection $inventoryDescriptors, + ): array { + $subject = is_array($outcome['subject'] ?? null) ? $outcome['subject'] : []; + $proof = is_array($outcome['proof'] ?? null) ? $outcome['proof'] : []; + $subjectTypeKey = $this->stringValue( + $subject['subject_type_key'] ?? $subject['policy_type'] ?? $proof['policy_type'] ?? null, + ) ?? 'unknown'; + $subjectClass = $this->stringValue($subject['subject_class'] ?? null) ?? SubjectClass::PolicyBacked->value; + $subjectDomain = $this->stringValue($subject['subject_domain'] ?? $subject['domain_key'] ?? null) ?? 'baseline'; + $subjectKey = $this->stringValue($subject['canonical_subject_key'] ?? $subject['subject_key'] ?? null); + $canonicalSubjectKey = $this->canonicalSubjectKey($subject); + $displayLabel = $this->stringValue( + $subject['display_label'] + ?? $subject['operator_label'] + ?? $subject['subject_key'] + ?? $subject['external_subject_id'] + ?? null, + ); + $providerDescriptor = $this->descriptorFromSubject($subject); + $decisionIdentity = $providerDescriptor?->identity; + $candidates = $this->candidateRows( + subject: $subject, + subjectTypeKey: $subjectTypeKey, + subjectClass: $subjectClass, + canonicalSubjectKey: $canonicalSubjectKey, + outcomeDescriptors: $this->candidateDescriptorsFromOutcome($outcome), + inventoryDescriptors: $inventoryDescriptors, + ); + $activeBinding = $this->activeBindingFor( + activeBindings: $activeBindings, + canonicalSubjectKey: $canonicalSubjectKey, + decisionIdentity: $decisionIdentity, + ); + $providerKey = $decisionIdentity?->providerKey + ?? ($candidates[0]['provider_key'] ?? null) + ?? $this->stringValue($subject['provider_key'] ?? $proof['provider_key'] ?? null) + ?? 'unknown'; + + $reason = $this->stringValue($outcome['reason'] ?? null) ?? 'unknown'; + $actionability = $this->stringValue($outcome['actionability'] ?? null) ?? 'unknown'; + $readinessImpact = $this->stringValue($outcome['readiness_impact'] ?? null) ?? 'unknown'; + $rowId = $this->rowId($run, $index, $reason, $subjectTypeKey, $subjectKey, $canonicalSubjectKey); + $sourceReferences = is_array($subject['source_references'] ?? null) ? $subject['source_references'] : []; + + return [ + 'id' => $rowId, + 'workspace_id' => (int) $environment->workspace_id, + 'managed_environment_id' => (int) $environment->getKey(), + 'source_operation_run_id' => (int) $run->getKey(), + 'source_baseline_snapshot_id' => is_numeric(data_get($run->context, 'baseline_snapshot_id')) + ? (int) data_get($run->context, 'baseline_snapshot_id') + : null, + 'subject_domain' => $subjectDomain, + 'subject_class' => $subjectClass, + 'subject_class_label' => $this->label($subjectClass), + 'subject_type_key' => $subjectTypeKey, + 'resource_type_label' => InventoryPolicyTypeMeta::baselineCompareLabel($subjectTypeKey) + ?? InventoryPolicyTypeMeta::label($subjectTypeKey) + ?? $this->label($subjectTypeKey), + 'subject_key' => $subjectKey, + 'canonical_subject_key' => $canonicalSubjectKey, + 'subject_label' => $displayLabel ?: ($subjectKey ?: $this->label($subjectTypeKey)), + 'provider_key' => (string) $providerKey, + 'provider_label' => $this->label((string) $providerKey), + 'reason' => $reason, + 'reason_label' => $this->label($reason), + 'actionability' => $actionability, + 'actionability_label' => $this->actionabilityLabel($actionability), + 'readiness_impact' => $readinessImpact, + 'readiness_label' => $this->readinessLabel($readinessImpact), + 'identity_status' => $this->stringValue($outcome['identity_status'] ?? null), + 'comparison_status' => $this->stringValue($outcome['comparison_status'] ?? null), + 'coverage_status' => $this->stringValue($outcome['coverage_status'] ?? null), + 'trust_level' => $this->stringValue($outcome['trust_level'] ?? null), + 'candidate_count' => count($candidates), + 'candidates' => $candidates, + 'has_candidates' => $candidates !== [], + 'decision_identity' => $decisionIdentity?->toArray(), + 'active_binding_id' => $activeBinding instanceof ProviderResourceBinding ? (int) $activeBinding->getKey() : null, + 'active_binding_mode' => $activeBinding instanceof ProviderResourceBinding + ? $this->enumValue($activeBinding->resolution_mode) + : null, + 'current_decision_label' => $activeBinding instanceof ProviderResourceBinding + ? $this->label($this->enumValue($activeBinding->resolution_mode)) + : 'None recorded', + 'source_inventory_item_id' => is_numeric($sourceReferences['inventory_item_id'] ?? null) + ? (int) $sourceReferences['inventory_item_id'] + : null, + 'source_policy_version_id' => is_numeric($sourceReferences['policy_version_id'] ?? null) + ? (int) $sourceReferences['policy_version_id'] + : null, + 'last_seen' => $candidates[0]['last_seen_at'] ?? null, + 'search_text' => Str::lower(implode(' ', array_filter([ + $displayLabel, + $subjectKey, + $canonicalSubjectKey, + $subjectTypeKey, + $subjectClass, + $providerKey, + $reason, + $actionability, + $readinessImpact, + $activeBinding?->display_label, + ]))), + ]; + } + + /** + * @return Collection + */ + private function candidateDescriptorsFromOutcome(array $outcome): Collection + { + return collect([ + data_get($outcome, 'candidate_descriptors'), + data_get($outcome, 'subject.candidate_descriptors'), + data_get($outcome, 'proof.candidate_descriptors'), + data_get($outcome, 'candidates'), + data_get($outcome, 'subject.candidates'), + data_get($outcome, 'proof.candidates'), + ]) + ->flatMap(function (mixed $payload): array { + if (! is_array($payload)) { + return []; + } + + return array_is_list($payload) ? $payload : [$payload]; + }) + ->map(fn (mixed $payload): ?ProviderResourceDescriptor => is_array($payload) + ? $this->descriptorFromPayload($payload) + : null) + ->filter() + ->values(); + } + + /** + * @param Collection $outcomeDescriptors + * @param Collection $inventoryDescriptors + * @return list> + */ + private function candidateRows( + array $subject, + string $subjectTypeKey, + string $subjectClass, + ?string $canonicalSubjectKey, + Collection $outcomeDescriptors, + Collection $inventoryDescriptors, + ): array { + $subjectProviderKey = $this->stringValue(data_get($subject, 'provider_resource_descriptor.identity.provider_key')); + + $outcomeRows = $outcomeDescriptors + ->filter(fn (ProviderResourceDescriptor $descriptor): bool => $this->descriptorMatchesCandidateScope( + descriptor: $descriptor, + subjectTypeKey: $subjectTypeKey, + subjectClass: $subjectClass, + canonicalSubjectKey: $canonicalSubjectKey, + subjectProviderKey: $subjectProviderKey, + requireCanonicalMatch: false, + )) + ->map(fn (ProviderResourceDescriptor $descriptor): array => $this->candidateRow($descriptor)) + ->values(); + + $inventoryRows = $inventoryDescriptors + ->filter(fn (ProviderResourceDescriptor $descriptor): bool => $this->descriptorMatchesCandidateScope( + descriptor: $descriptor, + subjectTypeKey: $subjectTypeKey, + subjectClass: $subjectClass, + canonicalSubjectKey: $canonicalSubjectKey, + subjectProviderKey: $subjectProviderKey, + requireCanonicalMatch: true, + )) + ->map(fn (ProviderResourceDescriptor $descriptor): array => $this->candidateRow($descriptor)) + ->values(); + + return $outcomeRows + ->merge($inventoryRows) + ->unique('candidate_key') + ->values() + ->all(); + } + + private function descriptorMatchesCandidateScope( + ProviderResourceDescriptor $descriptor, + string $subjectTypeKey, + string $subjectClass, + ?string $canonicalSubjectKey, + ?string $subjectProviderKey, + bool $requireCanonicalMatch, + ): bool { + if ($descriptor->subjectTypeKey !== $subjectTypeKey) { + return false; + } + + $descriptorClass = $descriptor->subjectClass instanceof SubjectClass + ? $descriptor->subjectClass->value + : (string) $descriptor->subjectClass; + + if ($subjectClass !== '' && $descriptorClass !== $subjectClass) { + return false; + } + + if ($subjectProviderKey !== null && $descriptor->identity->providerKey !== $subjectProviderKey) { + return false; + } + + $descriptorCanonicalKey = BaselineSubjectKey::forProviderResourceIdentity( + $descriptor->subjectDomain, + $descriptor->subjectClass, + $descriptor->subjectTypeKey, + $descriptor->identity, + ); + + if ($canonicalSubjectKey !== null && $descriptorCanonicalKey === $canonicalSubjectKey) { + return true; + } + + return ! $requireCanonicalMatch; + } + + /** + * @return array + */ + private function candidateRow(ProviderResourceDescriptor $descriptor): array + { + $identity = $descriptor->identity; + $sourceReferences = $descriptor->sourceReferences; + + return [ + 'candidate_key' => $identity->fingerprint(), + 'identity' => $identity->toArray(), + 'display_label' => $descriptor->displayLabel ?: $this->label((string) ($identity->providerResourceType ?? 'resource')), + 'provider_key' => $identity->providerKey, + 'provider_label' => $this->label($identity->providerKey), + 'provider_resource_type' => $identity->providerResourceType, + 'identity_kind' => $identity->identityKind, + 'stable_identity_preview' => $this->preview($identity->stableIdentityValue()), + 'source_inventory_item_id' => is_numeric($sourceReferences['inventory_item_id'] ?? null) + ? (int) $sourceReferences['inventory_item_id'] + : null, + 'source_policy_version_id' => is_numeric($sourceReferences['policy_version_id'] ?? null) + ? (int) $sourceReferences['policy_version_id'] + : null, + 'last_seen_at' => $descriptor->lastSeenAt, + ]; + } + + private function descriptorFromSubject(array $subject): ?ProviderResourceDescriptor + { + $descriptorPayload = $subject['provider_resource_descriptor'] ?? null; + + return is_array($descriptorPayload) ? $this->descriptorFromPayload($descriptorPayload) : null; + } + + /** + * @param array $payload + */ + private function descriptorFromPayload(array $payload): ?ProviderResourceDescriptor + { + $descriptorPayload = $payload['provider_resource_descriptor'] ?? $payload['descriptor'] ?? $payload; + + if (! is_array($descriptorPayload)) { + return null; + } + + try { + return ProviderResourceDescriptor::fromArray($descriptorPayload); + } catch (InvalidArgumentException) { + return null; + } + } + + private function descriptorFromInventoryItem(InventoryItem $inventoryItem): ?ProviderResourceDescriptor + { + $metaJsonb = is_array($inventoryItem->meta_jsonb) ? $inventoryItem->meta_jsonb : []; + $descriptorPayload = $metaJsonb['provider_resource_descriptor'] ?? null; + + if (is_array($descriptorPayload)) { + try { + return ProviderResourceDescriptor::fromArray($descriptorPayload); + } catch (InvalidArgumentException) { + return null; + } + } + + $identity = $this->resourceIdentityFromMeta( + metaJsonb: $metaJsonb, + fallbackProviderKey: $this->stringValue($metaJsonb['provider_key'] ?? $metaJsonb['provider'] ?? null) ?? 'inventory', + fallbackResourceType: $this->stringValue($metaJsonb['provider_resource_type'] ?? $metaJsonb['resource_type'] ?? null) + ?? (string) $inventoryItem->policy_type, + fallbackResourceId: $this->stringValue($inventoryItem->external_id), + ); + + if (! $identity instanceof ResourceIdentity) { + return null; + } + + return ProviderResourceDescriptor::fromIdentity( + identity: $identity, + subjectDomain: $this->stringValue($metaJsonb['subject_domain'] ?? null) ?? 'baseline', + subjectClass: $this->stringValue($metaJsonb['subject_class'] ?? null) ?? SubjectClass::PolicyBacked->value, + subjectTypeKey: (string) $inventoryItem->policy_type, + displayLabel: $this->stringValue($inventoryItem->display_name) + ?? $this->stringValue($metaJsonb['display_name'] ?? null), + sourceReferences: [ + 'inventory_item_id' => (int) $inventoryItem->getKey(), + 'external_id' => (string) $inventoryItem->external_id, + ], + fingerprint: $this->stringValue($metaJsonb['provider_resource_fingerprint'] ?? null) ?? $identity->fingerprint(), + lastSeenAt: $inventoryItem->last_seen_at?->toIso8601String(), + ); + } + + /** + * @param array $metaJsonb + */ + private function resourceIdentityFromMeta( + array $metaJsonb, + ?string $fallbackProviderKey = null, + ?string $fallbackResourceType = null, + ?string $fallbackResourceId = null, + ): ?ResourceIdentity { + $descriptorPayload = $metaJsonb['provider_resource_descriptor'] ?? null; + $identityPayload = is_array($descriptorPayload) ? ($descriptorPayload['identity'] ?? null) : null; + + if (! is_array($identityPayload)) { + $identityPayload = $metaJsonb['provider_resource_identity'] ?? null; + } + + if (is_array($identityPayload)) { + try { + return ResourceIdentity::fromArray($identityPayload); + } catch (InvalidArgumentException) { + return null; + } + } + + $providerKey = $this->stringValue($metaJsonb['provider_key'] ?? $metaJsonb['provider'] ?? null) ?? $fallbackProviderKey; + $resourceType = $this->stringValue($metaJsonb['provider_resource_type'] ?? $metaJsonb['resource_type'] ?? $metaJsonb['provider_object_type'] ?? null) + ?? $fallbackResourceType; + $resourceId = $this->stringValue($metaJsonb['provider_resource_id'] ?? $metaJsonb['resource_id'] ?? null) ?? $fallbackResourceId; + $discriminator = $this->stringValue($metaJsonb['provider_resource_discriminator'] ?? $metaJsonb['canonical_discriminator'] ?? null); + $identityKind = $this->stringValue($metaJsonb['provider_resource_identity_kind'] ?? $metaJsonb['identity_kind'] ?? null) + ?? ResourceIdentity::ProviderResource; + + if ($providerKey === null || $resourceType === null) { + return null; + } + + try { + return new ResourceIdentity( + providerKey: $providerKey, + identityKind: $identityKind, + providerResourceType: $resourceType, + providerResourceId: $identityKind === ResourceIdentity::ProviderResource ? $resourceId : null, + canonicalDiscriminator: $identityKind === ResourceIdentity::ProviderResource ? null : $discriminator, + ); + } catch (InvalidArgumentException) { + return null; + } + } + + /** + * @param EloquentCollection $activeBindings + */ + private function activeBindingFor( + EloquentCollection $activeBindings, + ?string $canonicalSubjectKey, + ?ResourceIdentity $decisionIdentity, + ): ?ProviderResourceBinding { + return $activeBindings->first(function (ProviderResourceBinding $binding) use ($canonicalSubjectKey, $decisionIdentity): bool { + if ($canonicalSubjectKey !== null && (string) $binding->canonical_subject_key === $canonicalSubjectKey) { + return true; + } + + return $decisionIdentity instanceof ResourceIdentity + && (string) $binding->provider_key === $decisionIdentity->providerKey + && (string) $binding->provider_resource_fingerprint === $decisionIdentity->fingerprint(); + }); + } + + private function canonicalSubjectKey(array $subject): ?string + { + $candidate = $this->stringValue($subject['canonical_subject_key'] ?? $subject['subject_key'] ?? null); + + return BaselineSubjectKey::isProviderResourceCanonicalKey($candidate) ? $candidate : null; + } + + /** + * @param array $filters + */ + private function includeOutcome(array $outcome, array $filters): bool + { + if ((bool) ($filters['include_resolved'] ?? false)) { + return true; + } + + $actionability = $this->stringValue($outcome['actionability'] ?? null); + + return ! in_array($actionability, [ + CompareResultActionability::None->value, + CompareResultActionability::Accepted->value, + CompareResultActionability::Excluded->value, + ], true); + } + + /** + * @param array $filters + */ + private function matchesFilters(array $row, array $filters): bool + { + $stringFilters = [ + 'provider' => 'provider_key', + 'subject_class' => 'subject_class', + 'resource_type' => 'subject_type_key', + 'actionability' => 'actionability', + 'readiness_impact' => 'readiness_impact', + 'reason' => 'reason', + ]; + + foreach ($stringFilters as $filterKey => $rowKey) { + $filterValue = $this->stringFilter($filters, $filterKey); + + if ($filterValue !== null && (string) ($row[$rowKey] ?? '') !== $filterValue) { + return false; + } + } + + $activeBinding = $this->stringFilter($filters, 'active_binding'); + if ($activeBinding === 'yes' && $row['active_binding_id'] === null) { + return false; + } + + if ($activeBinding === 'no' && $row['active_binding_id'] !== null) { + return false; + } + + $candidates = $this->stringFilter($filters, 'candidates'); + if ($candidates === 'yes' && ! (bool) ($row['has_candidates'] ?? false)) { + return false; + } + + if ($candidates === 'no' && (bool) ($row['has_candidates'] ?? false)) { + return false; + } + + return true; + } + + /** + * @param list> $rows + * @return array + */ + private function countBy(array $rows, string $key): array + { + return collect($rows) + ->countBy(fn (array $row): string => (string) ($row[$key] ?? 'unknown')) + ->sortKeys() + ->all(); + } + + private function rowId(OperationRun $run, int $index, string $reason, string $subjectTypeKey, ?string $subjectKey, ?string $canonicalSubjectKey): string + { + return hash('sha256', implode('|', [ + (string) $run->getKey(), + (string) $index, + $reason, + $subjectTypeKey, + (string) ($canonicalSubjectKey ?? $subjectKey ?? 'subject'), + ])); + } + + private function intFilter(array $filters, string $key): ?int + { + $value = $filters[$key] ?? null; + + return is_numeric($value) ? (int) $value : null; + } + + private function stringFilter(array $filters, string $key): ?string + { + $value = $filters[$key] ?? null; + $value = is_array($value) ? ($value['value'] ?? null) : $value; + + return $this->stringValue($value); + } + + private function stringValue(mixed $value): ?string + { + if (! is_string($value) && ! is_numeric($value)) { + return null; + } + + $value = trim((string) $value); + + return $value !== '' ? $value : null; + } + + private function enumValue(mixed $value): string + { + if ($value instanceof \BackedEnum) { + return (string) $value->value; + } + + return (string) $value; + } + + private function label(string $value): string + { + $value = trim($value); + + return $value === '' ? 'Unknown' : Str::of($value)->replace(['_', '-'], ' ')->headline()->toString(); + } + + private function actionabilityLabel(string $actionability): string + { + return match ($actionability) { + CompareResultActionability::BindingRequired->value => 'Binding required', + CompareResultActionability::OperatorActionRequired->value => 'Operator decision required', + CompareResultActionability::ProviderDataRefreshRequired->value => 'Refresh provider data', + CompareResultActionability::ImplementationGap->value => 'Implementation gap', + CompareResultActionability::ScopeDecisionRequired->value => 'Scope decision required', + default => $this->label($actionability), + }; + } + + private function readinessLabel(string $readinessImpact): string + { + return match ($readinessImpact) { + 'customer_blocker' => 'Customer blocker', + 'internal_blocker' => 'Internal blocker', + 'customer_limitation' => 'Customer limitation', + 'internal_limitation' => 'Internal limitation', + 'no_impact' => 'No impact', + default => $this->label($readinessImpact), + }; + } + + private function readinessSortWeight(string $readinessImpact): int + { + return match ($readinessImpact) { + 'customer_blocker' => 0, + 'internal_blocker' => 1, + 'customer_limitation' => 2, + 'internal_limitation' => 3, + default => 4, + }; + } + + private function preview(?string $value): ?string + { + if ($value === null || trim($value) === '') { + return null; + } + + $value = trim($value); + + return Str::length($value) > 16 ? Str::substr($value, 0, 10).'...'.Str::substr($value, -4) : $value; + } +} diff --git a/apps/platform/app/Support/ManagedEnvironmentLinks.php b/apps/platform/app/Support/ManagedEnvironmentLinks.php index 506b6f2b..65050d6c 100644 --- a/apps/platform/app/Support/ManagedEnvironmentLinks.php +++ b/apps/platform/app/Support/ManagedEnvironmentLinks.php @@ -3,6 +3,7 @@ namespace App\Support; use App\Filament\Pages\BaselineCompareLanding; +use App\Filament\Pages\BaselineSubjectResolution; use App\Filament\Resources\ProviderConnectionResource; use App\Models\ManagedEnvironment; use App\Models\ProviderConnection; @@ -77,6 +78,14 @@ public static function baselineCompareUrl(ManagedEnvironment $environment, array return BaselineCompareLanding::getUrl($query, panel: 'admin', tenant: $environment); } + /** + * @param array $query + */ + public static function baselineSubjectResolutionUrl(ManagedEnvironment $environment, array $query = []): string + { + return BaselineSubjectResolution::getUrl($query, panel: 'admin', tenant: $environment); + } + /** * @param array $query */ diff --git a/apps/platform/app/Support/Navigation/CrossResourceNavigationMatrix.php b/apps/platform/app/Support/Navigation/CrossResourceNavigationMatrix.php index 61034d4f..f2c362d3 100644 --- a/apps/platform/app/Support/Navigation/CrossResourceNavigationMatrix.php +++ b/apps/platform/app/Support/Navigation/CrossResourceNavigationMatrix.php @@ -77,6 +77,7 @@ private function rules(): array new NavigationMatrixRule(self::SOURCE_OPERATION_RUN, self::SURFACE_DETAIL_SECTION, 'evidence_snapshot', 'evidence_snapshot', 'direct_record', 55, missingStatePolicy: 'hide'), new NavigationMatrixRule(self::SOURCE_OPERATION_RUN, self::SURFACE_DETAIL_SECTION, 'environment_review', 'environment_review', 'direct_record', 56, missingStatePolicy: 'hide'), new NavigationMatrixRule(self::SOURCE_OPERATION_RUN, self::SURFACE_DETAIL_SECTION, 'review_pack', 'review_pack', 'direct_record', 57, missingStatePolicy: 'hide'), + new NavigationMatrixRule(self::SOURCE_OPERATION_RUN, self::SURFACE_DETAIL_SECTION, 'baseline_subject_resolution', 'baseline_subject_resolution', 'canonical_page', 58, missingStatePolicy: 'hide'), new NavigationMatrixRule(self::SOURCE_OPERATION_RUN, self::SURFACE_DETAIL_SECTION, 'operations', 'operations', 'canonical_page', 60, missingStatePolicy: 'hide'), new NavigationMatrixRule(self::SOURCE_BASELINE_PROFILE, self::SURFACE_DETAIL_HEADER, 'baseline_snapshot', 'baseline_snapshot', 'direct_record', 10, missingStatePolicy: 'hide'), diff --git a/apps/platform/app/Support/Navigation/RelatedActionLabelCatalog.php b/apps/platform/app/Support/Navigation/RelatedActionLabelCatalog.php index 09762479..ba12dbe8 100644 --- a/apps/platform/app/Support/Navigation/RelatedActionLabelCatalog.php +++ b/apps/platform/app/Support/Navigation/RelatedActionLabelCatalog.php @@ -16,6 +16,7 @@ final class RelatedActionLabelCatalog 'baseline_profile' => 'Baseline profile', 'baseline_snapshot' => 'Snapshot', 'backup_set' => 'Backup set', + 'baseline_subject_resolution' => 'Baseline subject resolution', 'current_policy_version' => 'Current policy version', 'environment_review' => 'ManagedEnvironment Review', 'evidence_snapshot' => 'Evidence snapshot', @@ -34,6 +35,7 @@ final class RelatedActionLabelCatalog 'baseline_profile' => 'View baseline profile', 'baseline_snapshot' => 'View snapshot', 'backup_set' => 'View backup set', + 'baseline_subject_resolution' => 'Resolve baseline subjects', 'current_policy_version' => 'View policy version', 'environment_review' => 'ManagedEnvironment Review', 'evidence_snapshot' => 'View evidence snapshot', diff --git a/apps/platform/app/Support/Navigation/RelatedNavigationResolver.php b/apps/platform/app/Support/Navigation/RelatedNavigationResolver.php index e70d7648..d7060742 100644 --- a/apps/platform/app/Support/Navigation/RelatedNavigationResolver.php +++ b/apps/platform/app/Support/Navigation/RelatedNavigationResolver.php @@ -36,7 +36,10 @@ use App\Services\Auth\CapabilityResolver; use App\Services\Auth\WorkspaceCapabilityResolver; use App\Support\Auth\Capabilities; +use App\Services\Baselines\BaselineSubjectResolutionQuery; +use App\Support\ManagedEnvironmentLinks; use App\Support\OperateHub\OperateHubShell; +use App\Support\OperationRunType; use App\Support\OperationRunLinks; use App\Support\References\ReferenceClass; use App\Support\References\ReferenceDescriptor; @@ -518,6 +521,7 @@ private function resolveOperationRunRule(NavigationMatrixRule $rule, OperationRu 'evidence_snapshot' => $this->evidenceSnapshotEntry($rule, $run), 'environment_review' => $this->environmentReviewEntry($rule, $run), 'review_pack' => $this->reviewPackEntry($rule, $run), + 'baseline_subject_resolution' => $this->baselineSubjectResolutionEntry($rule, $run), 'operations' => $this->operationsEntry( rule: $rule, tenant: $run->tenant, @@ -788,6 +792,39 @@ private function reviewPackEntry(NavigationMatrixRule $rule, OperationRun $run): ); } + private function baselineSubjectResolutionEntry(NavigationMatrixRule $rule, OperationRun $run): ?RelatedContextEntry + { + $tenant = $run->tenant; + + if ( + ! $tenant instanceof ManagedEnvironment + || $run->canonicalOperationType() !== OperationRunType::BaselineCompare->value + || ! $this->canOpenTenantRecord($tenant, Capabilities::WORKSPACE_BASELINES_VIEW) + ) { + return null; + } + + $summary = app(BaselineSubjectResolutionQuery::class)->summary($tenant, (int) $run->getKey()); + + if ((int) ($summary['actionable_count'] ?? 0) <= 0) { + return null; + } + + return RelatedContextEntry::available( + key: $rule->relationKey, + label: $this->labels->entryLabel($rule->relationKey), + value: 'Baseline subject resolution', + secondaryValue: OperationRunLinks::identifier($run), + targetUrl: ManagedEnvironmentLinks::baselineSubjectResolutionUrl($tenant, [ + 'operation_run_id' => (int) $run->getKey(), + ]), + targetKind: $rule->targetType, + priority: $rule->priority, + actionLabel: $this->labels->actionLabel($rule->relationKey), + contextBadge: 'Baseline compare', + ); + } + private function parentPolicyEntryForFinding(NavigationMatrixRule $rule, Finding $finding): ?RelatedContextEntry { $policyVersionId = $this->findingPolicyVersionId($finding); diff --git a/apps/platform/app/Support/OperationRunLinks.php b/apps/platform/app/Support/OperationRunLinks.php index 10f6eef1..c086a1b4 100644 --- a/apps/platform/app/Support/OperationRunLinks.php +++ b/apps/platform/app/Support/OperationRunLinks.php @@ -20,6 +20,7 @@ use App\Models\RestoreRun; use App\Models\ReviewPack; use App\Models\Workspace; +use App\Services\Baselines\BaselineSubjectResolutionQuery; use App\Support\Navigation\CanonicalNavigationContext; use App\Support\Workspaces\WorkspaceContext; @@ -226,6 +227,14 @@ public static function related(OperationRun $run, ?ManagedEnvironment $tenant): if ($canonicalType === 'baseline.compare') { $links['Drift'] = ManagedEnvironmentLinks::baselineCompareUrl($tenant); + + $summary = app(BaselineSubjectResolutionQuery::class)->summary($tenant, (int) $run->getKey()); + + if ((int) ($summary['actionable_count'] ?? 0) > 0) { + $links['Baseline Subject Resolution'] = ManagedEnvironmentLinks::baselineSubjectResolutionUrl($tenant, [ + 'operation_run_id' => (int) $run->getKey(), + ]); + } } if ($canonicalType === 'baseline.capture') { diff --git a/apps/platform/resources/views/filament/pages/baseline-compare-landing.blade.php b/apps/platform/resources/views/filament/pages/baseline-compare-landing.blade.php index 12c31b98..92c28f8a 100644 --- a/apps/platform/resources/views/filament/pages/baseline-compare-landing.blade.php +++ b/apps/platform/resources/views/filament/pages/baseline-compare-landing.blade.php @@ -154,6 +154,25 @@ + @if (($subjectResolutionActionCount ?? 0) > 0 && filled($subjectResolutionUrl ?? null)) + +
+
+
+ Baseline subject decisions +
+
+ {{ (int) $subjectResolutionActionCount }} {{ \Illuminate\Support\Str::plural('subject', (int) $subjectResolutionActionCount) }} need identity or coverage decisions before compare output is fully trustworthy. +
+
+ + + Resolve baseline subjects + +
+
+ @endif + @if (! empty($compareReadinessFlow))
diff --git a/apps/platform/resources/views/filament/pages/baseline-subject-resolution.blade.php b/apps/platform/resources/views/filament/pages/baseline-subject-resolution.blade.php new file mode 100644 index 00000000..98608652 --- /dev/null +++ b/apps/platform/resources/views/filament/pages/baseline-subject-resolution.blade.php @@ -0,0 +1,50 @@ + + @php + $summary = is_array($summary ?? null) ? $summary : []; + $actionableCount = (int) ($summary['actionable_count'] ?? 0); + $sourceRunId = $summary['source_operation_run_id'] ?? null; + $legacyPayloadOnly = (bool) ($summary['legacy_payload_only'] ?? false); + @endphp + +
+ +
+
+
+ + {{ $actionableCount }} {{ \Illuminate\Support\Str::plural('decision', $actionableCount) }} required + + + @if (is_numeric($sourceRunId)) + + Operation #{{ (int) $sourceRunId }} + + @endif +
+ +
+ Baseline subject decisions are TenantPilot-only records. They do not mutate the provider tenant. +
+
+ +
+ @if (filled($sourceRunUrl ?? null)) + + Open operation + + @endif + + @if (filled($compareUrl ?? null)) + + Open baseline compare + + @endif +
+
+
+ + + {{ $this->table }} + +
+
diff --git a/apps/platform/tests/Browser/Spec384BaselineSubjectResolutionSmokeTest.php b/apps/platform/tests/Browser/Spec384BaselineSubjectResolutionSmokeTest.php new file mode 100644 index 00000000..1630f47f --- /dev/null +++ b/apps/platform/tests/Browser/Spec384BaselineSubjectResolutionSmokeTest.php @@ -0,0 +1,164 @@ +browser()->timeout(45_000); + +uses(RefreshDatabase::class); + +it('Spec384 smokes baseline subject resolution reachability and action modal', function (): void { + [$user, $tenant] = createUserWithTenant(role: 'owner', workspaceRole: 'manager'); + $tenant->forceFill(['name' => 'Spec384 Subject Resolution Environment'])->save(); + + $run = spec384BrowserSubjectResolutionRun($tenant); + $resolutionPath = spec384BrowserPath(ManagedEnvironmentLinks::baselineSubjectResolutionUrl($tenant, [ + 'operation_run_id' => (int) $run->getKey(), + ])); + + $page = visit(spec384BrowserLoginUrl($user, $tenant, $resolutionPath)) + ->resize(1440, 1100) + ->waitForText('Baseline subject resolution') + ->assertSee('Spec384 Subject Resolution Environment') + ->assertSee('1 decision required') + ->assertSee('Decision worklist') + ->assertSee('Duplicate policy') + ->assertSee('Unresolved Duplicate Candidates') + ->assertSee('Binding required') + ->assertSee('TenantPilot-only') + ->assertSee('Open operation') + ->assertSee('Open baseline compare') + ->assertSee('Bind subject') + ->assertNoJavaScriptErrors() + ->assertNoConsoleLogs() + ->screenshot(true, spec384BrowserScreenshotName('01-baseline-subject-resolution')); + + spec384BrowserCopyScreenshot('01-baseline-subject-resolution'); + + $page + ->click('Bind subject') + ->waitForText('Record a TenantPilot-only subject binding') + ->assertSee('Provider resource') + ->assertSee('Operator note') + ->assertSee('This does not mutate the provider tenant') + ->assertNoJavaScriptErrors() + ->assertNoConsoleLogs() + ->screenshot(true, spec384BrowserScreenshotName('02-baseline-subject-resolution-bind-modal')); + + spec384BrowserCopyScreenshot('02-baseline-subject-resolution-bind-modal'); + + $page + ->resize(430, 900) + ->assertScript('document.documentElement.scrollWidth <= window.innerWidth', true) + ->assertNoJavaScriptErrors() + ->screenshot(true, spec384BrowserScreenshotName('03-baseline-subject-resolution-mobile')); + + spec384BrowserCopyScreenshot('03-baseline-subject-resolution-mobile'); +}); + +function spec384BrowserSubjectResolutionRun(ManagedEnvironment $tenant) +{ + [$profile, $snapshot] = seedActiveBaselineForTenant($tenant); + $leftIdentity = ResourceIdentity::providerResource('fake-provider', 'policy', 'candidate-left'); + $rightIdentity = ResourceIdentity::providerResource('fake-provider', 'policy', 'candidate-right'); + $leftDescriptor = BaselineSubjectResolutionFixtures::providerDescriptor($leftIdentity, 'Duplicate policy'); + $rightDescriptor = BaselineSubjectResolutionFixtures::providerDescriptor($rightIdentity, 'Duplicate policy'); + + BaselineSubjectResolutionFixtures::inventoryCandidate( + $tenant, + $leftIdentity, + 'Duplicate policy', + ); + BaselineSubjectResolutionFixtures::inventoryCandidate( + $tenant, + $rightIdentity, + 'Duplicate policy', + ); + + return seedBaselineCompareRun( + tenant: $tenant, + profile: $profile, + snapshot: $snapshot, + compareContext: [ + 'result_semantics' => [ + 'version' => 1, + 'subject_outcomes' => [ + BaselineSubjectResolutionFixtures::semanticOutcome([ + 'reason' => 'unresolved_duplicate_candidates', + 'actionability' => 'binding_required', + 'readiness_impact' => 'customer_blocker', + 'subject' => [ + 'subject_domain' => 'baseline', + 'subject_class' => SubjectClass::PolicyBacked->value, + 'subject_type_key' => 'deviceConfiguration', + 'subject_key' => 'legacy-display-key', + 'display_label' => 'Duplicate policy', + 'candidate_descriptors' => [ + $leftDescriptor->toArray(), + $rightDescriptor->toArray(), + ], + ], + ]), + ], + ], + ], + status: OperationRunStatus::Completed->value, + outcome: OperationRunOutcome::PartiallySucceeded->value, + ); +} + +function spec384BrowserLoginUrl(User $user, ManagedEnvironment $tenant, string $redirect): string +{ + return route('admin.local.smoke-login', [ + 'email' => $user->email, + 'tenant' => $tenant->external_id, + 'workspace' => $tenant->workspace->slug, + 'redirect' => $redirect, + ]); +} + +function spec384BrowserPath(string $url): string +{ + $path = parse_url($url, PHP_URL_PATH) ?: '/admin'; + $query = parse_url($url, PHP_URL_QUERY); + + return is_string($query) && $query !== '' ? $path.'?'.$query : $path; +} + +function spec384BrowserScreenshotName(string $name): string +{ + return 'spec384-'.$name; +} + +function spec384BrowserCopyScreenshot(string $name): void +{ + $filename = spec384BrowserScreenshotName($name).'.png'; + $source = base_path('tests/Browser/Screenshots/'.$filename); + $targetDirectory = repo_path('specs/384-baseline-subject-resolution-ui/artifacts/screenshots'); + + if (! is_dir($targetDirectory)) { + @mkdir($targetDirectory, 0755, true); + } + + if (! is_file($source)) { + $source = \Pest\Browser\Support\Screenshot::path($filename); + } + + for ($attempt = 0; $attempt < 10 && ! is_file($source); $attempt++) { + usleep(100_000); + clearstatcache(true, $source); + } + + if (is_file($source) && is_dir($targetDirectory) && is_writable($targetDirectory)) { + @copy($source, $targetDirectory.DIRECTORY_SEPARATOR.$filename); + } +} diff --git a/apps/platform/tests/Feature/Baselines/Support/BaselineSubjectResolutionFixtures.php b/apps/platform/tests/Feature/Baselines/Support/BaselineSubjectResolutionFixtures.php index 05744e7c..5abff4b6 100644 --- a/apps/platform/tests/Feature/Baselines/Support/BaselineSubjectResolutionFixtures.php +++ b/apps/platform/tests/Feature/Baselines/Support/BaselineSubjectResolutionFixtures.php @@ -4,10 +4,13 @@ namespace Tests\Feature\Baselines\Support; +use App\Models\InventoryItem; use App\Support\Baselines\OperatorActionCategory; use App\Support\Baselines\ResolutionOutcome; use App\Support\Baselines\ResolutionPath; use App\Support\Baselines\SubjectClass; +use App\Support\Resources\ProviderResourceDescriptor; +use App\Support\Resources\ResourceIdentity; final class BaselineSubjectResolutionFixtures { @@ -82,4 +85,63 @@ public static function captureContext(array $subjects, array $overrides = []): a ], ], $overrides); } + + /** + * @param array $overrides + * @return array + */ + public static function semanticOutcome(array $overrides = []): array + { + return array_replace_recursive([ + 'reason' => 'identity_required', + 'category' => 'action_required', + 'actionability' => 'binding_required', + 'readiness_impact' => 'customer_blocker', + 'identity_status' => 'unresolved', + 'comparison_status' => 'not_compared', + 'coverage_status' => 'missing_local_evidence', + 'trust_level' => 'untrusted', + 'subject' => [ + 'subject_domain' => 'baseline', + 'subject_class' => SubjectClass::PolicyBacked->value, + 'subject_type_key' => 'deviceConfiguration', + 'subject_key' => 'subject-key', + 'display_label' => 'Subject label', + ], + 'proof' => [], + ], $overrides); + } + + public static function inventoryCandidate(mixed $tenant, ResourceIdentity $identity, string $displayName): InventoryItem + { + $descriptor = self::providerDescriptor($identity, $displayName); + + return InventoryItem::factory()->create([ + 'workspace_id' => (int) $tenant->workspace_id, + 'managed_environment_id' => (int) $tenant->getKey(), + 'policy_type' => 'deviceConfiguration', + 'external_id' => $identity->providerResourceId, + 'display_name' => $displayName, + 'meta_jsonb' => [ + 'provider_resource_descriptor' => $descriptor->toArray(), + 'provider_resource_identity' => $identity->toArray(), + 'provider_resource_fingerprint' => $identity->fingerprint(), + ], + 'last_seen_at' => now(), + ]); + } + + public static function providerDescriptor(ResourceIdentity $identity, string $displayName): ProviderResourceDescriptor + { + return ProviderResourceDescriptor::fromIdentity( + identity: $identity, + subjectDomain: 'baseline', + subjectClass: SubjectClass::PolicyBacked, + subjectTypeKey: 'deviceConfiguration', + displayLabel: $displayName, + sourceReferences: [], + fingerprint: $identity->fingerprint(), + lastSeenAt: now()->toIso8601String(), + ); + } } diff --git a/apps/platform/tests/Feature/Filament/BaselineSubjectResolutionPageTest.php b/apps/platform/tests/Feature/Filament/BaselineSubjectResolutionPageTest.php new file mode 100644 index 00000000..99c474f6 --- /dev/null +++ b/apps/platform/tests/Feature/Filament/BaselineSubjectResolutionPageTest.php @@ -0,0 +1,370 @@ + (int) $run->getKey()]) + ->assertOk() + ->assertSee('Baseline subject resolution') + ->assertSee('Duplicate policy') + ->assertSee('Unresolved Duplicate Candidates') + ->assertSee('Binding required') + ->assertSee('TenantPilot-only'); +}); + +it('renders specific empty states for missing compare runs and quiet compare results', function (): void { + [$user, $tenant] = createUserWithTenant(role: 'owner'); + + spec384BaselineSubjectResolutionLivewire($tenant, $user) + ->assertOk() + ->assertSee('Run baseline compare first') + ->assertSee('No baseline compare run exists for this environment yet.'); + + [$quietUser, $quietTenant] = createUserWithTenant(role: 'owner'); + [$profile, $snapshot] = seedActiveBaselineForTenant($quietTenant); + $quietRun = seedBaselineCompareRun($quietTenant, $profile, $snapshot, [ + 'result_semantics' => [ + 'version' => 1, + 'subject_outcomes' => [ + BaselineSubjectResolutionFixtures::semanticOutcome([ + 'reason' => 'verified_no_drift', + 'actionability' => 'none', + 'readiness_impact' => 'no_impact', + 'subject' => ['subject_type_key' => 'deviceConfiguration', 'subject_key' => 'quiet'], + ]), + ], + ], + ]); + + spec384BaselineSubjectResolutionLivewire($quietTenant, $quietUser, ['operation_run_id' => (int) $quietRun->getKey()]) + ->assertOk() + ->assertSee('No baseline subject decisions required') + ->assertSee('The selected compare context has no unresolved or decision-required baseline subjects.'); +}); + +it('records manual bindings through the confirmed Filament action', function (): void { + [$user, $tenant] = createUserWithTenant(role: 'owner'); + $run = spec384SeedSubjectResolutionRun($tenant); + $row = app(BaselineSubjectResolutionQuery::class)->rows($tenant, [ + 'operation_run_id' => (int) $run->getKey(), + ])[0]; + $candidateKey = (string) $row['candidates'][0]['candidate_key']; + + $component = spec384BaselineSubjectResolutionLivewire($tenant, $user, ['operation_run_id' => (int) $run->getKey()]); + $record = collect($component->instance()->getTableRecords()->items())->first(); + + $component->callTableAction('bindSubject', $record, data: [ + 'candidate_key' => $candidateKey, + 'operator_note' => 'Operator selected the matching provider resource after reviewing duplicate candidates.', + ]); + + $binding = ProviderResourceBinding::query() + ->where('managed_environment_id', (int) $tenant->getKey()) + ->firstOrFail(); + + expect($binding->resolution_mode)->toBe(ProviderResourceResolutionMode::ManualBinding) + ->and($binding->provider_resource_id)->toBe('candidate-left') + ->and((int) $binding->source_operation_run_id)->toBe((int) $run->getKey()); + + expect(AuditLog::query() + ->where('action', AuditActionId::ProviderResourceBindingCreated->value) + ->where('managed_environment_id', (int) $tenant->getKey()) + ->exists())->toBeTrue(); +}); + +it('records and revokes subject decisions through confirmed Filament actions', function (): void { + [$user, $tenant] = createUserWithTenant(role: 'owner'); + $run = spec384SeedDecisionIdentityRun($tenant); + + $component = spec384BaselineSubjectResolutionLivewire($tenant, $user, ['operation_run_id' => (int) $run->getKey()]); + $record = collect($component->instance()->getTableRecords()->items())->first(); + + $component->callTableAction('recordDecision', $record, data: [ + 'decision' => 'accepted_limitation', + 'operator_note' => 'Operator accepted this baseline limitation after confirming provider coverage scope.', + ]); + + $binding = ProviderResourceBinding::query() + ->where('managed_environment_id', (int) $tenant->getKey()) + ->firstOrFail(); + + expect($binding->resolution_mode)->toBe(ProviderResourceResolutionMode::AcceptedLimitation) + ->and($binding->binding_status)->toBe(ProviderResourceBindingStatus::Active) + ->and(AuditLog::query() + ->where('action', AuditActionId::ProviderResourceBindingCreated->value) + ->where('managed_environment_id', (int) $tenant->getKey()) + ->exists())->toBeTrue(); + + $updatedRecord = collect($component->instance()->getTableRecords()->items())->first(); + + $component + ->assertTableActionVisible('revokeDecision', $updatedRecord) + ->callTableAction('revokeDecision', $updatedRecord, data: [ + 'operator_note' => 'Operator revoked the limitation because fresh provider evidence is expected.', + ]); + + expect($binding->refresh()->binding_status)->toBe(ProviderResourceBindingStatus::Revoked) + ->and(AuditLog::query() + ->where('action', AuditActionId::ProviderResourceBindingRevoked->value) + ->where('managed_environment_id', (int) $tenant->getKey()) + ->exists())->toBeTrue(); +}); + +it('disables decision actions for workspace members missing manage capability', function (): void { + [$owner, $tenant] = createUserWithTenant(role: 'owner'); + $run = spec384SeedSubjectResolutionRun($tenant); + [$readonly] = createUserWithTenant(tenant: $tenant, role: 'owner', workspaceRole: 'readonly'); + + $component = spec384BaselineSubjectResolutionLivewire($tenant, $readonly, ['operation_run_id' => (int) $run->getKey()]); + $record = collect($component->instance()->getTableRecords()->items())->first(); + + $component + ->assertTableActionVisible('bindSubject', $record) + ->assertTableActionDisabled('bindSubject', $record); +}); + +it('returns not found for users outside the workspace/environment scope', function (): void { + [$owner, $tenant] = createUserWithTenant(role: 'owner'); + spec384SeedSubjectResolutionRun($tenant); + [$outsider] = createUserWithTenant(role: 'owner'); + + $this->actingAs($outsider) + ->get(ManagedEnvironmentLinks::baselineSubjectResolutionUrl($tenant)) + ->assertNotFound(); +}); + +it('returns not found when the route workspace does not own the environment', function (): void { + [$owner, $tenant] = createUserWithTenant(role: 'owner'); + spec384SeedSubjectResolutionRun($tenant); + [, $foreignTenant] = createUserWithTenant(role: 'owner'); + + $url = str_replace( + '/workspaces/'.$tenant->workspace->slug.'/', + '/workspaces/'.$foreignTenant->workspace->slug.'/', + ManagedEnvironmentLinks::baselineSubjectResolutionUrl($tenant), + ); + + $this->actingAs($owner) + ->get($url) + ->assertNotFound(); +}); + +it('reauthorizes livewire reads after workspace membership changes', function (): void { + [$user, $tenant] = createUserWithTenant(role: 'owner'); + $run = spec384SeedSubjectResolutionRun($tenant); + + $component = spec384BaselineSubjectResolutionLivewire($tenant, $user, ['operation_run_id' => (int) $run->getKey()]) + ->assertOk(); + + WorkspaceMembership::query() + ->where('workspace_id', (int) $tenant->workspace_id) + ->where('user_id', (int) $user->getKey()) + ->delete(); + + app(WorkspaceCapabilityResolver::class)->clearCache(); + app(ManagedEnvironmentAccessScopeResolver::class)->clearCache(); + + expect(fn (): mixed => $component->instance()->currentEnvironment()) + ->toThrow(NotFoundHttpException::class); +}); + +it('adds a contextual link from baseline compare only when action is required', function (): void { + [$user, $tenant] = createUserWithTenant(role: 'owner'); + $run = spec384SeedSubjectResolutionRun($tenant); + + baselineCompareLandingLivewire($tenant, user: $user) + ->assertSee('Resolve baseline subjects') + ->assertSee('1 subject need identity or coverage decisions'); + + [$quietUser, $quietTenant] = createUserWithTenant(role: 'owner'); + [$profile, $snapshot] = seedActiveBaselineForTenant($quietTenant); + seedBaselineCompareRun($quietTenant, $profile, $snapshot, [ + 'result_semantics' => [ + 'version' => 1, + 'subject_outcomes' => [ + BaselineSubjectResolutionFixtures::semanticOutcome([ + 'reason' => 'verified_no_drift', + 'actionability' => 'none', + 'readiness_impact' => 'no_impact', + 'subject' => ['subject_type_key' => 'deviceConfiguration', 'subject_key' => 'quiet'], + ]), + ], + ], + ]); + + baselineCompareLandingLivewire($quietTenant, user: $quietUser) + ->assertDontSee('Resolve baseline subjects'); +}); + +it('adds a baseline subject resolution link to baseline compare run related links', function (): void { + [$user, $tenant] = createUserWithTenant(role: 'owner'); + $run = spec384SeedSubjectResolutionRun($tenant); + + $links = \App\Support\OperationRunLinks::related($run, $tenant); + + expect($links)->toHaveKey('Baseline Subject Resolution') + ->and($links['Baseline Subject Resolution'])->toContain('baseline-subject-resolution') + ->and($links['Baseline Subject Resolution'])->toContain('operation_run_id='.(int) $run->getKey()); + + [, $quietTenant] = createUserWithTenant(role: 'owner'); + [$profile, $snapshot] = seedActiveBaselineForTenant($quietTenant); + $quietRun = seedBaselineCompareRun($quietTenant, $profile, $snapshot, [ + 'result_semantics' => [ + 'version' => 1, + 'subject_outcomes' => [ + BaselineSubjectResolutionFixtures::semanticOutcome([ + 'reason' => 'verified_no_drift', + 'actionability' => 'none', + 'readiness_impact' => 'no_impact', + 'subject' => ['subject_type_key' => 'deviceConfiguration', 'subject_key' => 'quiet'], + ]), + ], + ], + ]); + + expect(\App\Support\OperationRunLinks::related($quietRun, $quietTenant)) + ->not->toHaveKey('Baseline Subject Resolution'); +}); + +it('adds a baseline subject resolution entry to operation run detail related navigation', function (): void { + [$user, $tenant] = createUserWithTenant(role: 'owner'); + $this->actingAs($user); + + $run = spec384SeedSubjectResolutionRun($tenant); + + $entry = collect(app(RelatedNavigationResolver::class) + ->detailEntries(CrossResourceNavigationMatrix::SOURCE_OPERATION_RUN, $run)) + ->firstWhere('key', 'baseline_subject_resolution'); + + expect($entry)->not->toBeNull() + ->and($entry['targetUrl'])->toContain('baseline-subject-resolution') + ->and($entry['targetUrl'])->toContain('operation_run_id='.(int) $run->getKey()) + ->and($entry['actionLabel'])->toBe('Resolve baseline subjects'); +}); + +function spec384SeedSubjectResolutionRun($tenant) +{ + [$profile, $snapshot] = seedActiveBaselineForTenant($tenant); + $leftIdentity = ResourceIdentity::providerResource('fake-provider', 'policy', 'candidate-left'); + $rightIdentity = ResourceIdentity::providerResource('fake-provider', 'policy', 'candidate-right'); + $leftDescriptor = BaselineSubjectResolutionFixtures::providerDescriptor($leftIdentity, 'Duplicate policy'); + $rightDescriptor = BaselineSubjectResolutionFixtures::providerDescriptor($rightIdentity, 'Duplicate policy'); + + BaselineSubjectResolutionFixtures::inventoryCandidate($tenant, $leftIdentity, 'Duplicate policy'); + BaselineSubjectResolutionFixtures::inventoryCandidate($tenant, $rightIdentity, 'Duplicate policy'); + + return seedBaselineCompareRun( + tenant: $tenant, + profile: $profile, + snapshot: $snapshot, + compareContext: [ + 'result_semantics' => [ + 'version' => 1, + 'subject_outcomes' => [ + BaselineSubjectResolutionFixtures::semanticOutcome([ + 'reason' => 'unresolved_duplicate_candidates', + 'actionability' => 'binding_required', + 'readiness_impact' => 'customer_blocker', + 'subject' => [ + 'subject_domain' => 'baseline', + 'subject_class' => \App\Support\Baselines\SubjectClass::PolicyBacked->value, + 'subject_type_key' => 'deviceConfiguration', + 'subject_key' => 'legacy-display-key', + 'display_label' => 'Duplicate policy', + 'candidate_descriptors' => [ + $leftDescriptor->toArray(), + $rightDescriptor->toArray(), + ], + ], + ]), + ], + ], + ], + status: OperationRunStatus::Completed->value, + outcome: OperationRunOutcome::PartiallySucceeded->value, + ); +} + +function spec384SeedDecisionIdentityRun($tenant) +{ + [$profile, $snapshot] = seedActiveBaselineForTenant($tenant); + $identity = ResourceIdentity::providerResource('fake-provider', 'policy', 'limited-policy'); + $descriptor = ProviderResourceDescriptor::fromIdentity( + identity: $identity, + subjectDomain: 'baseline', + subjectClass: \App\Support\Baselines\SubjectClass::PolicyBacked, + subjectTypeKey: 'deviceConfiguration', + displayLabel: 'Accepted limitation policy', + sourceReferences: [], + fingerprint: $identity->fingerprint(), + lastSeenAt: now()->toIso8601String(), + ); + + return seedBaselineCompareRun( + tenant: $tenant, + profile: $profile, + snapshot: $snapshot, + compareContext: [ + 'result_semantics' => [ + 'version' => 1, + 'subject_outcomes' => [ + BaselineSubjectResolutionFixtures::semanticOutcome([ + 'reason' => 'foundation_limitation', + 'actionability' => 'decision_required', + 'readiness_impact' => 'internal_blocker', + 'subject' => [ + 'subject_domain' => 'baseline', + 'subject_class' => \App\Support\Baselines\SubjectClass::PolicyBacked->value, + 'subject_type_key' => 'deviceConfiguration', + 'subject_key' => 'accepted-limitation-policy', + 'display_label' => 'Accepted limitation policy', + 'provider_resource_descriptor' => $descriptor->toArray(), + ], + ]), + ], + ], + ], + status: OperationRunStatus::Completed->value, + outcome: OperationRunOutcome::PartiallySucceeded->value, + ); +} + +function spec384BaselineSubjectResolutionLivewire($tenant, $user, array $queryParams = []) +{ + $manager = Livewire::withHeaders([ + 'Referer' => ManagedEnvironmentLinks::baselineSubjectResolutionUrl($tenant), + ])->actingAs($user); + + if ($queryParams !== []) { + $manager = $manager->withQueryParams($queryParams); + } + + return $manager->test(BaselineSubjectResolution::class, ['environment' => $tenant]); +} diff --git a/apps/platform/tests/Unit/Support/Baselines/BaselineSubjectResolutionQueryTest.php b/apps/platform/tests/Unit/Support/Baselines/BaselineSubjectResolutionQueryTest.php new file mode 100644 index 00000000..dd53904c --- /dev/null +++ b/apps/platform/tests/Unit/Support/Baselines/BaselineSubjectResolutionQueryTest.php @@ -0,0 +1,168 @@ + [ + 'version' => 1, + 'subject_outcomes' => [ + BaselineSubjectResolutionFixtures::semanticOutcome([ + 'reason' => 'unresolved_duplicate_candidates', + 'actionability' => 'binding_required', + 'readiness_impact' => 'customer_blocker', + 'subject' => [ + 'subject_domain' => 'baseline', + 'subject_class' => SubjectClass::PolicyBacked->value, + 'subject_type_key' => 'deviceConfiguration', + 'subject_key' => 'legacy-display-key', + 'display_label' => 'Duplicate policy', + 'candidate_descriptors' => [ + $leftDescriptor->toArray(), + $rightDescriptor->toArray(), + ], + ], + ]), + BaselineSubjectResolutionFixtures::semanticOutcome([ + 'reason' => 'verified_no_drift', + 'actionability' => 'none', + 'readiness_impact' => 'no_impact', + 'subject' => [ + 'subject_type_key' => 'deviceConfiguration', + 'subject_key' => 'resolved-subject', + 'display_label' => 'Resolved subject', + ], + ]), + ], + ], + ], + status: OperationRunStatus::Completed->value, + outcome: OperationRunOutcome::PartiallySucceeded->value, + ); + + $rows = app(BaselineSubjectResolutionQuery::class)->rows($tenant, [ + 'operation_run_id' => (int) $run->getKey(), + ]); + + expect($rows)->toHaveCount(1) + ->and($rows[0]['reason'])->toBe('unresolved_duplicate_candidates') + ->and($rows[0]['actionability'])->toBe('binding_required') + ->and($rows[0]['candidate_count'])->toBe(2) + ->and($rows[0]['decision_identity'])->toBeNull() + ->and(collect($rows[0]['candidates'])->pluck('identity.provider_resource_id')->all()) + ->toContain('candidate-left', 'candidate-right'); + + expect(app(BaselineSubjectResolutionQuery::class)->rows($tenant, [ + 'operation_run_id' => (int) $run->getKey(), + 'actionability' => 'binding_required', + 'readiness_impact' => 'customer_blocker', + 'reason' => 'unresolved_duplicate_candidates', + 'resource_type' => 'deviceConfiguration', + 'candidates' => 'yes', + 'active_binding' => 'no', + ]))->toHaveCount(1); + + $resolvedRows = app(BaselineSubjectResolutionQuery::class)->rows($tenant, [ + 'operation_run_id' => (int) $run->getKey(), + 'include_resolved' => true, + ]); + + expect($resolvedRows)->toHaveCount(2); +}); + +it('does not derive bindable candidates from display label matches alone', function (): void { + [$user, $tenant] = createUserWithTenant(role: 'owner'); + [$profile, $snapshot] = seedActiveBaselineForTenant($tenant); + + BaselineSubjectResolutionFixtures::inventoryCandidate( + $tenant, + ResourceIdentity::providerResource('fake-provider', 'policy', 'candidate-left'), + 'Duplicate policy', + ); + BaselineSubjectResolutionFixtures::inventoryCandidate( + $tenant, + ResourceIdentity::providerResource('fake-provider', 'policy', 'candidate-right'), + 'Duplicate policy', + ); + + $run = seedBaselineCompareRun( + tenant: $tenant, + profile: $profile, + snapshot: $snapshot, + compareContext: [ + 'result_semantics' => [ + 'version' => 1, + 'subject_outcomes' => [ + BaselineSubjectResolutionFixtures::semanticOutcome([ + 'reason' => 'unresolved_duplicate_candidates', + 'actionability' => 'binding_required', + 'readiness_impact' => 'customer_blocker', + 'subject' => [ + 'subject_domain' => 'baseline', + 'subject_class' => SubjectClass::PolicyBacked->value, + 'subject_type_key' => 'deviceConfiguration', + 'subject_key' => 'legacy-display-key', + 'display_label' => 'Duplicate policy', + ], + ]), + ], + ], + ], + status: OperationRunStatus::Completed->value, + outcome: OperationRunOutcome::PartiallySucceeded->value, + ); + + $rows = app(BaselineSubjectResolutionQuery::class)->rows($tenant, [ + 'operation_run_id' => (int) $run->getKey(), + ]); + + expect($rows)->toHaveCount(1) + ->and($rows[0]['candidate_count'])->toBe(0) + ->and($rows[0]['candidates'])->toBe([]); +}); + +it('does not treat legacy evidence-gap payloads as authoritative subject decisions', function (): void { + [$user, $tenant] = createUserWithTenant(role: 'owner'); + [$profile, $snapshot] = seedActiveBaselineForTenant($tenant); + + $run = seedBaselineCompareRun($tenant, $profile, $snapshot, [ + 'evidence_gaps' => [ + 'count' => 1, + 'by_reason' => ['unresolved_duplicate_candidates' => 1], + 'subjects' => [[ + 'policy_type' => 'deviceConfiguration', + 'subject_key' => 'legacy-only', + 'reason_code' => 'unresolved_duplicate_candidates', + ]], + ], + ], outcome: OperationRunOutcome::PartiallySucceeded->value); + + $query = app(BaselineSubjectResolutionQuery::class); + + expect($query->rows($tenant, ['operation_run_id' => (int) $run->getKey()]))->toBe([]) + ->and($query->summary($tenant, (int) $run->getKey())['legacy_payload_only'])->toBeTrue(); +}); diff --git a/docs/ui-ux-enterprise-audit/design-coverage-matrix.md b/docs/ui-ux-enterprise-audit/design-coverage-matrix.md index 50425b7c..6c540660 100644 --- a/docs/ui-ux-enterprise-audit/design-coverage-matrix.md +++ b/docs/ui-ux-enterprise-audit/design-coverage-matrix.md @@ -6,12 +6,12 @@ ## Summary | Metric | Count | Notes | | --- | ---: | --- | -| UI route/page inventory rows | 99 | Includes dynamic route families and utility/auth endpoints. | -| Unique page reports | 20 | `page-reports/*.md`; some inventory rows intentionally share existing reports where routes resolve to the same surface. | -| Desktop screenshots | 16 | Route-inventory-linked desktop evidence, including strategic runtime captures, blocker evidence screenshots, and the Spec 366 rendered-report capture. | +| UI route/page inventory rows | 100 | Includes dynamic route families and utility/auth endpoints. | +| Unique page reports | 21 | `page-reports/*.md`; some inventory rows intentionally share existing reports where routes resolve to the same surface. | +| Desktop screenshots | 17 | Route-inventory-linked desktop evidence, including strategic runtime captures, blocker evidence screenshots, and the Spec 366 rendered-report capture. | | Tablet screenshots | 0 | Deferred to later strategic mockup/implementation specs. | -| Mobile screenshots | 1 | Spec 366 adds mobile-ish rendered-report evidence for the customer technical profile; broader mobile coverage remains deferred. | -| Strategic Surface rows | 45 | Individual target treatment or explicit product decision required. | +| Mobile screenshots | 2 | Spec 366 adds mobile-ish rendered-report evidence for the customer technical profile; Spec 384 adds a narrow baseline subject resolution smoke capture. | +| Strategic Surface rows | 46 | Individual target treatment or explicit product decision required. | | Domain Pattern Surface rows | 45 | Can be handled through grouped pattern specs unless later evidence raises risk. | | Design-System Cleanup Surface rows | 7 | Tables/forms/states/copy cleanup, no individual target mockup expected by default. | | Internal / Deprecated / Hidden rows | 1 | Local-only smoke login routes. | @@ -49,7 +49,7 @@ ## Coverage By Area | Area | Rows | Coverage Notes | | --- | ---: | --- | | Platform/system | 14 | Route-discovered; not browser-reviewed in Spec 323 because system auth/capability state needs separate fixture. | -| Governance | 12 | Strong browser coverage for inbox, decisions, exceptions, baselines; detail/diff routes remain unresolved. | +| Governance | 13 | Strong browser coverage for inbox, decisions, exceptions, baselines; detail/diff routes remain unresolved; Spec 384 adds a feature-tested baseline subject resolution worklist. | | Monitoring | 9 | Operations hub and alert delivery landing captured; record details and config forms remain pattern/manual review. | | Inventory | 8 | Route-discovered only; coverage, policy version detail, and raw-data exposure need later review. | | Evidence / audit | 8 | Audit log captured; evidence/report detail routes need customer-safe progressive-disclosure review. | @@ -76,7 +76,7 @@ ## Coverage By Primary Archetype | Evidence / Audit | 10 | Must keep proof, timestamps, source, and raw details clearly separated. | | Operations / Monitoring | 9 | Needs consistent run status, retry/rerun semantics, and diagnostic hierarchy. | | Inventory | 8 | Needs raw provider payload disclosure rules and confidence/status language. | -| Drift / Diff | 8 | Needs assignment, comparison, snapshot, and evidence-gap hierarchy. | +| Drift / Diff | 9 | Needs assignment, comparison, subject-resolution, snapshot, and evidence-gap hierarchy. | | Provider / Integration | 7 | Consent, credentials, permissions, and disconnect states require high trust clarity. | | Reviews | 7 | Customer/auditor language, export context, and proof links are central. | | Findings / Inbox | 6 | Needs triage, owner, SLA, exception, and close-state clarity. | @@ -93,7 +93,7 @@ ## Coverage By Design Depth | Design Depth | Rows | Gate Treatment | | --- | ---: | --- | -| Strategic Surface | 45 | Requires individual target artifact or explicit product decision before substantive UI implementation. | +| Strategic Surface | 46 | Requires individual target artifact or explicit product decision before substantive UI implementation. | | Domain Pattern Surface | 45 | Can be handled by grouped pattern specs and shared components. | | Design-System Cleanup Surface | 7 | Table/form/action/state cleanup can be folded into implementation waves. | | Manual Review Required | 1 | Must not be treated as product-ready until route/auth state is confirmed. | diff --git a/docs/ui-ux-enterprise-audit/page-reports/ui-100-baseline-subject-resolution.md b/docs/ui-ux-enterprise-audit/page-reports/ui-100-baseline-subject-resolution.md new file mode 100644 index 00000000..ce1d8697 --- /dev/null +++ b/docs/ui-ux-enterprise-audit/page-reports/ui-100-baseline-subject-resolution.md @@ -0,0 +1,48 @@ +# UI-100 Baseline Subject Resolution + +| Field | Value | +| --- | --- | +| Route | `/admin/workspaces/{workspace}/environments/{environment}/baseline-subject-resolution` | +| Source | `BaselineSubjectResolution` | +| Area / scope | Governance / environment | +| Archetype | Drift / Diff | +| Design depth | Strategic Surface | +| Repo truth | browser-verified route; feature-tested | +| Screenshot | [desktop](../../../specs/384-baseline-subject-resolution-ui/artifacts/screenshots/spec384-01-baseline-subject-resolution.png), [bind modal](../../../specs/384-baseline-subject-resolution-ui/artifacts/screenshots/spec384-02-baseline-subject-resolution-bind-modal.png), [mobile](../../../specs/384-baseline-subject-resolution-ui/artifacts/screenshots/spec384-03-baseline-subject-resolution-mobile.png) | +| Browser status | Browser smoke passed for route reachability, scoped worklist content, bind-modal copy, and narrow viewport overflow. | + +## First Five Seconds + +The page should read as a focused decision queue for baseline subjects that need identity binding or coverage decisions. It must make the active environment, source compare run, problem category, readiness impact, and available candidates visible before any action. + +## Productization Review + +- Decision-first: operators see actionable subjects, readiness impact, actionability, candidates, and current decision state before raw evidence. +- Evidence-first: source operation and provider/resource metadata remain available but not dominant. +- Context: environment-bound route with workspace/environment scope enforced before rendering. +- Customer/auditor safety: high, because decisions affect future baseline compare interpretation. +- Diagnostics: raw provider identifiers and fingerprints stay secondary and truncated/collapsed by default. + +## Information Inventory + +Default content shows summary counts, active source run, actionable subject rows, provider/class/type filters, readiness/actionability/reason filters, candidate availability, current binding/decision state, and source/run links. + +## Dangerous Actions + +Manual binding, decision recording, revocation, and rerun compare are high-impact. They require confirmation, capability enforcement, server-side authorization, operator notes where persisted decisions are written, and audit logging through the existing binding service or OperationRun flow. + +## Scores + +| IA | Density | User Clarity | Sellability | Disclosure | Hierarchy | DS Fit | A11y | Responsive | Components | UX Writing | Perf | +| ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | +| 7 | 7 | 7 | 6 | 7 | 7 | 7 | 6 | 6 | 7 | 7 | 7 | + +## Top Issues + +1. The compact decision modals should be rechecked visually if additional decision modes add more fields. +2. Follow-up iteration should add richer audit-history disclosure if operator volume grows. +3. The current V1 table uses inline context plus action modals rather than a separate detail route. + +## Target Direction + +Keep this as a scoped operator worklist rather than a broad governance landing page. Preserve the TenantPilot-only decision boundary, route-bound environment context, and compare/run entry links. diff --git a/docs/ui-ux-enterprise-audit/route-inventory.md b/docs/ui-ux-enterprise-audit/route-inventory.md index ec0aab63..e78af0eb 100644 --- a/docs/ui-ux-enterprise-audit/route-inventory.md +++ b/docs/ui-ux-enterprise-audit/route-inventory.md @@ -68,6 +68,7 @@ # Route Inventory | UI-059 | `/admin/baseline-snapshots` | resource | Baseline Snapshots | Evidence / audit | workspace analysis | route exists | workspace member | Evidence / Audit | Drift / Diff | Domain Pattern Surface | repo-verified | - | - | Workspace-owned evidence library. | | UI-060 | `/admin/baseline-snapshots/{record}` | resource | Baseline Snapshot Detail | Evidence / audit | workspace record | route exists | workspace + record entitlement | Evidence / Audit | Drift / Diff | Domain Pattern Surface | repo-verified | - | - | Snapshot detail may expose raw payloads; review later. | | UI-061 | `/admin/workspaces/{workspace}/environments/{environment}/baseline-compare` | page | Baseline Compare | Governance | environment-bound | browser blocked/404 in fixture | workspace + environment entitlement and baseline state | Drift / Diff | Operations / Monitoring | Strategic Surface | repo-verified | [blocked](screenshots/desktop/ui-015-baseline-compare-blocked-404.png) | [report](page-reports/ui-015-baseline-compare.md) | Route exists in route list; smoke fixture could not render it. | +| UI-100 | `/admin/workspaces/{workspace}/environments/{environment}/baseline-subject-resolution` | page | Baseline Subject Resolution | Governance | environment-bound | browser-verified | workspace + environment entitlement; view requires baseline view capability, mutations require baseline manage capability | Drift / Diff | Evidence / Audit | Strategic Surface | browser-verified | [desktop](../../specs/384-baseline-subject-resolution-ui/artifacts/screenshots/spec384-01-baseline-subject-resolution.png) | [report](page-reports/ui-100-baseline-subject-resolution.md) | Focused operator worklist for persisted baseline subject identity and coverage decisions; reachable from Baseline Compare and Operation detail only when actionable outcomes exist. | | UI-062 | `/admin/workspaces/{workspace}/environments/{environment}/inventory` | cluster | Inventory Cluster | Inventory | environment-bound | route exists | environment entitlement | Inventory | Workspace / Tenant Context | Domain Pattern Surface | repo-verified | - | - | Cluster landing/navigation surface. | | UI-063 | `/admin/workspaces/{workspace}/environments/{environment}/inventory/inventory-coverage` | page | Inventory Coverage | Inventory | environment-bound | route exists | environment entitlement | Inventory | Evidence / Audit | Strategic Surface | repo-verified | - | - | Coverage truth page; strategic because it gates evidence confidence. | | UI-064 | `/admin/workspaces/{workspace}/environments/{environment}/inventory-items` | resource | Inventory Items | Inventory | environment-bound | route exists | environment entitlement | Inventory | Evidence / Audit | Domain Pattern Surface | repo-verified | - | - | Core observed-state list. | diff --git a/specs/384-baseline-subject-resolution-ui/artifacts/screenshots/spec384-01-baseline-subject-resolution.png b/specs/384-baseline-subject-resolution-ui/artifacts/screenshots/spec384-01-baseline-subject-resolution.png new file mode 100644 index 00000000..5f6166c0 Binary files /dev/null and b/specs/384-baseline-subject-resolution-ui/artifacts/screenshots/spec384-01-baseline-subject-resolution.png differ diff --git a/specs/384-baseline-subject-resolution-ui/artifacts/screenshots/spec384-02-baseline-subject-resolution-bind-modal.png b/specs/384-baseline-subject-resolution-ui/artifacts/screenshots/spec384-02-baseline-subject-resolution-bind-modal.png new file mode 100644 index 00000000..9ab6203d Binary files /dev/null and b/specs/384-baseline-subject-resolution-ui/artifacts/screenshots/spec384-02-baseline-subject-resolution-bind-modal.png differ diff --git a/specs/384-baseline-subject-resolution-ui/artifacts/screenshots/spec384-03-baseline-subject-resolution-mobile.png b/specs/384-baseline-subject-resolution-ui/artifacts/screenshots/spec384-03-baseline-subject-resolution-mobile.png new file mode 100644 index 00000000..d8cd2fda Binary files /dev/null and b/specs/384-baseline-subject-resolution-ui/artifacts/screenshots/spec384-03-baseline-subject-resolution-mobile.png differ diff --git a/specs/384-baseline-subject-resolution-ui/checklists/requirements.md b/specs/384-baseline-subject-resolution-ui/checklists/requirements.md new file mode 100644 index 00000000..21b8d336 --- /dev/null +++ b/specs/384-baseline-subject-resolution-ui/checklists/requirements.md @@ -0,0 +1,77 @@ +# Specification Quality Checklist: Spec 384 - Baseline Subject Resolution UI and Operator Decisions v1 + +**Purpose**: Validate specification completeness, repo alignment, and implementation readiness before implementation. +**Created**: 2026-06-16 +**Feature**: `specs/384-baseline-subject-resolution-ui/spec.md` + +## Candidate Selection and Completion Guardrail + +- [x] CHK001 The selected candidate is directly user-provided as Spec 384. +- [x] CHK002 The active auto-candidate queue was not used as a forced source because it currently has no safe automatic next-best-prep target. +- [x] CHK003 Specs 381, 382, and 383 are treated as completed dependency context only. +- [x] CHK004 Historical Spec 163 is treated as adjacent/historical context and is not selected or modified. +- [x] CHK005 No existing Spec 384 package or branch was found before creation. + +## Content Quality + +- [x] CHK010 The spec states the operator problem, business value, users, scope, out-of-scope boundaries, assumptions, risks, and success criteria. +- [x] CHK011 Functional and non-functional requirements are testable and numbered. +- [x] CHK012 Acceptance criteria map to the selected V1 scope. +- [x] CHK013 No `[NEEDS CLARIFICATION]` markers remain. +- [x] CHK014 Open questions are explicitly non-blocking follow-up decisions. + +## Constitution and Scope Control + +- [x] CHK020 The spec includes a Spec Candidate Check and scored approval class. +- [x] CHK021 The spec includes a proportionality review for the new page/query layer and high-impact actions. +- [x] CHK022 The spec rejects a generic workflow/task/approval engine. +- [x] CHK023 The spec rejects a new primary decision table by default and reuses `provider_resource_bindings`. +- [x] CHK024 The spec rejects legacy subject-key UI, old payload readers, display-name identity, and compatibility shims. +- [x] CHK025 Evidence/Review readiness and customer-facing output are explicitly deferred to Spec 385. + +## UI and Productization Coverage + +- [x] CHK030 The spec includes exactly one coherent UI Surface Impact decision with concrete impact boxes checked. +- [x] CHK031 UI/Productization Coverage is completed for the new strategic operator surface. +- [x] CHK032 The plan and tasks require updates to `docs/ui-ux-enterprise-audit/route-inventory.md`, `design-coverage-matrix.md`, and a page report during implementation. +- [x] CHK033 The new page is classified as a Primary Decision Surface. +- [x] CHK034 Decision-first, audience-aware disclosure, UI/UX surface classification, and operator surface contract tables are present. +- [x] CHK035 Browser smoke is required because a new strategic high-impact surface is added. +- [x] CHK036 The spec, plan, and tasks reference `docs/product/standards/list-surface-review-checklist.md` for the new list/table surface. + +## RBAC, Audit, and Safety + +- [x] CHK040 The spec defines 404 for non-members and 403 for entitled members missing capability. +- [x] CHK041 The spec reuses existing baseline capabilities by default and requires a spec update before adding a new capability family. +- [x] CHK042 All high-impact decision actions require confirmation, server-side authorization, required notes where specified, and audit. +- [x] CHK043 The spec states that UI visibility/disabled state is not authorization. +- [x] CHK044 Audit metadata requirements are included. + +## OperationRun and Provider Boundary + +- [x] CHK050 Resolution decisions do not create a new OperationRun. +- [x] CHK051 Rerun/refresh compare must reuse existing baseline compare OperationRun UX. +- [x] CHK052 The spec forbids local OperationRun lifecycle/status/outcome handling. +- [x] CHK053 Provider/platform boundary is classified as mixed with provider identifiers retained only as proof/candidate data. +- [x] CHK054 Display names are labels only and cannot be identity. + +## Test and Task Readiness + +- [x] CHK060 `spec.md`, `plan.md`, and `tasks.md` exist. +- [x] CHK061 Tasks are grouped by setup/foundation/user story/polish phases. +- [x] CHK062 Tasks include unit, feature, Filament/Livewire, browser, RBAC, audit, and regression validation. +- [x] CHK063 Tasks include explicit UI coverage artifact work. +- [x] CHK064 Tasks include final validation commands, Pint, `git diff --check`, and close-out recording. +- [x] CHK065 Tasks include explicit no-Graph/provider-runtime negative coverage for the DB-only resolution surface. +- [x] CHK066 Tasks include a `missing_expected` preflight so the optional mode is implemented only if existing service/model support is sufficient. + +## Review Outcome + +- [x] CHK070 Review outcome class: `acceptable-special-case`. +- [x] CHK071 Workflow outcome: `keep`. +- [x] CHK072 Candidate Selection Gate result: PASS. +- [x] CHK073 Spec Readiness Gate preparation status: PASS after preparation analysis. + +## Notes + +- Preparation-only scope was maintained. No application code, migrations, models, services, jobs, Filament runtime files, routes, tests, or views were modified. diff --git a/specs/384-baseline-subject-resolution-ui/implementation-close-out.md b/specs/384-baseline-subject-resolution-ui/implementation-close-out.md new file mode 100644 index 00000000..7785a66c --- /dev/null +++ b/specs/384-baseline-subject-resolution-ui/implementation-close-out.md @@ -0,0 +1,102 @@ +# Implementation Close-Out: Spec 384 - Baseline Subject Resolution UI + +Date: 2026-06-16 +Branch: `384-baseline-subject-resolution-ui` + +## Summary + +Implemented a focused environment-scoped Baseline Subject Resolution page that derives actionable rows from Spec 383 compare semantics and active provider-resource decisions. Authorized operators can create manual bindings, record subject decisions, revoke active decisions, and rerun compare through existing baseline compare OperationRun UX. + +No new persisted entity, migration, capability family, provider call, workflow engine, customer-facing readiness mapping, or report/PDF scope was added. + +## Filament / Livewire Contract + +- Filament v5 compliance: implemented against Filament 5.2.1 and Livewire 4.1.4; no Livewire v3 or Filament v3/v4 APIs were introduced. +- Provider registration: the new page is registered in the existing admin panel provider at `apps/platform/app/Providers/Filament/AdminPanelProvider.php`; the provider remains registered through `apps/platform/bootstrap/providers.php`. +- Global search: no Filament Resource was added, so no new globally searchable resource exists. +- Destructive/high-impact actions: + - `bindSubject`: `Action::make(...)->action(...)`, `->requiresConfirmation()`, `workspace_baselines.manage` UI enforcement, server-side environment authorization, existing binding service audit. + - `recordDecision`: `Action::make(...)->action(...)`, `->requiresConfirmation()`, `workspace_baselines.manage` UI enforcement, server-side environment authorization, existing binding service audit. + - `revokeDecision`: `Action::make(...)->action(...)`, `->requiresConfirmation()`, destructive UI enforcement, `Gate::authorize('revoke')`, existing binding service audit. + - `runComparisonAgain`: confirmed action, requires `tenant.sync`, delegates to `BaselineCompareService` and existing OperationRun presenter/events. +- Asset strategy: no new Filament assets, panel assets, or heavy frontend assets were registered. Normal deploy can keep the existing `cd apps/platform && php artisan filament:assets` step. +- Testing plan/result: unit tests cover derived query/filter behavior; Filament/Livewire tests cover page render, empty states, manual binding, record decision, revoke, RBAC disabled state, outside-scope 404, Baseline Compare link behavior, and OperationRun related-navigation link behavior; browser smoke covers route/content/modal/mobile overflow. + +## Validation + +Passed: + +- `cd apps/platform && ./vendor/bin/sail artisan route:list --path=baseline-subject-resolution` +- `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Unit/Support/Baselines/BaselineSubjectResolutionQueryTest.php tests/Unit/Support/Baselines/Matching/SubjectMatchingPipelineTest.php tests/Unit/Support/Resources/ResourceIdentityTest.php tests/Unit/Support/Resources/ProviderResourceDescriptorTest.php` + - 16 passed, 112 assertions +- `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Unit/Support/Baselines tests/Unit/Support/Resources` + - 73 passed, 577 assertions +- `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/ProviderResources` + - 20 passed, 4 skipped, 75 assertions +- `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Unit/Support/Baselines/BaselineSubjectResolutionQueryTest.php tests/Feature/Filament/BaselineSubjectResolutionPageTest.php` + - 14 passed, 65 assertions +- `cd apps/platform && ./vendor/bin/sail php vendor/bin/pest tests/Browser/Spec384BaselineSubjectResolutionSmokeTest.php --filter BaselineSubjectResolution` + - 1 passed, 21 assertions +- `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Evidence/BaselineDriftPostureSourceTest.php tests/Feature/ReviewPack/Spec347ReviewPackReadinessSemanticsTest.php tests/Feature/ReviewPack/Spec349ReviewPackResolutionGuidanceTest.php` + - 11 passed, 58 assertions +- `cd apps/platform && ./vendor/bin/sail bin pint --dirty --format agent` +- `git diff --check` + +## Review Remediation + +Date: 2026-06-17 + +Manual review findings resolved: + +- Route workspace/environment mismatch now denies as not found before page access or action/query handling. +- Livewire table reads now reauthorize the locked environment ID on each read path instead of trusting mount-time state. +- Candidate discovery no longer treats matching display labels as bindable identity. Bindable candidates must come from stable compare `ProviderResourceDescriptor` payloads or inventory descriptors matched by canonical provider-resource identity. + +Additional validation: + +- `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Unit/Support/Baselines/BaselineSubjectResolutionQueryTest.php tests/Feature/Filament/BaselineSubjectResolutionPageTest.php` + - 14 passed, 65 assertions +- `cd apps/platform && ./vendor/bin/sail php vendor/bin/pest tests/Browser/Spec384BaselineSubjectResolutionSmokeTest.php --filter BaselineSubjectResolution` + - 1 passed, 21 assertions +- `cd apps/platform && ./vendor/bin/sail bin pint --format agent app/Filament/Pages/BaselineSubjectResolution.php app/Services/Baselines/BaselineSubjectResolutionQuery.php tests/Feature/Baselines/Support/BaselineSubjectResolutionFixtures.php tests/Feature/Filament/BaselineSubjectResolutionPageTest.php tests/Unit/Support/Baselines/BaselineSubjectResolutionQueryTest.php tests/Browser/Spec384BaselineSubjectResolutionSmokeTest.php` +- Untracked Spec-384 whitespace check passed. + +Residual non-Spec-384 failures: + +- `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Baselines tests/Feature/ProviderResources` + - 187 passed, 4 skipped, 4 failed. + - Failures are in existing baseline capture/compare tests outside the new page/query/action code: + - `BaselineCaptureAmbiguousMatchGapTest` expected partial success, got succeeded. + - `BaselineCaptureGapClassificationTest` expected capture gap reason counts. + - `BaselineCompareFindingsTest` expected 3 total counts, got 4. + - `BaselineCompareStrategySelectionTest` calls missing `BaselineSnapshotIdentity::subjectKey()`. + +- `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Guards/ActionSurfaceContractTest.php` + - Spec 384 page declaration violation was fixed and no longer appears. + - Remaining failures are existing expectations outside this feature: + - OperationRun clickable-row URL expectation differs because the table record URL includes canonical navigation context query parameters. + - Required Permissions page assertion expects `Start verification`, which the rendered page did not contain. + +## Browser Smoke Evidence + +Browser smoke result: PASS + +Route: `/admin/workspaces/{workspace}/environments/{environment}/baseline-subject-resolution?operation_run_id={run}` + +Context: seeded workspace manager/owner with one baseline compare run containing an actionable duplicate-candidate subject. + +Steps: smoke-login redirect to page, verify environment/worklist/action text, open `Bind subject` modal, verify TenantPilot-only/no-provider-mutation copy, resize to narrow viewport and verify no horizontal overflow. + +Screenshots: + +- `specs/384-baseline-subject-resolution-ui/artifacts/screenshots/spec384-01-baseline-subject-resolution.png` +- `specs/384-baseline-subject-resolution-ui/artifacts/screenshots/spec384-02-baseline-subject-resolution-bind-modal.png` +- `specs/384-baseline-subject-resolution-ui/artifacts/screenshots/spec384-03-baseline-subject-resolution-mobile.png` + +## Deployment Impact + +- Migrations: none. +- Environment variables: none. +- Queue/cron workers: no new workers or schedules. Existing queue workers should be restarted during normal deployment so baseline compare rerun paths use current code. +- Storage/volumes: no runtime storage changes. Browser screenshots are test/spec artifacts only. +- Dokploy/Staging: validate the new page on Staging with a seeded or real baseline compare run before production promotion because it exposes high-impact admin decisions. diff --git a/specs/384-baseline-subject-resolution-ui/plan.md b/specs/384-baseline-subject-resolution-ui/plan.md new file mode 100644 index 00000000..62510452 --- /dev/null +++ b/specs/384-baseline-subject-resolution-ui/plan.md @@ -0,0 +1,263 @@ +# Implementation Plan: Spec 384 - Baseline Subject Resolution UI and Operator Decisions v1 + +**Branch**: `384-baseline-subject-resolution-ui` | **Date**: 2026-06-16 | **Spec**: `specs/384-baseline-subject-resolution-ui/spec.md` +**Input**: Feature specification from `/specs/384-baseline-subject-resolution-ui/spec.md` + +## Summary + +Add a focused environment-scoped Baseline Subject Resolution surface that derives actionable rows from Spec 383 structured compare semantics, lets authorized operators create or revoke audited decisions through existing `ProviderResourceBindingService`, links from existing Baseline Compare and OperationRun contexts, and delegates rerun/refresh compare to existing baseline compare OperationRun UX. Do not add a generic workflow engine, new decision table, Evidence/Review readiness mapping, or provider mutation. + +## Technical Context + +**Language/Version**: PHP 8.4.15, Laravel 12.52.0 +**Primary Dependencies**: Filament 5.2.1, Livewire 4.1.4, Pest 4.3.1, PostgreSQL via Sail +**Storage**: Existing PostgreSQL tables; `provider_resource_bindings` is the decision truth. No new primary table approved. +**Testing**: Pest 4 feature/unit tests, Filament/Livewire action tests, targeted browser smoke for the new surface +**Validation Lanes**: fast-feedback, confidence, browser; PostgreSQL lane only if migrations/indexes/locks/constraints are added +**Target Platform**: Laravel monolith in `apps/platform`, Sail locally, Dokploy for staging/production +**Project Type**: Laravel/Filament web application inside `apps/platform` +**Performance Goals**: New page renders from DB-only data without provider/Graph calls; query remains scoped by workspace/environment and paginated. +**Constraints**: No Graph calls on render; no display-name identity; non-member 404; member missing capability 403; high-impact actions require confirmation, note rules, audit, and tests. +**Scale/Scope**: One environment-scoped resolution surface plus contextual links from existing Baseline Compare and OperationRun surfaces. + +## UI / Surface Guardrail Plan + +- **Guardrail scope**: changed surfaces plus new strategic operator surface. +- **Affected routes/pages/actions/states/navigation/panel/provider surfaces**: new Baseline Subject Resolution page; existing `BaselineCompareLanding`; existing `OperationRunResource` detail; optional environment dashboard/Baseline Profile summary links. +- **No-impact class, if applicable**: N/A. +- **Native vs custom classification summary**: native Filament page/table/forms/actions first; custom Blade only if native/detail layout is insufficient and documented. +- **Shared-family relevance**: status messaging, action links, OperationRun follow-up links, audit/history, high-impact action modals. +- **State layers in scope**: page, URL-query, detail/modal, and existing admin panel page-list registration; no new panel provider or shell navigation. +- **Audience modes in scope**: operator-MSP and support-platform. No customer/read-only output. +- **Decision/diagnostic/raw hierarchy plan**: decision-first default rows; diagnostics and raw provider proof collapsed/truncated/capability-gated. +- **Raw/support gating plan**: truncate provider IDs by default; full IDs/proof/audit details inside diagnostics or detail drawer. +- **One-primary-action / duplicate-truth control**: the resolution page owns decision summaries and actions; Baseline Compare/OperationRun show counts/link only. +- **Handling modes by drift class or surface**: new page is `review-mandatory`; existing page links are `report-only` unless layout/action hierarchy changes. +- **Repository-signal treatment**: UI coverage registry updates are required because a new reachable surface is added. +- **Special surface test profiles**: `shared-detail-family` for the page/detail hierarchy; `standard-native-filament` for action/table behavior. +- **Required tests or manual smoke**: Filament/Livewire action tests, RBAC/audit tests, browser smoke. +- **Exception path and spread control**: none planned. +- **Active feature PR close-out entry**: Baseline Subject Resolution UI / Operator Decision Coverage. +- **UI/Productization coverage decision**: coverage artifacts must be updated during implementation. +- **Coverage artifacts to update**: `docs/ui-ux-enterprise-audit/route-inventory.md`, `docs/ui-ux-enterprise-audit/design-coverage-matrix.md`, and a new or updated page report for Baseline Subject Resolution. +- **List-surface checklist**: implementation must apply `docs/product/standards/list-surface-review-checklist.md` because the new page includes a list/table surface. +- **No-impact rationale**: N/A. +- **Navigation / Filament provider-panel handling**: register the page in the existing admin panel provider only; no new panel provider and no broad top-level navigation in V1. +- **Screenshot or page-report need**: yes, proportional screenshot/browser smoke because this is a new strategic decision surface with high-impact actions. + +## Shared Pattern & System Fit + +- **Cross-cutting feature marker**: yes. +- **Systems touched**: baseline compare semantics/rendering, OperationRun detail, provider resource binding decisions, audit logging, RBAC UI enforcement, UI coverage registry. +- **Shared abstractions reused**: `ProviderResourceBindingService`, `ProviderResourceBindingPolicy`, `ProviderResourceResolutionMode`, `ProviderResourceBindingStatus`, `AuditRecorder`, `UiEnforcement`/`WorkspaceUiEnforcement`, existing OperationRun link/start UX helpers, existing badge/status helpers. +- **New abstraction introduced? why?**: a focused `BaselineSubjectResolutionQuery` or equivalent may be added to derive actionable rows from structured compare semantics and active bindings. It is not persisted truth and not a workflow engine. +- **Why the existing abstraction was sufficient or insufficient**: binding service is sufficient for mutations; existing evidence-gap tables are insufficient because they are read-only and do not combine candidates/current decisions/audit/action forms into one decision context. +- **Bounded deviation / spread control**: the query service may serve only baseline subject resolution rows and filters. Any broader decision inbox/workflow usage requires a follow-up spec. + +## OperationRun UX Impact + +- **Touches OperationRun start/completion/link UX?**: yes for existing run detail links and optional baseline compare rerun; no new run type. +- **Central contract reused**: existing baseline compare OperationRun start UX through `BaselineCompareService`, `OperationUxPresenter`, `OperationRunLinks`, and existing lifecycle notification behavior. +- **Delegated UX behaviors**: queued toast, run link, browser event, dedupe/already-running messaging, terminal notification, and tenant/workspace-safe URL resolution remain delegated to the existing compare start path. +- **Surface-owned behavior kept local**: decision input collection, page filters, current decision display, and navigation to existing compare start controls. +- **Queued DB-notification policy**: no new queued DB notification. +- **Terminal notification path**: existing OperationRun lifecycle mechanism. +- **Exception path**: none. + +## Provider Boundary & Portability Fit + +- **Shared provider/platform boundary touched?**: yes. +- **Provider-owned seams**: provider key, provider resource type, provider resource ID, external ID, identity kind, canonical discriminator, descriptors/fingerprints. +- **Platform-core seams**: governed subject, canonical subject key, actionability, readiness impact, binding/exclusion/limitation decision, audit metadata, operator vocabulary. +- **Neutral platform terms / contracts preserved**: provider resource, governed subject, binding, accepted limitation, exclusion, unsupported coverage, missing expected, active/revoked/superseded decision. +- **Retained provider-specific semantics and why**: provider identifiers are needed to select a real candidate. They stay proof data and are not primary operator vocabulary or identity generated from display labels. +- **Bounded extraction or follow-up path**: document-in-feature for any provider-specific display hotspot; follow-up-spec if a reusable provider candidate renderer becomes necessary across unrelated domains. + +## Constitution Check + +*GATE: Must pass before implementation. Re-check after design and before code changes.* + +- Inventory-first: compare outcomes are derived from last observed inventory/compare context; no provider calls during render. +- Read/write separation: decisions are TenantPilot-only DB mutations with confirmation, required notes, authorization, audit, and tests. No provider mutation. +- Graph contract path: no Graph calls are introduced. If future evidence refresh requires Graph, it must use existing compare/sync jobs through `GraphClientInterface`, not this page render. +- Deterministic capabilities: V1 reuses `workspace_baselines.view` and `workspace_baselines.manage`; no raw capability strings in feature code. +- RBAC-UX: `/admin` tenant/workspace plane only; non-member workspace/environment access is 404; member missing capability is 403 for mutations; authorization uses Gates/Policies plus UI enforcement. +- Workspace isolation: workspace route context is mandatory and enforced before records/candidates are revealed. +- Tenant/environment isolation: every query/action is scoped to managed environment and workspace. +- Destructive-like actions: exclusion, accepted limitation, unsupported, missing expected, revocation, and manual binding require `->requiresConfirmation()` or equivalent confirmed action flow. +- Global search: no globally searchable resource is added. If a Resource is later introduced, disable global search unless View/Edit and tenant-safe routing are proven. +- Run observability: resolution decisions are DB-only and audited; rerun compare uses existing OperationRun path. +- OperationRun start UX: local page must not compose queued toasts/links/events; reuse existing compare start UX. +- Ops-UX lifecycle: no direct OperationRun status/outcome transitions outside `OperationRunService`. +- Ops-UX summary counts: no new summary key is approved unless `OperationSummaryKeys::all()` and tests are updated. +- Test governance: unit, feature, Filament/Livewire, and browser lanes are explicitly planned. +- Proportionality: no new table/status family/capability family by default; one derived query/read service is justified by the focused decision workflow. +- Persisted truth: `provider_resource_bindings` remains the only durable decision truth. +- Behavioral state: reuse existing binding status and resolution modes. +- UI semantics: direct mapping from Spec 383 outcome truth and binding truth to UI; no new badge/status taxonomy unless an existing catalog cannot serve and spec is updated. +- Shared pattern first: use existing services/helpers/renderers before local implementations. +- Provider boundary: provider-specific identity stays descriptor/proof data; core UI and audit language remain provider-neutral. +- Filament-native UI: use native Filament components/shared primitives; no parallel local design system. +- UI/Productization coverage: implementation must update UI coverage registry and browser smoke evidence for the new surface. + +Gate result for preparation: PASS. + +## Test Governance Check + +- **Test purpose / classification by changed surface**: Unit for derived query/mapping; Feature for binding decisions, audit, RBAC, future compare consumption; Filament/Livewire for table/actions/modals; Browser for page reachability and visual smoke. +- **Affected validation lanes**: fast-feedback, confidence, browser; pgsql only if migrations/indexes/locks are introduced. +- **Why this lane mix is the narrowest sufficient proof**: The core risk is DB-backed decision correctness plus interactive Filament actions. Browser is limited to the new strategic surface. +- **Narrowest proving command(s)**: see spec planned validation commands and tasks final validation. +- **Fixture / helper / factory / seed / context cost risks**: provider candidates and workspace/environment context can be expensive; keep them feature-local and explicit. +- **Expensive defaults or shared helper growth introduced?**: no planned shared defaults. +- **Heavy-family additions, promotions, or visibility changes**: no heavy-governance family by default. +- **Surface-class relief / special coverage rule**: `standard-native-filament` plus browser smoke for new strategic page. +- **Closing validation and reviewer handoff**: verify confirmation/auth/audit/display-name rejection/future compare consumption, UI registry updates, and browser smoke. +- **Budget / baseline / trend follow-up**: document-in-feature if browser fixture setup is slow. +- **Review-stop questions**: lane fit, hidden fixture cost, high-impact action confirmation, no raw provider proof as primary UI, no workflow creep. +- **Escalation path**: document-in-feature for contained UI/browser notes; follow-up-spec for granular capabilities, expiration, approval, or reusable workbench framework. +- **Active feature PR close-out entry**: Baseline Subject Resolution UI / Operator Decision Coverage. +- **Why no dedicated follow-up spec is needed**: the needed V1 resolution surface is bounded; listed follow-ups are optional future product decisions. + +## Existing Repository Surfaces Likely Affected + +```text +apps/platform/app/Filament/Pages/BaselineCompareLanding.php +apps/platform/app/Filament/Resources/OperationRunResource.php +apps/platform/app/Livewire/BaselineCompareEvidenceGapTable.php +apps/platform/app/Services/Resources/ProviderResourceBindingService.php +apps/platform/app/Models/ProviderResourceBinding.php +apps/platform/app/Policies/ProviderResourceBindingPolicy.php +apps/platform/app/Services/Baselines/Matching/SubjectMatchingPipeline.php +apps/platform/app/Support/Baselines/CompareSemantics/* +apps/platform/app/Support/Resources/ProviderResourceResolutionMode.php +apps/platform/app/Support/Resources/ProviderResourceBindingStatus.php +apps/platform/app/Support/Auth/Capabilities.php +apps/platform/app/Support/Audit/AuditActionId.php +docs/ui-ux-enterprise-audit/route-inventory.md +docs/ui-ux-enterprise-audit/design-coverage-matrix.md +docs/ui-ux-enterprise-audit/page-reports/... +``` + +The implementation may add a new page class, query/read service, focused Livewire component, translations, and tests under existing app/test directories. Exact file names should be verified during implementation rather than invented beyond this plan. + +## Technical Approach + +1. Build a derived resolution query/read model over current baseline compare result semantics and active provider resource bindings. +2. Add a focused Filament page scoped by workspace/environment route and URL query filters. +3. Render a native Filament table with grouped actionable rows, clear empty states, and progressive detail. +4. Wire decision actions to `ProviderResourceBindingService` only. Do not duplicate transaction, supersession, or audit behavior in UI closures. +5. Add contextual links/counts from Baseline Compare and OperationRun detail. +6. Delegate rerun/refresh compare to existing baseline compare service and OperationRun UX. +7. Update UI coverage artifacts and run targeted validation. + +## Data / Migration Implications + +- No new primary persisted entity is approved. +- No migration is planned for V1. +- If implementation proves an additive index or field is required, stop and update spec/plan before adding it. +- Existing `provider_resource_bindings` active uniqueness and status/mode checks remain authoritative. +- Decision records are tenant-owned operational truth scoped by `workspace_id` and `managed_environment_id`. + +## UI / Filament Implications + +- Filament v5 / Livewire v4.0+ compliance is required; project currently has Livewire 4.1.4. +- Panel providers remain in `apps/platform/bootstrap/providers.php`; no new panel provider is planned. +- Do not enable global search for the resolution surface by default. If a Resource is introduced, disable global search unless View/Edit and tenant-safe URL rules are satisfied. +- The new list/table surface must follow `docs/product/standards/list-surface-review-checklist.md`; any deviation must be recorded in implementation close-out. +- High-impact actions execute through `Action::make(...)->action(...)` or equivalent Livewire/Filament action handlers and include confirmation. +- Use `UiEnforcement`/`WorkspaceUiEnforcement` and server-side Gates/Policies. UI disabled/hidden state is not authorization. +- No registered Filament assets are planned; `php artisan filament:assets` remains the normal deploy step only if assets are registered elsewhere. + +## Implementation Phases + +### Phase 1 - Prep and Guardrail Alignment + +Confirm dependencies, re-read completed close-outs, update UI coverage classification, and verify no Spec 385/report/workflow scope enters the implementation. + +### Phase 2 - Resolution Query and Row Model + +Create the derived query/read service and tests for actionable rows, filters, current decisions, candidate counts, and legacy-payload refusal. + +### Phase 3 - Decision Actions + +Wire actions to existing binding service for manual binding, canonical confirmation where applicable, exclusion, accepted limitation, unsupported coverage, missing expected where already supported, and revocation. Add authorization, confirmation, audit, and note-rule tests. + +### Phase 4 - Filament Surface + +Create the page/table/detail/modal UX with native Filament components, safe empty states, scope signals, progressive diagnostics, and no raw provider details as primary UI. + +### Phase 5 - Contextual Links and Compare Refresh + +Add filtered links/counts from Baseline Compare and OperationRun detail. Add rerun/refresh compare path only through existing baseline compare OperationRun UX. + +### Phase 6 - Validation and Coverage Close-Out + +Run targeted tests, browser smoke, UI registry updates, Pint, and `git diff --check`. Record close-out without changing completed dependency specs. + +## Risk Controls + +- Stop if implementation needs new table, new capability family, provider call, workflow/approval engine, or customer readiness mapping. +- Stop and update spec/plan if the existing binding service cannot support a required V1 decision without changing persistence semantics. +- Keep candidate data sanitized/truncated and diagnostics secondary. +- Keep link-only changes out of OperationRun lifecycle logic. +- Require tests for display-name rejection and cross-workspace/environment denial. + +## Rollout Considerations + +- Staging validation required because this adds high-impact governance UI. +- No env vars, scheduler changes, queue names, storage volumes, or reverse proxy changes are planned. +- Queue workers should be restarted as part of normal deploy so rerun compare paths use the latest code. +- No new `filament:assets` requirement unless implementation registers assets. +- Browser smoke is required before production promotion because this is a new reachable operator surface. + +## Project Structure + +```text +specs/384-baseline-subject-resolution-ui/ +├── spec.md +├── plan.md +├── tasks.md +└── checklists/ + └── requirements.md + +apps/platform/app/ +├── Filament/ +│ ├── Pages/ +│ └── Resources/ +├── Livewire/ +├── Services/ +│ ├── Baselines/ +│ └── Resources/ +├── Support/ +│ ├── Baselines/ +│ ├── Resources/ +│ └── Rbac/ +└── Policies/ + +apps/platform/tests/ +├── Unit/ +├── Feature/ +│ ├── Baselines/ +│ ├── Filament/ +│ └── ProviderResources/ +└── Browser/ +``` + +**Structure Decision**: Use existing Laravel/Filament app structure. Add only feature-local classes where needed and prefer existing services/policies/helpers. + +## Complexity Tracking + +| Violation | Why Needed | Simpler Alternative Rejected Because | +|---|---|---| +| New derived resolution query/read service | Operators need one decision worklist that combines compare semantics, candidates, and active decisions | Local buttons on existing gap rows scatter decision context and duplicate semantics | +| New strategic Filament page | Human decisions need a focused context with candidates/current decision/audit/action forms | OperationRun detail is diagnostics-oriented and Baseline Compare is status/review-oriented | + +## Proportionality Review + +- **Current operator problem**: actionable compare blockers cannot become durable decisions from UI. +- **Existing structure is insufficient because**: current surfaces are read-only or diagnostic and current binding service has no operator worklist. +- **Narrowest correct implementation**: one derived query, one focused page, existing service/policy/audit for mutations. +- **Ownership cost created**: page/query/tests/UI coverage and future maintenance of decision copy/action modals. +- **Alternative intentionally rejected**: generic workflow/task engine and broad Governance Inbox. +- **Release truth**: current-release truth after Specs 381-383. diff --git a/specs/384-baseline-subject-resolution-ui/spec.md b/specs/384-baseline-subject-resolution-ui/spec.md new file mode 100644 index 00000000..09e7a902 --- /dev/null +++ b/specs/384-baseline-subject-resolution-ui/spec.md @@ -0,0 +1,476 @@ +# Feature Specification: Spec 384 - Baseline Subject Resolution UI and Operator Decisions v1 + +**Feature Branch**: `384-baseline-subject-resolution-ui` +**Created**: 2026-06-16 +**Status**: Implemented / Close-out recorded +**Input**: User-provided candidate "Spec 384 - Baseline Subject Resolution UI & Operator Decisions v1" from `/Users/ahmeddarrazi/.codex/attachments/e9f8b1ac-2968-4c6f-8326-af19f7f7b8ad/pasted-text.txt`. + +## Repo-Truth Adjustment + +The user supplied a complete numbered candidate for Spec 384. Repo truth confirms: + +- `specs/381-provider-resource-identity-binding/` is implemented and closed out. It created `provider_resource_bindings`, `ProviderResourceBinding`, `ProviderResourceBindingService`, `ProviderResourceResolutionMode`, audit IDs, policy coverage, and DB-backed active binding uniqueness. +- `specs/382-baseline-matching-canonicalization/` is implemented and closed out. It added provider-resource matching, canonicalization, binding-first matching, and removed legacy/display-name identity paths. +- `specs/383-baseline-result-semantics/` is implemented and closed out on the current branch. It added structured compare result semantics and explicitly did not implement Spec 384 operator resolution UI. +- Historical `specs/163-baseline-subject-resolution/` is related context only. It targeted semantic/evidence gap foundation on existing surfaces and explicitly did not require a new operator screen. It must not be rewritten by this prep. + +This prepared Spec 384 narrows the candidate to the smallest implementation-ready slice: + +- A focused environment-scoped Baseline Subject Resolution surface. +- A query/read model over existing Spec 383 structured compare outcomes to list actionable subject-resolution items. +- Operator decisions persisted through existing `provider_resource_bindings` and `ProviderResourceBindingService`. +- Actions for manual binding, exclusion, accepted limitation, unsupported coverage, missing expected where already supported, and revocation. +- Contextual links from existing Baseline Compare and OperationRun surfaces. +- Audit, RBAC, workspace/environment isolation, and no display-name identity. +- No Evidence/Review readiness final mapping, customer-facing report wording, generic workflow engine, broad Governance Inbox, or Management Report/PDF scope. + +V1 reuses current baseline capabilities (`workspace_baselines.view` and `workspace_baselines.manage`) unless implementation proves that a separate capability family is required. Introducing granular `baseline_subject_resolution.*` capabilities would require updating this spec and the plan with a proportionality review before implementation continues. + +## Candidate Selection Gate + +- **Selected candidate**: Spec 384 - Baseline Subject Resolution UI and Operator Decisions v1. +- **Source**: Direct user-provided candidate attachment; follow-up references in Specs 381, 382, and 383. +- **Why selected**: It is the next sequence item after completed Specs 381-383 and is the first visible operator decision layer for unresolved baseline subject identity and coverage outcomes. +- **Roadmap relationship**: Supports baseline governance productization, provider-neutral identity decisions, auditability, and future Evidence/Review readiness without reopening completed productization or report lanes. +- **Close alternatives deferred**: + - Spec 385 - Evidence and Review Readiness Integration v1: depends on durable decisions from Spec 384. + - Management Report/PDF runtime validation: unrelated manual follow-through lane. + - Broad Governance Inbox or approval workflow: would turn this into a generic workflow engine and is out of scope. + - Provider readiness onboarding productization: optional manual-promotion backlog, not part of baseline subject resolution. +- **Completed-spec guardrail result**: + - Specs 381, 382, and 383 are completed and used as dependency context only. + - Spec 163 is historical/adjacent and not selected or modified. + - No existing `specs/384-*` package or `384-*` local/remote branch was found before the Spec Kit create script ran. +- **Smallest viable implementation slice**: Add one focused resolution page and service/query layer that lets authorized operators make audited binding/scope/limitation decisions for Spec 383 actionable outcomes and then rerun/refresh compare through existing baseline compare mechanisms. +- **Gate result**: PASS. The candidate is user-provided, not already covered by an active/completed Spec 384 package, aligns with the 381-385 sequence, and is scoped as a bounded UI/decision slice. + +## Spec Candidate Check *(mandatory - SPEC-GATE-001)* + +- **Problem**: Structured compare outcomes can identify unresolved identity, duplicate candidates, missing provider resources, unsupported coverage, accepted limitations, excluded subjects, and foundation limitations, but operators do not yet have a focused place to turn those outcomes into durable decisions. +- **Today's failure**: The same actionable blockers can reappear across future compares because operators cannot bind the correct provider resource, exclude a non-governed subject, accept a limitation, mark unsupported coverage, or revoke stale decisions from a safe UI. +- **User-visible improvement**: Operators can clear real baseline subject decision blockers from a dedicated resolution surface, with clear impact language and links from Baseline Compare and OperationRun context. +- **Smallest enterprise-capable version**: One environment-scoped resolution page, DB-only decision actions through existing binding service/policy/audit paths, contextual links, and targeted tests. +- **Explicit non-goals**: No generic workflow engine, no approval workflow, no Governance Inbox, no customer-facing readiness wording, no Evidence/Review final mapping, no Management Report/PDF work, no legacy subject-key UI, and no display-name identity. +- **Permanent complexity imported**: One focused Filament page or equivalent Livewire/Filament surface, a query/read service for actionable subject outcomes, action form/modal wiring, tests, and UI coverage artifacts. No new primary decision table is approved. +- **Why now**: Spec 383 now produces structured actionability/readiness semantics, and Spec 385 needs durable operator decisions before evidence/review readiness can be truthful. +- **Why not local**: Local buttons on existing gap rows would scatter decision behavior across OperationRun, Baseline Compare, and dashboard contexts. A focused page keeps one decision context, one audit path, one RBAC contract, and one route for filtered links. +- **Approval class**: Core Enterprise. +- **Red flags triggered**: New UI surface, high-impact decision actions, possible new query/service layer. Defense: the scope reuses existing persisted truth and policy/audit service paths, avoids new workflow tables, and contains decisions to baseline subject resolution only. +- **Score**: Nutzen: 2 | Dringlichkeit: 2 | Scope: 2 | Komplexitaet: 1 | Produktnaehe: 2 | Wiederverwendung: 2 | **Gesamt: 11/12** +- **Decision**: approve as a narrowed Core Enterprise UI/decision slice. + +## Problem Statement + +Baseline compare can now classify why a governed subject is not fully comparable or ready: unresolved identity, duplicate candidates, missing local evidence, missing provider resource, unsupported resource class, foundation limitation, accepted limitation, excluded non-governed subject, low-trust identity, drift, no drift, or compare failure. + +Some of those states require a human decision. Examples: + +- A baseline subject has multiple tenant-owned provider candidates and must be bound to the correct resource. +- A foundation object is inventory-only and requires an accepted limitation. +- A subject is intentionally outside governed scope and should be excluded. +- A previously expected provider resource is intentionally absent. +- An accepted limitation or binding is no longer valid and must be revoked. + +Without a focused resolution surface, these decisions cannot be made safely, audited consistently, or consumed predictably by later compares. + +## Business / Product Value + +- Converts structured compare blockers into operator actions. +- Reduces repeated false blockers across future baseline compares. +- Preserves auditability for customer-impacting decisions. +- Prevents false green states by making exclusions and accepted limitations explicit and not no-drift. +- Makes Spec 385 Evidence/Review readiness possible without embedding customer-output rules in Spec 384. +- Keeps baseline governance productized without creating a generic workflow engine. + +## Primary Users / Operators + +- Workspace manager or baseline governance operator resolving compare blockers. +- MSP engineer selecting the correct provider resource when multiple candidates exist. +- Support/platform operator diagnosing why compare output remains blocked or limited. +- Release reviewer verifying provider-neutral identity, RBAC, audit, and UI safety. + +## Spec Scope Fields *(mandatory)* + +- **Scope**: tenant-owned/environment-owned baseline subject decision surface inside the admin panel. +- **Primary Routes**: Add an environment-scoped admin page, preferred route `/admin/workspaces/{workspace}/environments/{environment}/baseline-subject-resolution`. Existing links may originate from `/admin/workspaces/{workspace}/environments/{environment}/baseline-compare`, OperationRun detail, environment dashboard, and Baseline Profile detail. +- **Data Ownership**: Existing `provider_resource_bindings` remains tenant-owned operational decision truth. Existing OperationRun context/result payloads remain execution/proof truth. Existing baseline snapshots and compare outputs remain their current truth sources. No new primary decision table is approved. +- **RBAC**: View requires existing baseline compare visibility (`workspace_baselines.view`). Mutating decisions require existing baseline manage capability (`workspace_baselines.manage`) through policies/gates. Non-members are deny-as-not-found. Entitled members missing capability receive forbidden. + +For canonical-view specs: + +- **Default filter behavior when tenant-context is active**: Not applicable. The new page is environment-scoped by route. OperationRun links must include environment/workspace-safe filters. +- **Explicit entitlement checks preventing cross-tenant leakage**: Every page load, query, action, and linked candidate must enforce workspace and managed-environment entitlement before revealing subject, candidate, binding, source run, or audit context. + +## UI Surface Impact *(mandatory - UI-COV-001)* + +Does this spec add, remove, rename, or materially change any reachable UI surface? + +- [ ] No UI surface impact +- [x] Existing page changed +- [x] New page/route added +- [ ] Navigation changed +- [x] Filament panel/provider surface changed +- [x] New modal/drawer/wizard/action added +- [x] New table/form/state added +- [ ] Customer-facing surface changed +- [x] Dangerous action changed +- [x] Status/evidence/review presentation changed +- [x] Workspace/environment context presentation changed + +## UI/Productization Coverage + +- **Route/page/surface**: New Baseline Subject Resolution page at the environment route above; contextual links from Baseline Compare, OperationRun detail, environment dashboard, and Baseline Profile detail where relevant. +- **Current or new page archetype**: Decision Workbench / Resolution Queue for an environment-owned governance workflow. +- **Design depth**: Strategic Surface because it is a new human decision surface with high-impact audited actions. +- **Repo-truth level**: repo-verified foundations; new page is spec-backed until implemented. +- **Existing pattern reused**: Existing Baseline Compare page report `docs/ui-ux-enterprise-audit/page-reports/ui-015-baseline-compare.md`, OperationRun detail progressive disclosure, `UiEnforcement`, `WorkspaceUiEnforcement`, `BadgeCatalog`, native Filament tables/forms/actions/modals, and `ProviderResourceBindingService`. +- **New pattern required**: Domain pattern for baseline subject resolution, documented through the UI coverage registry during implementation. No target mockup is required before implementation, but a screenshot/browser smoke is required before close-out because this is a new strategic surface. +- **List-surface standards**: Because this spec adds a new list/table surface, implementation must apply `docs/product/standards/list-surface-review-checklist.md` and record any deviation in the feature close-out. +- **Screenshot required**: yes during implementation close-out for desktop and a narrow/mobile viewport if the page is reachable in browser fixtures. +- **Page audit required**: yes, add or update a page report for the new route and update the design coverage matrix. +- **Customer-safe review required**: no. Spec 384 is admin/operator-only. Spec 385 decides customer-facing readiness and limitations. +- **Dangerous-action review required**: yes. Bind, exclude, accept limitation, mark unsupported, missing expected, and revoke are high-impact governance decisions that require confirmation, server-side authorization, required notes where specified, audit, and tests. +- **Coverage files updated or explicitly not needed**: + - [x] `docs/ui-ux-enterprise-audit/route-inventory.md` + - [x] `docs/ui-ux-enterprise-audit/design-coverage-matrix.md` + - [x] `docs/ui-ux-enterprise-audit/page-reports/...` + - [ ] `docs/ui-ux-enterprise-audit/strategic-surfaces.md` + - [ ] `docs/ui-ux-enterprise-audit/grouped-follow-up-candidates.md` + - [ ] `docs/ui-ux-enterprise-audit/unresolved-pages.md` + - [ ] `N/A - no reachable UI surface impact` +- **No-impact rationale when applicable**: Not applicable. + +## Cross-Cutting / Shared Pattern Reuse + +- **Cross-cutting feature?**: yes. +- **Interaction class(es)**: status messaging, action links, header actions, deep links, audit entries, OperationRun follow-up links, baseline compare evidence/status presentation, and high-impact Filament action modals. +- **Systems touched**: Baseline Compare page, OperationRun detail, baseline compare evidence gap table/presentation, environment dashboard summaries where present, provider resource binding service/policy/audit, baseline compare rerun links, UI coverage registry, tests. +- **Existing pattern(s) to extend**: Native Filament pages/tables/forms/actions, `UiEnforcement` and `WorkspaceUiEnforcement`, `ProviderResourceBindingService`, `ProviderResourceBindingPolicy`, `AuditRecorder`, `OperationRunLinks`, `OperationUxPresenter` for rerun/start UX if rerun is offered. +- **Shared contract / presenter / builder / renderer to reuse**: `ProviderResourceBindingService` for all decisions; existing OperationRun link helpers; existing badge/status helpers; existing reason/presentation helpers where status labels are rendered. +- **Why the existing shared path is sufficient or insufficient**: Binding mutation paths already exist and are sufficient for decision truth. A new query/read surface is needed because current evidence-gap tables are read-only and do not provide a single decision context for candidates, current decisions, and audit history. +- **Allowed deviation and why**: A focused resolution query/read service may be added because it derives one operator worklist from structured compare outcomes and existing bindings. It must not become a generic workflow, task, or approval engine. +- **Consistency impact**: Baseline Compare, OperationRun follow-ups, and the new page must use the same actionability/readiness/result semantics and must not invent separate labels for the same blocker. +- **Review focus**: no display-name identity, no local authorization-only UI visibility, no duplicate decision table, no customer readiness wording, no ad-hoc OperationRun start UX, no raw provider identifiers as primary UI. + +## OperationRun UX Impact + +- **Touches OperationRun start/completion/link UX?**: yes for links and optional rerun/refresh only; no new OperationRun type is created by resolution decisions. +- **Shared OperationRun UX contract/layer reused**: Existing Baseline Compare start/rerun UX, `OperationRunLinks`, and `OperationUxPresenter` must be reused if the page offers rerun/refresh compare. +- **Delegated start/completion UX behaviors**: Any queued toast, run link, browser event, dedupe/already-running messaging, terminal notification, and tenant/workspace-safe operation URL resolution must remain delegated to existing baseline compare OperationRun UX paths. +- **Local surface-owned behavior that remains**: The resolution page may collect decision inputs and may offer a navigation/control to rerun compare, but it must not locally compose queued operation notifications or run lifecycle semantics. +- **Queued DB-notification policy**: no new queued DB notifications. Existing baseline compare terminal notification behavior remains unchanged. +- **Terminal notification path**: existing OperationRun lifecycle mechanism only. +- **Exception required?**: none. + +Resolution decisions themselves are DB-only audited mutations and do not create an `OperationRun`. Baseline compare rerun remains the existing queued/observable operation. + +## Provider Boundary / Platform Core Check + +- **Shared provider/platform boundary touched?**: yes. +- **Boundary classification**: mixed. The resolution workflow and decision semantics are platform-core; provider resource descriptors and concrete IDs remain provider-owned proof. +- **Seams affected**: governed subject identity, provider resource binding, canonical subject key, candidate presentation, compare actionability, audit metadata, operator vocabulary. +- **Neutral platform terms preserved or introduced**: governed subject, provider resource, binding, exclusion, accepted limitation, unsupported coverage, missing expected, actionability, readiness impact, decision. +- **Provider-specific semantics retained and why**: provider key, provider resource type, provider resource ID, external ID, and descriptors are retained as proof/candidate data because the operator must bind to real provider resources. They must not become display-name identity or platform-wide taxonomy. +- **Why this does not deepen provider coupling accidentally**: The UI uses existing provider-neutral descriptors and stable resource identity metadata. Microsoft/Intune labels may appear only as provider-provided labels, not as canonical identity or core branching logic. +- **Follow-up path**: Spec 385 consumes the decisions for evidence/review readiness. Granular capability family, approval workflow, decision expiration, or customer output mapping require separate spec updates/follow-ups. + +## UI / Surface Guardrail Impact + +| Surface / Change | Operator-facing surface change? | Native vs Custom | Shared-Family Relevance | State Layers Touched | Exception Needed? | Low-Impact / `N/A` Note | +|---|---|---|---|---|---|---| +| Baseline Subject Resolution page | yes | Native Filament page/table/actions with shared primitives | decision workbench, status messaging, action links, audit | page, URL-query, detail/modal | no | New strategic surface; requires coverage registry and browser smoke | +| Baseline Compare contextual link/counts | yes | Existing native/shared surface | status messaging, header/context action | page, URL-query | no | Existing route changed only to link/count unresolved decisions | +| OperationRun follow-up link | yes | Existing OperationRun detail surface | OperationRun proof and follow-up link | detail, URL-query | no | Link only; no local run UX | +| Environment dashboard/Baseline Profile shortcuts | yes, if implemented | Existing native/shared surfaces | dashboard signal/action link | page, URL-query | no | Summary link only; no redesign | + +## Decision-First Surface Role + +| 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 | +|---|---|---|---|---|---|---|---| +| Baseline Subject Resolution page | Primary Decision Surface | Resolve unresolved identity or scope/coverage decision blockers | subject label, reason, readiness impact, actionability, candidate count, current decision, safe primary action | raw provider IDs, source inventory/policy/run proof, audit history | Primary because this is where the operator makes the binding/exclusion/limitation decision | Follows "resolve compare blockers" workflow, not storage object browsing | Removes search across run detail, gap table, binding records, and audit logs | +| Baseline Compare page | Secondary Context Surface | Decide whether compare output needs subject resolution | action-required counts and one link to resolution | full gap table and run proof | Secondary because it identifies the need but does not host all decision inputs | Follows compare review workflow | Directs operator to one focused resolution surface | +| OperationRun detail | Tertiary Evidence / Diagnostics Surface with follow-up | Understand why a run is blocked and open filtered resolution | concise follow-up text and filtered link | raw run context, gap proof, diagnostics | Tertiary because it proves execution and points to decision surface | Follows operation troubleshooting workflow | Avoids requiring operators to resolve from raw run context | + +## Audience-Aware Disclosure + +| 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 | +|---|---|---|---|---|---|---|---| +| Baseline Subject Resolution page | operator-MSP, support-platform | blocker reason, subject class/type, environment, readiness impact, candidate count, current decision, required action | candidate details, source run, source inventory/policy references, compare context | full provider IDs, external IDs, fingerprints, raw proof payloads, audit history | Resolve subject or revoke decision | raw identifiers and proof stay truncated/collapsed/capability-gated where applicable | page owns the decision summary; linked surfaces show counts and route into it | + +## UI/UX Surface Classification + +| 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 | +|---|---|---|---|---|---|---|---|---|---|---|---|---|---| +| Baseline Subject Resolution page | Queue / Table / Workbench | Decision workbench | Resolve subject | row focus opens detail drawer/modal or focused detail area | allowed if it opens the same detail as primary inspect | secondary navigation in row More or detail area | high-impact state changes in confirmed actions, grouped by risk | `/admin/workspaces/{workspace}/environments/{environment}/baseline-subject-resolution` | same route with query/focused subject or modal state | workspace and environment are visible from route/page context | Baseline subject decision | actionability, reason, readiness impact, current decision, candidate count | none | + +## Operator Surface Contract + +| 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 | +|---|---|---|---|---|---|---|---|---|---|---| +| Baseline Subject Resolution page | Baseline governance operator | Bind, exclude, accept limitation, mark unsupported/missing expected, revoke | Primary Decision Surface | What subject decision is blocking compare, and what action is safe now? | subject, problem, readiness impact, actionability, candidates, current decision, environment, next action | raw provider IDs, fingerprints, source proof, full audit trail | identity, comparison, coverage, actionability, readiness, decision status | TenantPilot only for decision storage; future compare uses it; no direct provider mutation | Bind subject, Exclude subject, Accept limitation, Mark unsupported, Revoke decision, Run comparison again | exclude, accept limitation, mark unsupported, missing expected, revoke, and manual binding are high-impact and require confirmation, server authorization, note rules, and audit | + +## Proportionality Review *(mandatory when structural complexity is introduced)* + +- **New source of truth?**: no new source of truth. Existing active `provider_resource_bindings` remains the durable decision truth. The resolution query is derived from existing compare output and active bindings. +- **New persisted entity/table/artifact?**: no new primary table or artifact is approved. Additive fields/indexes are out of scope unless implementation updates this spec and proves current-release need. +- **New abstraction?**: yes, a focused query/read service may be introduced to derive actionable resolution rows from existing OperationRun/compare payloads and bindings. +- **New enum/state/reason family?**: no new V1 reason family is approved. Use existing `ProviderResourceResolutionMode`, `ProviderResourceBindingStatus`, and Spec 383 compare semantics. +- **New cross-domain UI framework/taxonomy?**: no. Use native Filament/shared primitives and direct mapping from canonical domain truth. +- **Current operator problem**: Operators cannot act on actionable subject-resolution blockers without manually reconstructing candidates and decisions from run payloads and binding records. +- **Existing structure is insufficient because**: Existing gap tables are read-only, OperationRun detail is diagnostics-oriented, and binding service methods have no focused operator surface or filtered worklist. +- **Narrowest correct implementation**: One derived query/read service, one environment-scoped page, and action modals wired to existing binding service/policy/audit. +- **Ownership cost**: A new page/query/test surface and UI coverage registry updates. Reviewers must prevent it from growing into a broad workflow/approval engine. +- **Alternative intentionally rejected**: Add local buttons to evidence-gap rows only. That would scatter decision state, duplicate action semantics, and make OperationRun/Baseline Compare surfaces own decisions they should only link to. +- **Release truth**: Current-release truth. Specs 381-383 have prepared the backend and semantics; operator resolution is now the missing product layer. + +### Compatibility posture + +TenantPilot is pre-production. V1 must not add legacy subject-key UI, old OperationRun payload readers, display-name matching, display-name-derived canonical keys, historical payload compatibility screens, or dual old/new decision paths. + +## Testing / Lane / Runtime Impact *(mandatory for runtime behavior changes)* + +- **Test purpose / classification**: Unit for query/read service and binding service guard cases; Feature for DB/action/RBAC/audit/future-compare behavior; Filament/Livewire action tests for page/table/modals; Browser smoke for the new reachable decision surface. +- **Validation lane(s)**: fast-feedback, confidence, browser. PostgreSQL lane only if implementation adds indexes, constraints, locks, JSONB query behavior, or migrations. +- **Why this classification and these lanes are sufficient**: The feature is a DB-only decision workflow plus Filament/Livewire surface. No provider calls or direct Microsoft mutations are introduced. +- **New or expanded test families**: focused `BaselineSubjectResolution*` tests, ProviderResourceBinding decision/action tests, OperationRun follow-up tests, baseline compare rerun/consumption regression, and one browser smoke for the new page. +- **Fixture / helper cost impact**: reuse existing baseline compare matrix/gap fixtures and provider-resource binding factories. Any new provider candidates or workspace/environment setup must stay feature-local and opt-in. +- **Heavy-family visibility / justification**: no heavy-governance family by default. Browser smoke is explicit because this adds a strategic surface with high-impact actions. +- **Special surface test profile**: `shared-detail-family` plus `standard-native-filament` for native Filament table/action behavior. +- **Standard-native relief or required special coverage**: ordinary Filament action tests for native actions; browser smoke for route/page/action-modal reachability and no JS/visual overlap. +- **Reviewer handoff**: verify lane fit, no display-name identity, no local OperationRun UX, no new table without spec update, no customer readiness mapping, and every high-impact action has confirmation, server authorization, audit, and tests. +- **Budget / baseline / trend impact**: expected moderate feature/Filament test increase. Escalate as `document-in-feature` if browser fixtures are expensive; `follow-up-spec` only if a reusable resolution-workbench pattern is needed later. +- **Escalation needed**: document-in-feature for UI coverage/browser-smoke notes; follow-up-spec for approval workflow, decision expiration, granular capabilities, or customer readiness. +- **Active feature PR close-out entry**: Baseline Subject Resolution UI / Operator Decision Coverage. +- **Planned validation commands**: + - `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Unit/Support/Baselines tests/Unit/Support/Resources` + - `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Baselines tests/Feature/ProviderResources` + - `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Filament/BaselineSubjectResolutionPageTest.php tests/Feature/Filament/OperationRunSubjectResolutionFollowUpTest.php` + - `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Evidence/BaselineDriftPostureSourceTest.php tests/Feature/ReviewPack/Spec347ReviewPackReadinessSemanticsTest.php tests/Feature/ReviewPack/Spec349ReviewPackResolutionGuidanceTest.php` + - `cd apps/platform && ./vendor/bin/sail test:browser --filter BaselineSubjectResolution` + - `cd apps/platform && ./vendor/bin/sail bin pint --dirty --format agent` + - `git diff --check` + +## User Scenarios & Testing *(mandatory)* + +### User Story 1 - Find actionable subject decisions (Priority: P1) + +As a baseline governance operator, I need one filtered page that lists unresolved or decision-required baseline subjects from the current compare context, so I can see what blocks trustworthy compare output without reading raw run payloads. + +**Why this priority**: This is the MVP because decisions cannot be made safely until the operator can see actionable subjects and candidates in one context. + +**Independent Test**: Given a baseline compare run with unresolved duplicate candidates, missing provider resources, unsupported coverage, accepted limitations, and excluded subjects, the page lists only actionable items by default, supports filters, and shows empty states when no decisions are required. + +**Acceptance Scenarios**: + +1. **Given** an environment has a latest baseline compare with duplicate provider candidates, **When** an entitled operator opens Baseline Subject Resolution, **Then** the subject appears with problem, candidate count, readiness impact, actionability, and a resolve action. +2. **Given** a compare has only trusted no-drift and resolved subjects, **When** the page loads, **Then** the empty state says no baseline subject decisions are required. +3. **Given** no compare context exists for the environment, **When** the page loads, **Then** the empty state directs the operator to run baseline compare first. + +--- + +### User Story 2 - Make audited resolution decisions (Priority: P1) + +As an authorized operator, I need to bind a subject to a provider resource, exclude it, accept a limitation, mark unsupported or missing expected, and revoke stale decisions, so future compares can consume real operator decisions. + +**Why this priority**: This is the primary product value. The page must do more than report blockers; it must create durable audited decisions. + +**Independent Test**: Each decision action is executed through the page against a scoped subject and candidate; the resulting active binding/decision exists, previous active decision is superseded or revoked, audit is written, and unauthorized actors are denied. + +**Acceptance Scenarios**: + +1. **Given** a subject has valid provider candidates, **When** an authorized operator binds one candidate with a note, **Then** an active `manual_binding` decision is persisted and audited. +2. **Given** a subject is intentionally not governed, **When** an authorized operator excludes it with a reason and note, **Then** an active `excluded_non_governed` decision is persisted and the UI states that exclusion is not no-drift. +3. **Given** an active decision exists, **When** an authorized operator revokes it with a note, **Then** the decision becomes inactive for future compares and the revocation is audited. +4. **Given** a subject has only display-label data and no valid provider identity, **When** an operator attempts to bind it, **Then** the action is blocked and no display-name identity is stored. + +--- + +### User Story 3 - Navigate from compare and operation context (Priority: P2) + +As an operator reviewing compare output or an OperationRun, I need contextual links into the resolution page with filters already applied, so I can resolve blockers without reconstructing context manually. + +**Why this priority**: Links make the decision surface discoverable while avoiding a broad top-level workflow hub. + +**Independent Test**: OperationRun detail and Baseline Compare surface show resolution counts/links only when actionable subject-resolution outcomes exist; links open the resolution page scoped to the same workspace/environment and filtered by operation run/reason/actionability. + +**Acceptance Scenarios**: + +1. **Given** a baseline compare OperationRun has actionable subject-resolution outcomes, **When** an entitled operator opens the run detail, **Then** the follow-up link opens the resolution page filtered to that run. +2. **Given** Baseline Compare has action-required subject outcomes, **When** the operator opens the compare page, **Then** the page shows a concise count and a "Resolve baseline subjects" link. +3. **Given** the operator lacks manage capability, **When** they follow the link, **Then** they can view entitled resolution context but decision actions are disabled or forbidden according to the 404/403 rules. + +--- + +### User Story 4 - Re-run or refresh compare after decisions (Priority: P3) + +As an operator who has resolved one or more subject decisions, I need a clear path to run or refresh baseline compare through existing operation UX, so I can confirm whether blockers are cleared. + +**Why this priority**: Decisions affect future compares, but starting a compare must remain in the existing queued/observable baseline compare workflow. + +**Independent Test**: After decisions are saved, the page offers a rerun/refresh path that delegates to existing baseline compare start UX and does not create local OperationRun messaging. + +**Acceptance Scenarios**: + +1. **Given** a decision was saved, **When** the operator chooses to run comparison again, **Then** the existing baseline compare service and OperationRun UX are used. +2. **Given** the next compare consumes an active decision, **When** results are rendered, **Then** the subject is no longer listed as unresolved for the same reason. + +### Edge Cases + +- A subject has multiple candidates with similar display labels but different provider identities. +- A subject has no valid `ResourceIdentity`; display label exists only for human readability. +- The previous active decision was superseded, revoked, or belongs to another workspace/environment. +- A user is a workspace/environment member but lacks manage capability. +- A user is not entitled to the workspace or environment. +- A linked OperationRun is from another environment or workspace. +- Compare payload lacks structured Spec 383 semantics; the page must not parse legacy payloads and should show a safe empty/error state. +- Provider resource IDs or external IDs are long or sensitive; primary UI truncates/masks and pushes full details into diagnostics. +- Accepted limitation, excluded, unsupported, and missing expected decisions must never render as verified no-drift. + +## Requirements *(mandatory)* + +**Constitution alignment (required):** This feature adds DB-only high-impact governance decisions and a new operator surface. It does not add Graph calls, direct provider mutations, new OperationRun type, or new persisted decision table. Every decision must use server-side authorization, confirmation, required note rules where specified, audit logging, workspace/environment isolation, and tests. + +**Constitution alignment (RBAC-UX):** Non-members receive deny-as-not-found before any subject, candidate, binding, or run detail is exposed. Entitled members missing capability receive forbidden for execution; UI visibility/disabled state is never the security boundary. + +**Constitution alignment (OPS-UX):** Resolution decisions do not create an OperationRun. Any rerun/refresh compare path must reuse existing baseline compare OperationRun start UX. OperationRun status/outcome transitions remain service-owned. + +**Constitution alignment (UI-COV-001):** The feature adds a new strategic operator surface and high-impact actions, so implementation must update UI coverage artifacts and perform browser smoke. + +**Constitution alignment (PROV-001):** Provider identifiers are proof/candidate data, not platform-core semantics. Display names remain labels only. + +**Constitution alignment (TEST-GOV-001):** Unit, feature, Filament/Livewire, and browser lanes are explicitly planned. Fixture setup must remain feature-local and opt-in. + +### Functional Requirements + +- **FR-384-001**: TenantPilot MUST provide a focused Baseline Subject Resolution surface scoped to workspace and managed environment. +- **FR-384-002**: The surface MUST derive actionable items from Spec 383 structured compare semantics and existing active binding decisions. +- **FR-384-003**: The surface MUST NOT parse legacy subject-key or historical compare payload shapes as authoritative resolution input. +- **FR-384-004**: The default list MUST show subject, subject class, resource type, provider, environment, problem, readiness impact, actionability, candidate count, current decision, last seen/source context, and available action. +- **FR-384-005**: The list MUST support filters for environment context, provider, subject class, resource type, actionability, readiness impact, reason, active binding, candidates, and operation run where relevant. +- **FR-384-006**: The page MUST show safe empty states for no decisions required and for no compare context available. +- **FR-384-007**: Operators MUST be able to bind unresolved subjects to valid provider resources through `manual_binding`. +- **FR-384-008**: Operators MUST be able to confirm canonical built-in or virtual target decisions only when the candidate identity comes from provider/canonical metadata, not display names. +- **FR-384-009**: Operators MUST be able to exclude a subject as non-governed through `excluded_non_governed`. +- **FR-384-010**: Operators MUST be able to accept a limitation through `accepted_limitation`. +- **FR-384-011**: Operators MUST be able to mark unsupported coverage through `unsupported_coverage`. +- **FR-384-012**: Operators SHOULD be able to mark missing expected through `missing_expected` if existing service/model support is sufficient without new persistence. +- **FR-384-013**: Operators MUST be able to revoke an active decision. +- **FR-384-014**: Manual binding, exclusion, accepted limitation, unsupported coverage, missing expected, and revocation MUST require an operator note. +- **FR-384-015**: Decision actions MUST supersede previous active decisions for the same scoped canonical subject according to existing binding service behavior. +- **FR-384-016**: Revoked decisions MUST NOT affect future baseline compares. +- **FR-384-017**: Every manual decision MUST emit audit metadata including actor, workspace, environment, provider, subject key, mode, previous/new decision where applicable, note metadata, source compare/run, and source references when available. +- **FR-384-018**: Every action MUST enforce workspace/environment scoped authorization server-side. +- **FR-384-019**: Non-member access MUST be denied as not found; entitled members missing manage capability MUST be forbidden on mutation. +- **FR-384-020**: Display names MUST NOT be accepted or persisted as identity. They may appear only as labels. +- **FR-384-021**: OperationRun detail MUST link to filtered resolution context where actionable subject-resolution outcomes exist. +- **FR-384-022**: Baseline Compare MUST link to the resolution surface where actionable outcomes exist. +- **FR-384-023**: Environment dashboard and Baseline Profile shortcuts MAY be added only as summary links, not full redesigns. +- **FR-384-024**: The page MUST offer a safe path to rerun/refresh comparison through existing baseline compare OperationRun UX. +- **FR-384-025**: Subsequent baseline compares MUST consume active decisions through the existing matching pipeline. +- **FR-384-026**: Accepted limitation, exclusion, unsupported, and missing expected decisions MUST NOT be presented as verified no-drift. +- **FR-384-027**: The feature MUST NOT finalize Evidence Snapshot readiness, Review Pack publication readiness, or customer-facing limitation wording. +- **FR-384-028**: The feature MUST NOT introduce a generic workflow/task/approval engine. + +### Non-Functional Requirements + +- **NFR-384-001**: Workspace isolation is mandatory for all reads, candidates, decisions, links, and audit context. +- **NFR-384-002**: Managed-environment isolation is mandatory; decisions must not cross environment boundaries. +- **NFR-384-003**: Rendering the page and details must be DB-only and must not call Graph or provider runtime APIs. +- **NFR-384-004**: Provider resource IDs and external IDs must be truncated or progressively disclosed by default. +- **NFR-384-005**: The UI must be calm, scan-first, and decision-led, with diagnostics secondary. +- **NFR-384-006**: Native Filament components/shared primitives must be used before custom Blade or local styling. +- **NFR-384-007**: High-impact action modals must clearly state mutation scope: TenantPilot decision only, not direct provider mutation. +- **NFR-384-008**: Test fixtures must remain feature-local and must not widen default provider/workspace setup. + +## UI Action Matrix *(mandatory when Filament is changed)* + +| Surface | Location | Header Actions | Inspect Affordance (List/Table) | Row Actions (max 2 visible) | Bulk Actions (grouped) | Empty-State CTA(s) | View Header Actions | Create/Edit Save+Cancel | Audit log? | Notes / Exemptions | +|---|---|---|---|---|---|---|---|---|---|---| +| Baseline Subject Resolution page | `apps/platform/app/Filament/Pages/...` preferred | `Run comparison again` only if delegated to existing compare UX; no broad nav action | row focus/detail drawer or focused detail area; exactly one inspect model | primary contextual `Resolve` or `Review decision`; risky actions in modal/detail More | none in V1 | `Run baseline compare` if no compare context and user can manage; no CTA when no decisions required unless compare rerun is useful | N/A unless detail subpage is chosen | N/A | yes for every decision action through binding service | Action labels must use Verb + Object. Destructive/high-impact actions require confirmation, authorization, note rules, and audit. | +| Baseline Compare page link | existing `BaselineCompareLanding` | `Resolve baseline subjects` link only when actionable outcomes exist | existing inspect paths unchanged | no new row mutation | none | existing compare CTA remains | N/A | N/A | N/A for link | Link is navigation, not mutation. | +| OperationRun detail follow-up | existing `OperationRunResource` detail | no new mutation | existing detail sections unchanged | follow-up link only | none | N/A | N/A | N/A | N/A for link | Link opens filtered resolution page; no local run UX. | + +### Key Entities + +- **Baseline subject decision row**: Derived UI row representing a governed subject outcome from structured compare semantics plus current binding/decision state. +- **Provider resource candidate**: A provider-backed identity option with `ResourceIdentity`/descriptor proof that can be selected for binding. +- **Provider resource binding**: Existing persisted decision truth in `provider_resource_bindings`. +- **Resolution mode**: Existing `ProviderResourceResolutionMode` value such as `manual_binding`, `excluded_non_governed`, `accepted_limitation`, `unsupported_coverage`, or `missing_expected`. +- **Current decision**: Active binding/decision for a scoped canonical subject. + +## Out of Scope + +- Evidence Snapshot readiness final mapping. +- Review Output readiness or Review Pack publication blocker final mapping. +- Customer-facing report wording. +- Generic workflow/task/approval engine. +- Broad Governance Inbox. +- Management Report/PDF runtime or staging validation. +- Legacy subject-key UI or old OperationRun payload compatibility UI. +- Display-name-based matching or binding. +- Broad Baseline Profile redesign. +- Bulk destructive actions. +- New primary decision table. +- Direct Graph/provider mutation. +- New granular capability family unless this spec and plan are updated first. + +## Acceptance Criteria + +- **AC-384-001**: A focused Baseline Subject Resolution page exists and is reachable through environment-scoped admin routing. +- **AC-384-002**: The page lists actionable outcomes from Spec 383 semantics and hides resolved/no-action subjects by default. +- **AC-384-003**: Manual binding to a valid provider resource creates or supersedes an active audited decision. +- **AC-384-004**: Exclusion, accepted limitation, unsupported coverage, missing expected where supported, and revocation actions require notes, confirmation, authorization, and audit. +- **AC-384-005**: Unauthorized/non-entitled access follows 404/403 rules. +- **AC-384-006**: Display-name-only identity is rejected. +- **AC-384-007**: OperationRun and Baseline Compare contextual links open filtered resolution context. +- **AC-384-008**: Future compare consumes active decisions and ignores revoked decisions. +- **AC-384-009**: Exclusion and accepted limitation are not displayed as verified no-drift. +- **AC-384-010**: UI coverage registry and browser smoke evidence are updated during implementation. +- **AC-384-011**: No Evidence/Review final mapping, customer-facing wording, Management Report/PDF work, or workflow engine is introduced. + +## Success Criteria *(mandatory)* + +### Measurable Outcomes + +- **SC-384-001**: In validation fixtures with actionable subject blockers, an entitled operator can identify the problem, candidate count, current decision, and next action for each subject from the default page without opening raw diagnostics. +- **SC-384-002**: 100% of decision actions in targeted tests write an audit entry and enforce server-side authorization. +- **SC-384-003**: 100% of display-name-only binding attempts in targeted tests are rejected without creating or updating a binding. +- **SC-384-004**: After an active binding/exclusion/limitation decision is created and compare is rerun in targeted tests, the same subject no longer appears as unresolved for the prior reason. +- **SC-384-005**: Automated or manual browser smoke reaches the new surface and verifies the page is nonblank, scoped, and action modals are reachable without overlapping or broken UI. + +## Assumptions + +- Specs 381, 382, and 383 remain implemented on the target branch before Spec 384 implementation begins. +- Existing `provider_resource_bindings` fields and modes are sufficient for V1 decisions. +- Existing baseline capabilities are sufficient for V1 RBAC; granular capabilities are a later follow-up unless proven necessary. +- Resolution decisions are TenantPilot-only DB decisions and do not mutate provider resources. +- Existing compare rerun/start behavior remains the only path for refreshing compare output. +- Browser fixtures can provide a reachable workspace/environment/baseline compare scenario; if not, implementation must perform and document manual browser-smoke evidence instead of skipping browser validation. + +## Risks + +- **Workflow creep**: The surface could become a generic remediation queue. Mitigation: only baseline subject decision actions are in scope. +- **False green**: Accepted limitations or exclusions could look healthy. Mitigation: UI and tests must state they are not no-drift. +- **Identity regression**: Display names could be treated as identity. Mitigation: bind only valid `ResourceIdentity` candidates. +- **Permission risk**: Operators could hide real issues. Mitigation: manage capability, required notes, confirmations, and audit. +- **UI noise**: Too many categories can overwhelm operators. Mitigation: group by actionability and keep diagnostics progressive. +- **Provider-specific leakage**: Microsoft labels could become core truth. Mitigation: keep provider-specific data as descriptors/proof only. + +## Open Questions + +No open question blocks implementation readiness. + +Non-blocking follow-up decisions: + +- Whether a later spec should introduce granular `baseline_subject_resolution.*` capabilities. +- Whether accepted limitations should expire or require renewal. +- Whether subject-level exclusions should later roll up to Baseline Profile scope policy. +- Whether revocation history should appear in the main list or only in compact audit history after V1. + +## Follow-Up Spec Candidates + +- Spec 385 - Evidence and Review Readiness Integration v1. +- Accepted limitation expiry and renewal policy. +- Granular baseline subject resolution capabilities. +- Baseline Profile subject-scope policy productization. +- Approval workflow for high-risk exclusions, if customer evidence demands it. diff --git a/specs/384-baseline-subject-resolution-ui/tasks.md b/specs/384-baseline-subject-resolution-ui/tasks.md new file mode 100644 index 00000000..59d26079 --- /dev/null +++ b/specs/384-baseline-subject-resolution-ui/tasks.md @@ -0,0 +1,233 @@ +# Tasks: Spec 384 - Baseline Subject Resolution UI and Operator Decisions v1 + +**Input**: Design documents from `/specs/384-baseline-subject-resolution-ui/` +**Prerequisites**: `plan.md`, `spec.md` + +**Tests**: Tests are REQUIRED because this feature adds runtime behavior, high-impact Filament actions, RBAC/audit paths, and a new reachable operator surface. + +## Test Governance Checklist + +- [x] TGC001 Lane assignment is named and is the narrowest sufficient proof for the changed behavior. +- [x] TGC002 New or changed tests stay in the smallest honest family, and any heavy-governance or browser addition is explicit. +- [x] TGC003 Shared helpers, factories, seeds, fixtures, and context defaults stay cheap by default; any widening is isolated or documented. +- [x] TGC004 Planned validation commands cover the change without pulling in unrelated lane cost. +- [x] TGC005 The declared surface test profile or `standard-native-filament` relief is explicit. +- [x] TGC006 Any material budget, baseline, trend, or escalation note is recorded in the active spec or PR. + +## Implementation Notes + +- Planned separate test files were consolidated where narrower: `BaselineSubjectResolutionPageTest.php` covers render, empty states, actions, RBAC, Baseline Compare link behavior, and OperationRun related-navigation behavior; `BaselineSubjectResolutionQueryTest.php` covers query/filter/legacy semantics. +- Existing `ProviderResourceBindingServiceTest.php` and `SubjectMatchingPipelineTest.php` remain the canonical coverage for all V1 binding modes and active/revoked decision consumption. +- Broad `tests/Feature/Baselines tests/Feature/ProviderResources` validation was run and residual baseline capture/compare failures are recorded in `implementation-close-out.md`. + +## Phase 1: Setup and Guardrails + +**Purpose**: Confirm dependency close-outs, repo truth, and UI guardrails before implementation starts. + +- [x] T001 Confirm `specs/381-provider-resource-identity-binding/implementation-close-out.md`, `specs/382-baseline-matching-canonicalization/implementation-close-out.md`, and `specs/383-baseline-result-semantics/implementation-close-out.md` exist and treat them as dependency context only. +- [x] T002 Confirm no code or artifact changes are made to completed specs `specs/381-provider-resource-identity-binding/`, `specs/382-baseline-matching-canonicalization/`, `specs/383-baseline-result-semantics/`, or historical `specs/163-baseline-subject-resolution/`. +- [x] T003 Re-read `apps/platform/app/Services/Resources/ProviderResourceBindingService.php`, `apps/platform/app/Models/ProviderResourceBinding.php`, `apps/platform/app/Policies/ProviderResourceBindingPolicy.php`, and `apps/platform/app/Support/Resources/ProviderResourceResolutionMode.php`; explicitly verify whether `missing_expected` is already supported without new persistence before implementing that mode. +- [x] T004 Re-read `apps/platform/app/Filament/Pages/BaselineCompareLanding.php`, `apps/platform/app/Filament/Resources/OperationRunResource.php`, `apps/platform/app/Livewire/BaselineCompareEvidenceGapTable.php`, and `docs/ui-ux-enterprise-audit/page-reports/ui-015-baseline-compare.md`. +- [x] T005 Apply `docs/product/standards/list-surface-review-checklist.md` for the new list/table surface, then update UI coverage artifacts for the new surface in `docs/ui-ux-enterprise-audit/route-inventory.md`, `docs/ui-ux-enterprise-audit/design-coverage-matrix.md`, and a new or updated page report under `docs/ui-ux-enterprise-audit/page-reports/`. +- [x] T006 Confirm no new Filament panel provider, broad top-level navigation item, global search resource, generic workflow engine, Evidence/Review readiness mapping, or Management Report/PDF scope is added; if required, stop and update `spec.md` and `plan.md`. + +--- + +## Phase 2: Foundational Resolution Query + +**Purpose**: Build the derived read path that turns Spec 383 result semantics plus active decisions into actionable resolution rows. + +- [x] T007 [P] Add unit coverage for actionable row derivation in `apps/platform/tests/Unit/Support/Baselines/BaselineSubjectResolutionQueryTest.php`, including a negative assertion that row derivation uses persisted compare/binding data and does not invoke Graph or provider runtime clients. +- [x] T008 [P] Add feature coverage for workspace/environment denial in `apps/platform/tests/Feature/Filament/BaselineSubjectResolutionPageTest.php`. +- [x] T009 [P] Add feature coverage proving legacy subject-key or historical payload shapes are not authoritative in `apps/platform/tests/Unit/Support/Baselines/BaselineSubjectResolutionQueryTest.php`. +- [x] T010 Add a focused query/read service for subject resolution rows under `apps/platform/app/Services/Baselines/` or `apps/platform/app/Support/Baselines/`, deriving rows from current compare semantics and active `provider_resource_bindings`. +- [x] T011 Ensure the query supports filters for operation run, provider, subject class, resource type, actionability, readiness impact, reason, active binding, and candidate availability. +- [x] T012 Ensure the query returns display labels only as human-readable metadata and never as identity. +- [x] T013 Ensure resolved/no-action subjects are excluded from the default worklist while available through explicit filters if needed. + +**Checkpoint**: Actionable subject rows can be derived and tested without UI. + +--- + +## Phase 3: User Story 1 - Find Actionable Subject Decisions (Priority: P1) - MVP + +**Goal**: Provide the focused list/detail context operators need before decisions can be made. + +**Independent Test**: The page lists actionable outcomes, supports filters, and shows correct empty states without raw diagnostics. + +### Tests for User Story 1 + +- [x] T014 [P] [US1] Add Filament/Livewire page render coverage in `apps/platform/tests/Feature/Filament/BaselineSubjectResolutionPageTest.php`, including DB-only render coverage. +- [x] T015 [P] [US1] Add filter and empty-state coverage in `apps/platform/tests/Feature/Filament/BaselineSubjectResolutionPageTest.php` and `apps/platform/tests/Unit/Support/Baselines/BaselineSubjectResolutionQueryTest.php`. +- [x] T016 [P] [US1] Add candidate/detail disclosure coverage in `apps/platform/tests/Feature/Filament/BaselineSubjectResolutionPageTest.php`. + +### Implementation for User Story 1 + +- [x] T017 [US1] Add the environment-scoped Baseline Subject Resolution page under `apps/platform/app/Filament/Pages/` using the route chosen in `spec.md`. +- [x] T018 [US1] Implement the native Filament table/list with columns for subject, class, type, provider, problem, readiness impact, actionability, candidate count, current decision, source/last seen, and action. +- [x] T019 [US1] Implement focused row/action-modal detail with subject context, candidate list, and current decision using progressive disclosure. +- [x] T020 [US1] Add empty states for "no baseline subject decisions required" and "run baseline compare first". +- [x] T021 [US1] Ensure raw provider IDs, external IDs, fingerprints, and source proof are truncated/collapsed by default and not primary page content. + +**Checkpoint**: Operators can find actionable decisions in one scoped page. + +--- + +## Phase 4: User Story 2 - Make Audited Resolution Decisions (Priority: P1) + +**Goal**: Let authorized operators persist binding, exclusion, limitation, unsupported, missing expected, and revocation decisions through existing decision truth. + +**Independent Test**: Each action creates/supersedes/revokes an active decision, emits audit, enforces note rules, and denies unauthorized actors. + +### Tests for User Story 2 + +- [x] T022 [P] [US2] Add decision action tests in `apps/platform/tests/Feature/ProviderResources/ProviderResourceBindingServiceTest.php` and `apps/platform/tests/Feature/Filament/BaselineSubjectResolutionPageTest.php` for binding, accepted limitation, `missing_expected` support, supersession, and revocation. +- [x] T023 [P] [US2] Add RBAC positive/negative action tests in `apps/platform/tests/Feature/Filament/BaselineSubjectResolutionPageTest.php`. +- [x] T024 [P] [US2] Add audit assertions in `apps/platform/tests/Feature/ProviderResources/ProviderResourceBindingServiceTest.php` and `apps/platform/tests/Feature/Filament/BaselineSubjectResolutionPageTest.php`. +- [x] T025 [P] [US2] Add display-name rejection coverage in `apps/platform/tests/Feature/ProviderResources/ProviderResourceBindingServiceTest.php`. +- [x] T026 [P] [US2] Add Filament action modal/note/confirmation tests in `apps/platform/tests/Feature/Filament/BaselineSubjectResolutionPageTest.php`. + +### Implementation for User Story 2 + +- [x] T027 [US2] Wire manual binding action to `ProviderResourceBindingService::createManualBinding()` and require a valid `ResourceIdentity` candidate plus operator note. +- [x] T028 [US2] Wire decision recording only when provider/canonical metadata supplies a valid identity. +- [x] T029 [US2] Wire exclusion, accepted limitation, unsupported coverage, and `missing_expected` only when T003 confirms existing support to existing `ProviderResourceBindingService` methods, requiring notes and clear modal copy. +- [x] T030 [US2] Wire revocation action to `ProviderResourceBindingService::revoke()` with required note and confirmation. +- [x] T031 [US2] Apply `UiEnforcement` or `WorkspaceUiEnforcement` and server-side Gate/Policy checks so non-members are 404 and members missing manage capability are 403 on mutation. +- [x] T032 [US2] Ensure every high-impact action uses Filament `->action(...)` plus confirmation and does not execute through URL-only actions. +- [x] T033 [US2] Ensure action copy states mutation scope is TenantPilot decision only and not a direct provider/Microsoft mutation. + +**Checkpoint**: Operators can make audited decisions; unauthorized actors cannot. + +--- + +## Phase 5: User Story 3 - Navigate From Compare and Operation Context (Priority: P2) + +**Goal**: Add filtered links/counts from existing surfaces without turning those surfaces into decision owners. + +**Independent Test**: Baseline Compare and OperationRun detail show links only when actionable outcomes exist and preserve workspace/environment filters. + +### Tests for User Story 3 + +- [x] T034 [P] [US3] Add Baseline Compare contextual link/count coverage in `apps/platform/tests/Feature/Filament/BaselineSubjectResolutionPageTest.php`. +- [x] T035 [P] [US3] Add OperationRun follow-up link coverage in `apps/platform/tests/Feature/Filament/BaselineSubjectResolutionPageTest.php`. +- [x] T036 [P] [US3] Add link-scope denial coverage in `apps/platform/tests/Feature/Filament/BaselineSubjectResolutionPageTest.php`. + +### Implementation for User Story 3 + +- [x] T037 [US3] Update `apps/platform/app/Filament/Pages/BaselineCompareLanding.php` or its supporting presenter to show action-required counts and a `Resolve baseline subjects` navigation link only when actionable outcomes exist. +- [x] T038 [US3] Update the OperationRun related-navigation support to add concise subject-resolution follow-up text and a filtered link for baseline compare runs. +- [x] T039 [US3] Ensure links include only safe filters such as operation run, workspace, and environment, and reject cross-environment run IDs. +- [x] T040 [US3] No environment dashboard or Baseline Profile shortcut was added in V1. + +**Checkpoint**: Operators can reach the resolution page from compare/run context without duplicate decision UI. + +--- + +## Phase 6: User Story 4 - Re-run or Refresh Compare After Decisions (Priority: P3) + +**Goal**: Give operators a safe path to validate decisions through existing baseline compare OperationRun UX. + +**Independent Test**: Rerun/refresh delegates to existing compare start UX, and the next compare consumes active decisions. + +### Tests for User Story 4 + +- [x] T041 [P] [US4] Existing `apps/platform/tests/Unit/Support/Baselines/Matching/SubjectMatchingPipelineTest.php` covers active-decision consumption. +- [x] T042 [P] [US4] Rerun/refresh UX delegates to existing compare UX in `apps/platform/app/Filament/Pages/BaselineSubjectResolution.php`. +- [x] T043 [P] [US4] Existing matching and provider-resource tests cover revoked decisions not being active truth. + +### Implementation for User Story 4 + +- [x] T044 [US4] Add a rerun/refresh compare path only by delegating to existing baseline compare service/start UX; do not locally compose queued toasts, run links, terminal notifications, or OperationRun lifecycle changes. +- [x] T045 [US4] Ensure `SubjectMatchingPipeline` or existing compare integration consumes active decisions and ignores revoked decisions without adding display-name fallback. +- [x] T046 [US4] Ensure resolved/excluded/accepted-limitation subjects no longer appear as unresolved after a rerun, while still avoiding false no-drift presentation. + +**Checkpoint**: Operators can validate decisions through existing compare workflow. + +--- + +## Phase 7: Polish and Cross-Cutting Validation + +**Purpose**: Close UI coverage, browser smoke, regression, formatting, and deployment notes. + +- [x] T047 [P] Update UI coverage close-out details in `docs/ui-ux-enterprise-audit/route-inventory.md`, `docs/ui-ux-enterprise-audit/design-coverage-matrix.md`, and the new/updated page report. +- [x] T048 [P] Review localization/translation handling for new labels, empty states, actions, modal headings, warnings, and audit-facing copy; V1 keeps page-local operator copy consistent with adjacent Filament pages. +- [x] T049 [P] Add automated browser smoke coverage for the new surface under `apps/platform/tests/Browser/`. +- [x] T050 Run `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Unit/Support/Baselines tests/Unit/Support/Resources`. +- [x] T051 Run `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Baselines tests/Feature/ProviderResources`; residual non-Spec-384 failures recorded in `implementation-close-out.md`. +- [x] T052 Run `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Unit/Support/Baselines/BaselineSubjectResolutionQueryTest.php tests/Feature/Filament/BaselineSubjectResolutionPageTest.php`. +- [x] T053 Run `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Evidence/BaselineDriftPostureSourceTest.php tests/Feature/ReviewPack/Spec347ReviewPackReadinessSemanticsTest.php tests/Feature/ReviewPack/Spec349ReviewPackResolutionGuidanceTest.php`. +- [x] T054 Run `cd apps/platform && ./vendor/bin/sail php vendor/bin/pest tests/Browser/Spec384BaselineSubjectResolutionSmokeTest.php --filter BaselineSubjectResolution`. +- [x] T055 Run `cd apps/platform && ./vendor/bin/sail bin pint --dirty --format agent`. +- [x] T056 Run `git diff --check`. +- [x] T057 Record implementation close-out with Livewire v4 compliance, provider registration location, global search status, destructive/high-impact action confirmation/authorization/audit, asset strategy, tests run, browser smoke result, and deployment impact. + +--- + +## Dependencies and Execution Order + +### Phase Dependencies + +- **Phase 1** blocks implementation because dependency and UI coverage decisions must be confirmed first. +- **Phase 2** blocks all user stories because the page and links need a single derived query/read path. +- **US1** can begin after Phase 2 and delivers the MVP visible decision worklist. +- **US2** depends on Phase 2 and can run alongside parts of US1 after the page action targets are known. +- **US3** depends on the query and route from US1. +- **US4** depends on decision actions from US2 and link/page behavior from US1. +- **Phase 7** follows all implemented stories. + +### User Story Dependencies + +- **US1 (P1)**: MVP list/detail surface. +- **US2 (P1)**: primary mutation value; depends on query rows/candidates. +- **US3 (P2)**: discoverability from existing surfaces; depends on route/query. +- **US4 (P3)**: validation loop after decisions; depends on actions. + +### Parallel Opportunities + +- T007-T009 can run in parallel. +- T014-T016 can run in parallel. +- T022-T026 can run in parallel. +- T034-T036 can run in parallel. +- T041-T043 can run in parallel. +- T047-T049 can run in parallel near close-out. + +## Parallel Example: Query Foundation + +```text +Task: "Add unit coverage for actionable row derivation in apps/platform/tests/Unit/Support/Baselines/BaselineSubjectResolutionQueryTest.php" +Task: "Add feature coverage for workspace/environment denial in apps/platform/tests/Feature/Baselines/BaselineSubjectResolutionIsolationTest.php" +Task: "Add legacy-payload refusal coverage in apps/platform/tests/Feature/Baselines/BaselineSubjectResolutionLegacyPayloadTest.php" +``` + +## Parallel Example: Decision Actions + +```text +Task: "Add decision action tests in apps/platform/tests/Feature/ProviderResources/ProviderResourceBindingServiceResolutionTest.php" +Task: "Add RBAC positive/negative action tests in apps/platform/tests/Feature/Filament/BaselineSubjectResolutionActionAuthorizationTest.php" +Task: "Add Filament action modal/note/confirmation tests in apps/platform/tests/Feature/Filament/BaselineSubjectResolutionActionsTest.php" +``` + +## Implementation Strategy + +### MVP First + +Deliver Phase 2 plus US1 first. This gives a focused, scoped, read-only operator worklist and proves the query/page shape before high-impact actions land. + +### Incremental Delivery + +1. Finish setup/guardrails and query foundation. +2. Implement the list/detail page without mutations. +3. Add audited decision actions. +4. Add contextual links from Baseline Compare and OperationRun detail. +5. Add rerun/refresh compare delegation and future-compare consumption checks. +6. Finish UI coverage, browser smoke, regression, and close-out. + +### Non-Goals During Implementation + +- Do not implement Spec 385 Evidence/Review readiness. +- Do not add Management Report/PDF work. +- Do not introduce a generic workflow, task, approval, or notification engine. +- Do not parse legacy subject-key payloads. +- Do not use display names as identity. +- Do not add a new primary decision table without updating spec and plan.