diff --git a/.github/agents/copilot-instructions.md b/.github/agents/copilot-instructions.md index 644481dd..17a1fa86 100644 --- a/.github/agents/copilot-instructions.md +++ b/.github/agents/copilot-instructions.md @@ -216,10 +216,18 @@ ## Active Technologies - PostgreSQL via existing `baseline_snapshots`, `evidence_snapshots`, `evidence_snapshot_items`, `tenant_reviews`, `review_packs`, and `operation_runs` tables; no schema change planned (214-governance-outcome-compression) - Astro 6.0.0 templates + TypeScript 5.9 strict + Astro 6, Tailwind CSS v4 via `@tailwindcss/vite`, Astro content collections, local Astro layout/primitive/content helpers, Playwright smoke tests (215-website-core-pages) - Static filesystem pages, content modules, and Astro content collections under `apps/website/src` and `apps/website/public`; no database (215-website-core-pages) +- PHP 8.4.15, Laravel 12, Filament v5, Livewire v4 + Filament Resources/Pages/Actions, Livewire 4, Pest 4, `ProviderOperationStartGate`, `ProviderOperationRegistry`, `ProviderConnectionResolver`, `OperationRunService`, `ProviderNextStepsRegistry`, `ReasonPresenter`, `OperationUxPresenter`, `OperationRunLinks` (216-provider-dispatch-gate) +- PostgreSQL via existing `operation_runs`, `provider_connections`, `managed_tenant_onboarding_sessions`, `restore_runs`, and tenant-owned runtime records; no new tables planned (216-provider-dispatch-gate) - Astro 6.0.0 templates + TypeScript 5.9.x + Astro 6, Tailwind CSS v4, local Astro layout/section primitives, Astro content collections, Playwright browser smoke tests (217-homepage-structure) - Static filesystem content, Astro content collections, and assets under `apps/website/src` and `apps/website/public`; no database (217-homepage-structure) - Astro 6.0.0 templates + TypeScript 5.9.x + Astro 6, Tailwind CSS v4, existing Astro content modules and section primitives, Playwright browser smoke tests (218-homepage-hero) - Static filesystem content and assets under `apps/website/src` and `apps/website/public`; no database (218-homepage-hero) +- PHP 8.4.15 / Laravel 12 + Filament v5, Livewire v4.0+, Pest v4, Tailwind CSS v4 (219-finding-ownership-semantics) +- PostgreSQL via Sail; existing `findings.owner_user_id`, `findings.assignee_user_id`, and `finding_exceptions.owner_user_id` fields; no schema changes planned (219-finding-ownership-semantics) +- PHP 8.4.15, Laravel 12, Filament v5, Livewire v4, Blade + Filament v5, Livewire v4, Pest v4, Laravel Sail, `TenantlessOperationRunViewer`, `OperationRunResource`, `ArtifactTruthPresenter`, `OperatorExplanationBuilder`, `ReasonPresenter`, `OperationUxPresenter`, `SummaryCountsNormalizer`, and the existing enterprise-detail builders (220-governance-run-summaries) +- PostgreSQL via existing `operation_runs` plus related `baseline_snapshots`, `evidence_snapshots`, `tenant_reviews`, and `review_packs`; no schema changes planned (220-governance-run-summaries) +- PHP 8.4.15, Laravel 12, Filament v5, Livewire v4, Blade + Filament admin panel pages, `Finding`, `FindingResource`, `WorkspaceOverviewBuilder`, `WorkspaceContext`, `WorkspaceCapabilityResolver`, `CapabilityResolver`, `CanonicalAdminTenantFilterState`, and `CanonicalNavigationContext` (221-findings-operator-inbox) +- PostgreSQL via existing `findings`, `tenants`, `tenant_memberships`, and workspace context session state; no schema changes planned (221-findings-operator-inbox) - PHP 8.4.15 (feat/005-bulk-operations) @@ -254,8 +262,24 @@ ## Code Style PHP 8.4.15: Follow standard conventions ## Recent Changes +- 221-findings-operator-inbox: Added PHP 8.4.15, Laravel 12, Filament v5, Livewire v4, Blade + Filament admin panel pages, `Finding`, `FindingResource`, `WorkspaceOverviewBuilder`, `WorkspaceContext`, `WorkspaceCapabilityResolver`, `CapabilityResolver`, `CanonicalAdminTenantFilterState`, and `CanonicalNavigationContext` +- 220-governance-run-summaries: Added PHP 8.4.15, Laravel 12, Filament v5, Livewire v4, Blade + Filament v5, Livewire v4, Pest v4, Laravel Sail, `TenantlessOperationRunViewer`, `OperationRunResource`, `ArtifactTruthPresenter`, `OperatorExplanationBuilder`, `ReasonPresenter`, `OperationUxPresenter`, `SummaryCountsNormalizer`, and the existing enterprise-detail builders +- 219-finding-ownership-semantics: Added PHP 8.4.15 / Laravel 12 + Filament v5, Livewire v4.0+, Pest v4, Tailwind CSS v4 - 218-homepage-hero: Added Astro 6.0.0 templates + TypeScript 5.9.x + Astro 6, Tailwind CSS v4, existing Astro content modules and section primitives, Playwright browser smoke tests - 217-homepage-structure: Added Astro 6.0.0 templates + TypeScript 5.9.x + Astro 6, Tailwind CSS v4, local Astro layout/section primitives, Astro content collections, Playwright browser smoke tests +- 216-provider-dispatch-gate: Added PHP 8.4.15, Laravel 12, Filament v5, Livewire v4 + Filament Resources/Pages/Actions, Livewire 4, Pest 4, `ProviderOperationStartGate`, `ProviderOperationRegistry`, `ProviderConnectionResolver`, `OperationRunService`, `ProviderNextStepsRegistry`, `ReasonPresenter`, `OperationUxPresenter`, `OperationRunLinks` - 215-website-core-pages: Added Astro 6.0.0 templates + TypeScript 5.9 strict + Astro 6, Tailwind CSS v4 via `@tailwindcss/vite`, Astro content collections, local Astro layout/primitive/content helpers, Playwright smoke tests + +### Pre-production compatibility check + +Before adding aliases, fallback readers, dual-write logic, migration shims, or legacy fixtures, verify all of the following: + +1. Do live production data exist? +2. Is shared staging migration-relevant? +3. Does an external contract depend on the old shape? +4. Does the spec explicitly require compatibility behavior? + +If all answers are no, replace the old shape and remove the compatibility path. + diff --git a/.specify/memory/constitution.md b/.specify/memory/constitution.md index a6bd2778..a0cabb61 100644 --- a/.specify/memory/constitution.md +++ b/.specify/memory/constitution.md @@ -1,21 +1,21 @@ @@ -133,6 +133,37 @@ ### Spec Candidate Gate (SPEC-GATE-001) ### Default Bias (BIAS-001) - Default codebase bias is: derive before persist, map before frameworkize, localize before generalize, simplify before extend, replace before layer, explicit before generic, and present directly before interpreting recursively. +### Pre-Production Lean Doctrine (LEAN-001) + +This product has no production deployment, no live customer data, no shared staging with migration-relevant state, and no external API contract consumers. + +#### Data and schema +- Old data shapes, column names, enum values, and operation types MAY be replaced in place. +- Migration shims, dual-write logic, and fallback readers MUST NOT be created unless a spec explicitly requires compatibility behavior. + +#### Terminology and types +- Renamed or unified operation types, reason codes, and status values MUST replace the old value everywhere (code, config, tests, fixtures, seed data). +- Legacy aliases kept "just in case" are forbidden. + +#### Codebase hygiene +- Dead constants, dead enum cases, orphan config keys, and test fixtures that reference replaced shapes MUST be removed in the same PR that introduces the replacement. +- "Old runs / old rows don't matter" is the standing assumption until the product ships. + +#### AI-agent rule +- Before adding aliases, fallback readers, dual-write logic, migration shims, or legacy fixtures, agents MUST verify: + 1. Do live production data exist? + 2. Is shared staging migration-relevant? + 3. Does an external contract depend on the old shape? + 4. Does the spec explicitly require compatibility behavior? +- If all answers are no, replace the old shape and remove the compatibility path. + +#### Review rule +- Any PR that introduces a new legacy alias, compatibility shim, or historical fixture without answering the four questions above is a merge blocker. + +#### Exit condition +- LEAN-001 expires when the first production deployment occurs. +- At that point, the constitution MUST be amended to define the real migration and compatibility policy. + ### Workspace Isolation is Non-negotiable - Workspace membership is an isolation boundary. If the actor is not entitled to the workspace scope, the system MUST respond as deny-as-not-found (404). @@ -1573,4 +1604,4 @@ ### Versioning Policy (SemVer) - **MINOR**: new principle/section or materially expanded guidance. - **MAJOR**: removing/redefining principles in a backward-incompatible way. -**Version**: 2.6.0 | **Ratified**: 2026-01-03 | **Last Amended**: 2026-04-18 +**Version**: 2.7.0 | **Ratified**: 2026-01-03 | **Last Amended**: 2025-07-19 diff --git a/.specify/templates/spec-template.md b/.specify/templates/spec-template.md index edb6e389..69b98f54 100644 --- a/.specify/templates/spec-template.md +++ b/.specify/templates/spec-template.md @@ -101,6 +101,14 @@ ## Proportionality Review *(mandatory when structural complexity is introduced)* - **Alternative intentionally rejected**: [What simpler option was considered and why it was not sufficient] - **Release truth**: [Current-release truth or future-release preparation] +### Compatibility posture + +This feature assumes a pre-production environment. + +Backward compatibility, legacy aliases, migration shims, historical fixtures, and compatibility-specific tests are out of scope unless explicitly required by this spec. + +Canonical replacement is preferred over preservation. + ## Testing / Lane / Runtime Impact *(mandatory for runtime behavior changes)* For docs-only or template-only changes, state concise `N/A` or `none`. For runtime- or test-affecting work, classification MUST follow the proving purpose of the change rather than the file path or folder name. diff --git a/apps/platform/app/Filament/Concerns/ResolvesPanelTenantContext.php b/apps/platform/app/Filament/Concerns/ResolvesPanelTenantContext.php index 27a20e7b..9d4e29d7 100644 --- a/apps/platform/app/Filament/Concerns/ResolvesPanelTenantContext.php +++ b/apps/platform/app/Filament/Concerns/ResolvesPanelTenantContext.php @@ -13,7 +13,9 @@ trait ResolvesPanelTenantContext { protected static function resolveTenantContextForCurrentPanel(): ?Tenant { - if (Filament::getCurrentPanel()?->getId() === 'admin') { + $request = request(); + + if (static::currentPanelId($request) === 'admin') { $tenant = app(OperateHubShell::class)->tenantOwnedPanelContext(request()); return $tenant instanceof Tenant ? $tenant : null; @@ -49,4 +51,41 @@ protected static function resolveTrustedPanelTenantContextOrFail(): Tenant { return static::resolveTenantContextForCurrentPanelOrFail(); } + + private static function currentPanelId(mixed $request): ?string + { + $panelId = Filament::getCurrentPanel()?->getId(); + + if (is_string($panelId) && $panelId !== '') { + return $panelId; + } + + $routeName = is_object($request) && method_exists($request, 'route') + ? $request->route()?->getName() + : null; + + if (is_string($routeName) && $routeName !== '') { + if (str_contains($routeName, '.tenant.')) { + return 'tenant'; + } + + if (str_contains($routeName, '.admin.')) { + return 'admin'; + } + } + + $path = is_object($request) && method_exists($request, 'path') + ? '/'.ltrim((string) $request->path(), '/') + : null; + + if (is_string($path) && str_starts_with($path, '/admin/t/')) { + return 'tenant'; + } + + if (is_string($path) && str_starts_with($path, '/admin/')) { + return 'admin'; + } + + return null; + } } diff --git a/apps/platform/app/Filament/Pages/Findings/MyFindingsInbox.php b/apps/platform/app/Filament/Pages/Findings/MyFindingsInbox.php new file mode 100644 index 00000000..264ca077 --- /dev/null +++ b/apps/platform/app/Filament/Pages/Findings/MyFindingsInbox.php @@ -0,0 +1,687 @@ +|null + */ + private ?array $authorizedTenants = null; + + /** + * @var array|null + */ + private ?array $visibleTenants = null; + + private ?Workspace $workspace = null; + + public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration + { + return ActionSurfaceDeclaration::forPage(ActionSurfaceProfile::ListOnlyReadOnly, ActionSurfaceType::ReadOnlyRegistryReport) + ->satisfy(ActionSurfaceSlot::ListHeader, 'Header actions keep the assigned-to-me scope fixed and expose only a tenant-prefilter clear action when needed.') + ->satisfy(ActionSurfaceSlot::InspectAffordance, ActionSurfaceInspectAffordance::ClickableRow->value) + ->exempt(ActionSurfaceSlot::ListBulkMoreGroup, 'The personal findings inbox does not expose bulk actions.') + ->satisfy(ActionSurfaceSlot::ListEmptyState, 'The empty state stays calm, explains scope boundaries, and offers exactly one recovery CTA.') + ->exempt(ActionSurfaceSlot::DetailHeader, 'Row navigation returns to the existing tenant finding detail page.'); + } + + public function mount(): void + { + $this->authorizePageAccess(); + + app(CanonicalAdminTenantFilterState::class)->sync( + $this->getTableFiltersSessionKey(), + ['overdue', 'reopened', 'high_severity'], + request(), + ); + + $this->applyRequestedTenantPrefilter(); + $this->mountInteractsWithTable(); + $this->normalizeTenantFilterState(); + } + + protected function getHeaderActions(): array + { + return [ + Action::make('clear_tenant_filter') + ->label('Clear tenant filter') + ->icon('heroicon-o-x-mark') + ->color('gray') + ->visible(fn (): bool => $this->currentTenantFilterId() !== null) + ->action(fn (): mixed => $this->clearTenantFilter()), + ]; + } + + public function table(Table $table): Table + { + return $table + ->query(fn (): Builder => $this->queueBaseQuery()) + ->paginated(TablePaginationProfiles::customPage()) + ->persistFiltersInSession() + ->columns([ + TextColumn::make('tenant.name') + ->label('Tenant'), + TextColumn::make('subject_display_name') + ->label('Finding') + ->state(fn (Finding $record): string => $record->resolvedSubjectDisplayName() ?? 'Finding #'.$record->getKey()) + ->description(fn (Finding $record): ?string => $this->ownerContext($record)) + ->wrap(), + TextColumn::make('severity') + ->badge() + ->formatStateUsing(BadgeRenderer::label(BadgeDomain::FindingSeverity)) + ->color(BadgeRenderer::color(BadgeDomain::FindingSeverity)) + ->icon(BadgeRenderer::icon(BadgeDomain::FindingSeverity)) + ->iconColor(BadgeRenderer::iconColor(BadgeDomain::FindingSeverity)), + TextColumn::make('status') + ->badge() + ->formatStateUsing(BadgeRenderer::label(BadgeDomain::FindingStatus)) + ->color(BadgeRenderer::color(BadgeDomain::FindingStatus)) + ->icon(BadgeRenderer::icon(BadgeDomain::FindingStatus)) + ->iconColor(BadgeRenderer::iconColor(BadgeDomain::FindingStatus)) + ->description(fn (Finding $record): ?string => $this->reopenedCue($record)), + TextColumn::make('due_at') + ->label('Due') + ->dateTime() + ->placeholder('—') + ->description(fn (Finding $record): ?string => FindingExceptionResource::relativeTimeDescription($record->due_at) ?? FindingResource::dueAttentionLabelFor($record)), + ]) + ->filters([ + SelectFilter::make('tenant_id') + ->label('Tenant') + ->options(fn (): array => $this->tenantFilterOptions()) + ->searchable(), + Filter::make('overdue') + ->label('Overdue') + ->query(fn (Builder $query): Builder => $query + ->whereNotNull('due_at') + ->where('due_at', '<', now())), + Filter::make('reopened') + ->label('Reopened') + ->query(fn (Builder $query): Builder => $query->whereNotNull('reopened_at')), + Filter::make('high_severity') + ->label('High severity') + ->query(fn (Builder $query): Builder => $query->whereIn('severity', Finding::highSeverityValues())), + ]) + ->actions([]) + ->bulkActions([]) + ->recordUrl(fn (Finding $record): string => $this->findingDetailUrl($record)) + ->emptyStateHeading(fn (): string => $this->emptyState()['title']) + ->emptyStateDescription(fn (): string => $this->emptyState()['body']) + ->emptyStateIcon(fn (): string => $this->emptyState()['icon']) + ->emptyStateActions($this->emptyStateActions()); + } + + /** + * @return array + */ + public function appliedScope(): array + { + $tenant = $this->filteredTenant(); + + return [ + 'workspace_scoped' => true, + 'assignee_scope' => 'current_user_only', + 'tenant_prefilter_source' => $this->tenantPrefilterSource(), + 'tenant_label' => $tenant?->name, + ]; + } + + /** + * @return array> + */ + public function availableFilters(): array + { + return [ + [ + 'key' => 'assignee_scope', + 'label' => 'Assigned to me', + 'fixed' => true, + 'options' => [], + ], + [ + 'key' => 'tenant', + 'label' => 'Tenant', + 'fixed' => false, + 'options' => collect($this->visibleTenants()) + ->map(fn (Tenant $tenant): array => [ + 'value' => (string) $tenant->getKey(), + 'label' => (string) $tenant->name, + ]) + ->values() + ->all(), + ], + [ + 'key' => 'overdue', + 'label' => 'Overdue', + 'fixed' => false, + 'options' => [], + ], + [ + 'key' => 'reopened', + 'label' => 'Reopened', + 'fixed' => false, + 'options' => [], + ], + [ + 'key' => 'high_severity', + 'label' => 'High severity', + 'fixed' => false, + 'options' => [], + ], + ]; + } + + /** + * @return array{open_assigned: int, overdue_assigned: int} + */ + public function summaryCounts(): array + { + $query = $this->filteredQueueQuery(); + + return [ + 'open_assigned' => (clone $query)->count(), + 'overdue_assigned' => (clone $query) + ->whereNotNull('due_at') + ->where('due_at', '<', now()) + ->count(), + ]; + } + + /** + * @return array + */ + public function emptyState(): array + { + if ($this->tenantFilterAloneExcludesRows()) { + return [ + 'title' => 'No assigned findings match this tenant scope', + 'body' => 'Your current tenant filter is hiding assigned work that is still visible elsewhere in this workspace.', + 'icon' => 'heroicon-o-funnel', + 'action_name' => 'clear_tenant_filter_empty', + 'action_label' => 'Clear tenant filter', + 'action_kind' => 'clear_tenant_filter', + ]; + } + + $activeTenant = $this->activeVisibleTenant(); + + if ($activeTenant instanceof Tenant) { + return [ + 'title' => 'No visible assigned findings right now', + 'body' => 'Nothing currently assigned to you needs attention in the visible tenant scope. You can still open tenant findings for broader context.', + 'icon' => 'heroicon-o-clipboard-document-check', + 'action_name' => 'open_tenant_findings_empty', + 'action_label' => 'Open tenant findings', + 'action_kind' => 'url', + 'action_url' => FindingResource::getUrl('index', panel: 'tenant', tenant: $activeTenant), + ]; + } + + return [ + 'title' => 'No visible assigned findings right now', + 'body' => 'Nothing currently assigned to you needs attention across the visible tenant scope. Choose a tenant to continue working elsewhere in the workspace.', + 'icon' => 'heroicon-o-clipboard-document-check', + 'action_name' => 'choose_tenant_empty', + 'action_label' => 'Choose a tenant', + 'action_kind' => 'url', + 'action_url' => route('filament.admin.pages.choose-tenant'), + ]; + } + + public function updatedTableFilters(): void + { + $this->normalizeTenantFilterState(); + } + + public function clearTenantFilter(): void + { + $this->removeTableFilter('tenant_id'); + $this->resetTable(); + } + + /** + * @return array + */ + public function visibleTenants(): array + { + if ($this->visibleTenants !== null) { + return $this->visibleTenants; + } + + $user = auth()->user(); + $tenants = $this->authorizedTenants(); + + if (! $user instanceof User || $tenants === []) { + return $this->visibleTenants = []; + } + + $resolver = app(CapabilityResolver::class); + $resolver->primeMemberships( + $user, + array_map(static fn (Tenant $tenant): int => (int) $tenant->getKey(), $tenants), + ); + + return $this->visibleTenants = array_values(array_filter( + $tenants, + fn (Tenant $tenant): bool => $resolver->can($user, $tenant, Capabilities::TENANT_FINDINGS_VIEW), + )); + } + + private function authorizePageAccess(): void + { + $user = auth()->user(); + $workspace = $this->workspace(); + + if (! $user instanceof User) { + abort(403); + } + + if (! $workspace instanceof Workspace) { + throw new NotFoundHttpException; + } + + $resolver = app(WorkspaceCapabilityResolver::class); + + if (! $resolver->isMember($user, $workspace)) { + throw new NotFoundHttpException; + } + } + + /** + * @return array + */ + private function authorizedTenants(): array + { + if ($this->authorizedTenants !== null) { + return $this->authorizedTenants; + } + + $user = auth()->user(); + $workspace = $this->workspace(); + + if (! $user instanceof User || ! $workspace instanceof Workspace) { + return $this->authorizedTenants = []; + } + + return $this->authorizedTenants = $user->tenants() + ->where('tenants.workspace_id', (int) $workspace->getKey()) + ->where('tenants.status', 'active') + ->orderBy('tenants.name') + ->get(['tenants.id', 'tenants.name', 'tenants.external_id', 'tenants.workspace_id']) + ->all(); + } + + private function workspace(): ?Workspace + { + if ($this->workspace instanceof Workspace) { + return $this->workspace; + } + + $workspaceId = app(WorkspaceContext::class)->currentWorkspaceId(request()); + + if (! is_int($workspaceId)) { + return null; + } + + return $this->workspace = Workspace::query()->whereKey($workspaceId)->first(); + } + + private function queueBaseQuery(): Builder + { + $user = auth()->user(); + $workspace = $this->workspace(); + $tenantIds = array_map( + static fn (Tenant $tenant): int => (int) $tenant->getKey(), + $this->visibleTenants(), + ); + + if (! $user instanceof User || ! $workspace instanceof Workspace) { + return Finding::query()->whereRaw('1 = 0'); + } + + return Finding::query() + ->with(['tenant', 'ownerUser', 'assigneeUser']) + ->withSubjectDisplayName() + ->where('workspace_id', (int) $workspace->getKey()) + ->whereIn('tenant_id', $tenantIds === [] ? [-1] : $tenantIds) + ->where('assignee_user_id', (int) $user->getKey()) + ->whereIn('status', Finding::openStatusesForQuery()) + ->orderByRaw( + 'case when due_at is not null and due_at < ? then 0 when reopened_at is not null then 1 else 2 end asc', + [now()], + ) + ->orderByRaw('case when due_at is null then 1 else 0 end asc') + ->orderBy('due_at') + ->orderByDesc('id'); + } + + private function filteredQueueQuery(bool $includeTenantFilter = true): Builder + { + $query = $this->queueBaseQuery(); + $filters = $this->currentQueueFiltersState(); + + if ($includeTenantFilter && ($tenantId = $this->currentTenantFilterIdFromFilters($filters)) !== null) { + $query->where('tenant_id', $tenantId); + } + + if ($this->filterIsActive($filters, 'overdue')) { + $query + ->whereNotNull('due_at') + ->where('due_at', '<', now()); + } + + if ($this->filterIsActive($filters, 'reopened')) { + $query->whereNotNull('reopened_at'); + } + + if ($this->filterIsActive($filters, 'high_severity')) { + $query->whereIn('severity', Finding::highSeverityValues()); + } + + return $query; + } + + /** + * @return array + */ + private function tenantFilterOptions(): array + { + return collect($this->visibleTenants()) + ->mapWithKeys(static fn (Tenant $tenant): array => [ + (string) $tenant->getKey() => (string) $tenant->name, + ]) + ->all(); + } + + private function applyRequestedTenantPrefilter(): void + { + $requestedTenant = request()->query('tenant'); + + if (! is_string($requestedTenant) && ! is_numeric($requestedTenant)) { + return; + } + + foreach ($this->visibleTenants() as $tenant) { + if ((string) $tenant->getKey() !== (string) $requestedTenant && (string) $tenant->external_id !== (string) $requestedTenant) { + continue; + } + + $this->tableFilters['tenant_id']['value'] = (string) $tenant->getKey(); + $this->tableDeferredFilters['tenant_id']['value'] = (string) $tenant->getKey(); + + return; + } + } + + private function normalizeTenantFilterState(): void + { + $configuredTenantFilter = data_get($this->currentQueueFiltersState(), 'tenant_id.value'); + + if ($configuredTenantFilter === null || $configuredTenantFilter === '') { + return; + } + + if ($this->currentTenantFilterId() !== null) { + return; + } + + $this->removeTableFilter('tenant_id'); + } + + /** + * @return array + */ + private function currentQueueFiltersState(): array + { + $persisted = session()->get($this->getTableFiltersSessionKey(), []); + + return array_replace_recursive( + is_array($persisted) ? $persisted : [], + $this->tableFilters ?? [], + ); + } + + private function currentTenantFilterId(): ?int + { + return $this->currentTenantFilterIdFromFilters($this->currentQueueFiltersState()); + } + + /** + * @param array $filters + */ + private function currentTenantFilterIdFromFilters(array $filters): ?int + { + $tenantFilter = data_get($filters, 'tenant_id.value'); + + if (! is_numeric($tenantFilter)) { + return null; + } + + $tenantId = (int) $tenantFilter; + + foreach ($this->visibleTenants() as $tenant) { + if ((int) $tenant->getKey() === $tenantId) { + return $tenantId; + } + } + + return null; + } + + /** + * @param array $filters + */ + private function filterIsActive(array $filters, string $name): bool + { + return (bool) data_get($filters, "{$name}.isActive", false); + } + + private function filteredTenant(): ?Tenant + { + $tenantId = $this->currentTenantFilterId(); + + if (! is_int($tenantId)) { + return null; + } + + foreach ($this->visibleTenants() as $tenant) { + if ((int) $tenant->getKey() === $tenantId) { + return $tenant; + } + } + + return null; + } + + private function activeVisibleTenant(): ?Tenant + { + $activeTenant = app(OperateHubShell::class)->activeEntitledTenant(request()); + + if (! $activeTenant instanceof Tenant) { + return null; + } + + foreach ($this->visibleTenants() as $tenant) { + if ($tenant->is($activeTenant)) { + return $tenant; + } + } + + return null; + } + + private function tenantPrefilterSource(): string + { + $tenant = $this->filteredTenant(); + + if (! $tenant instanceof Tenant) { + return 'none'; + } + + $activeTenant = $this->activeVisibleTenant(); + + if ($activeTenant instanceof Tenant && $activeTenant->is($tenant)) { + return 'active_tenant_context'; + } + + return 'explicit_filter'; + } + + private function ownerContext(Finding $record): ?string + { + $ownerLabel = FindingResource::accountableOwnerDisplayFor($record); + $assigneeLabel = $record->assigneeUser?->name; + + if ($record->owner_user_id === null || $ownerLabel === $assigneeLabel) { + return null; + } + + return 'Owner: '.$ownerLabel; + } + + private function reopenedCue(Finding $record): ?string + { + if ($record->reopened_at === null) { + return null; + } + + return 'Reopened'; + } + + private function tenantFilterAloneExcludesRows(): bool + { + if ($this->currentTenantFilterId() === null) { + return false; + } + + if ((clone $this->filteredQueueQuery())->exists()) { + return false; + } + + return (clone $this->filteredQueueQuery(includeTenantFilter: false))->exists(); + } + + private function findingDetailUrl(Finding $record): string + { + $tenant = $record->tenant; + + if (! $tenant instanceof Tenant) { + return '#'; + } + + $url = FindingResource::getUrl('view', ['record' => $record], panel: 'tenant', tenant: $tenant); + + return $this->appendQuery($url, $this->navigationContext()->toQuery()); + } + + private function navigationContext(): CanonicalNavigationContext + { + return new CanonicalNavigationContext( + sourceSurface: 'findings.my_inbox', + canonicalRouteName: static::getRouteName(Filament::getPanel('admin')), + tenantId: $this->currentTenantFilterId(), + backLinkLabel: 'Back to my findings', + backLinkUrl: $this->queueUrl(), + ); + } + + private function queueUrl(): string + { + $tenant = $this->filteredTenant(); + + return static::getUrl( + panel: 'admin', + parameters: array_filter([ + 'tenant' => $tenant?->external_id, + ], static fn (mixed $value): bool => $value !== null && $value !== ''), + ); + } + + /** + * @return array + */ + private function emptyStateActions(): array + { + $emptyState = $this->emptyState(); + $action = Action::make((string) $emptyState['action_name']) + ->label((string) $emptyState['action_label']) + ->icon('heroicon-o-arrow-right') + ->color('gray'); + + if (($emptyState['action_kind'] ?? null) === 'clear_tenant_filter') { + return [ + $action->action(fn (): mixed => $this->clearTenantFilter()), + ]; + } + + return [ + $action->url((string) $emptyState['action_url']), + ]; + } + + /** + * @param array $query + */ + private function appendQuery(string $url, array $query): string + { + if ($query === []) { + return $url; + } + + return $url.(str_contains($url, '?') ? '&' : '?').http_build_query($query); + } +} diff --git a/apps/platform/app/Filament/Pages/InventoryCoverage.php b/apps/platform/app/Filament/Pages/InventoryCoverage.php index 9b924bc1..bfdc6df4 100644 --- a/apps/platform/app/Filament/Pages/InventoryCoverage.php +++ b/apps/platform/app/Filament/Pages/InventoryCoverage.php @@ -521,7 +521,7 @@ public function basisRunSummary(): array 'badgeColor' => null, 'runUrl' => null, 'historyUrl' => null, - 'inventoryItemsUrl' => InventoryItemResource::getUrl('index', tenant: $tenant), + 'inventoryItemsUrl' => InventoryItemResource::getUrl('index', panel: 'tenant', tenant: $tenant), ]; } @@ -537,7 +537,7 @@ public function basisRunSummary(): array 'badgeColor' => $badge->color, 'runUrl' => $canViewRun ? route('admin.operations.view', ['run' => (int) $truth->basisRun->getKey()]) : null, 'historyUrl' => $canViewRun ? $this->inventorySyncHistoryUrl($tenant) : null, - 'inventoryItemsUrl' => InventoryItemResource::getUrl('index', tenant: $tenant), + 'inventoryItemsUrl' => InventoryItemResource::getUrl('index', panel: 'tenant', tenant: $tenant), ]; } diff --git a/apps/platform/app/Filament/Pages/Operations/TenantlessOperationRunViewer.php b/apps/platform/app/Filament/Pages/Operations/TenantlessOperationRunViewer.php index c83eb20b..b1cb71b6 100644 --- a/apps/platform/app/Filament/Pages/Operations/TenantlessOperationRunViewer.php +++ b/apps/platform/app/Filament/Pages/Operations/TenantlessOperationRunViewer.php @@ -246,21 +246,10 @@ public function blockedExecutionBanner(): ?array return null; } - $operatorExplanation = $this->governanceOperatorExplanation(); - $reasonEnvelope = app(ReasonPresenter::class)->forOperationRun($this->run, 'run_detail'); - $lines = $operatorExplanation instanceof OperatorExplanationPattern - ? array_values(array_filter([ - $operatorExplanation->headline, - $operatorExplanation->dominantCauseExplanation, - ])) - : ($reasonEnvelope?->toBodyLines(false) ?? [ - $this->surfaceFailureDetail() ?? 'The queued operation was refused before side effects could begin.', - ]); - return [ 'tone' => 'amber', 'title' => 'Blocked by prerequisite', - 'body' => implode(' ', array_values(array_unique($lines))), + 'body' => 'This run was blocked before the artifact-producing work could finish. Review the summary below for the dominant blocker and next step.', ]; } diff --git a/apps/platform/app/Filament/Pages/Reviews/ReviewRegister.php b/apps/platform/app/Filament/Pages/Reviews/ReviewRegister.php index f6da3c40..d85a703d 100644 --- a/apps/platform/app/Filament/Pages/Reviews/ReviewRegister.php +++ b/apps/platform/app/Filament/Pages/Reviews/ReviewRegister.php @@ -122,17 +122,17 @@ public function table(Table $table): Table TextColumn::make('outcome') ->label('Outcome') ->badge() - ->getStateUsing(fn (TenantReview $record): string => $this->reviewOutcome($record)->primaryLabel) - ->color(fn (TenantReview $record): string => $this->reviewOutcome($record)->primaryBadge->color) - ->icon(fn (TenantReview $record): ?string => $this->reviewOutcome($record)->primaryBadge->icon) - ->iconColor(fn (TenantReview $record): ?string => $this->reviewOutcome($record)->primaryBadge->iconColor) - ->description(fn (TenantReview $record): ?string => $this->reviewOutcome($record)->primaryReason) + ->getStateUsing(\Closure::fromCallable([$this, 'reviewOutcomeLabel'])) + ->color(\Closure::fromCallable([$this, 'reviewOutcomeBadgeColor'])) + ->icon(\Closure::fromCallable([$this, 'reviewOutcomeBadgeIcon'])) + ->iconColor(\Closure::fromCallable([$this, 'reviewOutcomeBadgeIconColor'])) + ->description(\Closure::fromCallable([$this, 'reviewOutcomeDescription'])) ->wrap(), TextColumn::make('generated_at')->dateTime()->placeholder('—')->sortable(), TextColumn::make('published_at')->dateTime()->placeholder('—')->sortable(), TextColumn::make('next_step') ->label('Next step') - ->getStateUsing(fn (TenantReview $record): string => $this->reviewOutcome($record)->nextActionText) + ->getStateUsing(\Closure::fromCallable([$this, 'reviewOutcomeNextStep'])) ->wrap(), ]) ->filters([ @@ -330,13 +330,46 @@ private function reviewTruth(TenantReview $record, bool $fresh = false): Artifac : $presenter->forTenantReview($record); } + private function reviewOutcomeLabel(TenantReview $record): string + { + return $this->reviewOutcome($record)->primaryLabel; + } + + private function reviewOutcomeBadgeColor(TenantReview $record): string + { + return $this->reviewOutcome($record)->primaryBadge->color; + } + + private function reviewOutcomeBadgeIcon(TenantReview $record): ?string + { + return $this->reviewOutcome($record)->primaryBadge->icon; + } + + private function reviewOutcomeBadgeIconColor(TenantReview $record): ?string + { + return $this->reviewOutcome($record)->primaryBadge->iconColor; + } + + private function reviewOutcomeDescription(TenantReview $record): ?string + { + return $this->reviewOutcome($record)->primaryReason; + } + + private function reviewOutcomeNextStep(TenantReview $record): string + { + return $this->reviewOutcome($record)->nextActionText; + } + private function reviewOutcome(TenantReview $record, bool $fresh = false): CompressedGovernanceOutcome { $presenter = app(ArtifactTruthPresenter::class); + $truth = $fresh + ? $this->reviewTruth($record, true) + : $this->reviewTruth($record); return $presenter->compressedOutcomeFor($record, SurfaceCompressionContext::reviewRegister(), $fresh) ?? $presenter->compressedOutcomeFromEnvelope( - $this->reviewTruth($record, $fresh), + $truth, SurfaceCompressionContext::reviewRegister(), ); } diff --git a/apps/platform/app/Filament/Pages/Workspaces/ManagedTenantOnboardingWizard.php b/apps/platform/app/Filament/Pages/Workspaces/ManagedTenantOnboardingWizard.php index 13f86a7c..fbff44fa 100644 --- a/apps/platform/app/Filament/Pages/Workspaces/ManagedTenantOnboardingWizard.php +++ b/apps/platform/app/Filament/Pages/Workspaces/ManagedTenantOnboardingWizard.php @@ -46,6 +46,7 @@ use App\Support\OperationRunOutcome; use App\Support\OperationRunStatus; use App\Support\OpsUx\OperationUxPresenter; +use App\Support\OpsUx\ProviderOperationStartResultPresenter; use App\Support\OpsUx\OpsUxBrowserEvents; use App\Support\Providers\ProviderConnectionType; use App\Support\Providers\ProviderConsentStatus; @@ -2873,65 +2874,22 @@ public function startVerification(): void ); } + $notification = app(ProviderOperationStartResultPresenter::class)->notification( + result: $result, + blockedTitle: 'Verification blocked', + runUrl: $this->tenantlessOperationRunUrl((int) $result->run->getKey()), + ); + if ($result->status === 'scope_busy') { OpsUxBrowserEvents::dispatchRunEnqueued($this); - Notification::make() - ->title('Another operation is already running') - ->body('Please wait for the active operation to finish.') - ->warning() - ->actions([ - Action::make('view_run') - ->label(OperationRunLinks::openLabel()) - ->url($this->tenantlessOperationRunUrl((int) $result->run->getKey())), - ]) - ->send(); + $notification->send(); return; } if ($result->status === 'blocked') { - $reasonCode = is_string($result->run->context['reason_code'] ?? null) - ? (string) $result->run->context['reason_code'] - : 'unknown_error'; - - $actions = [ - Action::make('view_run') - ->label(OperationRunLinks::openLabel()) - ->url($this->tenantlessOperationRunUrl((int) $result->run->getKey())), - ]; - - $nextSteps = $result->run->context['next_steps'] ?? []; - $nextSteps = is_array($nextSteps) ? $nextSteps : []; - - foreach ($nextSteps as $index => $step) { - if (! is_array($step)) { - continue; - } - - $label = is_string($step['label'] ?? null) ? trim((string) $step['label']) : ''; - $url = is_string($step['url'] ?? null) ? trim((string) $step['url']) : ''; - - if ($label === '' || $url === '') { - continue; - } - - $actions[] = Action::make('next_step_'.$index) - ->label($label) - ->url($url); - - break; - } - - $reasonEnvelope = app(\App\Support\ReasonTranslation\ReasonPresenter::class)->forOperationRun($result->run, 'notification'); - $bodyLines = $reasonEnvelope?->toBodyLines() ?? ['Blocked by provider configuration.']; - - Notification::make() - ->title('Verification blocked') - ->body(implode("\n", $bodyLines)) - ->warning() - ->actions($actions) - ->send(); + $notification->send(); return; } @@ -2939,24 +2897,12 @@ public function startVerification(): void OpsUxBrowserEvents::dispatchRunEnqueued($this); if ($result->status === 'deduped') { - OperationUxPresenter::alreadyQueuedToast((string) $result->run->type) - ->actions([ - Action::make('view_run') - ->label(OperationRunLinks::openLabel()) - ->url($this->tenantlessOperationRunUrl((int) $result->run->getKey())), - ]) - ->send(); + $notification->send(); return; } - OperationUxPresenter::queuedToast((string) $result->run->type) - ->actions([ - Action::make('view_run') - ->label(OperationRunLinks::openLabel()) - ->url($this->tenantlessOperationRunUrl((int) $result->run->getKey())), - ]) - ->send(); + $notification->send(); } public function refreshVerificationStatus(): void @@ -3056,85 +3002,73 @@ public function startBootstrap(array $operationTypes): void actor: $user, expectedVersion: $this->expectedDraftVersion(), mutator: function (TenantOnboardingSession $draft) use ($tenant, $connection, $types, $registry, $user, &$result): void { - $lockedConnection = ProviderConnection::query() - ->whereKey($connection->getKey()) - ->lockForUpdate() - ->firstOrFail(); + $nextOperationType = $this->nextBootstrapOperationType($draft, $types, (int) $connection->getKey()); - $activeRun = OperationRun::query() - ->where('tenant_id', $tenant->getKey()) - ->active() - ->where('context->provider_connection_id', (int) $lockedConnection->getKey()) - ->orderByDesc('id') - ->first(); - - if ($activeRun instanceof OperationRun) { + if ($nextOperationType === null) { $result = [ - 'status' => 'scope_busy', - 'run' => $activeRun, + 'status' => 'already_completed', + 'operation_type' => null, + 'remaining_types' => [], ]; return; } - $runsService = app(OperationRunService::class); - $bootstrapRuns = []; - $bootstrapCreated = []; + $capability = $this->resolveBootstrapCapability($nextOperationType); - foreach ($types as $operationType) { - $definition = $registry->get($operationType); + if ($capability === null) { + throw new RuntimeException("Unsupported bootstrap operation type: {$nextOperationType}"); + } - $context = [ + $startResult = app(ProviderOperationStartGate::class)->start( + tenant: $tenant, + connection: $connection, + operationType: $nextOperationType, + dispatcher: function (OperationRun $run) use ($tenant, $user, $connection, $nextOperationType): void { + $this->dispatchBootstrapJob( + operationType: $nextOperationType, + tenantId: (int) $tenant->getKey(), + userId: (int) $user->getKey(), + providerConnectionId: (int) $connection->getKey(), + run: $run, + ); + }, + initiator: $user, + extraContext: [ 'wizard' => [ 'flow' => 'managed_tenant_onboarding', 'step' => 'bootstrap', ], - 'provider' => $lockedConnection->provider, - 'module' => $definition['module'], - 'provider_connection_id' => (int) $lockedConnection->getKey(), - 'target_scope' => [ - 'entra_tenant_id' => $lockedConnection->entra_tenant_id, - ], - ]; - - $run = $runsService->ensureRunWithIdentity( - tenant: $tenant, - type: $operationType, - identityInputs: [ - 'provider_connection_id' => (int) $lockedConnection->getKey(), - ], - context: $context, - initiator: $user, - ); - - if ($run->wasRecentlyCreated) { - $this->dispatchBootstrapJob( - operationType: $operationType, - tenantId: (int) $tenant->getKey(), - userId: (int) $user->getKey(), - providerConnectionId: (int) $lockedConnection->getKey(), - run: $run, - ); - } - - $bootstrapRuns[$operationType] = (int) $run->getKey(); - $bootstrapCreated[$operationType] = (bool) $run->wasRecentlyCreated; - } + 'required_capability' => $capability, + ], + ); $state = $draft->state ?? []; $existing = $state['bootstrap_operation_runs'] ?? []; $existing = is_array($existing) ? $existing : []; - $state['bootstrap_operation_runs'] = array_merge($existing, $bootstrapRuns); + if ($startResult->status !== 'scope_busy') { + $existing[$nextOperationType] = (int) $startResult->run->getKey(); + } + + $state['bootstrap_operation_runs'] = $existing; $state['bootstrap_operation_types'] = $types; $draft->state = $state; $draft->current_step = 'bootstrap'; + $remainingTypes = array_values(array_filter( + $types, + fn (string $candidate): bool => $candidate !== $nextOperationType + && ! $this->bootstrapOperationSucceeded($draft, $candidate, (int) $connection->getKey()), + )); + $result = [ - 'status' => 'started', - 'runs' => $bootstrapRuns, - 'created' => $bootstrapCreated, + 'status' => $startResult->status, + 'start_result' => $startResult, + 'operation_type' => $nextOperationType, + 'run' => $startResult->run, + 'remaining_types' => $remainingTypes, ]; }, )); @@ -3152,26 +3086,36 @@ public function startBootstrap(array $operationTypes): void throw new RuntimeException('Bootstrap start did not return a run result.'); } - if ($result['status'] === 'scope_busy') { - OpsUxBrowserEvents::dispatchRunEnqueued($this); - + if ($result['status'] === 'already_completed') { Notification::make() - ->title('Another operation is already running') - ->body('Please wait for the active operation to finish.') - ->warning() - ->actions([ - Action::make('view_run') - ->label(OperationRunLinks::openLabel()) - ->url($this->tenantlessOperationRunUrl((int) $result['run']->getKey())), - ]) + ->title('Bootstrap already completed') + ->body('All selected bootstrap actions have already finished successfully for this provider connection.') + ->info() ->send(); return; } - $bootstrapRuns = $result['runs']; + $operationType = (string) ($result['operation_type'] ?? ''); + $startResult = $result['start_result'] ?? null; + $run = $result['run'] ?? null; + + if (! $startResult instanceof \App\Services\Providers\ProviderOperationStartResult || ! $run instanceof OperationRun || $operationType === '') { + throw new RuntimeException('Bootstrap start did not return a canonical run result.'); + } + + $remainingTypes = is_array($result['remaining_types'] ?? null) + ? array_values(array_filter($result['remaining_types'], static fn (mixed $value): bool => is_string($value) && $value !== '')) + : []; if ($this->onboardingSession instanceof TenantOnboardingSession) { + $auditStatus = match ($result['status']) { + 'started' => 'success', + 'deduped' => 'deduped', + 'scope_busy' => 'blocked', + default => 'success', + }; + app(WorkspaceAuditLogger::class)->log( workspace: $this->workspace, action: AuditActionId::ManagedTenantOnboardingBootstrapStarted->value, @@ -3181,36 +3125,40 @@ public function startBootstrap(array $operationTypes): void 'tenant_db_id' => (int) $tenant->getKey(), 'onboarding_session_id' => (int) $this->onboardingSession->getKey(), 'operation_types' => $types, - 'operation_run_ids' => $bootstrapRuns, + 'started_operation_type' => $operationType, + 'operation_run_id' => (int) $run->getKey(), + 'result' => (string) $result['status'], ], ], actor: $user, - status: 'success', + status: $auditStatus, resourceType: 'managed_tenant_onboarding_session', resourceId: (string) $this->onboardingSession->getKey(), ); } - OpsUxBrowserEvents::dispatchRunEnqueued($this); + $notification = app(ProviderOperationStartResultPresenter::class)->notification( + result: $startResult, + blockedTitle: 'Bootstrap action blocked', + runUrl: $this->tenantlessOperationRunUrl((int) $run->getKey()), + scopeBusyTitle: 'Bootstrap action busy', + scopeBusyBody: $remainingTypes !== [] + ? 'Another provider-backed bootstrap action is already running for this provider connection. Open the active operation, then continue with the remaining bootstrap actions after it finishes.' + : 'Another provider-backed bootstrap action is already running for this provider connection. Open the active operation for progress and next steps.', + ); - foreach ($types as $operationType) { - $runId = (int) ($bootstrapRuns[$operationType] ?? 0); - $runUrl = $runId > 0 ? $this->tenantlessOperationRunUrl($runId) : null; - $wasCreated = (bool) ($result['created'][$operationType] ?? false); + if (in_array($result['status'], ['started', 'deduped', 'scope_busy'], true)) { + OpsUxBrowserEvents::dispatchRunEnqueued($this); + } - $toast = $wasCreated - ? OperationUxPresenter::queuedToast($operationType) - : OperationUxPresenter::alreadyQueuedToast($operationType); + $notification->send(); - if ($runUrl !== null) { - $toast->actions([ - Action::make('view_run') - ->label(OperationRunLinks::openLabel()) - ->url($runUrl), - ]); - } - - $toast->send(); + if ($remainingTypes !== [] && in_array($result['status'], ['started', 'deduped'], true)) { + Notification::make() + ->title('Continue bootstrap after this run finishes') + ->body(sprintf('%d additional bootstrap action(s) remain selected for this provider connection.', count($remainingTypes))) + ->info() + ->send(); } } @@ -3227,17 +3175,65 @@ private function dispatchBootstrapJob( userId: $userId, providerConnectionId: $providerConnectionId, operationRun: $run, - ), + )->afterCommit(), 'compliance.snapshot' => ProviderComplianceSnapshotJob::dispatch( tenantId: $tenantId, userId: $userId, providerConnectionId: $providerConnectionId, operationRun: $run, - ), + )->afterCommit(), default => throw new RuntimeException("Unsupported bootstrap operation type: {$operationType}"), }; } + /** + * @param array $types + */ + private function nextBootstrapOperationType(TenantOnboardingSession $draft, array $types, int $providerConnectionId): ?string + { + foreach ($types as $type) { + if (! $this->bootstrapOperationSucceeded($draft, $type, $providerConnectionId)) { + return $type; + } + } + + return null; + } + + private function bootstrapOperationSucceeded(TenantOnboardingSession $draft, string $type, int $providerConnectionId): bool + { + $state = is_array($draft->state) ? $draft->state : []; + $runMap = $state['bootstrap_operation_runs'] ?? []; + + if (! is_array($runMap)) { + return false; + } + + $runId = $runMap[$type] ?? null; + + if (! is_numeric($runId)) { + return false; + } + + $run = OperationRun::query()->whereKey((int) $runId)->first(); + + if (! $run instanceof OperationRun) { + return false; + } + + $context = is_array($run->context ?? null) ? $run->context : []; + $runProviderConnectionId = is_numeric($context['provider_connection_id'] ?? null) + ? (int) $context['provider_connection_id'] + : null; + + if ($runProviderConnectionId !== $providerConnectionId) { + return false; + } + + return $run->status === OperationRunStatus::Completed->value + && $run->outcome === OperationRunOutcome::Succeeded->value; + } + private function resolveBootstrapCapability(string $operationType): ?string { return match ($operationType) { diff --git a/apps/platform/app/Filament/Resources/BackupSetResource/RelationManagers/BackupItemsRelationManager.php b/apps/platform/app/Filament/Resources/BackupSetResource/RelationManagers/BackupItemsRelationManager.php index 9fc4a8cb..1e5226db 100644 --- a/apps/platform/app/Filament/Resources/BackupSetResource/RelationManagers/BackupItemsRelationManager.php +++ b/apps/platform/app/Filament/Resources/BackupSetResource/RelationManagers/BackupItemsRelationManager.php @@ -137,7 +137,7 @@ public function table(Table $table): Table $opService = app(OperationRunService::class); $opRun = $opService->ensureRun( tenant: $tenant, - type: 'backup_set.remove_policies', + type: 'backup_set.update', inputs: [ 'backup_set_id' => (int) $backupSet->getKey(), 'backup_item_ids' => $backupItemIds, @@ -220,7 +220,7 @@ public function table(Table $table): Table $opService = app(OperationRunService::class); $opRun = $opService->ensureRun( tenant: $tenant, - type: 'backup_set.remove_policies', + type: 'backup_set.update', inputs: [ 'backup_set_id' => (int) $backupSet->getKey(), 'backup_item_ids' => $backupItemIds, diff --git a/apps/platform/app/Filament/Resources/BaselineSnapshotResource.php b/apps/platform/app/Filament/Resources/BaselineSnapshotResource.php index e45df3ae..cf2b69f4 100644 --- a/apps/platform/app/Filament/Resources/BaselineSnapshotResource.php +++ b/apps/platform/app/Filament/Resources/BaselineSnapshotResource.php @@ -182,7 +182,11 @@ public static function table(Table $table): Table ->color(static fn (BaselineSnapshot $record): string => self::compressedOutcome($record)->primaryBadge->color) ->icon(static fn (BaselineSnapshot $record): ?string => self::compressedOutcome($record)->primaryBadge->icon) ->iconColor(static fn (BaselineSnapshot $record): ?string => self::compressedOutcome($record)->primaryBadge->iconColor) - ->description(static fn (BaselineSnapshot $record): ?string => self::compressedOutcome($record)->primaryReason) + ->description(static fn (BaselineSnapshot $record): ?string => self::truthHeadline($record)) + ->wrap(), + TextColumn::make('coverage_summary') + ->label('Coverage') + ->getStateUsing(static fn (BaselineSnapshot $record): string => self::fidelitySummary($record)) ->wrap(), TextColumn::make('next_step') ->label('Next step') @@ -377,6 +381,12 @@ private static function truthEnvelope(BaselineSnapshot $snapshot, bool $fresh = : $presenter->forBaselineSnapshot($snapshot); } + private static function truthHeadline(BaselineSnapshot $record): ?string + { + return self::truthEnvelope($record)->operatorExplanation?->headline + ?? self::compressedOutcome($record)->primaryReason; + } + private static function compressedOutcome(BaselineSnapshot $snapshot, bool $fresh = false): CompressedGovernanceOutcome { $presenter = app(ArtifactTruthPresenter::class); diff --git a/apps/platform/app/Filament/Resources/EntraGroupResource/Pages/ListEntraGroups.php b/apps/platform/app/Filament/Resources/EntraGroupResource/Pages/ListEntraGroups.php index 336a67ed..354313f8 100644 --- a/apps/platform/app/Filament/Resources/EntraGroupResource/Pages/ListEntraGroups.php +++ b/apps/platform/app/Filament/Resources/EntraGroupResource/Pages/ListEntraGroups.php @@ -3,16 +3,14 @@ namespace App\Filament\Resources\EntraGroupResource\Pages; use App\Filament\Resources\EntraGroupResource; -use App\Jobs\EntraGroupSyncJob; use App\Models\Tenant; use App\Models\User; -use App\Services\Directory\EntraGroupSelection; -use App\Services\OperationRunService; +use App\Services\Directory\EntraGroupSyncService; use App\Support\Auth\Capabilities; use App\Support\Filament\CanonicalAdminTenantFilterState; use App\Support\OperationRunLinks; -use App\Support\OpsUx\OperationUxPresenter; use App\Support\OpsUx\OpsUxBrowserEvents; +use App\Support\OpsUx\ProviderOperationStartResultPresenter; use App\Support\Rbac\UiEnforcement; use Filament\Actions\Action; use Filament\Facades\Filament; @@ -55,7 +53,7 @@ protected function getHeaderActions(): array ->label('Sync Groups') ->icon('heroicon-o-arrow-path') ->color('primary') - ->action(function (): void { + ->action(function (EntraGroupSyncService $syncService): void { $user = auth()->user(); $tenant = EntraGroupResource::panelTenantContext(); @@ -63,52 +61,18 @@ protected function getHeaderActions(): array return; } - $selectionKey = EntraGroupSelection::allGroupsV1(); - - // --- Phase 3: Canonical Operation Run Start --- - /** @var OperationRunService $opService */ - $opService = app(OperationRunService::class); - $opRun = $opService->ensureRunWithIdentity( - tenant: $tenant, - type: 'entra_group_sync', - identityInputs: ['selection_key' => $selectionKey], - context: [ - 'selection_key' => $selectionKey, - 'trigger' => 'manual', - ], - initiator: $user, + $result = $syncService->startManualSync($tenant, $user); + $notification = app(ProviderOperationStartResultPresenter::class)->notification( + result: $result, + blockedTitle: 'Directory groups sync blocked', + runUrl: OperationRunLinks::view($result->run, $tenant), ); - if (! $opRun->wasRecentlyCreated && in_array($opRun->status, ['queued', 'running'])) { + if (in_array($result->status, ['started', 'deduped', 'scope_busy'], true)) { OpsUxBrowserEvents::dispatchRunEnqueued($this); - OperationUxPresenter::alreadyQueuedToast((string) $opRun->type) - ->actions([ - Action::make('view_run') - ->label('Open operation') - ->url(OperationRunLinks::view($opRun, $tenant)), - ]) - ->send(); - - return; } - // ---------------------------------------------- - dispatch(new EntraGroupSyncJob( - tenantId: (int) $tenant->getKey(), - selectionKey: $selectionKey, - slotKey: null, - runId: null, - operationRun: $opRun - )); - - OpsUxBrowserEvents::dispatchRunEnqueued($this); - OperationUxPresenter::queuedToast((string) $opRun->type) - ->actions([ - Action::make('view_run') - ->label('Open operation') - ->url(OperationRunLinks::view($opRun, $tenant)), - ]) - ->send(); + $notification->send(); }) ) ->requireCapability(Capabilities::TENANT_SYNC) diff --git a/apps/platform/app/Filament/Resources/EvidenceSnapshotResource.php b/apps/platform/app/Filament/Resources/EvidenceSnapshotResource.php index b2842307..1e6b1da7 100644 --- a/apps/platform/app/Filament/Resources/EvidenceSnapshotResource.php +++ b/apps/platform/app/Filament/Resources/EvidenceSnapshotResource.php @@ -691,9 +691,12 @@ private static function truthEnvelope(EvidenceSnapshot $record, bool $fresh = fa private static function truthState(EvidenceSnapshot $record, bool $fresh = false): array { $presenter = app(ArtifactTruthPresenter::class); + $truth = $fresh + ? static::truthEnvelope($record, true) + : static::truthEnvelope($record); return $presenter->surfaceStateFor($record, SurfaceCompressionContext::evidenceSnapshot(), $fresh) - ?? static::truthEnvelope($record, $fresh)->toArray(static::compressedOutcome($record, $fresh)); + ?? $truth->toArray(static::compressedOutcome($record, $fresh)); } private static function compressedOutcome(EvidenceSnapshot $record, bool $fresh = false): CompressedGovernanceOutcome diff --git a/apps/platform/app/Filament/Resources/FindingResource.php b/apps/platform/app/Filament/Resources/FindingResource.php index eef1bfe7..e065f515 100644 --- a/apps/platform/app/Filament/Resources/FindingResource.php +++ b/apps/platform/app/Filament/Resources/FindingResource.php @@ -165,9 +165,9 @@ public static function infolist(Schema $schema): Schema TextEntry::make('finding_due_attention') ->label('Due state') ->badge() - ->state(fn (Finding $record): ?string => static::dueAttentionLabel($record)) - ->color(fn (Finding $record): string => static::dueAttentionColor($record)) - ->visible(fn (Finding $record): bool => static::dueAttentionLabel($record) !== null), + ->state(fn (Finding $record): ?string => static::dueAttentionLabelFor($record)) + ->color(fn (Finding $record): string => static::dueAttentionColorFor($record)) + ->visible(fn (Finding $record): bool => static::dueAttentionLabelFor($record) !== null), TextEntry::make('finding_governance_validity_leading') ->label('Governance') ->badge() @@ -177,12 +177,11 @@ public static function infolist(Schema $schema): Schema ->icon(BadgeRenderer::icon(BadgeDomain::FindingRiskGovernanceValidity)) ->iconColor(BadgeRenderer::iconColor(BadgeDomain::FindingRiskGovernanceValidity)) ->visible(fn (Finding $record): bool => static::governanceValidityState($record) !== null), - TextEntry::make('owner_user_id_leading') - ->label('Owner') - ->state(fn (Finding $record): string => $record->ownerUser?->name ?? 'Unassigned'), - TextEntry::make('assignee_user_id_leading') - ->label('Assignee') - ->state(fn (Finding $record): string => $record->assigneeUser?->name ?? 'Unassigned'), + TextEntry::make('finding_responsibility_state_leading') + ->label('Responsibility state') + ->badge() + ->state(fn (Finding $record): string => $record->responsibilityStateLabel()) + ->color(fn (Finding $record): string => static::responsibilityStateColor($record)), TextEntry::make('finding_primary_narrative') ->label('Current reading') ->state(fn (Finding $record): string => static::primaryNarrative($record)) @@ -207,6 +206,27 @@ public static function infolist(Schema $schema): Schema ->columns(2) ->columnSpanFull(), + Section::make('Responsibility') + ->schema([ + TextEntry::make('finding_responsibility_state') + ->label('Responsibility state') + ->badge() + ->state(fn (Finding $record): string => $record->responsibilityStateLabel()) + ->color(fn (Finding $record): string => static::responsibilityStateColor($record)), + TextEntry::make('owner_user_id_leading') + ->label('Accountable owner') + ->state(fn (Finding $record): string => static::accountableOwnerDisplayFor($record)), + TextEntry::make('assignee_user_id_leading') + ->label('Active assignee') + ->state(fn (Finding $record): string => static::activeAssigneeDisplayFor($record)), + TextEntry::make('finding_responsibility_summary') + ->label('Current split') + ->state(fn (Finding $record): string => static::responsibilitySummary($record)) + ->columnSpanFull(), + ]) + ->columns(2) + ->columnSpanFull(), + Section::make('Finding') ->schema([ TextEntry::make('finding_type')->badge()->label('Type'), @@ -268,12 +288,6 @@ public static function infolist(Schema $schema): Schema TextEntry::make('times_seen')->label('Times seen')->placeholder('—'), TextEntry::make('sla_days')->label('SLA days')->placeholder('—'), TextEntry::make('due_at')->label('Due at')->dateTime()->placeholder('—'), - TextEntry::make('owner_user_id') - ->label('Owner') - ->formatStateUsing(fn (mixed $state, Finding $record): string => $record->ownerUser?->name ?? ($state ? 'User #'.$state : '—')), - TextEntry::make('assignee_user_id') - ->label('Assignee') - ->formatStateUsing(fn (mixed $state, Finding $record): string => $record->assigneeUser?->name ?? ($state ? 'User #'.$state : '—')), TextEntry::make('triaged_at')->label('Triaged at')->dateTime()->placeholder('—'), TextEntry::make('in_progress_at')->label('In progress at')->dateTime()->placeholder('—'), TextEntry::make('reopened_at')->label('Reopened at')->dateTime()->placeholder('—'), @@ -722,7 +736,13 @@ public static function table(Table $table): Table ->icon(BadgeRenderer::icon(BadgeDomain::FindingRiskGovernanceValidity)) ->iconColor(BadgeRenderer::iconColor(BadgeDomain::FindingRiskGovernanceValidity)) ->placeholder('—') - ->description(fn (Finding $record): ?string => static::governanceWarning($record)), + ->description(fn (Finding $record): ?string => static::governanceListDescription($record)), + Tables\Columns\TextColumn::make('responsibility_state') + ->label('Responsibility') + ->badge() + ->state(fn (Finding $record): string => $record->responsibilityStateLabel()) + ->color(fn (Finding $record): string => static::responsibilityStateColor($record)) + ->description(fn (Finding $record): string => static::responsibilitySummary($record)), Tables\Columns\TextColumn::make('evidence_fidelity') ->label('Fidelity') ->badge() @@ -744,11 +764,13 @@ public static function table(Table $table): Table ->dateTime() ->sortable() ->placeholder('—') - ->description(fn (Finding $record): ?string => FindingExceptionResource::relativeTimeDescription($record->due_at) ?? static::dueAttentionLabel($record)), + ->description(fn (Finding $record): ?string => FindingExceptionResource::relativeTimeDescription($record->due_at) ?? static::dueAttentionLabelFor($record)), + Tables\Columns\TextColumn::make('ownerUser.name') + ->label('Accountable owner') + ->placeholder('—'), Tables\Columns\TextColumn::make('assigneeUser.name') - ->label('Assignee') - ->placeholder('—') - ->description(fn (Finding $record): string => $record->ownerUser?->name !== null ? 'Owner: '.$record->ownerUser->name : 'Owner: unassigned'), + ->label('Active assignee') + ->placeholder('—'), Tables\Columns\TextColumn::make('subject_external_id')->label('External ID')->toggleable(isToggledHiddenByDefault: true), Tables\Columns\TextColumn::make('scope_key')->label('Scope')->toggleable(isToggledHiddenByDefault: true), Tables\Columns\TextColumn::make('created_at')->since()->label('Created'), @@ -770,7 +792,7 @@ public static function table(Table $table): Table Finding::SEVERITY_CRITICAL, ])), Tables\Filters\Filter::make('my_assigned') - ->label('My assigned') + ->label('My assigned work') ->query(function (Builder $query): Builder { $userId = auth()->id(); @@ -780,6 +802,17 @@ public static function table(Table $table): Table return $query->where('assignee_user_id', (int) $userId); }), + Tables\Filters\Filter::make('my_accountability') + ->label('My accountability') + ->query(function (Builder $query): Builder { + $userId = auth()->id(); + + if (! is_numeric($userId)) { + return $query->whereRaw('1 = 0'); + } + + return $query->where('owner_user_id', (int) $userId); + }), Tables\Filters\SelectFilter::make('status') ->options(FilterOptionCatalog::findingStatuses()) ->label('Status'), @@ -966,13 +999,15 @@ public static function table(Table $table): Table ->requiresConfirmation() ->form([ Select::make('assignee_user_id') - ->label('Assignee') + ->label('Active assignee') ->placeholder('Unassigned') + ->helperText('Assign the person currently expected to perform or coordinate the remediation work.') ->options(fn (): array => static::tenantMemberOptions()) ->searchable(), Select::make('owner_user_id') - ->label('Owner') + ->label('Accountable owner') ->placeholder('Unassigned') + ->helperText('Assign the person accountable for ensuring the finding reaches a governed outcome.') ->options(fn (): array => static::tenantMemberOptions()) ->searchable(), ]) @@ -990,6 +1025,7 @@ public static function table(Table $table): Table $assignedCount = 0; $skippedCount = 0; $failedCount = 0; + $classificationCounts = []; foreach ($records as $record) { if (! $record instanceof Finding) { @@ -1012,14 +1048,25 @@ public static function table(Table $table): Table try { $record = static::resolveProtectedFindingRecordOrFail($record); + $classification = $workflow->responsibilityChangeClassification( + beforeOwnerUserId: is_numeric($record->owner_user_id) ? (int) $record->owner_user_id : null, + beforeAssigneeUserId: is_numeric($record->assignee_user_id) ? (int) $record->assignee_user_id : null, + afterOwnerUserId: $ownerUserId, + afterAssigneeUserId: $assigneeUserId, + ); $workflow->assign($record, $tenant, $user, $assigneeUserId, $ownerUserId); $assignedCount++; + $classificationCounts[$classification ?? 'unchanged'] = ($classificationCounts[$classification ?? 'unchanged'] ?? 0) + 1; } catch (Throwable) { $failedCount++; } } $body = "Updated {$assignedCount} finding".($assignedCount === 1 ? '' : 's').'.'; + $classificationSummary = static::bulkResponsibilityClassificationSummary($classificationCounts); + if ($classificationSummary !== null) { + $body .= ' '.$classificationSummary; + } if ($skippedCount > 0) { $body .= " Skipped {$skippedCount}."; } @@ -1373,28 +1420,20 @@ public static function assignAction(): Actions\Action ]) ->form([ Select::make('assignee_user_id') - ->label('Assignee') + ->label('Active assignee') ->placeholder('Unassigned') + ->helperText('Assign the person currently expected to perform or coordinate the remediation work.') ->options(fn (): array => static::tenantMemberOptions()) ->searchable(), Select::make('owner_user_id') - ->label('Owner') + ->label('Accountable owner') ->placeholder('Unassigned') + ->helperText('Assign the person accountable for ensuring the finding reaches a governed outcome.') ->options(fn (): array => static::tenantMemberOptions()) ->searchable(), ]) ->action(function (Finding $record, array $data, FindingWorkflowService $workflow): void { - static::runWorkflowMutation( - record: $record, - successTitle: 'Finding assignment updated', - callback: fn (Finding $finding, Tenant $tenant, User $user): Finding => $workflow->assign( - $finding, - $tenant, - $user, - is_numeric($data['assignee_user_id'] ?? null) ? (int) $data['assignee_user_id'] : null, - is_numeric($data['owner_user_id'] ?? null) ? (int) $data['owner_user_id'] : null, - ), - ); + static::runResponsibilityMutation($record, $data, $workflow); }) ) ->preserveVisibility() @@ -1488,8 +1527,9 @@ public static function requestExceptionAction(): Actions\Action ->requiresConfirmation() ->form([ Select::make('owner_user_id') - ->label('Owner') + ->label('Exception owner') ->required() + ->helperText('Owns the exception record, not the finding outcome.') ->options(fn (): array => static::tenantMemberOptions()) ->searchable(), Textarea::make('request_reason') @@ -1556,8 +1596,9 @@ public static function renewExceptionAction(): Actions\Action ->modalDescription($rule->modalDescription) ->form([ Select::make('owner_user_id') - ->label('Owner') + ->label('Exception owner') ->required() + ->helperText('Owns the exception record, not the finding outcome.') ->options(fn (): array => static::tenantMemberOptions()) ->searchable(), Textarea::make('request_reason') @@ -1727,6 +1768,76 @@ private static function runWorkflowMutation(Finding $record, string $successTitl ->send(); } + /** + * @param array $data + */ + private static function runResponsibilityMutation(Finding $record, array $data, FindingWorkflowService $workflow): void + { + $pageRecord = $record; + $record = static::resolveProtectedFindingRecordOrFail($record); + $tenant = static::resolveTenantContextForCurrentPanel(); + $user = auth()->user(); + + if (! $tenant instanceof Tenant || ! $user instanceof User) { + return; + } + + if ((int) $record->tenant_id !== (int) $tenant->getKey()) { + Notification::make() + ->title('Finding belongs to a different tenant') + ->danger() + ->send(); + + return; + } + + if ((int) $record->workspace_id !== (int) $tenant->workspace_id) { + Notification::make() + ->title('Finding belongs to a different workspace') + ->danger() + ->send(); + + return; + } + + $beforeOwnerUserId = is_numeric($record->owner_user_id) ? (int) $record->owner_user_id : null; + $beforeAssigneeUserId = is_numeric($record->assignee_user_id) ? (int) $record->assignee_user_id : null; + $assigneeUserId = is_numeric($data['assignee_user_id'] ?? null) ? (int) $data['assignee_user_id'] : null; + $ownerUserId = is_numeric($data['owner_user_id'] ?? null) ? (int) $data['owner_user_id'] : null; + + try { + $workflow->assign($record, $tenant, $user, $assigneeUserId, $ownerUserId); + + $pageRecord->refresh(); + } catch (InvalidArgumentException $e) { + Notification::make() + ->title('Responsibility update failed') + ->body($e->getMessage()) + ->danger() + ->send(); + + return; + } + + $classification = $workflow->responsibilityChangeClassification( + beforeOwnerUserId: $beforeOwnerUserId, + beforeAssigneeUserId: $beforeAssigneeUserId, + afterOwnerUserId: $ownerUserId, + afterAssigneeUserId: $assigneeUserId, + ); + + Notification::make() + ->title($classification === null ? 'Finding responsibility unchanged' : 'Finding responsibility updated') + ->body($workflow->responsibilityChangeSummary( + beforeOwnerUserId: $beforeOwnerUserId, + beforeAssigneeUserId: $beforeAssigneeUserId, + afterOwnerUserId: $ownerUserId, + afterAssigneeUserId: $assigneeUserId, + )) + ->success() + ->send(); + } + /** * @param array $data */ @@ -1754,6 +1865,7 @@ private static function runExceptionRequestMutation(Finding $record, array $data Notification::make() ->title('Exception request submitted') + ->body('Exception ownership stays separate from the finding owner.') ->success() ->actions([ Actions\Action::make('view_exception') @@ -1789,6 +1901,7 @@ private static function runExceptionRenewalMutation(Finding $record, array $data Notification::make() ->title('Renewal request submitted') + ->body('Exception ownership stays separate from the finding owner.') ->success() ->actions([ Actions\Action::make('view_exception') @@ -1913,6 +2026,87 @@ private static function findingExceptionViewUrl(\App\Models\FindingException $ex return FindingExceptionResource::getUrl('view', ['record' => $exception], panel: 'tenant', tenant: $tenant); } + /** + * @param array $classificationCounts + */ + private static function bulkResponsibilityClassificationSummary(array $classificationCounts): ?string + { + $parts = []; + + foreach ($classificationCounts as $classification => $count) { + $parts[] = static::responsibilityClassificationLabel($classification).': '.$count; + } + + if ($parts === []) { + return null; + } + + return implode('. ', $parts).'.'; + } + + private static function responsibilityClassificationLabel(string $classification): string + { + return match ($classification) { + 'owner_only' => 'Owner only', + 'assignee_only' => 'Assignee only', + 'owner_and_assignee' => 'Owner and assignee', + 'clear_owner' => 'Cleared owner', + 'clear_assignee' => 'Cleared assignee', + default => 'Unchanged', + }; + } + + private static function responsibilityStateColor(Finding $finding): string + { + return match ($finding->responsibilityState()) { + Finding::RESPONSIBILITY_STATE_ORPHANED_ACCOUNTABILITY => 'danger', + Finding::RESPONSIBILITY_STATE_OWNED_UNASSIGNED => 'warning', + default => 'success', + }; + } + + public static function accountableOwnerDisplayFor(Finding $finding): string + { + return $finding->ownerUser?->name ?? 'Unassigned'; + } + + public static function activeAssigneeDisplayFor(Finding $finding): string + { + return $finding->assigneeUser?->name ?? 'Unassigned'; + } + + private static function responsibilitySummary(Finding $finding): string + { + $ownerName = $finding->ownerUser?->name; + $assigneeName = $finding->assigneeUser?->name; + + return match ($finding->responsibilityState()) { + Finding::RESPONSIBILITY_STATE_ORPHANED_ACCOUNTABILITY => $assigneeName !== null + ? "No accountable owner is set. {$assigneeName} is currently carrying the active remediation work." + : 'No accountable owner or active assignee is set.', + Finding::RESPONSIBILITY_STATE_OWNED_UNASSIGNED => "{$ownerName} owns the outcome, but active remediation is still unassigned.", + default => $ownerName === $assigneeName + ? "{$ownerName} owns the outcome and is also the active assignee." + : "{$ownerName} owns the outcome. {$assigneeName} is the active assignee.", + }; + } + + private static function governanceListDescription(Finding $finding): ?string + { + $parts = array_values(array_filter([ + static::governanceWarning($finding), + static::resolvedFindingException($finding)?->owner?->name !== null + ? 'Exception owner: '.static::resolvedFindingException($finding)?->owner?->name + : null, + ])); + + if ($parts === []) { + return null; + } + + return implode(' ', $parts); + } + private static function governanceWarning(Finding $finding): ?string { return app(FindingRiskGovernanceResolver::class) @@ -1958,7 +2152,7 @@ private static function primaryNextAction(Finding $finding): ?string ->resolvePrimaryNextAction($finding, static::resolvedFindingException($finding)); } - private static function dueAttentionLabel(Finding $finding): ?string + public static function dueAttentionLabelFor(Finding $finding): ?string { if (! $finding->hasOpenStatus() || ! $finding->due_at) { return null; @@ -1975,9 +2169,9 @@ private static function dueAttentionLabel(Finding $finding): ?string return null; } - private static function dueAttentionColor(Finding $finding): string + public static function dueAttentionColorFor(Finding $finding): string { - return match (static::dueAttentionLabel($finding)) { + return match (static::dueAttentionLabelFor($finding)) { 'Overdue' => 'danger', 'Due soon' => 'warning', default => 'gray', diff --git a/apps/platform/app/Filament/Resources/InventoryItemResource.php b/apps/platform/app/Filament/Resources/InventoryItemResource.php index 4ab1b8a9..736d7dce 100644 --- a/apps/platform/app/Filament/Resources/InventoryItemResource.php +++ b/apps/platform/app/Filament/Resources/InventoryItemResource.php @@ -316,7 +316,13 @@ public static function getEloquentQuery(): Builder public static function resolveScopedRecordOrFail(int|string $key): Model { - return static::resolveTenantOwnedRecordOrFail($key, parent::getEloquentQuery()->with('lastSeenRun')); + $tenant = static::resolveTenantContextForCurrentPanelOrFail(); + + return static::resolveTenantOwnedRecordOrFail( + $key, + parent::getEloquentQuery()->with('lastSeenRun'), + $tenant, + ); } public static function getPages(): array diff --git a/apps/platform/app/Filament/Resources/InventoryItemResource/Pages/ViewInventoryItem.php b/apps/platform/app/Filament/Resources/InventoryItemResource/Pages/ViewInventoryItem.php index 780f95c7..d4c77849 100644 --- a/apps/platform/app/Filament/Resources/InventoryItemResource/Pages/ViewInventoryItem.php +++ b/apps/platform/app/Filament/Resources/InventoryItemResource/Pages/ViewInventoryItem.php @@ -2,14 +2,30 @@ namespace App\Filament\Resources\InventoryItemResource\Pages; +use App\Filament\Concerns\ResolvesPanelTenantContext; use App\Filament\Resources\InventoryItemResource; +use App\Models\Tenant; +use App\Support\Workspaces\WorkspaceContext; use Filament\Resources\Pages\ViewRecord; use Illuminate\Database\Eloquent\Model; class ViewInventoryItem extends ViewRecord { + use ResolvesPanelTenantContext; + protected static string $resource = InventoryItemResource::class; + public function mount(int|string $record): void + { + $tenant = static::resolveTenantContextForCurrentPanel(); + + if ($tenant instanceof Tenant) { + app(WorkspaceContext::class)->rememberTenantContext($tenant, request()); + } + + parent::mount($record); + } + protected function resolveRecord(int|string $key): Model { return InventoryItemResource::resolveScopedRecordOrFail($key); diff --git a/apps/platform/app/Filament/Resources/OperationRunResource.php b/apps/platform/app/Filament/Resources/OperationRunResource.php index 7551fc2d..5d412cb4 100644 --- a/apps/platform/app/Filament/Resources/OperationRunResource.php +++ b/apps/platform/app/Filament/Resources/OperationRunResource.php @@ -280,16 +280,25 @@ private static function enterpriseDetailPage(OperationRun $record): \App\Support : null; $artifactTruth = static::artifactTruthEnvelope($record); $operatorExplanation = $artifactTruth?->operatorExplanation; + $diagnosticSummary = OperationUxPresenter::governanceDiagnosticSummary($record); $reasonEnvelope = app(ReasonPresenter::class)->forOperationRun($record, 'run_detail'); $primaryNextStep = static::resolvePrimaryNextStep($record, $artifactTruth, $operatorExplanation); + $decisionNextStep = $diagnosticSummary instanceof \App\Support\OpsUx\GovernanceRunDiagnosticSummary + ? [ + 'text' => $diagnosticSummary->nextActionText, + 'source' => $diagnosticSummary->nextActionCategory, + 'secondaryGuidance' => $primaryNextStep['secondaryGuidance'], + ] + : $primaryNextStep; $restoreContinuation = static::restoreContinuation($record); $supportingGroups = static::supportingGroups( record: $record, factory: $factory, referencedTenantLifecycle: $referencedTenantLifecycle, + diagnosticSummary: $diagnosticSummary, operatorExplanation: $operatorExplanation, reasonEnvelope: $reasonEnvelope, - primaryNextStep: $primaryNextStep, + primaryNextStep: $decisionNextStep, ); $builder = \App\Support\Ui\EnterpriseDetail\EnterpriseDetailBuilder::make('operation_run', 'workspace-context') @@ -307,49 +316,25 @@ private static function enterpriseDetailPage(OperationRun $record): \App\Support descriptionHint: 'Decision guidance and high-signal context stay ahead of diagnostic payloads and raw JSON.', )) ->decisionZone($factory->decisionZone( - facts: array_values(array_filter([ - $factory->keyFact( - 'Execution state', - $statusSpec->label, - badge: $factory->statusBadge($statusSpec->label, $statusSpec->color, $statusSpec->icon, $statusSpec->iconColor), - ), - $factory->keyFact( - 'Outcome', - $outcomeSpec->label, - badge: $factory->statusBadge($outcomeSpec->label, $outcomeSpec->color, $outcomeSpec->icon, $outcomeSpec->iconColor), - ), - static::artifactTruthFact($factory, $artifactTruth), - $operatorExplanation instanceof OperatorExplanationPattern - ? $factory->keyFact( - 'Result meaning', - $operatorExplanation->evaluationResultLabel(), - $operatorExplanation->headline, - ) - : null, - $operatorExplanation instanceof OperatorExplanationPattern - ? $factory->keyFact( - 'Result trust', - $operatorExplanation->trustworthinessLabel(), - static::detailHintUnlessDuplicate( - $operatorExplanation->reliabilityStatement, - $artifactTruth?->primaryExplanation, - ), - ) - : null, - is_array($restoreContinuation) - ? $factory->keyFact( - 'Restore continuation', - (string) ($restoreContinuation['badge_label'] ?? 'Restore detail'), - (string) ($restoreContinuation['summary'] ?? 'Restore continuation detail is unavailable.'), - ) - : null, - ])), - primaryNextStep: $factory->primaryNextStep( - $primaryNextStep['text'], - $primaryNextStep['source'], - $primaryNextStep['secondaryGuidance'], + facts: static::decisionFacts( + factory: $factory, + record: $record, + statusSpec: $statusSpec, + outcomeSpec: $outcomeSpec, + artifactTruth: $artifactTruth, + operatorExplanation: $operatorExplanation, + restoreContinuation: $restoreContinuation, + diagnosticSummary: $diagnosticSummary, ), - description: 'Start here to see how the operation ended, whether the result is trustworthy enough to use, and the one primary next step.', + primaryNextStep: $factory->primaryNextStep( + $decisionNextStep['text'], + $decisionNextStep['source'], + $decisionNextStep['secondaryGuidance'], + 'Primary next step', + ), + description: $diagnosticSummary instanceof \App\Support\OpsUx\GovernanceRunDiagnosticSummary + ? 'Start here to see what happened, how reliable the resulting artifact is, what was affected, and the one next step.' + : 'Start here to see how the operation ended, whether the result is trustworthy enough to use, and the one primary next step.', compactCounts: $summaryLine !== null ? $factory->countPresentation(summaryLine: $summaryLine) : null, @@ -550,6 +535,7 @@ private static function supportingGroups( OperationRun $record, \App\Support\Ui\EnterpriseDetail\EnterpriseDetailSectionFactory $factory, ?ReferencedTenantLifecyclePresentation $referencedTenantLifecycle, + ?\App\Support\OpsUx\GovernanceRunDiagnosticSummary $diagnosticSummary, ?OperatorExplanationPattern $operatorExplanation, ?ReasonResolutionEnvelope $reasonEnvelope, array $primaryNextStep, @@ -559,6 +545,21 @@ private static function supportingGroups( $reasonSemantics = app(ReasonPresenter::class)->semantics($reasonEnvelope); $guidanceItems = array_values(array_filter([ + ...array_map( + static fn (array $fact): array => $factory->keyFact( + (string) ($fact['label'] ?? 'Summary detail'), + (string) ($fact['value'] ?? '—'), + is_string($fact['hint'] ?? null) ? $fact['hint'] : null, + tone: match ($fact['emphasis'] ?? null) { + 'blocked' => 'danger', + 'caution' => 'warning', + default => null, + }, + ), + $diagnosticSummary instanceof \App\Support\OpsUx\GovernanceRunDiagnosticSummary + ? array_values(array_filter($diagnosticSummary->secondaryFacts, 'is_array')) + : [], + ), $operatorExplanation instanceof OperatorExplanationPattern && $operatorExplanation->coverageStatement !== null ? $factory->keyFact('Coverage', $operatorExplanation->coverageStatement) : null, @@ -811,6 +812,8 @@ private static function guidanceLabel(string $source): string private static function artifactTruthFact( \App\Support\Ui\EnterpriseDetail\EnterpriseDetailSectionFactory $factory, ?ArtifactTruthEnvelope $artifactTruth, + ?string $hintOverride = null, + bool $preferOverride = false, ): ?array { if (! $artifactTruth instanceof ArtifactTruthEnvelope) { return null; @@ -823,19 +826,138 @@ private static function artifactTruthFact( $badge = $outcome->primaryBadge; return $factory->keyFact( - 'Outcome', + 'Artifact impact', $outcome->primaryLabel, - $outcome->primaryReason, + $preferOverride ? $hintOverride : ($hintOverride ?? $outcome->primaryReason), $factory->statusBadge($badge->label, $badge->color, $badge->icon, $badge->iconColor), ); } + /** + * @return list> + */ + private static function decisionFacts( + \App\Support\Ui\EnterpriseDetail\EnterpriseDetailSectionFactory $factory, + OperationRun $record, + \App\Support\Badges\BadgeSpec $statusSpec, + \App\Support\Badges\BadgeSpec $outcomeSpec, + ?ArtifactTruthEnvelope $artifactTruth, + ?OperatorExplanationPattern $operatorExplanation, + mixed $restoreContinuation, + ?\App\Support\OpsUx\GovernanceRunDiagnosticSummary $diagnosticSummary, + ): array { + if (! $diagnosticSummary instanceof \App\Support\OpsUx\GovernanceRunDiagnosticSummary) { + return array_values(array_filter([ + $factory->keyFact( + 'Execution state', + $statusSpec->label, + badge: $factory->statusBadge($statusSpec->label, $statusSpec->color, $statusSpec->icon, $statusSpec->iconColor), + ), + $factory->keyFact( + 'Outcome', + $outcomeSpec->label, + badge: $factory->statusBadge($outcomeSpec->label, $outcomeSpec->color, $outcomeSpec->icon, $outcomeSpec->iconColor), + ), + static::artifactTruthFact($factory, $artifactTruth), + $operatorExplanation instanceof OperatorExplanationPattern + ? $factory->keyFact( + 'Result meaning', + $operatorExplanation->evaluationResultLabel(), + $operatorExplanation->headline, + ) + : null, + $operatorExplanation instanceof OperatorExplanationPattern + ? $factory->keyFact( + 'Result trust', + $operatorExplanation->trustworthinessLabel(), + static::detailHintUnlessDuplicate( + $operatorExplanation->reliabilityStatement, + $artifactTruth?->primaryExplanation, + ), + ) + : null, + is_array($restoreContinuation) + ? $factory->keyFact( + 'Restore continuation', + (string) ($restoreContinuation['badge_label'] ?? 'Restore detail'), + (string) ($restoreContinuation['summary'] ?? 'Restore continuation detail is unavailable.'), + ) + : null, + ])); + } + + $facts = [ + $factory->keyFact( + 'Execution state', + $statusSpec->label, + badge: $factory->statusBadge($statusSpec->label, $statusSpec->color, $statusSpec->icon, $statusSpec->iconColor), + ), + $factory->keyFact( + 'Outcome', + $diagnosticSummary->executionOutcomeLabel, + badge: $factory->statusBadge($outcomeSpec->label, $outcomeSpec->color, $outcomeSpec->icon, $outcomeSpec->iconColor), + ), + static::artifactTruthFact( + $factory, + $artifactTruth, + static::detailHintUnlessDuplicate( + $diagnosticSummary->headline, + $artifactTruth?->primaryExplanation, + $diagnosticSummary->primaryReason, + ), + true, + ), + $factory->keyFact( + 'Dominant cause', + $diagnosticSummary->dominantCause['label'], + $diagnosticSummary->primaryReason, + tone: in_array($diagnosticSummary->nextActionCategory, ['refresh_prerequisite_data', 'review_scope_or_ambiguous_matches'], true) + ? 'warning' + : (in_array($diagnosticSummary->nextActionCategory, ['retry_later', 'no_further_action'], true) ? null : 'danger'), + ), + $operatorExplanation instanceof OperatorExplanationPattern + ? $factory->keyFact( + 'Result trust', + $operatorExplanation->trustworthinessLabel(), + static::detailHintUnlessDuplicate( + $operatorExplanation->reliabilityStatement, + $diagnosticSummary->primaryReason, + ), + tone: match ($operatorExplanation->trustworthinessLevel->value) { + 'unusable' => 'danger', + 'diagnostic_only', 'limited_confidence' => 'warning', + default => 'success', + }, + ) + : null, + is_array($restoreContinuation) + ? $factory->keyFact( + 'Restore continuation', + (string) ($restoreContinuation['badge_label'] ?? 'Restore detail'), + (string) ($restoreContinuation['summary'] ?? 'Restore continuation detail is unavailable.'), + ) + : null, + ]; + + if (is_array($diagnosticSummary->affectedScaleCue)) { + $source = str_replace('_', ' ', (string) ($diagnosticSummary->affectedScaleCue['source'] ?? 'recorded detail')); + + $facts[] = $factory->keyFact( + (string) ($diagnosticSummary->affectedScaleCue['label'] ?? 'Affected scale'), + (string) ($diagnosticSummary->affectedScaleCue['value'] ?? 'Recorded detail is available.'), + 'Backed by '.$source.'.', + ); + } + + return array_values(array_filter($facts)); + } + private static function decisionAttentionNote(OperationRun $record): ?string { return OperationUxPresenter::decisionAttentionNote($record); } - private static function detailHintUnlessDuplicate(?string $hint, ?string $duplicateOf): ?string + private static function detailHintUnlessDuplicate(?string $hint, ?string ...$duplicates): ?string { $normalizedHint = static::normalizeDetailText($hint); @@ -843,8 +965,10 @@ private static function detailHintUnlessDuplicate(?string $hint, ?string $duplic return null; } - if ($normalizedHint === static::normalizeDetailText($duplicateOf)) { - return null; + foreach ($duplicates as $duplicate) { + if ($normalizedHint === static::normalizeDetailText($duplicate)) { + return null; + } } return trim($hint ?? ''); diff --git a/apps/platform/app/Filament/Resources/ProviderConnectionResource.php b/apps/platform/app/Filament/Resources/ProviderConnectionResource.php index 04133be8..ee155533 100644 --- a/apps/platform/app/Filament/Resources/ProviderConnectionResource.php +++ b/apps/platform/app/Filament/Resources/ProviderConnectionResource.php @@ -21,6 +21,7 @@ use App\Support\Filament\TablePaginationProfiles; use App\Support\OperationRunLinks; use App\Support\OpsUx\OperationUxPresenter; +use App\Support\OpsUx\ProviderOperationStartResultPresenter; use App\Support\OpsUx\OpsUxBrowserEvents; use App\Support\Providers\ProviderConnectionType; use App\Support\Providers\ProviderReasonCodes; @@ -1357,20 +1358,23 @@ private static function handleCheckConnectionAction(ProviderConnection $record, initiator: $user, ); + $runUrl = OperationRunLinks::view($result->run, $tenant); + $extraActions = $result->status === 'started' + ? [] + : [ + Actions\Action::make('manage_connections') + ->label('Manage Provider Connections') + ->url(static::getUrl('index', tenant: $tenant)), + ]; + $notification = app(ProviderOperationStartResultPresenter::class)->notification( + result: $result, + blockedTitle: 'Connection check blocked', + runUrl: $runUrl, + extraActions: $extraActions, + ); + if ($result->status === 'scope_busy') { - Notification::make() - ->title('Scope busy') - ->body('Another provider operation is already running for this connection.') - ->warning() - ->actions([ - Actions\Action::make('view_run') - ->label('Open operation') - ->url(OperationRunLinks::view($result->run, $tenant)), - Actions\Action::make('manage_connections') - ->label('Manage Provider Connections') - ->url(static::getUrl('index', tenant: $tenant)), - ]) - ->send(); + $notification->send(); return; } @@ -1378,50 +1382,20 @@ private static function handleCheckConnectionAction(ProviderConnection $record, if ($result->status === 'deduped') { OpsUxBrowserEvents::dispatchRunEnqueued($livewire); - OperationUxPresenter::alreadyQueuedToast((string) $result->run->type) - ->actions([ - Actions\Action::make('view_run') - ->label('Open operation') - ->url(OperationRunLinks::view($result->run, $tenant)), - Actions\Action::make('manage_connections') - ->label('Manage Provider Connections') - ->url(static::getUrl('index', tenant: $tenant)), - ]) - ->send(); + $notification->send(); return; } if ($result->status === 'blocked') { - $reasonEnvelope = app(\App\Support\ReasonTranslation\ReasonPresenter::class)->forOperationRun($result->run, 'notification'); - $bodyLines = $reasonEnvelope?->toBodyLines() ?? ['Blocked by provider configuration.']; - - Notification::make() - ->title('Connection check blocked') - ->body(implode("\n", $bodyLines)) - ->warning() - ->actions([ - Actions\Action::make('view_run') - ->label('Open operation') - ->url(OperationRunLinks::view($result->run, $tenant)), - Actions\Action::make('manage_connections') - ->label('Manage Provider Connections') - ->url(static::getUrl('index', tenant: $tenant)), - ]) - ->send(); + $notification->send(); return; } OpsUxBrowserEvents::dispatchRunEnqueued($livewire); - OperationUxPresenter::queuedToast((string) $result->run->type) - ->actions([ - Actions\Action::make('view_run') - ->label('Open operation') - ->url(OperationRunLinks::view($result->run, $tenant)), - ]) - ->send(); + $notification->send(); } /** @@ -1452,17 +1426,14 @@ private static function handleProviderOperationAction( initiator: $user, ); + $notification = app(ProviderOperationStartResultPresenter::class)->notification( + result: $result, + blockedTitle: $blockedTitle, + runUrl: OperationRunLinks::view($result->run, $tenant), + ); + if ($result->status === 'scope_busy') { - Notification::make() - ->title('Scope is busy') - ->body('Another provider operation is already running for this connection.') - ->danger() - ->actions([ - Actions\Action::make('view_run') - ->label('Open operation') - ->url(OperationRunLinks::view($result->run, $tenant)), - ]) - ->send(); + $notification->send(); return; } @@ -1470,44 +1441,20 @@ private static function handleProviderOperationAction( if ($result->status === 'deduped') { OpsUxBrowserEvents::dispatchRunEnqueued($livewire); - OperationUxPresenter::alreadyQueuedToast((string) $result->run->type) - ->actions([ - Actions\Action::make('view_run') - ->label('Open operation') - ->url(OperationRunLinks::view($result->run, $tenant)), - ]) - ->send(); + $notification->send(); return; } if ($result->status === 'blocked') { - $reasonEnvelope = app(\App\Support\ReasonTranslation\ReasonPresenter::class)->forOperationRun($result->run, 'notification'); - $bodyLines = $reasonEnvelope?->toBodyLines() ?? ['Blocked by provider configuration.']; - - Notification::make() - ->title($blockedTitle) - ->body(implode("\n", $bodyLines)) - ->warning() - ->actions([ - Actions\Action::make('view_run') - ->label('Open operation') - ->url(OperationRunLinks::view($result->run, $tenant)), - ]) - ->send(); + $notification->send(); return; } OpsUxBrowserEvents::dispatchRunEnqueued($livewire); - OperationUxPresenter::queuedToast((string) $result->run->type) - ->actions([ - Actions\Action::make('view_run') - ->label('Open operation') - ->url(OperationRunLinks::view($result->run, $tenant)), - ]) - ->send(); + $notification->send(); } public static function getEloquentQuery(): Builder diff --git a/apps/platform/app/Filament/Resources/RestoreRunResource.php b/apps/platform/app/Filament/Resources/RestoreRunResource.php index 1d9f3c3d..ad63d385 100644 --- a/apps/platform/app/Filament/Resources/RestoreRunResource.php +++ b/apps/platform/app/Filament/Resources/RestoreRunResource.php @@ -14,6 +14,7 @@ use App\Models\BackupItem; use App\Models\BackupSet; use App\Models\EntraGroup; +use App\Models\OperationRun; use App\Models\RestoreRun; use App\Models\Tenant; use App\Models\User; @@ -26,6 +27,8 @@ use App\Services\Intune\RestoreService; use App\Services\OperationRunService; use App\Services\Operations\BulkSelectionIdentity; +use App\Services\Providers\ProviderOperationStartGate; +use App\Services\Providers\ProviderOperationStartResult; use App\Support\Auth\Capabilities; use App\Support\BackupQuality\BackupQualityResolver; use App\Support\Badges\BadgeDomain; @@ -35,6 +38,7 @@ use App\Support\OperationRunLinks; use App\Support\OpsUx\OperationUxPresenter; use App\Support\OpsUx\OpsUxBrowserEvents; +use App\Support\OpsUx\ProviderOperationStartResultPresenter; use App\Support\Rbac\UiEnforcement; use App\Support\RestoreRunIdempotency; use App\Support\RestoreRunStatus; @@ -1917,6 +1921,53 @@ public static function createRestoreRun(array $data): RestoreRun ->executionSafetySnapshot($tenant, $user, $data) ->toArray(); + [$result, $restoreRun] = static::startQueuedRestoreExecution( + tenant: $tenant, + backupSet: $backupSet, + selectedItemIds: $selectedItemIds, + preview: $preview, + metadata: $metadata, + groupMapping: $groupMapping, + actorEmail: $actorEmail, + actorName: $actorName, + ); + + app(ProviderOperationStartResultPresenter::class) + ->notification( + result: $result, + blockedTitle: 'Restore execution blocked', + runUrl: OperationRunLinks::view($result->run, $tenant), + ) + ->send(); + + if (! in_array($result->status, ['started', 'deduped'], true)) { + throw new \Filament\Support\Exceptions\Halt; + } + + if (! $restoreRun instanceof RestoreRun) { + throw new \RuntimeException('Restore execution was accepted without creating a restore run.'); + } + + return $restoreRun; + } + + /** + * @param array|null $selectedItemIds + * @param array $preview + * @param array $metadata + * @param array $groupMapping + * @return array{0: \App\Services\Providers\ProviderOperationStartResult, 1: ?RestoreRun} + */ + private static function startQueuedRestoreExecution( + Tenant $tenant, + BackupSet $backupSet, + ?array $selectedItemIds, + array $preview, + array $metadata, + array $groupMapping, + ?string $actorEmail, + ?string $actorName, + ): array { $idempotencyKey = RestoreRunIdempotency::restoreExecuteKey( tenantId: (int) $tenant->getKey(), backupSetId: (int) $backupSet->getKey(), @@ -1924,34 +1975,27 @@ public static function createRestoreRun(array $data): RestoreRun groupMapping: $groupMapping, ); - $existing = RestoreRunIdempotency::findActiveRestoreRun((int) $tenant->getKey(), $idempotencyKey); + $initiator = auth()->user(); + $initiator = $initiator instanceof User ? $initiator : null; - if ($existing) { - $existingOpRunId = (int) ($existing->operation_run_id ?? 0); - $existingOpRun = $existingOpRunId > 0 - ? \App\Models\OperationRun::query()->find($existingOpRunId) - : null; + $queuedRestoreRun = null; - $toast = OperationUxPresenter::alreadyQueuedToast('restore.execute') - ->body('Reusing the active restore run.'); - - if ($existingOpRun) { - $toast->actions([ - Actions\Action::make('view_run') - ->label('Open operation') - ->url(OperationRunLinks::view($existingOpRun, $tenant)), - ]); - } - - $toast->send(); - - return $existing; - } - - try { - $restoreRun = RestoreRun::create([ + $dispatcher = function (OperationRun $run) use ( + $tenant, + $backupSet, + $selectedItemIds, + $preview, + $metadata, + $groupMapping, + $actorEmail, + $actorName, + $idempotencyKey, + &$queuedRestoreRun, + ): void { + $queuedRestoreRun = RestoreRun::create([ 'tenant_id' => $tenant->id, 'backup_set_id' => $backupSet->id, + 'operation_run_id' => $run->getKey(), 'requested_by' => $actorEmail, 'is_dry_run' => false, 'status' => RestoreRunStatus::Queued->value, @@ -1961,83 +2005,114 @@ public static function createRestoreRun(array $data): RestoreRun 'metadata' => $metadata, 'group_mapping' => $groupMapping !== [] ? $groupMapping : null, ]); - } catch (QueryException $exception) { - $existing = RestoreRunIdempotency::findActiveRestoreRun((int) $tenant->getKey(), $idempotencyKey); - if ($existing) { - $existingOpRunId = (int) ($existing->operation_run_id ?? 0); - $existingOpRun = $existingOpRunId > 0 - ? \App\Models\OperationRun::query()->find($existingOpRunId) - : null; + $context = is_array($run->context) ? $run->context : []; + $context['restore_run_id'] = (int) $queuedRestoreRun->getKey(); + $run->forceFill(['context' => $context])->save(); - $toast = OperationUxPresenter::alreadyQueuedToast('restore.execute') - ->body('Reusing the active restore run.'); + app(AuditLogger::class)->log( + tenant: $tenant, + action: 'restore.queued', + context: [ + 'metadata' => [ + 'restore_run_id' => $queuedRestoreRun->id, + 'backup_set_id' => $backupSet->id, + ], + ], + actorEmail: $actorEmail, + actorName: $actorName, + resourceType: 'restore_run', + resourceId: (string) $queuedRestoreRun->id, + status: 'success', + ); - if ($existingOpRun) { - $toast->actions([ - Actions\Action::make('view_run') - ->label('Open operation') - ->url(OperationRunLinks::view($existingOpRun, $tenant)), - ]); - } + $providerConnectionId = is_numeric($context['provider_connection_id'] ?? null) + ? (int) $context['provider_connection_id'] + : null; - $toast->send(); + ExecuteRestoreRunJob::dispatch( + restoreRunId: (int) $queuedRestoreRun->getKey(), + actorEmail: $actorEmail, + actorName: $actorName, + operationRun: $run, + providerConnectionId: $providerConnectionId, + )->afterCommit(); + }; - return $existing; + if (static::requiresProviderExecution($backupSet, $selectedItemIds)) { + $result = app(ProviderOperationStartGate::class)->start( + tenant: $tenant, + connection: null, + operationType: 'restore.execute', + dispatcher: $dispatcher, + initiator: $initiator, + extraContext: [ + 'backup_set_id' => (int) $backupSet->getKey(), + 'is_dry_run' => false, + 'execution_authority_mode' => 'actor_bound', + 'required_capability' => Capabilities::TENANT_MANAGE, + ], + ); + } else { + $run = app(OperationRunService::class)->ensureRunWithIdentity( + tenant: $tenant, + type: 'restore.execute', + identityInputs: [ + 'idempotency_key' => $idempotencyKey, + ], + context: [ + 'backup_set_id' => (int) $backupSet->getKey(), + 'is_dry_run' => false, + 'execution_authority_mode' => 'actor_bound', + 'required_capability' => Capabilities::TENANT_MANAGE, + 'target_scope' => [ + 'entra_tenant_id' => $tenant->graphTenantId(), + ], + ], + initiator: $initiator, + ); + + if ($run->wasRecentlyCreated) { + $dispatcher($run); + + $result = ProviderOperationStartResult::started($run, true); + } else { + $result = ProviderOperationStartResult::deduped($run); + } + } + + if (! $queuedRestoreRun instanceof RestoreRun && $result->status === 'deduped') { + $restoreRunId = data_get($result->run->context ?? [], 'restore_run_id'); + + if (is_numeric($restoreRunId)) { + $queuedRestoreRun = RestoreRun::query()->whereKey((int) $restoreRunId)->first(); } - throw $exception; + $queuedRestoreRun ??= RestoreRunIdempotency::findActiveRestoreRun( + (int) $tenant->getKey(), + $idempotencyKey, + ); } - app(AuditLogger::class)->log( - tenant: $tenant, - action: 'restore.queued', - context: [ - 'metadata' => [ - 'restore_run_id' => $restoreRun->id, - 'backup_set_id' => $backupSet->id, - ], - ], - actorEmail: $actorEmail, - actorName: $actorName, - resourceType: 'restore_run', - resourceId: (string) $restoreRun->id, - status: 'success', - ); + return [$result, $queuedRestoreRun?->refresh()]; + } - /** @var OperationRunService $runs */ - $runs = app(OperationRunService::class); - $initiator = auth()->user(); - $initiator = $initiator instanceof \App\Models\User ? $initiator : null; + /** + * @param array|null $selectedItemIds + */ + private static function requiresProviderExecution(BackupSet $backupSet, ?array $selectedItemIds): bool + { + $query = $backupSet->items()->select(['id', 'policy_type']); - $opRun = $runs->ensureRun( - tenant: $tenant, - type: 'restore.execute', - inputs: [ - 'restore_run_id' => (int) $restoreRun->getKey(), - 'backup_set_id' => (int) $backupSet->getKey(), - 'is_dry_run' => (bool) ($restoreRun->is_dry_run ?? false), - 'execution_authority_mode' => 'actor_bound', - 'required_capability' => Capabilities::TENANT_MANAGE, - ], - initiator: $initiator, - ); - - if ((int) ($restoreRun->operation_run_id ?? 0) !== (int) $opRun->getKey()) { - $restoreRun->update(['operation_run_id' => $opRun->getKey()]); + if (is_array($selectedItemIds) && $selectedItemIds !== []) { + $query->whereIn('id', $selectedItemIds); } - ExecuteRestoreRunJob::dispatch($restoreRun->id, $actorEmail, $actorName, $opRun); + return $query->get()->contains(function (BackupItem $item): bool { + $restoreMode = static::typeMeta($item->policy_type)['restore'] ?? 'preview-only'; - OperationUxPresenter::queuedToast('restore.execute') - ->actions([ - Actions\Action::make('view_run') - ->label('Open operation') - ->url(OperationRunLinks::view($opRun, $tenant)), - ]) - ->send(); - - return $restoreRun->refresh(); + return $restoreMode !== 'preview-only'; + }); } /** @@ -2452,122 +2527,34 @@ private static function rerunActionWithGate(): Actions\Action|BulkAction 'rerun_of_restore_run_id' => $record->id, ]; - $idempotencyKey = RestoreRunIdempotency::restoreExecuteKey( - tenantId: (int) $tenant->getKey(), - backupSetId: (int) $backupSet->getKey(), - selectedItemIds: $selectedItemIds, - groupMapping: $groupMapping, - ); + $metadata['rerun_of_restore_run_id'] = $record->id; - $existing = RestoreRunIdempotency::findActiveRestoreRun((int) $tenant->getKey(), $idempotencyKey); - - if ($existing) { - $existingOpRunId = (int) ($existing->operation_run_id ?? 0); - $existingOpRun = $existingOpRunId > 0 - ? \App\Models\OperationRun::query()->find($existingOpRunId) - : null; - - OpsUxBrowserEvents::dispatchRunEnqueued($livewire); - - $toast = OperationUxPresenter::alreadyQueuedToast('restore.execute') - ->body('Reusing the active restore run.'); - - if ($existingOpRun) { - $toast->actions([ - Action::make('view_run') - ->label('Open operation') - ->url(OperationRunLinks::view($existingOpRun, $tenant)), - ]); - } - - $toast->send(); - - return; - } - - try { - $newRun = RestoreRun::create([ - 'tenant_id' => $tenant->id, - 'backup_set_id' => $backupSet->id, - 'requested_by' => $actorEmail, - 'is_dry_run' => false, - 'status' => RestoreRunStatus::Queued->value, - 'idempotency_key' => $idempotencyKey, - 'requested_items' => $selectedItemIds, - 'preview' => $preview, - 'metadata' => $metadata, - 'group_mapping' => $groupMapping !== [] ? $groupMapping : null, - ]); - } catch (QueryException $exception) { - $existing = RestoreRunIdempotency::findActiveRestoreRun((int) $tenant->getKey(), $idempotencyKey); - - if ($existing) { - $existingOpRunId = (int) ($existing->operation_run_id ?? 0); - $existingOpRun = $existingOpRunId > 0 - ? \App\Models\OperationRun::query()->find($existingOpRunId) - : null; - - OpsUxBrowserEvents::dispatchRunEnqueued($livewire); - - $toast = OperationUxPresenter::alreadyQueuedToast('restore.execute') - ->body('Reusing the active restore run.'); - - if ($existingOpRun) { - $toast->actions([ - Action::make('view_run') - ->label('Open operation') - ->url(OperationRunLinks::view($existingOpRun, $tenant)), - ]); - } - - $toast->send(); - - return; - } - - throw $exception; - } - - $auditLogger->log( + [$result, $newRun] = static::startQueuedRestoreExecution( tenant: $tenant, - action: 'restore.queued', - context: [ - 'metadata' => [ - 'restore_run_id' => $newRun->id, - 'backup_set_id' => $backupSet->id, - 'rerun_of_restore_run_id' => $record->id, - ], - ], + backupSet: $backupSet, + selectedItemIds: $selectedItemIds, + preview: $preview, + metadata: $metadata, + groupMapping: $groupMapping, actorEmail: $actorEmail, actorName: $actorName, - resourceType: 'restore_run', - resourceId: (string) $newRun->id, - status: 'success', ); - /** @var OperationRunService $runs */ - $runs = app(OperationRunService::class); - $initiator = auth()->user(); - $initiator = $initiator instanceof \App\Models\User ? $initiator : null; - - $opRun = $runs->ensureRun( - tenant: $tenant, - type: 'restore.execute', - inputs: [ - 'restore_run_id' => (int) $newRun->getKey(), - 'backup_set_id' => (int) $backupSet->getKey(), - 'is_dry_run' => (bool) ($newRun->is_dry_run ?? false), - 'execution_authority_mode' => 'actor_bound', - 'required_capability' => Capabilities::TENANT_MANAGE, - ], - initiator: $initiator, - ); - - if ((int) ($newRun->operation_run_id ?? 0) !== (int) $opRun->getKey()) { - $newRun->update(['operation_run_id' => $opRun->getKey()]); + if (in_array($result->status, ['started', 'deduped', 'scope_busy'], true)) { + OpsUxBrowserEvents::dispatchRunEnqueued($livewire); } - ExecuteRestoreRunJob::dispatch($newRun->id, $actorEmail, $actorName, $opRun); + app(ProviderOperationStartResultPresenter::class) + ->notification( + result: $result, + blockedTitle: 'Restore execution blocked', + runUrl: OperationRunLinks::view($result->run, $tenant), + ) + ->send(); + + if ($result->status !== 'started' || ! $newRun instanceof RestoreRun) { + return; + } $auditLogger->log( tenant: $tenant, @@ -2585,15 +2572,6 @@ private static function rerunActionWithGate(): Actions\Action|BulkAction actorName: $actorName, ); - OpsUxBrowserEvents::dispatchRunEnqueued($livewire); - OperationUxPresenter::queuedToast('restore.execute') - ->actions([ - Action::make('view_run') - ->label('Open operation') - ->url(OperationRunLinks::view($opRun, $tenant)), - ]) - ->send(); - return; } diff --git a/apps/platform/app/Filament/Resources/ReviewPackResource.php b/apps/platform/app/Filament/Resources/ReviewPackResource.php index 11cf26bb..d69b4e81 100644 --- a/apps/platform/app/Filament/Resources/ReviewPackResource.php +++ b/apps/platform/app/Filament/Resources/ReviewPackResource.php @@ -399,9 +399,12 @@ private static function truthEnvelope(ReviewPack $record, bool $fresh = false): private static function truthState(ReviewPack $record, bool $fresh = false): array { $presenter = app(ArtifactTruthPresenter::class); + $truth = $fresh + ? static::truthEnvelope($record, true) + : static::truthEnvelope($record); return $presenter->surfaceStateFor($record, SurfaceCompressionContext::reviewPack(), $fresh) - ?? static::truthEnvelope($record, $fresh)->toArray(static::compressedOutcome($record, $fresh)); + ?? $truth->toArray(static::compressedOutcome($record, $fresh)); } private static function compressedOutcome(ReviewPack $record, bool $fresh = false): CompressedGovernanceOutcome diff --git a/apps/platform/app/Filament/Resources/TenantResource.php b/apps/platform/app/Filament/Resources/TenantResource.php index 74c192fe..8971e621 100644 --- a/apps/platform/app/Filament/Resources/TenantResource.php +++ b/apps/platform/app/Filament/Resources/TenantResource.php @@ -42,6 +42,7 @@ use App\Support\Filament\TablePaginationProfiles; use App\Support\OperationRunLinks; use App\Support\OpsUx\OperationUxPresenter; +use App\Support\OpsUx\ProviderOperationStartResultPresenter; use App\Support\OpsUx\OpsUxBrowserEvents; use App\Support\PortfolioTriage\PortfolioArrivalContextToken; use App\Support\PortfolioTriage\TenantTriageReviewStateResolver; @@ -513,20 +514,16 @@ private static function handleVerifyConfigurationAction( ); $runUrl = OperationRunLinks::tenantlessView($result->run); + $notification = app(ProviderOperationStartResultPresenter::class)->notification( + result: $result, + blockedTitle: 'Verification blocked', + runUrl: $runUrl, + ); if ($result->status === 'scope_busy') { OpsUxBrowserEvents::dispatchRunEnqueued($livewire); - Notification::make() - ->title('Another operation is already running') - ->body('Please wait for the active operation to finish.') - ->warning() - ->actions([ - Actions\Action::make('view_run') - ->label(OperationRunLinks::openLabel()) - ->url($runUrl), - ]) - ->send(); + $notification->send(); return; } @@ -534,68 +531,20 @@ private static function handleVerifyConfigurationAction( if ($result->status === 'deduped') { OpsUxBrowserEvents::dispatchRunEnqueued($livewire); - OperationUxPresenter::alreadyQueuedToast((string) $result->run->type) - ->actions([ - Actions\Action::make('view_run') - ->label(OperationRunLinks::openLabel()) - ->url($runUrl), - ]) - ->send(); + $notification->send(); return; } if ($result->status === 'blocked') { - $actions = [ - Actions\Action::make('view_run') - ->label(OperationRunLinks::openLabel()) - ->url($runUrl), - ]; - - $nextSteps = $result->run->context['next_steps'] ?? []; - $nextSteps = is_array($nextSteps) ? $nextSteps : []; - - foreach ($nextSteps as $index => $step) { - if (! is_array($step)) { - continue; - } - - $label = is_string($step['label'] ?? null) ? trim((string) $step['label']) : ''; - $url = is_string($step['url'] ?? null) ? trim((string) $step['url']) : ''; - - if ($label === '' || $url === '') { - continue; - } - - $actions[] = Actions\Action::make('next_step_'.$index) - ->label($label) - ->url($url); - - break; - } - - $reasonEnvelope = app(\App\Support\ReasonTranslation\ReasonPresenter::class)->forOperationRun($result->run, 'notification'); - $bodyLines = $reasonEnvelope?->toBodyLines() ?? ['Blocked by provider configuration.']; - - Notification::make() - ->title('Verification blocked') - ->body(implode("\n", $bodyLines)) - ->warning() - ->actions($actions) - ->send(); + $notification->send(); return; } OpsUxBrowserEvents::dispatchRunEnqueued($livewire); - OperationUxPresenter::queuedToast((string) $result->run->type) - ->actions([ - Actions\Action::make('view_run') - ->label(OperationRunLinks::openLabel()) - ->url($runUrl), - ]) - ->send(); + $notification->send(); } private static function userCanManageAnyTenant(User $user): bool @@ -3319,29 +3268,14 @@ public static function syncRoleDefinitionsAction(): Actions\Action abort(403); } - $opRun = $syncService->startManualSync($record, $user); + $result = $syncService->startManualSync($record, $user); + $notification = app(ProviderOperationStartResultPresenter::class)->notification( + result: $result, + blockedTitle: 'Role definitions sync blocked', + runUrl: OperationRunLinks::tenantlessView($result->run), + ); - $runUrl = OperationRunLinks::tenantlessView($opRun); - - if (! $opRun->wasRecentlyCreated && in_array($opRun->status, ['queued', 'running'], true)) { - OperationUxPresenter::alreadyQueuedToast((string) $opRun->type) - ->actions([ - Actions\Action::make('view_run') - ->label(OperationRunLinks::openLabel()) - ->url($runUrl), - ]) - ->send(); - - return; - } - - OperationUxPresenter::queuedToast('directory_role_definitions.sync') - ->actions([ - Actions\Action::make('view_run') - ->label(OperationRunLinks::openLabel()) - ->url($runUrl), - ]) - ->send(); + $notification->send(); }); } } diff --git a/apps/platform/app/Filament/Widgets/Tenant/TenantVerificationReport.php b/apps/platform/app/Filament/Widgets/Tenant/TenantVerificationReport.php index 77c9b82d..dd17ba70 100644 --- a/apps/platform/app/Filament/Widgets/Tenant/TenantVerificationReport.php +++ b/apps/platform/app/Filament/Widgets/Tenant/TenantVerificationReport.php @@ -17,6 +17,7 @@ use App\Support\OperationRunLinks; use App\Support\OperationRunStatus; use App\Support\OpsUx\OperationUxPresenter; +use App\Support\OpsUx\ProviderOperationStartResultPresenter; use App\Support\OpsUx\OpsUxBrowserEvents; use Filament\Actions\Action; use Filament\Facades\Filament; @@ -71,20 +72,16 @@ public function startVerification(StartVerification $verification): void ); $runUrl = OperationRunLinks::tenantlessView($result->run); + $notification = app(ProviderOperationStartResultPresenter::class)->notification( + result: $result, + blockedTitle: 'Verification blocked', + runUrl: $runUrl, + ); if ($result->status === 'scope_busy') { OpsUxBrowserEvents::dispatchRunEnqueued($this); - Notification::make() - ->title('Another operation is already running') - ->body('Please wait for the active operation to finish.') - ->warning() - ->actions([ - Action::make('view_run') - ->label(OperationRunLinks::openLabel()) - ->url($runUrl), - ]) - ->send(); + $notification->send(); return; } @@ -92,72 +89,20 @@ public function startVerification(StartVerification $verification): void if ($result->status === 'deduped') { OpsUxBrowserEvents::dispatchRunEnqueued($this); - OperationUxPresenter::alreadyQueuedToast((string) $result->run->type) - ->actions([ - Action::make('view_run') - ->label(OperationRunLinks::openLabel()) - ->url($runUrl), - ]) - ->send(); + $notification->send(); return; } if ($result->status === 'blocked') { - $reasonCode = is_string($result->run->context['reason_code'] ?? null) - ? (string) $result->run->context['reason_code'] - : 'unknown_error'; - - $actions = [ - Action::make('view_run') - ->label(OperationRunLinks::openLabel()) - ->url($runUrl), - ]; - - $nextSteps = $result->run->context['next_steps'] ?? []; - $nextSteps = is_array($nextSteps) ? $nextSteps : []; - - foreach ($nextSteps as $index => $step) { - if (! is_array($step)) { - continue; - } - - $label = is_string($step['label'] ?? null) ? trim((string) $step['label']) : ''; - $url = is_string($step['url'] ?? null) ? trim((string) $step['url']) : ''; - - if ($label === '' || $url === '') { - continue; - } - - $actions[] = Action::make('next_step_'.$index) - ->label($label) - ->url($url); - - break; - } - - $reasonEnvelope = app(\App\Support\ReasonTranslation\ReasonPresenter::class)->forOperationRun($result->run, 'notification'); - $bodyLines = $reasonEnvelope?->toBodyLines() ?? ['Blocked by provider configuration.']; - - Notification::make() - ->title('Verification blocked') - ->body(implode("\n", $bodyLines)) - ->warning() - ->actions($actions) - ->send(); + $notification->send(); return; } OpsUxBrowserEvents::dispatchRunEnqueued($this); - OperationUxPresenter::queuedToast((string) $result->run->type) - ->actions([ - Action::make('view_run') - ->label(OperationRunLinks::openLabel()) - ->url($runUrl), - ]) - ->send(); + $notification->send(); } /** diff --git a/apps/platform/app/Http/Middleware/EnsureWorkspaceSelected.php b/apps/platform/app/Http/Middleware/EnsureWorkspaceSelected.php index 4bcf3671..ca532fe2 100644 --- a/apps/platform/app/Http/Middleware/EnsureWorkspaceSelected.php +++ b/apps/platform/app/Http/Middleware/EnsureWorkspaceSelected.php @@ -121,6 +121,10 @@ public function handle(Request $request, Closure $next): Response reason: 'single_membership', ); + if ($this->requestHasExplicitTenantContext($request)) { + return $next($request); + } + return $this->redirectViaTenantBranching($workspace, $user); } } @@ -144,6 +148,10 @@ public function handle(Request $request, Closure $next): Response reason: 'last_used', ); + if ($this->requestHasExplicitTenantContext($request)) { + return $next($request); + } + return $this->redirectViaTenantBranching($lastWorkspace, $user); } @@ -203,6 +211,17 @@ private function isChooserFirstPath(string $path): bool return in_array($path, ['/admin', '/admin/choose-tenant'], true); } + private function requestHasExplicitTenantContext(Request $request): bool + { + if (filled($request->query('tenant')) || filled($request->query('tenant_id'))) { + return true; + } + + $route = $request->route(); + + return $route?->hasParameter('tenant') && filled($route->parameter('tenant')); + } + private function redirectToChooser(): Response { return new \Illuminate\Http\Response('', 302, ['Location' => '/admin/choose-workspace']); diff --git a/apps/platform/app/Jobs/AddPoliciesToBackupSetJob.php b/apps/platform/app/Jobs/AddPoliciesToBackupSetJob.php index aad1f639..309f9a23 100644 --- a/apps/platform/app/Jobs/AddPoliciesToBackupSetJob.php +++ b/apps/platform/app/Jobs/AddPoliciesToBackupSetJob.php @@ -31,6 +31,10 @@ class AddPoliciesToBackupSetJob implements ShouldQueue { use Dispatchable, InteractsWithQueue, Queueable, SerializesModels; + public int $timeout = 240; + + public bool $failOnTimeout = true; + public ?OperationRun $operationRun = null; /** diff --git a/apps/platform/app/Jobs/EntraGroupSyncJob.php b/apps/platform/app/Jobs/EntraGroupSyncJob.php index 395dc75e..5dbac095 100644 --- a/apps/platform/app/Jobs/EntraGroupSyncJob.php +++ b/apps/platform/app/Jobs/EntraGroupSyncJob.php @@ -31,6 +31,7 @@ public function __construct( public string $selectionKey, public ?string $slotKey = null, public ?int $runId = null, + public ?int $providerConnectionId = null, ?OperationRun $operationRun = null ) { $this->operationRun = $operationRun; @@ -74,7 +75,7 @@ public function handle(EntraGroupSyncService $syncService, AuditLogger $auditLog resourceId: (string) $this->operationRun->getKey(), ); - $result = $syncService->sync($tenant, $this->selectionKey); + $result = $syncService->sync($tenant, $this->selectionKey, $this->providerConnectionId()); $terminalStatus = 'succeeded'; @@ -133,4 +134,16 @@ public function handle(EntraGroupSyncService $syncService, AuditLogger $auditLog resourceId: (string) $this->operationRun->getKey(), ); } + + private function providerConnectionId(): ?int + { + if (is_int($this->providerConnectionId) && $this->providerConnectionId > 0) { + return $this->providerConnectionId; + } + + $context = is_array($this->operationRun?->context ?? null) ? $this->operationRun->context : []; + $providerConnectionId = $context['provider_connection_id'] ?? null; + + return is_numeric($providerConnectionId) ? (int) $providerConnectionId : null; + } } diff --git a/apps/platform/app/Jobs/ExecuteRestoreRunJob.php b/apps/platform/app/Jobs/ExecuteRestoreRunJob.php index 5026d18a..4dea0f5f 100644 --- a/apps/platform/app/Jobs/ExecuteRestoreRunJob.php +++ b/apps/platform/app/Jobs/ExecuteRestoreRunJob.php @@ -36,6 +36,7 @@ public function __construct( public ?string $actorEmail = null, public ?string $actorName = null, ?OperationRun $operationRun = null, + public ?int $providerConnectionId = null, ) { $this->operationRun = $operationRun; } @@ -160,12 +161,15 @@ public function handle(RestoreService $restoreService, AuditLogger $auditLogger) ); try { + $providerConnectionId = $this->providerConnectionId(); + $restoreService->executeForRun( restoreRun: $restoreRun, tenant: $tenant, backupSet: $backupSet, actorEmail: $this->actorEmail, actorName: $this->actorName, + providerConnectionId: $providerConnectionId, ); app(SyncRestoreRunToOperationRun::class)->handle($restoreRun->refresh()); @@ -207,4 +211,16 @@ public function handle(RestoreService $restoreService, AuditLogger $auditLogger) throw $throwable; } } + + private function providerConnectionId(): ?int + { + if (is_int($this->providerConnectionId) && $this->providerConnectionId > 0) { + return $this->providerConnectionId; + } + + $context = is_array($this->operationRun?->context ?? null) ? $this->operationRun->context : []; + $providerConnectionId = $context['provider_connection_id'] ?? null; + + return is_numeric($providerConnectionId) ? (int) $providerConnectionId : null; + } } diff --git a/apps/platform/app/Jobs/RemovePoliciesFromBackupSetJob.php b/apps/platform/app/Jobs/RemovePoliciesFromBackupSetJob.php index c5ad5260..c39a01c7 100644 --- a/apps/platform/app/Jobs/RemovePoliciesFromBackupSetJob.php +++ b/apps/platform/app/Jobs/RemovePoliciesFromBackupSetJob.php @@ -22,6 +22,10 @@ class RemovePoliciesFromBackupSetJob implements ShouldQueue { use Dispatchable, InteractsWithQueue, Queueable, SerializesModels; + public int $timeout = 240; + + public bool $failOnTimeout = true; + public ?OperationRun $operationRun = null; /** diff --git a/apps/platform/app/Jobs/SyncRoleDefinitionsJob.php b/apps/platform/app/Jobs/SyncRoleDefinitionsJob.php index d3b824d3..9028db3a 100644 --- a/apps/platform/app/Jobs/SyncRoleDefinitionsJob.php +++ b/apps/platform/app/Jobs/SyncRoleDefinitionsJob.php @@ -31,6 +31,7 @@ class SyncRoleDefinitionsJob implements ShouldQueue */ public function __construct( public int $tenantId, + public ?int $providerConnectionId = null, ?OperationRun $operationRun = null, ) { $this->operationRun = $operationRun; @@ -69,7 +70,7 @@ public function handle(RoleDefinitionsSyncService $syncService, AuditLogger $aud resourceId: (string) $this->operationRun->getKey(), ); - $result = $syncService->sync($tenant); + $result = $syncService->sync($tenant, $this->providerConnectionId()); /** @var OperationRunService $opService */ $opService = app(OperationRunService::class); @@ -124,4 +125,16 @@ public function handle(RoleDefinitionsSyncService $syncService, AuditLogger $aud resourceId: (string) $this->operationRun->getKey(), ); } + + private function providerConnectionId(): ?int + { + if (is_int($this->providerConnectionId) && $this->providerConnectionId > 0) { + return $this->providerConnectionId; + } + + $context = is_array($this->operationRun?->context ?? null) ? $this->operationRun->context : []; + $providerConnectionId = $context['provider_connection_id'] ?? null; + + return is_numeric($providerConnectionId) ? (int) $providerConnectionId : null; + } } diff --git a/apps/platform/app/Livewire/BackupSetPolicyPickerTable.php b/apps/platform/app/Livewire/BackupSetPolicyPickerTable.php index 1f7525ec..39c433db 100644 --- a/apps/platform/app/Livewire/BackupSetPolicyPickerTable.php +++ b/apps/platform/app/Livewire/BackupSetPolicyPickerTable.php @@ -293,7 +293,7 @@ public function table(Table $table): Table $opService = app(OperationRunService::class); $opRun = $opService->enqueueBulkOperation( tenant: $tenant, - type: 'backup_set.add_policies', + type: 'backup_set.update', targetScope: [ 'entra_tenant_id' => (string) ($tenant->tenant_id ?? $tenant->external_id), ], diff --git a/apps/platform/app/Livewire/InventoryItemDependencyEdgesTable.php b/apps/platform/app/Livewire/InventoryItemDependencyEdgesTable.php index 27d718d7..48603c5a 100644 --- a/apps/platform/app/Livewire/InventoryItemDependencyEdgesTable.php +++ b/apps/platform/app/Livewire/InventoryItemDependencyEdgesTable.php @@ -4,13 +4,16 @@ namespace App\Livewire; -use App\Filament\Resources\InventoryItemResource; use App\Models\InventoryItem; use App\Models\Tenant; +use App\Models\User; +use App\Services\Auth\CapabilityResolver; use App\Services\Inventory\DependencyQueryService; use App\Services\Inventory\DependencyTargets\DependencyTargetResolver; +use App\Support\Auth\Capabilities; use App\Support\Enums\RelationshipType; use App\Support\Filament\TablePaginationProfiles; +use App\Support\Workspaces\WorkspaceContext; use Filament\Facades\Filament; use Filament\Tables\Columns\TextColumn; use Filament\Tables\Filters\SelectFilter; @@ -235,7 +238,7 @@ private function resolveInventoryItem(): InventoryItem $inventoryItem = InventoryItem::query()->findOrFail($this->inventoryItemId); $tenant = $this->resolveCurrentTenant(); - if ((int) $inventoryItem->tenant_id !== (int) $tenant->getKey() || ! InventoryItemResource::canView($inventoryItem)) { + if (! $this->canViewInventoryItem($inventoryItem, $tenant)) { throw new NotFoundHttpException; } @@ -246,6 +249,10 @@ private function resolveCurrentTenant(): Tenant { $tenant = Filament::getTenant(); + if (! $tenant instanceof Tenant) { + $tenant = app(WorkspaceContext::class)->rememberedTenant(request()); + } + if (! $tenant instanceof Tenant) { throw new NotFoundHttpException; } @@ -253,6 +260,21 @@ private function resolveCurrentTenant(): Tenant return $tenant; } + private function canViewInventoryItem(InventoryItem $inventoryItem, Tenant $tenant): bool + { + $user = auth()->user(); + + if (! $user instanceof User) { + return false; + } + + $capabilityResolver = app(CapabilityResolver::class); + + return (int) $inventoryItem->tenant_id === (int) $tenant->getKey() + && $capabilityResolver->isMember($user, $tenant) + && $capabilityResolver->can($user, $tenant, Capabilities::TENANT_VIEW); + } + private function normalizeRelationshipType(mixed $value): ?string { if (! is_string($value) || $value === '' || $value === 'all') { @@ -261,4 +283,4 @@ private function normalizeRelationshipType(mixed $value): ?string return RelationshipType::tryFrom($value)?->value; } -} \ No newline at end of file +} diff --git a/apps/platform/app/Livewire/PolicyVersionAssignmentsWidget.php b/apps/platform/app/Livewire/PolicyVersionAssignmentsWidget.php index 30e13dd3..e904862d 100644 --- a/apps/platform/app/Livewire/PolicyVersionAssignmentsWidget.php +++ b/apps/platform/app/Livewire/PolicyVersionAssignmentsWidget.php @@ -41,6 +41,14 @@ private function assignmentReferences(): array $tenant = rescue(fn () => Tenant::current(), null); + if (! $tenant instanceof Tenant) { + $this->version->loadMissing('tenant'); + + $tenant = $this->version->tenant instanceof Tenant + ? $this->version->tenant + : null; + } + $groupIds = []; $sourceNames = []; diff --git a/apps/platform/app/Models/Finding.php b/apps/platform/app/Models/Finding.php index 3df02031..955434ba 100644 --- a/apps/platform/app/Models/Finding.php +++ b/apps/platform/app/Models/Finding.php @@ -47,6 +47,12 @@ class Finding extends Model public const string STATUS_RISK_ACCEPTED = 'risk_accepted'; + public const string RESPONSIBILITY_STATE_ORPHANED_ACCOUNTABILITY = 'orphaned_accountability'; + + public const string RESPONSIBILITY_STATE_OWNED_UNASSIGNED = 'owned_unassigned'; + + public const string RESPONSIBILITY_STATE_ASSIGNED = 'assigned'; + protected $guarded = []; protected $casts = [ @@ -246,6 +252,33 @@ public function resolvedSubjectDisplayName(): ?string return $fallback !== '' ? $fallback : null; } + public function responsibilityState(): string + { + if ($this->owner_user_id === null) { + return self::RESPONSIBILITY_STATE_ORPHANED_ACCOUNTABILITY; + } + + if ($this->assignee_user_id === null) { + return self::RESPONSIBILITY_STATE_OWNED_UNASSIGNED; + } + + return self::RESPONSIBILITY_STATE_ASSIGNED; + } + + public function hasAccountabilityGap(): bool + { + return $this->responsibilityState() === self::RESPONSIBILITY_STATE_ORPHANED_ACCOUNTABILITY; + } + + public function responsibilityStateLabel(): string + { + return match ($this->responsibilityState()) { + self::RESPONSIBILITY_STATE_ORPHANED_ACCOUNTABILITY => 'orphaned accountability', + self::RESPONSIBILITY_STATE_OWNED_UNASSIGNED => 'owned but unassigned', + default => 'assigned', + }; + } + public function scopeWithSubjectDisplayName(Builder $query): Builder { return $query->addSelect([ diff --git a/apps/platform/app/Providers/Filament/AdminPanelProvider.php b/apps/platform/app/Providers/Filament/AdminPanelProvider.php index 59095b4e..52d293f1 100644 --- a/apps/platform/app/Providers/Filament/AdminPanelProvider.php +++ b/apps/platform/app/Providers/Filament/AdminPanelProvider.php @@ -5,6 +5,7 @@ use App\Filament\Pages\Auth\Login; use App\Filament\Pages\ChooseTenant; use App\Filament\Pages\ChooseWorkspace; +use App\Filament\Pages\Findings\MyFindingsInbox; use App\Filament\Pages\InventoryCoverage; use App\Filament\Pages\Monitoring\FindingExceptionsQueue; use App\Filament\Pages\NoAccess; @@ -176,6 +177,7 @@ public function panel(Panel $panel): Panel InventoryCoverage::class, TenantRequiredPermissions::class, WorkspaceSettings::class, + MyFindingsInbox::class, FindingExceptionsQueue::class, ReviewRegister::class, ]) diff --git a/apps/platform/app/Services/Baselines/SnapshotRendering/BaselineSnapshotPresenter.php b/apps/platform/app/Services/Baselines/SnapshotRendering/BaselineSnapshotPresenter.php index 7aae6bc3..1a3c5055 100644 --- a/apps/platform/app/Services/Baselines/SnapshotRendering/BaselineSnapshotPresenter.php +++ b/apps/platform/app/Services/Baselines/SnapshotRendering/BaselineSnapshotPresenter.php @@ -225,6 +225,7 @@ public function presentEnterpriseDetail(BaselineSnapshot $snapshot, array $relat title: 'Coverage summary', view: 'filament.infolists.entries.baseline-snapshot-summary-table', viewData: ['rows' => $rendered->summaryRows], + description: $rendered->fidelitySummary, emptyState: $factory->emptyState('No captured governed subjects are available in this snapshot.'), ), $factory->viewSection( @@ -262,6 +263,7 @@ public function presentEnterpriseDetail(BaselineSnapshot $snapshot, array $relat title: 'Coverage', items: [ $factory->keyFact('Overall fidelity', $fidelitySpec->label, badge: $fidelityBadge), + $factory->keyFact('Fidelity mix', $rendered->fidelitySummary), $factory->keyFact('Evidence gaps', $rendered->overallGapCount), $factory->keyFact('Captured items', $capturedItemCount), ], diff --git a/apps/platform/app/Services/Directory/EntraGroupSyncService.php b/apps/platform/app/Services/Directory/EntraGroupSyncService.php index 2bce61b3..070f908e 100644 --- a/apps/platform/app/Services/Directory/EntraGroupSyncService.php +++ b/apps/platform/app/Services/Directory/EntraGroupSyncService.php @@ -10,8 +10,10 @@ use App\Services\Graph\GraphClientInterface; use App\Services\Graph\GraphContractRegistry; use App\Services\Graph\GraphResponse; -use App\Services\OperationRunService; use App\Services\Providers\MicrosoftGraphOptionsResolver; +use App\Services\Providers\ProviderOperationStartGate; +use App\Services\Providers\ProviderOperationStartResult; +use App\Support\Auth\Capabilities; use Carbon\CarbonImmutable; class EntraGroupSyncService @@ -20,38 +22,38 @@ public function __construct( private readonly GraphClientInterface $graph, private readonly GraphContractRegistry $contracts, private readonly MicrosoftGraphOptionsResolver $graphOptionsResolver, + private readonly ProviderOperationStartGate $providerStarts, ) {} - public function startManualSync(Tenant $tenant, User $user): OperationRun + public function startManualSync(Tenant $tenant, User $user): ProviderOperationStartResult { $selectionKey = EntraGroupSelection::allGroupsV1(); - /** @var OperationRunService $opService */ - $opService = app(OperationRunService::class); - $opRun = $opService->ensureRunWithIdentity( + return $this->providerStarts->start( tenant: $tenant, - type: 'entra_group_sync', - identityInputs: ['selection_key' => $selectionKey], - context: [ + connection: null, + operationType: 'entra_group_sync', + dispatcher: function (OperationRun $run) use ($tenant, $selectionKey): void { + $providerConnectionId = is_numeric($run->context['provider_connection_id'] ?? null) + ? (int) $run->context['provider_connection_id'] + : null; + + EntraGroupSyncJob::dispatch( + tenantId: (int) $tenant->getKey(), + selectionKey: $selectionKey, + slotKey: null, + runId: null, + providerConnectionId: $providerConnectionId, + operationRun: $run, + )->afterCommit(); + }, + initiator: $user, + extraContext: [ 'selection_key' => $selectionKey, 'trigger' => 'manual', + 'required_capability' => Capabilities::TENANT_SYNC, ], - initiator: $user, ); - - if (! $opRun->wasRecentlyCreated && in_array($opRun->status, ['queued', 'running'], true)) { - return $opRun; - } - - dispatch(new EntraGroupSyncJob( - tenantId: (int) $tenant->getKey(), - selectionKey: $selectionKey, - slotKey: null, - runId: null, - operationRun: $opRun, - )); - - return $opRun; } /** @@ -67,7 +69,7 @@ public function startManualSync(Tenant $tenant, User $user): OperationRun * error_summary:?string * } */ - public function sync(Tenant $tenant, string $selectionKey): array + public function sync(Tenant $tenant, string $selectionKey, ?int $providerConnectionId = null): array { $nowUtc = CarbonImmutable::now('UTC'); @@ -105,7 +107,9 @@ public function sync(Tenant $tenant, string $selectionKey): array $errorSummary = null; $errorCount = 0; - $options = $this->graphOptionsResolver->resolveForTenant($tenant); + $options = $providerConnectionId !== null + ? $this->graphOptionsResolver->resolveForConnection($tenant, $providerConnectionId) + : $this->graphOptionsResolver->resolveForTenant($tenant); $useQuery = $query; $nextPath = $path; diff --git a/apps/platform/app/Services/Directory/RoleDefinitionsSyncService.php b/apps/platform/app/Services/Directory/RoleDefinitionsSyncService.php index 2234890f..387dadfa 100644 --- a/apps/platform/app/Services/Directory/RoleDefinitionsSyncService.php +++ b/apps/platform/app/Services/Directory/RoleDefinitionsSyncService.php @@ -10,8 +10,10 @@ use App\Services\Graph\GraphClientInterface; use App\Services\Graph\GraphContractRegistry; use App\Services\Graph\GraphResponse; -use App\Services\OperationRunService; use App\Services\Providers\MicrosoftGraphOptionsResolver; +use App\Services\Providers\ProviderOperationStartGate; +use App\Services\Providers\ProviderOperationStartResult; +use App\Support\Auth\Capabilities; use Carbon\CarbonImmutable; class RoleDefinitionsSyncService @@ -20,36 +22,35 @@ public function __construct( private readonly GraphClientInterface $graph, private readonly GraphContractRegistry $contracts, private readonly MicrosoftGraphOptionsResolver $graphOptionsResolver, + private readonly ProviderOperationStartGate $providerStarts, ) {} - public function startManualSync(Tenant $tenant, User $user): OperationRun + public function startManualSync(Tenant $tenant, User $user): ProviderOperationStartResult { $selectionKey = 'role_definitions_v1'; - /** @var OperationRunService $opService */ - $opService = app(OperationRunService::class); - - $opRun = $opService->ensureRunWithIdentity( + return $this->providerStarts->start( tenant: $tenant, - type: 'directory_role_definitions.sync', - identityInputs: ['selection_key' => $selectionKey], - context: [ + connection: null, + operationType: 'directory_role_definitions.sync', + dispatcher: function (OperationRun $run) use ($tenant): void { + $providerConnectionId = is_numeric($run->context['provider_connection_id'] ?? null) + ? (int) $run->context['provider_connection_id'] + : null; + + SyncRoleDefinitionsJob::dispatch( + tenantId: (int) $tenant->getKey(), + providerConnectionId: $providerConnectionId, + operationRun: $run, + )->afterCommit(); + }, + initiator: $user, + extraContext: [ 'selection_key' => $selectionKey, 'trigger' => 'manual', + 'required_capability' => Capabilities::TENANT_MANAGE, ], - initiator: $user, ); - - if (! $opRun->wasRecentlyCreated && in_array($opRun->status, ['queued', 'running'], true)) { - return $opRun; - } - - dispatch(new SyncRoleDefinitionsJob( - tenantId: (int) $tenant->getKey(), - operationRun: $opRun, - )); - - return $opRun; } /** @@ -65,7 +66,7 @@ public function startManualSync(Tenant $tenant, User $user): OperationRun * error_summary:?string * } */ - public function sync(Tenant $tenant): array + public function sync(Tenant $tenant, ?int $providerConnectionId = null): array { $nowUtc = CarbonImmutable::now('UTC'); @@ -103,7 +104,9 @@ public function sync(Tenant $tenant): array $errorSummary = null; $errorCount = 0; - $options = $this->graphOptionsResolver->resolveForTenant($tenant); + $options = $providerConnectionId !== null + ? $this->graphOptionsResolver->resolveForConnection($tenant, $providerConnectionId) + : $this->graphOptionsResolver->resolveForTenant($tenant); $useQuery = $query; $nextPath = $path; diff --git a/apps/platform/app/Services/Findings/FindingExceptionService.php b/apps/platform/app/Services/Findings/FindingExceptionService.php index ce55fd15..cace742b 100644 --- a/apps/platform/app/Services/Findings/FindingExceptionService.php +++ b/apps/platform/app/Services/Findings/FindingExceptionService.php @@ -651,16 +651,18 @@ private function assertFindingOwnedByTenant(Finding $finding, Tenant $tenant): v private function validatedTenantMemberId(Tenant $tenant, mixed $userId, string $field, bool $required = false): ?int { + $label = $this->fieldLabel($field); + if ($userId === null || $userId === '') { if ($required) { - throw new InvalidArgumentException(sprintf('%s is required.', $field)); + throw new InvalidArgumentException(sprintf('%s is required.', $label)); } return null; } if (! is_numeric($userId) || (int) $userId <= 0) { - throw new InvalidArgumentException(sprintf('%s must reference a valid user.', $field)); + throw new InvalidArgumentException(sprintf('%s must reference a valid user.', $label)); } $resolvedUserId = (int) $userId; @@ -671,7 +673,7 @@ private function validatedTenantMemberId(Tenant $tenant, mixed $userId, string $ ->exists(); if (! $isMember) { - throw new InvalidArgumentException(sprintf('%s must reference a current tenant member.', $field)); + throw new InvalidArgumentException(sprintf('%s must reference a current tenant member.', $label)); } return $resolvedUserId; @@ -679,18 +681,20 @@ private function validatedTenantMemberId(Tenant $tenant, mixed $userId, string $ private function validatedReason(mixed $reason, string $field): string { + $label = $this->fieldLabel($field); + if (! is_string($reason)) { - throw new InvalidArgumentException(sprintf('%s is required.', $field)); + throw new InvalidArgumentException(sprintf('%s is required.', $label)); } $resolved = trim($reason); if ($resolved === '') { - throw new InvalidArgumentException(sprintf('%s is required.', $field)); + throw new InvalidArgumentException(sprintf('%s is required.', $label)); } if (mb_strlen($resolved) > 2000) { - throw new InvalidArgumentException(sprintf('%s must be at most 2000 characters.', $field)); + throw new InvalidArgumentException(sprintf('%s must be at most 2000 characters.', $label)); } return $resolved; @@ -698,10 +702,12 @@ private function validatedReason(mixed $reason, string $field): string private function validatedDate(mixed $value, string $field): CarbonImmutable { + $label = $this->fieldLabel($field); + try { return CarbonImmutable::parse((string) $value); } catch (\Throwable) { - throw new InvalidArgumentException(sprintf('%s must be a valid date-time.', $field)); + throw new InvalidArgumentException(sprintf('%s must be a valid date-time.', $label)); } } @@ -710,7 +716,7 @@ private function validatedFutureDate(mixed $value, string $field): CarbonImmutab $date = $this->validatedDate($value, $field); if ($date->lessThanOrEqualTo(CarbonImmutable::now())) { - throw new InvalidArgumentException(sprintf('%s must be in the future.', $field)); + throw new InvalidArgumentException(sprintf('%s must be in the future.', $this->fieldLabel($field))); } return $date; @@ -735,6 +741,21 @@ private function validatedOptionalExpiry(mixed $value, CarbonImmutable $minimum, return $expiresAt; } + private function fieldLabel(string $field): string + { + return match ($field) { + 'owner_user_id' => 'Exception owner', + 'request_reason' => 'Request reason', + 'review_due_at' => 'Review due at', + 'approval_reason' => 'Approval reason', + 'rejection_reason' => 'Rejection reason', + 'revocation_reason' => 'Revocation reason', + 'effective_from' => 'Effective from', + 'expires_at' => 'Expires at', + default => str_replace('_', ' ', $field), + }; + } + /** * @return list 'Closed is a historical workflow state. It does not prove the issue is permanently gone.', default => 'This finding is historical workflow context.', }, - default => 'This finding is still active workflow work and should be reviewed until it is resolved, closed, or formally governed.', + default => match ($finding->responsibilityState()) { + Finding::RESPONSIBILITY_STATE_ORPHANED_ACCOUNTABILITY => 'This finding is still active workflow work and currently has orphaned accountability.', + Finding::RESPONSIBILITY_STATE_OWNED_UNASSIGNED => 'This finding has an accountable owner, but the active remediation work is still unassigned.', + default => 'This finding is still active workflow work with accountable ownership and an active assignee.', + }, }; } public function resolvePrimaryNextAction(Finding $finding, ?FindingException $exception = null, ?CarbonImmutable $now = null): ?string { if ($finding->hasOpenStatus() && $finding->due_at?->isPast() === true) { - return 'Review the overdue finding and update ownership or next workflow step.'; + return match ($finding->responsibilityState()) { + Finding::RESPONSIBILITY_STATE_ORPHANED_ACCOUNTABILITY => 'Review the overdue finding, set an accountable owner, and confirm the next workflow step.', + Finding::RESPONSIBILITY_STATE_OWNED_UNASSIGNED => 'Review the overdue finding and assign the active remediation work or the next workflow step.', + default => 'Review the overdue finding and confirm the next workflow step.', + }; } if ($this->resolveWorkflowFamily($finding) === 'accepted_risk') { @@ -249,11 +257,11 @@ public function resolvePrimaryNextAction(Finding $finding, ?FindingException $ex return 'Review the closure context and reopen the finding if the issue has returned or governance has lapsed.'; } - if ($finding->assignee_user_id === null || $finding->owner_user_id === null) { - return 'Assign an owner and next workflow step so follow-up does not stall.'; - } - - return 'Review the current workflow state and decide whether to progress, resolve, close, or request governance coverage.'; + return match ($finding->responsibilityState()) { + Finding::RESPONSIBILITY_STATE_ORPHANED_ACCOUNTABILITY => 'Set an accountable owner so follow-up does not stall, even if remediation work is already assigned.', + Finding::RESPONSIBILITY_STATE_OWNED_UNASSIGNED => 'An accountable owner is set. Assign the active remediation work or record the next workflow step.', + default => 'Review the current workflow state and decide whether to progress, resolve, close, or request governance coverage.', + }; } public function syncExceptionState(FindingException $exception, ?CarbonImmutable $now = null): FindingException diff --git a/apps/platform/app/Services/Findings/FindingWorkflowService.php b/apps/platform/app/Services/Findings/FindingWorkflowService.php index b2e93140..10168bc5 100644 --- a/apps/platform/app/Services/Findings/FindingWorkflowService.php +++ b/apps/platform/app/Services/Findings/FindingWorkflowService.php @@ -110,6 +110,19 @@ public function assign( $this->assertTenantMemberOrNull($tenant, $assigneeUserId, 'assignee_user_id'); $this->assertTenantMemberOrNull($tenant, $ownerUserId, 'owner_user_id'); + $changeClassification = $this->responsibilityChangeClassification( + beforeOwnerUserId: is_numeric($finding->owner_user_id) ? (int) $finding->owner_user_id : null, + beforeAssigneeUserId: is_numeric($finding->assignee_user_id) ? (int) $finding->assignee_user_id : null, + afterOwnerUserId: $ownerUserId, + afterAssigneeUserId: $assigneeUserId, + ); + $changeSummary = $this->responsibilityChangeSummary( + beforeOwnerUserId: is_numeric($finding->owner_user_id) ? (int) $finding->owner_user_id : null, + beforeAssigneeUserId: is_numeric($finding->assignee_user_id) ? (int) $finding->assignee_user_id : null, + afterOwnerUserId: $ownerUserId, + afterAssigneeUserId: $assigneeUserId, + ); + return $this->mutateAndAudit( finding: $finding, tenant: $tenant, @@ -119,6 +132,8 @@ public function assign( 'metadata' => [ 'assignee_user_id' => $assigneeUserId, 'owner_user_id' => $ownerUserId, + 'responsibility_change_classification' => $changeClassification, + 'responsibility_change_summary' => $changeSummary, ], ], mutate: function (Finding $record) use ($assigneeUserId, $ownerUserId): void { @@ -128,6 +143,55 @@ public function assign( ); } + public function responsibilityChangeClassification( + ?int $beforeOwnerUserId, + ?int $beforeAssigneeUserId, + ?int $afterOwnerUserId, + ?int $afterAssigneeUserId, + ): ?string { + $ownerChanged = $beforeOwnerUserId !== $afterOwnerUserId; + $assigneeChanged = $beforeAssigneeUserId !== $afterAssigneeUserId; + + if ($ownerChanged && $assigneeChanged) { + return 'owner_and_assignee'; + } + + if ($ownerChanged) { + return $afterOwnerUserId === null + ? 'clear_owner' + : 'owner_only'; + } + + if ($assigneeChanged) { + return $afterAssigneeUserId === null + ? 'clear_assignee' + : 'assignee_only'; + } + + return null; + } + + public function responsibilityChangeSummary( + ?int $beforeOwnerUserId, + ?int $beforeAssigneeUserId, + ?int $afterOwnerUserId, + ?int $afterAssigneeUserId, + ): string { + return match ($this->responsibilityChangeClassification( + beforeOwnerUserId: $beforeOwnerUserId, + beforeAssigneeUserId: $beforeAssigneeUserId, + afterOwnerUserId: $afterOwnerUserId, + afterAssigneeUserId: $afterAssigneeUserId, + )) { + 'owner_only' => 'Updated the accountable owner and kept the active assignee unchanged.', + 'assignee_only' => 'Updated the active assignee and kept the accountable owner unchanged.', + 'owner_and_assignee' => 'Updated the accountable owner and the active assignee.', + 'clear_owner' => 'Cleared the accountable owner and kept the active assignee unchanged.', + 'clear_assignee' => 'Cleared the active assignee and kept the accountable owner unchanged.', + default => 'No responsibility changes were needed.', + }; + } + public function resolve(Finding $finding, Tenant $tenant, User $actor, string $reason): Finding { $this->authorize($finding, $tenant, $actor, [Capabilities::TENANT_FINDINGS_RESOLVE]); diff --git a/apps/platform/app/Services/Intune/RestoreService.php b/apps/platform/app/Services/Intune/RestoreService.php index 5d3bee04..f8b08ae8 100644 --- a/apps/platform/app/Services/Intune/RestoreService.php +++ b/apps/platform/app/Services/Intune/RestoreService.php @@ -236,6 +236,7 @@ public function executeForRun( BackupSet $backupSet, ?string $actorEmail = null, ?string $actorName = null, + ?int $providerConnectionId = null, ): RestoreRun { $this->assertActiveContext($tenant, $backupSet); @@ -266,6 +267,7 @@ public function executeForRun( actorName: $actorName, groupMapping: $restoreRun->group_mapping ?? [], existingRun: $restoreRun, + providerConnectionId: $providerConnectionId, ); } @@ -286,6 +288,7 @@ public function execute( ?string $actorName = null, array $groupMapping = [], ?RestoreRun $existingRun = null, + ?int $providerConnectionId = null, ): RestoreRun { $this->assertActiveContext($tenant, $backupSet); @@ -297,7 +300,7 @@ public function execute( $baseGraphOptions = []; if (! $dryRun) { - $connection = $this->resolveProviderConnection($tenant); + $connection = $this->resolveProviderConnection($tenant, $providerConnectionId); $tenantIdentifier = (string) $connection->entra_tenant_id; $baseGraphOptions = $this->providerGateway()->graphOptions($connection); } @@ -2910,9 +2913,23 @@ private function buildScopeTagsForVersion( ]; } - private function resolveProviderConnection(Tenant $tenant): ProviderConnection + private function resolveProviderConnection(Tenant $tenant, ?int $providerConnectionId = null): ProviderConnection { - $resolution = $this->providerConnections()->resolveDefault($tenant, 'microsoft'); + if ($providerConnectionId !== null) { + $connection = ProviderConnection::query()->find($providerConnectionId); + + if (! $connection instanceof ProviderConnection) { + throw new RuntimeException(sprintf( + '[%s] %s', + ProviderReasonCodes::ProviderConnectionInvalid, + 'Provider connection is not configured.', + )); + } + + $resolution = $this->providerConnections()->validateConnection($tenant, 'microsoft', $connection); + } else { + $resolution = $this->providerConnections()->resolveDefault($tenant, 'microsoft'); + } if ($resolution->resolved && $resolution->connection instanceof ProviderConnection) { return $resolution->connection; diff --git a/apps/platform/app/Services/Inventory/DependencyTargets/DependencyTargetDto.php b/apps/platform/app/Services/Inventory/DependencyTargets/DependencyTargetDto.php index 00a27bdf..52a38ff1 100644 --- a/apps/platform/app/Services/Inventory/DependencyTargets/DependencyTargetDto.php +++ b/apps/platform/app/Services/Inventory/DependencyTargets/DependencyTargetDto.php @@ -72,7 +72,7 @@ public static function unresolvedFoundation(string $label, string $foundationTyp public static function resolvedFoundation(string $label, string $foundationType, string $targetId, string $displayName, ?int $inventoryItemId, Tenant $tenant): self { $maskedId = static::mask($targetId); - $url = $inventoryItemId ? InventoryItemResource::getUrl('view', ['record' => $inventoryItemId], tenant: $tenant) : null; + $url = $inventoryItemId ? InventoryItemResource::getUrl('view', ['record' => $inventoryItemId], panel: 'tenant', tenant: $tenant) : null; return new self( targetLabel: $label, diff --git a/apps/platform/app/Services/Providers/MicrosoftGraphOptionsResolver.php b/apps/platform/app/Services/Providers/MicrosoftGraphOptionsResolver.php index 6284a770..c0da8067 100644 --- a/apps/platform/app/Services/Providers/MicrosoftGraphOptionsResolver.php +++ b/apps/platform/app/Services/Providers/MicrosoftGraphOptionsResolver.php @@ -2,7 +2,9 @@ namespace App\Services\Providers; +use App\Models\ProviderConnection; use App\Models\Tenant; +use App\Support\Providers\ProviderReasonCodes; final class MicrosoftGraphOptionsResolver { @@ -28,4 +30,37 @@ public function resolveForTenant(Tenant $tenant, array $overrides = []): array return $this->gateway->graphOptions($resolution->connection, $overrides); } + + /** + * @return array + */ + public function resolveForConnection(Tenant $tenant, int|ProviderConnection $connection, array $overrides = []): array + { + $providerConnection = $connection instanceof ProviderConnection + ? $connection + : ProviderConnection::query()->find($connection); + + if (! $providerConnection instanceof ProviderConnection) { + throw ProviderConfigurationRequiredException::forTenant( + tenant: $tenant, + provider: 'microsoft', + resolution: ProviderConnectionResolution::blocked( + ProviderReasonCodes::ProviderConnectionInvalid, + 'The selected provider connection could not be found.', + ), + ); + } + + $resolution = $this->connections->validateConnection($tenant, 'microsoft', $providerConnection); + + if (! $resolution->resolved || $resolution->connection === null) { + throw ProviderConfigurationRequiredException::forTenant( + tenant: $tenant, + provider: 'microsoft', + resolution: $resolution, + ); + } + + return $this->gateway->graphOptions($resolution->connection, $overrides); + } } diff --git a/apps/platform/app/Services/Providers/ProviderOperationRegistry.php b/apps/platform/app/Services/Providers/ProviderOperationRegistry.php index e96f8678..49aaa2a3 100644 --- a/apps/platform/app/Services/Providers/ProviderOperationRegistry.php +++ b/apps/platform/app/Services/Providers/ProviderOperationRegistry.php @@ -2,12 +2,13 @@ namespace App\Services\Providers; +use App\Support\Auth\Capabilities; use InvalidArgumentException; final class ProviderOperationRegistry { /** - * @return array + * @return array */ public function all(): array { @@ -16,16 +17,37 @@ public function all(): array 'provider' => 'microsoft', 'module' => 'health_check', 'label' => 'Provider connection check', + 'required_capability' => Capabilities::PROVIDER_RUN, ], 'inventory_sync' => [ 'provider' => 'microsoft', 'module' => 'inventory', 'label' => 'Inventory sync', + 'required_capability' => Capabilities::PROVIDER_RUN, ], 'compliance.snapshot' => [ 'provider' => 'microsoft', 'module' => 'compliance', 'label' => 'Compliance snapshot', + 'required_capability' => Capabilities::PROVIDER_RUN, + ], + 'restore.execute' => [ + 'provider' => 'microsoft', + 'module' => 'restore', + 'label' => 'Restore execution', + 'required_capability' => Capabilities::TENANT_MANAGE, + ], + 'entra_group_sync' => [ + 'provider' => 'microsoft', + 'module' => 'directory_groups', + 'label' => 'Directory groups sync', + 'required_capability' => Capabilities::TENANT_SYNC, + ], + 'directory_role_definitions.sync' => [ + 'provider' => 'microsoft', + 'module' => 'directory_role_definitions', + 'label' => 'Role definitions sync', + 'required_capability' => Capabilities::TENANT_MANAGE, ], ]; } @@ -36,7 +58,7 @@ public function isAllowed(string $operationType): bool } /** - * @return array{provider: string, module: string, label: string} + * @return array{provider: string, module: string, label: string, required_capability: string} */ public function get(string $operationType): array { diff --git a/apps/platform/app/Services/Providers/ProviderOperationStartGate.php b/apps/platform/app/Services/Providers/ProviderOperationStartGate.php index b49b9af2..6be334ed 100644 --- a/apps/platform/app/Services/Providers/ProviderOperationStartGate.php +++ b/apps/platform/app/Services/Providers/ProviderOperationStartGate.php @@ -244,6 +244,15 @@ private function resolveRequiredCapability(string $operationType, array $extraCo return trim((string) $extraContext['required_capability']); } + if ($this->registry->isAllowed($operationType)) { + $definition = $this->registry->get($operationType); + $requiredCapability = $definition['required_capability'] ?? null; + + if (is_string($requiredCapability) && trim($requiredCapability) !== '') { + return trim($requiredCapability); + } + } + if ($this->registry->isAllowed($operationType)) { return Capabilities::PROVIDER_RUN; } diff --git a/apps/platform/app/Support/Middleware/EnsureFilamentTenantSelected.php b/apps/platform/app/Support/Middleware/EnsureFilamentTenantSelected.php index 0d61a9ca..50473329 100644 --- a/apps/platform/app/Support/Middleware/EnsureFilamentTenantSelected.php +++ b/apps/platform/app/Support/Middleware/EnsureFilamentTenantSelected.php @@ -76,6 +76,12 @@ public function handle(Request $request, Closure $next): Response return $next($request); } + if ($path === '/admin/findings/my-work') { + $this->configureNavigationForRequest($panel); + + return $next($request); + } + if ($path === '/admin/operations/'.$request->route('run')) { $this->configureNavigationForRequest($panel); @@ -86,6 +92,10 @@ public function handle(Request $request, Closure $next): Response ! $resolvedContext->hasTenant() && $this->adminPathRequiresTenantSelection($path) ) { + if ($this->requestHasExplicitTenantHint($request)) { + abort(404); + } + return redirect()->route('filament.admin.pages.choose-tenant'); } @@ -109,7 +119,7 @@ public function handle(Request $request, Closure $next): Response str_starts_with($path, '/admin/w/') || str_starts_with($path, '/admin/workspaces') || str_starts_with($path, '/admin/operations') - || in_array($path, ['/admin', '/admin/choose-workspace', '/admin/choose-tenant', '/admin/no-access', '/admin/alerts', '/admin/audit-log', '/admin/onboarding', '/admin/settings/workspace'], true) + || in_array($path, ['/admin', '/admin/choose-workspace', '/admin/choose-tenant', '/admin/no-access', '/admin/alerts', '/admin/audit-log', '/admin/onboarding', '/admin/settings/workspace', '/admin/findings/my-work'], true) ) { $this->configureNavigationForRequest($panel); @@ -232,12 +242,25 @@ private function isCanonicalWorkspaceRecordViewerPath(string $path): bool return TenantPageCategory::fromPath($path) === TenantPageCategory::CanonicalWorkspaceRecordViewer; } + private function requestHasExplicitTenantHint(Request $request): bool + { + return filled($request->query('tenant')) || filled($request->query('tenant_id')); + } + private function adminPathRequiresTenantSelection(string $path): bool { if (! str_starts_with($path, '/admin/')) { return false; } - return preg_match('#^/admin/(inventory|policies|policy-versions|backup-sets)(/|$)#', $path) === 1; + if (str_starts_with($path, '/admin/finding-exceptions/queue')) { + return false; + } + + if (str_starts_with($path, '/admin/findings/my-work')) { + return false; + } + + return preg_match('#^/admin/(inventory|policies|policy-versions|backup-sets|backup-schedules|findings|finding-exceptions)(/|$)#', $path) === 1; } } diff --git a/apps/platform/app/Support/OperateHub/OperateHubShell.php b/apps/platform/app/Support/OperateHub/OperateHubShell.php index d290f2b9..042f9b73 100644 --- a/apps/platform/app/Support/OperateHub/OperateHubShell.php +++ b/apps/platform/app/Support/OperateHub/OperateHubShell.php @@ -119,7 +119,7 @@ private function buildResolvedContext(?Request $request = null): ResolvedShellCo $pageCategory = $this->pageCategory($request); $routeTenantCandidate = $this->resolveRouteTenantCandidate($request, $pageCategory); $sessionWorkspace = $this->workspaceContext->currentWorkspace($request); - $workspace = $this->workspaceContext->currentWorkspaceOrTenantWorkspace($routeTenantCandidate, $request); + $workspace = $this->resolveWorkspaceForPageCategory($routeTenantCandidate, $pageCategory, $request); $workspaceSource = match (true) { $workspace instanceof Workspace && $sessionWorkspace instanceof Workspace && $workspace->is($sessionWorkspace) => 'session_workspace', @@ -185,6 +185,19 @@ private function buildResolvedContext(?Request $request = null): ResolvedShellCo $recoveryReason ??= $queryHintTenant['reason']; + if ($this->requiresStrictQueryTenantHintResolution($request)) { + return new ResolvedShellContext( + workspace: $workspace, + tenant: null, + pageCategory: $pageCategory, + state: 'invalid_tenant', + displayMode: 'recovery', + workspaceSource: $workspaceSource, + recoveryAction: 'abort_not_found', + recoveryReason: $recoveryReason ?? 'missing_tenant', + ); + } + $tenant = $this->resolveValidatedFilamentTenant($request, $pageCategory, $workspace); if ($tenant instanceof Tenant) { @@ -256,7 +269,7 @@ private function resolveValidatedFilamentTenant( } $pageCategory ??= $this->pageCategory($request); - $workspace ??= $this->workspaceContext->currentWorkspaceOrTenantWorkspace($tenant, $request); + $workspace ??= $this->resolveWorkspaceForPageCategory($tenant, $pageCategory, $request); if ($workspace instanceof Workspace && $this->tenantValidationReason($tenant, $workspace, $request, $pageCategory) === null) { return $tenant; @@ -288,6 +301,18 @@ private function resolveValidatedRouteTenant( return ['tenant' => $tenant, 'reason' => null]; } + private function resolveWorkspaceForPageCategory( + ?Tenant $tenant, + TenantPageCategory $pageCategory, + ?Request $request = null, + ): ?Workspace { + return match ($pageCategory) { + TenantPageCategory::TenantScopedEvidence => $this->workspaceContext->currentWorkspace($request), + TenantPageCategory::TenantBound => $this->workspaceContext->currentWorkspaceOrTenantWorkspace($tenant, $request), + default => $this->workspaceContext->currentWorkspaceOrTenantWorkspace($tenant, $request), + }; + } + private function resolveValidatedQueryHintTenant( ?Request $request, Workspace $workspace, @@ -349,6 +374,30 @@ private function resolveQueryTenantHint(?Request $request = null): ?Tenant return null; } + private function hasExplicitQueryTenantHint(?Request $request = null): bool + { + return filled($request?->query('tenant')) || filled($request?->query('tenant_id')); + } + + private function requiresStrictQueryTenantHintResolution(?Request $request = null): bool + { + if (! $this->hasExplicitQueryTenantHint($request)) { + return false; + } + + $path = '/'.ltrim((string) $request?->path(), '/'); + + if (! str_starts_with($path, '/admin/')) { + return false; + } + + if (str_starts_with($path, '/admin/finding-exceptions/queue')) { + return false; + } + + return preg_match('#^/admin/(inventory|policies|policy-versions|backup-sets|backup-schedules|findings|finding-exceptions)(/|$)#', $path) === 1; + } + private function resolveTenantIdentifier(mixed $tenantIdentifier): ?Tenant { if ($tenantIdentifier instanceof Tenant) { diff --git a/apps/platform/app/Support/OperationCatalog.php b/apps/platform/app/Support/OperationCatalog.php index 3638ec28..fbc0bb72 100644 --- a/apps/platform/app/Support/OperationCatalog.php +++ b/apps/platform/app/Support/OperationCatalog.php @@ -273,8 +273,7 @@ private static function operationAliases(): array new OperationTypeAlias('compliance.snapshot', 'compliance.snapshot', 'canonical', true), new OperationTypeAlias('provider.compliance.snapshot', 'compliance.snapshot', 'legacy_alias', false, 'Provider-prefixed compliance snapshot values resolve to the canonical compliance.snapshot operation.', 'Avoid emitting provider.compliance.snapshot on new platform-owned surfaces.'), new OperationTypeAlias('entra_group_sync', 'directory.groups.sync', 'legacy_alias', true, 'Historical entra_group_sync values resolve to directory.groups.sync.', 'Prefer directory.groups.sync on new platform-owned read models.'), - new OperationTypeAlias('backup_set.add_policies', 'backup_set.update', 'canonical', true), - new OperationTypeAlias('backup_set.remove_policies', 'backup_set.update', 'legacy_alias', true, 'Removal and addition both resolve to the same backup-set update operator meaning.', 'Use backup_set.update for canonical reporting buckets.'), + new OperationTypeAlias('backup_set.update', 'backup_set.update', 'canonical', true), new OperationTypeAlias('backup_set.delete', 'backup_set.archive', 'canonical', true), new OperationTypeAlias('backup_set.restore', 'backup_set.restore', 'canonical', true), new OperationTypeAlias('backup_set.force_delete', 'backup_set.delete', 'legacy_alias', true, 'Force-delete wording is normalized to the canonical delete label.', 'Use backup_set.delete for new platform-owned summaries.'), diff --git a/apps/platform/app/Support/OperationRunLinks.php b/apps/platform/app/Support/OperationRunLinks.php index 56e48df1..6d16cf73 100644 --- a/apps/platform/app/Support/OperationRunLinks.php +++ b/apps/platform/app/Support/OperationRunLinks.php @@ -175,7 +175,7 @@ public static function related(OperationRun $run, ?Tenant $tenant): array } } - if (in_array($run->type, ['backup_set.add_policies', 'backup_set.remove_policies'], true)) { + if ($run->type === 'backup_set.update') { $links['Backup Sets'] = BackupSetResource::getUrl('index', panel: 'tenant', tenant: $tenant); $backupSetId = $context['backup_set_id'] ?? null; diff --git a/apps/platform/app/Support/OperationRunType.php b/apps/platform/app/Support/OperationRunType.php index 969fb060..22c7c935 100644 --- a/apps/platform/app/Support/OperationRunType.php +++ b/apps/platform/app/Support/OperationRunType.php @@ -10,8 +10,7 @@ enum OperationRunType: string case PolicySync = 'policy.sync'; case PolicySyncOne = 'policy.sync_one'; case DirectoryGroupsSync = 'entra_group_sync'; - case BackupSetAddPolicies = 'backup_set.add_policies'; - case BackupSetRemovePolicies = 'backup_set.remove_policies'; + case BackupSetUpdate = 'backup_set.update'; case BackupScheduleExecute = 'backup_schedule_run'; case BackupScheduleRetention = 'backup_schedule_retention'; case BackupSchedulePurge = 'backup_schedule_purge'; @@ -36,6 +35,7 @@ public function canonicalCode(): string self::InventorySync => 'inventory.sync', self::PolicySync, self::PolicySyncOne => 'policy.sync', self::DirectoryGroupsSync => 'directory.groups.sync', + self::BackupSetUpdate => 'backup_set.update', self::BackupScheduleExecute => 'backup.schedule.execute', self::BackupScheduleRetention => 'backup.schedule.retention', self::BackupSchedulePurge => 'backup.schedule.purge', diff --git a/apps/platform/app/Support/OpsUx/GovernanceRunDiagnosticSummary.php b/apps/platform/app/Support/OpsUx/GovernanceRunDiagnosticSummary.php new file mode 100644 index 00000000..ad566546 --- /dev/null +++ b/apps/platform/app/Support/OpsUx/GovernanceRunDiagnosticSummary.php @@ -0,0 +1,92 @@ + $secondaryCauses + * @param list $secondaryFacts + */ + public function __construct( + public string $headline, + public string $executionOutcomeLabel, + public string $artifactImpactLabel, + public string $primaryReason, + public ?array $affectedScaleCue, + public string $nextActionCategory, + public string $nextActionText, + public array $dominantCause, + public array $secondaryCauses = [], + public array $secondaryFacts = [], + public bool $diagnosticsAvailable = false, + ) { + foreach ([ + 'headline' => $this->headline, + 'executionOutcomeLabel' => $this->executionOutcomeLabel, + 'artifactImpactLabel' => $this->artifactImpactLabel, + 'primaryReason' => $this->primaryReason, + 'nextActionCategory' => $this->nextActionCategory, + 'nextActionText' => $this->nextActionText, + ] as $field => $value) { + if (trim($value) === '') { + throw new InvalidArgumentException("Governance run summaries require {$field}."); + } + } + + if (trim((string) ($this->dominantCause['label'] ?? '')) === '' || trim((string) ($this->dominantCause['explanation'] ?? '')) === '') { + throw new InvalidArgumentException('Governance run summaries require a dominant cause label and explanation.'); + } + } + + /** + * @return array{ + * headline: string, + * executionOutcomeLabel: string, + * artifactImpactLabel: string, + * primaryReason: string, + * affectedScaleCue: array{label: string, value: string, source: string, confidence?: string}|null, + * nextActionCategory: string, + * nextActionText: string, + * dominantCause: array{code: ?string, label: string, explanation: string}, + * secondaryCauses: list, + * secondaryFacts: list, + * diagnosticsAvailable: bool + * } + */ + public function toArray(): array + { + return [ + 'headline' => $this->headline, + 'executionOutcomeLabel' => $this->executionOutcomeLabel, + 'artifactImpactLabel' => $this->artifactImpactLabel, + 'primaryReason' => $this->primaryReason, + 'affectedScaleCue' => $this->affectedScaleCue, + 'nextActionCategory' => $this->nextActionCategory, + 'nextActionText' => $this->nextActionText, + 'dominantCause' => $this->dominantCause, + 'secondaryCauses' => $this->secondaryCauses, + 'secondaryFacts' => $this->secondaryFacts, + 'diagnosticsAvailable' => $this->diagnosticsAvailable, + ]; + } +} diff --git a/apps/platform/app/Support/OpsUx/GovernanceRunDiagnosticSummaryBuilder.php b/apps/platform/app/Support/OpsUx/GovernanceRunDiagnosticSummaryBuilder.php new file mode 100644 index 00000000..859f5992 --- /dev/null +++ b/apps/platform/app/Support/OpsUx/GovernanceRunDiagnosticSummaryBuilder.php @@ -0,0 +1,913 @@ +supportsOperatorExplanation()) { + return null; + } + + $artifactTruth ??= $this->artifactTruthPresenter->forOperationRun($run); + $operatorExplanation ??= $artifactTruth?->operatorExplanation; + $reasonEnvelope ??= $this->reasonPresenter->forOperationRun($run, 'run_detail'); + + if (! $artifactTruth instanceof ArtifactTruthEnvelope && ! $operatorExplanation instanceof OperatorExplanationPattern) { + return null; + } + + $canonicalType = OperationCatalog::canonicalCode((string) $run->type); + $context = is_array($run->context) ? $run->context : []; + $counts = SummaryCountsNormalizer::normalize(is_array($run->summary_counts) ? $run->summary_counts : []); + $causeCandidates = $this->rankCauseCandidates($canonicalType, $run, $artifactTruth, $operatorExplanation, $reasonEnvelope, $context); + $dominantCause = $causeCandidates[0] ?? $this->fallbackCause($artifactTruth, $operatorExplanation, $reasonEnvelope); + $secondaryCauses = array_values(array_slice($causeCandidates, 1)); + $artifactImpactLabel = $this->artifactImpactLabel($artifactTruth, $operatorExplanation); + $headline = $this->headline($canonicalType, $run, $artifactTruth, $operatorExplanation, $dominantCause, $context, $counts); + $primaryReason = $this->primaryReason($dominantCause, $artifactTruth, $operatorExplanation, $reasonEnvelope); + $nextActionCategory = $this->nextActionCategory($canonicalType, $run, $reasonEnvelope, $operatorExplanation, $context); + $nextActionText = $this->nextActionText($artifactTruth, $operatorExplanation, $reasonEnvelope); + $affectedScaleCue = $this->affectedScaleCue($canonicalType, $run, $artifactTruth, $operatorExplanation, $context, $counts); + $secondaryFacts = $this->secondaryFacts($artifactTruth, $operatorExplanation, $secondaryCauses, $nextActionCategory, $nextActionText); + + return new GovernanceRunDiagnosticSummary( + headline: $headline, + executionOutcomeLabel: $this->executionOutcomeLabel($run), + artifactImpactLabel: $artifactImpactLabel, + primaryReason: $primaryReason, + affectedScaleCue: $affectedScaleCue, + nextActionCategory: $nextActionCategory, + nextActionText: $nextActionText, + dominantCause: [ + 'code' => $dominantCause['code'] ?? null, + 'label' => $dominantCause['label'], + 'explanation' => $dominantCause['explanation'], + ], + secondaryCauses: array_map( + static fn (array $cause): array => [ + 'code' => $cause['code'] ?? null, + 'label' => $cause['label'], + 'explanation' => $cause['explanation'], + ], + $secondaryCauses, + ), + secondaryFacts: $secondaryFacts, + diagnosticsAvailable: (bool) ($operatorExplanation?->diagnosticsAvailable ?? false), + ); + } + + private function executionOutcomeLabel(OperationRun $run): string + { + $spec = BadgeCatalog::spec(BadgeDomain::OperationRunOutcome, (string) $run->outcome); + + return $spec->label !== 'Unknown' + ? $spec->label + : ucfirst(str_replace('_', ' ', trim((string) $run->outcome))); + } + + private function artifactImpactLabel( + ?ArtifactTruthEnvelope $artifactTruth, + ?OperatorExplanationPattern $operatorExplanation, + ): string { + if ($artifactTruth instanceof ArtifactTruthEnvelope && trim($artifactTruth->primaryLabel) !== '') { + return $artifactTruth->primaryLabel; + } + + if ($operatorExplanation instanceof OperatorExplanationPattern) { + return $operatorExplanation->trustworthinessLabel(); + } + + return 'Result needs review'; + } + + /** + * @param array{code: ?string, label: string, explanation: string} $dominantCause + * @param array $context + * @param array $counts + */ + private function headline( + string $canonicalType, + OperationRun $run, + ?ArtifactTruthEnvelope $artifactTruth, + ?OperatorExplanationPattern $operatorExplanation, + array $dominantCause, + array $context, + array $counts, + ): string { + return match ($canonicalType) { + 'baseline.capture' => $this->baselineCaptureHeadline($artifactTruth, $context, $counts, $operatorExplanation), + 'baseline.compare' => $this->baselineCompareHeadline($artifactTruth, $context, $counts, $operatorExplanation), + 'tenant.evidence.snapshot.generate' => $this->evidenceSnapshotHeadline($artifactTruth, $operatorExplanation), + 'tenant.review.compose' => $this->reviewComposeHeadline($artifactTruth, $dominantCause, $operatorExplanation), + 'tenant.review_pack.generate' => $this->reviewPackHeadline($artifactTruth, $dominantCause, $operatorExplanation), + default => $operatorExplanation?->headline + ?? $artifactTruth?->primaryExplanation + ?? 'This governance run needs review before it can be relied on.', + }; + } + + /** + * @param array $context + * @param array $counts + */ + private function baselineCaptureHeadline( + ?ArtifactTruthEnvelope $artifactTruth, + array $context, + array $counts, + ?OperatorExplanationPattern $operatorExplanation, + ): string { + $subjectsTotal = $this->intValue(data_get($context, 'baseline_capture.subjects_total')); + $resumeToken = data_get($context, 'baseline_capture.resume_token'); + $gapCount = $this->intValue(data_get($context, 'baseline_capture.gaps.count')); + + if ($subjectsTotal === 0) { + return 'No baseline was captured because no governed subjects were ready.'; + } + + if (is_string($resumeToken) && trim($resumeToken) !== '') { + return 'The baseline capture started, but more evidence still needs to be collected.'; + } + + if ($gapCount > 0) { + return 'The baseline capture finished, but evidence gaps still limit the snapshot.'; + } + + if (($artifactTruth?->artifactExistence ?? null) === 'created_but_not_usable') { + return 'The baseline capture finished without a usable snapshot.'; + } + + if (($counts['total'] ?? 0) === 0 && ($counts['processed'] ?? 0) === 0) { + return 'The baseline capture finished without producing a decision-grade snapshot.'; + } + + return $operatorExplanation?->headline + ?? $artifactTruth?->primaryExplanation + ?? 'The baseline capture needs review before it can be used.'; + } + + /** + * @param array $context + * @param array $counts + */ + private function baselineCompareHeadline( + ?ArtifactTruthEnvelope $artifactTruth, + array $context, + array $counts, + ?OperatorExplanationPattern $operatorExplanation, + ): string { + $reasonCode = (string) data_get($context, 'baseline_compare.reason_code', ''); + $proof = data_get($context, 'baseline_compare.coverage.proof'); + $resumeToken = data_get($context, 'baseline_compare.resume_token'); + + if ($reasonCode === BaselineCompareReasonCode::AmbiguousSubjects->value) { + return 'The compare finished, but ambiguous subject matching limited the result.'; + } + + if ($reasonCode === BaselineCompareReasonCode::StrategyFailed->value) { + return 'The compare finished, but a compare strategy failure kept the result incomplete.'; + } + + if (is_string($resumeToken) && trim($resumeToken) !== '') { + return 'The compare finished, but evidence capture still needs to resume before the result is complete.'; + } + + if (($counts['total'] ?? 0) === 0 && ($counts['processed'] ?? 0) === 0) { + return 'The compare finished, but no decision-grade result is available yet.'; + } + + if ($proof === false) { + return 'The compare finished, but missing coverage proof suppressed the normal result.'; + } + + return $operatorExplanation?->headline + ?? $artifactTruth?->primaryExplanation + ?? 'The compare needs follow-up before it can be treated as complete.'; + } + + private function evidenceSnapshotHeadline( + ?ArtifactTruthEnvelope $artifactTruth, + ?OperatorExplanationPattern $operatorExplanation, + ): string { + return match (true) { + $artifactTruth?->freshnessState === 'stale' => 'The snapshot finished processing, but its evidence basis is already stale.', + $artifactTruth?->contentState === 'partial' => 'The snapshot finished processing, but its evidence basis is incomplete.', + $artifactTruth?->contentState === 'missing_input' => 'The snapshot finished processing without a complete evidence basis.', + default => $operatorExplanation?->headline + ?? $artifactTruth?->primaryExplanation + ?? 'The evidence snapshot needs review before it is relied on.', + }; + } + + /** + * @param array{code: ?string, label: string, explanation: string} $dominantCause + */ + private function reviewComposeHeadline( + ?ArtifactTruthEnvelope $artifactTruth, + array $dominantCause, + ?OperatorExplanationPattern $operatorExplanation, + ): string { + return match (true) { + $artifactTruth?->contentState === 'partial' && $artifactTruth?->freshnessState === 'stale' + => 'The review was generated, but missing sections and stale evidence keep it from being decision-grade.', + $artifactTruth?->contentState === 'partial' + => 'The review was generated, but required sections are still incomplete.', + $artifactTruth?->freshnessState === 'stale' + => 'The review was generated, but it relies on stale evidence.', + default => $operatorExplanation?->headline + ?? $dominantCause['explanation'] + ?? 'The review needs follow-up before it should guide action.', + }; + } + + /** + * @param array{code: ?string, label: string, explanation: string} $dominantCause + */ + private function reviewPackHeadline( + ?ArtifactTruthEnvelope $artifactTruth, + array $dominantCause, + ?OperatorExplanationPattern $operatorExplanation, + ): string { + return match (true) { + $artifactTruth?->publicationReadiness === 'blocked' + => 'The pack did not produce a shareable artifact yet.', + $artifactTruth?->publicationReadiness === 'internal_only' + => 'The pack finished, but it should stay internal until the source review is refreshed.', + default => $operatorExplanation?->headline + ?? $dominantCause['explanation'] + ?? 'The review pack needs follow-up before it is shared.', + }; + } + + /** + * @param array{code: ?string, label: string, explanation: string} $dominantCause + */ + private function primaryReason( + array $dominantCause, + ?ArtifactTruthEnvelope $artifactTruth, + ?OperatorExplanationPattern $operatorExplanation, + ?ReasonResolutionEnvelope $reasonEnvelope, + ): string { + return $dominantCause['explanation'] + ?? $operatorExplanation?->dominantCauseExplanation + ?? $reasonEnvelope?->shortExplanation + ?? $artifactTruth?->primaryExplanation + ?? $operatorExplanation?->reliabilityStatement + ?? 'TenantPilot recorded diagnostic detail for this run.'; + } + + /** + * @param array $context + */ + private function nextActionCategory( + string $canonicalType, + OperationRun $run, + ?ReasonResolutionEnvelope $reasonEnvelope, + ?OperatorExplanationPattern $operatorExplanation, + array $context, + ): string { + if ($reasonEnvelope?->actionability === 'retryable_transient' || $operatorExplanation?->nextActionCategory === 'retry_later') { + return 'retry_later'; + } + + if (in_array($canonicalType, ['baseline.capture', 'baseline.compare'], true)) { + $resumeToken = $canonicalType === 'baseline.capture' + ? data_get($context, 'baseline_capture.resume_token') + : data_get($context, 'baseline_compare.resume_token'); + + if (is_string($resumeToken) && trim($resumeToken) !== '') { + return 'resume_capture_or_generation'; + } + } + + $reasonCode = (string) (data_get($context, 'baseline_compare.reason_code') ?? $reasonEnvelope?->internalCode ?? ''); + + if (in_array($reasonCode, [ + BaselineCompareReasonCode::AmbiguousSubjects->value, + BaselineCompareReasonCode::UnsupportedSubjects->value, + ], true)) { + return 'review_scope_or_ambiguous_matches'; + } + + if ($canonicalType === 'baseline.capture' && $this->intValue(data_get($context, 'baseline_capture.subjects_total')) === 0) { + return 'refresh_prerequisite_data'; + } + + if ($operatorExplanation?->nextActionCategory === 'none' || trim((string) $operatorExplanation?->nextActionText) === 'No action needed') { + return 'no_further_action'; + } + + if ( + $reasonEnvelope?->actionability === 'prerequisite_missing' + || in_array($canonicalType, ['tenant.evidence.snapshot.generate', 'tenant.review.compose', 'tenant.review_pack.generate'], true) + ) { + return 'refresh_prerequisite_data'; + } + + return 'manually_validate'; + } + + private function nextActionText( + ?ArtifactTruthEnvelope $artifactTruth, + ?OperatorExplanationPattern $operatorExplanation, + ?ReasonResolutionEnvelope $reasonEnvelope, + ): string { + $text = $operatorExplanation?->nextActionText + ?? $artifactTruth?->nextStepText() + ?? $reasonEnvelope?->firstNextStep()?->label + ?? 'No action needed'; + + return trim(rtrim($text, '.')).'.'; + } + + /** + * @param array $context + * @param array $counts + * @return array{label: string, value: string, source: string, confidence?: string}|null + */ + private function affectedScaleCue( + string $canonicalType, + OperationRun $run, + ?ArtifactTruthEnvelope $artifactTruth, + ?OperatorExplanationPattern $operatorExplanation, + array $context, + array $counts, + ): ?array { + return match ($canonicalType) { + 'baseline.capture' => $this->baselineCaptureScaleCue($context, $counts), + 'baseline.compare' => $this->baselineCompareScaleCue($context, $counts), + 'tenant.evidence.snapshot.generate' => $this->countDescriptorScaleCue($operatorExplanation?->countDescriptors ?? [], ['Missing dimensions', 'Stale dimensions', 'Evidence dimensions']), + 'tenant.review.compose' => $this->reviewScaleCue($artifactTruth, $operatorExplanation?->countDescriptors ?? []), + 'tenant.review_pack.generate' => $this->reviewPackScaleCue($artifactTruth, $operatorExplanation?->countDescriptors ?? []), + default => $this->summaryCountsScaleCue($counts), + }; + } + + /** + * @param array $context + * @param array $counts + * @return array{label: string, value: string, source: string, confidence?: string}|null + */ + private function baselineCaptureScaleCue(array $context, array $counts): ?array + { + $subjectsTotal = $this->intValue(data_get($context, 'baseline_capture.subjects_total')); + $gapCount = $this->intValue(data_get($context, 'baseline_capture.gaps.count')); + + if ($gapCount > 0) { + return [ + 'label' => 'Affected subjects', + 'value' => "{$gapCount} governed subjects still need evidence follow-up.", + 'source' => 'context', + 'confidence' => 'exact', + ]; + } + + if ($subjectsTotal >= 0) { + return [ + 'label' => 'Capture scope', + 'value' => "{$subjectsTotal} governed subjects were in the recorded capture scope.", + 'source' => 'context', + 'confidence' => 'exact', + ]; + } + + return $this->summaryCountsScaleCue($counts); + } + + /** + * @param array $context + * @param array $counts + * @return array{label: string, value: string, source: string, confidence?: string}|null + */ + private function baselineCompareScaleCue(array $context, array $counts): ?array + { + $gapCount = $this->intValue(data_get($context, 'baseline_compare.evidence_gaps.count')); + $subjectsTotal = $this->intValue(data_get($context, 'baseline_compare.subjects_total')); + $uncoveredTypes = array_values(array_filter((array) data_get($context, 'baseline_compare.coverage.uncovered_types', []), 'is_string')); + + if ($gapCount > 0) { + return [ + 'label' => 'Affected subjects', + 'value' => "{$gapCount} governed subjects still have evidence gaps.", + 'source' => 'context', + 'confidence' => 'exact', + ]; + } + + if ($uncoveredTypes !== []) { + $count = count($uncoveredTypes); + + return [ + 'label' => 'Coverage scope', + 'value' => "{$count} policy types were left without proven compare coverage.", + 'source' => 'context', + 'confidence' => 'bounded', + ]; + } + + if ($subjectsTotal > 0) { + return [ + 'label' => 'Compare scope', + 'value' => "{$subjectsTotal} governed subjects were in scope for this compare run.", + 'source' => 'context', + 'confidence' => 'exact', + ]; + } + + return $this->summaryCountsScaleCue($counts); + } + + /** + * @param array $countDescriptors + * @return array{label: string, value: string, source: string, confidence?: string}|null + */ + private function reviewScaleCue(?ArtifactTruthEnvelope $artifactTruth, array $countDescriptors): ?array + { + if ($artifactTruth?->contentState === 'partial') { + $sections = $this->findCountDescriptor($countDescriptors, 'Sections'); + + if ($sections instanceof CountDescriptor) { + return [ + 'label' => 'Review sections', + 'value' => "{$sections->value} sections were recorded and still need review for completeness.", + 'source' => 'related_artifact_truth', + 'confidence' => 'best_available', + ]; + } + + return [ + 'label' => 'Review sections', + 'value' => 'Required review sections are still incomplete.', + 'source' => 'related_artifact_truth', + 'confidence' => 'best_available', + ]; + } + + if ($artifactTruth?->freshnessState === 'stale') { + return [ + 'label' => 'Evidence freshness', + 'value' => 'The source evidence is stale for at least part of this review.', + 'source' => 'related_artifact_truth', + 'confidence' => 'best_available', + ]; + } + + return $this->countDescriptorScaleCue($countDescriptors, ['Sections', 'Findings']); + } + + /** + * @param array $countDescriptors + * @return array{label: string, value: string, source: string, confidence?: string}|null + */ + private function reviewPackScaleCue(?ArtifactTruthEnvelope $artifactTruth, array $countDescriptors): ?array + { + if ($artifactTruth?->publicationReadiness === 'internal_only') { + return [ + 'label' => 'Sharing scope', + 'value' => 'The pack is suitable for internal follow-up only in its current state.', + 'source' => 'related_artifact_truth', + 'confidence' => 'best_available', + ]; + } + + return $this->countDescriptorScaleCue($countDescriptors, ['Reports', 'Findings']); + } + + /** + * @param array $countDescriptors + * @param list $preferredLabels + * @return array{label: string, value: string, source: string, confidence?: string}|null + */ + private function countDescriptorScaleCue(array $countDescriptors, array $preferredLabels): ?array + { + foreach ($preferredLabels as $label) { + $descriptor = $this->findCountDescriptor($countDescriptors, $label); + + if (! $descriptor instanceof CountDescriptor || $descriptor->value <= 0) { + continue; + } + + return [ + 'label' => $descriptor->label, + 'value' => "{$descriptor->value} {$this->pluralizeDescriptor($descriptor)}.", + 'source' => 'related_artifact_truth', + 'confidence' => 'exact', + ]; + } + + return null; + } + + /** + * @param array $counts + * @return array{label: string, value: string, source: string, confidence?: string}|null + */ + private function summaryCountsScaleCue(array $counts): ?array + { + foreach (['total', 'processed', 'failed', 'items', 'finding_count'] as $key) { + $value = (int) ($counts[$key] ?? 0); + + if ($value <= 0) { + continue; + } + + return [ + 'label' => SummaryCountsNormalizer::label($key), + 'value' => "{$value} recorded in the canonical run counters.", + 'source' => 'summary_counts', + 'confidence' => 'exact', + ]; + } + + return null; + } + + /** + * @param array $context + * @return list + */ + private function rankCauseCandidates( + string $canonicalType, + OperationRun $run, + ?ArtifactTruthEnvelope $artifactTruth, + ?OperatorExplanationPattern $operatorExplanation, + ?ReasonResolutionEnvelope $reasonEnvelope, + array $context, + ): array { + $candidates = []; + + $this->pushCandidate( + $candidates, + code: $reasonEnvelope?->internalCode ?? $operatorExplanation?->dominantCauseCode, + label: $reasonEnvelope?->operatorLabel ?? $operatorExplanation?->dominantCauseLabel ?? $artifactTruth?->primaryLabel, + explanation: $reasonEnvelope?->shortExplanation ?? $operatorExplanation?->dominantCauseExplanation ?? $artifactTruth?->primaryExplanation, + rank: $this->reasonRank($reasonEnvelope, $operatorExplanation), + ); + + match ($canonicalType) { + 'baseline.capture' => $this->baselineCaptureCandidates($candidates, $context), + 'baseline.compare' => $this->baselineCompareCandidates($candidates, $context), + 'tenant.evidence.snapshot.generate' => $this->evidenceSnapshotCandidates($candidates, $artifactTruth, $operatorExplanation), + 'tenant.review.compose' => $this->reviewComposeCandidates($candidates, $artifactTruth), + 'tenant.review_pack.generate' => $this->reviewPackCandidates($candidates, $artifactTruth), + default => null, + }; + + usort($candidates, static function (array $left, array $right): int { + $rank = ($right['rank'] <=> $left['rank']); + + if ($rank !== 0) { + return $rank; + } + + return strcmp($left['label'], $right['label']); + }); + + return array_values(array_map( + static fn (array $candidate): array => [ + 'code' => $candidate['code'], + 'label' => $candidate['label'], + 'explanation' => $candidate['explanation'], + ], + $candidates, + )); + } + + /** + * @param list $candidates + */ + private function pushCandidate(array &$candidates, ?string $code, ?string $label, ?string $explanation, int $rank): void + { + $label = is_string($label) ? trim($label) : ''; + $explanation = is_string($explanation) ? trim($explanation) : ''; + + if ($label === '' || $explanation === '') { + return; + } + + foreach ($candidates as $candidate) { + if (($candidate['label'] ?? null) === $label && ($candidate['explanation'] ?? null) === $explanation) { + return; + } + } + + $candidates[] = [ + 'code' => $code, + 'label' => $label, + 'explanation' => $explanation, + 'rank' => $rank, + ]; + } + + /** + * @param list $candidates + * @param array $context + */ + private function baselineCaptureCandidates(array &$candidates, array $context): void + { + $subjectsTotal = $this->intValue(data_get($context, 'baseline_capture.subjects_total')); + $gapCount = $this->intValue(data_get($context, 'baseline_capture.gaps.count')); + $resumeToken = data_get($context, 'baseline_capture.resume_token'); + + if ($subjectsTotal === 0) { + $this->pushCandidate($candidates, 'no_subjects_in_scope', 'No governed subjects captured', 'No governed subjects were available for this baseline capture.', 95); + } + + if ($gapCount > 0) { + $this->pushCandidate($candidates, 'baseline_capture_gaps', 'Evidence gaps remain', "{$gapCount} governed subjects still need evidence capture before the snapshot is complete.", 82); + } + + if (is_string($resumeToken) && trim($resumeToken) !== '') { + $this->pushCandidate($candidates, 'baseline_capture_resume', 'Capture can resume', 'TenantPilot recorded a resume point because this capture could not finish in one pass.', 84); + } + } + + /** + * @param list $candidates + * @param array $context + */ + private function baselineCompareCandidates(array &$candidates, array $context): void + { + $reasonCode = (string) data_get($context, 'baseline_compare.reason_code', ''); + $gapCount = $this->intValue(data_get($context, 'baseline_compare.evidence_gaps.count')); + $uncoveredTypes = array_values(array_filter((array) data_get($context, 'baseline_compare.coverage.uncovered_types', []), 'is_string')); + $proof = data_get($context, 'baseline_compare.coverage.proof'); + $resumeToken = data_get($context, 'baseline_compare.resume_token'); + + if ($reasonCode === BaselineCompareReasonCode::AmbiguousSubjects->value) { + $this->pushCandidate($candidates, $reasonCode, 'Ambiguous matches', 'One or more governed subjects stayed ambiguous, so the compare result needs scope review.', 92); + } + + if ($reasonCode === BaselineCompareReasonCode::StrategyFailed->value) { + $this->pushCandidate($candidates, $reasonCode, 'Compare strategy failed', 'A compare strategy failed while processing in-scope governed subjects.', 94); + } + + if ($gapCount > 0) { + $this->pushCandidate($candidates, 'baseline_compare_gaps', 'Evidence gaps', "{$gapCount} governed subjects still have evidence gaps, so the compare output is incomplete.", 83); + } + + if ($proof === false || $uncoveredTypes !== []) { + $count = count($uncoveredTypes); + $explanation = $count > 0 + ? "{$count} policy types were left without proven compare coverage." + : 'Coverage proof was missing, so TenantPilot suppressed part of the normal compare output.'; + + $this->pushCandidate($candidates, 'coverage_unproven', 'Coverage proof missing', $explanation, 81); + } + + if (is_string($resumeToken) && trim($resumeToken) !== '') { + $this->pushCandidate($candidates, 'baseline_compare_resume', 'Evidence capture needs to resume', 'The compare recorded a resume point because evidence capture did not finish in one pass.', 80); + } + } + + /** + * @param list $candidates + * @param array $countDescriptors + */ + private function evidenceSnapshotCandidates(array &$candidates, ?ArtifactTruthEnvelope $artifactTruth, ?OperatorExplanationPattern $operatorExplanation): void + { + $countDescriptors = $operatorExplanation?->countDescriptors ?? []; + $missing = $this->findCountDescriptor($countDescriptors, 'Missing dimensions'); + $stale = $this->findCountDescriptor($countDescriptors, 'Stale dimensions'); + + if ($missing instanceof CountDescriptor && $missing->value > 0) { + $this->pushCandidate($candidates, 'missing_dimensions', 'Missing dimensions', "{$missing->value} evidence dimensions are still missing from this snapshot.", 88); + } + + if ($artifactTruth?->freshnessState === 'stale' || ($stale instanceof CountDescriptor && $stale->value > 0)) { + $value = $stale instanceof CountDescriptor && $stale->value > 0 + ? "{$stale->value} evidence dimensions are stale and should be refreshed." + : 'Part of the evidence basis is stale and should be refreshed before use.'; + + $this->pushCandidate($candidates, 'stale_evidence', 'Stale evidence basis', $value, 82); + } + } + + /** + * @param list $candidates + */ + private function reviewComposeCandidates(array &$candidates, ?ArtifactTruthEnvelope $artifactTruth): void + { + if ($artifactTruth?->contentState === 'partial') { + $this->pushCandidate($candidates, 'review_missing_sections', 'Missing sections', 'Required review sections are still incomplete for this generated review.', 90); + } + + if ($artifactTruth?->freshnessState === 'stale') { + $this->pushCandidate($candidates, 'review_stale_evidence', 'Stale evidence basis', 'The review relies on stale evidence and needs a refreshed evidence basis.', 86); + } + + if ($artifactTruth?->publicationReadiness === 'blocked') { + $this->pushCandidate($candidates, 'review_blocked', 'Publication blocked', 'The review cannot move forward until its blocking prerequisites are cleared.', 95); + } + } + + /** + * @param list $candidates + */ + private function reviewPackCandidates(array &$candidates, ?ArtifactTruthEnvelope $artifactTruth): void + { + if ($artifactTruth?->publicationReadiness === 'blocked') { + $this->pushCandidate($candidates, 'review_pack_blocked', 'Shareable pack not available', 'The pack did not produce a shareable artifact yet.', 94); + } + + if ($artifactTruth?->publicationReadiness === 'internal_only') { + $this->pushCandidate($candidates, 'review_pack_internal_only', 'Internal-only outcome', 'The pack can support internal follow-up, but it should not be shared externally yet.', 80); + } + + if ($artifactTruth?->freshnessState === 'stale') { + $this->pushCandidate($candidates, 'review_pack_stale_source', 'Source review is stale', 'The pack inherits stale review evidence and needs a refreshed source review.', 84); + } + + if ($artifactTruth?->contentState === 'partial') { + $this->pushCandidate($candidates, 'review_pack_partial_source', 'Source review is incomplete', 'The pack inherits incomplete source review content and needs follow-up before sharing.', 86); + } + } + + private function reasonRank( + ?ReasonResolutionEnvelope $reasonEnvelope, + ?OperatorExplanationPattern $operatorExplanation, + ): int { + if ($reasonEnvelope?->actionability === 'retryable_transient') { + return 76; + } + + return match ($operatorExplanation?->nextActionCategory) { + 'fix_prerequisite' => 92, + 'retry_later' => 76, + 'none' => 40, + default => 85, + }; + } + + /** + * @param list $secondaryCauses + * @return list + */ + private function secondaryFacts( + ?ArtifactTruthEnvelope $artifactTruth, + ?OperatorExplanationPattern $operatorExplanation, + array $secondaryCauses, + string $nextActionCategory, + string $nextActionText, + ): array { + $facts = []; + + if ($operatorExplanation instanceof OperatorExplanationPattern) { + $facts[] = [ + 'label' => 'Result trust', + 'value' => $operatorExplanation->trustworthinessLabel(), + 'hint' => $this->deduplicateSecondaryFactHint( + $operatorExplanation->reliabilityStatement, + $operatorExplanation->dominantCauseExplanation, + $artifactTruth?->primaryExplanation, + ), + 'emphasis' => $this->emphasisFromTrust($operatorExplanation->trustworthinessLevel->value), + ]; + + if ($operatorExplanation->evaluationResultLabel() !== '') { + $facts[] = [ + 'label' => 'Result meaning', + 'value' => $operatorExplanation->evaluationResultLabel(), + 'hint' => $operatorExplanation->coverageStatement, + 'emphasis' => 'neutral', + ]; + } + } + + if ($secondaryCauses !== []) { + $facts[] = [ + 'label' => 'Secondary causes', + 'value' => implode(' · ', array_map(static fn (array $cause): string => $cause['label'], $secondaryCauses)), + 'hint' => 'Additional contributing causes stay visible without replacing the dominant cause.', + 'emphasis' => 'caution', + ]; + } + + if ($artifactTruth?->relatedArtifactUrl === null && $nextActionCategory !== 'no_further_action') { + $facts[] = [ + 'label' => 'Related artifact access', + 'value' => 'No related artifact link is available from this run.', + 'emphasis' => 'neutral', + ]; + } + + return $facts; + } + + private function emphasisFromTrust(string $trust): string + { + return match ($trust) { + 'unusable' => 'blocked', + 'diagnostic_only', 'limited_confidence' => 'caution', + default => 'neutral', + }; + } + + private function deduplicateSecondaryFactHint(?string $hint, ?string ...$duplicates): ?string + { + $normalizedHint = $this->normalizeFactText($hint); + + if ($normalizedHint === null) { + return null; + } + + foreach ($duplicates as $duplicate) { + if ($normalizedHint === $this->normalizeFactText($duplicate)) { + return null; + } + } + + return trim($hint ?? ''); + } + + private function fallbackCause( + ?ArtifactTruthEnvelope $artifactTruth, + ?OperatorExplanationPattern $operatorExplanation, + ?ReasonResolutionEnvelope $reasonEnvelope, + ): array { + return [ + 'code' => $reasonEnvelope?->internalCode ?? $operatorExplanation?->dominantCauseCode, + 'label' => $reasonEnvelope?->operatorLabel + ?? $operatorExplanation?->dominantCauseLabel + ?? $artifactTruth?->primaryLabel + ?? 'Follow-up required', + 'explanation' => $reasonEnvelope?->shortExplanation + ?? $operatorExplanation?->dominantCauseExplanation + ?? $artifactTruth?->primaryExplanation + ?? 'TenantPilot recorded enough detail to keep this run out of an all-clear state.', + ]; + } + + private function findCountDescriptor(array $countDescriptors, string $label): ?CountDescriptor + { + foreach ($countDescriptors as $descriptor) { + if ($descriptor instanceof CountDescriptor && $descriptor->label === $label) { + return $descriptor; + } + } + + return null; + } + + private function intValue(mixed $value): ?int + { + return is_numeric($value) ? (int) $value : null; + } + + private function pluralizeDescriptor(CountDescriptor $descriptor): string + { + return match ($descriptor->label) { + 'Missing dimensions' => 'evidence dimensions are missing', + 'Stale dimensions' => 'evidence dimensions are stale', + 'Evidence dimensions' => 'evidence dimensions were recorded', + 'Sections' => 'sections were recorded', + 'Reports' => 'reports were recorded', + 'Findings' => 'findings were recorded', + default => strtolower($descriptor->label).' were recorded', + }; + } + + private function normalizeFactText(?string $value): ?string + { + if (! is_string($value)) { + return null; + } + + $normalized = trim((string) preg_replace('/\s+/', ' ', $value)); + + if ($normalized === '') { + return null; + } + + return mb_strtolower($normalized); + } +} diff --git a/apps/platform/app/Support/OpsUx/OperationUxPresenter.php b/apps/platform/app/Support/OpsUx/OperationUxPresenter.php index 57eb7e25..01021df2 100644 --- a/apps/platform/app/Support/OpsUx/OperationUxPresenter.php +++ b/apps/platform/app/Support/OpsUx/OperationUxPresenter.php @@ -53,6 +53,34 @@ public static function alreadyQueuedToast(string $operationType): FilamentNotifi ->duration(self::QUEUED_TOAST_DURATION_MS); } + /** + * Canonical provider-backed dedupe feedback using the shared start vocabulary. + */ + public static function alreadyRunningToast(string $operationType): FilamentNotification + { + $operationLabel = OperationCatalog::label($operationType); + + return FilamentNotification::make() + ->title("{$operationLabel} already running") + ->body('A matching operation is already queued or running. Open the operation for progress and next steps.') + ->info() + ->duration(self::QUEUED_TOAST_DURATION_MS); + } + + /** + * Canonical provider-backed protected-scope conflict feedback. + */ + public static function scopeBusyToast( + string $title = 'Scope busy', + string $body = 'Another provider-backed operation is already running for this scope. Open the active operation for progress and next steps.', + ): FilamentNotification { + return FilamentNotification::make() + ->title($title) + ->body($body) + ->warning() + ->duration(self::QUEUED_TOAST_DURATION_MS); + } + /** * Terminal DB notification payload. * @@ -322,6 +350,16 @@ public static function governanceOperatorExplanation(OperationRun $run): ?Operat return self::resolveGovernanceOperatorExplanation($run); } + public static function governanceDiagnosticSummary(OperationRun $run): ?GovernanceRunDiagnosticSummary + { + return self::resolveGovernanceDiagnosticSummary($run); + } + + public static function governanceDiagnosticSummaryFresh(OperationRun $run): ?GovernanceRunDiagnosticSummary + { + return self::resolveGovernanceDiagnosticSummary($run, fresh: true); + } + public static function governanceOperatorExplanationFresh(OperationRun $run): ?OperatorExplanationPattern { return self::resolveGovernanceOperatorExplanation($run, fresh: true); @@ -464,6 +502,29 @@ private static function resolveGovernanceOperatorExplanation(OperationRun $run, ); } + private static function resolveGovernanceDiagnosticSummary(OperationRun $run, bool $fresh = false): ?GovernanceRunDiagnosticSummary + { + if (! $run->supportsOperatorExplanation()) { + return null; + } + + return self::memoizeExplanation( + run: $run, + variant: 'governance_diagnostic_summary', + resolver: fn (): ?GovernanceRunDiagnosticSummary => app(GovernanceRunDiagnosticSummaryBuilder::class)->build( + run: $run, + artifactTruth: $fresh + ? app(ArtifactTruthPresenter::class)->forOperationRunFresh($run) + : app(ArtifactTruthPresenter::class)->forOperationRun($run), + operatorExplanation: $fresh + ? self::resolveGovernanceOperatorExplanation($run, fresh: true) + : self::resolveGovernanceOperatorExplanation($run), + reasonEnvelope: app(ReasonPresenter::class)->forOperationRun($run, 'run_detail'), + ), + fresh: $fresh, + ); + } + private static function memoizeGuidance( OperationRun $run, string $variant, diff --git a/apps/platform/app/Support/OpsUx/ProviderOperationStartResultPresenter.php b/apps/platform/app/Support/OpsUx/ProviderOperationStartResultPresenter.php new file mode 100644 index 00000000..dc10b741 --- /dev/null +++ b/apps/platform/app/Support/OpsUx/ProviderOperationStartResultPresenter.php @@ -0,0 +1,96 @@ + $extraActions + */ + public function notification( + ProviderOperationStartResult $result, + string $blockedTitle, + string $runUrl, + array $extraActions = [], + string $scopeBusyTitle = 'Scope busy', + string $scopeBusyBody = 'Another provider-backed operation is already running for this scope. Open the active operation for progress and next steps.', + ): FilamentNotification { + $notification = match ($result->status) { + 'started' => OperationUxPresenter::queuedToast((string) $result->run->type), + 'deduped' => OperationUxPresenter::alreadyRunningToast((string) $result->run->type), + 'scope_busy' => OperationUxPresenter::scopeBusyToast($scopeBusyTitle, $scopeBusyBody), + 'blocked' => FilamentNotification::make() + ->title($blockedTitle) + ->body(implode("\n", $this->blockedBodyLines($result))) + ->warning(), + default => OperationUxPresenter::queuedToast((string) $result->run->type), + }; + + return $notification->actions($this->actionsFor($result, $runUrl, $extraActions)); + } + + /** + * @param array $extraActions + * @return array + */ + private function actionsFor(ProviderOperationStartResult $result, string $runUrl, array $extraActions): array + { + $actions = [ + Action::make('view_run') + ->label(OperationRunLinks::openLabel()) + ->url($runUrl), + ]; + + if ($result->status === 'blocked') { + $nextStep = $this->firstNextStep($result); + + if ($nextStep instanceof NextStepOption && $nextStep->kind === 'link' && is_string($nextStep->destination) && trim($nextStep->destination) !== '') { + $actions[] = Action::make('next_step_0') + ->label($nextStep->label) + ->url($nextStep->destination); + } + } + + return [...$actions, ...$extraActions]; + } + + /** + * @return array + */ + private function blockedBodyLines(ProviderOperationStartResult $result): array + { + $reasonEnvelope = $this->reasonPresenter->forOperationRun($result->run, 'notification'); + + return $reasonEnvelope?->toBodyLines() ?? ['Blocked by provider configuration.']; + } + + private function firstNextStep(ProviderOperationStartResult $result): ?NextStepOption + { + $nextSteps = is_array($result->run->context['next_steps'] ?? null) + ? $result->run->context['next_steps'] + : []; + + $storedNextStep = NextStepOption::collect($nextSteps)[0] ?? null; + + if ($storedNextStep instanceof NextStepOption) { + return $storedNextStep; + } + + $reasonEnvelope = $this->reasonPresenter->forOperationRun($result->run, 'notification'); + + return $reasonEnvelope?->firstNextStep(); + } +} \ No newline at end of file diff --git a/apps/platform/app/Support/Ui/GovernanceArtifactTruth/ArtifactTruthCause.php b/apps/platform/app/Support/Ui/GovernanceArtifactTruth/ArtifactTruthCause.php index 3e167248..f0933401 100644 --- a/apps/platform/app/Support/Ui/GovernanceArtifactTruth/ArtifactTruthCause.php +++ b/apps/platform/app/Support/Ui/GovernanceArtifactTruth/ArtifactTruthCause.php @@ -21,6 +21,7 @@ public function __construct( public ?string $operatorLabel, public ?string $shortExplanation, public ?string $diagnosticCode, + public ?string $actionability, public string $trustImpact, public ?string $absencePattern, public array $nextSteps = [], @@ -43,6 +44,7 @@ public static function fromReasonResolutionEnvelope( operatorLabel: $reason->operatorLabel, shortExplanation: $reason->shortExplanation, diagnosticCode: $reason->diagnosticCode(), + actionability: $reason->actionability, trustImpact: $reason->trustImpact, absencePattern: $reason->absencePattern, nextSteps: array_values(array_map( @@ -79,7 +81,8 @@ public function toReasonResolutionEnvelope(): ReasonResolutionEnvelope internalCode: $this->reasonCode ?? 'artifact_truth_reason', operatorLabel: $this->operatorLabel ?? 'Operator attention required', shortExplanation: $this->shortExplanation ?? 'Technical diagnostics are available for this result.', - actionability: $this->absencePattern === 'true_no_result' ? 'non_actionable' : 'prerequisite_missing', + actionability: $this->actionability + ?? ($this->absencePattern === 'true_no_result' ? 'non_actionable' : 'prerequisite_missing'), nextSteps: array_map( static fn (string $label): NextStepOption => NextStepOption::instruction($label), $this->nextSteps, @@ -98,6 +101,7 @@ public function toReasonResolutionEnvelope(): ReasonResolutionEnvelope * operatorLabel: ?string, * shortExplanation: ?string, * diagnosticCode: ?string, + * actionability: ?string, * trustImpact: string, * absencePattern: ?string, * nextSteps: array, @@ -114,6 +118,7 @@ public function toArray(): array 'operatorLabel' => $this->operatorLabel, 'shortExplanation' => $this->shortExplanation, 'diagnosticCode' => $this->diagnosticCode, + 'actionability' => $this->actionability, 'trustImpact' => $this->trustImpact, 'absencePattern' => $this->absencePattern, 'nextSteps' => $this->nextSteps, diff --git a/apps/platform/app/Support/Workspaces/WorkspaceOverviewBuilder.php b/apps/platform/app/Support/Workspaces/WorkspaceOverviewBuilder.php index 44d28e59..c63075ab 100644 --- a/apps/platform/app/Support/Workspaces/WorkspaceOverviewBuilder.php +++ b/apps/platform/app/Support/Workspaces/WorkspaceOverviewBuilder.php @@ -6,10 +6,12 @@ use App\Filament\Pages\BaselineCompareLanding; use App\Filament\Pages\ChooseTenant; +use App\Filament\Pages\Findings\MyFindingsInbox; use App\Filament\Pages\TenantDashboard; use App\Filament\Resources\FindingResource; use App\Filament\Resources\TenantResource; use App\Models\AlertDelivery; +use App\Models\Finding; use App\Models\FindingException; use App\Models\OperationRun; use App\Models\Tenant; @@ -40,6 +42,7 @@ use App\Support\Tenants\TenantRecoveryTriagePresentation; use Illuminate\Database\Eloquent\Builder; use Illuminate\Support\Collection; +use Illuminate\Support\Str; final class WorkspaceOverviewBuilder { @@ -130,6 +133,8 @@ public function build(Workspace $workspace, User $user): array 'action_url' => $calmness['next_action']['url'] ?? ChooseTenant::getUrl(panel: 'admin'), ]; + $myFindingsSignal = $this->myFindingsSignal($workspaceId, $accessibleTenants, $user); + $zeroTenantState = null; if ($accessibleTenants->isEmpty()) { @@ -168,6 +173,7 @@ public function build(Workspace $workspace, User $user): array 'workspace_id' => $workspaceId, 'workspace_name' => (string) $workspace->name, 'accessible_tenant_count' => $accessibleTenants->count(), + 'my_findings_signal' => $myFindingsSignal, 'summary_metrics' => $summaryMetrics, 'triage_review_progress' => $triageReviewProgress['families'], 'attention_items' => $attentionItems, @@ -198,6 +204,68 @@ private function accessibleTenants(Workspace $workspace, User $user): Collection ->get(['id', 'name', 'external_id', 'workspace_id']); } + /** + * @param Collection $accessibleTenants + * @return array + */ + private function myFindingsSignal(int $workspaceId, Collection $accessibleTenants, User $user): array + { + $visibleTenantIds = $accessibleTenants + ->filter(fn (Tenant $tenant): bool => $this->capabilityResolver->can($user, $tenant, Capabilities::TENANT_FINDINGS_VIEW)) + ->pluck('id') + ->map(static fn (mixed $id): int => (int) $id) + ->values() + ->all(); + + $openAssignedCount = $visibleTenantIds === [] + ? 0 + : (int) $this->scopeToVisibleTenants( + Finding::query(), + $workspaceId, + $visibleTenantIds, + ) + ->where('assignee_user_id', (int) $user->getKey()) + ->whereIn('status', Finding::openStatusesForQuery()) + ->count(); + + $overdueAssignedCount = $visibleTenantIds === [] + ? 0 + : (int) $this->scopeToVisibleTenants( + Finding::query(), + $workspaceId, + $visibleTenantIds, + ) + ->where('assignee_user_id', (int) $user->getKey()) + ->whereIn('status', Finding::openStatusesForQuery()) + ->whereNotNull('due_at') + ->where('due_at', '<', now()) + ->count(); + + $isCalm = $openAssignedCount === 0; + + return [ + 'headline' => $isCalm + ? 'Assigned work is calm' + : sprintf( + '%d assigned %s visible in this workspace', + $openAssignedCount, + Str::plural('finding', $openAssignedCount), + ), + 'description' => $isCalm + ? 'No visible assigned findings currently need attention across your entitled tenants.' + : sprintf( + 'Visible assigned work stays in one queue. %d overdue %s currently need follow-up.', + $overdueAssignedCount, + Str::plural('finding', $overdueAssignedCount), + ), + 'open_assigned_count' => $openAssignedCount, + 'overdue_assigned_count' => $overdueAssignedCount, + 'is_calm' => $isCalm, + 'cta_label' => 'Open my findings', + 'cta_url' => MyFindingsInbox::getUrl(panel: 'admin'), + ]; + } + /** * @param Collection $accessibleTenants * @return list> diff --git a/apps/platform/config/tenantpilot.php b/apps/platform/config/tenantpilot.php index e2b81a67..aaefb635 100644 --- a/apps/platform/config/tenantpilot.php +++ b/apps/platform/config/tenantpilot.php @@ -76,6 +76,14 @@ 'direct_failed_bridge' => false, 'scheduled_reconciliation' => true, ], + 'backup_set.update' => [ + 'job_class' => \App\Jobs\AddPoliciesToBackupSetJob::class, + 'queued_stale_after_seconds' => 300, + 'running_stale_after_seconds' => 900, + 'expected_max_runtime_seconds' => 240, + 'direct_failed_bridge' => false, + 'scheduled_reconciliation' => true, + ], 'backup_schedule_run' => [ 'job_class' => \App\Jobs\RunBackupScheduleJob::class, 'queued_stale_after_seconds' => 300, diff --git a/apps/platform/resources/views/filament/pages/findings/my-findings-inbox.blade.php b/apps/platform/resources/views/filament/pages/findings/my-findings-inbox.blade.php new file mode 100644 index 00000000..b8aea460 --- /dev/null +++ b/apps/platform/resources/views/filament/pages/findings/my-findings-inbox.blade.php @@ -0,0 +1,92 @@ + + @php($scope = $this->appliedScope()) + @php($summary = $this->summaryCounts()) + @php($availableFilters = $this->availableFilters()) + +
+ +
+
+
+ + Assigned to me +
+ +
+

+ My Findings +

+ +

+ Review open assigned findings across visible tenants in one queue. Tenant context can narrow the view, but the personal assignment scope stays fixed. +

+
+
+ +
+
+
+ Open assigned +
+
+ {{ $summary['open_assigned'] }} +
+
+ Visible rows after the current filters. +
+
+ +
+
+ Overdue +
+
+ {{ $summary['overdue_assigned'] }} +
+
+ Assigned findings that are already past due. +
+
+ +
+
+ Applied scope +
+
+ Assigned to me only +
+
+ @if (($scope['tenant_prefilter_source'] ?? 'none') === 'active_tenant_context') + Tenant prefilter from active context: + {{ $scope['tenant_label'] }} + @elseif (($scope['tenant_prefilter_source'] ?? 'none') === 'explicit_filter') + Tenant filter applied: + {{ $scope['tenant_label'] }} + @else + All visible tenants are currently included. + @endif +
+
+ +
+
+ Available filters +
+
+ @foreach ($availableFilters as $filter) + + {{ $filter['label'] }} + @if (($filter['fixed'] ?? false) === true) + Fixed + @endif + + @endforeach +
+
+
+
+
+ + {{ $this->table }} +
+
diff --git a/apps/platform/resources/views/filament/pages/operations/tenantless-operation-run-viewer.blade.php b/apps/platform/resources/views/filament/pages/operations/tenantless-operation-run-viewer.blade.php index 2581a134..e7bef612 100644 --- a/apps/platform/resources/views/filament/pages/operations/tenantless-operation-run-viewer.blade.php +++ b/apps/platform/resources/views/filament/pages/operations/tenantless-operation-run-viewer.blade.php @@ -11,44 +11,6 @@
- -

- Scope context, return navigation, utility, related drilldowns, and run-specific follow-up stay in separate lanes on this viewer. -

- -
-
-

Scope context

-

{{ $monitoringDetail['scope_label'] }}

-

{{ $monitoringDetail['scope_body'] }}

-
- -
-

Navigation lane

-

{{ $monitoringDetail['navigation_label'] }}

-

{{ $monitoringDetail['navigation_body'] }}

-
- -
-

Utility lane

-

Refresh

-

{{ $monitoringDetail['utility_body'] }}

-
- -
-

Related drilldown

-

Open

-

{{ $monitoringDetail['related_body'] }}

-
- -
-

Follow-up lane

-

{{ $monitoringDetail['follow_up_label'] ?? 'No follow-up action' }}

-

{{ $monitoringDetail['follow_up_body'] }}

-
-
-
- @if ($contextBanner !== null) @php $bannerClasses = match ($contextBanner['tone']) { @@ -117,5 +79,43 @@ @endif {{ $this->infolist }} + + +

+ Scope context, return navigation, utility, related drilldowns, and run-specific follow-up stay in separate lanes on this viewer. +

+ +
+
+

Scope context

+

{{ $monitoringDetail['scope_label'] }}

+

{{ $monitoringDetail['scope_body'] }}

+
+ +
+

Navigation lane

+

{{ $monitoringDetail['navigation_label'] }}

+

{{ $monitoringDetail['navigation_body'] }}

+
+ +
+

Utility lane

+

Refresh

+

{{ $monitoringDetail['utility_body'] }}

+
+ +
+

Related drilldown

+

Open

+

{{ $monitoringDetail['related_body'] }}

+
+ +
+

Follow-up lane

+

{{ $monitoringDetail['follow_up_label'] ?? 'No follow-up action' }}

+

{{ $monitoringDetail['follow_up_body'] }}

+
+
+
diff --git a/apps/platform/resources/views/filament/pages/workspace-overview.blade.php b/apps/platform/resources/views/filament/pages/workspace-overview.blade.php index 1d99d520..c0d6cc96 100644 --- a/apps/platform/resources/views/filament/pages/workspace-overview.blade.php +++ b/apps/platform/resources/views/filament/pages/workspace-overview.blade.php @@ -2,6 +2,7 @@ @php $workspace = $overview['workspace'] ?? ['name' => 'Workspace']; $quickActions = $overview['quick_actions'] ?? []; + $myFindingsSignal = $overview['my_findings_signal'] ?? null; $zeroTenantState = $overview['zero_tenant_state'] ?? null; @endphp @@ -57,6 +58,49 @@ class="rounded-xl border border-gray-200 bg-white px-4 py-3 text-left transition @endif + @if (is_array($myFindingsSignal)) +
+
+
+
+ + Assigned to me +
+ +
+

+ {{ $myFindingsSignal['headline'] }} +

+

+ {{ $myFindingsSignal['description'] }} +

+
+ +
+ + Open assigned: {{ $myFindingsSignal['open_assigned_count'] }} + + + Overdue: {{ $myFindingsSignal['overdue_assigned_count'] }} + + + {{ ($myFindingsSignal['is_calm'] ?? false) ? 'Calm' : 'Needs follow-up' }} + +
+
+ + + {{ $myFindingsSignal['cta_label'] }} + +
+
+ @endif + @if (is_array($zeroTenantState))
diff --git a/apps/platform/tests/Browser/Spec177InventoryCoverageTruthSmokeTest.php b/apps/platform/tests/Browser/Spec177InventoryCoverageTruthSmokeTest.php index 5dfd3324..1a815a6c 100644 --- a/apps/platform/tests/Browser/Spec177InventoryCoverageTruthSmokeTest.php +++ b/apps/platform/tests/Browser/Spec177InventoryCoverageTruthSmokeTest.php @@ -123,11 +123,11 @@ function seedSpec177InventoryItemFilterPaginationFixtures(Tenant $tenant): void ]); session()->put(WorkspaceContext::SESSION_KEY, (int) $tenant->workspace_id); - $coverageUrl = InventoryCoverage::getUrl(tenant: $tenant); + $coverageUrl = InventoryCoverage::getUrl(panel: 'tenant', tenant: $tenant); $basisRunUrl = OperationRunLinks::view($run, $tenant); - $inventoryItemsUrl = InventoryItemResource::getUrl('index', tenant: $tenant); + $inventoryItemsUrl = InventoryItemResource::getUrl('index', panel: 'tenant', tenant: $tenant); - $searchPage = visit(InventoryItemResource::getUrl('index', tenant: $tenant)); + $searchPage = visit(InventoryItemResource::getUrl('index', panel: 'tenant', tenant: $tenant)); $searchPage ->waitForText('Inventory Items') diff --git a/apps/platform/tests/Browser/Spec192RecordPageHeaderDisciplineSmokeTest.php b/apps/platform/tests/Browser/Spec192RecordPageHeaderDisciplineSmokeTest.php index 8dd1ef7e..745684a5 100644 --- a/apps/platform/tests/Browser/Spec192RecordPageHeaderDisciplineSmokeTest.php +++ b/apps/platform/tests/Browser/Spec192RecordPageHeaderDisciplineSmokeTest.php @@ -239,7 +239,7 @@ function spec192ApprovedFindingException(Tenant $tenant, User $requester) OperationRun::factory()->forTenant($tenant)->create([ 'workspace_id' => (int) $tenant->workspace_id, - 'type' => 'backup_set.add_policies', + 'type' => 'backup_set.update', 'context' => [ 'backup_set_id' => (int) $backupSet->getKey(), ], diff --git a/apps/platform/tests/Feature/Authorization/MyWorkInboxAuthorizationTest.php b/apps/platform/tests/Feature/Authorization/MyWorkInboxAuthorizationTest.php new file mode 100644 index 00000000..c75a5d7e --- /dev/null +++ b/apps/platform/tests/Feature/Authorization/MyWorkInboxAuthorizationTest.php @@ -0,0 +1,171 @@ +create(); + + $workspaceA = Workspace::factory()->create(); + $workspaceB = Workspace::factory()->create(); + + WorkspaceMembership::factory()->create([ + 'workspace_id' => (int) $workspaceA->getKey(), + 'user_id' => (int) $user->getKey(), + 'role' => 'owner', + ]); + + WorkspaceMembership::factory()->create([ + 'workspace_id' => (int) $workspaceB->getKey(), + 'user_id' => (int) $user->getKey(), + 'role' => 'owner', + ]); + + $this->actingAs($user) + ->get(MyFindingsInbox::getUrl(panel: 'admin')) + ->assertRedirect('/admin/choose-workspace'); +}); + +it('returns 404 for users outside the active workspace on the inbox route', function (): void { + $user = User::factory()->create(); + $workspace = Workspace::factory()->create(); + + WorkspaceMembership::factory()->create([ + 'workspace_id' => (int) Workspace::factory()->create()->getKey(), + 'user_id' => (int) $user->getKey(), + 'role' => 'owner', + ]); + + $this->actingAs($user) + ->withSession([WorkspaceContext::SESSION_KEY => (int) $workspace->getKey()]) + ->get(MyFindingsInbox::getUrl(panel: 'admin')) + ->assertNotFound(); +}); + +it('keeps the inbox accessible while suppressing blocked-tenant rows and summary counts', function (): void { + $tenant = Tenant::factory()->create(['status' => 'active']); + [$user, $tenant] = createUserWithTenant($tenant, role: 'readonly', workspaceRole: 'readonly'); + + $finding = Finding::factory()->for($tenant)->create([ + 'workspace_id' => (int) $tenant->workspace_id, + 'assignee_user_id' => (int) $user->getKey(), + 'owner_user_id' => (int) $user->getKey(), + 'status' => Finding::STATUS_NEW, + ]); + + mock(CapabilityResolver::class, function ($mock) use ($tenant): void { + $mock->shouldReceive('primeMemberships')->once(); + $mock->shouldReceive('isMember')->andReturnTrue(); + $mock->shouldReceive('can') + ->andReturnUsing(static function (User $user, Tenant $resolvedTenant, string $capability) use ($tenant): bool { + expect((int) $resolvedTenant->getKey())->toBe((int) $tenant->getKey()); + + return $capability === Capabilities::TENANT_FINDINGS_VIEW ? false : false; + }); + }); + + $this->actingAs($user); + setAdminPanelContext(); + session()->put(WorkspaceContext::SESSION_KEY, (int) $tenant->workspace_id); + + $component = Livewire::actingAs($user)->test(MyFindingsInbox::class) + ->assertCanNotSeeTableRecords([$finding]) + ->assertSee('No visible assigned findings right now'); + + expect($component->instance()->summaryCounts())->toBe([ + 'open_assigned' => 0, + 'overdue_assigned' => 0, + ]); +}); + +it('suppresses hidden-tenant findings and keeps their detail route not found', function (): void { + $visibleTenant = Tenant::factory()->create(['status' => 'active']); + [$user, $visibleTenant] = createUserWithTenant($visibleTenant, role: 'readonly', workspaceRole: 'readonly'); + + $hiddenTenant = Tenant::factory()->create([ + 'status' => 'active', + 'workspace_id' => (int) $visibleTenant->workspace_id, + ]); + + $visibleFinding = Finding::factory()->for($visibleTenant)->create([ + 'workspace_id' => (int) $visibleTenant->workspace_id, + 'assignee_user_id' => (int) $user->getKey(), + 'owner_user_id' => (int) $user->getKey(), + 'status' => Finding::STATUS_TRIAGED, + ]); + + $hiddenFinding = Finding::factory()->for($hiddenTenant)->create([ + 'workspace_id' => (int) $hiddenTenant->workspace_id, + 'assignee_user_id' => (int) $user->getKey(), + 'owner_user_id' => (int) $user->getKey(), + 'status' => Finding::STATUS_NEW, + ]); + + $this->actingAs($user); + setAdminPanelContext(); + session()->put(WorkspaceContext::SESSION_KEY, (int) $visibleTenant->workspace_id); + + Livewire::actingAs($user) + ->test(MyFindingsInbox::class) + ->assertCanSeeTableRecords([$visibleFinding]) + ->assertCanNotSeeTableRecords([$hiddenFinding]); + + $this->actingAs($user) + ->withSession([WorkspaceContext::SESSION_KEY => (int) $visibleTenant->workspace_id]) + ->get(FindingResource::getUrl('view', ['record' => $hiddenFinding], panel: 'tenant', tenant: $hiddenTenant)) + ->assertNotFound(); +}); + +it('preserves forbidden detail destinations for workspace members who still lack findings capability', function (): void { + $tenant = Tenant::factory()->create(['status' => 'active']); + [$user, $tenant] = createUserWithTenant($tenant, role: 'readonly', workspaceRole: 'readonly'); + + $finding = Finding::factory()->for($tenant)->create([ + 'workspace_id' => (int) $tenant->workspace_id, + 'assignee_user_id' => (int) $user->getKey(), + 'owner_user_id' => (int) $user->getKey(), + 'status' => Finding::STATUS_NEW, + ]); + + mock(CapabilityResolver::class, function ($mock) use ($tenant): void { + $mock->shouldReceive('primeMemberships')->once(); + $mock->shouldReceive('isMember')->andReturnTrue(); + $mock->shouldReceive('can') + ->andReturnUsing(static function (User $user, Tenant $resolvedTenant, string $capability) use ($tenant): bool { + expect((int) $resolvedTenant->getKey())->toBe((int) $tenant->getKey()); + + return $capability === Capabilities::TENANT_FINDINGS_VIEW ? false : false; + }); + }); + + $this->actingAs($user); + setAdminPanelContext(); + session()->put(WorkspaceContext::SESSION_KEY, (int) $tenant->workspace_id); + + Livewire::actingAs($user) + ->test(MyFindingsInbox::class) + ->assertCanNotSeeTableRecords([$finding]); + + Gate::define(Capabilities::TENANT_FINDINGS_VIEW, static function (User $user, ?Tenant $resolvedTenant = null): bool { + return false; + }); + + $this->actingAs($user) + ->withSession([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id]) + ->get(FindingResource::getUrl('view', ['record' => $finding], panel: 'tenant', tenant: $tenant)) + ->assertForbidden(); +}); diff --git a/apps/platform/tests/Feature/Authorization/OperatorExplanationSurfaceAuthorizationTest.php b/apps/platform/tests/Feature/Authorization/OperatorExplanationSurfaceAuthorizationTest.php index 706d9594..b0a2f20b 100644 --- a/apps/platform/tests/Feature/Authorization/OperatorExplanationSurfaceAuthorizationTest.php +++ b/apps/platform/tests/Feature/Authorization/OperatorExplanationSurfaceAuthorizationTest.php @@ -3,6 +3,7 @@ declare(strict_types=1); use App\Filament\Pages\BaselineCompareLanding; +use App\Filament\Pages\Operations\TenantlessOperationRunViewer; use App\Filament\Pages\Reviews\ReviewRegister; use App\Filament\Resources\BaselineSnapshotResource; use App\Filament\Resources\TenantReviewResource; @@ -15,6 +16,8 @@ use App\Models\WorkspaceMembership; use App\Services\Auth\WorkspaceCapabilityResolver; use App\Support\Workspaces\WorkspaceContext; +use Filament\Facades\Filament; +use Livewire\Livewire; it('returns 404 for non-members on the baseline compare explanation surface', function (): void { [$member, $tenant] = createUserWithTenant(role: 'owner'); @@ -99,3 +102,65 @@ ->get(ReviewRegister::getUrl(panel: 'admin')) ->assertNotFound(); }); + +it('renders governance summary facts for entitled viewers on the canonical run detail surface', function (): void { + $tenant = Tenant::factory()->create(); + [$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner'); + + $run = OperationRun::factory()->create([ + 'tenant_id' => (int) $tenant->getKey(), + 'workspace_id' => (int) $tenant->workspace_id, + 'type' => 'baseline_compare', + 'status' => 'completed', + 'outcome' => 'partially_succeeded', + 'context' => [ + 'baseline_compare' => [ + 'reason_code' => 'ambiguous_subjects', + 'evidence_gaps' => [ + 'count' => 2, + ], + ], + ], + 'completed_at' => now(), + ]); + + Filament::setTenant(null, true); + $this->withSession([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id]); + session([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id]); + + Livewire::actingAs($user) + ->test(TenantlessOperationRunViewer::class, ['run' => $run]) + ->assertSee('Artifact impact') + ->assertSee('Dominant cause') + ->assertSee('Ambiguous matches'); +}); + +it('keeps governance summary surfaces deny-as-not-found for workspace members without tenant entitlement', function (): void { + $workspace = Workspace::factory()->create(); + $tenant = Tenant::factory()->create([ + 'workspace_id' => (int) $workspace->getKey(), + 'status' => 'active', + ]); + + $user = User::factory()->create(); + + WorkspaceMembership::factory()->create([ + 'workspace_id' => (int) $workspace->getKey(), + 'user_id' => (int) $user->getKey(), + 'role' => 'owner', + ]); + + $run = OperationRun::factory()->create([ + 'tenant_id' => (int) $tenant->getKey(), + 'workspace_id' => (int) $workspace->getKey(), + 'type' => 'tenant.review_pack.generate', + 'status' => 'completed', + 'outcome' => 'succeeded', + 'completed_at' => now(), + ]); + + $this->actingAs($user) + ->withSession([WorkspaceContext::SESSION_KEY => (int) $workspace->getKey()]) + ->get(route('admin.operations.view', ['run' => (int) $run->getKey()])) + ->assertNotFound(); +}); diff --git a/apps/platform/tests/Feature/BackupSets/AddPoliciesStartSurfaceTest.php b/apps/platform/tests/Feature/BackupSets/AddPoliciesStartSurfaceTest.php index f6c82cdd..5bb674ab 100644 --- a/apps/platform/tests/Feature/BackupSets/AddPoliciesStartSurfaceTest.php +++ b/apps/platform/tests/Feature/BackupSets/AddPoliciesStartSurfaceTest.php @@ -49,7 +49,7 @@ $opRun = OperationRun::query() ->where('tenant_id', $tenant->getKey()) - ->where('type', 'backup_set.add_policies') + ->where('type', 'backup_set.update') ->latest('id') ->first(); diff --git a/apps/platform/tests/Feature/BackupSets/AddPoliciesToBackupSetJobTest.php b/apps/platform/tests/Feature/BackupSets/AddPoliciesToBackupSetJobTest.php index 95a17b31..f327e848 100644 --- a/apps/platform/tests/Feature/BackupSets/AddPoliciesToBackupSetJobTest.php +++ b/apps/platform/tests/Feature/BackupSets/AddPoliciesToBackupSetJobTest.php @@ -49,7 +49,7 @@ 'tenant_id' => $tenant->id, 'user_id' => $user->id, 'initiator_name' => $user->name, - 'type' => 'backup_set.add_policies', + 'type' => 'backup_set.update', 'status' => 'queued', 'outcome' => 'pending', 'context' => [ @@ -202,7 +202,7 @@ 'tenant_id' => $tenant->id, 'user_id' => $user->id, 'initiator_name' => $user->name, - 'type' => 'backup_set.add_policies', + 'type' => 'backup_set.update', 'status' => 'queued', 'outcome' => 'pending', 'context' => [ diff --git a/apps/platform/tests/Feature/BackupSets/RemovePoliciesJobNotificationTest.php b/apps/platform/tests/Feature/BackupSets/RemovePoliciesJobNotificationTest.php index e6321ea1..7d8d81c0 100644 --- a/apps/platform/tests/Feature/BackupSets/RemovePoliciesJobNotificationTest.php +++ b/apps/platform/tests/Feature/BackupSets/RemovePoliciesJobNotificationTest.php @@ -28,7 +28,7 @@ 'tenant_id' => $tenant->getKey(), 'user_id' => $user->getKey(), 'initiator_name' => $user->name, - 'type' => 'backup_set.remove_policies', + 'type' => 'backup_set.update', 'status' => 'queued', 'outcome' => 'pending', 'run_identity_hash' => 'remove-hash-1', diff --git a/apps/platform/tests/Feature/BackupSets/RemovePoliciesStartSurfaceTest.php b/apps/platform/tests/Feature/BackupSets/RemovePoliciesStartSurfaceTest.php index 585b93d2..8a3d03b3 100644 --- a/apps/platform/tests/Feature/BackupSets/RemovePoliciesStartSurfaceTest.php +++ b/apps/platform/tests/Feature/BackupSets/RemovePoliciesStartSurfaceTest.php @@ -50,7 +50,7 @@ $run = OperationRun::query() ->where('tenant_id', $tenant->getKey()) - ->where('type', 'backup_set.remove_policies') + ->where('type', 'backup_set.update') ->latest('id') ->first(); diff --git a/apps/platform/tests/Feature/Concerns/BuildsGovernanceArtifactTruthFixtures.php b/apps/platform/tests/Feature/Concerns/BuildsGovernanceArtifactTruthFixtures.php index 94d07c1e..49ba4954 100644 --- a/apps/platform/tests/Feature/Concerns/BuildsGovernanceArtifactTruthFixtures.php +++ b/apps/platform/tests/Feature/Concerns/BuildsGovernanceArtifactTruthFixtures.php @@ -79,6 +79,24 @@ protected function makePartialArtifactTruthEvidenceSnapshot( ); } + protected function makeMissingArtifactTruthEvidenceSnapshot( + Tenant $tenant, + array $snapshotOverrides = [], + array $summaryOverrides = [], + ): EvidenceSnapshot { + $snapshot = $this->makeArtifactTruthEvidenceSnapshot($tenant, $snapshotOverrides, $summaryOverrides); + + return $this->restateArtifactTruthEvidenceSnapshot( + $snapshot, + EvidenceCompletenessState::Missing, + array_replace([ + 'dimension_count' => 0, + 'missing_dimensions' => 1, + 'stale_dimensions' => 0, + ], $summaryOverrides), + ); + } + protected function makeArtifactTruthReview( Tenant $tenant, User $user, @@ -115,6 +133,32 @@ protected function makeArtifactTruthReview( return TenantReview::query()->create(array_replace($defaults, $reviewOverrides)); } + protected function makePartialArtifactTruthReview( + Tenant $tenant, + User $user, + ?EvidenceSnapshot $snapshot = null, + array $reviewOverrides = [], + array $summaryOverrides = [], + ): TenantReview { + return $this->makeArtifactTruthReview( + tenant: $tenant, + user: $user, + snapshot: $snapshot, + reviewOverrides: array_replace([ + 'status' => TenantReviewStatus::Ready->value, + 'completeness_state' => TenantReviewCompletenessState::Partial->value, + ], $reviewOverrides), + summaryOverrides: array_replace_recursive([ + 'section_state_counts' => [ + 'complete' => 4, + 'partial' => 1, + 'missing' => 1, + 'stale' => 0, + ], + ], $summaryOverrides), + ); + } + protected function makeBlockedArtifactTruthReview( Tenant $tenant, User $user, diff --git a/apps/platform/tests/Feature/Dashboard/MyFindingsSignalTest.php b/apps/platform/tests/Feature/Dashboard/MyFindingsSignalTest.php new file mode 100644 index 00000000..466d25e4 --- /dev/null +++ b/apps/platform/tests/Feature/Dashboard/MyFindingsSignalTest.php @@ -0,0 +1,146 @@ +create(['status' => 'active']); + [$user, $tenantA] = createUserWithTenant($tenantA, role: 'readonly', workspaceRole: 'readonly'); + + $tenantB = Tenant::factory()->create([ + 'status' => 'active', + 'workspace_id' => (int) $tenantA->workspace_id, + 'name' => 'Bravo Tenant', + ]); + createUserWithTenant($tenantB, $user, role: 'readonly', workspaceRole: 'readonly'); + + $hiddenTenant = Tenant::factory()->create([ + 'status' => 'active', + 'workspace_id' => (int) $tenantA->workspace_id, + 'name' => 'Hidden Tenant', + ]); + + Finding::factory()->for($tenantA)->create([ + 'workspace_id' => (int) $tenantA->workspace_id, + 'assignee_user_id' => (int) $user->getKey(), + 'owner_user_id' => (int) $user->getKey(), + 'status' => Finding::STATUS_NEW, + 'due_at' => now()->subHour(), + ]); + + Finding::factory()->for($tenantB)->create([ + 'workspace_id' => (int) $tenantB->workspace_id, + 'assignee_user_id' => (int) $user->getKey(), + 'owner_user_id' => (int) $user->getKey(), + 'status' => Finding::STATUS_TRIAGED, + 'due_at' => now()->addDay(), + ]); + + Finding::factory()->for($tenantB)->create([ + 'workspace_id' => (int) $tenantB->workspace_id, + 'assignee_user_id' => null, + 'owner_user_id' => (int) $user->getKey(), + 'status' => Finding::STATUS_NEW, + ]); + + Finding::factory()->for($hiddenTenant)->create([ + 'workspace_id' => (int) $hiddenTenant->workspace_id, + 'assignee_user_id' => (int) $user->getKey(), + 'owner_user_id' => (int) $user->getKey(), + 'status' => Finding::STATUS_NEW, + ]); + + $signal = app(WorkspaceOverviewBuilder::class) + ->build($tenantA->workspace()->firstOrFail(), $user)['my_findings_signal']; + + expect($signal['open_assigned_count'])->toBe(2) + ->and($signal['overdue_assigned_count'])->toBe(1) + ->and($signal['is_calm'])->toBeFalse() + ->and($signal['cta_label'])->toBe('Open my findings') + ->and($signal['cta_url'])->toBe(MyFindingsInbox::getUrl(panel: 'admin')); +}); + +it('keeps the signal calm when no visible assigned findings remain', function (): void { + $tenant = Tenant::factory()->create(['status' => 'active']); + [$user, $tenant] = createUserWithTenant($tenant, role: 'readonly', workspaceRole: 'readonly'); + + Finding::factory()->for($tenant)->create([ + 'workspace_id' => (int) $tenant->workspace_id, + 'assignee_user_id' => (int) $user->getKey(), + 'owner_user_id' => (int) $user->getKey(), + 'status' => Finding::STATUS_RESOLVED, + 'resolved_at' => now(), + ]); + + $signal = app(WorkspaceOverviewBuilder::class) + ->build($tenant->workspace()->firstOrFail(), $user)['my_findings_signal']; + + expect($signal['open_assigned_count'])->toBe(0) + ->and($signal['overdue_assigned_count'])->toBe(0) + ->and($signal['is_calm'])->toBeTrue() + ->and($signal['description'])->toContain('visible assigned'); +}); + +it('suppresses blocked-tenant findings from the assigned-to-me signal', function (): void { + $visibleTenant = Tenant::factory()->create(['status' => 'active']); + [$user, $visibleTenant] = createUserWithTenant($visibleTenant, role: 'readonly', workspaceRole: 'readonly'); + + $blockedTenant = Tenant::factory()->create([ + 'status' => 'active', + 'workspace_id' => (int) $visibleTenant->workspace_id, + 'name' => 'Blocked Tenant', + ]); + createUserWithTenant($blockedTenant, $user, role: 'readonly', workspaceRole: 'readonly'); + + Finding::factory()->for($visibleTenant)->create([ + 'workspace_id' => (int) $visibleTenant->workspace_id, + 'assignee_user_id' => (int) $user->getKey(), + 'owner_user_id' => (int) $user->getKey(), + 'status' => Finding::STATUS_NEW, + ]); + + Finding::factory()->for($blockedTenant)->create([ + 'workspace_id' => (int) $blockedTenant->workspace_id, + 'assignee_user_id' => (int) $user->getKey(), + 'owner_user_id' => (int) $user->getKey(), + 'status' => Finding::STATUS_NEW, + ]); + + mock(CapabilityResolver::class, function ($mock) use ($visibleTenant, $blockedTenant): void { + $mock->shouldReceive('primeMemberships')->once(); + $mock->shouldReceive('isMember')->andReturnTrue(); + $mock->shouldReceive('can') + ->andReturnUsing(static function (User $user, Tenant $tenant, string $capability) use ($visibleTenant, $blockedTenant): bool { + expect([(int) $visibleTenant->getKey(), (int) $blockedTenant->getKey()]) + ->toContain((int) $tenant->getKey()); + + return match ($capability) { + Capabilities::TENANT_FINDINGS_VIEW => (int) $tenant->getKey() === (int) $visibleTenant->getKey(), + default => false, + }; + }); + }); + + $signal = app(WorkspaceOverviewBuilder::class) + ->build($visibleTenant->workspace()->firstOrFail(), $user)['my_findings_signal']; + + expect($signal['open_assigned_count'])->toBe(1) + ->and($signal['overdue_assigned_count'])->toBe(0) + ->and($signal['description'])->not->toContain($blockedTenant->name); +}); diff --git a/apps/platform/tests/Feature/Directory/ProviderBackedDirectoryStartTest.php b/apps/platform/tests/Feature/Directory/ProviderBackedDirectoryStartTest.php new file mode 100644 index 00000000..43546e1f --- /dev/null +++ b/apps/platform/tests/Feature/Directory/ProviderBackedDirectoryStartTest.php @@ -0,0 +1,77 @@ +mock(GraphClientInterface::class, function ($mock): void { + $mock->shouldReceive('listPolicies')->never(); + $mock->shouldReceive('getPolicy')->never(); + $mock->shouldReceive('getOrganization')->never(); + $mock->shouldReceive('applyPolicy')->never(); + $mock->shouldReceive('getServicePrincipalPermissions')->never(); + $mock->shouldReceive('request')->never(); + }); + + [$user, $tenant] = createUserWithTenant(role: 'owner', fixtureProfile: 'credential-enabled'); + $this->actingAs($user); + + $tenant->makeCurrent(); + Filament::setTenant($tenant, true); + + Livewire::test(ListEntraGroups::class) + ->callAction('sync_groups'); + + $run = OperationRun::query() + ->where('tenant_id', (int) $tenant->getKey()) + ->where('type', 'entra_group_sync') + ->latest('id') + ->first(); + + expect($run)->not->toBeNull(); + expect($run?->status)->toBe('queued'); + expect($run?->context['provider_connection_id'] ?? null)->toBeInt(); + + Queue::assertPushed(EntraGroupSyncJob::class, function (EntraGroupSyncJob $job) use ($run): bool { + return $job->providerConnectionId === ($run?->context['provider_connection_id'] ?? null) + && $job->operationRun?->is($run); + }); +}); + +it('blocks role definitions sync before queue when no provider connection is available', function (): void { + Bus::fake(); + + $tenant = Tenant::factory()->create([ + 'app_client_id' => 'client-123', + 'app_client_secret' => 'secret', + 'status' => 'active', + ]); + + [$user, $tenant] = createUserWithTenant( + tenant: $tenant, + role: 'owner', + ensureDefaultMicrosoftProviderConnection: false, + ); + + $result = app(RoleDefinitionsSyncService::class)->startManualSync($tenant, $user); + + expect($result->status)->toBe('blocked'); + expect($result->run->context['reason_code'] ?? null)->toBe(ProviderReasonCodes::ProviderConnectionMissing); + + Bus::assertNotDispatched(SyncRoleDefinitionsJob::class); +}); diff --git a/apps/platform/tests/Feature/DirectoryGroups/StartSyncFromGroupsPageTest.php b/apps/platform/tests/Feature/DirectoryGroups/StartSyncFromGroupsPageTest.php index 31f170c6..ea6b724f 100644 --- a/apps/platform/tests/Feature/DirectoryGroups/StartSyncFromGroupsPageTest.php +++ b/apps/platform/tests/Feature/DirectoryGroups/StartSyncFromGroupsPageTest.php @@ -38,11 +38,13 @@ expect($opRun)->not->toBeNull(); expect($opRun?->status)->toBe('queued'); expect($opRun?->context['selection_key'] ?? null)->toBe('groups-v1:all'); + expect($opRun?->context['provider_connection_id'] ?? null)->toBeInt(); Queue::assertPushed(EntraGroupSyncJob::class, function (EntraGroupSyncJob $job) use ($tenant, $opRun): bool { return $job->tenantId === (int) $tenant->getKey() && $job->selectionKey === 'groups-v1:all' && $job->runId === null + && $job->providerConnectionId === ($opRun?->context['provider_connection_id'] ?? null) && $job->operationRun instanceof OperationRun && (int) $job->operationRun->getKey() === (int) $opRun?->getKey(); }); diff --git a/apps/platform/tests/Feature/DirectoryGroups/StartSyncTest.php b/apps/platform/tests/Feature/DirectoryGroups/StartSyncTest.php index c731a44f..e9a8c70c 100644 --- a/apps/platform/tests/Feature/DirectoryGroups/StartSyncTest.php +++ b/apps/platform/tests/Feature/DirectoryGroups/StartSyncTest.php @@ -1,8 +1,8 @@ startManualSync($tenant, $user); + $result = $service->startManualSync($tenant, $user); + $run = $result->run; - expect($run)->toBeInstanceOf(OperationRun::class) + expect($result)->toBeInstanceOf(ProviderOperationStartResult::class) + ->and($result->status)->toBe('started'); + + expect($run) ->and($run->tenant_id)->toBe($tenant->getKey()) ->and($run->user_id)->toBe($user->getKey()) ->and($run->type)->toBe('entra_group_sync') ->and($run->status)->toBe('queued') - ->and($run->context['selection_key'] ?? null)->toBe('groups-v1:all'); + ->and($run->context['selection_key'] ?? null)->toBe('groups-v1:all') + ->and($run->context['provider_connection_id'] ?? null)->toBeInt(); Queue::assertPushed(EntraGroupSyncJob::class); }); diff --git a/apps/platform/tests/Feature/Filament/BackupItemsBulkRemoveTest.php b/apps/platform/tests/Feature/Filament/BackupItemsBulkRemoveTest.php index a96591bd..b8658769 100644 --- a/apps/platform/tests/Feature/Filament/BackupItemsBulkRemoveTest.php +++ b/apps/platform/tests/Feature/Filament/BackupItemsBulkRemoveTest.php @@ -74,7 +74,7 @@ $run = OperationRun::query() ->where('tenant_id', $tenant->getKey()) - ->where('type', 'backup_set.remove_policies') + ->where('type', 'backup_set.update') ->latest('id') ->first(); diff --git a/apps/platform/tests/Feature/Filament/BackupSetEnterpriseDetailPageTest.php b/apps/platform/tests/Feature/Filament/BackupSetEnterpriseDetailPageTest.php index ee944e70..31b1c1ba 100644 --- a/apps/platform/tests/Feature/Filament/BackupSetEnterpriseDetailPageTest.php +++ b/apps/platform/tests/Feature/Filament/BackupSetEnterpriseDetailPageTest.php @@ -43,7 +43,7 @@ $run = OperationRun::factory()->for($tenant)->create([ 'workspace_id' => (int) $tenant->workspace_id, - 'type' => 'backup_set.add_policies', + 'type' => 'backup_set.update', 'context' => [ 'backup_set_id' => (int) $backupSet->getKey(), ], diff --git a/apps/platform/tests/Feature/Filament/BackupSetPolicyPickerTableTest.php b/apps/platform/tests/Feature/Filament/BackupSetPolicyPickerTableTest.php index fb663aae..8f25542c 100644 --- a/apps/platform/tests/Feature/Filament/BackupSetPolicyPickerTableTest.php +++ b/apps/platform/tests/Feature/Filament/BackupSetPolicyPickerTableTest.php @@ -62,7 +62,7 @@ $run = OperationRun::query() ->where('tenant_id', $tenant->id) - ->where('type', 'backup_set.add_policies') + ->where('type', 'backup_set.update') ->latest('id') ->first(); @@ -71,7 +71,7 @@ expect($run?->outcome)->toBe('pending'); expect($run?->context['backup_set_id'] ?? null)->toBe($backupSet->getKey()); expect($run?->context['policy_count'] ?? null)->toBe(count($policyIds)); - expect($run?->context['operation']['type'] ?? null)->toBe('backup_set.add_policies'); + expect($run?->context['operation']['type'] ?? null)->toBe('backup_set.update'); expect($run?->context['selection']['kind'] ?? null)->toBe('ids'); expect($run?->context['idempotency']['fingerprint'] ?? null)->not->toBeNull(); @@ -122,13 +122,13 @@ expect(OperationRun::query() ->where('tenant_id', $tenant->id) - ->where('type', 'backup_set.add_policies') + ->where('type', 'backup_set.update') ->count())->toBe(1); Queue::assertPushed(AddPoliciesToBackupSetJob::class, 1); $notifications = session('filament.notifications', []); - $expectedToast = OperationUxPresenter::alreadyQueuedToast('backup_set.add_policies'); + $expectedToast = OperationUxPresenter::alreadyQueuedToast('backup_set.update'); expect($notifications)->not->toBeEmpty(); expect(collect($notifications)->last()['title'] ?? null)->toBe($expectedToast->getTitle()); @@ -173,7 +173,7 @@ expect(OperationRun::query() ->where('tenant_id', $tenant->id) - ->where('type', 'backup_set.add_policies') + ->where('type', 'backup_set.update') ->exists())->toBeFalse(); }); @@ -223,12 +223,12 @@ expect(OperationRun::query() ->where('tenant_id', $tenantA->id) - ->where('type', 'backup_set.add_policies') + ->where('type', 'backup_set.update') ->exists())->toBeFalse(); expect(OperationRun::query() ->where('tenant_id', $tenantB->id) - ->where('type', 'backup_set.add_policies') + ->where('type', 'backup_set.update') ->exists())->toBeFalse(); }); diff --git a/apps/platform/tests/Feature/Filament/BackupSetRelatedNavigationTest.php b/apps/platform/tests/Feature/Filament/BackupSetRelatedNavigationTest.php index d8a9deb6..df00f0fd 100644 --- a/apps/platform/tests/Feature/Filament/BackupSetRelatedNavigationTest.php +++ b/apps/platform/tests/Feature/Filament/BackupSetRelatedNavigationTest.php @@ -19,7 +19,7 @@ $run = OperationRun::factory()->for($tenant)->create([ 'workspace_id' => (int) $tenant->workspace_id, - 'type' => 'backup_set.add_policies', + 'type' => 'backup_set.update', 'context' => [ 'backup_set_id' => (int) $backupSet->getKey(), ], diff --git a/apps/platform/tests/Feature/Filament/BackupSetResolvedReferencePresentationTest.php b/apps/platform/tests/Feature/Filament/BackupSetResolvedReferencePresentationTest.php index 284433f2..b804dbc9 100644 --- a/apps/platform/tests/Feature/Filament/BackupSetResolvedReferencePresentationTest.php +++ b/apps/platform/tests/Feature/Filament/BackupSetResolvedReferencePresentationTest.php @@ -18,7 +18,7 @@ $run = OperationRun::factory()->for($tenant)->create([ 'workspace_id' => (int) $tenant->workspace_id, - 'type' => 'backup_set.add_policies', + 'type' => 'backup_set.update', 'context' => [ 'backup_set_id' => (int) $backupSet->getKey(), ], diff --git a/apps/platform/tests/Feature/Filament/InventoryPagesTest.php b/apps/platform/tests/Feature/Filament/InventoryPagesTest.php index 33222ad5..9e6e716a 100644 --- a/apps/platform/tests/Feature/Filament/InventoryPagesTest.php +++ b/apps/platform/tests/Feature/Filament/InventoryPagesTest.php @@ -66,8 +66,8 @@ function seedInventoryCoverageBasis(Tenant $tenant): OperationRun $basisRun = seedInventoryCoverageBasis($tenant); - $itemsUrl = InventoryItemResource::getUrl('index', tenant: $tenant); - $coverageUrl = InventoryCoverage::getUrl(tenant: $tenant); + $itemsUrl = InventoryItemResource::getUrl('index', panel: 'tenant', tenant: $tenant); + $coverageUrl = InventoryCoverage::getUrl(panel: 'tenant', tenant: $tenant); $this->actingAs($user) ->get($itemsUrl) @@ -102,7 +102,7 @@ function seedInventoryCoverageBasis(Tenant $tenant): OperationRun [$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner'); $this->actingAs($user) - ->get(InventoryCoverage::getUrl(tenant: $tenant)) + ->get(InventoryCoverage::getUrl(panel: 'tenant', tenant: $tenant)) ->assertOk() ->assertSee('No current coverage basis') ->assertSee('Run Inventory Sync from Inventory Items to establish current tenant coverage truth.') diff --git a/apps/platform/tests/Feature/Filament/OperationRunBaselineTruthSurfaceTest.php b/apps/platform/tests/Feature/Filament/OperationRunBaselineTruthSurfaceTest.php index d491fcf4..7e118b98 100644 --- a/apps/platform/tests/Feature/Filament/OperationRunBaselineTruthSurfaceTest.php +++ b/apps/platform/tests/Feature/Filament/OperationRunBaselineTruthSurfaceTest.php @@ -77,7 +77,7 @@ function visibleLivewireText(Testable $component): string ->assertSee('Outcome') ->assertSee('Artifact truth') ->assertSee('Execution failed') - ->assertSee($explanation?->headline ?? '') + ->assertSee('The baseline capture finished without a usable snapshot.') ->assertSee($explanation?->evaluationResultLabel() ?? '') ->assertSee($explanation?->trustworthinessLabel() ?? '') ->assertSee('Artifact not usable') @@ -136,11 +136,10 @@ function visibleLivewireText(Testable $component): string ->assertSee('Result trust') ->assertSee('Primary next step') ->assertSee('Artifact truth details') - ->assertSee($explanation?->headline ?? '') + ->assertSee('The compare finished, but no decision-grade result is available yet.') ->assertSee($explanation?->evaluationResultLabel() ?? '') ->assertSee($explanation?->trustworthinessLabel() ?? '') ->assertSee($explanation?->nextActionText ?? '') - ->assertSee('The run completed, but normal output was intentionally suppressed.') ->assertSee('Resume or rerun evidence capture before relying on this compare result.') ->assertDontSee('Artifact next step'); @@ -206,7 +205,7 @@ function visibleLivewireText(Testable $component): string Livewire::actingAs($user) ->test(TenantlessOperationRunViewer::class, ['run' => $run]) - ->assertSee($explanation?->headline ?? '') + ->assertSee('The compare finished, but a compare strategy failure kept the result incomplete.') ->assertSee($explanation?->nextActionText ?? '') ->assertSee('Compare strategy') ->assertSee('Intune Policy') @@ -314,7 +313,7 @@ function visibleLivewireText(Testable $component): string Livewire::actingAs($user) ->test(TenantlessOperationRunViewer::class, ['run' => $run]) - ->assertSee($explanation?->headline ?? '') + ->assertSee('The compare finished, but no decision-grade result is available yet.') ->assertSee($explanation?->evaluationResultLabel() ?? '') ->assertSee($explanation?->trustworthinessLabel() ?? '') ->assertDontSee('No confirmed drift in the latest baseline compare.'); diff --git a/apps/platform/tests/Feature/Filament/OperationRunEnterpriseDetailPageTest.php b/apps/platform/tests/Feature/Filament/OperationRunEnterpriseDetailPageTest.php index 59ea351a..8d9b7185 100644 --- a/apps/platform/tests/Feature/Filament/OperationRunEnterpriseDetailPageTest.php +++ b/apps/platform/tests/Feature/Filament/OperationRunEnterpriseDetailPageTest.php @@ -173,7 +173,7 @@ function baselineCompareGapContext(array $overrides = []): array $run = OperationRun::factory()->create([ 'workspace_id' => (int) $tenant->workspace_id, 'tenant_id' => (int) $tenant->getKey(), - 'type' => 'backup_set.add_policies', + 'type' => 'backup_set.update', 'status' => OperationRunStatus::Completed->value, 'outcome' => OperationRunOutcome::Succeeded->value, 'context' => [ diff --git a/apps/platform/tests/Feature/Filament/Resources/FindingResourceOwnershipSemanticsTest.php b/apps/platform/tests/Feature/Filament/Resources/FindingResourceOwnershipSemanticsTest.php new file mode 100644 index 00000000..26946aa0 --- /dev/null +++ b/apps/platform/tests/Feature/Filament/Resources/FindingResourceOwnershipSemanticsTest.php @@ -0,0 +1,242 @@ +for($tenant)->create([ + 'status' => Finding::STATUS_NEW, + 'owner_user_id' => (int) $ownerOnly->getKey(), + 'assignee_user_id' => null, + ]); + + $assignedFinding = Finding::factory()->for($tenant)->create([ + 'status' => Finding::STATUS_TRIAGED, + 'owner_user_id' => (int) $listOwner->getKey(), + 'assignee_user_id' => (int) $listAssignee->getKey(), + ]); + + $assigneeOnlyFinding = Finding::factory()->for($tenant)->create([ + 'status' => Finding::STATUS_IN_PROGRESS, + 'owner_user_id' => null, + 'assignee_user_id' => (int) $assigneeOnly->getKey(), + ]); + + $bothNullFinding = Finding::factory()->for($tenant)->create([ + 'status' => Finding::STATUS_REOPENED, + 'owner_user_id' => null, + 'assignee_user_id' => null, + ]); + + $sameUserFinding = Finding::factory()->for($tenant)->create([ + 'status' => Finding::STATUS_NEW, + 'owner_user_id' => (int) $samePerson->getKey(), + 'assignee_user_id' => (int) $samePerson->getKey(), + ]); + + $this->actingAs($viewer); + $tenant->makeCurrent(); + Filament::setTenant($tenant, true); + + Livewire::test(ListFindings::class) + ->assertCanSeeTableRecords([ + $ownerOnlyFinding, + $assignedFinding, + $assigneeOnlyFinding, + $bothNullFinding, + $sameUserFinding, + ]) + ->assertSee('Responsibility') + ->assertSee('Accountable owner') + ->assertSee('Active assignee') + ->assertSee('owned but unassigned') + ->assertSee('assigned') + ->assertSee('orphaned accountability') + ->assertSee('Owner Only') + ->assertSee('List Owner') + ->assertSee('List Assignee') + ->assertSee('Assignee Only') + ->assertSee('Same Person'); + + Livewire::test(ViewFinding::class, ['record' => $assigneeOnlyFinding->getKey()]) + ->assertSee('Responsibility state') + ->assertSee('orphaned accountability') + ->assertSee('Accountable owner') + ->assertSee('Active assignee') + ->assertSee('Assignee Only'); + + Livewire::test(ViewFinding::class, ['record' => $sameUserFinding->getKey()]) + ->assertSee('assigned') + ->assertSee('Same Person'); +}); + +it('isolates owner accountability and assigned work with separate personal filters', function (): void { + [$viewer, $tenant] = createUserWithTenant(role: 'manager'); + + $otherOwner = tenantFindingUser($tenant, 'Other Owner'); + $otherAssignee = tenantFindingUser($tenant, 'Other Assignee'); + + $assignedToMe = Finding::factory()->for($tenant)->create([ + 'status' => Finding::STATUS_NEW, + 'owner_user_id' => (int) $otherOwner->getKey(), + 'assignee_user_id' => (int) $viewer->getKey(), + ]); + + $ownedByMe = Finding::factory()->for($tenant)->create([ + 'status' => Finding::STATUS_TRIAGED, + 'owner_user_id' => (int) $viewer->getKey(), + 'assignee_user_id' => (int) $otherAssignee->getKey(), + ]); + + $bothMine = Finding::factory()->for($tenant)->create([ + 'status' => Finding::STATUS_REOPENED, + 'owner_user_id' => (int) $viewer->getKey(), + 'assignee_user_id' => (int) $viewer->getKey(), + ]); + + $neitherMine = Finding::factory()->for($tenant)->create([ + 'status' => Finding::STATUS_IN_PROGRESS, + 'owner_user_id' => (int) $otherOwner->getKey(), + 'assignee_user_id' => (int) $otherAssignee->getKey(), + ]); + + $this->actingAs($viewer); + $tenant->makeCurrent(); + Filament::setTenant($tenant, true); + + Livewire::test(ListFindings::class) + ->filterTable('my_assigned', true) + ->assertCanSeeTableRecords([$assignedToMe, $bothMine]) + ->assertCanNotSeeTableRecords([$ownedByMe, $neitherMine]) + ->removeTableFilter('my_assigned') + ->filterTable('my_accountability', true) + ->assertCanSeeTableRecords([$ownedByMe, $bothMine]) + ->assertCanNotSeeTableRecords([$assignedToMe, $neitherMine]); +}); + +it('keeps exception ownership visibly separate from finding ownership', function (): void { + [$owner, $tenant] = createUserWithTenant(role: 'owner'); + + $findingOwner = tenantFindingUser($tenant, 'Finding Owner'); + $findingAssignee = tenantFindingUser($tenant, 'Finding Assignee'); + $exceptionOwner = tenantFindingUser($tenant, 'Exception Owner'); + + $findingWithException = Finding::factory()->for($tenant)->create([ + 'status' => Finding::STATUS_NEW, + 'owner_user_id' => (int) $findingOwner->getKey(), + 'assignee_user_id' => (int) $findingAssignee->getKey(), + ]); + + FindingException::query()->create([ + 'workspace_id' => (int) $tenant->workspace_id, + 'tenant_id' => (int) $tenant->getKey(), + 'finding_id' => (int) $findingWithException->getKey(), + 'requested_by_user_id' => (int) $owner->getKey(), + 'owner_user_id' => (int) $exceptionOwner->getKey(), + 'status' => FindingException::STATUS_PENDING, + 'current_validity_state' => FindingException::VALIDITY_MISSING_SUPPORT, + 'request_reason' => 'Needs temporary governance coverage.', + 'requested_at' => now(), + 'review_due_at' => now()->addDays(7), + 'evidence_summary' => ['reference_count' => 0], + ]); + + $requestFinding = Finding::factory()->for($tenant)->create([ + 'status' => Finding::STATUS_NEW, + 'owner_user_id' => (int) $findingOwner->getKey(), + ]); + + $this->actingAs($owner); + $tenant->makeCurrent(); + Filament::setTenant($tenant, true); + + $this->get(FindingResource::getUrl('view', ['record' => $findingWithException], panel: 'tenant', tenant: $tenant)) + ->assertSuccessful() + ->assertSee('Accountable owner') + ->assertSee('Active assignee') + ->assertSee('Exception owner') + ->assertSee('Finding Owner') + ->assertSee('Finding Assignee') + ->assertSee('Exception Owner'); + + $component = Livewire::test(ViewFinding::class, ['record' => $requestFinding->getKey()]) + ->mountAction('request_exception'); + + $method = new \ReflectionMethod($component->instance(), 'getMountedActionForm'); + $method->setAccessible(true); + + $form = $method->invoke($component->instance()); + + $field = collect($form?->getFlatFields(withHidden: true) ?? []) + ->first(fn (Field $field): bool => $field->getName() === 'owner_user_id'); + + $helperText = collect($field?->getChildSchema(Field::BELOW_CONTENT_SCHEMA_KEY)?->getComponents() ?? []) + ->filter(fn (mixed $schemaComponent): bool => $schemaComponent instanceof Text) + ->map(fn (Text $schemaComponent): string => (string) $schemaComponent->getContent()) + ->implode(' '); + + expect($field?->getLabel())->toBe('Exception owner') + ->and($helperText)->toContain('Owns the exception record') + ->and($helperText)->toContain('not the finding outcome'); +}); + +it('allows in-scope members and returns 404 for non-members on tenant findings routes', function (): void { + $tenant = Tenant::factory()->create(); + [$member, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner'); + + $finding = Finding::factory()->for($tenant)->create(); + + $this->actingAs($member) + ->get(FindingResource::getUrl('index', panel: 'tenant', tenant: $tenant)) + ->assertSuccessful(); + + $this->actingAs($member) + ->get(FindingResource::getUrl('view', ['record' => $finding], panel: 'tenant', tenant: $tenant)) + ->assertSuccessful(); + + $tenantInSameWorkspace = Tenant::factory()->create([ + 'workspace_id' => (int) $tenant->workspace_id, + ]); + [$outsider] = createUserWithTenant(tenant: $tenantInSameWorkspace, role: 'owner'); + + $this->actingAs($outsider) + ->get(FindingResource::getUrl('index', panel: 'tenant', tenant: $tenant)) + ->assertNotFound(); + + $this->actingAs($outsider) + ->get(FindingResource::getUrl('view', ['record' => $finding], panel: 'tenant', tenant: $tenant)) + ->assertNotFound(); +}); + +function tenantFindingUser(Tenant $tenant, string $name): User +{ + $user = User::factory()->create([ + 'name' => $name, + ]); + + createUserWithTenant(tenant: $tenant, user: $user, role: 'operator'); + + return $user; +} diff --git a/apps/platform/tests/Feature/Filament/WorkspaceOverviewNavigationTest.php b/apps/platform/tests/Feature/Filament/WorkspaceOverviewNavigationTest.php index b81986ab..3844e5ce 100644 --- a/apps/platform/tests/Feature/Filament/WorkspaceOverviewNavigationTest.php +++ b/apps/platform/tests/Feature/Filament/WorkspaceOverviewNavigationTest.php @@ -2,6 +2,7 @@ declare(strict_types=1); +use App\Filament\Pages\Findings\MyFindingsInbox; use App\Support\Workspaces\WorkspaceContext; use Filament\Facades\Filament; @@ -13,8 +14,11 @@ ->get('/admin') ->assertOk() ->assertSee('Overview') - ->assertSee('Switch workspace'); + ->assertSee('Switch workspace') + ->assertSee('Assigned to me') + ->assertSee('Open my findings'); expect(Filament::getPanel('admin')->getHomeUrl())->toBe(route('admin.home')); expect((string) $response->getContent())->toContain('href="'.route('admin.home').'"'); + expect((string) $response->getContent())->toContain('href="'.MyFindingsInbox::getUrl(panel: 'admin').'"'); }); diff --git a/apps/platform/tests/Feature/Findings/FindingAssignmentAuditSemanticsTest.php b/apps/platform/tests/Feature/Findings/FindingAssignmentAuditSemanticsTest.php new file mode 100644 index 00000000..c1c42275 --- /dev/null +++ b/apps/platform/tests/Feature/Findings/FindingAssignmentAuditSemanticsTest.php @@ -0,0 +1,84 @@ +create(['name' => 'Existing Owner']); + createUserWithTenant(tenant: $tenant, user: $existingOwner, role: 'manager'); + + $existingAssignee = User::factory()->create(['name' => 'Existing Assignee']); + createUserWithTenant(tenant: $tenant, user: $existingAssignee, role: 'operator'); + + $replacementOwner = User::factory()->create(['name' => 'Replacement Owner']); + createUserWithTenant(tenant: $tenant, user: $replacementOwner, role: 'manager'); + + $replacementAssignee = User::factory()->create(['name' => 'Replacement Assignee']); + createUserWithTenant(tenant: $tenant, user: $replacementAssignee, role: 'operator'); + + $finding = $this->makeFindingForWorkflow($tenant, Finding::STATUS_TRIAGED, [ + 'owner_user_id' => (int) $existingOwner->getKey(), + 'assignee_user_id' => (int) $existingAssignee->getKey(), + ]); + + $ownerUserId = match ($ownerTarget) { + 'replacement' => (int) $replacementOwner->getKey(), + 'clear' => null, + default => (int) $existingOwner->getKey(), + }; + + $assigneeUserId = match ($assigneeTarget) { + 'replacement' => (int) $replacementAssignee->getKey(), + 'clear' => null, + default => (int) $existingAssignee->getKey(), + }; + + $updated = app(FindingWorkflowService::class)->assign( + finding: $finding, + tenant: $tenant, + actor: $actor, + assigneeUserId: $assigneeUserId, + ownerUserId: $ownerUserId, + ); + + $audit = $this->latestFindingAudit($updated, 'finding.assigned'); + + expect($audit)->not->toBeNull(); + expect(data_get($audit?->metadata, 'responsibility_change_classification'))->toBe($expectedClassification) + ->and(data_get($audit?->metadata, 'responsibility_change_summary'))->toBe($expectedSummary); + + expect($updated->owner_user_id)->toBe($ownerUserId) + ->and($updated->assignee_user_id)->toBe($assigneeUserId); +})->with([ + 'owner only' => ['replacement', 'existing', 'owner_only', 'Updated the accountable owner and kept the active assignee unchanged.'], + 'assignee only' => ['existing', 'replacement', 'assignee_only', 'Updated the active assignee and kept the accountable owner unchanged.'], + 'owner and assignee' => ['replacement', 'replacement', 'owner_and_assignee', 'Updated the accountable owner and the active assignee.'], + 'clear owner' => ['clear', 'existing', 'clear_owner', 'Cleared the accountable owner and kept the active assignee unchanged.'], + 'clear assignee' => ['existing', 'clear', 'clear_assignee', 'Cleared the active assignee and kept the accountable owner unchanged.'], +]); + +it('preserves 403 semantics for in-scope members without assignment capability', function (): void { + [$owner, $tenant] = createUserWithTenant(role: 'owner'); + [$readonly] = createUserWithTenant(tenant: $tenant, role: 'readonly'); + + $finding = $this->makeFindingForWorkflow($tenant, Finding::STATUS_NEW); + + expect(fn () => app(FindingWorkflowService::class)->assign( + finding: $finding, + tenant: $tenant, + actor: $readonly, + assigneeUserId: null, + ownerUserId: (int) $owner->getKey(), + ))->toThrow(AuthorizationException::class); +}); diff --git a/apps/platform/tests/Feature/Findings/FindingWorkflowRowActionsTest.php b/apps/platform/tests/Feature/Findings/FindingWorkflowRowActionsTest.php index 36f93c51..0ebebf11 100644 --- a/apps/platform/tests/Feature/Findings/FindingWorkflowRowActionsTest.php +++ b/apps/platform/tests/Feature/Findings/FindingWorkflowRowActionsTest.php @@ -9,8 +9,12 @@ use App\Models\User; use App\Support\Workspaces\WorkspaceContext; use Filament\Facades\Filament; +use Filament\Forms\Components\Field; +use Filament\Forms\Components\Select; +use Filament\Schemas\Components\Text; use Illuminate\Foundation\Testing\RefreshDatabase; use Livewire\Livewire; +use Symfony\Component\HttpKernel\Exception\NotFoundHttpException; uses(RefreshDatabase::class); @@ -107,41 +111,113 @@ ->and($exception?->request_reason)->toBe('accepted by security'); }); -it('assigns owners and assignees via row action and rejects non-member ids', function (): void { +it('keeps unchanged roles intact and exposes explicit assignment help text on row actions', function (): void { [$manager, $tenant] = createUserWithTenant(role: 'manager'); $this->actingAs($manager); Filament::setTenant($tenant, true); - $assignee = User::factory()->create(); - createUserWithTenant(tenant: $tenant, user: $assignee, role: 'operator'); + $initialOwner = User::factory()->create(); + createUserWithTenant(tenant: $tenant, user: $initialOwner, role: 'manager'); + + $initialAssignee = User::factory()->create(); + createUserWithTenant(tenant: $tenant, user: $initialAssignee, role: 'operator'); + + $replacementOwner = User::factory()->create(); + createUserWithTenant(tenant: $tenant, user: $replacementOwner, role: 'manager'); + + $replacementAssignee = User::factory()->create(); + createUserWithTenant(tenant: $tenant, user: $replacementAssignee, role: 'operator'); $outsider = User::factory()->create(); $finding = Finding::factory()->for($tenant)->create([ 'status' => Finding::STATUS_NEW, + 'owner_user_id' => (int) $initialOwner->getKey(), + 'assignee_user_id' => (int) $initialAssignee->getKey(), ]); - $component = Livewire::test(ListFindings::class); + $component = Livewire::test(ListFindings::class) + ->mountTableAction('assign', $finding) + ->assertFormFieldExists('owner_user_id', function (Select $field): bool { + $helperText = collect($field->getChildSchema(Field::BELOW_CONTENT_SCHEMA_KEY)?->getComponents() ?? []) + ->filter(fn (mixed $component): bool => $component instanceof Text) + ->map(fn (Text $component): string => (string) $component->getContent()) + ->implode(' '); + + return $field->getLabel() === 'Accountable owner' + && str_contains($helperText, 'accountable for ensuring the finding reaches a governed outcome'); + }) + ->assertFormFieldExists('assignee_user_id', function (Select $field): bool { + $helperText = collect($field->getChildSchema(Field::BELOW_CONTENT_SCHEMA_KEY)?->getComponents() ?? []) + ->filter(fn (mixed $component): bool => $component instanceof Text) + ->map(fn (Text $component): string => (string) $component->getContent()) + ->implode(' '); + + return $field->getLabel() === 'Active assignee' + && str_contains($helperText, 'currently expected to perform or coordinate the remediation work'); + }); $component ->callTableAction('assign', $finding, [ - 'assignee_user_id' => (int) $assignee->getKey(), - 'owner_user_id' => (int) $manager->getKey(), + 'assignee_user_id' => (int) $replacementAssignee->getKey(), + 'owner_user_id' => (int) $initialOwner->getKey(), ]) ->assertHasNoTableActionErrors(); $finding->refresh(); - expect((int) $finding->assignee_user_id)->toBe((int) $assignee->getKey()) - ->and((int) $finding->owner_user_id)->toBe((int) $manager->getKey()); + expect((int) $finding->assignee_user_id)->toBe((int) $replacementAssignee->getKey()) + ->and((int) $finding->owner_user_id)->toBe((int) $initialOwner->getKey()); + + $component + ->callTableAction('assign', $finding, [ + 'assignee_user_id' => (int) $replacementAssignee->getKey(), + 'owner_user_id' => (int) $replacementOwner->getKey(), + ]) + ->assertHasNoTableActionErrors(); + + $finding->refresh(); + expect((int) $finding->assignee_user_id)->toBe((int) $replacementAssignee->getKey()) + ->and((int) $finding->owner_user_id)->toBe((int) $replacementOwner->getKey()); $component ->callTableAction('assign', $finding, [ 'assignee_user_id' => (int) $outsider->getKey(), - 'owner_user_id' => (int) $manager->getKey(), + 'owner_user_id' => (int) $replacementOwner->getKey(), ]); $finding->refresh(); - expect((int) $finding->assignee_user_id)->toBe((int) $assignee->getKey()); + expect((int) $finding->assignee_user_id)->toBe((int) $replacementAssignee->getKey()) + ->and((int) $finding->owner_user_id)->toBe((int) $replacementOwner->getKey()); +}); + +it('returns 404 when a forged foreign-tenant assign row action is mounted', function (): void { + $tenantA = Tenant::factory()->create(); + [$user, $tenantA] = createUserWithTenant(tenant: $tenantA, role: 'owner'); + + $tenantB = Tenant::factory()->create([ + 'workspace_id' => (int) $tenantA->workspace_id, + ]); + + createUserWithTenant(tenant: $tenantB, user: $user, role: 'owner'); + + $foreignFinding = Finding::factory()->for($tenantB)->create([ + 'status' => Finding::STATUS_NEW, + ]); + + $this->actingAs($user); + Filament::setCurrentPanel('admin'); + Filament::setTenant(null, true); + Filament::bootCurrentPanel(); + + session()->put(WorkspaceContext::SESSION_KEY, (int) $tenantA->workspace_id); + session()->put(WorkspaceContext::LAST_TENANT_IDS_SESSION_KEY, [ + (string) $tenantA->workspace_id => (int) $tenantA->getKey(), + ]); + + $component = Livewire::actingAs($user)->test(ListFindings::class); + + expect(fn () => $component->instance()->mountTableAction('assign', (string) $foreignFinding->getKey())) + ->toThrow(NotFoundHttpException::class); }); it('keeps the admin workflow surface scoped to the canonical tenant', function (): void { diff --git a/apps/platform/tests/Feature/Findings/FindingWorkflowServiceTest.php b/apps/platform/tests/Feature/Findings/FindingWorkflowServiceTest.php index 265241aa..ff28482e 100644 --- a/apps/platform/tests/Feature/Findings/FindingWorkflowServiceTest.php +++ b/apps/platform/tests/Feature/Findings/FindingWorkflowServiceTest.php @@ -7,6 +7,7 @@ use App\Services\Findings\FindingWorkflowService; use App\Support\Audit\AuditActionId; use Illuminate\Auth\Access\AuthorizationException; +use Symfony\Component\HttpKernel\Exception\NotFoundHttpException; it('enforces the canonical transition matrix for service-driven status changes', function (): void { [$user, $tenant] = createUserWithTenant(role: 'owner'); @@ -57,9 +58,13 @@ ownerUserId: (int) $owner->getKey(), ); + $audit = $this->latestFindingAudit($assignedFinding, AuditActionId::FindingAssigned); + expect((int) $assignedFinding->assignee_user_id)->toBe((int) $assignee->getKey()) ->and((int) $assignedFinding->owner_user_id)->toBe((int) $owner->getKey()) - ->and($this->latestFindingAudit($assignedFinding, AuditActionId::FindingAssigned))->not->toBeNull(); + ->and($audit)->not->toBeNull() + ->and(data_get($audit?->metadata, 'responsibility_change_classification'))->toBe('owner_and_assignee') + ->and(data_get($audit?->metadata, 'responsibility_change_summary'))->toBe('Updated the accountable owner and the active assignee.'); expect(fn () => $service->assign( finding: $assignedFinding, @@ -70,6 +75,31 @@ ))->toThrow(\InvalidArgumentException::class, 'assignee_user_id must reference a current tenant member.'); }); +it('keeps 404 and 403 semantics distinct for assignment authorization', function (): void { + [$owner, $tenant] = createUserWithTenant(role: 'owner'); + [$readonly] = createUserWithTenant(tenant: $tenant, role: 'readonly'); + $outsider = User::factory()->create(); + $finding = $this->makeFindingForWorkflow($tenant, Finding::STATUS_NEW); + + $service = app(FindingWorkflowService::class); + + expect(fn () => $service->assign( + finding: $finding, + tenant: $tenant, + actor: $outsider, + assigneeUserId: null, + ownerUserId: (int) $owner->getKey(), + ))->toThrow(NotFoundHttpException::class); + + expect(fn () => $service->assign( + finding: $finding, + tenant: $tenant, + actor: $readonly, + assigneeUserId: null, + ownerUserId: (int) $owner->getKey(), + ))->toThrow(AuthorizationException::class); +}); + it('requires explicit reasons for resolve close and risk accept mutations', function (): void { [$user, $tenant] = createUserWithTenant(role: 'owner'); diff --git a/apps/platform/tests/Feature/Findings/MyWorkInboxTest.php b/apps/platform/tests/Feature/Findings/MyWorkInboxTest.php new file mode 100644 index 00000000..f5f61c5e --- /dev/null +++ b/apps/platform/tests/Feature/Findings/MyWorkInboxTest.php @@ -0,0 +1,346 @@ +create(['status' => 'active']); + [$user, $tenant] = createUserWithTenant($tenant, role: $role, workspaceRole: $workspaceRole); + + test()->actingAs($user); + setAdminPanelContext(); + session()->put(WorkspaceContext::SESSION_KEY, (int) $tenant->workspace_id); + + return [$user, $tenant]; +} + +function myWorkInboxPage(?User $user = null, array $query = []) +{ + if ($user instanceof User) { + test()->actingAs($user); + } + + setAdminPanelContext(); + + $factory = $query === [] ? Livewire::actingAs(auth()->user()) : Livewire::withQueryParams($query)->actingAs(auth()->user()); + + return $factory->test(MyFindingsInbox::class); +} + +function makeAssignedFindingForInbox(Tenant $tenant, User $assignee, array $attributes = []): Finding +{ + return Finding::factory()->for($tenant)->create(array_merge([ + 'workspace_id' => (int) $tenant->workspace_id, + 'owner_user_id' => (int) $assignee->getKey(), + 'assignee_user_id' => (int) $assignee->getKey(), + 'status' => Finding::STATUS_TRIAGED, + 'subject_external_id' => fake()->uuid(), + ], $attributes)); +} + +it('shows only visible assigned open findings and exposes the fixed filter contract', function (): void { + [$user, $tenantA] = myWorkInboxActingUser(); + $tenantA->forceFill(['name' => 'Alpha Tenant'])->save(); + + $tenantB = Tenant::factory()->create([ + 'status' => 'active', + 'workspace_id' => (int) $tenantA->workspace_id, + 'name' => 'Tenant Bravo', + ]); + createUserWithTenant($tenantB, $user, role: 'readonly', workspaceRole: 'readonly'); + + $hiddenTenant = Tenant::factory()->create([ + 'status' => 'active', + 'workspace_id' => (int) $tenantA->workspace_id, + 'name' => 'Hidden Tenant', + ]); + + $otherAssignee = User::factory()->create(); + createUserWithTenant($tenantA, $otherAssignee, role: 'operator', workspaceRole: 'readonly'); + + $otherOwner = User::factory()->create(); + createUserWithTenant($tenantB, $otherOwner, role: 'owner', workspaceRole: 'readonly'); + + $assignedVisible = makeAssignedFindingForInbox($tenantA, $user, [ + 'subject_external_id' => 'visible-a', + 'severity' => Finding::SEVERITY_HIGH, + 'status' => Finding::STATUS_NEW, + ]); + + $assignedOwnerSplit = makeAssignedFindingForInbox($tenantB, $user, [ + 'subject_external_id' => 'visible-b', + 'owner_user_id' => (int) $otherOwner->getKey(), + 'status' => Finding::STATUS_IN_PROGRESS, + 'due_at' => now()->subDay(), + ]); + + $ownerOnly = makeAssignedFindingForInbox($tenantA, $otherAssignee, [ + 'subject_external_id' => 'owner-only', + 'owner_user_id' => (int) $user->getKey(), + ]); + + $assignedTerminal = makeAssignedFindingForInbox($tenantA, $user, [ + 'subject_external_id' => 'terminal', + 'status' => Finding::STATUS_RESOLVED, + 'resolved_at' => now(), + ]); + + $assignedToOther = makeAssignedFindingForInbox($tenantA, $otherAssignee, [ + 'subject_external_id' => 'other-assignee', + ]); + + $hiddenAssigned = makeAssignedFindingForInbox($hiddenTenant, $user, [ + 'subject_external_id' => 'hidden', + ]); + + $component = myWorkInboxPage($user) + ->assertCanSeeTableRecords([$assignedVisible, $assignedOwnerSplit]) + ->assertCanNotSeeTableRecords([$ownerOnly, $assignedTerminal, $assignedToOther, $hiddenAssigned]) + ->assertSee('Owner: '.$otherOwner->name) + ->assertSee('Assigned to me only'); + + expect($component->instance()->summaryCounts())->toBe([ + 'open_assigned' => 2, + 'overdue_assigned' => 1, + ]); + + expect($component->instance()->availableFilters())->toBe([ + [ + 'key' => 'assignee_scope', + 'label' => 'Assigned to me', + 'fixed' => true, + 'options' => [], + ], + [ + 'key' => 'tenant', + 'label' => 'Tenant', + 'fixed' => false, + 'options' => [ + ['value' => (string) $tenantA->getKey(), 'label' => 'Alpha Tenant'], + ['value' => (string) $tenantB->getKey(), 'label' => $tenantB->name], + ], + ], + [ + 'key' => 'overdue', + 'label' => 'Overdue', + 'fixed' => false, + 'options' => [], + ], + [ + 'key' => 'reopened', + 'label' => 'Reopened', + 'fixed' => false, + 'options' => [], + ], + [ + 'key' => 'high_severity', + 'label' => 'High severity', + 'fixed' => false, + 'options' => [], + ], + ]); +}); + +it('defaults to the active tenant prefilter and lets the operator clear it without dropping personal scope', function (): void { + [$user, $tenantA] = myWorkInboxActingUser(); + + $tenantB = Tenant::factory()->create([ + 'status' => 'active', + 'workspace_id' => (int) $tenantA->workspace_id, + 'name' => 'Beta Tenant', + ]); + createUserWithTenant($tenantB, $user, role: 'readonly', workspaceRole: 'readonly'); + + $findingA = makeAssignedFindingForInbox($tenantA, $user, [ + 'subject_external_id' => 'tenant-a', + 'status' => Finding::STATUS_NEW, + ]); + $findingB = makeAssignedFindingForInbox($tenantB, $user, [ + 'subject_external_id' => 'tenant-b', + 'status' => Finding::STATUS_TRIAGED, + ]); + + session()->put(WorkspaceContext::LAST_TENANT_IDS_SESSION_KEY, [ + (string) $tenantA->workspace_id => (int) $tenantB->getKey(), + ]); + + $component = myWorkInboxPage($user) + ->assertSet('tableFilters.tenant_id.value', (string) $tenantB->getKey()) + ->assertCanSeeTableRecords([$findingB]) + ->assertCanNotSeeTableRecords([$findingA]) + ->assertActionVisible('clear_tenant_filter'); + + expect($component->instance()->appliedScope())->toBe([ + 'workspace_scoped' => true, + 'assignee_scope' => 'current_user_only', + 'tenant_prefilter_source' => 'active_tenant_context', + 'tenant_label' => $tenantB->name, + ]); + + $component->callAction('clear_tenant_filter') + ->assertCanSeeTableRecords([$findingA, $findingB]); + + expect($component->instance()->appliedScope())->toBe([ + 'workspace_scoped' => true, + 'assignee_scope' => 'current_user_only', + 'tenant_prefilter_source' => 'none', + 'tenant_label' => null, + ]); +}); + +it('orders overdue work before reopened work and keeps deterministic due-date and id tie breaks', function (): void { + [$user, $tenant] = myWorkInboxActingUser(); + + $overdue = makeAssignedFindingForInbox($tenant, $user, [ + 'subject_external_id' => 'overdue', + 'status' => Finding::STATUS_IN_PROGRESS, + 'due_at' => now()->subDay(), + ]); + + $reopened = makeAssignedFindingForInbox($tenant, $user, [ + 'subject_external_id' => 'reopened', + 'status' => Finding::STATUS_REOPENED, + 'reopened_at' => now()->subHours(6), + 'due_at' => now()->addDay(), + ]); + + $ordinarySooner = makeAssignedFindingForInbox($tenant, $user, [ + 'subject_external_id' => 'ordinary-sooner', + 'status' => Finding::STATUS_TRIAGED, + 'due_at' => now()->addDays(2), + ]); + + $ordinaryLater = makeAssignedFindingForInbox($tenant, $user, [ + 'subject_external_id' => 'ordinary-later', + 'status' => Finding::STATUS_NEW, + 'due_at' => now()->addDays(4), + ]); + + $ordinaryNoDue = makeAssignedFindingForInbox($tenant, $user, [ + 'subject_external_id' => 'ordinary-no-due', + 'status' => Finding::STATUS_TRIAGED, + 'due_at' => null, + ]); + + myWorkInboxPage($user) + ->assertCanSeeTableRecords([$overdue, $reopened, $ordinarySooner, $ordinaryLater, $ordinaryNoDue], inOrder: true) + ->assertSee('Reopened'); +}); + +it('applies reopened and high-severity filters without widening beyond assigned work', function (): void { + [$user, $tenant] = myWorkInboxActingUser(); + + $reopenedHigh = makeAssignedFindingForInbox($tenant, $user, [ + 'subject_external_id' => 'reopened-high', + 'status' => Finding::STATUS_REOPENED, + 'reopened_at' => now()->subHour(), + 'severity' => Finding::SEVERITY_CRITICAL, + ]); + + $highOnly = makeAssignedFindingForInbox($tenant, $user, [ + 'subject_external_id' => 'high-only', + 'status' => Finding::STATUS_TRIAGED, + 'severity' => Finding::SEVERITY_HIGH, + ]); + + $ordinary = makeAssignedFindingForInbox($tenant, $user, [ + 'subject_external_id' => 'ordinary', + 'status' => Finding::STATUS_NEW, + 'severity' => Finding::SEVERITY_MEDIUM, + ]); + + myWorkInboxPage($user) + ->set('tableFilters.reopened.isActive', true) + ->assertCanSeeTableRecords([$reopenedHigh]) + ->assertCanNotSeeTableRecords([$highOnly, $ordinary]) + ->set('tableFilters.reopened.isActive', false) + ->set('tableFilters.high_severity.isActive', true) + ->assertCanSeeTableRecords([$reopenedHigh, $highOnly]) + ->assertCanNotSeeTableRecords([$ordinary]); +}); + +it('renders the tenant-prefilter empty-state branch and offers only a clear-filter recovery action', function (): void { + [$user, $tenantA] = myWorkInboxActingUser(); + + $tenantB = Tenant::factory()->create([ + 'status' => 'active', + 'workspace_id' => (int) $tenantA->workspace_id, + 'name' => 'Work Tenant', + ]); + createUserWithTenant($tenantB, $user, role: 'readonly', workspaceRole: 'readonly'); + + makeAssignedFindingForInbox($tenantB, $user, [ + 'subject_external_id' => 'available-elsewhere', + ]); + + $component = myWorkInboxPage($user, [ + 'tenant' => (string) $tenantA->external_id, + ]) + ->assertCanNotSeeTableRecords([]) + ->assertSee('No assigned findings match this tenant scope') + ->assertTableEmptyStateActionsExistInOrder(['clear_tenant_filter_empty']); + + expect($component->instance()->summaryCounts())->toBe([ + 'open_assigned' => 0, + 'overdue_assigned' => 0, + ]); +}); + +it('renders the calm zero-work branch and points back to tenant selection when no active tenant context exists', function (): void { + [$user, $tenant] = myWorkInboxActingUser(); + + session()->forget(WorkspaceContext::LAST_TENANT_IDS_SESSION_KEY); + Filament::setTenant(null, true); + + myWorkInboxPage($user) + ->assertSee('No visible assigned findings right now') + ->assertTableEmptyStateActionsExistInOrder(['choose_tenant_empty']); +}); + +it('uses the active visible tenant for the calm empty-state drillback when tenant context exists', function (): void { + [$user, $tenant] = myWorkInboxActingUser(); + + session()->put(WorkspaceContext::LAST_TENANT_IDS_SESSION_KEY, [ + (string) $tenant->workspace_id => (int) $tenant->getKey(), + ]); + + $component = myWorkInboxPage($user) + ->assertSee('No visible assigned findings right now') + ->assertTableEmptyStateActionsExistInOrder(['open_tenant_findings_empty']); + + expect($component->instance()->emptyState())->toMatchArray([ + 'action_name' => 'open_tenant_findings_empty', + 'action_label' => 'Open tenant findings', + 'action_kind' => 'url', + 'action_url' => FindingResource::getUrl('index', panel: 'tenant', tenant: $tenant), + ]); +}); + +it('builds tenant detail drilldowns with inbox continuity', function (): void { + [$user, $tenant] = myWorkInboxActingUser(); + + $finding = makeAssignedFindingForInbox($tenant, $user, [ + 'subject_external_id' => 'continuity', + ]); + + $component = myWorkInboxPage($user); + $detailUrl = $component->instance()->getTable()->getRecordUrl($finding); + + expect($detailUrl)->toContain(FindingResource::getUrl('view', ['record' => $finding], panel: 'tenant', tenant: $tenant)) + ->and($detailUrl)->toContain('nav%5Bback_label%5D=Back+to+my+findings'); + + $this->actingAs($user) + ->withSession([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id]) + ->get($detailUrl) + ->assertOk() + ->assertSee('Back to my findings'); +}); diff --git a/apps/platform/tests/Feature/Guards/ProviderDispatchGateCoverageTest.php b/apps/platform/tests/Feature/Guards/ProviderDispatchGateCoverageTest.php new file mode 100644 index 00000000..d0bf7911 --- /dev/null +++ b/apps/platform/tests/Feature/Guards/ProviderDispatchGateCoverageTest.php @@ -0,0 +1,157 @@ +not->toBeFalse(); + + $start = is_int($start) ? $start : 0; + + if ($endAnchor === null) { + return substr($source, $start); + } + + $end = strpos($source, $endAnchor, $start + strlen($startAnchor)); + + expect($end)->not->toBeFalse(); + + $end = is_int($end) ? $end : strlen($source); + + return substr($source, $start, $end - $start); +} + +it('keeps first-slice route-bounded provider starts on canonical gate-owned entry points', function (): void { + $root = SourceFileScanner::projectRoot(); + + $checks = [ + [ + 'file' => $root.'/app/Services/Verification/StartVerification.php', + 'start' => 'public function providerConnectionCheckForTenant(', + 'end' => 'public function providerConnectionCheckUsingConnection(', + 'required' => [ + 'return $this->providers->start(', + "operationType: 'provider.connection.check'", + "'required_capability' => Capabilities::PROVIDER_RUN", + ], + 'forbidden' => ['ensureRun(', 'dispatchOrFail('], + ], + [ + 'file' => $root.'/app/Services/Verification/StartVerification.php', + 'start' => 'public function providerConnectionCheckUsingConnection(', + 'end' => 'private function dispatchConnectionHealthCheck(', + 'required' => [ + '$result = $this->providers->start(', + "operationType: 'provider.connection.check'", + 'ProviderVerificationStatus::Pending', + ], + 'forbidden' => ['ensureRun(', 'dispatchOrFail('], + ], + [ + 'file' => $root.'/app/Services/Directory/EntraGroupSyncService.php', + 'start' => 'public function startManualSync(', + 'end' => 'public function sync(', + 'required' => [ + 'return $this->providerStarts->start(', + "operationType: 'entra_group_sync'", + 'EntraGroupSyncJob::dispatch(', + '->afterCommit()', + ], + 'forbidden' => ['ensureRun(', 'dispatchOrFail('], + ], + [ + 'file' => $root.'/app/Services/Directory/RoleDefinitionsSyncService.php', + 'start' => 'public function startManualSync(', + 'end' => 'public function sync(', + 'required' => [ + 'return $this->providerStarts->start(', + "operationType: 'directory_role_definitions.sync'", + 'SyncRoleDefinitionsJob::dispatch(', + '->afterCommit()', + ], + 'forbidden' => ['ensureRun(', 'dispatchOrFail('], + ], + [ + 'file' => $root.'/app/Filament/Resources/RestoreRunResource.php', + 'start' => 'private static function startQueuedRestoreExecution(', + 'end' => 'private static function detailPreviewState(', + 'required' => [ + 'app(ProviderOperationStartGate::class)->start(', + "operationType: 'restore.execute'", + 'ExecuteRestoreRunJob::dispatch(', + '->afterCommit()', + ], + 'forbidden' => ['ensureRun(', 'dispatchOrFail('], + ], + [ + 'file' => $root.'/app/Filament/Resources/EntraGroupResource/Pages/ListEntraGroups.php', + 'start' => "Action::make('sync_groups')", + 'end' => '->requireCapability(Capabilities::TENANT_SYNC)', + 'required' => [ + '$syncService->startManualSync($tenant, $user)', + 'ProviderOperationStartResultPresenter::class', + ], + 'forbidden' => ['ensureRun(', 'dispatchOrFail('], + ], + [ + 'file' => $root.'/app/Filament/Resources/TenantResource.php', + 'start' => 'public static function syncRoleDefinitionsAction(): Actions\\Action', + 'end' => null, + 'required' => [ + '$result = $syncService->startManualSync($record, $user);', + 'ProviderOperationStartResultPresenter::class', + ], + 'forbidden' => ['ensureRun(', 'dispatchOrFail('], + ], + [ + 'file' => $root.'/app/Filament/Resources/ProviderConnectionResource.php', + 'start' => 'private static function handleCheckConnectionAction(', + 'end' => 'private static function handleProviderOperationAction(', + 'required' => [ + '$verification->providerConnectionCheck(', + 'ProviderOperationStartResultPresenter::class', + 'OperationRunLinks::view($result->run, $tenant)', + ], + 'forbidden' => ['ensureRun(', 'dispatchOrFail('], + ], + [ + 'file' => $root.'/app/Filament/Resources/ProviderConnectionResource.php', + 'start' => 'private static function handleProviderOperationAction(', + 'end' => 'public static function getEloquentQuery(): Builder', + 'required' => [ + '$result = $gate->start(', + 'ProviderOperationStartResultPresenter::class', + 'OperationRunLinks::view($result->run, $tenant)', + ], + 'forbidden' => ['ensureRun(', 'dispatchOrFail('], + ], + [ + 'file' => $root.'/app/Filament/Pages/Workspaces/ManagedTenantOnboardingWizard.php', + 'start' => 'public function startBootstrap(array $operationTypes): void', + 'end' => 'private function dispatchBootstrapJob(', + 'required' => [ + 'app(ProviderOperationStartGate::class)->start(', + 'ProviderOperationStartResultPresenter::class', + "'bootstrap_operation_types'", + ], + 'forbidden' => ['ensureRun(', 'dispatchOrFail('], + ], + ]; + + foreach ($checks as $check) { + $source = SourceFileScanner::read($check['file']); + $slice = providerDispatchGateSlice($source, $check['start'], $check['end']); + + foreach ($check['required'] as $needle) { + expect($slice)->toContain($needle); + } + + foreach ($check['forbidden'] as $needle) { + expect($slice)->not->toContain($needle); + } + } +})->group('surface-guard'); diff --git a/apps/platform/tests/Feature/Guards/TestLaneManifestTest.php b/apps/platform/tests/Feature/Guards/TestLaneManifestTest.php index bba2b8bc..fa47c3bc 100644 --- a/apps/platform/tests/Feature/Guards/TestLaneManifestTest.php +++ b/apps/platform/tests/Feature/Guards/TestLaneManifestTest.php @@ -136,6 +136,7 @@ ->and($families->has('policy-resource-admin-search-parity'))->toBeTrue() ->and($families->has('workspace-only-admin-surface-independence'))->toBeTrue() ->and($families->has('workspace-settings-slice-management'))->toBeTrue() + ->and($families->has('provider-dispatch-gate-coverage'))->toBeTrue() ->and($families->has('baseline-compare-matrix-workflow'))->toBeTrue() ->and($families->has('browser-smoke'))->toBeTrue(); @@ -159,7 +160,7 @@ expect($familyBudgets)->not->toBeEmpty() ->and($familyBudgets[0])->toHaveKeys(['familyId', 'targetType', 'targetId', 'selectors', 'thresholdSeconds']) ->and(collect($familyBudgets)->pluck('familyId')->all()) - ->toContain('action-surface-contract', 'browser-smoke', 'baseline-compare-matrix-workflow', 'baseline-profile-start-surfaces', 'drift-bulk-triage-all-matching', 'finding-bulk-actions-workflow', 'findings-workflow-surfaces', 'workspace-only-admin-surface-independence', 'workspace-settings-slice-management'); + ->toContain('action-surface-contract', 'browser-smoke', 'baseline-compare-matrix-workflow', 'baseline-profile-start-surfaces', 'drift-bulk-triage-all-matching', 'finding-bulk-actions-workflow', 'findings-workflow-surfaces', 'provider-dispatch-gate-coverage', 'workspace-only-admin-surface-independence', 'workspace-settings-slice-management'); }); it('publishes the heavy-governance contract, inventory, and guidance surfaces needed for honest rerun review', function (): void { diff --git a/apps/platform/tests/Feature/InventoryItemDependenciesTest.php b/apps/platform/tests/Feature/InventoryItemDependenciesTest.php index 30b4c9de..936f1315 100644 --- a/apps/platform/tests/Feature/InventoryItemDependenciesTest.php +++ b/apps/platform/tests/Feature/InventoryItemDependenciesTest.php @@ -5,11 +5,24 @@ use App\Models\InventoryLink; use App\Models\Tenant; use App\Services\Graph\GraphClientInterface; +use App\Support\Workspaces\WorkspaceContext; use Illuminate\Support\Str; +function inventoryItemAdminSession(Tenant $tenant): array +{ + return [ + WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id, + WorkspaceContext::LAST_TENANT_IDS_SESSION_KEY => [ + (string) $tenant->workspace_id => (int) $tenant->getKey(), + ], + ]; +} + it('shows zero-state when no dependencies and shows missing badge when applicable', function () { [$user, $tenant] = createUserWithTenant(); $this->actingAs($user); + setAdminPanelContext(); + $session = inventoryItemAdminSession($tenant); /** @var InventoryItem $item */ $item = InventoryItem::factory()->create([ @@ -19,7 +32,7 @@ // Zero state $url = InventoryItemResource::getUrl('view', ['record' => $item], panel: 'admin').'?tenant='.(string) $tenant->external_id; - $this->get($url)->assertOk()->assertSee('No dependencies found'); + $this->withSession($session)->get($url)->assertOk()->assertSee('No dependencies found'); // Create a missing edge and assert badge appears InventoryLink::factory()->create([ @@ -35,7 +48,7 @@ ], ]); - $this->get($url) + $this->withSession($session)->get($url) ->assertOk() ->assertSee('Missing') ->assertSee('Last known: Ghost Target'); @@ -44,6 +57,8 @@ it('renders native dependency controls in place instead of a GET apply workflow', function () { [$user, $tenant] = createUserWithTenant(); $this->actingAs($user); + setAdminPanelContext(); + $session = inventoryItemAdminSession($tenant); /** @var InventoryItem $item */ $item = InventoryItem::factory()->create([ @@ -82,7 +97,7 @@ $url = InventoryItemResource::getUrl('view', ['record' => $item], panel: 'admin').'?tenant='.(string) $tenant->external_id.'&direction=outbound'; - $this->get($url) + $this->withSession($session)->get($url) ->assertOk() ->assertSee('Direction') ->assertSee('Inbound') @@ -95,6 +110,8 @@ it('ignores legacy relationship query state while preserving visible target safety', function () { [$user, $tenant] = createUserWithTenant(); $this->actingAs($user); + setAdminPanelContext(); + $session = inventoryItemAdminSession($tenant); /** @var InventoryItem $item */ $item = InventoryItem::factory()->create([ @@ -126,7 +143,7 @@ $url = InventoryItemResource::getUrl('view', ['record' => $item], panel: 'admin') .'?tenant='.(string) $tenant->external_id.'&direction=outbound&relationship_type=scoped_by'; - $this->get($url) + $this->withSession($session)->get($url) ->assertOk() ->assertSee('Scoped Target') ->assertSee('Assigned Target'); @@ -135,6 +152,8 @@ it('does not show edges from other tenants (tenant isolation)', function () { [$user, $tenant] = createUserWithTenant(); $this->actingAs($user); + setAdminPanelContext(); + $session = inventoryItemAdminSession($tenant); /** @var InventoryItem $item */ $item = InventoryItem::factory()->create([ @@ -156,7 +175,7 @@ ]); $url = InventoryItemResource::getUrl('view', ['record' => $item], panel: 'admin').'?tenant='.(string) $tenant->external_id; - $this->get($url) + $this->withSession($session)->get($url) ->assertOk() ->assertDontSee('Other Tenant Edge'); }); @@ -164,6 +183,8 @@ it('shows masked identifier when last known name is missing', function () { [$user, $tenant] = createUserWithTenant(); $this->actingAs($user); + setAdminPanelContext(); + $session = inventoryItemAdminSession($tenant); /** @var InventoryItem $item */ $item = InventoryItem::factory()->create([ @@ -185,7 +206,7 @@ ]); $url = InventoryItemResource::getUrl('view', ['record' => $item], panel: 'admin').'?tenant='.(string) $tenant->external_id; - $this->get($url) + $this->withSession($session)->get($url) ->assertOk() ->assertSee('Group (external): 123456…'); }); @@ -193,6 +214,8 @@ it('resolves scope tag and assignment filter names from local inventory when available and labels groups as external', function () { [$user, $tenant] = createUserWithTenant(); $this->actingAs($user); + setAdminPanelContext(); + $session = inventoryItemAdminSession($tenant); /** @var InventoryItem $item */ $item = InventoryItem::factory()->create([ @@ -254,7 +277,7 @@ ]); $url = InventoryItemResource::getUrl('view', ['record' => $item], panel: 'admin').'?tenant='.(string) $tenant->external_id; - $this->get($url) + $this->withSession($session)->get($url) ->assertOk() ->assertSee('Scope Tag: Finance (6…)') ->assertSee('Assignment Filter: VIP Devices (62fb77…)') @@ -264,6 +287,8 @@ it('does not call Graph client while rendering inventory item dependencies view (FR-006 guard)', function () { [$user, $tenant] = createUserWithTenant(); $this->actingAs($user); + setAdminPanelContext(); + $session = inventoryItemAdminSession($tenant); $graph = \Mockery::mock(GraphClientInterface::class); $graph->shouldNotReceive('listPolicies'); @@ -301,7 +326,7 @@ ]); $url = InventoryItemResource::getUrl('view', ['record' => $item], panel: 'admin').'?tenant='.(string) $tenant->external_id; - $this->get($url) + $this->withSession($session)->get($url) ->assertOk() ->assertSee('Scope Tag: Finance'); }); diff --git a/apps/platform/tests/Feature/ManagedTenantOnboardingWizardTest.php b/apps/platform/tests/Feature/ManagedTenantOnboardingWizardTest.php index 7d566c77..2d0c7219 100644 --- a/apps/platform/tests/Feature/ManagedTenantOnboardingWizardTest.php +++ b/apps/platform/tests/Feature/ManagedTenantOnboardingWizardTest.php @@ -992,7 +992,7 @@ expect($session->completed_at)->not->toBeNull(); }); -it('starts selected bootstrap actions as separate operation runs and dispatches their jobs', function (): void { +it('starts one selected bootstrap action at a time and persists the remaining selections', function (): void { Bus::fake(); $workspace = Workspace::factory()->create(); @@ -1048,17 +1048,105 @@ $component->call('startBootstrap', ['inventory_sync', 'compliance.snapshot']); Bus::assertDispatchedTimes(\App\Jobs\ProviderInventorySyncJob::class, 1); - Bus::assertDispatchedTimes(\App\Jobs\ProviderComplianceSnapshotJob::class, 1); + Bus::assertNotDispatched(\App\Jobs\ProviderComplianceSnapshotJob::class); expect(OperationRun::query() ->where('tenant_id', (int) $tenant->getKey()) - ->whereIn('type', ['inventory_sync', 'compliance.snapshot']) - ->count())->toBe(2); + ->where('type', 'inventory_sync') + ->count())->toBe(1); + + expect(OperationRun::query() + ->where('tenant_id', (int) $tenant->getKey()) + ->where('type', 'compliance.snapshot') + ->count())->toBe(0); $session->refresh(); $runs = $session->state['bootstrap_operation_runs'] ?? []; expect($runs)->toBeArray(); expect($runs['inventory_sync'] ?? null)->toBeInt(); + expect($runs['compliance.snapshot'] ?? null)->toBeNull(); + expect($session->state['bootstrap_operation_types'] ?? null)->toBe(['inventory_sync', 'compliance.snapshot']); +}); + +it('starts the next pending bootstrap action after the prior one completes successfully', function (): void { + Bus::fake(); + + $workspace = Workspace::factory()->create(); + $user = User::factory()->create(); + + WorkspaceMembership::factory()->create([ + 'workspace_id' => $workspace->getKey(), + 'user_id' => $user->getKey(), + 'role' => 'owner', + ]); + + $this->actingAs($user); + + $tenantGuid = 'ffffffff-ffff-ffff-ffff-ffffffffffff'; + + $component = Livewire::test(\App\Filament\Pages\Workspaces\ManagedTenantOnboardingWizard::class, ['workspace' => $workspace]); + $component->call('identifyManagedTenant', ['tenant_id' => $tenantGuid, 'name' => 'Acme']); + + $tenant = Tenant::query()->where('tenant_id', $tenantGuid)->firstOrFail(); + + $connection = ProviderConnection::factory()->create([ + 'tenant_id' => (int) $tenant->getKey(), + 'provider' => 'microsoft', + 'entra_tenant_id' => $tenantGuid, + 'is_default' => true, + ]); + + $verificationRun = OperationRun::query()->create([ + 'tenant_id' => (int) $tenant->getKey(), + 'user_id' => (int) $user->getKey(), + 'initiator_name' => $user->name, + 'type' => 'provider.connection.check', + 'status' => 'completed', + 'outcome' => 'succeeded', + 'run_identity_hash' => sha1('verify-ok-bootstrap-next-'.(string) $connection->getKey()), + 'context' => [ + 'provider_connection_id' => (int) $connection->getKey(), + ], + ]); + + $session = TenantOnboardingSession::query() + ->where('workspace_id', (int) $workspace->getKey()) + ->where('tenant_id', (int) $tenant->getKey()) + ->firstOrFail(); + + $session->update([ + 'state' => array_merge($session->state ?? [], [ + 'provider_connection_id' => (int) $connection->getKey(), + 'verification_operation_run_id' => (int) $verificationRun->getKey(), + ]), + ]); + + $component->call('startBootstrap', ['inventory_sync', 'compliance.snapshot']); + + $inventoryRun = OperationRun::query() + ->where('tenant_id', (int) $tenant->getKey()) + ->where('type', 'inventory_sync') + ->latest('id') + ->firstOrFail(); + + $inventoryRun->forceFill([ + 'status' => 'completed', + 'outcome' => 'succeeded', + ])->save(); + + $component->call('startBootstrap', ['inventory_sync', 'compliance.snapshot']); + + Bus::assertDispatchedTimes(\App\Jobs\ProviderInventorySyncJob::class, 1); + Bus::assertDispatchedTimes(\App\Jobs\ProviderComplianceSnapshotJob::class, 1); + + expect(OperationRun::query() + ->where('tenant_id', (int) $tenant->getKey()) + ->where('type', 'compliance.snapshot') + ->count())->toBe(1); + + $session->refresh(); + $runs = $session->state['bootstrap_operation_runs'] ?? []; + expect($runs['inventory_sync'] ?? null)->toBeInt(); expect($runs['compliance.snapshot'] ?? null)->toBeInt(); }); diff --git a/apps/platform/tests/Feature/ManagedTenants/AuthorizationSemanticsTest.php b/apps/platform/tests/Feature/ManagedTenants/AuthorizationSemanticsTest.php index b7ea1aab..4026878c 100644 --- a/apps/platform/tests/Feature/ManagedTenants/AuthorizationSemanticsTest.php +++ b/apps/platform/tests/Feature/ManagedTenants/AuthorizationSemanticsTest.php @@ -104,6 +104,6 @@ ->withSession([WorkspaceContext::SESSION_KEY => (int) $tenantA->workspace_id]) ->get(route('admin.evidence.overview')) ->assertOk() - ->assertSee(EvidenceSnapshotResource::getUrl('view', ['record' => $allowedSnapshot], tenant: $tenantA)) - ->assertDontSee(EvidenceSnapshotResource::getUrl('view', ['record' => $deniedSnapshot], tenant: $tenantDenied)); + ->assertSee(EvidenceSnapshotResource::getUrl('view', ['record' => $allowedSnapshot], tenant: $tenantA, panel: 'tenant')) + ->assertDontSee(EvidenceSnapshotResource::getUrl('view', ['record' => $deniedSnapshot], tenant: $tenantDenied, panel: 'tenant')); }); diff --git a/apps/platform/tests/Feature/Monitoring/ArtifactTruthRunDetailTest.php b/apps/platform/tests/Feature/Monitoring/ArtifactTruthRunDetailTest.php index 7072319d..17446fc6 100644 --- a/apps/platform/tests/Feature/Monitoring/ArtifactTruthRunDetailTest.php +++ b/apps/platform/tests/Feature/Monitoring/ArtifactTruthRunDetailTest.php @@ -43,7 +43,7 @@ Livewire::actingAs($user) ->test(TenantlessOperationRunViewer::class, ['run' => $run]) ->assertSee('Artifact truth') - ->assertSee($explanation?->headline ?? '') + ->assertSee('The snapshot finished processing, but its evidence basis is incomplete.') ->assertSee($explanation?->evaluationResultLabel() ?? '') ->assertSee($explanation?->trustworthinessLabel() ?? '') ->assertSee('Partially complete') diff --git a/apps/platform/tests/Feature/Monitoring/GovernanceOperationRunSummariesTest.php b/apps/platform/tests/Feature/Monitoring/GovernanceOperationRunSummariesTest.php new file mode 100644 index 00000000..e7e1025e --- /dev/null +++ b/apps/platform/tests/Feature/Monitoring/GovernanceOperationRunSummariesTest.php @@ -0,0 +1,177 @@ +html(); + $html = preg_replace('/]*>.*?<\/script>/is', '', $html) ?? $html; + $html = preg_replace('/]*>.*?<\/style>/is', '', $html) ?? $html; + $html = preg_replace('/\s+wire:snapshot="[^"]*"/', '', $html) ?? $html; + $html = preg_replace('/\s+wire:effects="[^"]*"/', '', $html) ?? $html; + + return trim((string) preg_replace('/\s+/', ' ', strip_tags($html))); +} + +function governanceRunViewer(TestCase $testCase, $user, Tenant $tenant, OperationRun $run): Testable +{ + Filament::setTenant(null, true); + $testCase->withSession([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id]); + session([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id]); + + return Livewire::actingAs($user) + ->test(TenantlessOperationRunViewer::class, ['run' => $run]); +} + +it('renders a summary-first hierarchy for zero-output baseline compare runs', function (): void { + $tenant = Tenant::factory()->create(); + [$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner'); + + $run = OperationRun::factory()->create([ + 'tenant_id' => (int) $tenant->getKey(), + 'workspace_id' => (int) $tenant->workspace_id, + 'type' => 'baseline_compare', + 'status' => 'completed', + 'outcome' => 'partially_succeeded', + 'context' => [ + 'baseline_compare' => [ + 'reason_code' => 'coverage_unproven', + 'coverage' => [ + 'proof' => false, + ], + ], + ], + 'summary_counts' => [ + 'total' => 0, + 'processed' => 0, + 'errors_recorded' => 1, + ], + 'completed_at' => now(), + ]); + + $component = governanceRunViewer($this, $user, $tenant, $run) + ->assertSee('Decision') + ->assertSee('Artifact impact') + ->assertSee('Dominant cause') + ->assertSee('Primary next step') + ->assertSee('The compare finished, but no decision-grade result is available yet.') + ->assertSee('Artifact truth details') + ->assertSee('Monitoring detail'); + + $pageText = governanceVisibleText($component); + + expect(mb_strpos($pageText, 'Decision'))->toBeLessThan(mb_strpos($pageText, 'Artifact truth details')) + ->and(mb_strpos($pageText, 'Decision'))->toBeLessThan(mb_strpos($pageText, 'Monitoring detail')) + ->and($pageText)->toContain('no decision-grade result is available yet'); +}); + +it('keeps blocked baseline capture summaries ahead of diagnostics without adding new run-detail actions', function (): void { + $tenant = Tenant::factory()->create(); + [$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner'); + + $run = OperationRun::factory()->create([ + 'tenant_id' => (int) $tenant->getKey(), + 'workspace_id' => (int) $tenant->workspace_id, + 'type' => 'baseline_capture', + 'status' => 'completed', + 'outcome' => 'blocked', + 'context' => [ + 'reason_code' => 'missing_capability', + 'baseline_capture' => [ + 'subjects_total' => 0, + 'gaps' => [ + 'count' => 0, + ], + ], + ], + 'failure_summary' => [[ + 'reason_code' => 'missing_capability', + 'message' => 'A required capability is missing for this run.', + ]], + 'completed_at' => now(), + ]); + + $component = governanceRunViewer($this, $user, $tenant, $run) + ->assertActionVisible('operate_hub_back_to_operations') + ->assertActionVisible('refresh') + ->assertSee('Blocked by prerequisite') + ->assertSee('No baseline was captured') + ->assertSee('Artifact impact') + ->assertSee('Dominant cause'); + + $pageText = governanceVisibleText($component); + + expect(mb_substr_count($pageText, 'No baseline was captured'))->toBe(1) + ->and(mb_strpos($pageText, 'No baseline was captured'))->toBeLessThan(mb_strpos($pageText, 'Artifact truth details')); +}); + +it('shows processing outcome separately from artifact impact for stale evidence snapshot runs', function (): void { + $tenant = Tenant::factory()->create(); + [$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner'); + + $run = $this->makeArtifactTruthRun($tenant, 'tenant.evidence.snapshot.generate'); + + $this->makeStaleArtifactTruthEvidenceSnapshot( + tenant: $tenant, + snapshotOverrides: [ + 'operation_run_id' => (int) $run->getKey(), + ], + ); + + governanceRunViewer($this, $user, $tenant, $run) + ->assertSee('Outcome') + ->assertSee('Artifact impact') + ->assertSee('Completed successfully') + ->assertSee('The snapshot finished processing, but its evidence basis is already stale.') + ->assertSee('Result trust'); +}); + +it('preserves a dominant cause plus secondary causes for degraded review composition runs', function (): void { + $tenant = Tenant::factory()->create(); + [$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner'); + + $run = $this->makeArtifactTruthRun($tenant, 'tenant.review.compose'); + $snapshot = $this->makeStaleArtifactTruthEvidenceSnapshot($tenant); + + $this->makeArtifactTruthReview( + tenant: $tenant, + user: $user, + snapshot: $snapshot, + reviewOverrides: [ + 'operation_run_id' => (int) $run->getKey(), + 'completeness_state' => 'partial', + ], + summaryOverrides: [ + 'section_state_counts' => [ + 'complete' => 4, + 'partial' => 1, + 'missing' => 1, + 'stale' => 0, + ], + ], + ); + + $component = governanceRunViewer($this, $user, $tenant, $run) + ->assertSee('Dominant cause') + ->assertSee('Missing sections') + ->assertSee('Secondary causes') + ->assertSee('Stale evidence basis'); + + $pageText = governanceVisibleText($component); + + expect(mb_strpos($pageText, 'Missing sections'))->toBeLessThan(mb_strpos($pageText, 'Secondary causes')) + ->and($pageText)->toContain('stale evidence'); +}); diff --git a/apps/platform/tests/Feature/Monitoring/OperationRunResolvedReferencePresentationTest.php b/apps/platform/tests/Feature/Monitoring/OperationRunResolvedReferencePresentationTest.php index ed72a593..e6e6ebfa 100644 --- a/apps/platform/tests/Feature/Monitoring/OperationRunResolvedReferencePresentationTest.php +++ b/apps/platform/tests/Feature/Monitoring/OperationRunResolvedReferencePresentationTest.php @@ -19,7 +19,7 @@ $run = OperationRun::factory()->for($tenant)->create([ 'workspace_id' => (int) $tenant->workspace_id, - 'type' => 'backup_set.add_policies', + 'type' => 'backup_set.update', 'context' => [ 'backup_set_id' => (int) $backupSet->getKey(), ], diff --git a/apps/platform/tests/Feature/Monitoring/OperationsRelatedNavigationTest.php b/apps/platform/tests/Feature/Monitoring/OperationsRelatedNavigationTest.php index 92a71c80..6b65ea74 100644 --- a/apps/platform/tests/Feature/Monitoring/OperationsRelatedNavigationTest.php +++ b/apps/platform/tests/Feature/Monitoring/OperationsRelatedNavigationTest.php @@ -19,7 +19,7 @@ $run = OperationRun::factory()->for($tenant)->create([ 'workspace_id' => (int) $tenant->workspace_id, - 'type' => 'backup_set.add_policies', + 'type' => 'backup_set.update', 'context' => [ 'backup_set_id' => (int) $backupSet->getKey(), ], diff --git a/apps/platform/tests/Feature/Navigation/RelatedNavigationResolverMemoizationTest.php b/apps/platform/tests/Feature/Navigation/RelatedNavigationResolverMemoizationTest.php index 09a1da67..c6e4e0fc 100644 --- a/apps/platform/tests/Feature/Navigation/RelatedNavigationResolverMemoizationTest.php +++ b/apps/platform/tests/Feature/Navigation/RelatedNavigationResolverMemoizationTest.php @@ -107,7 +107,7 @@ Filament::setTenant($tenant, true); $run = OperationRun::factory()->minimal()->forTenant($tenant)->create([ - 'type' => 'backup_set.add_policies', + 'type' => 'backup_set.update', 'context' => [ 'backup_set_id' => 123, ], diff --git a/apps/platform/tests/Feature/Notifications/OperationRunNotificationTest.php b/apps/platform/tests/Feature/Notifications/OperationRunNotificationTest.php index e1cae278..ca646280 100644 --- a/apps/platform/tests/Feature/Notifications/OperationRunNotificationTest.php +++ b/apps/platform/tests/Feature/Notifications/OperationRunNotificationTest.php @@ -182,7 +182,7 @@ 'tenant_id' => $tenant->getKey(), 'user_id' => $user->getKey(), 'initiator_name' => $user->name, - 'type' => 'backup_set.add_policies', + 'type' => 'backup_set.update', 'status' => 'queued', 'outcome' => 'pending', 'context' => [ diff --git a/apps/platform/tests/Feature/Onboarding/OnboardingDraftLifecycleTest.php b/apps/platform/tests/Feature/Onboarding/OnboardingDraftLifecycleTest.php index 9e10e239..7103d974 100644 --- a/apps/platform/tests/Feature/Onboarding/OnboardingDraftLifecycleTest.php +++ b/apps/platform/tests/Feature/Onboarding/OnboardingDraftLifecycleTest.php @@ -397,8 +397,8 @@ ->assertSuccessful() ->assertSee('Complete onboarding') ->assertDontSee('Activate tenant') - ->assertDontSee('Restore') - ->assertDontSee('Archive') + ->assertDontSeeText('Restore tenant') + ->assertDontSeeText('Archive tenant') ->assertSee('After completion'); }); diff --git a/apps/platform/tests/Feature/Operations/ProviderBackedRunReasonAlignmentTest.php b/apps/platform/tests/Feature/Operations/ProviderBackedRunReasonAlignmentTest.php new file mode 100644 index 00000000..979d902d --- /dev/null +++ b/apps/platform/tests/Feature/Operations/ProviderBackedRunReasonAlignmentTest.php @@ -0,0 +1,113 @@ +makeCurrent(); + Filament::setTenant($tenant, true); + + $run = OperationRun::factory()->create([ + 'workspace_id' => (int) $tenant->workspace_id, + 'tenant_id' => (int) $tenant->getKey(), + 'user_id' => (int) $user->getKey(), + 'initiator_name' => $user->name, + 'type' => 'inventory_sync', + 'status' => 'completed', + 'outcome' => 'blocked', + 'context' => [ + 'provider' => 'microsoft', + 'module' => 'inventory', + 'provider_connection_id' => 999999, + 'reason_code' => ProviderReasonCodes::ProviderConnectionMissing, + 'blocked_by' => 'provider_preflight', + 'target_scope' => [ + 'entra_tenant_id' => $tenant->tenant_id, + ], + ], + ]); + + return [$user, $tenant, $run]; +} + +it('reuses translated provider-backed blocker language on the canonical run detail page', function (): void { + [$user, , $run] = makeProviderBlockedRun(); + $this->actingAs($user); + + $component = Livewire::actingAs($user)->test(TenantlessOperationRunViewer::class, ['run' => $run]); + $banner = $component->instance()->blockedExecutionBanner(); + + expect($banner)->not->toBeNull(); + expect($banner['title'] ?? null)->toBe('Blocked by prerequisite'); + expect($banner['body'] ?? null)->toContain('Provider connection required'); + expect($banner['body'] ?? null)->toContain('usable provider connection'); +}); + +it('keeps terminal notification reason translation aligned with the canonical provider-backed run detail', function (): void { + [$user, , $run] = makeProviderBlockedRun(); + $this->actingAs($user); + + $banner = Livewire::actingAs($user) + ->test(TenantlessOperationRunViewer::class, ['run' => $run]) + ->instance() + ->blockedExecutionBanner(); + + $payload = (new OperationRunCompleted($run))->toDatabase($user); + + expect($payload['title'] ?? null)->toBe('Inventory sync blocked by prerequisite'); + expect($payload['body'] ?? null)->toContain('Provider connection required'); + expect($payload['body'] ?? null)->toContain('usable provider connection'); + expect($payload['reason_translation']['operator_label'] ?? null)->toContain('Provider connection required'); + expect($payload['reason_translation']['short_explanation'] ?? null)->toContain('usable provider connection'); + expect($payload['diagnostic_reason_code'] ?? null)->toBe(ProviderReasonCodes::ProviderConnectionMissing); + expect($banner['body'] ?? '')->toContain('Provider connection required'); +}); + +it('keeps the same blocked provider-backed vocabulary for system-initiated runs on canonical detail', function (): void { + [$user, $tenant] = createUserWithTenant(role: 'owner', ensureDefaultMicrosoftProviderConnection: false); + + $tenant->makeCurrent(); + Filament::setTenant($tenant, true); + + $run = OperationRun::factory()->create([ + 'workspace_id' => (int) $tenant->workspace_id, + 'tenant_id' => (int) $tenant->getKey(), + 'user_id' => null, + 'initiator_name' => 'Scheduled automation', + 'type' => 'inventory_sync', + 'status' => 'completed', + 'outcome' => 'blocked', + 'context' => [ + 'provider' => 'microsoft', + 'module' => 'inventory', + 'provider_connection_id' => 999999, + 'reason_code' => ProviderReasonCodes::ProviderConnectionMissing, + 'blocked_by' => 'provider_preflight', + 'target_scope' => [ + 'entra_tenant_id' => $tenant->tenant_id, + ], + ], + ]); + + $banner = Livewire::actingAs($user) + ->test(TenantlessOperationRunViewer::class, ['run' => $run]) + ->instance() + ->blockedExecutionBanner(); + + expect($banner)->not->toBeNull(); + expect($banner['title'] ?? null)->toBe('Blocked by prerequisite'); + expect($banner['body'] ?? null)->toContain('Provider connection required'); + expect($banner['body'] ?? null)->toContain('usable provider connection'); +}); diff --git a/apps/platform/tests/Feature/Operations/TenantlessOperationRunViewerTest.php b/apps/platform/tests/Feature/Operations/TenantlessOperationRunViewerTest.php index ddc5cfe4..fb0a19e0 100644 --- a/apps/platform/tests/Feature/Operations/TenantlessOperationRunViewerTest.php +++ b/apps/platform/tests/Feature/Operations/TenantlessOperationRunViewerTest.php @@ -496,7 +496,7 @@ $run = OperationRun::factory()->create([ 'workspace_id' => (int) $workspace->getKey(), 'tenant_id' => (int) $tenant->getKey(), - 'type' => 'backup_set.add_policies', + 'type' => 'backup_set.update', 'status' => OperationRunStatus::Completed->value, 'outcome' => OperationRunOutcome::Succeeded->value, ]); diff --git a/apps/platform/tests/Feature/OpsUx/OperationRunSummaryCountsIncrementTest.php b/apps/platform/tests/Feature/OpsUx/OperationRunSummaryCountsIncrementTest.php index afad2fb5..0b143089 100644 --- a/apps/platform/tests/Feature/OpsUx/OperationRunSummaryCountsIncrementTest.php +++ b/apps/platform/tests/Feature/OpsUx/OperationRunSummaryCountsIncrementTest.php @@ -54,7 +54,7 @@ 'tenant_id' => $tenant->getKey(), 'user_id' => $user->getKey(), 'initiator_name' => $user->name, - 'type' => 'backup_set.add_policies', + 'type' => 'backup_set.update', 'status' => 'queued', 'outcome' => 'pending', 'context' => ['options' => ['include_foundations' => true]], diff --git a/apps/platform/tests/Feature/OpsUx/ProgressWidgetFiltersTest.php b/apps/platform/tests/Feature/OpsUx/ProgressWidgetFiltersTest.php index 9c085921..4e6b24d4 100644 --- a/apps/platform/tests/Feature/OpsUx/ProgressWidgetFiltersTest.php +++ b/apps/platform/tests/Feature/OpsUx/ProgressWidgetFiltersTest.php @@ -45,3 +45,30 @@ expect($runs->pluck('status')->unique()->values()->all())->toEqualCanonicalizing(['queued', 'running']); expect($runs->pluck('user_id')->all())->toContain($otherUser->id); })->group('ops-ux'); + +it('suppresses stale backup set update runs from the progress widget', function (string $operationType): void { + [$user, $tenant] = createUserWithTenant(role: 'owner'); + $this->actingAs($user); + Filament::setTenant($tenant, true); + + OperationRun::factory()->create([ + 'tenant_id' => $tenant->id, + 'workspace_id' => $tenant->workspace_id, + 'user_id' => $user->id, + 'type' => $operationType, + 'status' => 'queued', + 'outcome' => 'pending', + 'started_at' => null, + 'created_at' => now()->subMinutes(20), + ]); + + $component = Livewire::actingAs($user) + ->test(BulkOperationProgress::class) + ->call('refreshRuns'); + + expect($component->get('runs'))->toBeInstanceOf(Collection::class) + ->and($component->get('runs'))->toHaveCount(0) + ->and($component->get('hasActiveRuns'))->toBeFalse(); +})->with([ + 'backup set update' => 'backup_set.update', +])->group('ops-ux'); diff --git a/apps/platform/tests/Feature/OpsUx/QueuedToastCopyTest.php b/apps/platform/tests/Feature/OpsUx/QueuedToastCopyTest.php index c314f315..d70c84f7 100644 --- a/apps/platform/tests/Feature/OpsUx/QueuedToastCopyTest.php +++ b/apps/platform/tests/Feature/OpsUx/QueuedToastCopyTest.php @@ -22,7 +22,7 @@ })->group('ops-ux'); it('builds canonical already-queued toast copy', function (): void { - $toast = OperationUxPresenter::alreadyQueuedToast('backup_set.add_policies'); + $toast = OperationUxPresenter::alreadyQueuedToast('backup_set.update'); expect($toast->getTitle())->toBe('Backup set update already queued'); expect($toast->getBody())->toBe('A matching operation is already queued or running. No action needed unless it stays stuck.'); diff --git a/apps/platform/tests/Feature/ProviderConnections/ProviderDispatchGateStartSurfaceTest.php b/apps/platform/tests/Feature/ProviderConnections/ProviderDispatchGateStartSurfaceTest.php new file mode 100644 index 00000000..6edf24e0 --- /dev/null +++ b/apps/platform/tests/Feature/ProviderConnections/ProviderDispatchGateStartSurfaceTest.php @@ -0,0 +1,90 @@ +actingAs($user); + + $tenant->makeCurrent(); + Filament::setTenant($tenant, true); + + $connection = ProviderConnection::factory()->platform()->consentGranted()->create([ + 'tenant_id' => (int) $tenant->getKey(), + 'provider' => 'microsoft', + 'entra_tenant_id' => fake()->uuid(), + 'consent_status' => 'granted', + ]); + + $component = Livewire::test(ListProviderConnections::class); + $component->callTableAction('inventory_sync', $connection); + $component->callTableAction('compliance_snapshot', $connection); + + $inventoryRun = OperationRun::query() + ->where('tenant_id', (int) $tenant->getKey()) + ->where('type', 'inventory_sync') + ->latest('id') + ->first(); + + expect($inventoryRun)->not->toBeNull(); + expect(OperationRun::query() + ->where('tenant_id', (int) $tenant->getKey()) + ->where('type', 'compliance.snapshot') + ->count())->toBe(0); + + Queue::assertPushed(ProviderInventorySyncJob::class, 1); + Queue::assertPushed(ProviderComplianceSnapshotJob::class, 0); + + $notifications = session('filament.notifications', []); + expect($notifications)->not->toBeEmpty(); + expect(collect($notifications)->last()['actions'][0]['url'] ?? null) + ->toBe(OperationRunLinks::view($inventoryRun, $tenant)); +}); + +it('blocks provider connection checks with shared guidance and does not enqueue work', function (): void { + Queue::fake(); + + [$user, $tenant] = createUserWithTenant(role: 'operator', ensureDefaultMicrosoftProviderConnection: false); + $this->actingAs($user); + + $tenant->makeCurrent(); + Filament::setTenant($tenant, true); + + $connection = ProviderConnection::factory()->dedicated()->consentGranted()->create([ + 'tenant_id' => (int) $tenant->getKey(), + 'provider' => 'microsoft', + 'consent_status' => 'granted', + 'is_default' => true, + ]); + + Livewire::test(ListProviderConnections::class) + ->callTableAction('check_connection', $connection); + + $run = OperationRun::query() + ->where('tenant_id', (int) $tenant->getKey()) + ->where('type', 'provider.connection.check') + ->latest('id') + ->first(); + + expect($run)->not->toBeNull(); + expect($run?->outcome)->toBe('blocked'); + expect($run?->context['reason_code'] ?? null)->toBe(ProviderReasonCodes::DedicatedCredentialMissing); + + Queue::assertNothingPushed(); + Queue::assertNotPushed(ProviderConnectionHealthCheckJob::class); +}); diff --git a/apps/platform/tests/Feature/Rbac/BackupItemsRelationManagerSemanticsTest.php b/apps/platform/tests/Feature/Rbac/BackupItemsRelationManagerSemanticsTest.php index f3e69b1c..fba9e986 100644 --- a/apps/platform/tests/Feature/Rbac/BackupItemsRelationManagerSemanticsTest.php +++ b/apps/platform/tests/Feature/Rbac/BackupItemsRelationManagerSemanticsTest.php @@ -150,5 +150,5 @@ Queue::assertNothingPushed(); - expect(OperationRun::query()->where('type', 'backup_set.remove_policies')->exists())->toBeFalse(); + expect(OperationRun::query()->where('type', 'backup_set.update')->exists())->toBeFalse(); }); diff --git a/apps/platform/tests/Feature/Rbac/BackupItemsRelationManagerUiEnforcementTest.php b/apps/platform/tests/Feature/Rbac/BackupItemsRelationManagerUiEnforcementTest.php index 7eb9fcf7..d2cc4875 100644 --- a/apps/platform/tests/Feature/Rbac/BackupItemsRelationManagerUiEnforcementTest.php +++ b/apps/platform/tests/Feature/Rbac/BackupItemsRelationManagerUiEnforcementTest.php @@ -136,6 +136,6 @@ Queue::assertNothingPushed(); Queue::assertNotPushed(RemovePoliciesFromBackupSetJob::class); - expect(OperationRun::query()->where('type', 'backup_set.remove_policies')->exists())->toBeFalse(); + expect(OperationRun::query()->where('type', 'backup_set.update')->exists())->toBeFalse(); }); }); diff --git a/apps/platform/tests/Feature/Restore/RestoreRunProviderStartTest.php b/apps/platform/tests/Feature/Restore/RestoreRunProviderStartTest.php new file mode 100644 index 00000000..aaa299d4 --- /dev/null +++ b/apps/platform/tests/Feature/Restore/RestoreRunProviderStartTest.php @@ -0,0 +1,163 @@ + fake()->uuid(), + 'name' => 'Restore Tenant', + 'metadata' => [], + 'rbac_status' => 'ok', + 'rbac_last_checked_at' => now(), + ]); + + $tenant->makeCurrent(); + + if ($withProviderConnection) { + ensureDefaultProviderConnection($tenant, 'microsoft'); + } + + $policy = Policy::create([ + 'tenant_id' => $tenant->id, + 'external_id' => fake()->uuid(), + 'policy_type' => 'deviceConfiguration', + 'display_name' => 'Device Config Policy', + 'platform' => 'windows', + ]); + + $backupSet = BackupSet::create([ + 'tenant_id' => $tenant->id, + 'name' => 'Backup', + 'status' => 'completed', + 'item_count' => 1, + ]); + + $backupItem = BackupItem::create([ + 'tenant_id' => $tenant->id, + 'backup_set_id' => $backupSet->id, + 'policy_id' => $policy->id, + 'policy_identifier' => $policy->external_id, + 'policy_type' => $policy->policy_type, + 'platform' => $policy->platform, + 'payload' => ['id' => $policy->external_id], + 'metadata' => [ + 'displayName' => 'Backup Policy', + ], + ]); + + $user = User::factory()->create([ + 'email' => 'restore@example.com', + 'name' => 'Restore Operator', + ]); + + $user->tenants()->syncWithoutDetaching([ + $tenant->getKey() => ['role' => 'owner'], + ]); + + Filament::setTenant($tenant, true); + + return [$tenant, $backupSet, $backupItem, $user]; +} + +it('starts restore execution with explicit provider connection context', function (): void { + Bus::fake(); + + [$tenant, $backupSet, $backupItem, $user] = seedRestoreStartContext(); + $this->actingAs($user); + + Livewire::test(CreateRestoreRun::class) + ->fillForm([ + 'backup_set_id' => $backupSet->id, + ]) + ->goToNextWizardStep() + ->fillForm([ + 'scope_mode' => 'selected', + 'backup_item_ids' => [$backupItem->id], + ]) + ->goToNextWizardStep() + ->callFormComponentAction('check_results', 'run_restore_checks') + ->goToNextWizardStep() + ->callFormComponentAction('preview_diffs', 'run_restore_preview') + ->goToNextWizardStep() + ->fillForm([ + 'is_dry_run' => false, + 'acknowledged_impact' => true, + 'tenant_confirm' => 'Restore Tenant', + ]) + ->call('create') + ->assertHasNoFormErrors(); + + $restoreRun = RestoreRun::query()->latest('id')->first(); + $operationRun = OperationRun::query() + ->where('tenant_id', (int) $tenant->getKey()) + ->where('type', 'restore.execute') + ->latest('id') + ->first(); + + expect($restoreRun)->not->toBeNull(); + expect($restoreRun?->status)->toBe(RestoreRunStatus::Queued->value); + expect($operationRun)->not->toBeNull(); + expect($operationRun?->context['provider_connection_id'] ?? null)->toBeInt(); + + Bus::assertDispatched(ExecuteRestoreRunJob::class, function (ExecuteRestoreRunJob $job) use ($restoreRun, $operationRun): bool { + return $job->restoreRunId === (int) $restoreRun?->getKey() + && $job->providerConnectionId === ($operationRun?->context['provider_connection_id'] ?? null) + && $job->operationRun?->is($operationRun); + }); +}); + +it('blocks restore reruns before queue when no provider connection is available', function (): void { + Bus::fake(); + + [$tenant, $backupSet, $backupItem, $user] = seedRestoreStartContext(withProviderConnection: false); + $this->actingAs($user); + + $run = RestoreRun::create([ + 'tenant_id' => $tenant->id, + 'backup_set_id' => $backupSet->id, + 'status' => 'failed', + 'is_dry_run' => false, + 'requested_items' => [$backupItem->id], + 'group_mapping' => [], + ]); + + Livewire::test(ListRestoreRuns::class) + ->callTableAction('rerun', $run); + + expect(RestoreRun::query()->where('tenant_id', (int) $tenant->getKey())->count())->toBe(1); + + $operationRun = OperationRun::query() + ->where('tenant_id', (int) $tenant->getKey()) + ->where('type', 'restore.execute') + ->latest('id') + ->first(); + + expect($operationRun)->not->toBeNull(); + expect($operationRun?->outcome)->toBe('blocked'); + expect($operationRun?->context['reason_code'] ?? null)->toBe(ProviderReasonCodes::ProviderConnectionMissing); + + Bus::assertNotDispatched(ExecuteRestoreRunJob::class); +}); diff --git a/apps/platform/tests/Feature/RestoreRunWizardExecuteTest.php b/apps/platform/tests/Feature/RestoreRunWizardExecuteTest.php index 86997be9..12662ae5 100644 --- a/apps/platform/tests/Feature/RestoreRunWizardExecuteTest.php +++ b/apps/platform/tests/Feature/RestoreRunWizardExecuteTest.php @@ -189,10 +189,12 @@ expect($operationRun)->not->toBeNull(); expect($operationRun?->status)->toBe('queued'); expect((int) ($operationRun?->context['restore_run_id'] ?? 0))->toBe((int) $run->getKey()); + expect($operationRun?->context['provider_connection_id'] ?? null)->toBeInt(); expect((int) ($run->refresh()->operation_run_id ?? 0))->toBe((int) ($operationRun?->getKey() ?? 0)); Bus::assertDispatched(ExecuteRestoreRunJob::class, function (ExecuteRestoreRunJob $job) use ($run, $operationRun): bool { return $job->restoreRunId === (int) $run->getKey() + && $job->providerConnectionId === ($operationRun?->context['provider_connection_id'] ?? null) && $job->operationRun instanceof OperationRun && $job->operationRun->getKey() === $operationRun?->getKey(); }); diff --git a/apps/platform/tests/Feature/TenantRBAC/RoleDefinitionsSyncNowTest.php b/apps/platform/tests/Feature/TenantRBAC/RoleDefinitionsSyncNowTest.php index 5972ddaa..3c29b46d 100644 --- a/apps/platform/tests/Feature/TenantRBAC/RoleDefinitionsSyncNowTest.php +++ b/apps/platform/tests/Feature/TenantRBAC/RoleDefinitionsSyncNowTest.php @@ -3,6 +3,7 @@ declare(strict_types=1); use App\Models\Tenant; +use App\Services\Providers\ProviderOperationStartResult; use App\Services\Directory\RoleDefinitionsSyncService; use App\Support\OperationRunLinks; use Illuminate\Foundation\Testing\RefreshDatabase; @@ -20,19 +21,31 @@ 'status' => 'active', ]); - [$user, $tenant] = createUserWithTenant($tenant, role: 'owner'); + [$user, $tenant] = createUserWithTenant( + tenant: $tenant, + role: 'owner', + fixtureProfile: 'credential-enabled', + ); $service = app(RoleDefinitionsSyncService::class); - $run = $service->startManualSync($tenant, $user); + $result = $service->startManualSync($tenant, $user); + + expect($result)->toBeInstanceOf(ProviderOperationStartResult::class); + expect($result->status)->toBe('started'); + + $run = $result->run; expect($run->type)->toBe('directory_role_definitions.sync'); + expect($run->context['provider_connection_id'] ?? null)->toBeInt(); $url = OperationRunLinks::tenantlessView($run); expect($url)->toContain('/admin/operations/'); Bus::assertDispatched( App\Jobs\SyncRoleDefinitionsJob::class, - fn (App\Jobs\SyncRoleDefinitionsJob $job): bool => $job->tenantId === (int) $tenant->getKey() && $job->operationRun?->is($run) + fn (App\Jobs\SyncRoleDefinitionsJob $job): bool => $job->tenantId === (int) $tenant->getKey() + && $job->providerConnectionId === ($run->context['provider_connection_id'] ?? null) + && $job->operationRun?->is($run) ); }); diff --git a/apps/platform/tests/Feature/Tenants/TenantProviderBackedActionStartTest.php b/apps/platform/tests/Feature/Tenants/TenantProviderBackedActionStartTest.php new file mode 100644 index 00000000..94778b9b --- /dev/null +++ b/apps/platform/tests/Feature/Tenants/TenantProviderBackedActionStartTest.php @@ -0,0 +1,83 @@ +actingAs($user); + + $tenant->makeCurrent(); + Filament::setTenant($tenant, true); + + $connection = ProviderConnection::query() + ->where('tenant_id', (int) $tenant->getKey()) + ->where('provider', 'microsoft') + ->where('is_default', true) + ->firstOrFail(); + + $component = Livewire::test(ViewTenant::class, ['record' => $tenant->getRouteKey()]); + + $component->callAction('verify'); + $component->callAction('verify'); + + $run = OperationRun::query() + ->where('tenant_id', (int) $tenant->getKey()) + ->where('type', 'provider.connection.check') + ->latest('id') + ->first(); + + expect($run)->not->toBeNull(); + expect($run?->status)->toBe('queued'); + expect($run?->context)->toMatchArray([ + 'provider' => 'microsoft', + 'module' => 'health_check', + 'provider_connection_id' => (int) $connection->getKey(), + 'target_scope' => [ + 'entra_tenant_id' => $connection->entra_tenant_id, + ], + ]); + + expect(OperationRun::query() + ->where('tenant_id', (int) $tenant->getKey()) + ->where('type', 'provider.connection.check') + ->count())->toBe(1); + + Queue::assertPushed(ProviderConnectionHealthCheckJob::class, 1); +}); + +it('blocks tenant verification before queue when no provider connection is available', function (): void { + Queue::fake(); + + [$user, $tenant] = createUserWithTenant(role: 'operator', ensureDefaultMicrosoftProviderConnection: false); + $this->actingAs($user); + + $tenant->makeCurrent(); + Filament::setTenant($tenant, true); + + Livewire::test(ViewTenant::class, ['record' => $tenant->getRouteKey()]) + ->callAction('verify'); + + $run = OperationRun::query() + ->where('tenant_id', (int) $tenant->getKey()) + ->where('type', 'provider.connection.check') + ->latest('id') + ->first(); + + expect($run)->not->toBeNull(); + expect($run?->outcome)->toBe('blocked'); + expect($run?->context['reason_code'] ?? null)->toBe(ProviderReasonCodes::ProviderConnectionMissing); + + Queue::assertNothingPushed(); +}); diff --git a/apps/platform/tests/Feature/Workspaces/ManagedTenantOnboardingProviderStartTest.php b/apps/platform/tests/Feature/Workspaces/ManagedTenantOnboardingProviderStartTest.php new file mode 100644 index 00000000..4431f75e --- /dev/null +++ b/apps/platform/tests/Feature/Workspaces/ManagedTenantOnboardingProviderStartTest.php @@ -0,0 +1,167 @@ +create(); + $user = User::factory()->create(); + + WorkspaceMembership::factory()->create([ + 'workspace_id' => $workspace->getKey(), + 'user_id' => $user->getKey(), + 'role' => 'owner', + ]); + + session()->put(WorkspaceContext::SESSION_KEY, (int) $workspace->getKey()); + $this->actingAs($user); + + $tenantGuid = '10101010-1010-1010-1010-101010101010'; + + $component = Livewire::actingAs($user)->test(ManagedTenantOnboardingWizard::class); + $component->call('identifyManagedTenant', [ + 'entra_tenant_id' => $tenantGuid, + 'environment' => 'prod', + 'name' => 'Acme', + 'primary_domain' => 'acme.example', + 'notes' => 'Provider start test', + ]); + + $tenant = Tenant::query()->where('tenant_id', $tenantGuid)->firstOrFail(); + + $connection = ProviderConnection::factory()->platform()->consentGranted()->create([ + 'tenant_id' => (int) $tenant->getKey(), + 'provider' => 'microsoft', + 'entra_tenant_id' => $tenantGuid, + 'is_default' => true, + ]); + + OperationRun::query()->create([ + 'tenant_id' => (int) $tenant->getKey(), + 'user_id' => (int) $user->getKey(), + 'initiator_name' => $user->name, + 'type' => 'inventory_sync', + 'status' => 'queued', + 'outcome' => 'pending', + 'run_identity_hash' => sha1('busy-onboarding-'.(string) $connection->getKey()), + 'context' => [ + 'provider_connection_id' => (int) $connection->getKey(), + ], + ]); + + $component->set('selectedProviderConnectionId', (int) $connection->getKey()); + $component->call('startVerification'); + + expect(OperationRun::query() + ->where('tenant_id', (int) $tenant->getKey()) + ->where('type', 'provider.connection.check') + ->count())->toBe(0); + + Bus::assertNotDispatched(ProviderConnectionHealthCheckJob::class); +}); + +it('serializes onboarding bootstrap so only one selected provider-backed action starts at a time', function (): void { + Bus::fake(); + + $workspace = Workspace::factory()->create(); + $user = User::factory()->create(); + + WorkspaceMembership::factory()->create([ + 'workspace_id' => $workspace->getKey(), + 'user_id' => $user->getKey(), + 'role' => 'owner', + ]); + + session()->put(WorkspaceContext::SESSION_KEY, (int) $workspace->getKey()); + $this->actingAs($user); + + $tenantGuid = '20202020-2020-2020-2020-202020202020'; + + $component = Livewire::actingAs($user)->test(ManagedTenantOnboardingWizard::class); + $component->call('identifyManagedTenant', [ + 'entra_tenant_id' => $tenantGuid, + 'environment' => 'prod', + 'name' => 'Acme', + 'primary_domain' => 'acme.example', + 'notes' => 'Provider start test', + ]); + + $tenant = Tenant::query()->where('tenant_id', $tenantGuid)->firstOrFail(); + + $connection = ProviderConnection::factory()->platform()->consentGranted()->create([ + 'tenant_id' => (int) $tenant->getKey(), + 'provider' => 'microsoft', + 'entra_tenant_id' => $tenantGuid, + 'is_default' => true, + ]); + + $verificationRun = OperationRun::query()->create([ + 'tenant_id' => (int) $tenant->getKey(), + 'user_id' => (int) $user->getKey(), + 'initiator_name' => $user->name, + 'type' => 'provider.connection.check', + 'status' => 'completed', + 'outcome' => 'succeeded', + 'run_identity_hash' => sha1('verify-ok-bootstrap-provider-start-'.(string) $connection->getKey()), + 'context' => [ + 'provider_connection_id' => (int) $connection->getKey(), + ], + ]); + + $session = TenantOnboardingSession::query() + ->where('workspace_id', (int) $workspace->getKey()) + ->where('tenant_id', (int) $tenant->getKey()) + ->firstOrFail(); + + $session->update([ + 'state' => array_merge($session->state ?? [], [ + 'provider_connection_id' => (int) $connection->getKey(), + 'verification_operation_run_id' => (int) $verificationRun->getKey(), + ]), + ]); + + $component->call('startBootstrap', ['inventory_sync', 'compliance.snapshot']); + + $inventoryRun = OperationRun::query() + ->where('tenant_id', (int) $tenant->getKey()) + ->where('type', 'inventory_sync') + ->latest('id') + ->firstOrFail(); + + expect(OperationRun::query() + ->where('tenant_id', (int) $tenant->getKey()) + ->where('type', 'compliance.snapshot') + ->count())->toBe(0); + + $inventoryRun->forceFill([ + 'status' => 'completed', + 'outcome' => 'succeeded', + ])->save(); + + $component->call('startBootstrap', ['inventory_sync', 'compliance.snapshot']); + + expect(OperationRun::query() + ->where('tenant_id', (int) $tenant->getKey()) + ->where('type', 'compliance.snapshot') + ->count())->toBe(1); + + Bus::assertDispatchedTimes(ProviderInventorySyncJob::class, 1); + Bus::assertDispatchedTimes(ProviderComplianceSnapshotJob::class, 1); +}); diff --git a/apps/platform/tests/Feature/Workspaces/ManagedTenantsWorkspaceRoutingTest.php b/apps/platform/tests/Feature/Workspaces/ManagedTenantsWorkspaceRoutingTest.php index 42c61a51..1b3b43cb 100644 --- a/apps/platform/tests/Feature/Workspaces/ManagedTenantsWorkspaceRoutingTest.php +++ b/apps/platform/tests/Feature/Workspaces/ManagedTenantsWorkspaceRoutingTest.php @@ -71,7 +71,7 @@ ->assertDontSee('/admin/t/'.$tenantInOther->external_id, false); }); -it('returns 404 on tenant routes when workspace context is missing', function (): void { +it('uses the routed tenant workspace on tenant routes when workspace context is missing', function (): void { $user = User::factory()->create(); $workspace = Workspace::factory()->create(); @@ -93,7 +93,7 @@ $this->actingAs($user) ->get(TenantDashboard::getUrl(tenant: $tenant)) - ->assertNotFound(); + ->assertSuccessful(); }); it('returns 404 on tenant routes when tenant workspace mismatches current workspace', function (): void { diff --git a/apps/platform/tests/Support/TestLaneManifest.php b/apps/platform/tests/Support/TestLaneManifest.php index 029aa3eb..125f1ff8 100644 --- a/apps/platform/tests/Support/TestLaneManifest.php +++ b/apps/platform/tests/Support/TestLaneManifest.php @@ -535,6 +535,34 @@ public static function families(): array 'costSignals' => ['resource discovery', 'surface-wide validation', 'broad assertion density'], 'validationStatus' => 'guarded', ], + [ + 'familyId' => 'provider-dispatch-gate-coverage', + 'classificationId' => 'surface-guard', + 'purpose' => 'Keep the first-slice provider-backed start hosts on canonical ProviderOperationStartGate-owned entry points instead of route-bounded direct-dispatch bypasses.', + 'currentLaneId' => 'heavy-governance', + 'targetLaneId' => 'heavy-governance', + 'selectors' => [ + [ + 'selectorType' => 'group', + 'selectorValue' => 'surface-guard', + 'selectorRole' => 'include', + 'sourceOfTruth' => 'pest-group', + 'rationale' => 'The bypass guard spans multiple route-bounded start surfaces and belongs with heavy governance checks.', + ], + [ + 'selectorType' => 'file', + 'selectorValue' => 'tests/Feature/Guards/ProviderDispatchGateCoverageTest.php', + 'selectorRole' => 'inventory-only', + 'sourceOfTruth' => 'manifest', + 'rationale' => 'Canonical guard for first-slice provider-backed dispatch-gate coverage.', + ], + ], + 'hotspotFiles' => [ + 'tests/Feature/Guards/ProviderDispatchGateCoverageTest.php', + ], + 'costSignals' => ['route-bounded surface scan', 'start-host governance breadth', 'gate adoption regression detection'], + 'validationStatus' => 'guarded', + ], [ 'familyId' => 'policy-resource-admin-search-parity', 'classificationId' => 'discovery-heavy', @@ -1175,6 +1203,16 @@ public static function budgetTargets(): array 'lifecycleState' => 'documented', 'reviewCadence' => 'tighten after two stable heavy-governance runs', ], + [ + 'budgetId' => 'family-provider-dispatch-gate-coverage', + 'targetType' => 'family', + 'targetId' => 'provider-dispatch-gate-coverage', + 'thresholdSeconds' => 20, + 'baselineSource' => 'measured-current-suite', + 'enforcement' => 'warn', + 'lifecycleState' => 'documented', + 'reviewCadence' => 'tighten after two stable heavy-governance runs', + ], [ 'budgetId' => 'family-policy-resource-admin-search-parity', 'targetType' => 'family', diff --git a/apps/platform/tests/Unit/Operations/OperationLifecyclePolicyValidatorTest.php b/apps/platform/tests/Unit/Operations/OperationLifecyclePolicyValidatorTest.php index ab4d9088..13600444 100644 --- a/apps/platform/tests/Unit/Operations/OperationLifecyclePolicyValidatorTest.php +++ b/apps/platform/tests/Unit/Operations/OperationLifecyclePolicyValidatorTest.php @@ -16,6 +16,7 @@ 'policy.sync_one', 'entra_group_sync', 'directory_role_definitions.sync', + 'backup_set.update', 'backup_schedule_run', 'restore.execute', 'tenant.review_pack.generate', @@ -40,6 +41,8 @@ expect($validator->jobTimeoutSeconds('baseline_capture'))->toBe(300) ->and($validator->jobFailsOnTimeout('baseline_capture'))->toBeTrue() + ->and($validator->jobTimeoutSeconds('backup_set.update'))->toBe(240) + ->and($validator->jobFailsOnTimeout('backup_set.update'))->toBeTrue() ->and($validator->jobTimeoutSeconds('restore.execute'))->toBe(420) ->and($validator->jobFailsOnTimeout('restore.execute'))->toBeTrue(); }); diff --git a/apps/platform/tests/Unit/Providers/ProviderOperationStartGateTest.php b/apps/platform/tests/Unit/Providers/ProviderOperationStartGateTest.php index 390a1ac8..b795b1d0 100644 --- a/apps/platform/tests/Unit/Providers/ProviderOperationStartGateTest.php +++ b/apps/platform/tests/Unit/Providers/ProviderOperationStartGateTest.php @@ -5,6 +5,7 @@ use App\Models\ProviderCredential; use App\Models\Tenant; use App\Services\Providers\ProviderOperationStartGate; +use App\Support\Auth\Capabilities; use App\Support\OperationRunOutcome; use App\Support\OperationRunStatus; use App\Support\Providers\ProviderReasonCodes; @@ -157,3 +158,123 @@ expect($result->run->context['verification_report'] ?? null)->toBeArray(); expect(VerificationReportSchema::isValidReport($result->run->context['verification_report'] ?? []))->toBeTrue(); }); + +it('starts restore execution with explicit provider connection binding and operation capability metadata', function (): void { + $tenant = Tenant::factory()->create(); + $connection = ProviderConnection::factory()->dedicated()->consentGranted()->create([ + 'tenant_id' => $tenant->getKey(), + 'provider' => 'microsoft', + 'entra_tenant_id' => 'restore-entra-tenant-id', + 'consent_status' => 'granted', + ]); + ProviderCredential::factory()->create([ + 'provider_connection_id' => (int) $connection->getKey(), + ]); + + $dispatched = 0; + $gate = app(ProviderOperationStartGate::class); + + $result = $gate->start( + tenant: $tenant, + connection: $connection, + operationType: 'restore.execute', + dispatcher: function (OperationRun $run) use (&$dispatched): void { + $dispatched++; + + expect($run->type)->toBe('restore.execute'); + }, + ); + + expect($dispatched)->toBe(1); + expect($result->status)->toBe('started'); + expect($result->dispatched)->toBeTrue(); + + $run = $result->run->fresh(); + + expect($run)->not->toBeNull(); + expect($run->context)->toMatchArray([ + 'provider_connection_id' => (int) $connection->getKey(), + 'required_capability' => Capabilities::TENANT_MANAGE, + 'target_scope' => [ + 'entra_tenant_id' => 'restore-entra-tenant-id', + ], + ]); +}); + +it('starts directory group sync with explicit provider connection binding and sync capability metadata', function (): void { + $tenant = Tenant::factory()->create(); + $connection = ProviderConnection::factory()->dedicated()->consentGranted()->create([ + 'tenant_id' => $tenant->getKey(), + 'provider' => 'microsoft', + 'entra_tenant_id' => 'directory-entra-tenant-id', + 'consent_status' => 'granted', + ]); + ProviderCredential::factory()->create([ + 'provider_connection_id' => (int) $connection->getKey(), + ]); + + $dispatched = 0; + $gate = app(ProviderOperationStartGate::class); + + $result = $gate->start( + tenant: $tenant, + connection: $connection, + operationType: 'entra_group_sync', + dispatcher: function (OperationRun $run) use (&$dispatched): void { + $dispatched++; + + expect($run->type)->toBe('entra_group_sync'); + }, + ); + + expect($dispatched)->toBe(1); + expect($result->status)->toBe('started'); + expect($result->dispatched)->toBeTrue(); + + $run = $result->run->fresh(); + + expect($run)->not->toBeNull(); + expect($run->context)->toMatchArray([ + 'provider_connection_id' => (int) $connection->getKey(), + 'required_capability' => Capabilities::TENANT_SYNC, + 'target_scope' => [ + 'entra_tenant_id' => 'directory-entra-tenant-id', + ], + ]); +}); + +it('treats onboarding bootstrap provider starts as one protected scope', function (): void { + $tenant = Tenant::factory()->create(); + $connection = ProviderConnection::factory()->dedicated()->consentGranted()->create([ + 'tenant_id' => $tenant->getKey(), + 'consent_status' => 'granted', + ]); + ProviderCredential::factory()->create([ + 'provider_connection_id' => (int) $connection->getKey(), + ]); + + $blocking = OperationRun::factory()->create([ + 'tenant_id' => $tenant->getKey(), + 'type' => 'inventory_sync', + 'status' => 'running', + 'context' => [ + 'provider_connection_id' => (int) $connection->getKey(), + ], + ]); + + $dispatched = 0; + $gate = app(ProviderOperationStartGate::class); + + $result = $gate->start( + tenant: $tenant, + connection: $connection, + operationType: 'compliance.snapshot', + dispatcher: function () use (&$dispatched): void { + $dispatched++; + }, + ); + + expect($dispatched)->toBe(0); + expect($result->status)->toBe('scope_busy'); + expect($result->run->getKey())->toBe($blocking->getKey()); +}); diff --git a/apps/platform/tests/Unit/Providers/ProviderOperationStartResultPresenterTest.php b/apps/platform/tests/Unit/Providers/ProviderOperationStartResultPresenterTest.php new file mode 100644 index 00000000..5e85335f --- /dev/null +++ b/apps/platform/tests/Unit/Providers/ProviderOperationStartResultPresenterTest.php @@ -0,0 +1,149 @@ +create(); + $run = OperationRun::factory()->create([ + 'tenant_id' => $tenant->getKey(), + 'type' => 'provider.connection.check', + 'status' => 'queued', + 'context' => [ + 'provider_connection_id' => 123, + ], + ]); + + $presenter = app(ProviderOperationStartResultPresenter::class); + + $notification = $presenter->notification( + result: ProviderOperationStartResult::started($run, true), + blockedTitle: 'Verification blocked', + runUrl: OperationRunLinks::tenantlessView($run), + extraActions: [ + Action::make('manage_connections') + ->label('Manage Provider Connections') + ->url('/provider-connections'), + ], + ); + + $actions = collect($notification->getActions()); + + expect($notification->getTitle())->toBe('Provider connection check queued') + ->and($notification->getBody())->toBe('Queued for execution. Open the operation for progress and next steps.') + ->and($actions->map(fn (Action $action): string => (string) $action->getName())->all())->toBe([ + 'view_run', + 'manage_connections', + ]) + ->and($actions->map(fn (Action $action): string => (string) $action->getLabel())->all())->toBe([ + 'Open operation', + 'Manage Provider Connections', + ]); +}); + +it('builds already-running notifications for deduped provider-backed starts', function (): void { + $tenant = Tenant::factory()->create(); + $run = OperationRun::factory()->create([ + 'tenant_id' => $tenant->getKey(), + 'type' => 'provider.connection.check', + 'status' => 'running', + 'context' => [ + 'provider_connection_id' => 123, + ], + ]); + + $presenter = app(ProviderOperationStartResultPresenter::class); + + $notification = $presenter->notification( + result: ProviderOperationStartResult::deduped($run), + blockedTitle: 'Verification blocked', + runUrl: OperationRunLinks::tenantlessView($run), + ); + + expect($notification->getTitle())->toBe('Provider connection check already running') + ->and($notification->getBody())->toBe('A matching operation is already queued or running. Open the operation for progress and next steps.'); +}); + +it('builds scope-busy notifications for conflicting provider-backed starts', function (): void { + $tenant = Tenant::factory()->create(); + $run = OperationRun::factory()->create([ + 'tenant_id' => $tenant->getKey(), + 'type' => 'inventory_sync', + 'status' => 'running', + 'context' => [ + 'provider_connection_id' => 123, + ], + ]); + + $presenter = app(ProviderOperationStartResultPresenter::class); + + $notification = $presenter->notification( + result: ProviderOperationStartResult::scopeBusy($run), + blockedTitle: 'Inventory sync blocked', + runUrl: OperationRunLinks::tenantlessView($run), + ); + + expect($notification->getTitle())->toBe('Scope busy') + ->and($notification->getBody())->toBe('Another provider-backed operation is already running for this scope. Open the active operation for progress and next steps.'); +}); + +it('builds blocked notifications from translated reason detail and first next step', function (): void { + $tenant = Tenant::factory()->create(); + $reasonEnvelope = new ReasonResolutionEnvelope( + internalCode: 'provider_consent_missing', + operatorLabel: 'Admin consent required', + shortExplanation: 'Grant admin consent for this provider connection before retrying.', + actionability: 'prerequisite_missing', + nextSteps: [ + NextStepOption::link('Grant admin consent', '/provider-connections/1/consent'), + NextStepOption::link('Open provider settings', '/provider-connections/1'), + ], + ); + $run = OperationRun::factory()->create([ + 'tenant_id' => $tenant->getKey(), + 'type' => 'provider.connection.check', + 'status' => 'completed', + 'outcome' => 'blocked', + 'context' => [ + 'reason_code' => 'provider_consent_missing', + 'reason_translation' => $reasonEnvelope->toArray(), + ], + ]); + + $presenter = app(ProviderOperationStartResultPresenter::class); + + $notification = $presenter->notification( + result: ProviderOperationStartResult::blocked($run), + blockedTitle: 'Verification blocked', + runUrl: OperationRunLinks::tenantlessView($run), + extraActions: [ + Action::make('manage_connections') + ->label('Manage Provider Connections') + ->url('/provider-connections'), + ], + ); + + $actions = collect($notification->getActions()); + + expect($notification->getTitle())->toBe('Verification blocked') + ->and($notification->getBody())->toBe("Admin consent required\nGrant admin consent for this provider connection before retrying.\nNext step: Grant admin consent.") + ->and($actions->map(fn (Action $action): string => (string) $action->getName())->all())->toBe([ + 'view_run', + 'next_step_0', + 'manage_connections', + ]) + ->and($actions->map(fn (Action $action): string => (string) $action->getLabel())->all())->toBe([ + 'Open operation', + 'Grant admin consent', + 'Manage Provider Connections', + ]); +}); \ No newline at end of file diff --git a/apps/platform/tests/Unit/Support/OperationTypeResolutionTest.php b/apps/platform/tests/Unit/Support/OperationTypeResolutionTest.php index 85a9edc3..2e510fbd 100644 --- a/apps/platform/tests/Unit/Support/OperationTypeResolutionTest.php +++ b/apps/platform/tests/Unit/Support/OperationTypeResolutionTest.php @@ -16,17 +16,31 @@ ->toContain('inventory_sync', 'provider.inventory.sync'); }); +it('resolves canonical backup set update values without treating them as legacy aliases', function (): void { + $resolution = OperationCatalog::resolve('backup_set.update'); + + expect($resolution->canonical->canonicalCode)->toBe('backup_set.update') + ->and($resolution->canonical->displayLabel)->toBe('Backup set update') + ->and($resolution->aliasStatus)->toBe('canonical') + ->and($resolution->wasLegacyAlias)->toBeFalse() + ->and(array_map(static fn ($alias): string => $alias->rawValue, $resolution->aliasesConsidered)) + ->toBe(['backup_set.update']); +}); + it('deduplicates filter options by canonical operation code while preserving raw aliases for queries', function (): void { expect(OperationCatalog::filterOptions(['inventory_sync', 'provider.inventory.sync', 'policy.sync']))->toBe([ 'inventory.sync' => 'Inventory sync', 'policy.sync' => 'Policy sync', ])->and(OperationCatalog::rawValuesForCanonical('inventory.sync')) - ->toContain('inventory_sync', 'provider.inventory.sync'); + ->toContain('inventory_sync', 'provider.inventory.sync') + ->and(OperationCatalog::rawValuesForCanonical('backup_set.update')) + ->toBe(['backup_set.update']); }); it('maps enum-backed storage values to canonical operation codes', function (): void { expect(OperationRunType::BaselineCompare->canonicalCode())->toBe('baseline.compare') - ->and(OperationRunType::DirectoryGroupsSync->canonicalCode())->toBe('directory.groups.sync'); + ->and(OperationRunType::DirectoryGroupsSync->canonicalCode())->toBe('directory.groups.sync') + ->and(OperationRunType::BackupSetUpdate->canonicalCode())->toBe('backup_set.update'); }); it('publishes contributor-facing operation inventories and alias retirement metadata', function (): void { diff --git a/apps/platform/tests/Unit/Support/OperatorExplanation/OperatorExplanationBuilderTest.php b/apps/platform/tests/Unit/Support/OperatorExplanation/OperatorExplanationBuilderTest.php index 38556c89..66dd4395 100644 --- a/apps/platform/tests/Unit/Support/OperatorExplanation/OperatorExplanationBuilderTest.php +++ b/apps/platform/tests/Unit/Support/OperatorExplanation/OperatorExplanationBuilderTest.php @@ -61,6 +61,32 @@ expect($explanation->family)->toBe(ExplanationFamily::TrustworthyResult) ->and($explanation->evaluationResult)->toBe('full_result') ->and($explanation->trustworthinessLevel)->toBe(TrustworthinessLevel::Trustworthy) + ->and($explanation->nextActionCategory)->toBe('none') ->and($explanation->nextActionText)->toBe('No action needed') ->and($explanation->coverageStatement)->toContain('sufficient'); }); + +it('maps retryable transient reasons into retry-later guidance', function (): void { + $reason = $this->makeExplanationReasonEnvelope([ + 'internalCode' => 'baseline_capture_transient_timeout', + 'operatorLabel' => 'Capture paused', + 'shortExplanation' => 'The capture hit a transient timeout while collecting evidence.', + 'actionability' => 'retryable_transient', + 'nextSteps' => [\App\Support\ReasonTranslation\NextStepOption::instruction('Retry the capture after worker capacity recovers.')], + ]); + + $truth = $this->makeArtifactTruthEnvelope([ + 'executionOutcome' => 'partially_succeeded', + 'artifactExistence' => 'created_but_not_usable', + 'contentState' => 'missing_input', + 'actionability' => 'required', + 'primaryLabel' => 'Artifact not usable', + 'primaryExplanation' => 'The capture did not finish cleanly enough to produce a usable artifact.', + 'nextActionLabel' => 'Retry the capture after worker capacity recovers', + ], $reason); + + $explanation = app(OperatorExplanationBuilder::class)->fromArtifactTruthEnvelope($truth); + + expect($explanation->nextActionCategory)->toBe('retry_later') + ->and($explanation->nextActionText)->toBe('Retry the capture after worker capacity recovers'); +}); diff --git a/apps/platform/tests/Unit/Support/OpsUx/GovernanceRunDiagnosticSummaryBuilderTest.php b/apps/platform/tests/Unit/Support/OpsUx/GovernanceRunDiagnosticSummaryBuilderTest.php new file mode 100644 index 00000000..bdc44ec3 --- /dev/null +++ b/apps/platform/tests/Unit/Support/OpsUx/GovernanceRunDiagnosticSummaryBuilderTest.php @@ -0,0 +1,231 @@ +create(); + + $run = OperationRun::factory()->create([ + 'tenant_id' => (int) $tenant->getKey(), + 'workspace_id' => (int) $tenant->workspace_id, + 'type' => 'baseline_capture', + 'status' => 'completed', + 'outcome' => 'blocked', + 'context' => [ + 'reason_code' => 'missing_capability', + 'baseline_capture' => [ + 'subjects_total' => 0, + 'gaps' => [ + 'count' => 0, + ], + ], + ], + 'failure_summary' => [[ + 'reason_code' => 'missing_capability', + 'message' => 'A required capability is missing for this run.', + ]], + 'completed_at' => now(), + ]); + + $summary = app(GovernanceRunDiagnosticSummaryBuilder::class)->build($run); + + expect($summary)->not->toBeNull() + ->and($summary?->headline)->toContain('No baseline was captured') + ->and($summary?->dominantCause['label'])->toBe('No governed subjects captured') + ->and($summary?->nextActionCategory)->toBe('refresh_prerequisite_data') + ->and($summary?->affectedScaleCue['label'])->toBe('Capture scope') + ->and($summary?->affectedScaleCue['value'])->toContain('0 governed subjects'); +}); + +it('derives an ambiguous baseline compare summary with affected scale and scope review guidance', function (): void { + $tenant = Tenant::factory()->create(); + + $run = OperationRun::factory()->create([ + 'tenant_id' => (int) $tenant->getKey(), + 'workspace_id' => (int) $tenant->workspace_id, + 'type' => 'baseline_compare', + 'status' => 'completed', + 'outcome' => 'partially_succeeded', + 'context' => [ + 'baseline_compare' => [ + 'reason_code' => 'ambiguous_subjects', + 'subjects_total' => 12, + 'evidence_gaps' => [ + 'count' => 4, + ], + 'coverage' => [ + 'proof' => false, + ], + ], + ], + 'summary_counts' => [ + 'total' => 0, + 'processed' => 0, + 'errors_recorded' => 2, + ], + 'completed_at' => now(), + ]); + + $summary = app(GovernanceRunDiagnosticSummaryBuilder::class)->build($run); + + expect($summary)->not->toBeNull() + ->and($summary?->headline)->toContain('ambiguous subject matching') + ->and($summary?->dominantCause['label'])->toBe('Ambiguous matches') + ->and($summary?->nextActionCategory)->toBe('review_scope_or_ambiguous_matches') + ->and($summary?->affectedScaleCue['label'])->toBe('Affected subjects') + ->and($summary?->affectedScaleCue['value'])->toContain('4 governed subjects'); +}); + +it('keeps execution outcome separate from artifact impact for stale evidence snapshot runs', function (): void { + $tenant = Tenant::factory()->create(); + [, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner'); + + $run = $this->makeArtifactTruthRun($tenant, 'tenant.evidence.snapshot.generate'); + + $this->makeStaleArtifactTruthEvidenceSnapshot( + tenant: $tenant, + snapshotOverrides: [ + 'operation_run_id' => (int) $run->getKey(), + ], + ); + + $summary = app(GovernanceRunDiagnosticSummaryBuilder::class)->build($run->fresh()); + + expect($summary)->not->toBeNull() + ->and($summary?->executionOutcomeLabel)->toBe('Completed successfully') + ->and($summary?->artifactImpactLabel)->not->toBe($summary?->executionOutcomeLabel) + ->and($summary?->headline)->toContain('stale') + ->and($summary?->nextActionCategory)->toBe('refresh_prerequisite_data'); +}); + +it('derives resume capture or generation when a compare run records a resume token', function (): void { + $tenant = Tenant::factory()->create(); + + $run = OperationRun::factory()->create([ + 'tenant_id' => (int) $tenant->getKey(), + 'workspace_id' => (int) $tenant->workspace_id, + 'type' => 'baseline_compare', + 'status' => 'completed', + 'outcome' => 'partially_succeeded', + 'context' => [ + 'baseline_compare' => [ + 'resume_token' => 'resume-token-220', + 'evidence_gaps' => [ + 'count' => 2, + ], + ], + ], + 'completed_at' => now(), + ]); + + $summary = app(GovernanceRunDiagnosticSummaryBuilder::class)->build($run); + + expect($summary)->not->toBeNull() + ->and($summary?->nextActionCategory)->toBe('resume_capture_or_generation') + ->and($summary?->headline)->toContain('evidence capture still needs to resume'); +}); + +it('keeps deterministic multi-cause ordering for degraded review composition runs', function (): void { + $tenant = Tenant::factory()->create(); + [$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner'); + + $run = $this->makeArtifactTruthRun($tenant, 'tenant.review.compose'); + $snapshot = $this->makeStaleArtifactTruthEvidenceSnapshot($tenant, [ + 'operation_run_id' => null, + ]); + + $this->makeArtifactTruthReview( + tenant: $tenant, + user: $user, + snapshot: $snapshot, + reviewOverrides: [ + 'operation_run_id' => (int) $run->getKey(), + 'completeness_state' => 'partial', + ], + summaryOverrides: [ + 'section_state_counts' => [ + 'complete' => 4, + 'partial' => 1, + 'missing' => 1, + 'stale' => 0, + ], + ], + ); + + $builder = app(GovernanceRunDiagnosticSummaryBuilder::class); + $first = $builder->build($run->fresh()); + $second = $builder->build($run->fresh()); + + expect($first)->not->toBeNull() + ->and($second)->not->toBeNull() + ->and($first?->dominantCause['label'])->toBe('Missing sections') + ->and($first?->secondaryCauses[0]['label'] ?? null)->toBe('Stale evidence basis') + ->and($first?->secondaryCauses)->toEqual($second?->secondaryCauses) + ->and($first?->headline)->toContain('missing sections and stale evidence'); +}); + +it('derives no further action for publishable review pack runs', function (): void { + $tenant = Tenant::factory()->create(); + [$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner'); + + $run = $this->makeArtifactTruthRun($tenant, 'tenant.review_pack.generate'); + $snapshot = $this->makeArtifactTruthEvidenceSnapshot($tenant, [ + 'operation_run_id' => null, + ]); + $review = $this->makeArtifactTruthReview($tenant, $user, $snapshot, [ + 'operation_run_id' => null, + ]); + + $this->makeArtifactTruthReviewPack( + tenant: $tenant, + user: $user, + snapshot: $snapshot, + review: $review, + packOverrides: [ + 'operation_run_id' => (int) $run->getKey(), + ], + ); + + $summary = app(GovernanceRunDiagnosticSummaryBuilder::class)->build($run->fresh()); + + expect($summary)->not->toBeNull() + ->and($summary?->nextActionCategory)->toBe('no_further_action') + ->and($summary?->nextActionText)->toBe('No action needed.'); +}); + +it('does not invent new summary count keys while deriving scale cues', function (): void { + $tenant = Tenant::factory()->create(); + + $run = OperationRun::factory()->create([ + 'tenant_id' => (int) $tenant->getKey(), + 'workspace_id' => (int) $tenant->workspace_id, + 'type' => 'baseline_compare', + 'status' => 'completed', + 'outcome' => 'partially_succeeded', + 'summary_counts' => [ + 'total' => 7, + 'custom_noise' => 99, + ], + 'context' => [ + 'baseline_compare' => [], + ], + 'completed_at' => now(), + ]); + + $summary = app(GovernanceRunDiagnosticSummaryBuilder::class)->build($run); + + expect($summary)->not->toBeNull() + ->and(array_keys(SummaryCountsNormalizer::normalize(is_array($run->summary_counts) ? $run->summary_counts : []))) + ->toBe(['total']) + ->and($summary?->affectedScaleCue['source'])->toBe('summary_counts') + ->and($summary?->affectedScaleCue['label'])->toBe('Total'); +}); diff --git a/apps/platform/tests/Unit/Support/RelatedNavigationResolverTest.php b/apps/platform/tests/Unit/Support/RelatedNavigationResolverTest.php index 73d607af..d839c85a 100644 --- a/apps/platform/tests/Unit/Support/RelatedNavigationResolverTest.php +++ b/apps/platform/tests/Unit/Support/RelatedNavigationResolverTest.php @@ -87,7 +87,7 @@ $run = OperationRun::factory()->for($tenant)->create([ 'workspace_id' => (int) $tenant->workspace_id, - 'type' => 'backup_set.add_policies', + 'type' => 'backup_set.update', 'context' => [ 'backup_set_id' => (int) $backupSet->getKey(), ], diff --git a/docs/product/roadmap.md b/docs/product/roadmap.md index 97cde887..abdd7669 100644 --- a/docs/product/roadmap.md +++ b/docs/product/roadmap.md @@ -3,7 +3,7 @@ # Product Roadmap > Strategic thematic blocks and release trajectory. > This is the "big picture" — not individual specs. -**Last updated**: 2026-04-17 +**Last updated**: 2026-04-20 --- @@ -25,8 +25,8 @@ ### Governance & Architecture Hardening Goal: Turn the new audit constitution into enforceable backend and workflow guardrails before further governance surface area lands. **Active specs**: 144 -**Next wave candidates**: queued execution reauthorization and scope continuity, tenant-owned query canon and wrong-tenant guards, findings workflow enforcement and audit backstop, Livewire context locking and trusted-state reduction -**Operator truth initiative** (sequenced): Operator Outcome Taxonomy → Reason Code Translation → Artifact Truth Semantics → Governance Operator Outcome Compression; Provider Dispatch Gate Unification continues as the adjacent hardening lane (see spec-candidates.md — "Operator Truth Initiative" sequencing note) +**Specced follow-through (draft)**: 149 (queued execution reauthorization), 150 (tenant-owned query canon), 151 (findings workflow backstop), 152 (Livewire context locking), 214 (governance outcome compression), 216 (provider dispatch gate) +**Operator truth initiative** (sequenced): Operator Outcome Taxonomy (Spec 156) → Reason Code Translation (Spec 157) → Artifact Truth Semantics (Spec 158) → Governance Operator Outcome Compression (Spec 214, draft). Humanized Diagnostic Summaries for Governance Operations is now Spec 220 (draft) as the run-detail adoption slice, while Provider Dispatch Gate Unification is now Spec 216 (draft) as the adjacent hardening lane. **Source**: architecture audit 2026-03-15, audit constitution, semantic clarity audit 2026-03-21, product spec-candidates ### UI & Product Maturity Polish @@ -72,8 +72,8 @@ ## Planned (Next Quarter) ### R2 Completion — Evidence & Exception Workflows - Review pack export (Spec 109 — done) -- Exception/risk-acceptance workflow for Findings → **Not yet specced** -- Formal "evidence pack" entity → **Not yet specced** +- Exception/risk-acceptance workflow for Findings → Spec 154 (draft) +- Formal evidence/review-pack entity foundation → Spec 153 (evidence snapshots, draft) + Spec 155 (tenant review layer / review packs, draft) - Workspace-level PII override for review packs → deferred from 109 ### Findings Workflow v2 / Execution Layer diff --git a/docs/product/spec-candidates.md b/docs/product/spec-candidates.md index 64f11796..d905d288 100644 --- a/docs/product/spec-candidates.md +++ b/docs/product/spec-candidates.md @@ -5,7 +5,7 @@ # Spec Candidates > > **Flow**: Inbox → Qualified → Planned → Spec created → moved to `Promoted to Spec` -**Last reviewed**: 2026-04-19 (cleaned candidates already promoted to specs) +**Last reviewed**: 2026-04-21 (added `My Work` candidate family and aligned it with existing promoted work) --- @@ -41,6 +41,11 @@ ## Promoted to Spec - Operator Explanation Layer for Degraded / Partial / Suppressed Results → Spec 161 (`operator-explanation-layer`) - Request-Scoped Derived State and Resolver Memoization → Spec 167 (`derived-state-memoization`) - Tenant Governance Aggregate Contract → Spec 168 (`tenant-governance-aggregate-contract`) +- Governance Operator Outcome Compression → Spec 214 (`governance-outcome-compression`) +- Finding Ownership Semantics Clarification → Spec 219 (`finding-ownership-semantics`) +- Humanized Diagnostic Summaries for Governance Operations → Spec 220 (`governance-run-summaries`) +- Findings Operator Inbox v1 → Spec 221 (`findings-operator-inbox`) +- Provider-Backed Action Preflight and Dispatch Gate Unification → Spec 216 (`provider-dispatch-gate`) - Record Page Header Discipline & Contextual Navigation → Spec 192 (`record-header-discipline`) - Monitoring Surface Action Hierarchy & Workbench Semantics → Spec 193 (`monitoring-action-hierarchy`) - Governance Friction & Operator Vocabulary Hardening → Spec 194 (`governance-friction-hardening`) @@ -142,122 +147,6 @@ ### Baseline Capture Truthful Outcomes and Upstream Guardrails - **Dependencies**: Baseline drift engine stable (Specs 116–119), inventory coverage context, canonical operation-run presentation work (Specs 054, 114, 144), audit log foundation (Spec 134), tenant operability and execution legitimacy direction (Specs 148, 149) - **Related specs / candidates**: Spec 101 (baseline governance), Specs 116–119 (baseline drift engine), Spec 144 (canonical operation viewer context decoupling), Spec 149 (queued execution reauthorization), `discoveries.md` entry "Drift engine hard-fail when no Inventory Sync exists" -### Provider-Backed Action Preflight and Dispatch Gate Unification -- **Type**: hardening -- **Source**: prerequisite-handling architecture analysis, provider dispatch gate architecture review, semantic clarity audit 2026-03-21 -- **Problem**: TenantPilot has two generations of provider-backed action dispatch patterns that produce inconsistent operator experiences for the same class of problem (missing prerequisites, blocked execution, concurrency collision): - - **Gen 2 pattern** (correct, ~3 job types): `ProviderInventorySyncJob`, `ProviderConnectionHealthCheckJob`, `ProviderComplianceSnapshotJob` receive an explicit `providerConnectionId`, pass through `ProviderOperationStartGate` at dispatch time, and produce structured `ProviderOperationStartResult` envelopes with 4 clear states (`started`, `deduped`, `scope_busy`, `blocked`) plus structured reason codes and next-steps. Operators learn about blocked conditions **before** the job is queued. - - **Gen 1 pattern** (inconsistent, ~20 services + their jobs): `ExecuteRestoreRunJob`, `EntraGroupSyncJob`, `SyncRoleDefinitionsJob`, policy sync jobs, and approximately 17 other services resolve connections implicitly at runtime via `MicrosoftGraphOptionsResolver::resolveForTenant()` or internal `resolveProviderConnection()` methods. These services bypass the gate entirely — prerequisites are not checked before dispatch. Blocked conditions are discovered asynchronously during job execution, producing runtime exceptions (`ProviderConfigurationRequiredException`, `RuntimeException`, `InvalidArgumentException`) that surface to operators as after-the-fact failed runs rather than preventable preflight blocks. - - **Operator impact**: the same class of problem (missing provider connection, expired consent, invalid credentials, scope busy) produces two different operator experiences depending on which action triggered it. Gen 2 actions produce a clear "blocked" result with reason code and next-step guidance at the moment the operator clicks the button. Gen 1 actions silently queue, then fail asynchronously — the operator discovers the problem only when checking the operation run later, with a raw error message instead of structured guidance. - - **Concurrency and deduplication gaps**: the `ProviderOperationStartGate` handles scope_busy / deduplication for Gen 2 operations, but Gen 1 operations have no equivalent deduplication — multiple restore or sync jobs for the same tenant/scope can be queued simultaneously, competing for the same provider connection without coordination. - - **Notification inconsistency**: Gen 2 blocked results produce immediate toast/notification via `ProviderOperationStartResult` rendering in Filament actions. Gen 1 failures produce terminal `OperationRunCompleted` notifications with sanitized but still technical failure messages. The operator receives different feedback patterns for equivalent problems. -- **Why it matters now**: As TenantPilot adds more provider domains (Entra roles, enterprise apps, SharePoint sharing), more operation types (baseline capture, drift detection, evidence generation), and more governance workflows (restore, review, compliance snapshot), every new provider-backed action that follows the Gen 1 implicit pattern reproduces the same operator experience gap. The Gen 2 pattern is proven, architecturally correct, and already handles the hard problems (connection locking, stale run detection, structured reason codes). The gap is not design — it is incomplete adoption. Additionally, the "Provider Connection Resolution Normalization" candidate addresses the backend plumbing problem (explicit connection ID passing), but does not address the operator-facing preflight/dispatch gate UX pattern. This candidate addresses the operator experience layer: ensuring that all provider-backed actions follow one canonical start path and that operators receive consistent, structured, before-dispatch feedback about prerequisites. -- **Proposed direction**: - - **Canonical dispatch entry point for all provider-backed actions**: all operator-triggered provider-backed actions (sync, backup, restore, health check, compliance snapshot, baseline capture, evidence generation, and future provider operations) must pass through a canonical preflight/dispatch gate before queuing. The existing `ProviderOperationStartGate` is the reference implementation; this candidate extends its scope to cover all provider-backed operation types, not just the current 3. - - **Structured preflight result presentation contract**: define a shared Filament action result-rendering pattern for `ProviderOperationStartResult` states (`started`, `deduped`, `scope_busy`, `blocked`) so that every provider-backed action button produces the same UX feedback pattern. Currently, each Gen 2 consumer renders gate results with local if/else blocks — this should be a shared presenter or action mixin. - - **Pre-queue prerequisite detection**: blocked conditions (missing connection, expired consent, invalid credentials, tenant not operable, scope busy, missing required permissions) must be detected and surfaced to the operator **before** the job is dispatched to the queue. Operators should never discover a preventable prerequisite failure only after checking a terminal `OperationRun` record. - - **Dispatch-time connection locking for all operation types**: extend the `FOR UPDATE` row-locking pattern from Gen 2 to all provider-backed operations, preventing concurrent conflicting operations on the same provider connection. - - **Deduplication/scope-busy enforcement for all operation types**: extend scope_busy/dedup detection to Gen 1 operations (restore, group sync, role sync, etc.) that currently lack it. Operators should receive "An operation of this type is already running for this tenant" feedback at click time, not discover it through a failed run. - - **Unified next-steps for all blocked states**: extend the `ProviderNextStepsRegistry` pattern (or its successor from the Reason Code Translation candidate) to cover all provider-backed operation blocked states, not just provider connection codes. Every "blocked" gate result includes cause-specific next-action guidance. - - **Operator notification alignment**: terminal notifications for provider-backed operations must follow the same structured pattern regardless of which generation of plumbing dispatched them. The notification should include: translated reason code (per Reason Code Translation contract), structured next-action guidance, and a link to the relevant resolution surface. -- **Key decisions to encode**: - - `ProviderOperationStartGate` (or its evolved successor) is the single canonical dispatch entry point — no provider-backed action bypasses it - - Pre-queue prerequisite detection is a product guarantee for all provider operations — async-only failure discovery is an anti-pattern - - Scope-busy / deduplication is mandatory for all provider operations, not just Gen 2 - - The gate result presentation is a shared UI contract, not a per-action local rendering decision -- **Scope boundaries**: - - **In scope**: extending `ProviderOperationStartGate` scope to all provider-backed operation types, shared gate result presenter for Filament actions, pre-queue prerequisite detection for Gen 1 operations, scope-busy/dedup extension, next-steps enrichment for all gate blocked states, notification alignment for gate results, dispatch-time connection locking extension - - **Out of scope**: backend connection resolution refactoring (tracked separately as "Provider Connection Resolution Normalization" — that candidate handles explicit `providerConnectionId` passing; this candidate handles the operator-facing gate/preflight layer), provider connection UX label changes (tracked as "Provider Connection UX Clarity"), legacy credential cleanup (tracked as "Provider Connection Legacy Cleanup"), adding new provider domains (domain expansion specs own that), operation naming vocabulary (tracked separately), reason code translation contract definition (tracked as "Operator Reason Code Translation" — this candidate consumes translated labels) -- **Affected workflow families / surfaces**: All provider-backed Filament actions across TenantResource, ProviderConnectionResource, onboarding wizard, and future governance action surfaces. Approximately 20 services currently using Gen 1 implicit resolution. Notification templates for provider-backed operation terminal states. System console triage views for provider-related failures. -- **Why this should be one coherent candidate rather than per-action fixes**: The Gen 1 pattern is used by ~20 services. Fixing each service independently would produce 20 local preflight implementations with inconsistent result rendering, different dedup logic, and incompatible notification patterns. The value is the unified contract: one gate, one result envelope, one presenter, one notification pattern. Per-action fixes cannot deliver this convergence. -- **Dependencies**: Provider Connection Resolution Normalization (soft dependency — gate unification is more coherent when all services receive explicit connection IDs, but the gate itself can be extended before full backend normalization is complete since `ProviderConnectionResolver::resolveDefault()` already exists). Operator Reason Code Translation (for translated blocked-reason labels in gate results). Operator Outcome Taxonomy (for consistent outcome vocabulary in gate result presentation). -- **Related specs / candidates**: Provider Connection Resolution Normalization (backend plumbing), Provider Connection UX Clarity (UX labels), Provider Connection Legacy Cleanup (post-normalization cleanup), Queued Execution Reauthorization (execution-time revalidation — complementary to dispatch-time gating), Baseline Capture Truthful Outcomes (consumes gate results for baseline-specific preconditions), Operator Presentation & Lifecycle Action Hardening (rendering conventions for gate results) -- **Strategic sequencing**: Recommended as the adjacent hardening lane after the shared taxonomy and translation work are in place, while governance-surface adoption proceeds through Spec 158 and the governance compression follow-up. It benefits from the vocabulary defined by the Outcome Taxonomy and the humanized labels from Reason Code Translation, but much of the backend extension of `ProviderOperationStartGate` scope can proceed independently and in parallel with governance-surface work. -- **Priority**: high - -### Governance Operator Outcome Compression -- **Type**: hardening -- **Source**: product follow-up recommendation 2026-03-23; direct continuation of Spec 158 (`artifact-truth-semantics`) -- **Vehicle**: new standalone candidate -- **Problem**: Spec 158 establishes the correct internal truth model for governance artifacts, but several governance-facing list and summary surfaces still risk exposing too many internal semantic axes as first-class UI language. On baseline, evidence, review, and pack surfaces the product can still read as academically correct but operator-heavy: multiple adjacent status badges, architecture-derived labels, and equal treatment of existence, readiness, freshness, completeness, and publication semantics. Normal operators are forced to synthesize the answer to three simple workflow questions themselves: Is this artifact usable, why not, and what should I do next? -- **Why it matters**: This is the cockpit follow-up to Spec 158's engine work. Without it, TenantPilot preserves semantic correctness internally but leaks too much of that structure directly into governance UX. The result is lower scanability, weaker operator confidence, and a real risk that baseline, evidence, review, and pack domains each evolve their own local status dialect despite sharing the same truth foundation. Shipping this follow-up before broader governance expansion stabilizes operator language where MSP admins actually work. -- **Proposed direction**: - - Introduce a **compressed operator outcome layer** for governance artifacts that consumes the existing `ArtifactTruthEnvelope`, outcome taxonomy, and reason translation contracts without discarding any internal truth dimensions - - Define rendering rules that classify each truth dimension as **primary operator view**, **secondary explanatory detail**, or **diagnostics only** - - Make list and overview rows answer three questions first: **primary state**, **short reason**, **next action** - - Normalize visible operator language so internal architectural terms such as `artifact truth`, `missing_input`, `metadata_only`, or `publication truth` do not dominate primary workflow surfaces - - Clarify where **publication readiness** is the primary business statement versus where it is only one secondary dimension, especially for tenant reviews and review packs - - Keep diagnostics available on detail and run-detail pages, but demote raw reason structures, fidelity sub-axes, JSON context, and renderer/support facts behind the primary operator explanation -- **Primary adoption surfaces**: - - Baseline snapshot lists and detail pages - - Evidence snapshot lists and detail pages - - Evidence overview - - Tenant review lists and detail pages - - Review register - - Review pack lists and detail pages - - Shared governance detail templates and artifact-truth presenter surfaces - - Artifact-oriented run-detail pages only where the run is explaining baseline, evidence, review, or review-pack truth -- **Scope boundaries**: - - **In scope**: visible operator labels, list-column hierarchy, detail-page information hierarchy, mapping from artifact-truth envelopes to compressed operator states, explicit separation between default operator view and diagnostic detail, review/pack publication-readiness primacy rules, governance run-detail explanation hierarchy - - **Out of scope**: full operations-list redesign, broad visual polish, color or spacing retuning as the primary goal, new semantic foundation axes, broad findings or workspace overview rewrites, compliance/audit PDF output changes, alert routing or notification copy rewrites, domain-model refactors that change the underlying truth representation -- **Core product principles to encode**: - - One primary operator statement per artifact on scan surfaces - - No truth loss: internal artifact truth, reason structures, APIs, audit context, and JSON diagnostics remain intact and available - - Diagnostics are second-layer, not the default operator language - - Context-specific business language beats architecture-first vocabulary on primary governance surfaces - - Lists are scan surfaces, not diagnosis surfaces -- **Candidate requirements**: - - **R1 Composite operator outcome**: governance artifacts expose a compressed operator-facing outcome derived from the existing truth and reason model - - **R2 Primary / secondary / diagnostic rendering rules**: the system defines which semantic dimensions may appear in each rendering tier - - **R3 List-surface simplification**: governance lists stop defaulting to multi-column badge explosions for separate semantic axes - - **R4 Detail-surface hierarchy**: details lead with outcome, explanation, and next action before diagnostics - - **R5 Operator language normalization**: internal architecture terms are translated or removed from primary governance UI - - **R6 Review / pack publication clarity**: review and pack surfaces explicitly state when publishability is the main business decision and when it is not - - **R7 No truth loss**: APIs, audit, diagnostics, and raw context remain available even when the primary presentation is compressed -- **Acceptance points**: - - Governance lists no longer present multiple equal-weight semantic badge columns as the default mental model - - `artifact truth` and sibling architecture-first labels stop dominating primary operator surfaces - - Governance detail pages clearly separate primary state, explanatory reason, next action, and diagnostics - - Review and pack surfaces clearly answer whether the artifact is ready to publish or share - - Baseline and evidence surfaces clearly answer whether the artifact is trustworthy and usable - - Governance run-detail pages make the dominant problem and next action understandable without reading raw JSON - - The internal truth model remains fully usable for diagnostics, audit, and downstream APIs -- **Dependencies**: Spec 156 (operator-outcome-taxonomy), Spec 157 (reason-code-translation), Spec 158 (artifact-truth-semantics), shared governance detail templates, review-layer and evidence-domain adoption surfaces already in flight -- **Related specs / candidates**: Spec 153 (evidence-domain-foundation), Spec 155 (tenant-review-layer), Spec 158 (artifact-truth-semantics), Baseline Snapshot Fidelity Semantics candidate, Compliance Readiness & Executive Review Packs candidate -- **Strategic sequencing**: Recommended immediately after Spec 158 and before any major additional governance-surface expansion. This is the adoption layer that turns the truth semantics foundation into an operator-tolerable cockpit instead of a direct dump of internal semantic richness. -- **Priority**: high - -### Humanized Diagnostic Summaries for Governance Operations -- **Type**: hardening -- **Source**: operator-facing governance run-detail gap analysis 2026-03-23; follow-up to Specs 156, 157, and 158 -- **Vehicle**: new standalone candidate -- **Problem**: Governance run-detail pages now have the right outcome, reason, and artifact-truth semantics, but the operator explanation often still lives in raw JSON. A run can read as `Completed with follow-up`, `Partial`, `Blocked`, or `Missing input` while the important meaning stays hidden: how much was affected, which cause dominates, whether retry or resume helps, and whether the resulting artifact is actually trustworthy. -- **Why it matters**: This is the missing middle layer between Spec 158's truth engine and the operator's actual decision. Without it, TenantPilot stays semantically correct but too technical on one of its highest-trust governance surfaces. Raw JSON remains part of normal troubleshooting when it should be optional. -- **Proposed direction**: - - Add a humanized diagnostic summary layer on governance run-detail pages between semantic verdicts and raw JSON - - Lead with impact, dominant cause, artifact trustworthiness, and next action instead of forcing operators to infer those from badges plus raw context - - Render a compact dominant-cause breakdown for multi-cause degraded runs, including counts or relative scale where useful - - Separate processing-success counters from artifact usability so technically correct metrics do not read as false-green artifact success - - Upgrade generic `Inspect diagnostics` guidance into cause-aware next steps such as retry later, resume capture, refresh policy inventory, verify missing policies, review ambiguous matches, or fix access or scope configuration - - Keep raw JSON and low-level context fully available, but explicitly secondary -- **Primary adoption surfaces**: - - Canonical Monitoring run-detail pages for governance operation types - - Shared tenantless canonical run viewers and run-detail templates - - Governance run detail reached from baseline capture, baseline compare, evidence refresh or snapshot generation, tenant review generation, and review-pack generation -- **Scope boundaries**: - - **In scope**: run-detail explanation hierarchy, humanized impact summaries, dominant-cause breakdowns, clearer processing-versus-artifact terminology, reusable guidance pattern for governance run families - - **Out of scope**: full Operations redesign, broad list or dashboard overhaul, new persistence models for summaries, removal of raw JSON, new truth axes beyond the existing outcome or artifact-truth model, generalized rewrite of all governance artifact detail pages -- **Acceptance points**: - - A normal operator can understand the dominant problem and next step on a governance run-detail page without opening raw JSON - - Runs with technically successful processing but degraded artifacts explicitly explain why those truths diverge - - Multi-cause degraded runs show the dominant causes and their scale instead of only one flattened abstract state - - Guidance distinguishes retryable or resumable situations from structural prerequisite problems when the data supports that distinction - - Raw JSON remains present for audit and debugging but is clearly secondary in the page hierarchy -- **Dependencies**: Spec 156 (operator-outcome-taxonomy), Spec 157 (reason-code-translation), Spec 158 (artifact-truth-semantics), canonical operation viewer work, shared governance detail templates -- **Related specs / candidates**: Spec 144 (canonical operation viewer context decoupling), Spec 153 (evidence-domain-foundation), Spec 155 (tenant-review-layer), Spec 158 (artifact-truth-semantics), Governance Operator Outcome Compression candidate -- **Strategic sequencing**: Best treated as the run-detail explainability companion to Governance Operator Outcome Compression. Compression improves governance artifact scan surfaces; this candidate makes governance run detail self-explanatory once an operator drills in. -- **Priority**: high - > **Operator Truth Initiative — Sequencing Note** > > The operator-truth work now has two connected lanes: a shared truth-foundation lane and a governance-surface compression lane. Together they address the systemic gap between backend truth richness and operator-facing truth quality without forcing operators to parse raw internal semantics. @@ -267,9 +156,9 @@ ### Humanized Diagnostic Summaries for Governance Operations > 2. **Operator Reason Code Translation and Humanization Contract** — defines the translation bridge from internal codes to operator-facing labels using the Outcome Taxonomy's vocabulary. Can begin in parallel with the taxonomy using pragmatic interim labels, but final convergence depends on the taxonomy. > 3. **Governance Artifact Truthful Outcomes & Fidelity Semantics** — establishes the full internal truth model for governance artifacts, keeping existence, usability, freshness, completeness, publication readiness, and actionability distinct. > 4. **Operator Explanation Layer for Degraded / Partial / Suppressed Results** — defines the cross-cutting interpretation layer that turns internal truth dimensions into operator-readable explanation: multi-dimensional outcome separation (execution, evaluation, reliability, coverage, action), shared explanation patterns for degraded/partial/suppressed states, count semantics rules, "why no findings" patterns, and reliability visibility. Consumes the taxonomy, translation, and artifact-truth foundations; provides the shared explanation pattern library that compression and humanized summaries adopt on their respective surfaces. Baseline Compare is the golden-path reference implementation. -> 5. **Governance Operator Outcome Compression** — applies the foundation and explanation layer to governance workflow surfaces so lists and details answer the operator's primary question first, while preserving diagnostics as second-layer detail. -> 6. **Humanized Diagnostic Summaries for Governance Operations** — the run-detail explainability companion to compression; makes governance run detail self-explanatory using the explanation patterns established in step 4. -> 7. **Provider-Backed Action Preflight and Dispatch Gate Unification** — extends the proven Gen 2 gate pattern to all provider-backed operations and establishes a shared result presenter. Benefits from the shared vocabulary work but remains a parallel hardening lane rather than a governance-surface adoption slice. +> 5. **Governance Operator Outcome Compression** — now promoted to Spec 214, applying the foundation and explanation layer to governance workflow surfaces so lists and details answer the operator's primary question first while preserving diagnostics as second-layer detail. +> 6. **Humanized Diagnostic Summaries for Governance Operations** — now promoted to Spec 220 (`governance-run-summaries`), the run-detail explainability companion to compression that makes governance run detail self-explanatory using the explanation patterns established in step 4. +> 7. **Provider-Backed Action Preflight and Dispatch Gate Unification** — now promoted to Spec 216, extending the proven Gen 2 gate pattern to all provider-backed operations and establishing a shared result presenter as the adjacent hardening lane. > > **Why this order rather than the inverse:** The semantic-clarity audit classified the taxonomy problem as P0 (60% of warning badges are false alarms — actively damages operator trust). Reason code translation creates the shared human-facing language. Spec 158 establishes the correct internal truth engine for governance artifacts. The Operator Explanation Layer then defines the cross-cutting interpretation patterns that all downstream surfaces need — the systemwide rules for how degraded, partial, suppressed, and incomplete results are separated, explained, and acted upon. Compression and humanized summaries are adoption slices that apply those patterns to specific surface families (governance artifact lists and governance run details respectively). Gate unification remains highly valuable but is a neighboring hardening lane. > @@ -327,7 +216,7 @@ ### Operation Run Active-State Visibility & Stale Escalation - tenant-scoped surfaces never show another tenant's runs - operations list clearly surfaces problematic active runs for fast scan - **Dependencies**: existing operation-run lifecycle policy and stale detection foundations, canonical run viewer work, tenant-local active-run surfaces, operations monitoring list surfaces -- **Related specs / candidates**: Spec 114 (system console / operations monitoring foundations), Spec 144 (canonical operation viewer context decoupling), Spec 149 (queued execution reauthorization and lifecycle continuity), Spec 161 (operator-explanation-layer), Provider-Backed Action Preflight and Dispatch Gate Unification (neighboring operational hardening lane) +- **Related specs / candidates**: Spec 114 (system console / operations monitoring foundations), Spec 144 (canonical operation viewer context decoupling), Spec 149 (queued execution reauthorization and lifecycle continuity), Spec 161 (operator-explanation-layer), Spec 216 (Provider-Backed Action Preflight and Dispatch Gate Unification) - **Strategic sequencing**: Best treated before any broader operator intervention-actions spec. First make stale or hanging work visibly truthful across all existing surfaces; only then add retry / force-fail / reconcile-now UX on top of a coherent active-state language. - **Priority**: high @@ -380,8 +269,8 @@ ### Baseline Compare Scope Guardrails & Ambiguity Guidance - R2: too much guidance without technical guardrails might let operators keep building bad baselines → mitigation: conservative framing and later evolution via real usage data - R3: team reads this spec as a starting point for large identity architecture → mitigation: non-goals are explicitly and strictly scoped - **Roadmap fit**: Aligns directly with Release 1 — Golden Master Governance (R1.1 BaselineProfile, R1.3 baseline.compare, R1.4 Drift UI: Soll vs Ist). Improves V1 sellability without domain-model expansion: less confusing drift communication, clearer Golden Master story, no false operator blame, better trust basis for compare results. -- **Dependencies**: Baseline drift engine stable (Specs 116–119), Baseline Snapshot Fidelity Semantics candidate (complementary — snapshot-level fidelity clarity), Spec 161 (operator-explanation-layer), Governance Operator Outcome Compression candidate (complementary — governance artifact presentation) -- **Related specs / candidates**: Specs 116–119 (baseline drift engine), Spec 101 (baseline governance), Baseline Capture Truthful Outcomes candidate, Baseline Snapshot Fidelity Semantics candidate, Spec 161 (operator-explanation-layer), Governance Operator Outcome Compression candidate +- **Dependencies**: Baseline drift engine stable (Specs 116–119), Baseline Snapshot Fidelity Semantics candidate (complementary — snapshot-level fidelity clarity), Spec 161 (operator-explanation-layer), Spec 214 (Governance Operator Outcome Compression) as the complementary governance artifact presentation layer +- **Related specs / candidates**: Specs 116–119 (baseline drift engine), Spec 101 (baseline governance), Baseline Capture Truthful Outcomes candidate, Baseline Snapshot Fidelity Semantics candidate, Spec 161 (operator-explanation-layer), Spec 214 (Governance Operator Outcome Compression) - **Recommendation**: Treat before any large matching/identity extension. Small enough for V1, reduces real operator confusion, protects against scope creep, and sharpens the product message: TenantPilot compares curated governance baselines — not blindly every generic tenant default content. - **Priority**: high @@ -463,7 +352,7 @@ ### Tenant Operational Readiness & Status Truth Hierarchy - **Boundary with Provider Connection Status Vocabulary Cutover**: Provider vocabulary cutover owns the `ProviderConnection` model-level status field cleanup (canonical enum sources replacing legacy varchar fields). This candidate owns how provider truth appears on tenant-level surfaces in relation to other readiness domains. The vocabulary cutover provides cleaner provider truth; this candidate defines where and how that truth is presented alongside lifecycle, verification, RBAC, and evidence. - **Boundary with Inventory, Provider & Operability Semantics**: That candidate normalizes how inventory capture mode, provider health/prerequisites, and verification results are presented on their own surfaces. This candidate defines the tenant-level integration layer that synthesizes those domain truths into a single headline readiness answer. Complementary: domain-level semantic cleanup feeds into the readiness model. - **Boundary with Spec 161 (Operator Explanation Layer)**: The explanation layer defines cross-cutting interpretation patterns for degraded/partial/suppressed results. This candidate defines the tenant-specific readiness truth hierarchy. The explanation layer may inform how degraded readiness states are explained, but the readiness model itself is tenant-domain-specific. -- **Boundary with Governance Operator Outcome Compression**: Governance compression improves governance artifact scan surfaces. This candidate improves tenant operational readiness surfaces. Both reduce operator cognitive load but on different product domains. +- **Boundary with Spec 214 (Governance Operator Outcome Compression)**: Governance compression improves governance artifact scan surfaces. This candidate improves tenant operational readiness surfaces. Both reduce operator cognitive load but on different product domains. - **Dependencies**: Operator Outcome Taxonomy and Cross-Domain State Separation (shared vocabulary foundation), Tenant App Status False-Truth Removal (quick win that removes the most obvious legacy truth — can proceed independently before or during this spec), Provider Connection Status Vocabulary Cutover (cleaner provider truth sources — soft dependency), existing tenant lifecycle semantics (Spec 143) - **Related specs / candidates**: Spec 143 (tenant lifecycle operability context semantics), Tenant App Status False-Truth Removal, Provider Connection Status Vocabulary Cutover, Provider Connection UX Clarity, Inventory Provider & Operability Semantics, Operator Outcome Taxonomy (Spec 156), Operation Run Active-State Visibility & Stale Escalation - **Strategic sequencing**: Best tackled after the Operator Outcome Taxonomy provides shared vocabulary and after Tenant App Status False-Truth Removal removes the most obvious legacy truth as a quick win. Should land before major portfolio, MSP rollup, or compliance readiness surfaces are built, since those surfaces will inherit the tenant readiness model as a foundational input. @@ -471,30 +360,6 @@ ### Tenant Operational Readiness & Status Truth Hierarchy > Findings execution layer cluster: complementary to existing Spec 154 (`finding-risk-acceptance`). Keep these split so prioritization can pull workflow semantics, operator work surfaces, alerts, external handoff, and later portfolio operating slices independently instead of collapsing them into one oversized "Findings v2" spec. -### Finding Ownership Semantics Clarification -- **Type**: domain semantics / workflow hardening -- **Source**: findings execution layer candidate pack 2026-04-17; current Finding owner/assignee overlap analysis -- **Problem**: Finding already models `owner` and `assignee`, but the semantic split is not crisp enough to support inboxes, escalation, stale-work detection, or consistent audit language. Accountability and active execution responsibility can currently blur together in UI copy, policies, and workflow rules. -- **Why it matters**: Without a shared contract, every downstream workflow slice will invent its own meaning for owner versus assignee. That produces ambiguous queues, brittle escalation rules, and inconsistent governance language. -- **Proposed direction**: Define canonical semantics for accountability owner versus active assignee; align labels, policies, audit/event vocabulary, and permission expectations around that split; encode expected lifecycle states for unassigned, assigned, reassigned, and orphaned work without introducing team-queue abstractions yet. -- **Explicit non-goals**: Team-/queue-based ownership, ticketing, comments, and notification delivery. -- **Dependencies**: Existing Findings model and workflow state machine, Findings UI surfaces, audit vocabulary. -- **Roadmap fit**: Findings Workflow v2 hardening lane. -- **Strategic sequencing**: First. The rest of the findings execution layer consumes this decision. -- **Priority**: high - -### Findings Operator Inbox v1 -- **Type**: operator surface / workflow execution -- **Source**: findings execution layer candidate pack 2026-04-17; gap between assignment auditability and day-to-day operator flow -- **Problem**: Findings can be assigned, but TenantPilot lacks a personal work surface that turns assignment into a real operator queue. Assigned work is still discovered indirectly through broader tenant or findings lists. -- **Why it matters**: Without a dedicated personal queue, assignment remains metadata instead of operational flow. A "My Work" surface is the simplest bridge from governance data to daily execution. -- **Proposed direction**: Add a workspace-level or otherwise permission-safe "My Findings / My Work" surface for the current user; emphasize open, due, overdue, high-severity, and reopened findings; provide fast drilldown into the finding record; add a small "assigned to me" dashboard signal; reuse existing RBAC and finding visibility rules instead of inventing a second permission system. -- **Explicit non-goals**: Team queues, complex routing rules, external ticketing, and multi-step approval chains. -- **Dependencies**: Ownership semantics, `assignee_user_id`, `due_at`, finding status logic, RBAC on finding read/open. -- **Roadmap fit**: Findings Workflow v2; feeds later governance inbox work. -- **Strategic sequencing**: Second, ideally immediately after ownership semantics. -- **Priority**: high - ### Findings Intake & Team Queue v1 - **Type**: workflow execution / team operations - **Source**: findings execution layer candidate pack 2026-04-17; missing intake surface before personal assignment @@ -735,6 +600,106 @@ ### Surface Taxonomy & Workflow-First IA Classification - **Dependencies**: Decision-First Operating Constitution Hardening, existing navigation/context/action-surface specs, product surface inventory - **Priority**: high +### Personal Work IA / My Work +- **Type**: IA / workflow foundation +- **Source**: admin workspace IA discussion 2026-04-21; personal work architecture candidate pack +- **Problem**: TenantPilot now has a real assignee-facing work surface in Spec 221 (`findings-operator-inbox`), but future personal work would otherwise fragment across findings, approvals, reviews, alerts, and exception-renewal surfaces without one stable "what is my work today?" entry point. +- **Why it matters**: This is not just a navigation tweak. As TenantPilot becomes more workflow- and decision-oriented, personally addressed actionable work needs its own IA layer. Without that layer, discoverability, counts, and operator mental models drift by domain. +- **Proposed direction**: + - Add a top-level `My Work` group in the admin panel as the personal lens on domain work, not as a second monitoring tree or favorites bucket + - Allow only surfaces that are explicitly assigned to the current user or awaiting that user's concrete decision + - Keep global domain navigation canonical for browsing, reporting, and non-personal work + - Treat the dashboard as a signal and entry surface, not the durable home of personal queues + - Start with the IA contract and admission rules; do not require every future child surface to ship together +- **Admission rules**: + - Personal addressability: explicit assignee, approver, or decision owner; generic responsibility is insufficient + - Concrete next action: triage, approve, renew, close, escalate, or equivalent; reports and diagnostics alone are out + - Workspace-safe scope: rows, counts, and badges stay limited to visible, authorized workspace and tenant scope + - Personal value-add: the surface does more than deep-link to a global list by adding personal filtering, prioritization, or decision support + - No replacement of domain navigation: domain collections remain canonical outside the personal lens +- **Vehicle note**: `My Work — Assigned Findings` is already materially represented by Spec 221 (`findings-operator-inbox`) and should be treated as the first concrete child surface rather than a second open candidate. +- **Activation rule**: Introduce `My Work` as actual top-level navigation only once at least two real personal work surfaces exist or are committed near-term. Before that, the IA contract may exist without forcing a single-link top-level group. +- **Explicit non-goals**: Not a generic "My Area", not profile/settings relocation, not favorites/bookmarks, not a universal task engine, not a dashboard replacement, and not a notification center. +- **Boundary with Spec 221 (Findings Operator Inbox)**: Spec 221 defines the first concrete personal findings queue. This candidate defines the durable admin-IA rule that decides when that queue graduates into a broader personal-work group and how future personal surfaces should join it. +- **Boundary with Human-in-the-Loop Autonomous Governance / Governance Inbox**: Governance Inbox is the long-horizon cross-workflow decision cockpit with structured recommendations and controlled execution. `My Work` is the nearer-term IA layer for personally addressed queues in the existing admin workspace. It should not absorb the full governance-inbox ambition. +- **Dependencies**: Surface Taxonomy & Workflow-First IA Classification, Spec 221 (`findings-operator-inbox`), workspace/tenant scope enforcement, future assignment and approval routing semantics +- **Priority**: high + +> `My Work` candidate family: keep child surfaces and cross-cutting semantics split so prioritization can land the IA contract, the next concrete personal queues, and the routing/count foundations independently instead of turning personal work into one oversized umbrella spec. + +### My Work — Pending Approvals +- **Type**: workflow execution / approvals +- **Source**: personal work architecture candidate pack 2026-04-21; future approval-bearing workflows +- **Problem**: Approval work would otherwise be scattered across risk acceptance, drift governance, restore, or rollout surfaces without one trustworthy personal decision queue. +- **Why it matters**: Approval is the cleanest form of personally addressed work. If it remains buried in domain pages, operators lose the "awaiting my decision" contract. +- **Proposed direction**: Add a personal approvals queue for decisions that explicitly await the current user's approval or rejection; show decision summary, urgency, scope, and safe drilldown; keep FYI notifications and passive review signals out. +- **Explicit non-goals**: Notification center, knowledge-only acknowledgements, general automation orchestration, or inventing a full approval engine before approval-producing domains exist. +- **Dependencies**: Risk acceptance lifecycle (Spec 154), drift/change approval direction, restore or rollout approval producers, routing semantics +- **Strategic sequencing**: Strong candidate for the second real `My Work` child surface because it naturally satisfies the admission rules. +- **Priority**: high + +### My Work — Assigned Reviews +- **Type**: workflow execution / review work +- **Source**: personal work architecture candidate pack 2026-04-21; governance/review responsibility gap +- **Problem**: Review work can easily remain hidden in tenant review, evidence, or governance surfaces even when a specific reviewer is responsible. +- **Why it matters**: Reviews are person-bound work, but not all reviews are findings or approvals. A dedicated personal review queue keeps governance responsibility visible without flattening everything into one findings model. +- **Proposed direction**: Add a review queue for review packs, evidence bundles, or governance review steps explicitly assigned to the current user; emphasize due state, review scope, and next action. +- **Explicit non-goals**: Generic reporting hub, passive read receipts, or turning `My Work` into a full collaboration suite. +- **Dependencies**: Review-layer maturity, evidence surfaces, assignment semantics, due-date conventions +- **Priority**: medium + +### My Work — Risk Acceptance Renewals +- **Type**: workflow execution / time-bound governance +- **Source**: personal work architecture candidate pack 2026-04-21; exception-renewal follow-up +- **Problem**: Expiring risk acceptances or exceptions create person-addressed renewal work, but that work is neither standard findings triage nor generic monitoring. +- **Why it matters**: Renewal work is deadline-driven and materially important, so it needs a calm but trustworthy personal queue instead of disappearing inside exception detail pages. +- **Proposed direction**: Add a renewal queue for expiring or expired risk acceptances where the current user is owner or required approver; support renew, close, or escalate next steps. +- **Explicit non-goals**: Full exception lifecycle redesign or generic reminder infrastructure for every dated object in the product. +- **Dependencies**: Spec 154 (`finding-risk-acceptance`), due/expiry semantics, routing semantics +- **Priority**: medium + +### My Work — Actionable Alerts +- **Type**: alerts / workflow execution +- **Source**: personal work architecture candidate pack 2026-04-21; action-vs-notification boundary review +- **Problem**: Some alerts represent concrete assigned follow-up, while others are only awareness signals. Without a boundary, `My Work` either becomes noisy or misses genuine action-bearing alerts. +- **Why it matters**: `My Work` must stay quiet and trustworthy. Admitting every notification would destroy the queue's meaning; admitting none would keep action-bearing alerts disconnected from work. +- **Proposed direction**: Route only alerts with explicit ownership and one clear next action into `My Work`; keep generic notifications, telemetry, and passive monitoring signals outside the group. +- **Explicit non-goals**: General notification center, chat/activity feed, or bulk alert triage system. +- **Dependencies**: Alert infrastructure, ownership semantics, escalation rules, personal count semantics +- **Priority**: medium + +### My Work — Approval & Escalation Routing +- **Type**: foundation / routing semantics +- **Source**: personal work architecture candidate pack 2026-04-21; ownership and fallback analysis +- **Problem**: Personal queues become inconsistent when owner, assignee, approver, escalation target, and fallback role mean different things in each domain. +- **Why it matters**: `My Work` cannot be trustworthy without a shared answer to "why did this item land on me?" and "who gets it if no person is assigned?". +- **Proposed direction**: Define shared routing semantics for assignee versus owner versus approver, fallback-to-role behavior, no-assignee escalation, and future delegation boundaries; keep this as a governance contract, not a UI-only heuristic. +- **Explicit non-goals**: Full org-chart modeling, absence management, or automatic load balancing. +- **Dependencies**: Ownership semantics (Spec 219), findings workflow, approval-producing domains, RBAC/capability model, alerting +- **Strategic sequencing**: Foundational before `My Work` expands beyond findings into approvals, reviews, or renewals. +- **Priority**: high + +### My Work — Personal Counts & Priority Semantics +- **Type**: foundation / queue semantics +- **Source**: personal work architecture candidate pack 2026-04-21; count-trust and priority-shaping analysis +- **Problem**: Once more than one personal queue exists, badges and ordering can drift, double-count, or leak hidden scope unless the inclusion and weighting rules are explicit. +- **Why it matters**: Personal counts are operator trust surfaces. If badges are noisy, inconsistent, or scope-leaky, the IA layer becomes less usable than the domain pages it was meant to simplify. +- **Proposed direction**: Define group-badge inclusion, visible-scope count rules, urgency weighting for overdue versus pending approval versus reopened work, and the relationship between workspace-wide truth and active-tenant context. +- **Explicit non-goals**: Complex cross-domain scoring engine, productivity gamification, or predictive prioritization. +- **Dependencies**: `My Work` IA, routing semantics, alerting/approval/review producers, RBAC scope enforcement +- **Strategic sequencing**: Must exist before a multi-surface `My Work` badge ships. +- **Priority**: high + +### My Work — Dashboard Signals & Personal Entry Points +- **Type**: IA / entry-point semantics +- **Source**: personal work architecture candidate pack 2026-04-21; dashboard-versus-nav continuity analysis +- **Problem**: Dashboard summary cards, CTA strips, and future personal queues can easily duplicate or contradict each other unless their roles are defined together. +- **Why it matters**: The workspace dashboard should signal personal work, not become a second queue. Operators need consistent drill-in and return behavior between the dashboard and `My Work`. +- **Proposed direction**: Define which personal signals belong on `/admin`, when a CTA is enough versus when a nav point is required, and how context/filter carry-over works between dashboard signals and personal queues. +- **Explicit non-goals**: Full dashboard redesign or a second summary layer that mirrors every `My Work` list. +- **Dependencies**: Spec 221 workspace signal, `My Work` IA, dashboard surface conventions, personal count semantics +- **Priority**: medium + ### MSP Multi-Tenant Portfolio Dashboard & SLA Reporting - **Type**: feature - **Source**: roadmap-to-spec coverage audit 2026-03-18, 0800-future-features brainstorming (pillar #1 — MSP Portfolio & Operations), product positioning for MSP portfolio owners @@ -1432,6 +1397,37 @@ ### Run Log Inspect Affordance Alignment - **Why this boundary is right**: One resource, one anti-pattern, one fix. Expanding scope to "all run-log surfaces" or "all operation views" would turn a quick correction into a rollout spec and delay the most visible improvement. - **Priority**: medium +### Selected-Record Monitoring Host Alignment +- **Type**: workflow compression +- **Source**: enterprise UX review 2026-04-19 — Finding Exceptions Queue and Audit Log selected-record monitoring surfaces +- **Problem**: Specs 193 and 198 correctly established the semantics for `queue_workbench` and `selected_record_monitoring`, but they intentionally stopped at action hierarchy and page-state transport. The remaining gap is the active review host shape. `FindingExceptionsQueue` and `AuditLog` both preserve selection via query parameter and `inspect_action`, yet the current host experience still sits awkwardly between a list page, an inline expanded detail block, and a modal-style inspect affordance. That is technically valid, but it does not read as an enterprise-grade workbench. Operators get shareable URLs and refresh-safe state, but not a clearly expressed review mode with one deliberate place for context, next step, and close/return behavior. +- **Why it matters**: Enterprise operators working through queues or history need one of two unmistakable behaviors: either remain in a stable workbench where list context and active record review coexist intentionally, or leave the list for a canonical detail route with explicit return continuity. The current halfway pattern preserves state better than a slide-over, but it still weakens scanability, makes the active review lane feel bolted on, and leaves too much room for future local variations across monitoring surfaces. +- **Proposed direction**: + - Define two allowed enterprise host models for `selected_record_monitoring` surfaces: + - **Split-pane workbench**: the list, filters, and queue context remain continuously visible while the selected record occupies a dedicated persistent review pane + - **Canonical detail route**: the list remains list-first, and inspect opens a standalone detail page with explicit back/return continuity and optional preserved filter state + - Allow **quick-peek overlays** only as optional preview affordances, never as the sole canonical inspect or deep-link contract + - Add host-selection criteria so surfaces choose deliberately between split-pane and canonical detail route instead of drifting into full-page inline "focused lane above the table" patterns + - Pilot the rule on `FindingExceptionsQueue` and `AuditLog`, keeping current query-param addressability while upgrading the actual review host ergonomics + - Codify close/back/new-tab/reload semantics and invalid-selection fallback per host model so URL durability and review ergonomics are aligned rather than accidental +- **Smallest enterprise-capable version**: Limit the first slice to the two already-real `selected_record_monitoring` surfaces in Monitoring: `FindingExceptionsQueue` and `AuditLog`. The spec should choose and implement one clear host model per surface, document the decision rule, and stop there. No generic pane framework, no broad monitoring IA rewrite, and no rollout to unrelated list/detail pages. +- **Explicit non-goals**: Not a full Monitoring redesign, not a new modal framework, not a replacement for Spec 198 page-state semantics, not a generic shared-detail engine, not a broad action-surface retrofit outside `selected_record_monitoring`, and not a rewrite of finding or audit domain truth. +- **Permanent complexity imported**: One small host-pattern contract for `selected_record_monitoring`, explicit decision criteria for split-pane vs canonical detail route, focused regression coverage for two surfaces, and a small amount of new vocabulary around host model choice. No new persisted truth, no new provider/runtime architecture, and no new generalized UI platform are justified. +- **Why now**: The product already has at least two real consumers of the same selected-record monitoring pattern, and one of them is visible enough that the UX gap is now obvious. Leaving the gap open means future monitoring surfaces will keep re-solving the same question locally, and the currently correct page-state work will continue to feel less enterprise than it should. +- **Why not local**: A one-off polish pass on `FindingExceptionsQueue` would not answer what `AuditLog` should do, nor would it define when a selected-record monitoring surface should stay list-first versus move to canonical detail. The missing artifact is not just layout polish; it is the host decision rule for a small but real surface family. +- **Approval class**: Workflow Compression +- **Red flags triggered**: One red flag: this introduces a cross-surface host-model rule. The scope must stay bounded to the already-real `selected_record_monitoring` family and must not grow into a general monitoring-shell framework. +- **Score**: Nutzen: 2 | Dringlichkeit: 1 | Scope: 2 | Komplexität: 1 | Produktnähe: 2 | Wiederverwendung: 2 | **Gesamt: 10/12** +- **Decision**: approve +- **Acceptance points**: + - Each `selected_record_monitoring` surface declares one deliberate host model instead of expressing active review as an ad hoc inline expansion + - Deep links, refresh, and invalid-selection fallback remain stable after the host upgrade + - Operators can keep queue/history context while reviewing a record, or return to it predictably when the chosen host model uses a dedicated detail route + - Close, back, related drilldowns, and "open in full detail" semantics become consistent enough that selected-record monitoring feels like a product pattern instead of a local layout choice +- **Dependencies**: Spec 193 (`monitoring-action-hierarchy`), Spec 198 (`monitoring-page-state`), and the existing Monitoring page-state guards already in the repo +- **Related specs / candidates**: Spec 197 (`shared-detail-contract`), Action Surface Contract v1.1, Admin Visual Language Canon, Record Page Header Discipline & Contextual Navigation (for return semantics only; not a direct dependency) +- **Priority**: medium + ### Admin Visual Language Canon — First-Party UI Convention Codification and Drift Prevention - **Type**: foundation - **Source**: admin UI consistency analysis 2026-03-17 diff --git a/package.json b/package.json index 3b5423d7..f655f4ae 100644 --- a/package.json +++ b/package.json @@ -6,10 +6,10 @@ "node": ">=20.0.0" }, "scripts": { - "dev": "bash ./scripts/dev-workspace", - "dev:platform": "bash ./scripts/dev-platform", + "dev": "corepack pnpm dev:platform && (corepack pnpm --filter @tenantatlas/platform dev &) && corepack pnpm dev:website", + "dev:platform": "./scripts/platform-sail up -d", "dev:website": "WEBSITE_PORT=${WEBSITE_PORT:-4321} corepack pnpm --filter @tenantatlas/website dev", - "build:platform": "./scripts/platform-sail pnpm build", + "build:platform": "corepack pnpm --filter @tenantatlas/platform build", "build:website": "corepack pnpm --filter @tenantatlas/website build" } } diff --git a/specs/001-finding-ownership-semantics/plan.md b/specs/001-finding-ownership-semantics/plan.md new file mode 100644 index 00000000..bc22c612 --- /dev/null +++ b/specs/001-finding-ownership-semantics/plan.md @@ -0,0 +1,199 @@ +# Implementation Plan: Finding Ownership Semantics Clarification + +**Branch**: `001-finding-ownership-semantics` | **Date**: 2026-04-20 | **Spec**: [spec.md](./spec.md) +**Input**: Feature specification from `/specs/001-finding-ownership-semantics/spec.md` + +**Note**: The setup script reported a numeric-prefix collision with `001-rbac-onboarding`, but it still resolved the active branch and plan path correctly to this feature directory. Planning continues against the current branch path. + +## Summary + +Clarify the meaning of finding owner versus finding assignee across the existing tenant findings list, detail surface, responsibility-update flows, and exception-request context without adding new persistence, capabilities, or workflow services. The implementation will reuse the existing `owner_user_id` and `assignee_user_id` fields, add a derived responsibility-state presentation layer on top of current data, tighten operator-facing copy and audit/feedback wording, preserve tenant-safe Filament behavior, and extend focused Pest + Livewire coverage for list/detail semantics, responsibility updates, and exception-owner boundary cases. + +## Technical Context + +**Language/Version**: PHP 8.4.15 / Laravel 12 +**Primary Dependencies**: Filament v5, Livewire v4.0+, Pest v4, Tailwind CSS v4 +**Storage**: PostgreSQL via Sail; existing `findings.owner_user_id`, `findings.assignee_user_id`, and `finding_exceptions.owner_user_id` fields; no schema changes planned +**Testing**: Pest v4 feature and Livewire component tests via `./vendor/bin/sail artisan test --compact` +**Validation Lanes**: fast-feedback, confidence +**Target Platform**: Laravel monolith in Sail/Docker locally; Dokploy-hosted Linux deployment for staging/production +**Project Type**: Laravel monolith / Filament admin application +**Performance Goals**: No new remote calls, no new queued work, no material list/detail query growth beyond current eager loading of owner, assignee, and exception-owner relations +**Constraints**: Keep responsibility state derived rather than persisted; preserve existing tenant membership validation; preserve deny-as-not-found tenant isolation; do not split capabilities or add new abstractions; keep destructive-action confirmations unchanged +**Scale/Scope**: 1 primary Filament resource, 1 model helper or equivalent derived-state mapping, 1 workflow/audit wording touchpoint, 1 exception-owner wording boundary, and 2 focused new/expanded feature test families + +## UI / Surface Guardrail Plan + +- **Guardrail scope**: changed surfaces +- **Native vs custom classification summary**: native +- **Shared-family relevance**: existing tenant findings resource and exception-context surfaces only +- **State layers in scope**: page, detail, URL-query +- **Handling modes by drift class or surface**: review-mandatory +- **Repository-signal treatment**: review-mandatory +- **Special surface test profiles**: standard-native-filament +- **Required tests or manual smoke**: functional-core, state-contract +- **Exception path and spread control**: none; the feature reuses existing resource/table/infolist/action primitives and keeps exception-owner semantics local to the finding context +- **Active feature PR close-out entry**: Guardrail + +## Constitution Check + +*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.* + +- Inventory-first: PASS. The feature only clarifies operator semantics on tenant-owned findings and exception artifacts; it does not change observed-state or snapshot truth. +- Read/write separation: PASS. Responsibility updates remain TenantPilot-only writes on existing finding records; no Microsoft tenant mutation is introduced. Existing destructive-like actions keep their current confirmation rules. +- Graph contract path: PASS / N/A. No Graph calls or contract-registry changes are involved. +- Deterministic capabilities: PASS. Existing canonical findings capabilities remain the source of truth; no new capability split is introduced. +- RBAC-UX: PASS. Tenant-context membership remains the isolation boundary. Non-members remain 404 and in-scope members missing `TENANT_FINDINGS_ASSIGN` remain 403 for responsibility mutations. +- Workspace isolation: PASS. The feature remains inside tenant-context findings flows and preserves existing workspace-context stabilization patterns. +- Global search: PASS. `FindingResource` already has a `view` page, so any existing global-search participation remains compliant. This feature does not add or remove global search. +- Tenant isolation: PASS. All reads and writes remain tenant-scoped and continue to restrict owner/assignee selections to current tenant members. +- Run observability / Ops-UX: PASS / N/A. No new long-running, queued, or remote work is introduced and no `OperationRun` behavior changes. +- Automation / data minimization: PASS / N/A. No new background automation or payload persistence is introduced. +- Test governance (TEST-GOV-001): PASS. The narrowest proving surface is feature-level Filament + workflow coverage with low fixture cost and no heavy-family expansion. +- Proportionality / no premature abstraction / persisted truth / behavioral state: PASS. Responsibility state remains derived from existing fields and does not create a persisted enum, new abstraction, or new table. +- UI semantics / few layers: PASS. The plan uses direct domain-to-UI mapping on `FindingResource` and an optional local helper on `Finding` rather than a new presenter or taxonomy layer. +- Badge semantics (BADGE-001): PASS with restraint. If a new responsibility badge or label is added, it stays local to findings semantics unless multiple consumers later prove centralization is necessary. +- Filament-native UI / action surface contract / UX-001: PASS. Existing native Filament tables, infolists, filters, selects, grouped actions, and modals remain the implementation path; the finding remains the sole primary inspect/open model and row click remains the canonical inspect affordance. + +**Post-Phase-1 re-check**: PASS. The design keeps responsibility semantics derived, tenant-safe, and local to the existing findings resource and tests. + +## Test Governance Check + +- **Test purpose / classification by changed surface**: Feature +- **Affected validation lanes**: fast-feedback, confidence +- **Why this lane mix is the narrowest sufficient proof**: The business truth is visible in Filament list/detail rendering, responsibility action behavior, and tenant-scoped audit feedback. Unit-only testing would miss the operator-facing semantics, while browser/heavy-governance coverage would add unnecessary cost. +- **Narrowest proving command(s)**: + - `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Filament/Resources/FindingResourceOwnershipSemanticsTest.php` + - `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Findings/FindingAssignmentAuditSemanticsTest.php` +- **Fixture / helper / factory / seed / context cost risks**: Low. Existing tenant/user/finding factories and tenant membership helpers are sufficient. The only explicit context risk is tenant-panel routing and admin canonical tenant state. +- **Expensive defaults or shared helper growth introduced?**: no; tests should keep tenant context explicit rather than broadening shared fixtures. +- **Heavy-family additions, promotions, or visibility changes**: none +- **Surface-class relief / special coverage rule**: standard-native relief; explicit tenant-panel routing is required for authorization assertions. +- **Closing validation and reviewer handoff**: Re-run the two focused test files plus formatter on dirty files. Reviewers should verify owner versus assignee wording on list/detail surfaces, exception-owner separation, and 404/403 semantics for out-of-scope versus in-scope unauthorized users. +- **Budget / baseline / trend follow-up**: none +- **Review-stop questions**: lane fit, hidden fixture cost, accidental presenter growth, tenant-context drift in tests +- **Escalation path**: none +- **Active feature PR close-out entry**: Guardrail +- **Why no dedicated follow-up spec is needed**: This work stays inside the existing findings responsibility contract. Only future queue/team-routing or capability-split work would justify a separate spec. + +## Project Structure + +### Documentation (this feature) + +```text +specs/001-finding-ownership-semantics/ +├── plan.md +├── spec.md +├── research.md +├── data-model.md +├── quickstart.md +├── contracts/ +│ └── finding-responsibility.openapi.yaml +├── checklists/ +│ └── requirements.md +└── tasks.md +``` + +### Source Code (repository root) + +```text +apps/platform/ +├── app/ +│ ├── Filament/ +│ │ └── Resources/ +│ │ └── FindingResource.php # MODIFY: list/detail labels, derived responsibility state, filters, action help text, exception-owner wording +│ ├── Models/ +│ │ └── Finding.php # MODIFY: local derived responsibility-state helper if needed +│ └── Services/ +│ └── Findings/ +│ ├── FindingWorkflowService.php # MODIFY: mutation feedback / audit wording for owner-only vs assignee-only changes +│ ├── FindingExceptionService.php # MODIFY: request-exception wording if the exception-owner boundary needs alignment +│ └── FindingRiskGovernanceResolver.php # MODIFY: next-action copy to reflect orphaned-accountability semantics +└── tests/ + └── Feature/ + ├── Filament/ + │ └── Resources/ + │ └── FindingResourceOwnershipSemanticsTest.php # NEW: list/detail rendering, filters, exception-owner distinction, tenant-safe semantics + └── Findings/ + ├── FindingAssignmentAuditSemanticsTest.php # NEW: owner-only, assignee-only, combined update feedback/audit semantics + ├── FindingWorkflowRowActionsTest.php # MODIFY: assignment form/help-text semantics and member validation coverage + └── FindingWorkflowServiceTest.php # MODIFY: audit metadata and responsibility-mutation expectations +``` + +**Structure Decision**: Keep all work inside the existing Laravel/Filament monolith. The implementation is a targeted semantics pass over the current findings resource and workflow tests; no new folders, packages, or service families are required. + +## Complexity Tracking + +| Violation | Why Needed | Simpler Alternative Rejected Because | +|-----------|------------|-------------------------------------| +| — | — | — | + +## Proportionality Review + +- **Current operator problem**: Operators cannot tell whether a finding change transferred accountability, active remediation work, or only exception ownership. +- **Existing structure is insufficient because**: The existing fields and actions exist, but the current UI copy and derived next-step language do not establish a stable contract across list, detail, and exception flows. +- **Narrowest correct implementation**: Reuse the existing `owner_user_id` and `assignee_user_id` fields, derive responsibility state from them, and tighten wording on existing Filament surfaces and audit feedback. +- **Ownership cost created**: Low ongoing UI/test maintenance to keep future findings work aligned with the clarified contract. +- **Alternative intentionally rejected**: A new ownership framework, queue model, or capability split was rejected because the current product has not yet exhausted the simpler owner-versus-assignee model. +- **Release truth**: Current-release truth + +## Phase 0 — Research (output: `research.md`) + +See: [research.md](./research.md) + +Research goals: +- Confirm the existing source of truth for owner, assignee, and exception owner. +- Confirm the smallest derived responsibility-state model that fits the current schema. +- Confirm the existing findings tests and Filament routing pitfalls to avoid false negatives. +- Confirm which operator-facing wording changes belong in resource copy versus workflow service feedback. + +## Phase 1 — Design & Contracts (outputs: `data-model.md`, `contracts/`, `quickstart.md`) + +See: +- [data-model.md](./data-model.md) +- [contracts/finding-responsibility.openapi.yaml](./contracts/finding-responsibility.openapi.yaml) +- [quickstart.md](./quickstart.md) + +Design focus: +- Keep responsibility truth on existing finding and finding-exception records. +- Model responsibility state as a derived projection over owner and assignee presence rather than a persisted enum. +- Preserve exception owner as a separate governance concept when shown from a finding context. +- Keep tenant membership validation and existing `FindingWorkflowService::assign()` semantics as the mutation boundary. + +## Phase 2 — Implementation Outline (tasks created in `/speckit.tasks`) + +### Surface semantics pass +- Update the findings list column hierarchy so owner and assignee meaning is explicit at first scan. +- Add a derived responsibility-state label or equivalent summary on list/detail surfaces. +- Keep exception owner visibly separate from finding owner wherever both appear. + +### Responsibility mutation clarity +- Add owner/assignee help text to assignment flows. +- Differentiate owner-only, assignee-only, and combined responsibility changes in operator feedback and audit-facing wording. +- Keep current tenant-member validation and open-finding restrictions unchanged. + +### Personal-work and next-action alignment +- Add or refine personal-work filters so assignee-based work and owner-based accountability are explicitly separate. +- Update next-action copy for owner-missing states so assignee-only findings are treated as accountability gaps. + +### Regression protection +- Add focused list/detail rendering tests for owner-only, assignee-only, both-set, same-user, and both-null states. +- Add focused responsibility-update tests for owner-only, assignee-only, and combined changes. +- Preserve tenant-context and authorization regression coverage using explicit Filament panel routing where needed. + +### Verification +- Run the two focused Pest files and any directly modified sibling findings tests. +- Run Pint on dirty files through Sail. + +## Constitution Check (Post-Design) + +Re-check result: PASS. The design stays inside the existing findings domain, preserves tenant isolation and capability enforcement, avoids new persisted truth or semantic framework growth, and keeps responsibility state derived from current fields. + +## Filament v5 Agent Output Contract + +1. **Livewire v4.0+ compliance**: Yes. The feature only adjusts existing Filament v5 resources/pages/actions that already run on Livewire v4.0+. +2. **Provider registration location**: No new panel or service providers are needed. Existing Filament providers remain registered in `apps/platform/bootstrap/providers.php`. +3. **Global search**: `FindingResource` already has a `view` page via `getPages()`, so any existing global-search participation remains compliant. This feature does not enable new global search. +4. **Destructive actions and authorization**: No new destructive actions are introduced. Existing destructive-like findings actions remain server-authorized and keep `->requiresConfirmation()` where already required. Responsibility updates continue to enforce tenant membership and the canonical findings capability registry. +5. **Asset strategy**: No new frontend assets or published views. The feature uses existing Filament tables, infolists, filters, and action modals, so deployment asset handling stays unchanged and no new `filament:assets` step is added. +6. **Testing plan**: Cover the change with focused Pest feature tests for findings resource responsibility semantics, assignment/audit wording, and existing workflow regression surfaces. No browser or heavy-governance expansion is planned. diff --git a/specs/001-finding-ownership-semantics/spec.md b/specs/001-finding-ownership-semantics/spec.md new file mode 100644 index 00000000..93cf2b2e --- /dev/null +++ b/specs/001-finding-ownership-semantics/spec.md @@ -0,0 +1,204 @@ +# Feature Specification: Finding Ownership Semantics Clarification + +**Feature Branch**: `001-finding-ownership-semantics` +**Created**: 2026-04-20 +**Status**: Draft +**Input**: User description: "Finding Ownership Semantics Clarification" + +## Spec Candidate Check *(mandatory — SPEC-GATE-001)* + +- **Problem**: Open findings already store both an owner and an assignee, but the product does not yet state clearly which field represents accountability and which field represents active execution. +- **Today's failure**: Operators can see or change responsibility on a finding without being sure whether they transferred accountability, execution work, or only exception ownership, which makes triage slower and audit language less trustworthy. +- **User-visible improvement**: Findings surfaces make accountable owner, active assignee, and exception owner visibly distinct, so operators can route work faster and read ownership changes honestly. +- **Smallest enterprise-capable version**: Clarify the existing responsibility contract on current findings list/detail/action surfaces, add explicit derived responsibility states, and align audit and filter language without creating new roles, queues, or persistence. +- **Explicit non-goals**: No team queues, no escalation engine, no automatic reassignment hygiene, no new capability split, no new ownership framework, and no mandatory backfill before rollout. +- **Permanent complexity imported**: One explicit product contract for finding owner versus assignee, one derived responsibility-state vocabulary, and focused acceptance tests for mixed ownership contexts. +- **Why now**: The next findings execution slices depend on honest ownership semantics, and the ambiguity already affects triage, reassignment, and exception routing in today’s tenant workflow. +- **Why not local**: A one-surface copy fix would leave contradictory meaning across the findings list, detail view, assignment flows, personal work cues, and exception-request language. +- **Approval class**: Core Enterprise +- **Red flags triggered**: None after scope cut. This spec clarifies existing fields instead of introducing a new semantics axis. +- **Score**: Nutzen: 2 | Dringlichkeit: 2 | Scope: 2 | Komplexität: 2 | Produktnähe: 2 | Wiederverwendung: 1 | **Gesamt: 11/12** +- **Decision**: approve + +## Spec Scope Fields *(mandatory)* + +- **Scope**: tenant +- **Primary Routes**: `/admin/t/{tenant}/findings`, `/admin/t/{tenant}/findings/{finding}` +- **Data Ownership**: Tenant-owned findings remain the source of truth; this spec also covers exception ownership only when it is shown or edited from a finding-context surface. +- **RBAC**: Tenant membership is required for visibility. Tenant findings view permission gates read access. Tenant findings assign permission gates owner and assignee changes. Non-members or cross-tenant requests remain deny-as-not-found. Members without mutation permission receive an explicit authorization failure. + +## UI / Surface Guardrail Impact *(mandatory when operator-facing surfaces are changed; otherwise write `N/A`)* + +| Surface / Change | Operator-facing surface change? | Native vs Custom | Shared-Family Relevance | State Layers Touched | Exception Needed? | Low-Impact / `N/A` Note | +|---|---|---|---|---|---|---| +| Tenant findings list and grouped bulk actions | yes | Native Filament + existing UI enforcement helpers | Same tenant work-queue family as other tenant resources | table, bulk-action modal, notification copy | no | Clarifies an existing resource instead of adding a new page | +| Finding detail and single-record action modals | yes | Native Filament infolist + action modals | Same finding resource family | detail, action modal, helper copy | no | Keeps one decision flow inside the current resource | + +## Decision-First Surface Role *(mandatory when operator-facing surfaces are changed)* + +| Surface | Decision Role | Human-in-the-loop Moment | Immediately Visible for First Decision | On-Demand Detail / Evidence | Why This Is Primary or Why Not | Workflow Alignment | Attention-load Reduction | +|---|---|---|---|---|---|---|---| +| Tenant findings list and grouped bulk actions | Primary Decision Surface | A tenant operator reviews the open backlog and decides who owns the outcome and who should act next | Severity, lifecycle status, due state, owner, assignee, and derived responsibility state | Full evidence, audit trail, run metadata, and exception details | Primary because it is the queue where responsibility is routed at scale | Follows the operator’s “what needs action now?” workflow instead of raw data lineage | Removes the need to open each record just to tell who is accountable versus merely assigned | +| Finding detail and single-record action modals | Secondary Context Surface | A tenant operator verifies one finding before reassigning work or requesting an exception | Finding summary, current owner, current assignee, exception owner when applicable, and next workflow action | Raw evidence, historical context, and additional governance details | Secondary because it resolves one case after the list has already identified the work item | Preserves the single-record decision loop without creating a separate ownership page | Avoids cross-page reconstruction when ownership and exception context must be compared together | + +## UI/UX Surface Classification *(mandatory when operator-facing surfaces are changed)* + +| Surface | Action Surface Class | Surface Type | Likely Next Operator Action | Primary Inspect/Open Model | Row Click | Secondary Actions Placement | Destructive Actions Placement | Canonical Collection Route | Canonical Detail Route | Scope Signals | Canonical Noun | Critical Truth Visible by Default | Exception Type / Justification | +|---|---|---|---|---|---|---|---|---|---|---|---|---|---| +| Tenant findings list and grouped bulk actions | List / Table / Bulk | Workflow queue / list-first resource | Open a finding or update responsibility | Finding | required | Structured `More` action group and grouped bulk actions | Existing dangerous actions remain inside grouped actions with confirmation where already required | /admin/t/{tenant}/findings | /admin/t/{tenant}/findings/{finding} | Tenant context, filters, severity, due state, workflow state | Findings / Finding | Accountable owner, active assignee, and whether the finding is assigned, owned-but-unassigned, or orphaned | none | +| Finding detail and single-record action modals | Record / Detail / Actions | View-first operational detail | Confirm or update responsibility for one finding | Finding | N/A - detail surface | Structured header actions on the existing record page | Existing dangerous actions remain grouped and confirmed | /admin/t/{tenant}/findings | /admin/t/{tenant}/findings/{finding} | Tenant breadcrumb, lifecycle state, due state, governance context | Findings / Finding | Finding owner, finding assignee, and exception owner stay visibly distinct in the same context | none | + +## Operator Surface Contract *(mandatory when operator-facing surfaces are changed)* + +| Surface | Primary Persona | Decision / Operator Action Supported | Surface Type | Primary Operator Question | Default-visible Information | Diagnostics-only Information | Status Dimensions Used | Mutation Scope | Primary Actions | Dangerous Actions | +|---|---|---|---|---|---|---|---|---|---|---| +| Tenant findings list and grouped bulk actions | Tenant operator or tenant manager | Route responsibility on open findings and separate accountable ownership from active work | Workflow queue | Who owns this outcome, who is doing the work, and what needs reassignment now? | Severity, lifecycle state, due state, owner, assignee, derived responsibility state, and personal-work cues | Raw evidence, run identifiers, historical audit details | lifecycle, severity, governance validity, responsibility state | TenantPilot only | Open finding, Assign, Triage, Start progress | Resolve, Close, Request exception | +| Finding detail and single-record action modals | Tenant operator or tenant manager | Verify and change responsibility for one finding without losing exception context | Detail/action surface | Is this finding owned by the right person, assigned to the right executor, and clearly separate from any exception owner? | Finding summary, owner, assignee, exception owner when present, due state, lifecycle state | Raw evidence payloads, extended audit history, related run metadata | lifecycle, governance validity, responsibility state | TenantPilot only | Assign, Start progress, Resolve | Close, Request exception | + +## Proportionality Review *(mandatory when structural complexity is introduced)* + +- **New source of truth?**: no +- **New persisted entity/table/artifact?**: no +- **New abstraction?**: no +- **New enum/state/reason family?**: no persisted family; only derived responsibility-state rules on existing fields +- **New cross-domain UI framework/taxonomy?**: no +- **Current operator problem**: Operators cannot reliably distinguish accountability from active execution, which creates avoidable misrouting and weak audit interpretation. +- **Existing structure is insufficient because**: Two existing user fields and an exception-owner concept can already appear in the same workflow, but current copy does not establish a stable product contract across list, detail, and action surfaces. +- **Narrowest correct implementation**: Reuse the existing finding owner and assignee fields, define their meaning, and apply that meaning consistently to labels, filters, derived states, and audit copy. +- **Ownership cost**: Focused UI copy review, acceptance tests for role combinations, and ongoing discipline to keep future findings work aligned with the same contract. +- **Alternative intentionally rejected**: A broader ownership framework with separate role types, queues, or team routing was rejected because it adds durable complexity before the product has proven the simpler owner-versus-assignee contract. +- **Release truth**: Current-release truth. This spec clarifies the meaning of responsibility on live findings now and becomes the contract for later execution surfaces. + +## Testing / Lane / Runtime Impact *(mandatory for runtime behavior changes)* + +- **Test purpose / classification**: Feature +- **Validation lane(s)**: fast-feedback, confidence +- **Why this classification and these lanes are sufficient**: The change is visible through tenant findings UI behavior, responsibility-state rendering, and audit-facing wording. Focused feature coverage proves the contract without introducing browser or heavy-governance breadth. +- **New or expanded test families**: Expand tenant findings resource coverage to include responsibility rendering, personal-work cues, and mixed-context exception-owner wording. Add focused assignment-audit coverage for owner-only, assignee-only, and combined changes. +- **Fixture / helper cost impact**: Low. Existing tenant, membership, user, and finding factories are sufficient; tests only need explicit responsibility combinations and tenant-scoped memberships. +- **Heavy-family visibility / justification**: none +- **Special surface test profile**: standard-native-filament +- **Standard-native relief or required special coverage**: Ordinary feature coverage is sufficient, with explicit assertions for owner versus assignee visibility and authorization behavior. +- **Reviewer handoff**: Reviewers should confirm that one positive and one negative authorization case exist, that list and detail surfaces use the same vocabulary, and that audit-facing feedback distinguishes owner changes from assignee changes. +- **Budget / baseline / trend impact**: none +- **Escalation needed**: none +- **Active feature PR close-out entry**: Guardrail +- **Planned validation commands**: + - `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Filament/Resources/FindingResourceOwnershipSemanticsTest.php` + - `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Findings/FindingAssignmentAuditSemanticsTest.php` + +## User Scenarios & Testing *(mandatory)* + +### User Story 1 - Route accountable ownership clearly (Priority: P1) + +As a tenant operator, I want to tell immediately who owns a finding and who is actively assigned to it, so I can route remediation work without guessing which responsibility changed. + +**Why this priority**: This is the smallest slice that fixes the core trust gap. Without it, every later execution surface inherits ambiguous meaning. + +**Independent Test**: Can be tested by loading the tenant findings list and detail pages with findings in owner-only, owner-plus-assignee, and no-owner states, and verifying that the first decision is possible without opening unrelated screens. + +**Acceptance Scenarios**: + +1. **Given** an open finding with an owner and no assignee, **When** an operator views the findings list, **Then** the record is shown as owned but unassigned rather than fully assigned. +2. **Given** an open finding with both an owner and an assignee, **When** an operator opens the finding detail, **Then** the accountable owner and active assignee are shown as separate roles. +3. **Given** an open finding with no owner, **When** an operator views the finding from the list or detail page, **Then** the accountability gap is surfaced as orphaned work. + +--- + +### User Story 2 - Reassign work without losing accountability (Priority: P2) + +As a tenant manager, I want to change the assignee, the owner, or both in one responsibility update, so I can transfer execution work without silently transferring accountability. + +**Why this priority**: Reassignment is the first mutation where the ambiguity causes operational mistakes and misleading audit history. + +**Independent Test**: Can be tested by performing responsibility updates on open findings and asserting separate outcomes for owner-only, assignee-only, and combined changes. + +**Acceptance Scenarios**: + +1. **Given** an open finding with an existing owner and assignee, **When** an authorized operator changes only the assignee, **Then** the owner remains unchanged and the resulting feedback states that only the assignee changed. +2. **Given** an open finding with an existing owner and assignee, **When** an authorized operator changes only the owner, **Then** the assignee remains unchanged and the resulting feedback states that only the owner changed. + +--- + +### User Story 3 - Keep exception ownership separate (Priority: P3) + +As a governance reviewer, I want exception ownership to remain visibly separate from finding ownership when both appear in one flow, so risk-acceptance work does not overwrite the meaning of the finding owner. + +**Why this priority**: This is a narrower but important boundary that prevents the accepted-risk workflow from reintroducing the same ambiguity under a second owner label. + +**Independent Test**: Can be tested by opening a finding that has, or is about to create, an exception record and verifying that finding owner and exception owner remain distinct in the same operator context. + +**Acceptance Scenarios**: + +1. **Given** a finding detail view that shows both finding responsibility and exception context, **When** the operator reviews ownership information, **Then** exception ownership is labeled separately from finding owner. +2. **Given** an operator starts an exception request from a finding, **When** the request asks for exception ownership, **Then** the form language makes clear that the selected person owns the exception artifact, not the finding itself. + +### Edge Cases + +- A finding may have the same user as both owner and assignee; the UI must show both roles as satisfied without implying duplication or error. +- A finding with an assignee but no owner is still an orphaned accountability case, not a healthy assigned state. +- Historical or imported findings may have both responsibility fields empty; they must render as orphaned without blocking rollout on a data backfill. +- When personal-work cues are shown, assignee-based work and owner-based accountability must not collapse into one ambiguous shortcut. + +## Requirements *(mandatory)* + +**Constitution alignment (required):** This feature changes tenant-scoped finding workflow semantics only. It introduces no Microsoft Graph calls, no scheduled or long-running work, and no new `OperationRun`. Responsibility mutations remain audited tenant-local writes. + +**Constitution alignment (RBAC-UX):** The affected authorization plane is tenant-context only. Cross-tenant and non-member access remain deny-as-not-found. Members without the findings assign permission continue to be blocked from responsibility mutations. The feature reuses the canonical capability registry and does not introduce raw role checks or a new capability split. + +**Constitution alignment (UI-FIL-001 / Filament Action Surfaces / UX-001):** The feature reuses existing native Filament tables, infolist entries, filters, selects, modal actions, grouped actions, and notifications. No local badge taxonomy or custom action chrome is added. The action-surface contract remains satisfied: the finding is still the one and only primary inspect/open model, row click remains the canonical inspect affordance on the list, no redundant view action is introduced, and existing dangerous actions remain grouped and confirmed where already required. + +**Constitution alignment (UI-NAMING-001 / UI-SEM-001 / TEST-TRUTH-001):** Direct field labels alone are insufficient because finding owner, finding assignee, and exception owner can appear in the same operator flow. This feature resolves that ambiguity by tightening the product vocabulary on the existing domain truth instead of adding a new semantic layer or presenter family. Tests prove visible business consequences rather than thin indirection. + +### Functional Requirements + +- **FR-001**: The system MUST treat finding owner as the accountable user responsible for ensuring that a finding reaches a governed terminal outcome. +- **FR-002**: The system MUST treat finding assignee as the user currently expected to perform or coordinate active remediation work, and any operator-facing use of `assigned` language MUST refer to this role only. +- **FR-003**: Authorized users MUST be able to set, change, or clear finding owner and finding assignee independently on open findings while continuing to restrict selectable people to members of the active tenant. +- **FR-004**: The system MUST derive responsibility state from the existing owner and assignee fields without adding a persisted lifecycle field. At minimum it MUST distinguish `owned but unassigned`, `assigned`, and `orphaned accountability`. +- **FR-005**: Findings list, finding detail, and responsibility-update flows MUST label owner and assignee distinctly, and any mixed context that also references exception ownership MUST label that role as exception owner. +- **FR-006**: When personal-work shortcuts or filters are shown on the tenant findings list, the system MUST expose separate, explicitly named cues for assignee-based work and owner-based accountability. +- **FR-007**: Responsibility-update feedback and audit-facing wording MUST state whether a mutation changed the owner, the assignee, or both. +- **FR-008**: Historical or imported findings with null or partial responsibility data MUST render using the derived responsibility contract without requiring a blocking data migration before rollout. +- **FR-009**: Exception ownership MUST remain a separate governance concept. Creating, viewing, or editing an exception from a finding context MUST NOT silently replace the meaning of the finding owner. + +## 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 | +|---|---|---|---|---|---|---|---|---|---|---| +| Tenant findings list | `/admin/t/{tenant}/findings` | None added by this feature | Full-row open into finding detail | Primary open affordance plus `More` action group with `Triage`, `Start progress`, `Assign`, `Resolve`, `Close`, and `Request exception` | Grouped actions including `Triage selected`, `Assign selected`, `Resolve selected`, and existing close/exception-safe bulk actions if already present | Existing findings empty state remains; no new CTA introduced | N/A | N/A | Yes for responsibility mutations and existing workflow mutations | Action Surface Contract satisfied. No empty groups or redundant view action introduced. | +| Finding detail | `/admin/t/{tenant}/findings/{finding}` | Existing record-level workflow actions only | Entered from the list row click; no separate inspect action added | Structured record actions retain `Assign`, `Start progress`, `Resolve`, `Close`, and `Request exception` | N/A | N/A | Existing view header actions only | N/A | Yes for responsibility mutations and existing workflow mutations | UI-FIL-001 satisfied through existing infolist and action modal primitives. No exemption needed. | + +### Key Entities *(include if feature involves data)* + +- **Finding responsibility**: The visible responsibility contract attached to an open finding, consisting of accountable ownership, active execution assignment, and a derived responsibility state. +- **Finding owner**: The accountable person who owns the outcome of the finding and is expected to ensure it reaches a governed end state. +- **Finding assignee**: The person currently expected to perform or coordinate the remediation work on the finding. +- **Exception owner**: The accountable person for an exception artifact created from a finding; separate from finding owner whenever both appear in one operator context. + +## Success Criteria *(mandatory)* + +### Measurable Outcomes + +- **SC-001**: In acceptance review, an authorized operator can identify owner, assignee, and responsibility state for any scripted open-finding example from the list or detail surface within 10 seconds. +- **SC-002**: 100% of covered responsibility combinations in automated acceptance tests, including same-person, owner-only, owner-plus-assignee, assignee-only, and both-empty cases, render the correct visible labels and derived state. +- **SC-003**: 100% of covered responsibility-update tests distinguish owner-only, assignee-only, and combined changes in operator feedback and audit-facing wording. +- **SC-004**: When personal-work shortcuts are present, an operator can isolate assignee-based work and owner-based accountability from the findings list in one interaction each. + +## Assumptions + +- Existing finding owner and assignee fields remain the single source of truth for responsibility in this slice. +- Open findings may legitimately begin without an assignee while still needing an accountable owner. +- Membership hygiene for users who later lose tenant access is a separate follow-up concern and not solved by this clarification slice. + +## Non-Goals + +- Introduce team, queue, or workgroup ownership. +- Add automatic escalation, reassignment, or inactivity timers. +- Split authorization into separate owner-edit and assignee-edit capabilities. +- Require a mandatory historical backfill before the clarified semantics can ship. + +## Dependencies + +- Spec 111, Findings Workflow + SLA, remains the lifecycle and assignment baseline that this spec clarifies. +- Spec 154, Finding Risk Acceptance, remains the exception governance baseline whose owner semantics must stay separate from finding owner. diff --git a/specs/216-provider-dispatch-gate/checklists/requirements.md b/specs/216-provider-dispatch-gate/checklists/requirements.md new file mode 100644 index 00000000..2e950b28 --- /dev/null +++ b/specs/216-provider-dispatch-gate/checklists/requirements.md @@ -0,0 +1,36 @@ +# Specification Quality Checklist: Provider-Backed Action Preflight and Dispatch Gate Unification + +**Purpose**: Validate specification completeness and quality before proceeding to planning +**Created**: 2026-04-19 +**Feature**: [spec.md](../spec.md) + +## Content Quality + +- [x] No implementation details (languages, frameworks, APIs) +- [x] Focused on user value and business needs +- [x] Written for non-technical stakeholders +- [x] All mandatory sections completed + +## Requirement Completeness + +- [x] No [NEEDS CLARIFICATION] markers remain +- [x] Requirements are testable and unambiguous +- [x] Success criteria are measurable +- [x] Success criteria are technology-agnostic (no implementation details) +- [x] All acceptance scenarios are defined +- [x] Edge cases are identified +- [x] Scope is clearly bounded +- [x] Dependencies and assumptions identified + +## Feature Readiness + +- [x] All functional requirements have clear acceptance criteria +- [x] User scenarios cover primary flows +- [x] Feature meets measurable outcomes defined in Success Criteria +- [x] No implementation details leak into specification + +## Notes + +- Validation pass completed on 2026-04-19. +- The chosen spec is aligned with the active / near-term roadmap lane and the highest-priority unspecced qualified candidate in spec-candidates.md. +- No clarification questions were needed; the candidate already had sufficient scope, sequencing, and boundary detail for planning readiness. \ No newline at end of file diff --git a/specs/216-provider-dispatch-gate/contracts/provider-dispatch-gate.logical.openapi.yaml b/specs/216-provider-dispatch-gate/contracts/provider-dispatch-gate.logical.openapi.yaml new file mode 100644 index 00000000..0c501ed7 --- /dev/null +++ b/specs/216-provider-dispatch-gate/contracts/provider-dispatch-gate.logical.openapi.yaml @@ -0,0 +1,419 @@ +openapi: 3.1.0 +info: + title: Provider Dispatch Gate Start Contract + version: 1.0.0 + description: >- + Internal reference contract for the operator-triggered provider-backed start + surfaces covered by Spec 216. The real implementation remains Filament and + Livewire HTML actions. The vendor media types below document the structured + start-result and accepted-run payloads that must be derivable before + rendering. This is not a public API commitment. +paths: + /admin/t/{tenant}/provider-actions/{operation}/start: + post: + summary: Start a tenant-scoped provider-backed operation + parameters: + - name: tenant + in: path + required: true + schema: + type: string + - name: operation + in: path + required: true + schema: + type: string + requestBody: + required: false + content: + application/json: + schema: + $ref: '#/components/schemas/ProviderStartRequest' + responses: + '200': + description: Rendered Livewire action response for the start attempt + content: + text/html: + schema: + type: string + application/vnd.tenantpilot.provider-start-outcome+json: + schema: + $ref: '#/components/schemas/ProviderStartOutcome' + '403': + description: Tenant member lacks the required capability for the operation + '404': + description: Tenant is not visible because workspace or tenant entitlement is missing + /admin/provider-connections/{connection}/actions/{operation}/start: + post: + summary: Start a provider-connection-scoped operation + parameters: + - name: connection + in: path + required: true + schema: + type: integer + - name: operation + in: path + required: true + schema: + type: string + responses: + '200': + description: Rendered Livewire action response for the connection-scoped start attempt + content: + text/html: + schema: + type: string + application/vnd.tenantpilot.provider-start-outcome+json: + schema: + $ref: '#/components/schemas/ProviderStartOutcome' + '403': + description: Viewer is in scope but lacks the required capability for the action + '404': + description: Provider connection is not visible because entitlement is missing + /admin/t/{tenant}/restore-runs/{restoreRun}/execute: + post: + summary: Execute a restore through the canonical provider start gate + parameters: + - name: tenant + in: path + required: true + schema: + type: string + - name: restoreRun + in: path + required: true + schema: + type: integer + responses: + '200': + description: Rendered restore execute action response + content: + text/html: + schema: + type: string + application/vnd.tenantpilot.provider-start-outcome+json: + schema: + $ref: '#/components/schemas/ProviderStartOutcome' + '403': + description: Tenant member lacks restore execution capability after membership is established + '404': + description: Restore run is not visible because entitlement is missing + /admin/t/{tenant}/directory/groups/sync: + post: + summary: Start directory groups sync + parameters: + - name: tenant + in: path + required: true + schema: + type: string + requestBody: + required: false + content: + application/json: + schema: + $ref: '#/components/schemas/DirectoryGroupsSyncRequest' + responses: + '200': + description: Rendered directory groups sync action response + content: + text/html: + schema: + type: string + application/vnd.tenantpilot.provider-start-outcome+json: + schema: + $ref: '#/components/schemas/ProviderStartOutcome' + '403': + description: Tenant member lacks sync capability after membership is established + '404': + description: Tenant is not visible because entitlement is missing + /admin/t/{tenant}/directory/role-definitions/sync: + post: + summary: Start role definitions sync + parameters: + - name: tenant + in: path + required: true + schema: + type: string + responses: + '200': + description: Rendered role definitions sync action response + content: + text/html: + schema: + type: string + application/vnd.tenantpilot.provider-start-outcome+json: + schema: + $ref: '#/components/schemas/ProviderStartOutcome' + '403': + description: Tenant member lacks the required capability after membership is established + '404': + description: Tenant is not visible because entitlement is missing + /admin/onboarding/{session}/provider-actions/{operation}/start: + post: + summary: Start an onboarding provider verification action + parameters: + - name: session + in: path + required: true + schema: + type: integer + - name: operation + in: path + required: true + schema: + type: string + requestBody: + required: false + content: + application/json: + schema: + $ref: '#/components/schemas/OnboardingProviderStartRequest' + responses: + '200': + description: Rendered onboarding verification action response + content: + text/html: + schema: + type: string + application/vnd.tenantpilot.provider-start-outcome+json: + schema: + $ref: '#/components/schemas/ProviderStartOutcome' + '403': + description: Workspace member lacks the required capability after scope is established + '404': + description: Onboarding session is not visible because entitlement is missing + /admin/onboarding/{session}/provider-bootstrap/start: + post: + summary: Start onboarding bootstrap work under sequential protected-scope admission + parameters: + - name: session + in: path + required: true + schema: + type: integer + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/OnboardingBootstrapStartRequest' + responses: + '200': + description: Rendered onboarding bootstrap action response + content: + text/html: + schema: + type: string + application/vnd.tenantpilot.onboarding-bootstrap-start-outcome+json: + schema: + $ref: '#/components/schemas/OnboardingBootstrapStartOutcome' + '403': + description: Workspace member lacks the required capability after scope is established + '404': + description: Onboarding session is not visible because entitlement is missing + /admin/operations/{run}: + get: + summary: Canonical provider-backed operation run detail + parameters: + - name: run + in: path + required: true + schema: + type: integer + responses: + '200': + description: Rendered Monitoring → Operations run detail page + content: + text/html: + schema: + type: string + application/vnd.tenantpilot.provider-backed-run-detail+json: + schema: + $ref: '#/components/schemas/ProviderBackedRunDetail' + '403': + description: Viewer is in scope but lacks permission for related actions + '404': + description: Run is not visible because workspace or tenant entitlement is missing +components: + schemas: + ProviderStartRequest: + type: object + properties: + providerConnectionId: + type: integer + nullable: true + sourceSurface: + type: string + targetContext: + type: object + additionalProperties: true + DirectoryGroupsSyncRequest: + type: object + properties: + selectionKey: + type: string + default: all_groups_v1 + providerConnectionId: + type: integer + nullable: true + OnboardingProviderStartRequest: + type: object + properties: + providerConnectionId: + type: integer + nullable: true + step: + type: string + OnboardingBootstrapStartRequest: + type: object + required: + - providerConnectionId + - selectedOperations + properties: + providerConnectionId: + type: integer + selectedOperations: + type: array + minItems: 1 + items: + type: string + sourceSurface: + type: string + default: onboarding.bootstrap + ProviderStartOutcome: + type: object + description: >- + Canonical start-result shape derived before queue admission for every + route-bounded provider-backed start covered by Spec 216. + required: + - status + - operationType + - operatorMessage + properties: + status: + type: string + enum: + - accepted + - deduped + - scope_busy + - blocked + operationType: + type: string + operatorVerb: + type: string + operatorTarget: + type: string + operatorMessage: + type: string + shortReason: + type: string + nullable: true + providerConnection: + $ref: '#/components/schemas/ProviderConnectionContext' + run: + $ref: '#/components/schemas/RunReference' + nextSteps: + type: array + items: + $ref: '#/components/schemas/NextStep' + actions: + type: array + items: + $ref: '#/components/schemas/ActionLink' + OnboardingBootstrapStartOutcome: + allOf: + - $ref: '#/components/schemas/ProviderStartOutcome' + - type: object + properties: + acceptedOperation: + type: string + nullable: true + pendingOperations: + type: array + items: + type: string + ProviderConnectionContext: + type: object + properties: + id: + type: integer + provider: + type: string + label: + type: string + RunReference: + type: object + properties: + id: + type: integer + url: + type: string + status: + type: string + NextStep: + type: object + required: + - label + properties: + label: + type: string + description: + type: string + nullable: true + href: + type: string + nullable: true + actionType: + type: string + nullable: true + ActionLink: + type: object + required: + - label + - href + properties: + label: + type: string + href: + type: string + kind: + type: string + nullable: true + ProviderBackedRunDetail: + type: object + description: >- + Canonical Monitoring run detail contract for accepted provider-backed + work. The rendered page must reuse the same translated reason family + for operator-triggered and scheduled or system-initiated runs, while + terminal notifications remain initiator-only. + required: + - runId + - operationType + - executionStatus + properties: + runId: + type: integer + operationType: + type: string + executionStatus: + type: string + outcome: + type: string + nullable: true + providerConnection: + $ref: '#/components/schemas/ProviderConnectionContext' + protectedScope: + type: object + additionalProperties: true + shortReason: + type: string + nullable: true + nextSteps: + type: array + items: + $ref: '#/components/schemas/NextStep' + diagnosticsAvailable: + type: boolean \ No newline at end of file diff --git a/specs/216-provider-dispatch-gate/data-model.md b/specs/216-provider-dispatch-gate/data-model.md new file mode 100644 index 00000000..01ab5698 --- /dev/null +++ b/specs/216-provider-dispatch-gate/data-model.md @@ -0,0 +1,236 @@ +# Data Model: Provider-Backed Action Preflight and Dispatch Gate Unification + +## Overview + +This feature does not introduce new persisted entities or tables. It extends the existing provider-backed start contract around `ProviderConnection`, `OperationRun`, and onboarding draft state so every covered operator-triggered start follows the same queue-admission, conflict-protection, and operator-feedback rules. + +The key design constraint is that start truth remains service-owned and derived from existing runtime records: + +- provider readiness and connection identity from `ProviderConnection` plus `ProviderConnectionResolver` +- accepted or prevented work truth from `OperationRun` +- click-time queue-admission decisions from `ProviderOperationStartGate` +- onboarding bootstrap continuity from existing `TenantOnboardingSession.state` +- operator feedback from existing Ops UX helpers plus one thin shared provider-start presentation helper + +## Existing Persistent Inputs + +### 1. ProviderConnection + +- Purpose: Tenant-owned provider credential and readiness record that defines which delegated connection a provider-backed operation can use. +- Key persisted fields used by this feature: + - `id` + - `tenant_id` + - `provider` + - `entra_tenant_id` +- Existing runtime facts consumed through current services: + - default-vs-explicit selection + - consent readiness + - credential usability + - provider identity and scope targeting +- Relationships used by this feature: + - owning tenant + - related operation runs through `OperationRun.context.provider_connection_id` + +### 2. OperationRun + +- Purpose: Canonical operational truth for queued or executed provider-backed work and for blocked preflight attempts that must remain observable. +- Key persisted fields used by this feature: + - `id` + - `tenant_id` + - `type` + - `status` + - `outcome` + - `reason_code` + - `context` + - `summary_counts` + - `started_at` + - `completed_at` +- Existing relationships or references used by this feature: + - initiator user + - tenant scope + - canonical Monitoring → Operations detail route + +### 3. TenantOnboardingSession + +- Purpose: Workspace-owned onboarding workflow record that already stores onboarding progress and step state, including bootstrap operation selections and related run references. +- Key persisted fields used by this feature: + - `id` + - `workspace_id` + - `tenant_id` (nullable until attached) + - `current_step` + - `state` +- Existing state keys used by this feature: + - `bootstrap_operation_types` + - `bootstrap_operation_runs` +- Relationships used by this feature: + - workspace + - attached tenant when present + +### 4. RestoreRun + +- Purpose: Tenant-owned restore execution record whose execute action becomes part of the canonical provider-backed start contract. +- Key persisted fields used by this feature: + - `id` + - `tenant_id` + - restore configuration and preview state already captured before execution +- Relationships used by this feature: + - tenant + - backup source records and restore safety flow already owned by existing restore logic + +## Existing Service-Owned Inputs + +### A. ProviderOperationRegistry Entry + +This is an existing logical definition, not a new persisted entity. + +| Field | Meaning | +|---|---| +| `operationType` | The write-time operation type string admitted by the gate | +| `module` | Current operation family/module metadata used in run context | +| `dispatcher` | The queue-dispatch callback or equivalent start hook | +| `requiredCapability` | Capability gate required to start the operation | +| `providerScopeExpectation` | Whether the operation is connection-scoped and therefore protected by click-time conflict rules | + +Rules: + +- First-slice coverage adds entries for every covered action host instead of introducing a second registry. +- This feature does not normalize historical operation names; registry entries follow current write-time operation types. + +### B. ProviderConnectionResolution + +Logical output of `ProviderConnectionResolver` and `ProviderOperationStartGate`. + +| Field | Meaning | +|---|---| +| `providerConnectionId` | Explicit resolved connection identity when admission is possible | +| `provider` | Provider family used for copy and downstream dispatch | +| `targetScope` | Current tenant/provider scope metadata for run context | +| `reasonCode` | Stable blocked reason when admission is prevented | +| `reasonMeta` | Sanitized structured detail for translation and next steps | +| `nextSteps` | Resolution path metadata used by the shared presentation helper | + +Rules: + +- Every accepted covered start must resolve this state before queue admission. +- Blocked resolutions never admit a queued job. + +## Derived Coordination Entities + +### 1. ProtectedProviderScope + +This is a logical concurrency boundary, not a new table. + +| Field | Meaning | +|---|---| +| `tenantId` | Tenant boundary for the operation | +| `providerConnectionId` | Connection boundary for provider-backed conflict protection | +| `activeRunId` | Existing queued or running run occupying the scope, when present | +| `activeOperationType` | Operation currently using the protected scope | + +Rules: + +- At most one provider-backed operation may be accepted at a time for one protected scope. +- If the same operation type is already active on the scope, the result is `deduped`. +- If a different covered operation is already active on the same scope, the result is `scope_busy`. + +### 2. PreflightStartResult + +This is the shared logical result of a covered start attempt. + +| Field | Meaning | Source | +|---|---|---| +| `internalState` | Current service-owned state such as `started`, `deduped`, `scope_busy`, or `blocked` | `ProviderOperationStartResult` | +| `operatorState` | Operator-facing vocabulary: `accepted`, `deduped`, `scope_busy`, `blocked` | shared presenter | +| `operationType` | Covered operation being started | action host + registry | +| `runId` | Canonical run for accepted, deduped, or scope-busy results, and optionally for blocked truth where already created | `OperationRun` | +| `providerConnectionId` | Resolved connection identity when known | gate + resolver | +| `reasonCode` | Stable problem class for blocked or other directed outcomes | current reason system | +| `nextSteps` | Structured resolution or follow-up guidance | next-step registry / helper | + +Validation rules: + +- `blocked` must never dispatch a background job. +- `accepted`, `deduped`, and `scope_busy` must point to the canonical run the operator should inspect. +- `accepted` must carry the explicit `provider_connection_id` into accepted-work context. + +### 3. AcceptedProviderBackedRunContext + +Logical accepted-work context persisted inside `OperationRun.context`. + +| Field | Meaning | +|---|---| +| `provider_connection_id` | Explicit connection identity used by the accepted run | +| `provider` | Provider family label for display and diagnostics | +| `target_scope` | Sanitized tenant/provider scope metadata | +| `source_surface` | Action-host family such as tenant detail, provider connection, onboarding, restore, or directory sync | +| `initiator_user_id` | Starting actor | +| `operation_specific_context` | Existing per-operation context already needed by downstream jobs | + +Rules: + +- Jobs must receive the same `provider_connection_id` that was accepted at click time. +- Monitoring and notifications explain accepted work using this context rather than a runtime default connection lookup. + +### 4. ProviderStartPresentation + +Logical derived presentation output returned by the shared start-result helper. + +| Field | Meaning | +|---|---| +| `title` | Standardized accepted/deduped/scope-busy/blocked headline | +| `body` | Short reason or queue message aligned with the spec vocabulary | +| `statusStyle` | Existing toast/notification severity | +| `viewRunAction` | Canonical open-run action when a run exists | +| `nextStepActions[]` | Optional resolution actions or follow-up links | +| `domainVerb` | Local action verb preserved from the host surface | +| `domainTarget` | Local object noun preserved from the host surface | + +Rules: + +- Accepted and deduped outcomes continue to use the existing `OperationUxPresenter` toast style. +- Blocked and scope-busy outcomes must no longer rely on page-local copy branches. +- Domain verb and target remain local so the shared contract does not flatten the product vocabulary into generic verbs. + +### 5. OnboardingBootstrapAdmission + +Logical onboarding-only coordination model built from existing draft state. + +| Field | Meaning | +|---|---| +| `selectedOperationTypes[]` | Bootstrap operations the operator selected | +| `acceptedOperationType` | The one operation type admitted for the current submission | +| `pendingOperationTypes[]` | Remaining selected types still waiting to run | +| `runIdsByOperationType` | Existing and newly accepted run references persisted in draft state | + +Rules: + +- A bootstrap submission may accept at most one provider-backed operation for the protected scope. +- Remaining bootstrap selections stay in existing draft state rather than spawning a new orchestration entity. +- The onboarding step continues to be the only primary context; operators should not need a new workflow page to understand the pending follow-up. + +## State Transitions + +### Covered Start Attempt + +```text +requested + -> blocked (no queue admission, blocked truth retained where applicable) + -> deduped (existing same-operation active run reused) + -> scope_busy (existing different-operation active run reused) + -> accepted (run admitted, queued job dispatched with explicit provider connection) +``` + +### Onboarding Bootstrap Submission + +```text +selected operations + -> blocked / deduped / scope_busy (no new run admitted) + -> accepted operation + pending operations retained in draft state +``` + +## Non-Goals In The Data Model + +- No new provider-start table or umbrella batch entity +- No new persisted summary or presentation artifact +- No platform-wide operation-type rename or taxonomy rewrite +- No new status family beyond the shared start-result vocabulary already required by the spec \ No newline at end of file diff --git a/specs/216-provider-dispatch-gate/plan.md b/specs/216-provider-dispatch-gate/plan.md new file mode 100644 index 00000000..a77064b0 --- /dev/null +++ b/specs/216-provider-dispatch-gate/plan.md @@ -0,0 +1,228 @@ +# Implementation Plan: Provider-Backed Action Preflight and Dispatch Gate Unification + +**Branch**: `216-provider-dispatch-gate` | **Date**: 2026-04-19 | **Spec**: [spec.md](./spec.md) +**Input**: Feature specification from `/specs/216-provider-dispatch-gate/spec.md` + +## Summary + +Unify every covered operator-triggered provider-backed start behind the existing `ProviderOperationStartGate` so preventable provider blockers, same-operation dedupe, and cross-operation scope conflicts are resolved before queue admission instead of surfacing later as execution failures. The implementation extends the current gate and registry, adds one thin shared start-result presentation helper over the existing Ops UX stack, standardizes the public start wording as `queued`, `already running`, `scope busy`, and `blocked` over the canonical `accepted`, `deduped`, `scope busy`, and `blocked` result model, pins accepted work to an explicit `provider_connection_id`, keeps scheduled and system-run Monitoring language compatible without widening click-time UX scope, and migrates the first-slice tenant, provider-connection, restore, directory-sync, and onboarding start surfaces without adding new persistence or a second orchestration framework. + +## Technical Context + +**Language/Version**: PHP 8.4.15, Laravel 12, Filament v5, Livewire v4 +**Primary Dependencies**: Filament Resources/Pages/Actions, Livewire 4, Pest 4, `ProviderOperationStartGate`, `ProviderOperationRegistry`, `ProviderConnectionResolver`, `OperationRunService`, `ProviderNextStepsRegistry`, `ReasonPresenter`, `OperationUxPresenter`, `OperationRunLinks` +**Storage**: PostgreSQL via existing `operation_runs`, `provider_connections`, `managed_tenant_onboarding_sessions`, `restore_runs`, and tenant-owned runtime records; no new tables planned +**Testing**: Pest unit and focused feature tests for gate transitions, Filament action hosts, onboarding wizard flows, and Ops UX/run-detail alignment +**Validation Lanes**: `fast-feedback`, `confidence` +**Target Platform**: Laravel admin web app under Sail, rendered through Filament on Linux containers +**Project Type**: Monorepo with one Laravel platform application in `apps/platform` plus docs/spec artifacts at repository root +**Performance Goals**: Start preflight remains DB-only at click time, performs no Graph call before queue admission, adds no new remote latency source to the click-time path, and admits at most one accepted provider-backed run per protected scope +**Constraints**: No new provider-start framework, no parallel queue-admission path, no new persistence, no operation-type naming rewrite, no inline remote work on start surfaces, no drift from RBAC 404/403 semantics, no drift from Ops-UX 3-surface feedback, and no hidden bypass of the gate on covered start surfaces +**Scale/Scope**: First slice covers every current operator-triggered provider-backed start reachable from tenant-scoped surfaces, provider-connection surfaces, and onboarding: tenant verification, provider-connection check/inventory/compliance, restore execute, directory groups sync, role definitions sync, onboarding verification, and onboarding bootstrap + +## Filament v5 Implementation Contract + +- **Livewire v4.0+ compliance**: Preserved. This feature stays within existing Filament v5 and Livewire v4 patterns and does not introduce any Livewire v3-era APIs. +- **Provider registration location**: Unchanged. No panel/provider work is planned; existing panel providers remain registered in `bootstrap/providers.php`, not `bootstrap/app.php`. +- **Global search coverage**: + - `TenantResource` remains globally searchable and already has both view and edit pages. + - `EntraGroupResource` remains tenant-scoped for global search and already has a view page. + - `ProviderConnectionResource` keeps global search disabled (`$isGloballySearchable = false`). + - `RestoreRunResource` is not being newly enabled for global search in this feature; its existing view page remains available if current search behavior references it. +- **Destructive actions**: No new destructive actions are introduced. Restore execution remains a destructive-like flow that continues to rely on existing confirmation and authorization requirements; existing connection lifecycle actions remain `Action::make(...)->action(...)` based and keep `->requiresConfirmation()` where already required. +- **Asset strategy**: No new panel or shared assets are planned. Deployment expectations remain unchanged, including `cd apps/platform && php artisan filament:assets` when registered Filament assets change. +- **Testing plan**: Keep one supporting unit seam for the gate and shared presentation mapping, then prove the feature through focused Filament/Livewire feature coverage on tenant, provider-connection, onboarding, restore, directory-sync, and run-detail alignment. No browser lane or new heavy-governance family is planned. + +## UI / Surface Guardrail Plan + +- **Guardrail scope**: Changed surfaces across existing tenant-scoped provider-backed starts, provider-connection start actions, restore execute, directory sync starts, onboarding provider steps, and canonical Monitoring run drill-in language +- **Native vs custom classification summary**: Mixed shared-family change using native Filament actions, native notifications, and existing shared Ops UX helpers; no new custom shell or bespoke panel surface +- **Shared-family relevance**: Shared provider action family across `TenantResource`, `TenantVerificationReport`, `ProviderConnectionResource`, `RestoreRunResource`, `ListEntraGroups`, `ManagedTenantOnboardingWizard`, and related run-detail explanation surfaces +- **State layers in scope**: `page`, `detail`, `wizard-step`, and existing run-link drill-in; no new URL-query or shell ownership added +- **Handling modes by drift class or surface**: Hard-stop for blocked preflight and protected-scope conflicts; review-mandatory for any remaining direct dispatch path on a covered surface +- **Repository-signal treatment**: Review-mandatory because the feature changes a shared action-host family and canonical run-entry semantics +- **Special surface test profiles**: `standard-native-filament`, `workflow / wizard`, `monitoring-state-page` +- **Required tests or manual smoke**: `functional-core`, `state-contract`, `manual-smoke` +- **Exception path and spread control**: One named exception boundary only: onboarding bootstrap currently batches multiple provider-backed operations and must be normalized to one accepted protected-scope run without introducing a new orchestration framework +- **Active feature PR close-out entry**: `Guardrail` + +## Constitution Check + +*GATE: Passed before Phase 0 research. Re-checked after Phase 1 design: still passed with one bounded helper addition and no new persisted truth.* + +| Gate | Status | Plan Notes | +|------|--------|------------| +| Inventory-first / read-write separation | PASS | The feature changes start admission and feedback only. Accepted work still executes asynchronously; restore keeps preview/confirmation/audit semantics and no new snapshot or runtime truth is introduced. | +| Single Graph contract path / no inline remote work | PASS | Start surfaces remain authorize + preflight + enqueue only. Existing queued jobs continue to own Graph calls through the current provider abstractions; no render-time or click-time Graph call is added. | +| RBAC, workspace isolation, tenant isolation | PASS | Covered starts remain inside existing tenant/workspace scopes. Non-members remain 404, members missing capability remain 403, and provider readiness messaging must not leak cross-tenant active runs or connection identity. | +| Run observability / Ops-UX 3-surface feedback | PASS | Covered starts continue to create or reuse canonical `OperationRun` truth. Accepted starts keep toast-only intent feedback, progress stays in active widgets/run detail, and terminal DB notifications remain owned by `OperationRunService` for accepted work only. | +| Proportionality / no premature abstraction / few layers | PASS | The plan extends the existing gate and registry and adds one thin presentation helper to absorb five-plus duplicated local result branches. No second gate, coordinator framework, or persisted summary layer is introduced. | +| UI semantics / Filament-native action discipline | PASS | Existing action hosts remain in place. The feature standardizes start-result language without adding new navigation models, new shell surfaces, or page-local status frameworks. | +| Test governance | PASS | Proof stays in unit plus focused feature lanes with explicit provider/tenant context opt-in. No new browser or heavy-governance family is required. | + +## Test Governance Check + +- **Test purpose / classification by changed surface**: `Unit` for gate transition and presentation mapping seams; `Feature` for tenant/provider/onboarding/restore/directory start surfaces and accepted-run reason alignment; `Heavy-Governance`: none; `Browser`: none +- **Affected validation lanes**: `fast-feedback`, `confidence` +- **Why this lane mix is the narrowest sufficient proof**: The business truth is server-side preflight, authorization, dedupe/scope busy handling, queue admission, and consistent operator feedback on existing action hosts. That behavior is fully provable with direct feature tests and one small unit seam; browser coverage would add cost without proving unique behavior. +- **Narrowest proving command(s)**: + - `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail bin pint --dirty --format agent` + - `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Unit/Providers/ProviderOperationStartGateTest.php tests/Unit/Providers/ProviderOperationStartResultPresenterTest.php tests/Feature/ProviderConnections/ProviderDispatchGateStartSurfaceTest.php tests/Feature/Tenants/TenantProviderBackedActionStartTest.php tests/Feature/Workspaces/ManagedTenantOnboardingProviderStartTest.php tests/Feature/Restore/RestoreRunProviderStartTest.php tests/Feature/Directory/ProviderBackedDirectoryStartTest.php tests/Feature/Onboarding/OnboardingRbacSemanticsTest.php tests/Feature/Filament/RestoreRunUiEnforcementTest.php tests/Feature/Guards/ActionSurfaceContractTest.php tests/Feature/Operations/ProviderBackedRunReasonAlignmentTest.php tests/Feature/OpsUx/CanonicalViewRunLinksTest.php tests/Feature/Operations/OperationRunBlockedExecutionPresentationTest.php tests/Feature/OpsUx/Constitution/JobDbNotificationGuardTest.php tests/Feature/OpsUx/NoQueuedDbNotificationsTest.php tests/Feature/Guards/ProviderDispatchGateCoverageTest.php tests/Feature/Guards/TestLaneManifestTest.php` +- **Fixture / helper / factory / seed / context cost risks**: Provider-backed start fixtures need tenant membership, explicit provider connections, active-run setup, and onboarding draft state; these remain explicit per test and must not become default helpers. +- **Expensive defaults or shared helper growth introduced?**: No. Existing factories and helpers remain opt-in; no global provider/workspace bootstrap is planned. +- **Heavy-family additions, promotions, or visibility changes**: None +- **Surface-class relief / special coverage rule**: Standard native Filament relief for resource/page action hosts; onboarding uses a named wizard-step profile but still remains feature-testable without browser automation. +- **Closing validation and reviewer handoff**: Re-run `pint`, then the focused test command above, then do one human smoke pass for blocked, deduped, scope busy, and queued outcomes across tenant, provider-connection, onboarding, and restore. Reviewers should confirm there is no remaining direct-dispatch bypass on a route-bounded covered surface, that scheduled or system-run Monitoring wording stays compatible with the same problem classes, and that no new custom completion notification path was introduced. +- **Budget / baseline / trend follow-up**: None expected; document in feature if the new focused suite materially expands runtime beyond ordinary feature-local upkeep. +- **Review-stop questions**: Did any coverage drift into browser/heavy lanes without a unique proving need? Did any helper start providing implicit tenant/provider context? Did any route-bounded covered start remain on local `ensureRun*/dispatch` logic? Did scheduled or system-run compatibility widen into click-time UX scope? Did any test assert presentation details that should stay in the shared presenter seam only? +- **Escalation path**: `document-in-feature` +- **Active feature PR close-out entry**: `Guardrail` +- **Why no dedicated follow-up spec is needed**: This change is feature-local hardening around existing start surfaces. Only recurring operation-type normalization or structural lane-cost drift would justify a follow-up spec. + +## Project Structure + +### Documentation (this feature) + +```text +specs/216-provider-dispatch-gate/ +├── plan.md +├── research.md +├── data-model.md +├── quickstart.md +├── contracts/ +│ └── provider-dispatch-gate.logical.openapi.yaml +└── tasks.md +``` + +### Source Code (repository root) + +```text +apps/platform/ +├── app/ +│ ├── Filament/ +│ │ ├── Pages/Operations/TenantlessOperationRunViewer.php +│ │ ├── Pages/Workspaces/ManagedTenantOnboardingWizard.php +│ │ ├── Resources/EntraGroupResource/Pages/ListEntraGroups.php +│ │ ├── Resources/OperationRunResource.php +│ │ ├── Resources/ProviderConnectionResource.php +│ │ ├── Resources/RestoreRunResource.php +│ │ └── Resources/TenantResource.php +│ ├── Notifications/ +│ │ └── OperationRunCompleted.php +│ ├── Services/ +│ │ ├── Directory/RoleDefinitionsSyncService.php +│ │ ├── Providers/ProviderConnectionResolver.php +│ │ ├── Providers/ProviderOperationRegistry.php +│ │ ├── Providers/ProviderOperationStartGate.php +│ │ └── Verification/StartVerification.php +│ └── Support/ +│ ├── OperationRunLinks.php +│ ├── OpsUx/OperationUxPresenter.php +│ ├── OpsUx/ProviderOperationStartResultPresenter.php +│ ├── ReasonTranslation/ +│ └── Providers/ +└── tests/ + ├── Feature/ + │ ├── Directory/ + │ ├── Filament/ + │ ├── Guards/ + │ ├── Onboarding/ + │ ├── Operations/ + │ ├── OpsUx/ + │ ├── ProviderConnections/ + │ ├── Restore/ + │ ├── Tenants/ + │ └── Workspaces/ + ├── Support/ + └── Unit/Providers/ +``` + +**Structure Decision**: Single Laravel application inside the monorepo. All runtime work lands in `apps/platform`, while planning artifacts stay under `specs/216-provider-dispatch-gate`. + +## Complexity Tracking + +No constitutional violation is planned. One bounded addition is tracked explicitly because it adds a thin presentation layer. + +| Violation | Why Needed | Simpler Alternative Rejected Because | +|-----------|------------|-------------------------------------| +| BLOAT-001 bounded presenter helper | Five-plus real start surfaces already duplicate blocked/deduped/scope busy/accepted notification branches; one shared helper is the narrowest way to keep one operator vocabulary without inventing a framework | Keeping page-local branches would fail FR-216-008/009, preserve inconsistent copy drift, and force every new covered surface to duplicate the same start contract again | + +## Proportionality Review + +- **Current operator problem**: Operators can trigger provider-backed actions that look accepted at click time but only fail later because provider connection, consent, credential, or protected-scope prerequisites were missing. The same blocker also renders differently across tenant, provider-connection, onboarding, restore, and directory-sync surfaces. +- **Existing structure is insufficient because**: The canonical gate already exists, but only a subset of surfaces use it and those surfaces still duplicate result rendering locally. Legacy `ensureRun*/dispatch` starts also resolve provider identity too late, which allows runtime default drift and inconsistent click-time feedback. +- **Narrowest correct implementation**: Extend the existing `ProviderOperationRegistry` and `ProviderOperationStartGate`, add one thin start-result presentation helper over the current Ops UX primitives, and migrate the first-slice action hosts. Keep blocked/accepted truth in existing `OperationRun` records and do not add new persistence. +- **Ownership cost created**: One small shared presentation helper, a broader but still focused feature test surface, and explicit registry entries for each newly covered operation type. +- **Alternative intentionally rejected**: A new provider-start coordinator/orchestration framework or a second queue-admission path. Those would duplicate existing gate behavior, broaden the slice into architecture work, and violate FR-216-014. +- **Release truth**: Current-release truth. The problem exists today on current operator-facing start surfaces. + +## Phase 0 Research Summary + +- Reuse the current gate and registry; adoption breadth is the gap, not missing queue-admission infrastructure. +- Resolve and pin `provider_connection_id` at dispatch time for every accepted covered start to prevent runtime default drift. +- Preserve blocked preflight truth as canonical blocked start state, but never admit background work for blocked starts. +- Add one shared presentation helper over `OperationUxPresenter`, `ReasonPresenter`, `ProviderNextStepsRegistry`, and `OperationRunLinks` rather than leaving local `Notification::make()` branches in place. +- Keep the operator-facing start vocabulary explicit: the canonical outcomes remain `accepted`, `deduped`, `scope busy`, and `blocked`, while public wording stays `queued`, `already running`, `scope busy`, and `blocked`. +- Keep the first implementation slice bounded to tenant, provider-connection, onboarding, restore, and directory start surfaces named by the spec; workspace-level baseline/evidence/review generators remain outside this route-bounded slice. +- Do not combine dispatch-gate hardening with operation-type normalization; legacy write-time operation strings stay as-is in this feature. +- Keep scheduled and system-triggered compatibility bounded to Monitoring-side reason reuse and initiator-aware notification behavior; no click-time UX is added for those runs. +- Normalize onboarding bootstrap from multi-start batch admission to sequential protected-scope admission without adding a new orchestration framework. + +## Phase 1 Design Summary + +- `data-model.md` documents the feature as a service-owned extension over existing `ProviderConnection`, `OperationRun`, and onboarding draft state plus new logical models for protected scope, preflight result, accepted run context, and shared presentation output. +- `contracts/provider-dispatch-gate.logical.openapi.yaml` defines the internal action-start contract for tenant, provider-connection, restore, directory-sync, and onboarding action hosts plus canonical run-detail output. +- `quickstart.md` defines the focused verification path for blocked, deduped, scope busy, and accepted results, including the onboarding bootstrap sequentialization case. + +## Implementation Strategy + +1. **Extend canonical gate coverage** + - Expand `ProviderOperationRegistry` to include every first-slice covered operation type. + - Keep the existing `ProviderOperationStartGate` as the single queue-admission path. + - Ensure each migrated start resolves provider access before dispatch and passes the explicit `provider_connection_id` into both `OperationRun` context and job args. + +2. **Centralize start-result presentation** + - Add one shared helper that consumes `ProviderOperationStartResult` and emits the standardized operator feedback mapping: `accepted -> queued`, `deduped -> already running`, `scope busy -> scope busy`, and `blocked -> blocked`. + - Reuse `OperationUxPresenter` for queued and already running toast semantics and `ReasonPresenter` plus next-step metadata for blocked and scope busy outcomes. + - Keep domain verbs and target nouns local to each action host while unifying outcome vocabulary and run-link behavior. + +3. **Migrate already-gated surfaces first** + - Refactor `StartVerification`, `TenantResource`, `TenantVerificationReport`, `ProviderConnectionResource`, and the onboarding verification step to call the shared presentation helper instead of keeping local result-branching code. + - Preserve current protection while removing duplicated operator copy paths. + +4. **Migrate legacy direct-dispatch starts** + - Replace local `ensureRun*/dispatch` logic on restore execute, directory groups sync, role definitions sync, and onboarding bootstrap with the canonical gate. + - Keep destructive restore semantics intact: existing preview, warning, confirmation, and authorization remain unchanged; only queue admission and feedback unify. + +5. **Normalize onboarding bootstrap sequencing** + - Convert bootstrap admission from “start every selected provider-backed operation immediately” to “accept one protected-scope provider-backed operation at a time.” + - Reuse existing onboarding draft state to retain pending bootstrap selections and run references instead of introducing an umbrella batch entity or new orchestration model. + +6. **Align accepted-run diagnostics** + - Ensure canonical run detail and terminal notification reason translation stay aligned with the shared start-result problem classes whenever they describe the same accepted operation. + - Keep scheduled and system-triggered Monitoring reason reuse in the same public language family without adding click-time start UX or breaking initiator-only notification policy. + - Keep blocked starts distinguishable from accepted execution failures in monitoring and audit surfaces. + +7. **Backfill proof and regression guards** + - Extend the gate unit suite for newly registered operation types and onboarding sequencing. + - Add one route-bounded guard that inventories covered start hosts and fails if any first-slice action still bypasses `ProviderOperationStartGate` through local `ensureRun*/dispatch` logic. + - Add focused feature coverage around each migrated action host and around cross-surface reason vocabulary alignment. + +## Risks and Mitigations + +- **Onboarding bootstrap behavior change**: The current wizard can admit more than one provider-backed run for one connection. The mitigation is to normalize the flow to one accepted protected-scope run per submission and keep the rest as pending follow-up using existing draft state. +- **Legacy operation-type aliases**: Directory sync operations currently use legacy write-time names. The mitigation is to keep those names stable in this feature and avoid widening the scope into operation taxonomy cleanup. +- **Restore flow regression risk**: Restore is destructive-like and already confirmation-heavy. The mitigation is to change only the start path and feedback contract, not the preview/confirmation/authorization model. +- **Hidden direct-dispatch bypasses**: Additional provider-backed starts may exist outside obvious action hosts. The mitigation is to treat any remaining first-slice `ensureRun*/dispatch` path as a review blocker and keep the route-bounded scope explicit. +- **Over-testing via expensive fixtures**: Provider-backed starts need tenant, provider, and active-run context. The mitigation is to keep those fixtures opt-in and avoid a new shared “full provider world” helper. + +## Post-Design Re-check + +Phase 0 and Phase 1 outputs resolve the earlier design unknowns without introducing new gates, new persisted truth, or a second UI semantics framework. The plan remains constitution-compliant, bounded to current-release operator pain, and ready for `/speckit.tasks`. + +## Implementation Close-Out + +- **Final lane outcome**: Focused verification completed with `pint --dirty --format agent`, the documented quickstart suite, the broader migrated-surface regression suite, and the route-bounded guard/manifest close-out suite all passing during implementation. +- **Route-bounded covered-surface audit**: `tests/Feature/Guards/ProviderDispatchGateCoverageTest.php` now guards the first-slice route-bounded provider-backed starts so tenant verification, provider-connection actions, restore execute, directory sync, role definitions sync, and onboarding bootstrap stay on canonical gate-owned entry points rather than regressing to local `ensureRun` / `dispatchOrFail` admission paths. +- **Guardrail close-out**: Livewire v4.0+ compliance is preserved, provider registration remains in `bootstrap/providers.php`, no new global-search behavior was introduced, destructive restore semantics remain on existing confirmation/authorization paths, and no new Filament assets were added beyond the standing `php artisan filament:assets` deployment expectation when registered assets change. +- **Test governance disposition**: `document-in-feature` remains the explicit disposition. The proof stays in unit plus focused feature lanes, with one new heavy-governance guard family (`provider-dispatch-gate-coverage`) recorded in the manifest instead of widening into browser or new heavy workflow suites. diff --git a/specs/216-provider-dispatch-gate/quickstart.md b/specs/216-provider-dispatch-gate/quickstart.md new file mode 100644 index 00000000..d28e3671 --- /dev/null +++ b/specs/216-provider-dispatch-gate/quickstart.md @@ -0,0 +1,164 @@ +# Quickstart: Provider-Backed Action Preflight and Dispatch Gate Unification + +## Goal + +Validate that every covered operator-triggered provider-backed start now resolves click-time provider blockers, same-operation dedupe, and protected-scope conflicts before queue admission, while preserving one shared operator vocabulary and truthful Monitoring → Operations drill-in for accepted work. + +## Prerequisites + +1. Start Sail if it is not already running. +2. Use a tenant member with the exact capability required by the target action host. +3. Prepare at least one tenant with: + - one usable provider connection, + - one blocked provider condition such as missing default connection, missing consent, or unusable credentials, + - one active queued or running provider-backed `OperationRun` for same-operation dedupe checks, + - one active queued or running provider-backed `OperationRun` for cross-operation `scope_busy` checks, + - one restore run ready for execute validation, + - one onboarding session with provider verification and bootstrap steps available. +4. Keep a second user or session available if you want to validate tenant isolation and initiator-only notification behavior. + +## Focused Automated Verification + +Run formatting first: + +```bash +export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail bin pint --dirty --format agent +``` + +Then run the narrowest focused suite that proves the contract: + +```bash +export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact \ + tests/Unit/Providers/ProviderOperationStartGateTest.php \ + tests/Unit/Providers/ProviderOperationStartResultPresenterTest.php \ + tests/Feature/ProviderConnections/ProviderDispatchGateStartSurfaceTest.php \ + tests/Feature/Tenants/TenantProviderBackedActionStartTest.php \ + tests/Feature/Workspaces/ManagedTenantOnboardingProviderStartTest.php \ + tests/Feature/Restore/RestoreRunProviderStartTest.php \ + tests/Feature/Directory/ProviderBackedDirectoryStartTest.php \ + tests/Feature/Operations/ProviderBackedRunReasonAlignmentTest.php +``` + +If only one action host changed during implementation, rerun the smallest relevant subset before broadening to the full focused suite. + +## Manual Validation Pass + +### 1. Tenant-scoped start surfaces + +Trigger a covered tenant-scoped provider-backed action such as verification, restore execute, or directory sync in these four conditions: + +- blocked by missing or unusable provider access, +- deduped by an equivalent active run, +- `scope_busy` because a different covered operation is already active for the same provider connection, +- accepted with a valid explicit provider connection. + +Confirm that: + +- no blocked case queues background work, +- the operator sees one consistent accepted/deduped/scope-busy/blocked vocabulary, +- each non-blocked result points to the correct existing or accepted run, +- and no page-local notification copy contradicts the shared contract. + +### 2. Provider-connection resource surfaces + +From the provider-connection list and detail pages, start the covered connection-scoped actions. + +Confirm that: + +- the same blocker or active-run condition produces the same outcome category used on tenant surfaces, +- the provider connection identity shown to the operator matches the accepted run context, +- and connection lifecycle actions remain where they were before this feature. + +### 3. Restore execute flow + +Start a restore execution from the existing restore surface. + +Confirm that: + +- existing preview, warning, and confirmation behavior remains intact, +- blocked preflight prevents queue admission before execution starts, +- accepted execution opens the canonical run link, +- and restore-specific destructive semantics are unchanged apart from the unified start contract. + +### 4. Onboarding verification and bootstrap + +Use the onboarding wizard to trigger the provider verification step and the bootstrap step. + +Confirm that: + +- provider verification uses the same outcome categories as the other covered action hosts, +- bootstrap no longer admits multiple provider-backed runs concurrently for the same connection, +- a blocked, deduped, or `scope_busy` bootstrap attempt does not leave hidden extra queued work behind, +- and any remaining bootstrap work is visible as a follow-up state on the existing step rather than on a new workflow page. + +### 5. Monitoring → Operations run detail + +Open the run detail pages linked from accepted, deduped, and `scope_busy` results. + +Confirm that: + +- accepted work shows the same provider connection identity that was chosen at click time, +- run detail and terminal notification reason translation align with the same problem class used at the start surface, +- blocked starts remain distinguishable from accepted work that later fails, +- and no cross-tenant provider identity or active-run existence leaks through canonical monitoring views. + +### 6. Authorization and isolation non-regression + +Confirm that: + +- non-members still receive 404 behavior and learn nothing about provider readiness or active runs in other tenants, +- members missing the required capability still receive server-enforced 403 on execution, +- accepted-run notifications remain initiator-only, +- and no new start action bypasses the central capability registry. + +### 7. Ten-second scan check + +Timebox the first visible read of one result from each action-host family: + +- tenant-scoped start surface, +- provider-connection surface, +- onboarding provider step, +- Monitoring run detail. + +Confirm that within 10 seconds the operator can answer: + +- did the operation get accepted, +- if not, why not, +- and what should happen next. + +## Final Verification + +Before merge, re-run: + +```bash +export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail bin pint --dirty --format agent +export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact \ + tests/Unit/Providers/ProviderOperationStartGateTest.php \ + tests/Unit/Providers/ProviderOperationStartResultPresenterTest.php \ + tests/Feature/Tenants/TenantProviderBackedActionStartTest.php \ + tests/Feature/ProviderConnections/ProviderDispatchGateStartSurfaceTest.php \ + tests/Feature/ProviderConnections/ProviderConnectionHealthCheckStartSurfaceTest.php \ + tests/Feature/ProviderConnections/ProviderOperationBlockedGuidanceSpec081Test.php \ + tests/Feature/Filament/TenantVerificationReportWidgetTest.php \ + tests/Feature/Restore/RestoreRunProviderStartTest.php \ + tests/Feature/RestoreRunWizardExecuteTest.php \ + tests/Feature/RestoreRunRerunTest.php \ + tests/Feature/Directory/ProviderBackedDirectoryStartTest.php \ + tests/Feature/DirectoryGroups/StartSyncFromGroupsPageTest.php \ + tests/Feature/DirectoryGroups/StartSyncTest.php \ + tests/Feature/TenantRBAC/RoleDefinitionsSyncNowTest.php \ + tests/Feature/Workspaces/ManagedTenantOnboardingProviderStartTest.php \ + tests/Feature/ManagedTenantOnboardingWizardTest.php \ + tests/Feature/Onboarding/OnboardingRbacSemanticsTest.php \ + tests/Feature/Filament/RestoreRunUiEnforcementTest.php \ + tests/Feature/Operations/ProviderBackedRunReasonAlignmentTest.php \ + tests/Feature/OpsUx/CanonicalViewRunLinksTest.php \ + tests/Feature/Operations/OperationRunBlockedExecutionPresentationTest.php \ + tests/Feature/OpsUx/Constitution/JobDbNotificationGuardTest.php \ + tests/Feature/OpsUx/NoQueuedDbNotificationsTest.php \ + tests/Feature/Guards/ProviderDispatchGateCoverageTest.php \ + tests/Feature/Guards/ActionSurfaceContractTest.php \ + tests/Feature/Guards/TestLaneManifestTest.php +``` + +If broader confidence is wanted after the focused suite, offer a full application test run as a separate follow-up step. \ No newline at end of file diff --git a/specs/216-provider-dispatch-gate/research.md b/specs/216-provider-dispatch-gate/research.md new file mode 100644 index 00000000..51238fad --- /dev/null +++ b/specs/216-provider-dispatch-gate/research.md @@ -0,0 +1,73 @@ +# Research: Provider-Backed Action Preflight and Dispatch Gate Unification + +## Decision 1: Extend the existing gate and registry; do not create a second provider-start framework + +- Decision: Migrate every covered operator-triggered provider-backed start onto the existing `ProviderOperationStartGate` by expanding `ProviderOperationRegistry` and updating the current action hosts to call the same queue-admission path. +- Rationale: The repo already has the right hardening seam. `ProviderOperationStartGate` resolves connection readiness, blocks missing provider prerequisites before queue admission, dedupes same-operation starts, blocks conflicting operations on the same protected scope, and returns a shared result object. The problem is adoption breadth, not missing infrastructure. +- Alternatives considered: + - Introduce a new `ProviderStartCoordinator` or second orchestration pipeline. Rejected because it would duplicate the gate, widen the change into architecture work, and violate FR-216-014. + - Keep local `ensureRun*/dispatch` flows and copy the same preflight into each action. Rejected because that preserves semantic drift and repeats the same blocker logic across tenant, provider-connection, restore, directory, and onboarding surfaces. + +## Decision 2: Accepted work must pin `provider_connection_id` at dispatch time + +- Decision: Every migrated accepted start resolves its provider connection before queue admission and persists the chosen `provider_connection_id` into `OperationRun.context` and job arguments. +- Rationale: Existing non-gate starts often resolve the default connection at job execution time. That allows runtime default drift if the default connection changes between click time and job execution. Dispatch-time pinning is already the correct pattern on the gated starts and is required by FR-216-006. +- Alternatives considered: + - Continue resolving the default connection inside queued jobs. Rejected because the same operator action can execute against a different connection than the one implied at click time. + - Store only a display label or provider name in context. Rejected because monitoring, dedupe, and run-detail explanation need the stable connection identity, not only presentation data. + +## Decision 3: Keep blocked starts as canonical prevented-from-starting truth, but never admit background work + +- Decision: A blocked preflight continues to produce canonical blocked-start truth where the current gate already does so, but blocked starts never enqueue jobs and must remain distinguishable from accepted runs that later fail during execution. +- Rationale: FR-216-004 and FR-216-015 require the product to stop turning preventable prerequisite problems into ordinary execution failures. Preserving canonical blocked truth keeps auditability and resolution links intact while still ensuring no remote work was accepted. +- Alternatives considered: + - Show only an ephemeral toast and create no run truth at all. Rejected because operators and support would lose the canonical blocked audit trail and linked next-step metadata. + - Queue the work and let the job fail fast. Rejected because that is the failure mode this spec is correcting. + +## Decision 4: Standardize operator feedback through one thin presentation helper layered over the existing Ops UX stack + +- Decision: Add one narrow start-result presentation helper that consumes `ProviderOperationStartResult` and composes the existing `OperationUxPresenter`, `ReasonPresenter`, `ProviderNextStepsRegistry`, and `OperationRunLinks` building blocks. +- Rationale: The current gated surfaces already prove that the shared start result is viable, but they still duplicate local `if/else` notification code across tenant, widget, provider-connection, and onboarding surfaces. A thin presenter absorbs that duplication without introducing a new UI semantics framework. +- Alternatives considered: + - Leave each surface with its own `Notification::make()` branching. Rejected because it fails FR-216-008/009 and guarantees future copy drift. + - Invent a larger badge/explanation framework for provider starts. Rejected because the repo constitution explicitly discourages turning UI semantics into their own mandatory architecture. + +## Decision 5: The first slice is bounded by the spec routes, not by every possible provider-backed job in the codebase + +- Decision: The first implementation slice covers every current operator-triggered provider-backed start reachable from tenant-scoped surfaces, provider-connection surfaces, and onboarding: tenant verification, provider-connection check/inventory/compliance actions, restore execute, directory groups sync, role definitions sync, onboarding verification, and onboarding bootstrap. +- Rationale: FR-216-012 defines the route-bounded first slice. Read-only exploration also surfaced workspace-level baseline/evidence/review generators and other background operations, but those lie outside the spec's primary routes and would expand this hardening feature into adjacent workflow areas. +- Alternatives considered: + - Expand the first slice to every provider-backed operation in the entire repo. Rejected because it would overshoot the spec and slow delivery. + - Limit the slice to restore only. Rejected because the same operator pain already exists across onboarding, directory sync, and provider-connection action hosts. + +## Decision 6: Keep current write-time operation type strings in this feature; do not combine hardening with operation-type normalization + +- Decision: Migrated starts keep their current write-time operation type strings, even where `OperationCatalog` exposes newer aliases or canonical dotted names. +- Rationale: The operator problem here is queue admission and start-result consistency, not operation-type taxonomy cleanup. Renaming start types would widen the blast radius into monitoring, audit, test fixtures, and historical read-model expectations. +- Alternatives considered: + - Rename legacy operation types such as `entra_group_sync` or `directory_role_definitions.sync` during the gate migration. Rejected because that is a separate normalization concern and not required to deliver block-before-queue semantics. + - Add a new translation layer inside the gate just for this feature. Rejected because that adds semantic machinery without solving the core operator problem. + +## Decision 7: Onboarding bootstrap must normalize to sequential protected-scope admission + +- Decision: The onboarding wizard can no longer admit multiple provider-backed operations concurrently for the same provider connection. The existing wizard flow remains, but queue admission becomes sequential: one accepted provider-backed run per protected scope, with remaining selected bootstrap work retained as follow-up state. +- Rationale: The current bootstrap implementation explicitly starts more than one provider-backed run for the same connection if no run is active at the beginning of the transaction. That conflicts with FR-216-007 and SC-216-003, which require click-time conflict protection and at most one accepted provider-backed operation per protected scope. +- Alternatives considered: + - Keep the existing batch-start bypass for onboarding only. Rejected because it would leave a permanent exception to the canonical start contract inside the first slice. + - Introduce a new umbrella bootstrap orchestration entity. Rejected because it would create a second start framework and unnecessary new semantics. + +## Decision 8: The operator contract uses accepted/deduped/scope-busy/blocked vocabulary, even if the internal result object keeps `started` for compatibility + +- Decision: The shared operator-facing contract standardizes on `accepted`, `deduped`, `scope busy`, and `blocked`, while the internal `ProviderOperationStartResult` can retain its current `started` variant in this slice if that avoids unnecessary churn. +- Rationale: FR-216-002 is about operator-visible start outcomes. The narrowest implementation is to keep internal compatibility where it helps while converging every visible surface and logical contract on the same operator vocabulary. +- Alternatives considered: + - Rename every internal `started` code path immediately. Rejected because it widens refactor scope without increasing product certainty. + - Keep operator-facing copy inconsistent with the spec language. Rejected because that would fail the core purpose of the feature. + +## Decision 9: Testing should stay feature-first with one supporting unit seam + +- Decision: Reuse and extend the existing `ProviderOperationStartGateTest` unit suite, then prove the feature through focused feature coverage on real Filament action hosts and canonical run-detail reason alignment. +- Rationale: The business truth is server-side authorization, preflight, dedupe, scope-busy handling, queue admission, and cross-surface reason consistency. Those are best proven with targeted feature tests against current action hosts, not with browser-heavy coverage or a new dedicated presenter harness. +- Alternatives considered: + - Rely mainly on browser tests. Rejected because the critical behavior is server-owned and already easier to prove through existing resource/page test families. + - Create a large presenter-only test harness. Rejected because it would shift effort from the real action hosts to indirection created only for the test suite. \ No newline at end of file diff --git a/specs/216-provider-dispatch-gate/spec.md b/specs/216-provider-dispatch-gate/spec.md new file mode 100644 index 00000000..ea871ae6 --- /dev/null +++ b/specs/216-provider-dispatch-gate/spec.md @@ -0,0 +1,237 @@ +# Feature Specification: Provider-Backed Action Preflight and Dispatch Gate Unification + +**Feature Branch**: `216-provider-dispatch-gate` +**Created**: 2026-04-19 +**Status**: Draft +**Input**: User description: "Provider-Backed Action Preflight and Dispatch Gate Unification" + +## Spec Candidate Check *(mandatory — SPEC-GATE-001)* + +- **Problem**: Provider-backed actions currently use two different start patterns, so the same missing-prerequisite or concurrency problem can be blocked before queue on one surface but only discovered later as a failed run on another. +- **Today's failure**: Operators click a provider-backed action, see nothing obviously wrong, and only later discover that the job failed because a primary connection, valid consent, usable credentials, or an available scope was missing. +- **User-visible improvement**: Covered provider-backed action surfaces all answer the same first question immediately using one public vocabulary: was the start queued, is it already running, is the scope busy, or is it blocked and what should I do next. +- **Smallest enterprise-capable version**: Extend one canonical preflight-and-dispatch path plus one shared result-presentation contract to every current operator-triggered provider-backed start surface, without redesigning provider architecture or adding new provider domains. +- **Explicit non-goals**: No provider-connection label redesign, no broad provider-domain expansion, no operation naming overhaul, no new workflow hub, no legacy-credential cleanup, and no full backend normalization of every legacy provider service in this spec. +- **Permanent complexity imported**: One expanded start-gate contract, one shared start-result presentation contract, focused regression coverage across existing action hosts, and limited next-step translation expansion where current blocked reasons are insufficient. +- **Why now**: The roadmap marks this as an active adjacent hardening lane, the proven start-gate pattern already exists, and every new provider-backed action that bypasses it increases trust debt on core operator surfaces. +- **Why not local**: The failure mode spans tenant action surfaces, provider-connection surfaces, onboarding, and monitoring. Per-action fixes would recreate inconsistent blocked, deduped, and next-step language on roughly twenty workflows. +- **Approval class**: Core Enterprise +- **Red flags triggered**: New shared support contract and broad multi-surface rollout. This remains justified because it does not add new persisted truth, it protects operator trust on remote actions, and the narrowest alternative is still one shared gate rather than many local copies. +- **Score**: Nutzen: 2 | Dringlichkeit: 2 | Scope: 1 | Komplexität: 1 | Produktnähe: 2 | Wiederverwendung: 2 | **Gesamt: 10/12** +- **Decision**: approve + +## Spec Scope Fields *(mandatory)* + +- **Scope**: workspace, tenant, canonical-view +- **Primary Routes**: `/admin/t/{tenant}/...` existing tenant-scoped provider-backed action surfaces, `/admin/provider-connections`, `/admin/onboarding/...`, `/admin/operations/{run}` +- **Data Ownership**: Tenant-owned provider connections and tenant-bound operation runs remain the authoritative runtime records. Workspace context continues to scope access and canonical monitoring views; this spec does not introduce new persisted ownership models. +- **RBAC**: Existing workspace membership, tenant entitlement, and per-action capabilities remain authoritative. Preflight blocks never replace server-side authorization checks. + +For canonical-view specs, the spec MUST define: + +- **Default filter behavior when tenant-context is active**: Canonical run links opened from a tenant-context action continue to preserve the active tenant context and related filtering behavior rather than widening back to all tenants by default. +- **Explicit entitlement checks preventing cross-tenant leakage**: Canonical run detail, existing-run dedupe links, and blocked next-step guidance must only be built after workspace membership and tenant entitlement are confirmed. Non-members or non-entitled viewers remain deny-as-not-found and must not learn whether another tenant currently has an active conflicting run or configured provider connection. + +## UI / Surface Guardrail Impact *(mandatory when operator-facing surfaces are changed; otherwise write `N/A`)* + +| Surface / Change | Operator-facing surface change? | Native vs Custom | Shared-Family Relevance | State Layers Touched | Exception Needed? | Low-Impact / `N/A` Note | +|---|---|---|---|---|---|---| +| Tenant-scoped provider-backed start surfaces | yes | Native admin actions + shared provider-start feedback | shared provider action family | page, modal, detail | no | Existing start actions remain; only preflight and queue-accept feedback are unified | +| Provider-connection resource surfaces | yes | Native admin actions + shared provider-start feedback | shared provider action family | table, detail | no | Existing connection-check and provider-triggered actions adopt the same result language | +| Onboarding provider step | yes | Native wizard actions + shared provider-start feedback | shared provider action family | wizard step | no | No new onboarding page is introduced | +| Canonical provider-backed operation run detail | yes | Native monitoring detail primitives | shared monitoring family | detail | no | Run detail keeps diagnostics secondary to translated execution meaning | + +## Decision-First Surface Role *(mandatory when operator-facing surfaces are changed)* + +| Surface | Decision Role | Human-in-the-loop Moment | Immediately Visible for First Decision | On-Demand Detail / Evidence | Why This Is Primary or Why Not | Workflow Alignment | Attention-load Reduction | +|---|---|---|---|---|---|---|---| +| Tenant-scoped provider-backed start surfaces | Primary Decision Surface | Decide whether to start remote provider work now or resolve a blocker first | Start accepted vs blocked vs already running vs scope busy, short reason, next step, existing-run path when relevant | Full run detail, low-level provider diagnostics, connection detail | Primary because this is the moment an operator commits to remote work or resolves a blocker | Follows the action-start workflow directly on the tenant work surface | Removes the need to click, wait, and later inspect a failed run for preventable prerequisite problems | +| Provider-connection resource surfaces | Secondary Context Surface | Resolve provider readiness and retry a blocked provider-backed action | Current start-prevention reason, next step, retry path | Connection detail, historical runs, deeper diagnostics | Secondary because the connection surface supports the real start decision rather than replacing it | Follows provider-readiness remediation workflow | Shortens recovery from blocked states without inventing a second start dialect | +| Onboarding provider step | Primary Decision Surface | Decide whether onboarding can proceed with provider-backed verification or needs operator follow-up | Start accepted vs blocked, reason, next step, safe continue path | Provider detail, existing run detail, low-level diagnostics | Primary because onboarding must tell the operator immediately whether the next remote step can proceed | Follows onboarding completion workflow, not monitoring navigation | Prevents onboarding from feeling successful until a hidden failed run appears later | +| Canonical provider-backed operation run detail | Tertiary Evidence / Diagnostics Surface | Understand what happened after a covered operation was actually accepted | Human-readable outcome, dominant reason, next step, scope identity | Raw payload context, low-level diagnostics, implementation detail | Tertiary because operators usually arrive here after a start surface or notification already answered the first decision | Follows drill-in and troubleshooting workflow | Keeps deep diagnostics available without making them the first explanation for start-preventing problems | + +## UI/UX Surface Classification *(mandatory when operator-facing surfaces are changed)* + +| Surface | Action Surface Class | Surface Type | Likely Next Operator Action | Primary Inspect/Open Model | Row Click | Secondary Actions Placement | Destructive Actions Placement | Canonical Collection Route | Canonical Detail Route | Scope Signals | Canonical Noun | Critical Truth Visible by Default | Exception Type / Justification | +|---|---|---|---|---|---|---|---|---|---|---|---|---|---| +| Tenant-scoped provider-backed start surfaces | Record / Detail / Edit | Detail-first Operational Surface | Start work or resolve the blocker that prevented it | Existing detail page or modal on the current tenant surface | forbidden | Existing safe navigation remains secondary to the primary start decision | Existing dangerous actions stay where they already live; this spec adds none | `/admin/t/{tenant}/...` | existing tenant-scoped detail routes | Workspace and tenant scope, action target, provider readiness | Provider-backed operations / Provider-backed operation | Whether the action can start now and what the operator should do next | none | +| Provider-connection resource surfaces | List / Table / Bulk | CRUD / List-first Resource | Inspect provider readiness or start a connection-scoped check | Full-row click to connection detail where already used | allowed | Existing secondary actions remain grouped or contextual | Existing destructive connection lifecycle actions remain in danger placements | `/admin/provider-connections` | existing provider-connection detail route | Workspace and tenant scope, provider identity, readiness state | Provider connections / Provider connection | Whether the connection is usable for the requested operation | none | +| Onboarding provider step | Workflow / Wizard / Launch | Wizard / Step-driven Flow | Continue onboarding or resolve provider prerequisites | Existing wizard step as the only primary context | forbidden | Secondary navigation stays outside the primary step action | Destructive actions are out of scope | `/admin/onboarding/...` | existing onboarding step route | Workspace and tenant scope, onboarding phase, provider readiness | Onboarding / Onboarding step | Whether onboarding can continue with provider-backed work right now | none | +| Canonical provider-backed operation run detail | Record / Detail / Edit | Detail-first Operational Surface | Inspect the accepted run or open the related resolution surface | Explicit run detail page | forbidden | Related navigation and diagnostics remain secondary | Destructive actions are out of scope | `/admin/operations` | `/admin/operations/{run}` | Workspace and tenant scope, operation target, provider identity, active vs terminal state | Operation runs / Operation run | What actually happened after the operation was accepted | canonical monitoring detail | + +## Operator Surface Contract *(mandatory when operator-facing surfaces are changed)* + +| Surface | Primary Persona | Decision / Operator Action Supported | Surface Type | Primary Operator Question | Default-visible Information | Diagnostics-only Information | Status Dimensions Used | Mutation Scope | Primary Actions | Dangerous Actions | +|---|---|---|---|---|---|---|---|---|---|---| +| Tenant-scoped provider-backed start surfaces | Tenant operator or manager | Decide whether to start remote provider work now | Detail / launch affordance | Can this operation start now, and if not, what should I do next? | Start result, short reason, next step, existing-run path where relevant | Low-level provider diagnostics, raw failure context, deeper connection detail | start eligibility, dedupe, concurrency, actionability | Microsoft tenant and TenantPilot run creation when accepted | Start operation, open existing run, open resolution surface | none added | +| Provider-connection resource surfaces | Tenant manager or owner | Resolve connection readiness and retry blocked work | List/detail | Is this connection ready for the operation I am trying to start? | Start result, reason, next step, connection identity | Historical diagnostics, low-level provider context | readiness, actionability | TenantPilot only until an operation is accepted | Inspect connection, run connection-scoped checks, retry action | Existing destructive connection actions unchanged | +| Onboarding provider step | Tenant manager or onboarding operator | Continue onboarding or stop to resolve provider readiness | Wizard step | Can onboarding proceed with the next provider-backed action? | Start result, reason, next step, safe continuation or resolution path | Deep provider diagnostics, monitoring drill-in | readiness, actionability, progress continuity | TenantPilot onboarding flow and remote provider work when accepted | Continue onboarding, open resolution path | none added | +| Canonical provider-backed operation run detail | Tenant operator, workspace operator, support reviewer | Diagnose accepted provider-backed work after it started | Detail | What happened to the accepted operation, and what should happen next? | Human-readable outcome, short reason, next step, scope identity | Raw payload context, exception detail, low-level provider metadata | execution outcome, completeness, actionability | none | Open related target, inspect diagnostics | none added | + +## Proportionality Review *(mandatory when structural complexity is introduced)* + +- **New source of truth?**: no +- **New persisted entity/table/artifact?**: no +- **New abstraction?**: yes +- **New enum/state/reason family?**: no +- **New cross-domain UI framework/taxonomy?**: no +- **Current operator problem**: Operators cannot trust provider-backed start behavior because equivalent prerequisite and concurrency problems are surfaced at different times and in different languages depending on the action host. +- **Existing structure is insufficient because**: The current split between one guarded start path and many legacy local start paths creates inconsistent operator truth and inconsistent queue behavior across the same class of remote actions. +- **Narrowest correct implementation**: Reuse the existing canonical start-gate pattern and extend it to all current operator-triggered provider-backed starts, with one shared result-presentation contract and no new provider-domain framework. +- **Ownership cost**: One broader start-gate contract, one shared result-presentation contract, and focused regression coverage across covered action hosts and monitoring surfaces. +- **Alternative intentionally rejected**: Per-action preflight fixes on each surface. That would look cheaper short term but would preserve multiple provider-start dialects and duplicate concurrency logic in local action handlers. +- **Release truth**: Current-release truth. This feature closes a present operator and monitoring trust gap on already-shipped provider-backed workflows. + +## Testing / Lane / Runtime Impact *(mandatory for runtime behavior changes)* + +- **Test purpose / classification**: Feature +- **Validation lane(s)**: fast-feedback, confidence +- **Why this classification and these lanes are sufficient**: The change is proven by operator-visible start behavior, queue-accept semantics, dedupe and scope busy outcomes, and monitoring/notification alignment on existing action hosts. Focused feature coverage is the narrowest sufficient proof. +- **New or expanded test families**: Expanded action-surface feature coverage for tenant start surfaces, provider-connection surfaces, onboarding, and provider-backed run/notification alignment. Limited focused unit coverage may support start-result mapping, but the proving purpose remains feature-level. +- **Fixture / helper cost impact**: Moderate. Tests need provider connection state, tenant membership, action capability context, and active-run fixtures, but can reuse existing workspace and tenant setup. +- **Heavy-family visibility / justification**: none +- **Special surface test profile**: standard-native-filament +- **Standard-native relief or required special coverage**: Standard admin-surface feature coverage is sufficient; no browser or heavy-governance lane is required for the first slice. +- **Reviewer handoff**: Reviewers must confirm that missing prerequisites and concurrency collisions are caught before queue acceptance, that covered start surfaces all use the same result vocabulary, and that accepted runs still follow the canonical monitoring feedback contract. +- **Budget / baseline / trend impact**: Low-to-moderate increase in feature assertions around start surfaces and provider-backed run outcomes; no new heavy cost center expected. +- **Escalation needed**: none +- **Active feature PR close-out entry**: Guardrail +- **Planned validation commands**: `cd apps/platform && ./vendor/bin/sail artisan test --compact --filter=ProviderBackedActionStart`; `cd apps/platform && ./vendor/bin/sail artisan test --compact --filter=ProviderConnection`; `cd apps/platform && ./vendor/bin/sail artisan test --compact --filter=OperationRun` + +## User Scenarios & Testing *(mandatory)* + +### User Story 1 - Block Before Queue (Priority: P1) + +An operator starts a provider-backed action and needs the product to stop immediately when the operation cannot legitimately start, instead of silently queueing work that only fails later. + +**Why this priority**: This is the direct trust and workflow problem the spec exists to solve. If preventable failures still appear only as later failed runs, the spec has not delivered its core value. + +**Independent Test**: Can be fully tested by triggering covered actions under missing-connection, unusable-access, and active-run-conflict conditions and verifying that the operator receives an immediate blocked, deduped, or scope busy result before any background work is accepted. + +**Acceptance Scenarios**: + +1. **Given** a covered provider-backed action has no usable provider connection or access, **When** an operator starts it, **Then** the product blocks the start immediately and shows the cause and next step before any background work begins. +2. **Given** an equivalent action is already active for the same tenant or scope, **When** an operator starts the same action again, **Then** the product returns a deduped or scope busy result and points the operator to the existing work instead of queueing a duplicate. + +--- + +### User Story 2 - Same Start Semantics on Every Covered Surface (Priority: P2) + +An operator encounters the same provider problem from different action surfaces and needs the product to describe it in the same way every time. + +**Why this priority**: Shared start semantics are the leverage of the spec. Without cross-surface consistency, the product keeps teaching a different operational language per action host. + +**Independent Test**: Can be fully tested by triggering the same blocked or allowed scenario from tenant surfaces, provider-connection surfaces, and onboarding, then verifying that all of them return the same result categories and next-step structure. + +**Acceptance Scenarios**: + +1. **Given** the same blocked prerequisite appears from a tenant action and from onboarding, **When** the operator starts both actions, **Then** both surfaces use the same outcome category and the same next-step pattern. +2. **Given** the same action can start from a tenant surface and a provider-connection surface, **When** the operator starts it from either place, **Then** both surfaces confirm acceptance using the same queued-start pattern and the same run-link semantics. + +--- + +### User Story 3 - Keep Monitoring Truthful After Accepted Work (Priority: P3) + +An operator starts provider-backed work successfully and later needs monitoring and notifications to explain accepted work in the same human-readable language family used on the start surface. + +**Why this priority**: Start-surface trust must carry through to monitoring. Otherwise the product still feels split between action UX and run UX. + +**Independent Test**: Can be fully tested by accepting covered work, letting it finish in successful or failed terminal states, and verifying that run detail and terminal notifications preserve the same translated problem and next-step direction without pretending a preflight block was an execution failure. + +**Acceptance Scenarios**: + +1. **Given** a provider-backed action was accepted and later fails for execution-time reasons, **When** the operator opens run detail, **Then** the page explains the dominant problem and next step using the same humanized language family as the start surface. +2. **Given** preflight prevented a covered action from starting, **When** the operator later checks monitoring, **Then** there is no misleading ordinary failed run implying that remote work actually executed. + +### Edge Cases + +- Membership or capability denial must retain 404 and 403 semantics and must not be flattened into generic blocked-prerequisite copy. +- A provider connection can change after click time; accepted work must remain bound to the selected connection identity or record that identity before execution continues. +- Legacy operator-triggered actions that still rely on implicit provider resolution remain in scope for start gating even before full provider-connection normalization lands. +- Scheduled or system-triggered provider work is out of scope for immediate click-time feedback, but any shared reason language reused by monitoring must stay aligned with the covered start contract. +- Busy, blocked, and degraded follow-up conditions can overlap conceptually; the operator surface must lead with one dominant result and one next step instead of several equal-weight warnings. + +## Requirements *(mandatory)* + +**Constitution alignment (required):** This feature changes how existing provider-backed remote work is admitted to execution. It does not introduce new remote domains or new contract-registry object types. All underlying provider calls remain on the existing contract path. The gate runs before remote work is accepted and must preserve tenant isolation, run observability for accepted work, and focused regression coverage. + +**Constitution alignment (PROP-001 / ABSTR-001 / PERSIST-001 / STATE-001 / BLOAT-001):** The feature adds one shared start-gate expansion and one shared result-presentation contract because the operator problem is already cross-surface and cannot be solved safely with local action copy. No new persisted entity, no second source of truth, and no new top-level operation state family are introduced. + +**Constitution alignment (TEST-GOV-001):** Proof stays in focused feature coverage over existing action hosts and monitoring surfaces. No browser or heavy-governance family is added. Any helper introduced for provider start fixtures must keep provider, workspace, membership, and active-run context explicit rather than default. + +**Constitution alignment (OPS-UX):** Accepted provider-backed starts continue to use the three-surface feedback contract: queued intent feedback, active progress surfaces, and one terminal database notification. Preflight-blocked starts do not emit accepted-start feedback and must not masquerade as ordinary execution-failed runs. Any accepted run continues to use the canonical run-transition service and numeric summary-count rules. + +**Constitution alignment (RBAC-UX):** Covered start surfaces live on the tenant/admin plane and the canonical monitoring plane. Non-members or non-entitled viewers remain 404. Established members missing the required capability remain 403. Server-side authorization remains authoritative for every covered start action, and preflight guidance must never leak another tenant's connection or active-run existence. + +**Constitution alignment (OPS-EX-AUTH-001):** No authentication-handshake exception is used. This feature does not introduce synchronous outbound provider work on monitoring or auth routes. + +**Constitution alignment (BADGE-001):** Start-result and run-outcome semantics remain centralized. Covered surfaces must not invent local status mappings for blocked, deduped, scope busy, or accepted provider-backed starts. + +**Constitution alignment (UI-FIL-001):** Covered surfaces continue to use native admin actions, shared notifications, and shared monitoring detail primitives. The feature standardizes action feedback and explanation order without introducing custom local status widgets. + +**Constitution alignment (UI-NAMING-001):** The target objects remain the existing operation nouns such as sync, restore, capture, generate, or check. The canonical start-result model remains `accepted`, `deduped`, `scope busy`, and `blocked`, while operator-facing wording maps those outcomes to `queued`, `already running`, `scope busy`, and `blocked`. That mapping and next-step guidance must stay aligned across buttons, modals, run links, notifications, and monitoring detail. + +**Canonical vocabulary mapping:** The shared start-result model uses the internal outcomes `accepted`, `deduped`, `scope busy`, and `blocked`. Operator-facing wording presents those outcomes respectively as `queued`, `already running`, `scope busy`, and `blocked`. + +**Constitution alignment (DECIDE-001):** Tenant-scoped start actions and onboarding remain the primary decision moments because they determine whether remote work begins. Provider-connection surfaces remain supporting context for resolving blocked starts. Canonical run detail remains the diagnostic drill-in after work was actually accepted. + +**Constitution alignment (UI-CONST-001 / UI-SURF-001 / ACTSURF-001 / UI-HARD-001 / UI-EX-001 / UI-REVIEW-001 / HDR-001):** The feature does not add a new inspect model or new primary pages. Existing action hosts keep navigation separate from mutation, secondary navigation stays grouped or contextual, destructive actions remain in their current danger placements, and run detail remains the canonical diagnostic destination for accepted work. + +**Constitution alignment (ACTSURF-001 - action hierarchy):** Covered surfaces may standardize start-result feedback, but they must not mix navigation, mutation, and remediation into an unstructured catch-all. Existing start actions remain the primary action. Existing run links or resolution links remain secondary and contextual. + +### Functional Requirements + +- **FR-216-001**: Every current operator-triggered provider-backed action that can accept remote provider work MUST pass through one canonical preflight-and-dispatch path before background execution is admitted. +- **FR-216-002**: The canonical start path MUST use one shared result model with exactly four canonical outcomes: accepted, deduped, scope busy, and blocked. Operator-facing wording MUST present those outcomes consistently as queued, already running, scope busy, and blocked. +- **FR-216-003**: Missing or unusable provider access, missing required permissions, non-operable tenant state, and equivalent start-preventing conditions MUST be detected before queue admission on all covered start surfaces. +- **FR-216-004**: A covered action that is blocked during preflight MUST not silently queue work and later appear as an ordinary execution-failed run. +- **FR-216-005**: Deduped and scope busy outcomes MUST be decided at click time for covered operations and MUST direct the operator to the existing work or the correct resolution path. +- **FR-216-006**: Accepted starts MUST bind to a specific provider connection identity and carry that identity into accepted-work context so later monitoring and notifications can explain which connection the work used. +- **FR-216-007**: Covered starts MUST use dispatch-time conflict protection so two conflicting provider-backed operations for the same protected scope cannot both be accepted at once. +- **FR-216-008**: Covered start surfaces MUST render the shared start outcomes through one shared presentation contract rather than action-local conditional copy. +- **FR-216-009**: The shared presentation contract MUST preserve each action's domain verb and target object while keeping the outcome vocabulary and next-step structure consistent across all covered start surfaces. +- **FR-216-010**: Terminal notifications and canonical run detail for accepted provider-backed work MUST use the same translated problem and next-step direction as the covered start surfaces whenever they are explaining the same problem class. +- **FR-216-011**: Existing guarded provider-backed starts that already use the canonical preflight pattern MUST migrate to the shared presentation contract without losing any current protection. +- **FR-216-012**: The first implementation slice MUST cover all current operator-triggered provider-backed starts reachable from tenant-scoped surfaces, provider-connection surfaces, and onboarding. +- **FR-216-013**: Scheduled or system-triggered provider operations are out of scope for click-time UX. In scope for this feature is only that Monitoring-side shared reason vocabulary and initiator-aware notification behavior for those runs remain compatible with the covered start contract where the same problem class is shown. +- **FR-216-014**: The feature MUST not introduce a second provider-start framework, a parallel queue-admission path, or new per-action local preflight logic for covered surfaces. +- **FR-216-015**: Monitoring and audit surfaces MUST continue to distinguish between work that was actually accepted and executed versus work that was prevented from starting. +- **FR-216-016**: Covered start and monitoring surfaces MUST preserve deny-as-not-found tenant isolation and must not leak other tenants' connection readiness, active-run existence, or provider identity through start-result messaging. + +## UI Action Matrix *(mandatory when Filament is changed)* + +If this feature adds/modifies any Filament Resource / RelationManager / Page, fill out the matrix below. + +For each surface, list the exact action labels, whether they are destructive (confirmation? typed confirmation?), +RBAC gating (capability + enforcement helper), whether the mutation writes an audit log, and any exemption or exception used. + +| 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 | +|---|---|---|---|---|---|---|---|---|---|---| +| Tenant-scoped provider-backed start surfaces | Existing tenant detail pages and tenant-scoped resource pages that already expose provider-backed starts | Existing start actions such as sync, restore, capture, generate, or verify remain; no new header action is introduced by this spec | Existing inspect model remains unchanged on each host surface | Existing safe start affordances remain; no new visible destructive row action is added | Existing grouped bulk actions remain unchanged | Existing empty-state guidance remains unchanged | Existing start actions continue where already present | Existing create and edit flows remain unchanged | Existing operation-start and accepted-run audit behavior remains authoritative | This spec standardizes preflight result handling, not action inventory | +| Provider-connection resource surfaces | Existing provider-connection list and detail surfaces | Existing connection-management actions remain; connection-scoped checks adopt the shared start-result pattern where relevant | Full-row click or explicit inspect behavior remains unchanged | Existing safe actions such as inspect or connection health checks remain; no new destructive row action is added | Existing grouped bulk actions remain unchanged | Existing add-connection CTA remains unchanged | Existing connection-scoped start actions continue where already present | Existing create and edit flows remain unchanged | Existing connection lifecycle and accepted-run audit behavior remains authoritative | No connection-label redesign is introduced here | +| Onboarding provider step | Existing onboarding wizard step that triggers provider-backed verification or follow-up work | none added | n/a | n/a | none | Existing continue/setup CTA remains | Existing step actions remain the only primary controls | Existing save/continue flow remains | Existing onboarding audit behavior remains authoritative | The step adopts the shared blocked, deduped, scope busy, and accepted semantics | +| Canonical provider-backed operation run detail | Existing canonical monitoring detail surface | no new header action | Existing run detail open model remains canonical | none added | none | n/a | Existing related navigation remains | n/a | Existing run audit behavior remains authoritative | Detail hierarchy changes only insofar as it must stay aligned with the shared start-result language | + +### Key Entities *(include if feature involves data)* + +- **Provider-backed Start Surface**: Any existing operator-facing action host that can admit remote provider work and therefore must answer whether the operation can start now. +- **Preflight Start Result**: The shared start outcome for a covered action, including the canonical results accepted, deduped, scope busy, or blocked plus the operator-facing mapping to queued, already running, scope busy, or blocked, the short reason, and the next-step direction. +- **Accepted Provider-backed Run Context**: The canonical accepted-work context that records scope identity, chosen provider connection, and the related run or resolution path once work is admitted. + +## Assumptions & Dependencies + +- Provider Connection Resolution Normalization remains a soft dependency. This spec may use bridge behavior where current operator-triggered starts still rely on legacy provider-resolution internals, but accepted work must still record the chosen connection identity consistently. +- Shared outcome vocabulary and translated next-step guidance from the operator-truth lane are available to extend rather than re-invent. +- No new provider domain, no platform-wide operation naming rewrite, and no provider-connection UX relabeling are pulled into this scope. +- Existing canonical monitoring detail and terminal notification behavior for accepted runs remain authoritative and are aligned, not replaced. + +## Success Criteria *(mandatory)* + +### Measurable Outcomes + +- **SC-216-001**: In regression coverage for all covered start surfaces, 100% of seeded missing-prerequisite and scope-conflict scenarios are surfaced to the operator before any background work is accepted. +- **SC-216-002**: In release review across the covered start surfaces, operators encounter exactly one shared public start vocabulary: queued, already running, scope busy, and blocked. +- **SC-216-003**: In concurrent-start regression scenarios for the same protected scope, at most one provider-backed operation is accepted and all additional attempts are resolved as deduped or scope busy before queue admission. +- **SC-216-004**: In acceptance review, an operator can identify the next action for a blocked covered start within 10 seconds without opening low-level diagnostics. +- **SC-216-005**: Equivalent prerequisite problems no longer surface as immediate blocked guidance on one covered action and only as an after-the-fact ordinary failed run on another covered action. +- **SC-216-006**: In regression coverage for scheduled or system-triggered provider-backed runs that reuse shared reason translation, Monitoring preserves the same public wording for the same problem class without emitting initiator-only completion UX meant for click-time starts. \ No newline at end of file diff --git a/specs/216-provider-dispatch-gate/tasks.md b/specs/216-provider-dispatch-gate/tasks.md new file mode 100644 index 00000000..46104e47 --- /dev/null +++ b/specs/216-provider-dispatch-gate/tasks.md @@ -0,0 +1,242 @@ +# Tasks: Provider-Backed Action Preflight and Dispatch Gate Unification + +**Input**: Design documents from `/specs/216-provider-dispatch-gate/` +**Prerequisites**: `plan.md` (required), `spec.md` (required for user stories), `research.md`, `data-model.md`, `contracts/`, `quickstart.md` + +**Tests**: Runtime behavior changes in this repo require Pest coverage. This feature keeps proof in the `fast-feedback` and `confidence` lanes with one supporting unit seam and focused feature coverage on the existing Filament and Livewire action hosts. + +## Phase 1: Setup (Shared Gate Scaffolding) + +**Purpose**: Introduce the bounded shared testing and presentation seams needed before touching operator-facing start hosts. + +- [x] T001 [P] Add failing unit coverage for newly covered operation types, explicit `provider_connection_id` binding, and onboarding bootstrap protected-scope admission in `apps/platform/tests/Unit/Providers/ProviderOperationStartGateTest.php` +- [x] T002 [P] Add failing unit coverage for shared accepted, deduped, scope busy, and blocked presentation behavior in `apps/platform/tests/Unit/Providers/ProviderOperationStartResultPresenterTest.php` +- [x] T003 [P] Create the bounded shared presenter for provider-backed start outcomes in `apps/platform/app/Support/OpsUx/ProviderOperationStartResultPresenter.php` + +--- + +## Phase 2: Foundational (Blocking Prerequisites) + +**Purpose**: Extend the canonical gate, reason translation, and shared Ops UX wiring that every user story depends on. + +**Critical**: No user story work should begin until this phase is complete. + +- [x] T004 Expand first-slice provider-backed operation definitions and dispatch callbacks in `apps/platform/app/Services/Providers/ProviderOperationRegistry.php` +- [x] T005 Update canonical gate and result plumbing for explicit `provider_connection_id`, protected-scope conflict handling, and blocked-start truth in `apps/platform/app/Services/Providers/ProviderOperationStartGate.php` and `apps/platform/app/Services/Providers/ProviderOperationStartResult.php` +- [x] T006 [P] Extend provider blocker translation and next-step mapping for the shared start contract in `apps/platform/app/Support/Providers/ProviderNextStepsRegistry.php` and `apps/platform/app/Support/ReasonTranslation/ReasonPresenter.php` +- [x] T007 [P] Wire the shared presenter into existing toast and canonical run-link primitives in `apps/platform/app/Support/OpsUx/OperationUxPresenter.php`, `apps/platform/app/Support/OpsUx/ProviderOperationStartResultPresenter.php`, and `apps/platform/app/Support/OperationRunLinks.php` +- [x] T008 [P] Keep capability and alias-aware operation metadata aligned for the covered start types in `apps/platform/app/Support/Operations/OperationRunCapabilityResolver.php` and `apps/platform/app/Support/OperationCatalog.php` + +**Checkpoint**: The canonical gate, presenter seam, and shared reason vocabulary are ready; user story work can now proceed. + +--- + +## Phase 3: User Story 1 - Block Before Queue (Priority: P1) + +**Goal**: Every covered operator-triggered provider-backed start resolves blocked, deduped, and scope busy outcomes before queue admission. + +**Independent Test**: Trigger covered starts under missing-connection, unusable-access, same-operation active-run, and conflicting active-run conditions and verify the operator gets an immediate blocked, deduped, or scope busy result before any background work is accepted. + +### Tests for User Story 1 + +- [x] T009 [P] [US1] Add covered tenant and provider-connection gate-admission coverage for blocked, deduped, scope busy, and accepted starts in `apps/platform/tests/Feature/Tenants/TenantProviderBackedActionStartTest.php` and `apps/platform/tests/Feature/ProviderConnections/ProviderDispatchGateStartSurfaceTest.php` +- [x] T010 [P] [US1] Add restore and directory gate-admission coverage for explicit connection pinning and no-queue-on-block behavior in `apps/platform/tests/Feature/Restore/RestoreRunProviderStartTest.php` and `apps/platform/tests/Feature/Directory/ProviderBackedDirectoryStartTest.php` +- [x] T011 [P] [US1] Add onboarding verification and bootstrap gate-admission coverage, including protected-scope serialization, in `apps/platform/tests/Feature/Workspaces/ManagedTenantOnboardingProviderStartTest.php` + +### Implementation for User Story 1 + +- [x] T012 [P] [US1] Migrate restore execute to canonical gate admission and explicit connection-aware queued execution in `apps/platform/app/Filament/Resources/RestoreRunResource.php` and `apps/platform/app/Jobs/ExecuteRestoreRunJob.php` +- [x] T013 [P] [US1] Migrate directory sync action hosts and queued jobs to canonical gate admission and explicit connection-aware execution in `apps/platform/app/Filament/Resources/EntraGroupResource/Pages/ListEntraGroups.php`, `apps/platform/app/Filament/Resources/TenantResource.php`, `apps/platform/app/Services/Directory/EntraGroupSyncService.php`, `apps/platform/app/Services/Directory/RoleDefinitionsSyncService.php`, `apps/platform/app/Jobs/EntraGroupSyncJob.php`, and `apps/platform/app/Jobs/SyncRoleDefinitionsJob.php` +- [x] T014 [P] [US1] Migrate onboarding bootstrap to canonical gate admission and sequential protected-scope execution in `apps/platform/app/Filament/Pages/Workspaces/ManagedTenantOnboardingWizard.php` +- [x] T015 [US1] Verify existing gated tenant and provider-connection starts keep explicit connection context and no-queue-on-block behavior in `apps/platform/app/Services/Verification/StartVerification.php`, `apps/platform/app/Filament/Resources/TenantResource.php`, `apps/platform/app/Filament/Widgets/Tenant/TenantVerificationReport.php`, and `apps/platform/app/Filament/Resources/ProviderConnectionResource.php` +- [x] T016 [US1] Run the US1 focused verification flow documented in `specs/216-provider-dispatch-gate/quickstart.md` + +**Checkpoint**: User Story 1 is independently deliverable as the core block-before-queue hardening slice. + +--- + +## Phase 4: User Story 2 - Same Start Semantics on Every Covered Surface (Priority: P2) + +**Goal**: Covered tenant, provider-connection, restore, directory, and onboarding surfaces use one shared public start vocabulary and next-step structure. + +**Independent Test**: Trigger the same blocked and accepted scenarios from tenant surfaces, provider-connection surfaces, onboarding, restore, and directory starts and verify all of them render the same queued, already running, scope busy, and blocked wording with the same run-link and next-step pattern. + +### Tests for User Story 2 + +- [x] T017 [P] [US2] Add cross-surface queued, already running, scope busy, and blocked vocabulary parity assertions for tenant and provider-connection hosts in `apps/platform/tests/Feature/Tenants/TenantProviderBackedActionStartTest.php` and `apps/platform/tests/Feature/ProviderConnections/ProviderDispatchGateStartSurfaceTest.php` +- [x] T018 [P] [US2] Add onboarding, restore, and directory shared-language plus 404 and 403 assertions in `apps/platform/tests/Feature/Workspaces/ManagedTenantOnboardingProviderStartTest.php`, `apps/platform/tests/Feature/Restore/RestoreRunProviderStartTest.php`, `apps/platform/tests/Feature/Directory/ProviderBackedDirectoryStartTest.php`, `apps/platform/tests/Feature/Onboarding/OnboardingRbacSemanticsTest.php`, and `apps/platform/tests/Feature/Filament/RestoreRunUiEnforcementTest.php` +- [x] T019 [P] [US2] Update Filament action-surface contract coverage for the affected start hosts in `apps/platform/tests/Feature/Guards/ActionSurfaceContractTest.php` + +### Implementation for User Story 2 + +- [x] T020 [US2] Replace tenant and provider-connection local start-result branching with the shared presenter and public queued or already running vocabulary in `apps/platform/app/Services/Verification/StartVerification.php`, `apps/platform/app/Filament/Resources/TenantResource.php`, `apps/platform/app/Filament/Widgets/Tenant/TenantVerificationReport.php`, `apps/platform/app/Filament/Resources/ProviderConnectionResource.php`, and `apps/platform/app/Filament/Resources/ProviderConnectionResource/Pages/EditProviderConnection.php` +- [x] T021 [P] [US2] Apply the shared presenter and safe continuation wording to onboarding verification and bootstrap surfaces in `apps/platform/app/Filament/Pages/Workspaces/ManagedTenantOnboardingWizard.php` +- [x] T022 [US2] Apply the shared presenter and aligned `Verb + Object` copy plus queued or already running wording to restore and directory start surfaces in `apps/platform/app/Filament/Resources/RestoreRunResource.php`, `apps/platform/app/Filament/Resources/EntraGroupResource/Pages/ListEntraGroups.php`, and `apps/platform/app/Filament/Resources/TenantResource.php` +- [x] T023 [US2] Run the US2 cross-surface verification flow documented in `specs/216-provider-dispatch-gate/quickstart.md` + +**Checkpoint**: User Stories 1 and 2 both work independently, with start gating and operator copy fully aligned across the covered action hosts. + +--- + +## Phase 5: User Story 3 - Keep Monitoring Truthful After Accepted Work (Priority: P3) + +**Goal**: Accepted provider-backed work preserves the same translated problem and next-step direction in Monitoring and terminal notifications without turning prevented starts into ordinary execution failures. + +**Independent Test**: Accept covered provider-backed work, let it finish in successful or failed terminal states, and verify canonical run detail and terminal notifications stay aligned with the start contract while blocked starts remain distinguishable from executed work. + +### Tests for User Story 3 + +- [x] T024 [P] [US3] Add provider-backed run-detail reason-alignment, canonical view-run-link coverage, and scheduled or system-run shared-vocabulary compatibility assertions in `apps/platform/tests/Feature/Operations/ProviderBackedRunReasonAlignmentTest.php` and `apps/platform/tests/Feature/OpsUx/CanonicalViewRunLinksTest.php` +- [x] T025 [P] [US3] Add blocked-vs-executed monitoring truth, initiator-only no-queued-db-notification, and non-initiator compatibility assertions in `apps/platform/tests/Feature/Operations/OperationRunBlockedExecutionPresentationTest.php`, `apps/platform/tests/Feature/OpsUx/Constitution/JobDbNotificationGuardTest.php`, and `apps/platform/tests/Feature/OpsUx/NoQueuedDbNotificationsTest.php` + +### Implementation for User Story 3 + +- [x] T026 [P] [US3] Align terminal notification translation, initiator-only delivery, and canonical view-run actions with the shared start contract in `apps/platform/app/Notifications/OperationRunCompleted.php`, `apps/platform/app/Support/OpsUx/OperationUxPresenter.php`, and `apps/platform/app/Support/OperationRunLinks.php` +- [x] T027 [P] [US3] Align canonical Monitoring run detail with accepted provider-backed start semantics and scheduled or system-run shared reason reuse in `apps/platform/app/Filament/Resources/OperationRunResource.php` and `apps/platform/app/Filament/Pages/Operations/TenantlessOperationRunViewer.php` +- [x] T028 [P] [US3] Keep provider-backed jobs on service-owned lifecycle transitions and flat summary-count discipline in `apps/platform/app/Jobs/ExecuteRestoreRunJob.php`, `apps/platform/app/Jobs/EntraGroupSyncJob.php`, `apps/platform/app/Jobs/SyncRoleDefinitionsJob.php`, and `apps/platform/app/Services/OperationRunService.php` +- [x] T029 [US3] Run the US3 monitoring and notification verification flow documented in `specs/216-provider-dispatch-gate/quickstart.md` + +**Checkpoint**: All user stories are independently functional and monitoring truth stays aligned with the accepted-start contract. + +--- + +## Phase 6: Polish & Cross-Cutting Concerns + +**Purpose**: Finalize lane metadata, contract notes, and close-out proof across all stories. + +- [x] T030 [P] Add and register a route-bounded first-slice direct-dispatch bypass guard in `apps/platform/tests/Feature/Guards/ProviderDispatchGateCoverageTest.php`, `apps/platform/tests/Support/TestLaneManifest.php`, and `apps/platform/tests/Feature/Guards/TestLaneManifestTest.php` +- [x] T031 [P] Refresh the logical start contract and verification steps after implementation in `specs/216-provider-dispatch-gate/contracts/provider-dispatch-gate.logical.openapi.yaml` and `specs/216-provider-dispatch-gate/quickstart.md` +- [x] T032 Run formatting and the final focused Pest command set documented in `specs/216-provider-dispatch-gate/quickstart.md` +- [x] T033 Record the final lane outcome, route-bounded covered-surface audit, guardrail close-out, and `document-in-feature` test-governance disposition in `specs/216-provider-dispatch-gate/plan.md` + +--- + +## Test Governance Checklist + +- [x] Lane assignment is named and is the narrowest sufficient proof for the changed behavior. +- [x] New or changed tests stay in the smallest honest family, and any heavy-governance or browser addition is explicit. +- [x] Shared helpers, factories, seeds, fixtures, and context defaults stay cheap by default; any widening is isolated or documented. +- [x] Planned validation commands cover the change without pulling in unrelated lane cost. +- [x] The declared surface test profile or `standard-native-filament` relief is explicit. +- [x] Any material budget, baseline, trend, or escalation note is recorded in the active spec or PR. + +## Dependencies & Execution Order + +### Phase Dependencies + +- **Setup (Phase 1)**: No dependencies; can start immediately. +- **Foundational (Phase 2)**: Depends on Setup completion and blocks all user stories. +- **User Story 1 (Phase 3)**: Depends on Foundational completion. +- **User Story 2 (Phase 4)**: Depends on Foundational completion and should follow the stable gate adoption from User Story 1 on shared action hosts. +- **User Story 3 (Phase 5)**: Depends on Foundational completion and on the shared start vocabulary being stable enough for Monitoring parity. +- **Polish (Phase 6)**: Depends on all desired user stories being complete. + +### User Story Dependencies + +- **User Story 1 (P1)**: This is the MVP and should ship first. +- **User Story 2 (P2)**: Conceptually independent after Phase 2, but it reuses the shared presenter and touches some of the same action hosts stabilized in User Story 1. +- **User Story 3 (P3)**: Conceptually independent after Phase 2, but it must preserve parity with the accepted-start contract implemented by User Stories 1 and 2. + +### Within Each User Story + +- Tests should be written and fail before the corresponding implementation tasks. +- Shared gate and presenter changes must land before any surface-specific adoption task uses them. +- For shared files such as `apps/platform/app/Filament/Resources/TenantResource.php` and `apps/platform/app/Filament/Pages/Workspaces/ManagedTenantOnboardingWizard.php`, serialize edits even when the surrounding story tasks are otherwise parallelizable. +- Finish each story's verification task before moving to the next priority when working sequentially. + +### Parallel Opportunities + +- **Setup**: `T001`, `T002`, and `T003` can run in parallel. +- **Foundational**: `T006`, `T007`, and `T008` can run in parallel after `T004` and `T005` settle the canonical gate contract. +- **US1 tests**: `T009`, `T010`, and `T011` can run in parallel. +- **US1 implementation**: `T012`, `T013`, and `T014` can run in parallel; `T015` should follow once the legacy-start migrations are in place. +- **US2 tests**: `T017`, `T018`, and `T019` can run in parallel. +- **US2 implementation**: `T021` can run in parallel with `T020` or `T022`, but `T020` and `T022` should serialize because both touch `apps/platform/app/Filament/Resources/TenantResource.php`. +- **US3**: `T024` and `T025` can run in parallel, and `T026`, `T027`, and `T028` can run in parallel once the test expectations are fixed. +- **Polish**: `T030` and `T031` can run in parallel before `T032` and `T033`. + +--- + +## Parallel Example: User Story 1 + +```bash +# Run US1 coverage in parallel: +T009 apps/platform/tests/Feature/Tenants/TenantProviderBackedActionStartTest.php and apps/platform/tests/Feature/ProviderConnections/ProviderDispatchGateStartSurfaceTest.php +T010 apps/platform/tests/Feature/Restore/RestoreRunProviderStartTest.php and apps/platform/tests/Feature/Directory/ProviderBackedDirectoryStartTest.php +T011 apps/platform/tests/Feature/Workspaces/ManagedTenantOnboardingProviderStartTest.php + +# Then split the non-overlapping legacy start migrations: +T012 apps/platform/app/Filament/Resources/RestoreRunResource.php and apps/platform/app/Jobs/ExecuteRestoreRunJob.php +T013 apps/platform/app/Filament/Resources/EntraGroupResource/Pages/ListEntraGroups.php, apps/platform/app/Services/Directory/EntraGroupSyncService.php, apps/platform/app/Services/Directory/RoleDefinitionsSyncService.php, apps/platform/app/Jobs/EntraGroupSyncJob.php, and apps/platform/app/Jobs/SyncRoleDefinitionsJob.php +T014 apps/platform/app/Filament/Pages/Workspaces/ManagedTenantOnboardingWizard.php +``` + +--- + +## Parallel Example: User Story 2 + +```bash +# Run US2 parity and contract assertions in parallel: +T017 apps/platform/tests/Feature/Tenants/TenantProviderBackedActionStartTest.php and apps/platform/tests/Feature/ProviderConnections/ProviderDispatchGateStartSurfaceTest.php +T018 apps/platform/tests/Feature/Workspaces/ManagedTenantOnboardingProviderStartTest.php, apps/platform/tests/Feature/Restore/RestoreRunProviderStartTest.php, apps/platform/tests/Feature/Directory/ProviderBackedDirectoryStartTest.php, apps/platform/tests/Feature/Onboarding/OnboardingRbacSemanticsTest.php, and apps/platform/tests/Feature/Filament/RestoreRunUiEnforcementTest.php +T019 apps/platform/tests/Feature/Guards/ActionSurfaceContractTest.php + +# Then split presenter adoption where files do not overlap: +T020 apps/platform/app/Services/Verification/StartVerification.php, apps/platform/app/Filament/Resources/TenantResource.php, apps/platform/app/Filament/Widgets/Tenant/TenantVerificationReport.php, apps/platform/app/Filament/Resources/ProviderConnectionResource.php, and apps/platform/app/Filament/Resources/ProviderConnectionResource/Pages/EditProviderConnection.php +T021 apps/platform/app/Filament/Pages/Workspaces/ManagedTenantOnboardingWizard.php +``` + +--- + +## Parallel Example: User Story 3 + +```bash +# Run US3 monitoring and notification assertions in parallel: +T024 apps/platform/tests/Feature/Operations/ProviderBackedRunReasonAlignmentTest.php and apps/platform/tests/Feature/OpsUx/CanonicalViewRunLinksTest.php +T025 apps/platform/tests/Feature/Operations/OperationRunBlockedExecutionPresentationTest.php, apps/platform/tests/Feature/OpsUx/Constitution/JobDbNotificationGuardTest.php, and apps/platform/tests/Feature/OpsUx/NoQueuedDbNotificationsTest.php + +# Then split the non-overlapping Monitoring and notification implementation: +T026 apps/platform/app/Notifications/OperationRunCompleted.php, apps/platform/app/Support/OpsUx/OperationUxPresenter.php, and apps/platform/app/Support/OperationRunLinks.php +T027 apps/platform/app/Filament/Resources/OperationRunResource.php and apps/platform/app/Filament/Pages/Operations/TenantlessOperationRunViewer.php +T028 apps/platform/app/Jobs/ExecuteRestoreRunJob.php, apps/platform/app/Jobs/EntraGroupSyncJob.php, apps/platform/app/Jobs/SyncRoleDefinitionsJob.php, and apps/platform/app/Services/OperationRunService.php +``` + +--- + +## Implementation Strategy + +### MVP First (User Story 1 Only) + +1. Complete Phase 1: Setup. +2. Complete Phase 2: Foundational. +3. Complete Phase 3: User Story 1. +4. Stop and validate with `T016`. +5. Demo or ship the block-before-queue hardening before layering shared copy and Monitoring parity on top. + +### Incremental Delivery + +1. Setup and Foundational establish the canonical gate, presenter seam, and shared blocker vocabulary. +2. Add User Story 1 and validate legacy start migration and protected-scope admission. +3. Add User Story 2 and validate cross-surface copy, next-step, and authorization consistency. +4. Add User Story 3 and validate Monitoring and terminal notification parity. +5. Finish with lane metadata, quickstart and contract refresh, formatting, and close-out proof. + +### Parallel Team Strategy + +With multiple developers: + +1. Complete Setup and Foundational together. +2. After Phase 2: + - Developer A: restore and directory start migration in `apps/platform/app/Filament/Resources/RestoreRunResource.php`, `apps/platform/app/Filament/Resources/EntraGroupResource/Pages/ListEntraGroups.php`, and related jobs. + - Developer B: onboarding migration and shared presenter adoption in `apps/platform/app/Filament/Pages/Workspaces/ManagedTenantOnboardingWizard.php`. + - Developer C: tenant and provider-connection shared presenter adoption plus Monitoring parity in `apps/platform/app/Filament/Resources/TenantResource.php`, `apps/platform/app/Filament/Resources/ProviderConnectionResource.php`, and `apps/platform/app/Filament/Resources/OperationRunResource.php`. +3. Serialize edits in `apps/platform/app/Filament/Resources/TenantResource.php` and `apps/platform/app/Filament/Pages/Workspaces/ManagedTenantOnboardingWizard.php` because multiple stories touch those files. + +--- + +## Notes + +- `[P]` marks tasks that can run in parallel once their prerequisites are satisfied and the files do not overlap. +- `[US1]`, `[US2]`, and `[US3]` map directly to the spec's independently testable user stories. +- The narrowest proving lane remains `fast-feedback` plus `confidence`; do not widen into browser or heavy-governance without explicit follow-up justification. +- Keep new fixtures and helpers opt-in so provider, workspace, membership, active-run, and onboarding draft context do not become expensive test defaults. \ No newline at end of file diff --git a/specs/219-finding-ownership-semantics/checklists/requirements.md b/specs/219-finding-ownership-semantics/checklists/requirements.md new file mode 100644 index 00000000..38636071 --- /dev/null +++ b/specs/219-finding-ownership-semantics/checklists/requirements.md @@ -0,0 +1,35 @@ +# Specification Quality Checklist: Finding Ownership Semantics Clarification + +**Purpose**: Validate specification completeness and quality before proceeding to planning +**Created**: 2026-04-20 +**Feature**: [spec.md](../spec.md) + +## Content Quality + +- [x] No implementation details (languages, frameworks, APIs) +- [x] Focused on user value and business needs +- [x] Written for non-technical stakeholders +- [x] All mandatory sections completed + +## Requirement Completeness + +- [x] No [NEEDS CLARIFICATION] markers remain +- [x] Requirements are testable and unambiguous +- [x] Success criteria are measurable +- [x] Success criteria are technology-agnostic (no implementation details) +- [x] All acceptance scenarios are defined +- [x] Edge cases are identified +- [x] Scope is clearly bounded +- [x] Dependencies and assumptions identified + +## Feature Readiness + +- [x] All functional requirements have clear acceptance criteria +- [x] User scenarios cover primary flows +- [x] Feature meets measurable outcomes defined in Success Criteria +- [x] No implementation details leak into specification + +## Notes + +- Validated against the existing findings domain contract: finding owner versus finding assignee versus exception owner. +- Scope remains intentionally narrow: no new queue model, capability split, persistence, or ownership framework was introduced. \ No newline at end of file diff --git a/specs/219-finding-ownership-semantics/contracts/finding-responsibility.openapi.yaml b/specs/219-finding-ownership-semantics/contracts/finding-responsibility.openapi.yaml new file mode 100644 index 00000000..ce7a2bca --- /dev/null +++ b/specs/219-finding-ownership-semantics/contracts/finding-responsibility.openapi.yaml @@ -0,0 +1,248 @@ +openapi: 3.1.0 +info: + title: TenantPilot Internal Finding Responsibility Contract + version: 1.0.0 + description: | + Internal review contract for Spec 219. + + These are operator-facing Filament surfaces, not public API endpoints. The document exists so + the feature has an explicit, reviewable contract under specs/.../contracts/. + +paths: + /admin/t/{tenant}/findings: + get: + summary: Tenant findings list with explicit responsibility semantics + parameters: + - $ref: '#/components/parameters/TenantPathParam' + responses: + '200': + description: Findings list rendered + content: + application/json: + schema: + $ref: '#/components/schemas/FindingListSurface' + /admin/t/{tenant}/findings/{finding}: + get: + summary: Finding detail with owner, assignee, and optional exception-owner context + parameters: + - $ref: '#/components/parameters/TenantPathParam' + - $ref: '#/components/parameters/FindingPathParam' + responses: + '200': + description: Finding detail rendered + content: + application/json: + schema: + $ref: '#/components/schemas/FindingDetailSurface' + /admin/t/{tenant}/findings/{finding}/responsibility: + post: + summary: Update finding owner and assignee semantics + description: Conceptual responsibility-update contract implemented by Filament action endpoints. + parameters: + - $ref: '#/components/parameters/TenantPathParam' + - $ref: '#/components/parameters/FindingPathParam' + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/ResponsibilityUpdateRequest' + responses: + '200': + description: Responsibility updated + content: + application/json: + schema: + $ref: '#/components/schemas/ResponsibilityUpdateResult' + '403': + description: Actor is an in-scope tenant member but lacks assignment capability + '404': + description: Tenant or finding is outside the actor scope + /admin/t/{tenant}/findings/{finding}/exception-request: + post: + summary: Request exception with explicitly separate exception owner + description: Conceptual exception-request contract implemented by Filament action endpoints. + parameters: + - $ref: '#/components/parameters/TenantPathParam' + - $ref: '#/components/parameters/FindingPathParam' + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/ExceptionRequestInput' + responses: + '200': + description: Exception request accepted + content: + application/json: + schema: + $ref: '#/components/schemas/ExceptionOwnershipBoundaryResult' + +components: + parameters: + TenantPathParam: + name: tenant + in: path + required: true + schema: + type: string + description: Tenant route identifier for tenant-scoped findings surfaces. + FindingPathParam: + name: finding + in: path + required: true + schema: + type: integer + description: Tenant-owned finding identifier. + + schemas: + UserReference: + type: object + additionalProperties: false + required: + - id + - display_name + properties: + id: + type: integer + display_name: + type: string + + ResponsibilityState: + type: string + description: Uses internal slugs for API and test contracts. The operator-facing UI label for `orphaned_accountability` is `orphaned accountability`. + enum: + - orphaned_accountability + - owned_unassigned + - assigned + + FindingResponsibilitySummary: + type: object + additionalProperties: false + required: + - finding_id + - workflow_status + - responsibility_state + properties: + finding_id: + type: integer + workflow_status: + type: string + owner: + allOf: + - $ref: '#/components/schemas/UserReference' + nullable: true + assignee: + allOf: + - $ref: '#/components/schemas/UserReference' + nullable: true + responsibility_state: + $ref: '#/components/schemas/ResponsibilityState' + accountability_gap: + type: boolean + exception_owner: + allOf: + - $ref: '#/components/schemas/UserReference' + nullable: true + description: Present only when a finding exception exists or is shown in current context. + + FindingListSurface: + type: object + additionalProperties: false + required: + - surface + - collection_route + - primary_inspect_model + - items + properties: + surface: + const: tenant_findings_list + collection_route: + const: /admin/t/{tenant}/findings + primary_inspect_model: + const: finding + items: + type: array + items: + $ref: '#/components/schemas/FindingResponsibilitySummary' + + FindingDetailSurface: + allOf: + - $ref: '#/components/schemas/FindingResponsibilitySummary' + - type: object + additionalProperties: false + required: + - surface + - detail_route + properties: + surface: + const: finding_detail + detail_route: + const: /admin/t/{tenant}/findings/{finding} + + ResponsibilityUpdateRequest: + type: object + additionalProperties: false + properties: + owner_user_id: + type: integer + nullable: true + description: Accountable owner for the finding outcome. + assignee_user_id: + type: integer + nullable: true + description: Active remediation assignee for the finding. + + ResponsibilityUpdateResult: + type: object + additionalProperties: false + required: + - change_classification + - responsibility + properties: + change_classification: + type: string + description: Uses explicit slugs for single-role clears and `owner_and_assignee` whenever both fields change in one update, including mixed set/clear combinations. + enum: + - owner_only + - assignee_only + - owner_and_assignee + - clear_owner + - clear_assignee + responsibility: + $ref: '#/components/schemas/FindingResponsibilitySummary' + + ExceptionRequestInput: + type: object + additionalProperties: false + required: + - owner_user_id + - request_reason + - review_due_at + properties: + owner_user_id: + type: integer + description: Owner of the exception artifact, not the finding owner. + request_reason: + type: string + review_due_at: + type: string + format: date-time + expires_at: + type: string + format: date-time + nullable: true + + ExceptionOwnershipBoundaryResult: + type: object + additionalProperties: false + required: + - finding_owner_preserved + - exception_owner + properties: + finding_owner_preserved: + type: boolean + const: true + exception_owner: + $ref: '#/components/schemas/UserReference' \ No newline at end of file diff --git a/specs/219-finding-ownership-semantics/data-model.md b/specs/219-finding-ownership-semantics/data-model.md new file mode 100644 index 00000000..61bbe8f9 --- /dev/null +++ b/specs/219-finding-ownership-semantics/data-model.md @@ -0,0 +1,121 @@ +# Data Model: Finding Ownership Semantics Clarification + +**Date**: 2026-04-20 +**Branch**: `219-finding-ownership-semantics` + +## Overview + +This feature introduces no new persisted entities. It clarifies responsibility semantics over existing finding and finding-exception records and adds one derived responsibility-state projection for operator-facing surfaces. + +## Entity: Finding + +**Represents**: A tenant-owned operational governance finding that moves through the findings workflow and may carry both accountable ownership and active remediation assignment. + +### Key Fields + +| Field | Type | Required | Notes | +|---|---|---|---| +| `id` | bigint | yes | Primary key | +| `workspace_id` | bigint | yes | Derived tenant ownership boundary | +| `tenant_id` | bigint | yes | Tenant isolation boundary | +| `status` | string | yes | Existing findings lifecycle state | +| `severity` | string | yes | Existing severity dimension | +| `owner_user_id` | bigint nullable | no | Accountable person for the finding outcome | +| `assignee_user_id` | bigint nullable | no | Active remediation executor / coordinator | +| `due_at` | datetime nullable | no | Existing SLA/follow-up deadline | +| `resolved_reason` | string nullable | no | Existing closure context | +| `closed_reason` | string nullable | no | Existing closure/governance context | + +### Relationships + +| Relationship | Target | Cardinality | Purpose | +|---|---|---|---| +| `tenant()` | `Tenant` | belongsTo | Tenant ownership and authorization | +| `ownerUser()` | `User` | belongsTo | Accountable owner | +| `assigneeUser()` | `User` | belongsTo | Active remediation assignee | +| `findingException()` | `FindingException` | hasOne | Optional exception artifact for accepted-risk governance | + +### Validation Rules + +- `owner_user_id` MAY be null. +- `assignee_user_id` MAY be null. +- If present, either user ID MUST reference a current member of the active tenant. +- Responsibility changes are allowed only on open findings, matching the current `FindingWorkflowService::assign()` rule. + +## Entity: FindingException + +**Represents**: A tenant-owned exception artifact attached to a finding when governance coverage is requested or granted. + +### Key Fields + +| Field | Type | Required | Notes | +|---|---|---|---| +| `id` | bigint | yes | Primary key | +| `finding_id` | bigint | yes | Owning finding | +| `tenant_id` | bigint | yes | Tenant isolation boundary | +| `owner_user_id` | bigint nullable | no | Accountable owner of the exception artifact, not of the finding itself | +| `status` | string | yes | Existing exception lifecycle state | +| `current_validity_state` | string nullable | no | Existing governance-validity dimension | +| `request_reason` | text | yes | Existing request context | + +### Relationships + +| Relationship | Target | Cardinality | Purpose | +|---|---|---|---| +| `finding()` | `Finding` | belongsTo | Parent finding context | +| `owner()` | `User` | belongsTo | Exception artifact owner | + +### Validation Rules + +- Exception-owner selection continues to use current tenant-member validation. +- Exception ownership MUST remain semantically distinct from finding ownership on all mixed-context surfaces. + +## Derived Projection: ResponsibilityState + +**Represents**: An operator-facing derived state computed from `owner_user_id` and `assignee_user_id` without new persistence. + +**Naming convention**: + +- Operator-facing UI label: `orphaned accountability` +- Internal derived-state and contract slug: `orphaned_accountability` + +### Derived Values + +| Derived State | Rule | Operator Meaning | +|---|---|---| +| `orphaned_accountability` | `owner_user_id == null` | No accountable owner is set. This remains true even if an assignee exists. | +| `owned_unassigned` | `owner_user_id != null && assignee_user_id == null` | Someone owns the outcome, but active remediation work is not assigned. | +| `assigned` | `owner_user_id != null && assignee_user_id != null` | Accountability and active remediation assignment are both set. | + +### Rendering Notes + +- If owner and assignee are the same user, the state remains `assigned`; the UI should show both roles satisfied without implying a data problem. +- If both are null, the finding still uses the slug `orphaned_accountability` and the visible label `orphaned accountability`. +- If assignee is present but owner is null, the finding remains `orphaned_accountability`; the UI may also show that remediation is assigned without accountable ownership. + +## Mutation Contract: ResponsibilityUpdate + +**Represents**: The input/output contract of the existing assignment action. + +### Input Shape + +| Field | Type | Required | Notes | +|---|---|---|---| +| `owner_user_id` | bigint nullable | no | Set, change, or clear finding owner | +| `assignee_user_id` | bigint nullable | no | Set, change, or clear finding assignee | + +### Behavioral Rules + +- The existing `FindingWorkflowService::assign()` method remains the mutation boundary. +- The service MUST continue to write both fields explicitly to the finding. +- Operator feedback and audit-facing wording should classify the result as `owner_only`, `assignee_only`, `clear_owner`, `clear_assignee`, or `owner_and_assignee` when both fields change in one update. + +## State and Lifecycle Impact + +This feature does not add a new lifecycle family. It overlays responsibility semantics on top of existing findings lifecycle states. + +| Existing Lifecycle State | Responsibility Impact | +|---|---| +| `new`, `triaged`, `in_progress`, `reopened`, `acknowledged` | Responsibility state is actionable and visible by default | +| `resolved`, `closed` | Responsibility remains historical context only | +| `risk_accepted` | Responsibility remains visible, but exception-owner context may also appear and must remain separate | \ No newline at end of file diff --git a/specs/219-finding-ownership-semantics/plan.md b/specs/219-finding-ownership-semantics/plan.md new file mode 100644 index 00000000..7f856397 --- /dev/null +++ b/specs/219-finding-ownership-semantics/plan.md @@ -0,0 +1,198 @@ +# Implementation Plan: Finding Ownership Semantics Clarification + +**Branch**: `219-finding-ownership-semantics` | **Date**: 2026-04-20 | **Spec**: [spec.md](./spec.md) +**Input**: Feature specification from `/specs/219-finding-ownership-semantics/spec.md` + +## Summary + +Clarify the meaning of finding owner versus finding assignee across the existing tenant findings list, detail surface, responsibility-update flows, and exception-request context without adding new persistence, capabilities, or workflow services. The implementation will reuse the existing `owner_user_id` and `assignee_user_id` fields, add a derived responsibility-state presentation layer on top of current data, tighten operator-facing copy and audit/feedback wording, preserve tenant-safe Filament behavior, and extend focused Pest + Livewire coverage for list/detail semantics, responsibility updates, and exception-owner boundary cases. + +## Technical Context + +**Language/Version**: PHP 8.4.15 / Laravel 12 +**Primary Dependencies**: Filament v5, Livewire v4.0+, Pest v4, Tailwind CSS v4 +**Storage**: PostgreSQL via Sail; existing `findings.owner_user_id`, `findings.assignee_user_id`, and `finding_exceptions.owner_user_id` fields; no schema changes planned +**Testing**: Pest v4 feature and Livewire component tests via `./vendor/bin/sail artisan test --compact` +**Validation Lanes**: fast-feedback, confidence +**Target Platform**: Laravel monolith in Sail/Docker locally; Dokploy-hosted Linux deployment for staging/production +**Project Type**: Laravel monolith / Filament admin application +**Performance Goals**: No new remote calls, no new queued work, no material list/detail query growth beyond current eager loading of owner, assignee, and exception-owner relations +**Constraints**: Keep responsibility state derived rather than persisted; preserve existing tenant membership validation; preserve deny-as-not-found tenant isolation; do not split capabilities or add new abstractions; keep destructive-action confirmations unchanged +**Scale/Scope**: 1 primary Filament resource, 1 model helper or equivalent derived-state mapping, 1 workflow/audit wording touchpoint, 1 exception-owner wording boundary, and 2 focused new/expanded feature test families + +## UI / Surface Guardrail Plan + +- **Guardrail scope**: changed surfaces +- **Native vs custom classification summary**: native +- **Shared-family relevance**: existing tenant findings resource and exception-context surfaces only +- **State layers in scope**: page, detail, URL-query +- **Handling modes by drift class or surface**: review-mandatory +- **Repository-signal treatment**: review-mandatory +- **Special surface test profiles**: standard-native-filament +- **Required tests or manual smoke**: functional-core, state-contract +- **Exception path and spread control**: none; the feature reuses existing resource/table/infolist/action primitives and keeps exception-owner semantics local to the finding context +- **Active feature PR close-out entry**: Guardrail + +## Constitution Check + +*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.* + +- Inventory-first: PASS. The feature only clarifies operator semantics on tenant-owned findings and exception artifacts; it does not change observed-state or snapshot truth. +- Read/write separation: PASS. Responsibility updates remain TenantPilot-only writes on existing finding records; no Microsoft tenant mutation is introduced. Existing destructive-like actions keep their current confirmation rules. +- Graph contract path: PASS / N/A. No Graph calls or contract-registry changes are involved. +- Deterministic capabilities: PASS. Existing canonical findings capabilities remain the source of truth; no new capability split is introduced. +- RBAC-UX: PASS. Tenant-context membership remains the isolation boundary. Non-members remain 404 and in-scope members missing `TENANT_FINDINGS_ASSIGN` remain 403 for responsibility mutations. +- Workspace isolation: PASS. The feature remains inside tenant-context findings flows and preserves existing workspace-context stabilization patterns. +- Global search: PASS. `FindingResource` already has a `view` page, so any existing global-search participation remains compliant. This feature does not add or remove global search. +- Tenant isolation: PASS. All reads and writes remain tenant-scoped and continue to restrict owner/assignee selections to current tenant members. +- Run observability / Ops-UX: PASS / N/A. No new long-running, queued, or remote work is introduced and no `OperationRun` behavior changes. +- Automation / data minimization: PASS / N/A. No new background automation or payload persistence is introduced. +- Test governance (TEST-GOV-001): PASS. The narrowest proving surface is feature-level Filament + workflow coverage with low fixture cost and no heavy-family expansion. +- Proportionality / no premature abstraction / persisted truth / behavioral state: PASS. Responsibility state remains derived from existing fields and does not create a persisted enum, new abstraction, or new table. +- UI semantics / few layers: PASS. The plan uses direct domain-to-UI mapping on `FindingResource` and an optional local helper on `Finding` rather than a new presenter or taxonomy layer. +- Badge semantics (BADGE-001): PASS with restraint. If a new responsibility badge or label is added, it stays local to findings semantics unless multiple consumers later prove centralization is necessary. +- Filament-native UI / action surface contract / UX-001: PASS. Existing native Filament tables, infolists, filters, selects, grouped actions, and modals remain the implementation path; the finding remains the sole primary inspect/open model and row click remains the canonical inspect affordance. + +**Post-Phase-1 re-check**: PASS. The design keeps responsibility semantics derived, tenant-safe, and local to the existing findings resource and tests. + +## Test Governance Check + +- **Test purpose / classification by changed surface**: Feature +- **Affected validation lanes**: fast-feedback, confidence +- **Test governance outcome**: document-in-feature +- **Why this lane mix is the narrowest sufficient proof**: The business truth is visible in Filament list/detail rendering, responsibility action behavior, and tenant-scoped audit feedback. Unit-only testing would miss the operator-facing semantics, while browser/heavy-governance coverage would add unnecessary cost. +- **Narrowest proving command(s)**: + - `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Filament/Resources/FindingResourceOwnershipSemanticsTest.php` + - `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Findings/FindingAssignmentAuditSemanticsTest.php` +- **Fixture / helper / factory / seed / context cost risks**: Low. Existing tenant/user/finding factories and tenant membership helpers are sufficient. The only explicit context risk is tenant-panel routing and admin canonical tenant state. +- **Expensive defaults or shared helper growth introduced?**: no; tests should keep tenant context explicit rather than broadening shared fixtures. +- **Heavy-family additions, promotions, or visibility changes**: none +- **Surface-class relief / special coverage rule**: standard-native relief; explicit tenant-panel routing is required for authorization assertions. +- **Closing validation and reviewer handoff**: Re-run the two focused test files plus formatter on dirty files. Reviewers should verify owner versus assignee wording on list/detail surfaces, exception-owner separation, and 404/403 semantics for out-of-scope versus in-scope unauthorized users. +- **Budget / baseline / trend follow-up**: none +- **Review-stop questions**: lane fit, hidden fixture cost, accidental presenter growth, tenant-context drift in tests +- **Escalation path**: none +- **Active feature PR close-out entry**: Guardrail +- **Why no dedicated follow-up spec is needed**: This work stays inside the existing findings responsibility contract. Only future queue/team-routing or capability-split work would justify a separate spec. + +## Project Structure + +### Documentation (this feature) + +```text +specs/219-finding-ownership-semantics/ +├── plan.md +├── spec.md +├── research.md +├── data-model.md +├── quickstart.md +├── contracts/ +│ └── finding-responsibility.openapi.yaml +├── checklists/ +│ └── requirements.md +└── tasks.md +``` + +### Source Code (repository root) + +```text +apps/platform/ +├── app/ +│ ├── Filament/ +│ │ └── Resources/ +│ │ └── FindingResource.php # MODIFY: list/detail labels, derived responsibility state, filters, action help text, exception-owner wording +│ ├── Models/ +│ │ └── Finding.php # MODIFY: local derived responsibility-state helper if needed +│ └── Services/ +│ └── Findings/ +│ ├── FindingWorkflowService.php # MODIFY: mutation feedback / audit wording for owner-only, assignee-only, clear, and combined changes +│ ├── FindingExceptionService.php # MODIFY: request-exception wording if the exception-owner boundary needs alignment +│ └── FindingRiskGovernanceResolver.php # MODIFY: next-action copy to reflect orphaned accountability semantics +└── tests/ + └── Feature/ + ├── Filament/ + │ └── Resources/ + │ └── FindingResourceOwnershipSemanticsTest.php # NEW: list/detail rendering, filters, exception-owner distinction, tenant-safe semantics + └── Findings/ + ├── FindingAssignmentAuditSemanticsTest.php # NEW: owner-only, assignee-only, combined update feedback/audit semantics + ├── FindingWorkflowRowActionsTest.php # MODIFY: assignment form/help-text semantics and member validation coverage + └── FindingWorkflowServiceTest.php # MODIFY: audit metadata and responsibility-mutation expectations +``` + +**Structure Decision**: Keep all work inside the existing Laravel/Filament monolith. The implementation is a targeted semantics pass over the current findings resource and workflow tests; no new folders, packages, or service families are required. + +## Complexity Tracking + +| Violation | Why Needed | Simpler Alternative Rejected Because | +|-----------|------------|-------------------------------------| +| — | — | — | + +## Proportionality Review + +- **Current operator problem**: Operators cannot tell whether a finding change transferred accountability, active remediation work, or only exception ownership. +- **Existing structure is insufficient because**: The existing fields and actions exist, but the current UI copy and derived next-step language do not establish a stable contract across list, detail, and exception flows. +- **Narrowest correct implementation**: Reuse the existing `owner_user_id` and `assignee_user_id` fields, derive responsibility state from them, and tighten wording on existing Filament surfaces and audit feedback. +- **Ownership cost created**: Low ongoing UI/test maintenance to keep future findings work aligned with the clarified contract. +- **Alternative intentionally rejected**: A new ownership framework, queue model, or capability split was rejected because the current product has not yet exhausted the simpler owner-versus-assignee model. +- **Release truth**: Current-release truth + +## Phase 0 — Research (output: `research.md`) + +See: [research.md](./research.md) + +Research goals: +- Confirm the existing source of truth for owner, assignee, and exception owner. +- Confirm the smallest derived responsibility-state model that fits the current schema. +- Confirm the existing findings tests and Filament routing pitfalls to avoid false negatives. +- Confirm which operator-facing wording changes belong in resource copy versus workflow service feedback. + +## Phase 1 — Design & Contracts (outputs: `data-model.md`, `contracts/`, `quickstart.md`) + +See: +- [data-model.md](./data-model.md) +- [contracts/finding-responsibility.openapi.yaml](./contracts/finding-responsibility.openapi.yaml) +- [quickstart.md](./quickstart.md) + +Design focus: +- Keep responsibility truth on existing finding and finding-exception records. +- Model responsibility state as a derived projection over owner and assignee presence rather than a persisted enum. +- Preserve exception owner as a separate governance concept when shown from a finding context. +- Keep tenant membership validation and existing `FindingWorkflowService::assign()` semantics as the mutation boundary. + +## Phase 2 — Implementation Outline (tasks created in `/speckit.tasks`) + +### Surface semantics pass +- Update the findings list column hierarchy so owner and assignee meaning is explicit at first scan. +- Add a derived responsibility-state label or equivalent summary on list/detail surfaces. +- Keep exception owner visibly separate from finding owner wherever both appear. + +### Responsibility mutation clarity +- Add owner/assignee help text to assignment flows. +- Differentiate owner-only, assignee-only, clear-owner, clear-assignee, and combined responsibility changes in operator feedback and audit-facing wording. +- Keep current tenant-member validation and open-finding restrictions unchanged. + +### Personal-work and next-action alignment +- Add or refine personal-work filters so assignee-based work and owner-based accountability are explicitly separate. +- Update next-action copy for owner-missing states so assignee-only findings are treated as accountability gaps. + +### Regression protection +- Add focused list/detail rendering tests for owner-only, assignee-only, both-set, same-user, and both-null states. +- Add focused responsibility-update tests for owner-only, assignee-only, clear-owner, clear-assignee, and combined changes. +- Preserve tenant-context and authorization regression coverage using explicit Filament panel routing where needed. + +### Verification +- Run the two focused Pest files and any directly modified sibling findings tests. +- Run Pint on dirty files through Sail. + +## Constitution Check (Post-Design) + +Re-check result: PASS. The design stays inside the existing findings domain, preserves tenant isolation and capability enforcement, avoids new persisted truth or semantic framework growth, and keeps responsibility state derived from current fields. + +## Filament v5 Agent Output Contract + +1. **Livewire v4.0+ compliance**: Yes. The feature only adjusts existing Filament v5 resources/pages/actions that already run on Livewire v4.0+. +2. **Provider registration location**: No new panel or service providers are needed. Existing Filament providers remain registered in `apps/platform/bootstrap/providers.php`. +3. **Global search**: `FindingResource` already has a `view` page via `getPages()`, so any existing global-search participation remains compliant. This feature does not enable new global search. +4. **Destructive actions and authorization**: No new destructive actions are introduced. Existing destructive-like findings actions remain server-authorized and keep `->requiresConfirmation()` where already required. Responsibility updates continue to enforce tenant membership and the canonical findings capability registry. +5. **Asset strategy**: No new frontend assets or published views. The feature uses existing Filament tables, infolists, filters, and action modals, so deployment asset handling stays unchanged and no new `filament:assets` step is added. +6. **Testing plan**: Cover the change with focused Pest feature tests for findings resource responsibility semantics, assignment/audit wording, and existing workflow regression surfaces. No browser or heavy-governance expansion is planned. diff --git a/specs/219-finding-ownership-semantics/quickstart.md b/specs/219-finding-ownership-semantics/quickstart.md new file mode 100644 index 00000000..665fd82d --- /dev/null +++ b/specs/219-finding-ownership-semantics/quickstart.md @@ -0,0 +1,81 @@ +# Quickstart: Finding Ownership Semantics Clarification + +**Goal**: Implement the clarified finding owner versus assignee contract on existing findings surfaces without introducing new persistence, capabilities, or workflow services. + +## 1. Prepare the workspace + +```bash +cd apps/platform +./vendor/bin/sail up -d +``` + +## 2. Update responsibility semantics on the existing findings resource + +Primary file: + +- `app/Filament/Resources/FindingResource.php` + +Expected implementation steps: + +1. Keep owner and assignee as separate roles on list and detail surfaces. +2. Add a derived responsibility-state label, badge, or equivalent summary based on current owner/assignee presence. +3. Adjust filters or personal-work shortcuts so assignee-driven work and owner-driven accountability are not collapsed into one ambiguous view. +4. Keep `Exception owner` explicitly distinct anywhere exception context is rendered from a finding. +5. Add help text to assignment and exception-request forms so operators understand the semantic difference between the two owner concepts. + +## 3. Keep responsibility truth local and derived + +Supporting files: + +- `app/Models/Finding.php` +- `app/Services/Findings/FindingWorkflowService.php` +- `app/Services/Findings/FindingExceptionService.php` +- `app/Services/Findings/FindingRiskGovernanceResolver.php` + +Guidance: + +1. Prefer a small local derived helper on `Finding` if it simplifies repeated responsibility-state checks. +2. Do not add a new enum, table, or presenter for responsibility state. +3. Keep `FindingWorkflowService::assign()` as the canonical mutation boundary. +4. If feedback or audit wording changes, distinguish owner-only, assignee-only, clear-owner, clear-assignee, and combined changes explicitly. +5. If next-action copy is updated, treat missing owner as the visible state `orphaned accountability` even when an assignee exists. + +## 4. Add focused regression tests + +Primary test targets: + +- `tests/Feature/Filament/Resources/FindingResourceOwnershipSemanticsTest.php` +- `tests/Feature/Findings/FindingAssignmentAuditSemanticsTest.php` + +Potential supporting edits: + +- `tests/Feature/Findings/FindingWorkflowRowActionsTest.php` +- `tests/Feature/Findings/FindingWorkflowServiceTest.php` + +Coverage checklist: + +1. Owner-only finding renders as owned but unassigned. +2. Owner-plus-assignee finding renders both roles distinctly. +3. Assignee-only and both-null findings render as `orphaned accountability`. +4. Exception owner remains separately labeled from finding owner. +5. Responsibility updates preserve tenant-member validation and clearly report owner-only, assignee-only, clear-owner, clear-assignee, and combined changes. +6. Tenant-route authorization assertions use explicit panel selection when needed. + +## 5. Verify the feature + +Run the narrowest proof set first: + +```bash +cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Filament/Resources/FindingResourceOwnershipSemanticsTest.php +cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Findings/FindingAssignmentAuditSemanticsTest.php +cd apps/platform && ./vendor/bin/sail bin pint --dirty --format agent +``` + +## 6. Review expectations + +Before moving to tasks or implementation review, confirm: + +1. Owner, assignee, and exception owner mean one stable thing each across list, detail, and action flows. +2. Responsibility state is derived from existing fields only. +3. No new persistence, capability split, or presenter/framework layer was introduced. +4. Tenant-safe Filament behavior remains intact on both admin canonical and tenant-panel test paths. \ No newline at end of file diff --git a/specs/219-finding-ownership-semantics/research.md b/specs/219-finding-ownership-semantics/research.md new file mode 100644 index 00000000..1e111b4f --- /dev/null +++ b/specs/219-finding-ownership-semantics/research.md @@ -0,0 +1,52 @@ +# Research: Finding Ownership Semantics Clarification + +**Date**: 2026-04-20 +**Branch**: `219-finding-ownership-semantics` + +## Decision 1: Reuse the existing finding owner and assignee fields as the only responsibility truth + +- **Decision**: Keep `findings.owner_user_id` and `findings.assignee_user_id` as the sole persisted responsibility fields and derive any responsibility-state wording from them. +- **Rationale**: The current model, workflow service, migrations, and tests already support independent owner and assignee assignment. The spec problem is semantic ambiguity, not missing persistence. +- **Alternatives considered**: + - Add a new persisted responsibility status column. Rejected because the state is directly derivable from existing nullable fields. + - Introduce a separate responsibility aggregate/service object. Rejected because one resource and one model can express the needed truth without adding a new layer. + +## Decision 2: Treat assignee-without-owner as an accountability gap, not a healthy assigned state + +- **Decision**: Use a derived responsibility contract where owner presence determines accountability. `owner != null && assignee == null` is `owned but unassigned`; `owner != null && assignee != null` is `assigned`; any `owner == null` case is an accountability gap, even if an assignee exists. +- **Rationale**: The spec defines owner as accountable for the outcome and assignee as actively executing work. Existing resolver logic already treats missing owner or missing assignee as follow-up needing attention. +- **Alternatives considered**: + - Treat any assignee as `assigned` even without an owner. Rejected because it hides the exact accountability gap the feature exists to surface. + - Introduce a fourth persisted state family. Rejected because the business consequence is presentation and next-action guidance, not new workflow routing. + +## Decision 3: Keep exception owner explicitly separate from finding owner + +- **Decision**: Preserve `finding_exceptions.owner_user_id` as a distinct governance concept and label it only as `Exception owner` when shown in a finding context. +- **Rationale**: The current resource already surfaces exception owner separately, and the spec explicitly calls out the risk of accepted-risk flows reintroducing ambiguity under a second `Owner` label. +- **Alternatives considered**: + - Collapse exception owner into finding owner language. Rejected because it would blur responsibility for the finding with responsibility for the exception artifact. + - Rename finding owner around exception flows only. Rejected because local relabeling would increase rather than reduce semantic drift. + +## Decision 4: Keep the implementation local to the existing findings resource and current workflow services + +- **Decision**: Make the semantics change in `FindingResource`, with only small supporting adjustments in `Finding`, `FindingWorkflowService`, `FindingExceptionService`, and `FindingRiskGovernanceResolver` if wording or derived next-action copy needs alignment. +- **Rationale**: The current UI already displays owner, assignee, and exception owner. The gap is where those semantics are explained, prioritized, and tested. +- **Alternatives considered**: + - Add a new presenter or explanation layer for responsibility semantics. Rejected because a local derived helper is sufficient and aligned with the constitution’s anti-layering bias. + - Add a dedicated responsibility page. Rejected because the findings list/detail surfaces are already the operator decision context. + +## Decision 5: Use focused Pest feature + Livewire coverage, not browser or heavy-governance tests + +- **Decision**: Cover the feature with focused findings resource and workflow feature tests, including explicit tenant-context setup and Filament routing discipline. +- **Rationale**: Existing tests already prove tenant-member validation and row-action behavior. The missing coverage is list/detail semantics and owner-only versus assignee-only feedback. +- **Alternatives considered**: + - Browser tests. Rejected because the behavior is visible and provable through current Filament Livewire test patterns. + - Unit-only tests. Rejected because they would miss the operator-facing semantics that motivated the spec. + +## Decision 6: Preserve current tenant-panel and canonical-admin context rules during testing + +- **Decision**: Keep tenant context explicit in tests and use explicit panel selection for tenant-route assertions when necessary. +- **Rationale**: Repository memory shows two relevant pitfalls: tenant-scoped Filament list pages can lose context on later Livewire interactions, and `Resource::getUrl(..., tenant: $tenant)` defaults to the admin panel unless `panel: 'tenant'` is passed. +- **Alternatives considered**: + - Rely on implicit panel resolution in tests. Rejected because it can produce false redirects to `/admin/choose-tenant` instead of the intended authorization result. + - Broaden shared fixtures to always set tenant context. Rejected because the constitution prefers opt-in context rather than expensive defaults. \ No newline at end of file diff --git a/specs/219-finding-ownership-semantics/spec.md b/specs/219-finding-ownership-semantics/spec.md new file mode 100644 index 00000000..d519e0dc --- /dev/null +++ b/specs/219-finding-ownership-semantics/spec.md @@ -0,0 +1,210 @@ +# Feature Specification: Finding Ownership Semantics Clarification + +**Feature Branch**: `219-finding-ownership-semantics` +**Created**: 2026-04-20 +**Status**: Draft +**Input**: User description: "Finding Ownership Semantics Clarification" + +## Spec Candidate Check *(mandatory — SPEC-GATE-001)* + +- **Problem**: Open findings already store both an owner and an assignee, but the product does not yet state clearly which field represents accountability and which field represents active execution. +- **Today's failure**: Operators can see or change responsibility on a finding without being sure whether they transferred accountability, execution work, or only exception ownership, which makes triage slower and audit language less trustworthy. +- **User-visible improvement**: Findings surfaces make accountable owner, active assignee, and exception owner visibly distinct, so operators can route work faster and read ownership changes honestly. +- **Smallest enterprise-capable version**: Clarify the existing responsibility contract on current findings list/detail/action surfaces, add explicit derived responsibility states, and align audit and filter language without creating new roles, queues, or persistence. +- **Explicit non-goals**: No team queues, no escalation engine, no automatic reassignment hygiene, no new capability split, no new ownership framework, and no mandatory backfill before rollout. +- **Permanent complexity imported**: One explicit product contract for finding owner versus assignee, one derived responsibility-state vocabulary, and focused acceptance tests for mixed ownership contexts. +- **Why now**: The next findings execution slices depend on honest ownership semantics, and the ambiguity already affects triage, reassignment, and exception routing in today’s tenant workflow. +- **Why not local**: A one-surface copy fix would leave contradictory meaning across the findings list, detail view, assignment flows, personal work cues, and exception-request language. +- **Approval class**: Core Enterprise +- **Red flags triggered**: None after scope cut. This spec clarifies existing fields instead of introducing a new semantics axis. +- **Score**: Nutzen: 2 | Dringlichkeit: 2 | Scope: 2 | Komplexität: 2 | Produktnähe: 2 | Wiederverwendung: 1 | **Gesamt: 11/12** +- **Decision**: approve + +## Spec Scope Fields *(mandatory)* + +- **Scope**: tenant +- **Primary Routes**: `/admin/t/{tenant}/findings`, `/admin/t/{tenant}/findings/{finding}` +- **Data Ownership**: Tenant-owned findings remain the source of truth; this spec also covers exception ownership only when it is shown or edited from a finding-context surface. +- **RBAC**: Tenant membership is required for visibility. Tenant findings view permission gates read access. Tenant findings assign permission gates owner and assignee changes. Non-members or cross-tenant requests remain deny-as-not-found. Members without mutation permission receive an explicit authorization failure. + +## UI / Surface Guardrail Impact *(mandatory when operator-facing surfaces are changed; otherwise write `N/A`)* + +| Surface / Change | Operator-facing surface change? | Native vs Custom | Shared-Family Relevance | State Layers Touched | Exception Needed? | Low-Impact / `N/A` Note | +|---|---|---|---|---|---|---| +| Tenant findings list and grouped bulk actions | yes | Native Filament + existing UI enforcement helpers | Same tenant work-queue family as other tenant resources | table, bulk-action modal, notification copy | no | Clarifies an existing resource instead of adding a new page | +| Finding detail and single-record action modals | yes | Native Filament infolist + action modals | Same finding resource family | detail, action modal, helper copy | no | Keeps one decision flow inside the current resource | + +## Decision-First Surface Role *(mandatory when operator-facing surfaces are changed)* + +| Surface | Decision Role | Human-in-the-loop Moment | Immediately Visible for First Decision | On-Demand Detail / Evidence | Why This Is Primary or Why Not | Workflow Alignment | Attention-load Reduction | +|---|---|---|---|---|---|---|---| +| Tenant findings list and grouped bulk actions | Primary Decision Surface | A tenant operator reviews the open backlog and decides who owns the outcome and who should act next | Severity, lifecycle status, due state, owner, assignee, and derived responsibility state | Full evidence, audit trail, run metadata, and exception details | Primary because it is the queue where responsibility is routed at scale | Follows the operator’s “what needs action now?” workflow instead of raw data lineage | Removes the need to open each record just to tell who is accountable versus merely assigned | +| Finding detail and single-record action modals | Secondary Context Surface | A tenant operator verifies one finding before reassigning work or requesting an exception | Finding summary, current owner, current assignee, exception owner when applicable, and next workflow action | Raw evidence, historical context, and additional governance details | Secondary because it resolves one case after the list has already identified the work item | Preserves the single-record decision loop without creating a separate ownership page | Avoids cross-page reconstruction when ownership and exception context must be compared together | + +## UI/UX Surface Classification *(mandatory when operator-facing surfaces are changed)* + +| Surface | Action Surface Class | Surface Type | Likely Next Operator Action | Primary Inspect/Open Model | Row Click | Secondary Actions Placement | Destructive Actions Placement | Canonical Collection Route | Canonical Detail Route | Scope Signals | Canonical Noun | Critical Truth Visible by Default | Exception Type / Justification | +|---|---|---|---|---|---|---|---|---|---|---|---|---|---| +| Tenant findings list and grouped bulk actions | List / Table / Bulk | Workflow queue / list-first resource | Open a finding or update responsibility | Finding | required | Structured `More` action group and grouped bulk actions | Existing dangerous actions remain inside grouped actions with confirmation where already required | /admin/t/{tenant}/findings | /admin/t/{tenant}/findings/{finding} | Tenant context, filters, severity, due state, workflow state | Findings / Finding | Accountable owner, active assignee, and whether the finding is assigned, owned-but-unassigned, or orphaned | none | +| Finding detail and single-record action modals | Record / Detail / Actions | View-first operational detail | Confirm or update responsibility for one finding | Finding | N/A - detail surface | Structured header actions on the existing record page | Existing dangerous actions remain grouped and confirmed | /admin/t/{tenant}/findings | /admin/t/{tenant}/findings/{finding} | Tenant breadcrumb, lifecycle state, due state, governance context | Findings / Finding | Finding owner, finding assignee, and exception owner stay visibly distinct in the same context | none | + +## Operator Surface Contract *(mandatory when operator-facing surfaces are changed)* + +| Surface | Primary Persona | Decision / Operator Action Supported | Surface Type | Primary Operator Question | Default-visible Information | Diagnostics-only Information | Status Dimensions Used | Mutation Scope | Primary Actions | Dangerous Actions | +|---|---|---|---|---|---|---|---|---|---|---| +| Tenant findings list and grouped bulk actions | Tenant operator or tenant manager | Route responsibility on open findings and separate accountable ownership from active work | Workflow queue | Who owns this outcome, who is doing the work, and what needs reassignment now? | Severity, lifecycle state, due state, owner, assignee, derived responsibility state, and personal-work cues | Raw evidence, run identifiers, historical audit details | lifecycle, severity, governance validity, responsibility state | TenantPilot only | Open finding, Assign, Triage, Start progress | Resolve, Close, Request exception | +| Finding detail and single-record action modals | Tenant operator or tenant manager | Verify and change responsibility for one finding without losing exception context | Detail/action surface | Is this finding owned by the right person, assigned to the right executor, and clearly separate from any exception owner? | Finding summary, owner, assignee, exception owner when present, due state, lifecycle state | Raw evidence payloads, extended audit history, related run metadata | lifecycle, governance validity, responsibility state | TenantPilot only | Assign, Start progress, Resolve | Close, Request exception | + +## Proportionality Review *(mandatory when structural complexity is introduced)* + +- **New source of truth?**: no +- **New persisted entity/table/artifact?**: no +- **New abstraction?**: no +- **New enum/state/reason family?**: no persisted family; only derived responsibility-state rules on existing fields +- **New cross-domain UI framework/taxonomy?**: no +- **Current operator problem**: Operators cannot reliably distinguish accountability from active execution, which creates avoidable misrouting and weak audit interpretation. +- **Existing structure is insufficient because**: Two existing user fields and an exception-owner concept can already appear in the same workflow, but current copy does not establish a stable product contract across list, detail, and action surfaces. +- **Narrowest correct implementation**: Reuse the existing finding owner and assignee fields, define their meaning, and apply that meaning consistently to labels, filters, derived states, and audit copy. +- **Ownership cost**: Focused UI copy review, acceptance tests for role combinations, and ongoing discipline to keep future findings work aligned with the same contract. +- **Alternative intentionally rejected**: A broader ownership framework with separate role types, queues, or team routing was rejected because it adds durable complexity before the product has proven the simpler owner-versus-assignee contract. +- **Release truth**: Current-release truth. This spec clarifies the meaning of responsibility on live findings now and becomes the contract for later execution surfaces. + +## Testing / Lane / Runtime Impact *(mandatory for runtime behavior changes)* + +- **Test purpose / classification**: Feature +- **Validation lane(s)**: fast-feedback, confidence +- **Test governance outcome**: document-in-feature +- **Why this classification and these lanes are sufficient**: The change is visible through tenant findings UI behavior, responsibility-state rendering, and audit-facing wording. Focused feature coverage proves the contract without introducing browser or heavy-governance breadth. +- **New or expanded test families**: Expand tenant findings resource coverage to include responsibility rendering, personal-work cues, and mixed-context exception-owner wording. Add focused assignment-audit coverage for owner-only, assignee-only, and combined changes. +- **Fixture / helper cost impact**: Low. Existing tenant, membership, user, and finding factories are sufficient; tests only need explicit responsibility combinations and tenant-scoped memberships. +- **Heavy-family visibility / justification**: none +- **Special surface test profile**: standard-native-filament +- **Standard-native relief or required special coverage**: Ordinary feature coverage is sufficient, with explicit assertions for owner versus assignee visibility and authorization behavior. +- **Reviewer handoff**: Reviewers should confirm that one positive and one negative authorization case exist, that list and detail surfaces use the same vocabulary, and that audit-facing feedback distinguishes owner changes from assignee changes. +- **Budget / baseline / trend impact**: none +- **Escalation needed**: none +- **Active feature PR close-out entry**: Guardrail +- **Planned validation commands**: + - `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Filament/Resources/FindingResourceOwnershipSemanticsTest.php` + - `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Findings/FindingAssignmentAuditSemanticsTest.php` + +## User Scenarios & Testing *(mandatory)* + +### User Story 1 - Route accountable ownership clearly (Priority: P1) + +As a tenant operator, I want to tell immediately who owns a finding and who is actively assigned to it, so I can route remediation work without guessing which responsibility changed. + +**Why this priority**: This is the smallest slice that fixes the core trust gap. Without it, every later execution surface inherits ambiguous meaning. + +**Independent Test**: Can be tested by loading the tenant findings list and detail pages with findings in owner-only, owner-plus-assignee, and no-owner states, and verifying that the first decision is possible without opening unrelated screens. + +**Acceptance Scenarios**: + +1. **Given** an open finding with an owner and no assignee, **When** an operator views the findings list, **Then** the record is shown as owned but unassigned rather than fully assigned. +2. **Given** an open finding with both an owner and an assignee, **When** an operator opens the finding detail, **Then** the accountable owner and active assignee are shown as separate roles. +3. **Given** an open finding with no owner, **When** an operator views the finding from the list or detail page, **Then** the record is surfaced with the derived state `orphaned accountability`. + +--- + +### User Story 2 - Reassign work without losing accountability (Priority: P2) + +As a tenant manager, I want to change the assignee, the owner, or both in one responsibility update, so I can transfer execution work without silently transferring accountability. + +**Why this priority**: Reassignment is the first mutation where the ambiguity causes operational mistakes and misleading audit history. + +**Independent Test**: Can be tested by performing responsibility updates on open findings and asserting separate outcomes for owner-only, assignee-only, combined, clear-owner, and clear-assignee changes. + +**Acceptance Scenarios**: + +1. **Given** an open finding with an existing owner and assignee, **When** an authorized operator changes only the assignee, **Then** the owner remains unchanged and the resulting feedback states that only the assignee changed. +2. **Given** an open finding with an existing owner and assignee, **When** an authorized operator changes only the owner, **Then** the assignee remains unchanged and the resulting feedback states that only the owner changed. +3. **Given** an open finding with an existing owner and assignee, **When** an authorized operator changes both roles in one responsibility update, **Then** the resulting feedback states that both the owner and assignee changed. +4. **Given** an open finding with an existing owner and assignee, **When** an authorized operator clears only the assignee, **Then** the owner remains unchanged and the finding returns to `owned but unassigned`. +5. **Given** an open finding with an existing owner and assignee, **When** an authorized operator clears only the owner, **Then** the assignee remains unchanged and the finding returns to `orphaned accountability`. + +--- + +### User Story 3 - Keep exception ownership separate (Priority: P3) + +As a governance reviewer, I want exception ownership to remain visibly separate from finding ownership when both appear in one flow, so risk-acceptance work does not overwrite the meaning of the finding owner. + +**Why this priority**: This is a narrower but important boundary that prevents the accepted-risk workflow from reintroducing the same ambiguity under a second owner label. + +**Independent Test**: Can be tested by opening a finding that has, or is about to create, an exception record and verifying that finding owner and exception owner remain distinct in the same operator context. + +**Acceptance Scenarios**: + +1. **Given** a finding detail view that shows both finding responsibility and exception context, **When** the operator reviews ownership information, **Then** exception ownership is labeled separately from finding owner. +2. **Given** an operator starts an exception request from a finding, **When** the request asks for exception ownership, **Then** the form language makes clear that the selected person owns the exception artifact, not the finding itself. + +### Edge Cases + +- A finding may have the same user as both owner and assignee; the UI must show both roles as satisfied without implying duplication or error. +- A finding with an assignee but no owner is still an orphaned accountability case, not a healthy assigned state. +- Historical or imported findings may have both responsibility fields empty; they must render as orphaned without blocking rollout on a data backfill. +- When personal-work cues are shown, assignee-based work and owner-based accountability must not collapse into one ambiguous shortcut. + +## Requirements *(mandatory)* + +**Constitution alignment (required):** This feature changes tenant-scoped finding workflow semantics only. It introduces no Microsoft Graph calls, no scheduled or long-running work, and no new `OperationRun`. Responsibility mutations remain audited tenant-local writes. + +**Constitution alignment (RBAC-UX):** The affected authorization plane is tenant-context only. Cross-tenant and non-member access remain deny-as-not-found. Members without the findings assign permission continue to be blocked from responsibility mutations. The feature reuses the canonical capability registry and does not introduce raw role checks or a new capability split. + +**Constitution alignment (UI-FIL-001 / Filament Action Surfaces / UX-001):** The feature reuses existing native Filament tables, infolist entries, filters, selects, modal actions, grouped actions, and notifications. No local badge taxonomy or custom action chrome is added. The action-surface contract remains satisfied: the finding is still the one and only primary inspect/open model, row click remains the canonical inspect affordance on the list, no redundant view action is introduced, and existing dangerous actions remain grouped and confirmed where already required. + +**Constitution alignment (UI-NAMING-001 / UI-SEM-001 / TEST-TRUTH-001):** Direct field labels alone are insufficient because finding owner, finding assignee, and exception owner can appear in the same operator flow. This feature resolves that ambiguity by tightening the product vocabulary on the existing domain truth instead of adding a new semantic layer or presenter family. Tests prove visible business consequences rather than thin indirection. + +### Functional Requirements + +- **FR-001**: The system MUST treat finding owner as the accountable user responsible for ensuring that a finding reaches a governed terminal outcome. +- **FR-002**: The system MUST treat finding assignee as the user currently expected to perform or coordinate active remediation work, and any operator-facing use of `assigned` language MUST refer to this role only. +- **FR-003**: Authorized users MUST be able to set, change, or clear finding owner and finding assignee independently on open findings while continuing to restrict selectable people to members of the active tenant. +- **FR-004**: The system MUST derive responsibility state from the existing owner and assignee fields without adding a persisted lifecycle field. At minimum it MUST distinguish `owned but unassigned`, `assigned`, and `orphaned accountability`. +- **FR-005**: Findings list, finding detail, and responsibility-update flows MUST label owner and assignee distinctly, and any mixed context that also references exception ownership MUST label that role as exception owner. +- **FR-006**: When personal-work shortcuts or filters are shown on the tenant findings list, the system MUST expose separate, explicitly named cues for assignee-based work and owner-based accountability. +- **FR-007**: Responsibility-update feedback and audit-facing wording MUST state whether a mutation changed the owner, the assignee, or both. +- **FR-008**: Historical or imported findings with null or partial responsibility data MUST render using the derived responsibility contract without requiring a blocking data migration before rollout. +- **FR-009**: Exception ownership MUST remain a separate governance concept. Creating, viewing, or editing an exception from a finding context MUST NOT silently replace the meaning of the finding owner. + +**State naming convention**: Operator-facing copy uses `orphaned accountability` as the visible label. Internal derived-state and contract slugs use `orphaned_accountability`. + +## 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 | +|---|---|---|---|---|---|---|---|---|---|---| +| Tenant findings list | `/admin/t/{tenant}/findings` | None added by this feature | Full-row open into finding detail | Primary open affordance plus `More` action group with `Triage`, `Start progress`, `Assign`, `Resolve`, `Close`, and `Request exception` | Grouped actions including `Triage selected`, `Assign selected`, `Resolve selected`, and existing close/exception-safe bulk actions if already present | Existing findings empty state remains; no new CTA introduced | N/A | N/A | Yes for responsibility mutations and existing workflow mutations | Action Surface Contract satisfied. No empty groups or redundant view action introduced. | +| Finding detail | `/admin/t/{tenant}/findings/{finding}` | Existing record-level workflow actions only | Entered from the list row click; no separate inspect action added | Structured record actions retain `Assign`, `Start progress`, `Resolve`, `Close`, and `Request exception` | N/A | N/A | Existing view header actions only | N/A | Yes for responsibility mutations and existing workflow mutations | UI-FIL-001 satisfied through existing infolist and action modal primitives. No exemption needed. | + +### Key Entities *(include if feature involves data)* + +- **Finding responsibility**: The visible responsibility contract attached to an open finding, consisting of accountable ownership, active execution assignment, and a derived responsibility state. +- **Finding owner**: The accountable person who owns the outcome of the finding and is expected to ensure it reaches a governed end state. +- **Finding assignee**: The person currently expected to perform or coordinate the remediation work on the finding. +- **Exception owner**: The accountable person for an exception artifact created from a finding; separate from finding owner whenever both appear in one operator context. + +## Success Criteria *(mandatory)* + +### Measurable Outcomes + +- **SC-001**: In acceptance review, an authorized operator can identify owner, assignee, and responsibility state for any scripted open-finding example from the list or detail surface within 10 seconds. +- **SC-002**: 100% of covered responsibility combinations in automated acceptance tests, including same-person, owner-only, owner-plus-assignee, assignee-only, and both-empty cases, render the correct visible labels and derived state. +- **SC-003**: 100% of covered responsibility-update tests distinguish owner-only, assignee-only, clear-owner, clear-assignee, and combined changes in operator feedback and audit-facing wording. +- **SC-004**: When personal-work shortcuts are present, an operator can isolate assignee-based work and owner-based accountability from the findings list in one interaction each. + +## Assumptions + +- Existing finding owner and assignee fields remain the single source of truth for responsibility in this slice. +- Open findings may legitimately begin without an assignee while still needing an accountable owner. +- Membership hygiene for users who later lose tenant access is a separate follow-up concern and not solved by this clarification slice. + +## Non-Goals + +- Introduce team, queue, or workgroup ownership. +- Add automatic escalation, reassignment, or inactivity timers. +- Split authorization into separate owner-edit and assignee-edit capabilities. +- Require a mandatory historical backfill before the clarified semantics can ship. + +## Dependencies + +- Spec 111, Findings Workflow + SLA, remains the lifecycle and assignment baseline that this spec clarifies. +- Spec 154, Finding Risk Acceptance, remains the exception governance baseline whose owner semantics must stay separate from finding owner. diff --git a/specs/219-finding-ownership-semantics/tasks.md b/specs/219-finding-ownership-semantics/tasks.md new file mode 100644 index 00000000..4dc39315 --- /dev/null +++ b/specs/219-finding-ownership-semantics/tasks.md @@ -0,0 +1,207 @@ +# Tasks: Finding Ownership Semantics Clarification + +**Input**: Design documents from `/specs/219-finding-ownership-semantics/` +**Prerequisites**: plan.md, spec.md, research.md, data-model.md, contracts/finding-responsibility.openapi.yaml, quickstart.md + +**Tests**: Tests are REQUIRED for this runtime behavior change. Use focused Pest feature and Livewire tests via Sail. +**Test Governance Outcome**: `document-in-feature` +**Operations**: No new `OperationRun` work is required for this feature; responsibility changes remain tenant-local audited writes. +**RBAC**: No new authorization model is introduced; tasks must preserve existing tenant membership isolation, `404` versus `403` semantics, and canonical findings capability enforcement. +**Filament UI Action Surfaces**: Tasks must keep `FindingResource` compliant with the Action Surface Contract while clarifying owner, assignee, and exception-owner semantics. +**Filament UI UX-001**: Tasks must preserve the existing view-first findings detail layout and keep list/detail surfaces operator-first with explicit ownership truth. + +**Organization**: Tasks are grouped by user story so each story remains independently testable. + +## Phase 1: Setup (Shared Context) + +**Purpose**: Align implementation with the approved responsibility contract and current codebase patterns before editing runtime code. + +- [X] T001 [P] Review the approved contract and story goals in specs/219-finding-ownership-semantics/spec.md, specs/219-finding-ownership-semantics/plan.md, and specs/219-finding-ownership-semantics/contracts/finding-responsibility.openapi.yaml +- [X] T002 [P] Inspect the current responsibility surfaces in apps/platform/app/Filament/Resources/FindingResource.php, apps/platform/app/Services/Findings/FindingRiskGovernanceResolver.php, and apps/platform/app/Services/Findings/FindingExceptionService.php +- [X] T003 [P] Review the existing findings workflow and Filament test patterns in apps/platform/tests/Feature/Findings/FindingWorkflowRowActionsTest.php, apps/platform/tests/Feature/Findings/FindingWorkflowServiceTest.php, and specs/219-finding-ownership-semantics/quickstart.md + +--- + +## Phase 2: Foundational (Blocking Prerequisites) + +**Purpose**: Establish the shared derived semantics and focused test harness that all user stories depend on. + +**⚠️ CRITICAL**: No user story work should begin until this phase is complete. + +- [X] T004 Implement the shared derived responsibility-state helper in apps/platform/app/Models/Finding.php so owner, assignee, and `orphaned_accountability` state rules come from one local source +- [X] T005 [P] Create the focused Filament semantics test scaffold with explicit tenant-context setup in apps/platform/tests/Feature/Filament/Resources/FindingResourceOwnershipSemanticsTest.php +- [X] T006 [P] Create the focused workflow/audit semantics test scaffold in apps/platform/tests/Feature/Findings/FindingAssignmentAuditSemanticsTest.php + +**Checkpoint**: Derived responsibility semantics and dedicated test entry points are ready; user story implementation can now begin. + +--- + +## Phase 3: User Story 1 - Route accountable ownership clearly (Priority: P1) 🎯 MVP + +**Goal**: Make owner, assignee, and `orphaned accountability` visible on the existing findings list and detail surfaces. + +**Independent Test**: Load the tenant findings list and detail surfaces with owner-only, owner-plus-assignee, assignee-only, same-user, and both-null findings and verify the operator can tell accountability versus active assignment without leaving the resource, including one-interaction owner-work versus assignee-work isolation when personal-work cues are present. + +### Tests for User Story 1 ⚠️ + +> **NOTE**: Add or update these tests first and ensure they fail before implementation. + +- [X] T007 [US1] Add list/detail rendering, personal-work filter isolation, and authorization assertions for owner-only, owner-plus-assignee, assignee-only, same-user, both-null, positive access, and deny-as-not-found cases in apps/platform/tests/Feature/Filament/Resources/FindingResourceOwnershipSemanticsTest.php + +### Implementation for User Story 1 + +- [X] T008 [US1] Update the findings list hierarchy, responsibility summary, and personal-work filter behavior and naming in apps/platform/app/Filament/Resources/FindingResource.php so owner and assignee are explicitly distinct at first scan and can be isolated separately in one interaction each +- [X] T009 [US1] Update the finding detail infolist and default-visible ownership sections in apps/platform/app/Filament/Resources/FindingResource.php so responsibility state and owner versus assignee meaning stay explicit on record view +- [X] T010 [US1] Align `orphaned accountability` and next-step wording with the derived responsibility rules in apps/platform/app/Services/Findings/FindingRiskGovernanceResolver.php + +**Checkpoint**: User Story 1 should now make finding accountability and active assignment understandable from the existing findings resource alone. + +--- + +## Phase 4: User Story 2 - Reassign work without losing accountability (Priority: P2) + +**Goal**: Let operators update assignee, owner, or both without ambiguous mutation feedback or audit wording. + +**Independent Test**: Perform responsibility updates on open findings and verify that owner-only, assignee-only, combined, clear-owner, and clear-assignee changes are differentiated in the action flow, response feedback, and audit-facing expectations. + +### Tests for User Story 2 ⚠️ + +- [X] T011 [P] [US2] Add owner-only, assignee-only, combined, clear-owner, and clear-assignee change assertions in apps/platform/tests/Feature/Findings/FindingAssignmentAuditSemanticsTest.php +- [X] T012 [P] [US2] Extend assignment action and service expectations for help text, member validation, explicit `403` versus `404` semantics, and unchanged-role preservation in apps/platform/tests/Feature/Findings/FindingWorkflowRowActionsTest.php and apps/platform/tests/Feature/Findings/FindingWorkflowServiceTest.php + +### Implementation for User Story 2 + +- [X] T013 [US2] Update the row and bulk assignment actions in apps/platform/app/Filament/Resources/FindingResource.php with explicit owner/assignee help text and differentiated success feedback for owner-only, assignee-only, combined, clear-owner, and clear-assignee changes +- [X] T014 [US2] Update the responsibility mutation wording and audit-facing classification in apps/platform/app/Services/Findings/FindingWorkflowService.php so assignment changes and clears no longer read as one ambiguous action + +**Checkpoint**: User Story 2 should now preserve the accountability contract when responsibility changes are made from existing findings actions. + +--- + +## Phase 5: User Story 3 - Keep exception ownership separate (Priority: P3) + +**Goal**: Preserve a clear boundary between finding owner and exception owner wherever exception context appears on a finding surface. + +**Independent Test**: Open a finding with exception context and verify that finding owner remains distinct from exception owner on the detail surface and in the exception-request flow. + +### Tests for User Story 3 ⚠️ + +- [X] T015 [US3] Extend apps/platform/tests/Feature/Filament/Resources/FindingResourceOwnershipSemanticsTest.php with exception-owner distinction and finding-context exception-request wording coverage + +### Implementation for User Story 3 + +- [X] T016 [US3] Update the risk-governance display and exception-request action copy in apps/platform/app/Filament/Resources/FindingResource.php so `Exception owner` remains separate from finding owner on list/detail/action flows +- [X] T017 [US3] Align finding-context exception-request wording and related feedback in apps/platform/app/Services/Findings/FindingExceptionService.php so exception ownership never reuses finding-owner semantics + +**Checkpoint**: User Story 3 should now keep exception governance ownership visibly separate from finding accountability. + +--- + +## Phase 6: Polish & Cross-Cutting Concerns + +**Purpose**: Final verification, formatting, lane validation, and review readiness across all stories. + +- [X] T018 Reconcile any final lane or proof-depth changes against specs/219-finding-ownership-semantics/plan.md so the documented fast-feedback and confidence strategy still matches the implemented test surface and preserves the explicit `document-in-feature` outcome +- [X] T019 Run focused Pest coverage with `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Filament/Resources/FindingResourceOwnershipSemanticsTest.php tests/Feature/Findings/FindingAssignmentAuditSemanticsTest.php tests/Feature/Findings/FindingWorkflowRowActionsTest.php tests/Feature/Findings/FindingWorkflowServiceTest.php` +- [X] T020 Run `cd apps/platform && ./vendor/bin/sail bin pint --dirty --format agent` for apps/platform/app/Models/Finding.php, apps/platform/app/Filament/Resources/FindingResource.php, apps/platform/app/Services/Findings/FindingWorkflowService.php, apps/platform/app/Services/Findings/FindingExceptionService.php, and apps/platform/app/Services/Findings/FindingRiskGovernanceResolver.php +- [X] T021 Validate the final implementation against specs/219-finding-ownership-semantics/quickstart.md and record the active feature PR close-out entry as `Guardrail` with test-governance outcome `document-in-feature` + +--- + +## Dependencies & Execution Order + +### Phase Dependencies + +- **Setup (Phase 1)**: No dependencies; can start immediately. +- **Foundational (Phase 2)**: Depends on Setup completion; blocks all user stories. +- **User Story 1 (Phase 3)**: Depends on Foundational completion; this is the MVP. +- **User Story 2 (Phase 4)**: Depends on Foundational completion and is safest after User Story 1 because it refines the same findings resource/action surface. +- **User Story 3 (Phase 5)**: Depends on Foundational completion and User Story 1 because exception-owner distinction builds on the clarified base ownership surface. +- **Polish (Phase 6)**: Depends on completion of the desired user stories. + +### User Story Dependencies + +- **US1**: No dependency on other stories; delivers the core operator truth. +- **US2**: Depends on the shared derived responsibility contract from Phase 2 and integrates with the surfaces clarified in US1. +- **US3**: Depends on the shared derived responsibility contract from Phase 2 and the explicit owner/assignee rendering from US1. + +### Within Each User Story + +- Tests should be added or updated first and observed failing before implementation. +- The shared `Finding` helper should remain the source for responsibility-state derivation. +- Resource surface changes should land before service wording is finalized so copy and behavior stay aligned. +- Story-specific verification should complete before moving to the next priority. + +### Parallel Opportunities + +- T001, T002, and T003 can run in parallel. +- T005 and T006 can run in parallel. +- T011 and T012 can run in parallel. +- After Phase 2, one contributor can work on US1 surface rendering while another prepares US2 audit semantics tests. + +--- + +## Parallel Example: User Story 1 + +```bash +# Launch the shared ownership-surface work together after Phase 2: +Task: "Add list/detail rendering and authorization assertions in apps/platform/tests/Feature/Filament/Resources/FindingResourceOwnershipSemanticsTest.php" +Task: "Align orphaned accountability and next-step wording in apps/platform/app/Services/Findings/FindingRiskGovernanceResolver.php" +``` + +--- + +## Parallel Example: User Story 2 + +```bash +# Launch mutation-behavior coverage together: +Task: "Add owner-only, assignee-only, and combined change assertions in apps/platform/tests/Feature/Findings/FindingAssignmentAuditSemanticsTest.php" +Task: "Extend assignment action and service expectations in apps/platform/tests/Feature/Findings/FindingWorkflowRowActionsTest.php and apps/platform/tests/Feature/Findings/FindingWorkflowServiceTest.php" +``` + +--- + +## Parallel Example: User Story 3 + +```bash +# Launch exception-owner boundary work together: +Task: "Extend apps/platform/tests/Feature/Filament/Resources/FindingResourceOwnershipSemanticsTest.php with exception-owner distinction coverage" +Task: "Align finding-context exception-request wording in apps/platform/app/Services/Findings/FindingExceptionService.php" +``` + +--- + +## Implementation Strategy + +### MVP First (User Story 1 Only) + +1. Complete Phase 1: Setup +2. Complete Phase 2: Foundational +3. Complete Phase 3: User Story 1 +4. Validate the clarified owner/assignee semantics on list and detail surfaces +5. Stop and review before mutation-flow or exception-boundary refinements if needed + +### Incremental Delivery + +1. Ship the shared derived responsibility contract and visibility semantics in US1 +2. Add differentiated reassignment feedback and audit wording in US2 +3. Add exception-owner boundary protection in US3 +4. Finish with focused Sail tests, Pint, and quickstart/guardrail validation + +### Parallel Team Strategy + +With multiple contributors: + +1. One contributor owns `FindingResource` surface clarity for US1 +2. One contributor prepares the workflow/audit test surfaces for US2 +3. One contributor prepares the exception-owner wording and verification path for US3 +4. Recombine for focused regression runs, formatting, and guardrail close-out + +--- + +## Notes + +- [P] tasks touch different files and can be executed in parallel. +- User story labels map each task to the corresponding story in spec.md. +- No migrations, no new capabilities, and no new abstraction layers are expected. +- Preserve explicit tenant-context setup in Filament tests to avoid false routing regressions. diff --git a/specs/220-governance-run-summaries/checklists/requirements.md b/specs/220-governance-run-summaries/checklists/requirements.md new file mode 100644 index 00000000..fb1e405a --- /dev/null +++ b/specs/220-governance-run-summaries/checklists/requirements.md @@ -0,0 +1,35 @@ +# Specification Quality Checklist: Humanized Diagnostic Summaries for Governance Operations + +**Purpose**: Validate specification completeness and quality before proceeding to planning +**Created**: 2026-04-20 +**Feature**: [spec.md](../spec.md) + +## Content Quality + +- [x] No implementation details (languages, frameworks, APIs) +- [x] Focused on user value and business needs +- [x] Written for non-technical stakeholders +- [x] All mandatory sections completed + +## Requirement Completeness + +- [x] No [NEEDS CLARIFICATION] markers remain +- [x] Requirements are testable and unambiguous +- [x] Success criteria are measurable +- [x] Success criteria are technology-agnostic (no implementation details) +- [x] All acceptance scenarios are defined +- [x] Edge cases are identified +- [x] Scope is clearly bounded +- [x] Dependencies and assumptions identified + +## Feature Readiness + +- [x] All functional requirements have clear acceptance criteria +- [x] User scenarios cover primary flows +- [x] Feature meets measurable outcomes defined in Success Criteria +- [x] No implementation details leak into specification + +## Notes + +- Validation pass 1 complete. +- Required surface-governance metadata such as routes and action-matrix references are present, but the spec avoids implementation mechanics, framework instructions, and code-level solution design. \ No newline at end of file diff --git a/specs/220-governance-run-summaries/contracts/governance-run-summaries.logical.openapi.yaml b/specs/220-governance-run-summaries/contracts/governance-run-summaries.logical.openapi.yaml new file mode 100644 index 00000000..350f3062 --- /dev/null +++ b/specs/220-governance-run-summaries/contracts/governance-run-summaries.logical.openapi.yaml @@ -0,0 +1,230 @@ +openapi: 3.1.0 +info: + title: Governance Operation Run Summaries Contract + version: 1.0.0 + description: >- + Internal reference contract for Spec 220. These routes continue to return + HTML through Filament and Livewire. The vendor media types below document + the logical summary payloads that must be derivable before rendering. This + is not a public API commitment. +paths: + /admin/operations: + get: + summary: Canonical operations list entry point + responses: + '200': + description: Rendered canonical operations list page + content: + text/html: + schema: + type: string + application/vnd.tenantpilot.governance-operations-list+json: + schema: + $ref: '#/components/schemas/GovernanceOperationsListPage' + '404': + description: Workspace context is missing or the viewer is not entitled to the canonical monitoring scope + /admin/operations/{run}: + get: + summary: Canonical governance operation run detail + parameters: + - name: run + in: path + required: true + schema: + type: integer + responses: + '200': + description: Rendered canonical governance run-detail page + content: + text/html: + schema: + type: string + application/vnd.tenantpilot.governance-operation-run-detail+json: + schema: + $ref: '#/components/schemas/GovernanceOperationRunDetailPage' + '403': + description: Viewer is in scope but lacks required capability for a related action + '404': + description: Run is not visible because it does not exist or entitlement is missing +components: + schemas: + GovernanceOperationsListPage: + type: object + required: + - activeContext + - rowInspectModel + properties: + activeContext: + type: object + properties: + workspaceScope: + type: string + tenantContextActive: + type: boolean + rowInspectModel: + type: string + enum: + - row_click + canonicalDetailRoute: + type: string + example: /admin/operations/44 + GovernanceOperationRunDetailPage: + type: object + required: + - runId + - canonicalOperationType + - summary + - diagnosticsAvailable + properties: + runId: + type: integer + canonicalOperationType: + type: string + enum: + - baseline.capture + - baseline.compare + - tenant.evidence.snapshot.generate + - tenant.review.compose + - tenant.review_pack.generate + artifactFamily: + type: + - string + - 'null' + enum: + - baseline_snapshot + - evidence_snapshot + - tenant_review + - review_pack + - null + summary: + $ref: '#/components/schemas/GovernanceRunDiagnosticSummary' + relatedNavigation: + type: array + items: + $ref: '#/components/schemas/RelatedNavigationLink' + diagnosticsAvailable: + type: boolean + diagnosticsSections: + type: array + items: + $ref: '#/components/schemas/DiagnosticsSection' + GovernanceRunDiagnosticSummary: + type: object + required: + - headline + - executionOutcomeLabel + - artifactImpactLabel + - primaryReason + - nextActionText + properties: + headline: + type: string + executionOutcomeLabel: + type: string + artifactImpactLabel: + type: string + primaryReason: + type: string + affectedScaleCue: + $ref: '#/components/schemas/AffectedScaleCue' + nextActionText: + type: string + dominantCause: + $ref: '#/components/schemas/DominantCauseBreakdown' + secondaryFacts: + type: array + items: + $ref: '#/components/schemas/SummaryFact' + DominantCauseBreakdown: + type: object + required: + - primaryLabel + - primaryExplanation + properties: + primaryCode: + type: + - string + - 'null' + primaryLabel: + type: string + primaryExplanation: + type: string + secondaryCauses: + type: array + items: + type: string + AffectedScaleCue: + type: object + required: + - label + - value + - source + properties: + label: + type: string + value: + type: string + source: + type: string + enum: + - summary_counts + - context + - related_artifact_truth + confidence: + type: string + enum: + - exact + - bounded + - best_available + SummaryFact: + type: object + required: + - label + - value + properties: + label: + type: string + value: + type: string + emphasis: + type: string + enum: + - neutral + - caution + - blocked + RelatedNavigationLink: + type: object + required: + - label + - visible + properties: + label: + type: string + href: + type: + - string + - 'null' + visible: + type: boolean + deniedReason: + type: + - string + - 'null' + DiagnosticsSection: + type: object + required: + - title + - kind + properties: + title: + type: string + kind: + type: string + enum: + - supporting_detail + - count_diagnostics + - failure_payload + - evidence_gap_detail + - type_specific_detail + collapsedByDefault: + type: boolean \ No newline at end of file diff --git a/specs/220-governance-run-summaries/data-model.md b/specs/220-governance-run-summaries/data-model.md new file mode 100644 index 00000000..3bc31ac7 --- /dev/null +++ b/specs/220-governance-run-summaries/data-model.md @@ -0,0 +1,197 @@ +# Data Model: Humanized Diagnostic Summaries for Governance Operations + +## Overview + +This feature does not add or modify persisted domain entities. It adds a logical derived presentation model for canonical governance operation run detail under `/admin/operations/{run}`. + +The design constraint is strict: + +- `OperationRun` remains the only persisted source for run lifecycle and execution truth. +- Related artifacts such as `BaselineSnapshot`, `EvidenceSnapshot`, `TenantReview`, and `ReviewPack` remain the persisted source for artifact truth where they exist. +- `ArtifactTruthPresenter`, `OperatorExplanationBuilder`, and `ReasonPresenter` remain the semantic inputs. +- The new summary remains fully derived and surface-specific. + +## Existing Persistent Inputs + +### 1. OperationRun + +- Purpose: Canonical operational record for background and governance work. +- Key persisted fields used by this feature: + - `id` + - `workspace_id` + - `tenant_id` + - `type` + - `status` + - `outcome` + - `context` + - `summary_counts` + - `failure_summary` + - `started_at` + - `completed_at` +- Relationships and derived lookups used by this feature: + - workspace and tenant context + - related artifact resolution through current operation catalog and presenter logic + +### 2. Related Governance Artifacts + +These are not newly modeled by this feature, but they remain relevant when a run produced or references an artifact. + +- `BaselineSnapshot` +- `EvidenceSnapshot` +- `TenantReview` +- `ReviewPack` + +The feature only reads their already-derived truth where available. + +## Existing Derived Inputs + +### A. ArtifactTruthEnvelope + +`ArtifactTruthPresenter` already derives `ArtifactTruthEnvelope` for `OperationRun` and related artifact records. + +Important envelope dimensions already available: + +- `artifactExistence` +- `contentState` +- `freshnessState` +- `publicationReadiness` +- `supportState` +- `actionability` +- `primaryLabel` +- `primaryExplanation` +- `reason` +- `diagnosticLabel` + +This feature must consume that envelope instead of replacing it. + +### B. OperatorExplanationPattern + +`OperatorExplanationBuilder` already derives an explanation pattern containing: + +- `headline` +- `evaluationResult` +- `executionOutcome` +- `trustworthinessLevel` +- `reliabilityStatement` +- `coverageStatement` +- `dominantCauseCode` +- `dominantCauseLabel` +- `dominantCauseExplanation` +- `nextActionCategory` +- `nextActionText` +- `countDescriptors` + +This feature reuses that pattern as input to the new run-detail summary. + +## Derived Presentation Entities + +### 1. GovernanceRunDiagnosticSummary + +Primary derived object for canonical run detail. + +| Field | Meaning | Source | +|---|---|---| +| `headline` | One dominant first-pass statement for the run detail page | derived from `ArtifactTruthEnvelope` + `OperatorExplanationPattern` | +| `executionOutcomeLabel` | Technical execution result kept visible as a separate fact | `OperationRun.outcome` via existing badge semantics | +| `artifactImpactLabel` | What the resulting artifact means for operator action | artifact truth + explanation pattern | +| `primaryReason` | One short reason supporting the headline | dominant cause explanation or primary explanation | +| `affectedScaleCue` | One operator-readable scale cue, such as ambiguous subjects or missing sections | `summary_counts`, run `context`, or related artifact truth | +| `nextActionText` | First follow-up step the operator should see | existing explanation or next-step logic | +| `secondaryCauses[]` | Additional contributing causes preserved below the primary cause | ranked from reason/context inputs | +| `diagnosticsAvailable` | Whether deeper technical sections still exist below | derived from reason, payload, or technical sections | + +Validation rules: + +- Exactly one `headline` is allowed for the default-visible summary. +- `artifactImpactLabel` must stay distinct from `executionOutcomeLabel`. +- `affectedScaleCue` is optional, but when present it must be backed by numeric or enumerated persisted evidence, not freeform guesswork. +- `secondaryCauses[]` must not repeat the dominant cause. + +### 2. DominantCauseBreakdown + +Logical grouping of the main and supporting causes for degraded runs. + +| Field | Meaning | +|---|---| +| `primaryCauseCode` | Stable internal reason or derived cause key | +| `primaryCauseLabel` | Operator-facing dominant cause label | +| `primaryCauseExplanation` | Short explanation shown in the summary area | +| `secondaryCauses[]` | Additional causes shown in supporting detail only | +| `rankingRule` | Stable ranking rule used to keep ordering deterministic | + +Rules: + +- Ranking must be deterministic for equivalent runs. +- The same cause class must keep the same reading direction across covered governance families. +- A run with no meaningful secondary cause data may omit the secondary list entirely. + +### 3. AffectedScaleCue + +Small derived object explaining what was affected and at what scale. + +| Field | Meaning | +|---|---| +| `label` | Operator-facing scale label such as `Affected subjects`, `Missing sections`, or `Incomplete dimensions` | +| `value` | Human-readable count or scale statement | +| `source` | Where the cue came from: `summary_counts`, `context`, or related artifact truth | +| `confidence` | Whether the cue is exact, bounded, or best available from persisted context | + +Rules: + +- This object remains optional because not every run family has equally rich scale data. +- It must never introduce a new persisted count contract. +- It must not imply precision the persisted data does not support. + +### 4. GovernanceRunSummaryContext + +Logical context for the summary builder. + +| Field | Meaning | +|---|---| +| `surface` | Always `canonical_operation_run_detail` for this spec | +| `canonicalOperationType` | Canonical operation type from `OperationCatalog` | +| `artifactFamily` | Related artifact family when one exists | +| `tenantVisibility` | Whether related tenant/artifact context is visible to the current actor | + +Rules: + +- This context is surface-specific and must not become a cross-product taxonomy. +- Tenant visibility rules must suppress inaccessible related labels and links. + +## Covered Run Families + +| Canonical Type | Primary Artifact Family | Typical Affected-Scale Source | Dominant-Cause Focus | +|---|---|---|---| +| `baseline.capture` | `baseline_snapshot` | `summary_counts`, `context.result`, baseline snapshot summary | blocked prerequisite, zero in-scope subjects, unusable snapshot result | +| `baseline.compare` | none direct, but linked baseline/evidence truth may exist | `summary_counts`, `context.baseline_compare`, evidence-gap payloads | suppressed output, ambiguous matches, evidence gaps, strategy failure | +| `tenant.evidence.snapshot.generate` | `evidence_snapshot` | evidence snapshot summary, completeness state, run counts | stale or incomplete evidence basis, blocked snapshot generation | +| `tenant.review.compose` | `tenant_review` | review summary, missing sections, related evidence truth | missing sections, stale evidence, internal-only review outcome | +| `tenant.review_pack.generate` | `review_pack` | pack summary, linked review state, generation context | internal-only or blocked pack outcome, source-review limitations | + +## Derivation Rules + +### Summary selection order + +1. Resolve canonical operation type. +2. Resolve related artifact truth if present. +3. Resolve operator explanation pattern. +4. Derive dominant cause and supporting causes. +5. Derive affected-scale cue from existing persisted data. +6. Build one `GovernanceRunDiagnosticSummary`. +7. Render diagnostics below that summary without altering the underlying truth. + +### Zero-output runs + +- If a run completed technically but produced no decision-grade artifact, the summary must explicitly say so. +- Zero output must never default to a neutral or green reading. + +### Multi-cause degraded runs + +- One primary cause is required. +- Additional causes remain visible as supporting detail only. +- The ranking rule must be deterministic and shared across all covered run families. + +### Authorization-sensitive output + +- Related artifact names, tenant names, and links may only appear when entitlement checks already pass. +- The summary may remain useful without those labels by using generic operator-safe phrasing. \ No newline at end of file diff --git a/specs/220-governance-run-summaries/plan.md b/specs/220-governance-run-summaries/plan.md new file mode 100644 index 00000000..fcdf92e5 --- /dev/null +++ b/specs/220-governance-run-summaries/plan.md @@ -0,0 +1,299 @@ +# Implementation Plan: Humanized Diagnostic Summaries for Governance Operations + +**Branch**: `220-governance-run-summaries` | **Date**: 2026-04-20 | **Spec**: `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/220-governance-run-summaries/spec.md` +**Input**: Feature specification from `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/220-governance-run-summaries/spec.md` + +**Note**: This plan keeps the work inside the existing canonical Monitoring run-detail, artifact-truth, and operator-explanation seams. The intended implementation is a bounded derived summary layer for governance operation runs, not a new persistence model, not a new lifecycle/state family, and not a new action or surface framework. + +## Summary + +Add one operator-first diagnostic summary to canonical governance run detail so baseline capture, baseline compare, evidence snapshot generation, tenant review composition, and review-pack generation runs explain dominant artifact impact, dominant cause, affected scale, artifact trustworthiness, and next action before raw diagnostics. The implementation will reuse `TenantlessOperationRunViewer`, `OperationRunResource`, `ArtifactTruthPresenter`, `OperatorExplanationBuilder`, `ReasonPresenter`, `OperationUxPresenter`, `SummaryCountsNormalizer`, and the current enterprise-detail builders, and it will introduce one small `GovernanceRunDiagnosticSummary` value object plus builder under `App\Support\OpsUx` so the canonical detail page can express affected-scale and multi-cause ranking without inventing a broader UI framework. + +## Technical Context + +**Language/Version**: PHP 8.4.15, Laravel 12, Filament v5, Livewire v4, Blade +**Primary Dependencies**: Filament v5, Livewire v4, Pest v4, Laravel Sail, `TenantlessOperationRunViewer`, `OperationRunResource`, `ArtifactTruthPresenter`, `OperatorExplanationBuilder`, `ReasonPresenter`, `OperationUxPresenter`, `SummaryCountsNormalizer`, and the existing enterprise-detail builders +**Storage**: PostgreSQL via existing `operation_runs` plus related `baseline_snapshots`, `evidence_snapshots`, `tenant_reviews`, and `review_packs`; no schema changes planned +**Testing**: Pest v4 unit and feature tests, focused Monitoring/Filament/Authorization coverage +**Validation Lanes**: fast-feedback, confidence +**Target Platform**: Dockerized Laravel web application via Sail locally and Linux containers in deployment +**Project Type**: Laravel monolith web application inside the `wt-plattform` monorepo +**Performance Goals**: Preserve DB-only render behavior on canonical run detail, add no render-time external calls, avoid new query breadth, and keep first-pass operator comprehension inside a 10-15 second scan window +**Constraints**: No new Graph calls, no new routes, no new `OperationRun` statuses or outcomes, no new `summary_counts` keys, no new notification surfaces, no new destructive actions, no cross-tenant leakage, and no duplication between decision summary and existing banners +**Scale/Scope**: One canonical Monitoring detail surface, five governance run families, one bounded derived summary seam, and focused regression coverage for summary ordering, multi-cause explanation, zero-output runs, and authorization safety + +## UI / Surface Guardrail Plan + +- **Guardrail scope**: changed surfaces +- **Native vs custom classification summary**: native Filament + existing Monitoring detail primitives +- **Shared-family relevance**: governance run-detail family, operator explanation family, enterprise detail family +- **State layers in scope**: page, detail, URL-query +- **Handling modes by drift class or surface**: review-mandatory +- **Repository-signal treatment**: review-mandatory +- **Special surface test profiles**: monitoring-state-page +- **Required tests or manual smoke**: functional-core, state-contract, manual-smoke +- **Exception path and spread control**: retain the existing diagnostic-detail exception on canonical run detail; do not spread it into new surfaces or action patterns +- **Active feature PR close-out entry**: Guardrail + +## Constitution Check + +*GATE: Passed before Phase 0 research. Re-check after Phase 1 design.* + +| Principle | Pre-Research | Post-Design | Notes | +|-----------|--------------|-------------|-------| +| Inventory-first / snapshots-second | PASS | PASS | The feature reorders explanation on existing run and artifact truth only; inventory and snapshot ownership remain unchanged | +| Read/write separation | PASS | PASS | No new writes, previews, confirmations, or audit-log paths are introduced | +| Graph contract path | PASS | PASS | No new Graph calls or contract-registry changes | +| RBAC / workspace / tenant isolation | PASS | PASS | Canonical `/admin/operations/{run}` remains tenant-safe; non-members stay `404`; in-scope capability denials remain `403` | +| Run observability / Ops-UX | PASS | PASS | Existing `OperationRun` lifecycle, feedback surfaces, initiator rules, and summary-count contracts remain unchanged | +| Ops-UX summary counts | PASS | PASS | Existing flat numeric `summary_counts` stay canonical; the new summary only interprets them | +| Proportionality / no premature abstraction | PASS | PASS | One bounded run-summary helper is justified; no new framework, persistence, or state family is needed | +| Few layers / UI semantics | PASS | PASS | New logic stays downstream of `ArtifactTruthEnvelope` and `OperatorExplanationPattern`; no second truth source is introduced | +| Badge semantics (BADGE-001) | PASS | PASS | Existing badge domains remain canonical; the feature changes order and supporting copy only | +| Filament-native UI (UI-FIL-001) | PASS | PASS | Existing Filament detail page, sections, and enterprise-detail builders remain the implementation path | +| Action surface / inspect model | PASS | PASS | Canonical run detail remains the single inspect model; no new row, header, or bulk actions are introduced | +| Decision-first / OPSURF | PASS | PASS | The page remains a Tertiary Evidence / Diagnostics Surface, but its first read becomes operator-first | +| Test governance (TEST-GOV-001) | PASS | PASS | Proof stays in focused Monitoring feature coverage plus one narrow unit seam; no browser or heavy-governance expansion | +| Filament v5 / Livewire v4 compliance | PASS | PASS | The work stays entirely within the current Filament v5 + Livewire v4 stack | +| Provider registration / global search / assets | PASS | PASS | No panel/provider changes, `OperationRunResource` stays non-searchable, and no new assets are required | + +## Test Governance Check + +- **Test purpose / classification by changed surface**: `Feature` for canonical Monitoring run detail and authorization behavior; `Unit` only for the bounded run-summary builder or ranking helper if introduced +- **Affected validation lanes**: `fast-feedback`, `confidence` +- **Why this lane mix is the narrowest sufficient proof**: The feature is proven by operator-visible hierarchy, dominant-cause ordering, zero-output handling, and tenant-safe canonical run detail. That needs focused surface tests plus one narrow unit seam, not browser or heavy-governance breadth. +- **Narrowest proving command(s)**: + - `cd /Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Monitoring/GovernanceOperationRunSummariesTest.php` + - `cd /Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Filament/OperationRunBaselineTruthSurfaceTest.php tests/Feature/Monitoring/ArtifactTruthRunDetailTest.php tests/Feature/Authorization/OperatorExplanationSurfaceAuthorizationTest.php tests/Feature/RunAuthorizationTenantIsolationTest.php` + - `cd /Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform && ./vendor/bin/sail artisan test --compact tests/Unit/Support/OpsUx/GovernanceRunDiagnosticSummaryBuilderTest.php tests/Unit/Support/OperatorExplanation/OperatorExplanationBuilderTest.php` +- **Fixture / helper / factory / seed / context cost risks**: Shared fixture drift is the main risk. `BuildsGovernanceArtifactTruthFixtures` must stay opt-in, and any multi-cause seeded run helper should remain local to the Monitoring suite instead of becoming a repo-wide default. +- **Expensive defaults or shared helper growth introduced?**: no; all new scenario builders must require explicit run type, outcome, reason codes, and related artifact context +- **Heavy-family additions, promotions, or visibility changes**: none +- **Surface-class relief / special coverage rule**: `monitoring-state-page` coverage is required; existing `standard-native-filament` relief is not enough for summary-order assertions on the canonical detail page +- **Closing validation and reviewer handoff**: Reviewers must confirm summary-first order, no duplicate dominant-cause copy across banners and decision zone, zero-output runs staying non-green, cross-family consistency for shared cause classes, and `404` vs `403` semantics on the canonical route. +- **Budget / baseline / trend follow-up**: Low-to-moderate assertion growth within Monitoring and one new focused suite; no lane-budget follow-up expected unless helper sprawl begins +- **Review-stop questions**: Does the change stay inside current Monitoring detail seams? Did any new summary helper become broader than this surface needs? Did shared fixtures remain opt-in? Did any touched view leak inaccessible tenant or artifact hints? +- **Escalation path**: document-in-feature unless a second shared semantic layer, new persistence, or broad fixture default is proposed; then reject-or-split +- **Active feature PR close-out entry**: Guardrail +- **Why no dedicated follow-up spec is needed**: The expected suite cost and abstraction surface stay tightly bounded to one existing canonical detail page and its current governance run families + +## Project Structure + +### Documentation (this feature) + +```text +specs/220-governance-run-summaries/ +├── plan.md +├── research.md +├── data-model.md +├── quickstart.md +├── contracts/ +│ └── governance-run-summaries.logical.openapi.yaml +├── checklists/ +│ └── requirements.md +└── tasks.md +``` + +### Source Code (repository root) + +```text +apps/platform/ +├── app/ +│ ├── Filament/ +│ │ ├── Pages/ +│ │ │ └── Operations/ +│ │ │ └── TenantlessOperationRunViewer.php +│ │ └── Resources/ +│ │ └── OperationRunResource.php +│ └── Support/ +│ ├── OpsUx/ +│ │ ├── OperationUxPresenter.php +│ │ ├── SummaryCountsNormalizer.php +│ │ ├── GovernanceRunDiagnosticSummary.php +│ │ └── GovernanceRunDiagnosticSummaryBuilder.php +│ ├── ReasonTranslation/ +│ │ └── ReasonPresenter.php +│ └── Ui/ +│ ├── EnterpriseDetail/ +│ ├── GovernanceArtifactTruth/ +│ │ └── ArtifactTruthPresenter.php +│ └── OperatorExplanation/ +│ ├── OperatorExplanationBuilder.php +│ └── OperatorExplanationPattern.php +├── resources/ +│ └── views/ +│ └── filament/ +│ └── pages/ +│ └── operations/ +│ └── tenantless-operation-run-viewer.blade.php +└── tests/ + ├── Feature/ + │ ├── Authorization/ + │ │ └── OperatorExplanationSurfaceAuthorizationTest.php + │ ├── Monitoring/ + │ │ ├── ArtifactTruthRunDetailTest.php + │ │ ├── GovernanceOperationRunSummariesTest.php + │ │ └── GovernanceRunExplanationFallbackTest.php + │ ├── Filament/ + │ │ └── OperationRunBaselineTruthSurfaceTest.php + │ └── RunAuthorizationTenantIsolationTest.php + └── Unit/ + ├── Support/ + │ ├── OpsUx/ + │ │ └── GovernanceRunDiagnosticSummaryBuilderTest.php + │ └── OperatorExplanation/ + │ └── OperatorExplanationBuilderTest.php +``` + +**Structure Decision**: Standard Laravel monolith. The work stays concentrated in the current Monitoring detail files, existing `Support/OpsUx` and UI helper seams, and focused Pest suites. No new base directory, panel, or package is needed. + +## Complexity Tracking + +| Violation | Why Needed | Simpler Alternative Rejected Because | +|-----------|------------|-------------------------------------| +| Bounded run-summary helper/value object | Needed to keep dominant-cause ranking, affected-scale mapping, and summary ordering out of `OperationRunResource` and page templates | Extending the resource/page inline would bury operation-family logic in Filament schema code and make regression coverage brittle | + +## Proportionality Review + +- **Current operator problem**: Canonical governance run detail is still too technical for first-pass operator decisions, especially when execution succeeded but artifact usability did not, or when several degraded causes exist together. +- **Existing structure is insufficient because**: Existing badges, explanation patterns, and raw payload sections require operators to synthesize impact, trust, and next action themselves. The missing piece is a first-pass run-detail summary that ranks cause and scale for this single surface. +- **Narrowest correct implementation**: Add one run-detail-specific summary object and builder inside `Support/OpsUx`, derived entirely from `OperationRun`, `ArtifactTruthEnvelope`, `OperatorExplanationPattern`, and existing count/context payloads. +- **Ownership cost created**: One small builder/value-object pair, one local set of dominance rules, and focused Monitoring/unit tests. +- **Alternative intentionally rejected**: Page-local copy patches and ad-hoc Filament facts only. That would duplicate operation-type logic, make hierarchy drift likely, and fail to protect cross-family consistency. +- **Release truth**: Current-release truth. This plan improves an existing trust surface now rather than preparing a future platform abstraction. + +## Phase 0 Research + +Research outcomes are captured in `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/220-governance-run-summaries/research.md`. + +Key decisions: + +- Keep canonical Monitoring run detail on `OperationRunResource` + `TenantlessOperationRunViewer`; do not create a second run-detail page. +- Treat `ArtifactTruthPresenter`, `OperatorExplanationBuilder`, and `ReasonPresenter` as the canonical semantic inputs. +- Introduce one bounded `GovernanceRunDiagnosticSummary` seam so the decision zone can express affected scale, dominant-cause ranking, and secondary-cause detail without overloading the Filament resource schema. +- Derive affected-scale cues from existing `summary_counts`, run `context`, and related artifact metadata; do not add schema or `summary_counts` contract changes. +- Keep lifecycle/context banners specialized and let the decision zone own the dominant explanation to avoid duplicated operator copy. +- Extend current Monitoring and authorization suites and keep multi-cause fixture helpers local or opt-in. + +## Phase 1 Design + +Design artifacts are created under `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/220-governance-run-summaries/`: + +- `research.md`: implementation-seam decisions, risks, and rejected alternatives +- `data-model.md`: logical model for the derived governance run summary, dominant-cause breakdown, and affected-scale cues +- `contracts/governance-run-summaries.logical.openapi.yaml`: internal logical contract for canonical operations list/detail rendering requirements +- `quickstart.md`: focused verification workflow for manual and automated validation + +Design decisions: + +- No schema migration is required; all summary state remains derived. +- The primary implementation seam is canonical run detail plus a small helper under `App\Support\OpsUx`, not a new cross-domain UI framework. +- Existing Filament action topology, route shape, authorization behavior, and destructive-action semantics remain unchanged. +- `OperationUxPresenter` remains the façade for memoized governance explanation state on run detail. +- Existing technical sections such as count diagnostics, failure payloads, evidence-gap detail, and artifact-truth detail remain available but must become secondary to the new summary block. + +## Phase 1 Agent Context Update + +Run: + +- `.specify/scripts/bash/update-agent-context.sh copilot` + +## Constitution Check — Post-Design Re-evaluation + +- PASS — the design remains read-surface focused and does not introduce new write paths, Graph calls, assets, or authorization semantics. +- PASS — Livewire v4.0+ and Filament v5 constraints remain unchanged, no provider registration move is required, `OperationRunResource` remains non-searchable, and no new destructive actions or assets are introduced. + +## Implementation Strategy + +### Phase A — Introduce One Bounded Governance Run Summary Seam + +**Goal**: Derive one operator-first run-detail summary without creating a second truth source. + +| Step | File | Change | +|------|------|--------| +| A.1 | `apps/platform/app/Support/OpsUx/GovernanceRunDiagnosticSummary.php` | Add a small value object carrying headline, dominant cause, affected scale, trust statement, secondary causes, and next action | +| A.2 | `apps/platform/app/Support/OpsUx/GovernanceRunDiagnosticSummaryBuilder.php` | Derive the summary from `OperationRun`, `ArtifactTruthPresenter`, `OperatorExplanationBuilder`, `ReasonPresenter`, `summary_counts`, and run context | +| A.3 | `apps/platform/app/Support/OpsUx/OperationUxPresenter.php` | Reuse existing memoization to expose the summary on canonical run detail without adding a new cache family | + +### Phase B — Rewire Canonical Run Detail Around The First Decision + +**Goal**: Make the decision zone lead with humanized diagnostic summaries and push raw diagnostics down. + +| Step | File | Change | +|------|------|--------| +| B.1 | `apps/platform/app/Filament/Resources/OperationRunResource.php` | Update the enterprise-detail decision zone to render the new summary, affected-scale cue, and processing-versus-artifact split ahead of technical sections | +| B.2 | `apps/platform/app/Filament/Pages/Operations/TenantlessOperationRunViewer.php` | Keep scope, lifecycle, and restore banners specialized while removing duplicated dominant-cause copy from banner-level messaging | +| B.3 | `apps/platform/resources/views/filament/pages/operations/tenantless-operation-run-viewer.blade.php` or existing enterprise-detail view partials | Ensure the default reading order is summary first, supporting facts second, diagnostics third | + +### Phase C — Add Stable Rules For Covered Governance Run Families + +**Goal**: Keep summary language and affected-scale cues stable across the five scoped governance families. + +| Step | File | Change | +|------|------|--------| +| C.1 | `apps/platform/app/Support/OpsUx/GovernanceRunDiagnosticSummaryBuilder.php` | Add baseline-capture rules for blocked prerequisite, zero-subject capture, and unusable snapshot outcomes | +| C.2 | `apps/platform/app/Support/OpsUx/GovernanceRunDiagnosticSummaryBuilder.php` | Add baseline-compare rules for suppressed output, ambiguous matches, evidence gaps, and strategy failures | +| C.3 | `apps/platform/app/Support/OpsUx/GovernanceRunDiagnosticSummaryBuilder.php` | Add evidence snapshot, tenant review, and review-pack generation rules using existing related artifact truth plus run context | +| C.4 | `apps/platform/app/Support/OpsUx/GovernanceRunDiagnosticSummaryBuilder.php` | Add one stable dominant-cause ranking rule so tied degraded runs do not reorder arbitrarily between renders | + +### Phase D — Preserve Tenant Safety, Related Links, and Existing Action Topology + +**Goal**: Improve explanation without changing route, RBAC, or action behavior. + +| Step | File | Change | +|------|------|--------| +| D.1 | `apps/platform/app/Filament/Pages/Operations/TenantlessOperationRunViewer.php` | Keep canonical back-link lineage, active-tenant continuity, and grouped related navigation intact | +| D.2 | `apps/platform/app/Support/OpsUx/GovernanceRunDiagnosticSummaryBuilder.php` | Ensure summary output suppresses inaccessible artifact or tenant hints when related navigation is not allowed | +| D.3 | Existing authorization tests and related-link helpers | Keep `404` vs `403` semantics unchanged and verify no new mutation affordances appear | + +### Phase E — Protect The Surface With Focused Regression Coverage + +**Goal**: Add the smallest test set that locks summary order, multi-cause behavior, zero-output runs, and authorization safety. + +| Step | File | Change | +|------|------|--------| +| E.1 | `apps/platform/tests/Unit/Support/OpsUx/GovernanceRunDiagnosticSummaryBuilderTest.php` | Add unit coverage for dominant-cause ranking, affected-scale derivation, and next-step category mapping | +| E.2 | `apps/platform/tests/Feature/Monitoring/GovernanceOperationRunSummariesTest.php` | Add end-to-end run-detail coverage for multi-cause degraded runs, all-zero runs, cross-family parity, and diagnostics-secondary ordering | +| E.3 | `apps/platform/tests/Feature/Filament/OperationRunBaselineTruthSurfaceTest.php` and `apps/platform/tests/Feature/Monitoring/ArtifactTruthRunDetailTest.php` | Update existing assertions to match final summary-first wording and remove brittle duplication gaps | +| E.4 | `apps/platform/tests/Feature/Authorization/OperatorExplanationSurfaceAuthorizationTest.php` and `apps/platform/tests/Feature/RunAuthorizationTenantIsolationTest.php` | Extend canonical route coverage for tenant-safe summary rendering and inaccessible related navigation | +| E.5 | `cd apps/platform && ./vendor/bin/sail bin pint --dirty --format agent` plus focused Pest runs | Run formatting and the narrowest proving commands before implementation close-out | + +## Key Design Decisions + +### D-001 — Canonical run detail remains the only detailed run-inspection surface + +The feature improves the current canonical Monitoring detail page instead of creating a second run viewer or a special governance-only route. + +### D-002 — Existing truth and explanation envelopes remain canonical + +`ArtifactTruthEnvelope` and `OperatorExplanationPattern` remain the semantic source of truth. The new summary layer only ranks and presents them for this one surface. + +### D-003 — Affected scale stays derived from existing persisted signals + +`summary_counts`, run `context`, failure summaries, and related artifact truth are sufficient inputs. The plan explicitly avoids schema changes or new count contracts. + +### D-004 — Banners stay specialized; the decision zone owns the main explanation + +Context, lifecycle, or restore-continuation banners may still appear, but the dominant cause and next-step explanation must live in the decision zone so the page does not say the same thing twice. + +### D-005 — Shared fixtures stay opt-in + +Multi-cause or zero-output scenario builders should remain local to the Monitoring suite unless a second real consumer proves they belong in a shared concern. + +## Risk Assessment + +| Risk | Impact | Likelihood | Mitigation | +|------|--------|------------|------------| +| The new summary duplicates existing banner copy and makes the page louder instead of calmer | High | Medium | Keep banners specialized and let the decision zone own dominant explanation text | +| Dominant-cause ranking changes arbitrarily between equivalent multi-cause runs | High | Medium | Encode one explicit ranking rule and cover it with unit tests plus one multi-cause feature test | +| Affected-scale cues drift by operation family and become inconsistent | Medium | Medium | Centralize scale mapping in the builder and reuse it across all covered run families | +| Shared fixtures or helper defaults silently hide required run context | Medium | Medium | Require explicit type, outcome, reason, and related artifact context in new scenario builders | +| Summary copy leaks inaccessible tenant or artifact hints on canonical `/admin` routes | High | Low | Keep authorization tests on related links and summary rendering together and suppress inaccessible context | + +## Test Strategy + +- Add one new focused feature suite for governance run summaries and keep it scoped to canonical Monitoring run detail. +- Add one narrow unit suite for dominant-cause and affected-scale derivation only if a dedicated builder is introduced. +- Reuse existing Monitoring and authorization suites for regression coverage instead of creating browser or heavy-governance breadth. +- Keep `BuildsGovernanceArtifactTruthFixtures` opt-in and add any multi-cause builder locally to the Monitoring suite first. +- Preserve DB-only rendering guarantees on canonical run detail while adjusting the visible summary hierarchy. diff --git a/specs/220-governance-run-summaries/quickstart.md b/specs/220-governance-run-summaries/quickstart.md new file mode 100644 index 00000000..943ac777 --- /dev/null +++ b/specs/220-governance-run-summaries/quickstart.md @@ -0,0 +1,147 @@ +# Quickstart: Humanized Diagnostic Summaries for Governance Operations + +## Goal + +Validate that canonical governance operation run detail now answers the first operator question with one dominant summary, one short reason, one affected-scale cue where available, and one next step, while keeping raw diagnostics secondary and preserving current authorization and navigation semantics. + +## Prerequisites + +1. Start Sail if it is not already running. +2. Ensure the acting user is a valid workspace member and is entitled to the target tenant where the run is tenant-bound. +3. Prepare representative runs for these cases: + - blocked baseline capture with no usable inventory basis + - baseline compare with ambiguous matches or evidence gaps + - evidence snapshot generation with stale or incomplete output + - tenant review composition with missing sections or stale evidence + - review-pack generation with internal-only or blocked outcome + - one multi-cause degraded run + - one zero-output or all-zero run that must not read as green + +## Focused Automated Verification + +Run formatting first: + +```bash +cd apps/platform && ./vendor/bin/sail bin pint --dirty --format agent +``` + +Then run the smallest proving set: + +```bash +cd apps/platform && ./vendor/bin/sail artisan test --compact \ + tests/Feature/Monitoring/GovernanceOperationRunSummariesTest.php \ + tests/Feature/Filament/OperationRunBaselineTruthSurfaceTest.php \ + tests/Feature/Monitoring/ArtifactTruthRunDetailTest.php \ + tests/Feature/Authorization/OperatorExplanationSurfaceAuthorizationTest.php \ + tests/Feature/RunAuthorizationTenantIsolationTest.php \ + tests/Unit/Support/OpsUx/GovernanceRunDiagnosticSummaryBuilderTest.php \ + tests/Unit/Support/OperatorExplanation/OperatorExplanationBuilderTest.php +``` + +If the new focused suite is not yet isolated, run the Monitoring subset instead: + +```bash +cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Monitoring/ +``` + +## Manual Validation Pass + +### 1. Canonical run detail entry path + +Open `/admin/operations` and drill into a governance run. + +Confirm that: + +- row navigation remains the inspect model, +- no new row or header action appears, +- and arriving from tenant context does not silently widen back to all-tenant semantics. + +### 2. Baseline capture blocked by prerequisite + +Open a blocked baseline-capture run. + +Confirm that: + +- the page leads with `no baseline was captured`-style meaning, +- the missing prerequisite appears before raw payloads, +- execution status and artifact usability are visible as separate facts, +- and raw diagnostics remain lower on the page. + +### 3. Baseline compare with ambiguity or suppressed output + +Open a baseline-compare run with evidence gaps, ambiguous matches, or suppressed output. + +Confirm that: + +- the first summary names the compare outcome and its trust limitation, +- the dominant cause is understandable without raw JSON, +- any affected-scale cue is visible when supported by stored counts or gap detail, +- and `0 findings` or zero-output does not read as an all-clear. + +### 4. Evidence snapshot generation + +Open a run that produced stale or incomplete evidence. + +Confirm that: + +- processing success does not imply trustworthy evidence, +- the page states the evidence limitation before technical payloads, +- and next-step guidance points to the right recovery action. + +### 5. Tenant review composition and review-pack generation + +Open one review-compose run and one review-pack-generation run. + +Confirm that: + +- review generation can explain missing sections or stale evidence without JSON, +- pack generation can explain internal-only or blocked shareability outcomes, +- and related artifact links remain available only when the actor is entitled to them. + +### 6. Multi-cause degraded run + +Open a run with two or more stored degraded causes. + +Confirm that: + +- one dominant cause is shown first, +- at least one secondary cause is still discoverable, +- and the ordering is stable across reloads. + +### 7. Cross-family parity + +Open two covered governance runs from different families that share the same dominant cause class. + +Confirm that: + +- the same cause class keeps the same primary reading direction, +- the next-step category stays consistent where the persisted truth supports the same operator action, +- and cross-family wording does not drift into conflicting operator guidance. + +### 8. Authorization and tenant safety + +Confirm that: + +- non-members still receive deny-as-not-found behavior, +- in-scope members lacking capability still receive `403` where expected, +- summary text does not leak inaccessible tenant or artifact hints, +- and `OperationRun` remains non-searchable. + +### 9. Ten-second scan check + +Timebox the first visible scan of one blocked, one degraded, and one zero-output governance run detail page. + +Confirm that within 10-15 seconds an operator can determine: + +- what happened, +- whether the resulting artifact is trustworthy enough to act on, +- what was affected when the stored data supports that cue, +- and what the next step is, + +without opening diagnostic sections. + +## Final Verification Notes + +- Keep diagnostics present but secondary. +- Do not add retry, cancel, force-fail, or other intervention controls as part of this slice. +- If a manual reviewer sees the same dominant-cause copy both in a banner and in the decision zone, treat that as a regression and tighten the summary ownership. \ No newline at end of file diff --git a/specs/220-governance-run-summaries/research.md b/specs/220-governance-run-summaries/research.md new file mode 100644 index 00000000..0fa951aa --- /dev/null +++ b/specs/220-governance-run-summaries/research.md @@ -0,0 +1,49 @@ +# Research: Humanized Diagnostic Summaries for Governance Operations + +## Decision 1: Keep canonical governance run detail on the existing Monitoring viewer and detail resource + +- **Decision**: Reuse `OperationRunResource` and `TenantlessOperationRunViewer` as the single canonical run-detail surface for Spec 220 instead of creating a new governance-only viewer. +- **Rationale**: The repo already routes canonical Monitoring run detail through these seams and already has the right RBAC, action-surface, and navigation guardrails in place. The problem is explanation order and summary quality, not missing routing or missing surface ownership. +- **Alternatives considered**: + - Create a second governance-specific run-detail page. Rejected because it would duplicate route ownership, action hierarchy, and authorization semantics for one existing surface. + - Add page-local partials only in the Blade template. Rejected because the run-detail summary needs stable derivation rules, not just another rendering layer. + +## Decision 2: Treat `ArtifactTruthPresenter`, `OperatorExplanationBuilder`, and `ReasonPresenter` as the canonical semantic inputs + +- **Decision**: Build the new summary from the existing `ArtifactTruthEnvelope`, `OperatorExplanationPattern`, and reason-translation envelopes instead of introducing a second semantic source. +- **Rationale**: The repo already derives artifact truth and operator explanation for `OperationRun` records, including governance families like `baseline.capture`, `baseline.compare`, `tenant.evidence.snapshot.generate`, `tenant.review.compose`, and `tenant.review_pack.generate`. Reusing that chain preserves existing truth ownership and keeps the new work downstream and bounded. +- **Alternatives considered**: + - Add a new persisted summary state to `operation_runs`. Rejected because the desired summary is fully derivable from current persisted truth and would create drift risk. + - Put all summary logic directly inside `OperationRunResource`. Rejected because it would bury operation-family rules inside Filament schema code and make tests brittle. + +## Decision 3: Add one bounded `GovernanceRunDiagnosticSummary` seam only if affected-scale and dominant-cause rules cannot stay in the current presenter flow + +- **Decision**: If the current detail seams cannot cleanly express dominant cause, affected scale, and secondary-cause breakdown, add one small value object plus builder under `App\Support\OpsUx` and expose it through `OperationUxPresenter`. +- **Rationale**: Spec 220 needs more than current badges and explanation labels. It needs one stable first-pass summary, especially for multi-cause degraded runs and all-zero runs. A small run-detail-specific helper is justified because the work is limited to one existing surface and several real operation families already consume the same route. +- **Alternatives considered**: + - Extend `ArtifactTruthPresenter` to own all run-detail ranking logic. Rejected because artifact truth is broader than this one run-detail question and should remain canonical truth, not surface-specific emphasis logic. + - Build a generic cross-product explanation framework. Rejected because the spec is explicitly scoped to canonical governance run detail. + +## Decision 4: Derive affected-scale cues from existing `summary_counts`, run context, and related artifact truth + +- **Decision**: Affected scale must come from existing persisted signals such as `summary_counts`, known run-context payloads, failure summaries, and related artifact summaries. No schema change or count-contract expansion is planned. +- **Rationale**: Covered operation families already persist enough context to support statements like ambiguous subject matches, missing sections, partial evidence dimensions, or zero captured subjects. The missing work is ranking and presenting those signals consistently. +- **Alternatives considered**: + - Add new operation-specific summary fields or nested count structures. Rejected because Ops-UX already constrains `summary_counts` to flat numeric keys, and the feature does not need new persistence. + - Omit affected-scale cues entirely. Rejected because the spec explicitly requires the page to explain what was affected, not just why it failed. + +## Decision 5: Keep banners specialized and let the decision zone own the dominant explanation + +- **Decision**: Existing canonical context, lifecycle, blocked-execution, and restore-continuation banners remain specialized. The main humanized summary must live in the decision zone so the page does not duplicate dominant-cause copy. +- **Rationale**: The current run detail already has banner-level messaging. Adding another banner or repeating the same explanation in two places would increase attention load instead of reducing it. The summary should become the first read inside the decision zone, with banners reserved for scope, stale lifecycle, and special restore continuity contexts. +- **Alternatives considered**: + - Add a new top-of-page summary banner. Rejected because it would compete with existing lifecycle and context banners. + - Remove existing banners entirely. Rejected because they already communicate valid scope or lifecycle information outside the core diagnostic summary. + +## Decision 6: Extend current Monitoring and authorization suites and keep multi-cause fixtures local first + +- **Decision**: Reuse existing Monitoring, Filament, and authorization suites; add one new focused `GovernanceOperationRunSummariesTest` plus one narrow unit seam if a builder is introduced. Keep multi-cause fixture builders local to the Monitoring suite unless another consumer emerges. +- **Rationale**: The repo already has substantial run-detail coverage, including hierarchy assertions, artifact-truth rendering, and `404` vs `403` semantics. The main gaps are multi-cause degraded runs, all-zero runs, and cross-family consistency. Those gaps can be covered without creating a new heavy or browser test family. +- **Alternatives considered**: + - Rely mainly on browser tests. Rejected because the current feature is better proven through existing Livewire and feature suites. + - Move multi-cause builders into shared fixture concerns immediately. Rejected because only Spec 220 currently needs those seeds and shared defaults would be risky. \ No newline at end of file diff --git a/specs/220-governance-run-summaries/spec.md b/specs/220-governance-run-summaries/spec.md new file mode 100644 index 00000000..fbffd5e1 --- /dev/null +++ b/specs/220-governance-run-summaries/spec.md @@ -0,0 +1,238 @@ +# Feature Specification: Humanized Diagnostic Summaries for Governance Operations + +**Feature Branch**: `220-governance-run-summaries` +**Created**: 2026-04-20 +**Status**: Draft +**Input**: User description: "Humanized Diagnostic Summaries for Governance Operations" + +## Spec Candidate Check *(mandatory — SPEC-GATE-001)* + +- **Problem**: Governance operation run-detail pages already carry correct outcome, reason, and artifact-truth semantics, but the first useful explanation still often lives in raw JSON or low-level diagnostic sections. +- **Today's failure**: An operator can open a run that reads `Completed with follow-up`, `Partial`, or `Blocked` and still has to infer the real business meaning: what was affected, which cause dominates, whether retry or resume helps, and whether the resulting artifact is trustworthy enough to act on. +- **User-visible improvement**: Governance run detail leads with one human-readable summary that explains impact, dominant cause, artifact trustworthiness, and next action before any raw diagnostics. +- **Smallest enterprise-capable version**: Add one bounded humanized summary layer to canonical governance run detail only, reusing existing outcome taxonomy, reason translation, artifact-truth semantics, and explanation patterns without changing persistence, lifecycle ownership, or action inventory. +- **Explicit non-goals**: No operations-list redesign, no dashboard overhaul, no new persistence for summaries, no removal of raw JSON, no new remediation controls on run detail, and no generalized rewrite of every governance artifact page. +- **Permanent complexity imported**: One derived governance-run summary contract, one dominant-cause presentation rule set for multi-cause degraded runs, and focused regression coverage for cross-family consistency. +- **Why now**: The roadmap marks this as the next open adoption slice after Spec 214. Specs 156, 157, 158, 161, and 214 already established the language and truth model; leaving run detail technical would keep a core trust surface lagging behind the foundation work. +- **Why not local**: A page-local copy cleanup would recreate divergent run-detail dialects across baseline, evidence, review, and review-pack governance runs and would not reliably separate processing success from artifact usability. +- **Approval class**: Core Enterprise +- **Red flags triggered**: One red flag: a reusable guidance pattern across multiple governance run families. It remains acceptable because the scope is restricted to one existing canonical detail surface and does not add new persisted truth, new states, or a cross-product framework. +- **Score**: Nutzen: 2 | Dringlichkeit: 2 | Scope: 2 | Komplexität: 1 | Produktnähe: 2 | Wiederverwendung: 2 | **Gesamt: 11/12** +- **Decision**: approve + +## Spec Scope Fields *(mandatory)* + +- **Scope**: canonical-view +- **Primary Routes**: `/admin/operations`, `/admin/operations/{run}` +- **Data Ownership**: Tenant-bound governance `OperationRun` records remain tenant-owned operational artifacts exposed through the canonical Monitoring route. Related baseline snapshots can stay workspace-owned, while evidence snapshots, tenant reviews, and review packs remain tenant-owned. This feature changes interpretation and ordering on the canonical run-detail surface only. +- **RBAC**: Workspace membership is required for Monitoring access. Tenant entitlement is still required before revealing tenant-bound governance runs or related artifact links from the canonical route. Existing monitoring-view and related-artifact authorization rules remain authoritative. Non-members or non-entitled users remain deny-as-not-found. Members who can reach Monitoring but lack an existing related action permission remain authorization failures for that action. + +For canonical-view specs, the spec MUST define: + +- **Default filter behavior when tenant-context is active**: When a user reaches Monitoring from an active tenant context, the operations list and related links continue to preserve that tenant context. Opening a governance run detail must not silently broaden the operator back to all tenants. +- **Explicit entitlement checks preventing cross-tenant leakage**: Humanized summaries, dominant-cause labels, affected-scale cues, and related artifact links are only rendered after workspace and tenant entitlement checks succeed for the referenced run. Inaccessible tenant-bound runs and related records behave as not found and must not leak artifact names, tenant names, or result hints. + +## UI / Surface Guardrail Impact *(mandatory when operator-facing surfaces are changed; otherwise write `N/A`)* + +| Surface / Change | Operator-facing surface change? | Native vs Custom | Shared-Family Relevance | State Layers Touched | Exception Needed? | Low-Impact / `N/A` Note | +|---|---|---|---|---|---|---| +| Canonical Monitoring operation run detail for governance operations | yes | Native Filament + existing Monitoring detail primitives | shared governance run-detail family | detail, summary hierarchy, diagnostics hierarchy | yes | Existing diagnostic-surface exception remains; this slice only makes the first read operator-safe | + +## Decision-First Surface Role *(mandatory when operator-facing surfaces are changed)* + +| Surface | Decision Role | Human-in-the-loop Moment | Immediately Visible for First Decision | On-Demand Detail / Evidence | Why This Is Primary or Why Not | Workflow Alignment | Attention-load Reduction | +|---|---|---|---|---|---|---|---| +| Canonical Monitoring operation run detail for governance operations | Tertiary Evidence / Diagnostics Surface | After drilling in from a baseline, evidence, review, or pack workflow, the operator needs to understand what actually happened and what to do next | Dominant artifact impact, dominant cause, affected scale, processing-versus-artifact split, and next action | Raw JSON, complete reason-code detail, provider payloads, low-level counters, and full multi-cause evidence | Not primary because operators should usually arrive here after another surface already identified the case; this page is the deep explanation layer | Follows drill-in from governance artifact and Monitoring workflows instead of becoming a new queue | Removes the need to read badges and raw JSON before understanding the real problem | + +## UI/UX Surface Classification *(mandatory when operator-facing surfaces are changed)* + +| Surface | Action Surface Class | Surface Type | Likely Next Operator Action | Primary Inspect/Open Model | Row Click | Secondary Actions Placement | Destructive Actions Placement | Canonical Collection Route | Canonical Detail Route | Scope Signals | Canonical Noun | Critical Truth Visible by Default | Exception Type / Justification | +|---|---|---|---|---|---|---|---|---|---|---|---|---|---| +| Canonical Monitoring operation run detail for governance operations | Record / Detail / Actions | Canonical diagnostic detail | Open the related artifact or return to the source workflow with the correct next step | Explicit operation-run detail page | forbidden | Existing related navigation remains in header or contextual detail sections | none | /admin/operations | /admin/operations/{run} | Workspace context, active tenant context when present, related artifact type, run family | Operation runs / Operation run | Dominant artifact impact, dominant cause, affected scale, and next action before raw diagnostics | diagnostic_exception - canonical run detail remains the deepest evidence surface, so raw diagnostics stay present, but they must no longer lead the page | + +## Operator Surface Contract *(mandatory when operator-facing surfaces are changed)* + +| Surface | Primary Persona | Decision / Operator Action Supported | Surface Type | Primary Operator Question | Default-visible Information | Diagnostics-only Information | Status Dimensions Used | Mutation Scope | Primary Actions | Dangerous Actions | +|---|---|---|---|---|---|---|---|---|---|---| +| Canonical Monitoring operation run detail for governance operations | Workspace manager or entitled tenant operator | Diagnose why a governance run produced a trustworthy, limited, blocked, or unusable artifact and decide the correct follow-up | Canonical detail | What happened, how much was affected, can I trust the resulting artifact, and what should I do next? | Dominant artifact-impact statement, dominant cause, affected scale, processing-versus-artifact split, next-step guidance, and related artifact context | Raw JSON, full reason-code inventory, provider payloads, low-level counters, and complete multi-cause diagnostics | execution outcome, artifact usability, completeness or reliability, dominant cause, actionability | None on this page; any linked mutations keep their original mutation scopes on their native surfaces | Open related artifact, inspect diagnostics | none | + +## Proportionality Review *(mandatory when structural complexity is introduced)* + +- **New source of truth?**: no +- **New persisted entity/table/artifact?**: no +- **New abstraction?**: yes +- **New enum/state/reason family?**: no +- **New cross-domain UI framework/taxonomy?**: no +- **Current operator problem**: Run detail is semantically correct but still too technical for first-pass operator decisions, which allows false-green or ambiguous readings on a core governance troubleshooting surface. +- **Existing structure is insufficient because**: Existing badges, reason translation, and raw diagnostic payloads still force operators to synthesize impact, trust, and next action themselves. Local copy tweaks would drift by run family and would not reliably separate execution throughput from artifact trustworthiness. +- **Narrowest correct implementation**: Add one bounded summary contract for governance operation run detail only, derived from the existing truth and explanation foundations, while preserving all diagnostics beneath it. +- **Ownership cost**: Ongoing maintenance of one shared summary mapping, one stable dominant-cause breakdown rule set, and focused regression coverage for the covered governance run families. +- **Alternative intentionally rejected**: Per-page copy patches and a broader operations redesign. The first is too weak and inconsistent; the second is unnecessary for the current operator problem. +- **Release truth**: Current-release truth. This spec makes an existing trust surface readable now instead of preparing a future architecture layer. + +### Compatibility posture + +This feature assumes a pre-production environment. + +Backward compatibility, legacy aliases, migration shims, historical fixtures, and compatibility-specific tests are out of scope unless explicitly required by this spec. + +Canonical replacement is preferred over preservation. + +## Testing / Lane / Runtime Impact *(mandatory for runtime behavior changes)* + +- **Test purpose / classification**: Feature +- **Validation lane(s)**: fast-feedback, confidence +- **Why this classification and these lanes are sufficient**: The change is proven by what operators see on the canonical Monitoring run-detail page. Focused feature coverage over seeded governance run scenarios is sufficient to prove explanation hierarchy, cause breakdown, and authorization safety without introducing browser or heavy-governance breadth. +- **New or expanded test families**: Expand Monitoring feature coverage for governance run detail across baseline capture, baseline compare, evidence snapshot generation (`tenant.evidence.snapshot.generate`), tenant review composition (`tenant.review.compose`), and review-pack generation (`tenant.review_pack.generate`). Add one positive and one negative authorization case for tenant-bound governance runs on the canonical route. +- **Fixture / helper cost impact**: Low-to-moderate. Tests can reuse existing workspace, tenant, entitlement, and `OperationRun` setup, but need explicit seeded cases where execution outcome and artifact usability diverge, plus multi-cause degraded runs. +- **Heavy-family visibility / justification**: none +- **Special surface test profile**: monitoring-state-page +- **Standard-native relief or required special coverage**: Ordinary feature coverage is sufficient, with explicit assertions that summary-first hierarchy appears before raw diagnostics and that multi-cause degraded runs stay human-readable. +- **Reviewer handoff**: Reviewers must confirm that run detail leads with one dominant explanation, that processing success never reads as automatic artifact success, that raw JSON remains secondary, that a positive and negative authorization case exist, and that the proof stays inside focused Monitoring feature coverage. +- **Budget / baseline / trend impact**: Low increase in Monitoring feature assertions only; no new heavy or browser baseline is expected. +- **Escalation needed**: none +- **Active feature PR close-out entry**: Guardrail +- **Planned validation commands**: + - `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Monitoring/GovernanceOperationRunSummariesTest.php` + - `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Filament/OperationRunBaselineTruthSurfaceTest.php tests/Feature/Monitoring/ArtifactTruthRunDetailTest.php tests/Feature/Authorization/OperatorExplanationSurfaceAuthorizationTest.php tests/Feature/RunAuthorizationTenantIsolationTest.php` + - `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Unit/Support/OpsUx/GovernanceRunDiagnosticSummaryBuilderTest.php tests/Unit/Support/OperatorExplanation/OperatorExplanationBuilderTest.php` + +## User Scenarios & Testing *(mandatory)* + +### User Story 1 - Understand the dominant problem fast (Priority: P1) + +An operator opens a governance run detail page and needs to understand the dominant problem and next step without reading raw JSON. + +**Why this priority**: This is the core trust outcome. If the first read remains technical, the feature has not delivered its value. + +**Independent Test**: Can be fully tested by opening seeded governance runs on the canonical Monitoring detail route and verifying that an operator can identify what happened and what to do next from the default-visible summary alone. + +**Acceptance Scenarios**: + +1. **Given** a baseline compare run completed with follow-up because subject matching was ambiguous, **When** an operator opens the run detail page, **Then** the page states that the compare finished but the result is only partially trustworthy, names ambiguous matching as the dominant cause, and points the operator to scope review before any raw diagnostics. +2. **Given** a baseline capture run is blocked because no usable inventory basis exists, **When** an operator opens the run detail page, **Then** the page states that no baseline was captured, explains the missing prerequisite, and points to the prerequisite action before any raw JSON. + +--- + +### User Story 2 - Separate processing success from artifact trust (Priority: P2) + +An operator needs technically successful processing counts to remain visibly separate from whether the resulting artifact is usable, shareable, or decision-grade. + +**Why this priority**: False-green interpretations come from execution success reading like artifact success. + +**Independent Test**: Can be fully tested by reviewing governance runs where processing completed but the resulting artifact stayed stale, limited, internal-only, or otherwise not decision-grade. + +**Acceptance Scenarios**: + +1. **Given** an evidence snapshot generation run processed records successfully but produced a stale or incomplete snapshot, **When** an operator opens run detail, **Then** the page shows processing success separately from evidence usability and does not headline the run as unconditional success. +2. **Given** a review-pack generation run completed technically but the resulting pack is only suitable for internal follow-up, **When** an operator opens run detail, **Then** the page explains the pack outcome separately from the run completion state and names the correct follow-up. + +--- + +### User Story 3 - Read multi-cause degraded runs without flattening (Priority: P3) + +An operator needs a degraded governance run with several contributing causes to stay understandable without collapsing into one vague abstract state. + +**Why this priority**: Multi-cause degraded runs are where operator trust collapses fastest if the detail page is too generic. + +**Independent Test**: Can be fully tested by opening a seeded multi-cause degraded governance run and verifying that the page names one dominant cause first while preserving additional cause context in a secondary breakdown. + +**Acceptance Scenarios**: + +1. **Given** a tenant review generation run is limited by stale evidence and missing sections, **When** an operator opens run detail, **Then** the page shows one dominant cause with affected scale, preserves the second cause in secondary detail, and provides a next step that matches the dominant blocker. +2. **Given** a governance run contains both retryable and structural issues, **When** an operator opens run detail, **Then** the default summary distinguishes the dominant follow-up path instead of flattening all causes into one generic inspection message. + +### Edge Cases + +- A governance run can complete technically and still leave no decision-grade artifact. The page must explain that divergence directly instead of treating all-zero or fully processed counters as an all-clear. +- A governance run can have no persisted related artifact because input was missing or output was intentionally suppressed. The summary must explain the absence without requiring a raw payload. +- Multiple causes can have similar scale. The page must apply one stable dominant-cause rule so summary ordering does not become arbitrary between otherwise equivalent runs. +- Raw diagnostics can be unavailable, collapsed, or intentionally deferred. The first-pass summary must remain understandable from the persisted run truth alone. +- Scheduled or system-initiated governance runs can appear on the same page. The summary must stay humanized without implying that terminal user notifications or interactive start flows changed. + +## Requirements *(mandatory)* + +**Constitution alignment (required):** This feature does not introduce new Microsoft Graph calls, new mutation flows, or new scheduled or queued work. It changes the explanation hierarchy on the canonical Monitoring detail surface for already persisted governance runs. + +**Constitution alignment (PROP-001 / ABSTR-001 / PERSIST-001 / STATE-001 / BLOAT-001):** This feature introduces one bounded interpretation layer because direct mapping from existing outcome badges, reason labels, and raw context still forces operators to synthesize trust and next action themselves. A narrower per-family copy fix is insufficient because the same governance run families would drift apart. No new persistence, state family, or artifact truth source is added. + +**Constitution alignment (TEST-GOV-001):** Proof remains in focused feature coverage for Monitoring run detail. No new heavy-governance or browser family is required. Fixture cost stays explicit and limited to seeded run scenarios where execution outcome, artifact usability, and dominant cause differ. + +**Constitution alignment (OPS-UX):** Existing `OperationRun` lifecycle rules remain unchanged. The feature does not change the three feedback surfaces, does not change `OperationRun.status` or `OperationRun.outcome` ownership, and does not introduce new `summary_counts` keys or non-numeric summary values. Scheduled or system-run behavior remains unchanged, including initiator-null notification rules. New regression guards focus on run-detail explanation order and summary-count meaning, not lifecycle transitions. + +**Constitution alignment (RBAC-UX):** The affected authorization plane is the workspace-admin `/admin` Monitoring plane with tenant-entitlement enforcement for tenant-bound governance runs. Non-members or non-entitled viewers continue to receive 404. Members who can reach Monitoring but lack a currently required related action permission continue to receive 403 for that action. Existing server-side authorization remains authoritative for related artifact links and any linked mutation surfaces. Global search behavior is unchanged; `OperationRun` remains non-searchable and tenant-safe. + +**Constitution alignment (OPS-EX-AUTH-001):** No `/auth/*` behavior is introduced or broadened by this feature. + +**Constitution alignment (BADGE-001):** Any changed status emphasis on run detail continues to use centralized outcome, reason, and artifact-truth semantics. This feature changes ordering and explanation, not badge ownership or ad-hoc color rules. + +**Constitution alignment (UI-FIL-001):** The feature reuses native Filament detail primitives, sections, infolist-style summary areas, and existing Monitoring detail components. Local replacement markup for status language is intentionally avoided. Semantic emphasis stays in shared truth primitives and summary ordering rather than page-local color or border rules. + +**Constitution alignment (UI-NAMING-001):** The target object is the operation run. Primary summary language uses operator-facing terms such as completed with follow-up, blocked by prerequisite, partially trustworthy result, stale evidence basis, or internal-only pack outcome. Implementation-first terms such as raw reason-code slugs, payload keys, or support-tier labels remain secondary diagnostics only. + +**Constitution alignment (DECIDE-001):** The affected surface remains a Tertiary Evidence / Diagnostics Surface. It does not become a new primary queue. Its human-in-the-loop purpose is to make one drilled-in governance case understandable without further reconstruction. Immediate visibility must include impact, dominant cause, trust direction, and next action. Raw diagnostics remain preserved but explicitly secondary. + +**Constitution alignment (UI-CONST-001 / UI-SURF-001 / ACTSURF-001 / UI-HARD-001 / UI-EX-001 / UI-REVIEW-001 / HDR-001):** The chosen action-surface class is record/detail because the operator is already inside one explicit run. The most likely next action is to open the related artifact or return to the source workflow with the correct next step. The one primary inspect model remains the existing operation-run detail page. There is no row click on the detail surface. Pure navigation stays in existing related links and does not compete with mutation. No destructive actions are added. Canonical routes remain `/admin/operations` and `/admin/operations/{run}`. Scope signals remain workspace context, tenant context when relevant, and related artifact family. The canonical noun remains `Operation run`. + +**Constitution alignment (ACTSURF-001 - action hierarchy):** No header, row, bulk, or workbench action inventory changes are introduced. The feature must not use explanation hardening as a backdoor to add retry, cancel, force-fail, or other intervention controls. + +**Constitution alignment (OPSURF-001):** Default-visible content on `/admin/operations/{run}` must stay operator-first. Diagnostics are secondary and explicitly revealed below the primary summary. Status dimensions must stay distinct: execution outcome, artifact usability, dominant cause, and next-step category. Workspace and tenant context remain visible in the existing Monitoring detail shell. Any linked mutation continues to communicate its scope on the native surface where it lives. + +**Constitution alignment (UI-SEM-001 / LAYER-001 / TEST-TRUTH-001):** Direct mapping from canonical run truth to UI is insufficient because current badges and raw payloads still require operator interpretation. This feature adds one bounded run-summary layer and does not introduce redundant truth across models, service results, presenters, wrappers, or persisted mirrors. Tests focus on business consequences: first-pass understanding, no false-green reading, and consistent next-step guidance. + +**Constitution alignment (Filament Action Surfaces):** The feature modifies a Filament-backed detail surface and therefore includes a UI Action Matrix. The Action Surface Contract remains satisfied: exactly one primary inspect model exists, redundant `View` actions remain absent, empty action groups remain absent, and no destructive placement changes occur. UI-FIL-001 is satisfied with the existing diagnostic-surface exception retained. + +**Constitution alignment (UX-001 — Layout & Information Architecture):** The affected screen remains a structured detail page. Humanized summary content must live in deliberate summary sections ahead of diagnostics, not as scattered helper text. No create or edit layout changes are introduced, and no UX-001 exemption is needed beyond the already accepted diagnostic detail nature of the page. + +### Functional Requirements + +- **FR-220-001**: The system MUST derive a humanized governance-run summary from existing run outcome, reason translation, artifact-truth, and explanation inputs without creating a new persisted truth source. +- **FR-220-002**: Canonical governance run detail MUST lead with exactly one dominant artifact-impact statement, one short supporting reason, one next-step category, and one affected-scale cue in the default-visible summary area. +- **FR-220-003**: Governance run detail MUST keep processing success and throughput counts visibly separate from resulting artifact usability, trustworthiness, shareability, or decision-readiness. +- **FR-220-004**: For multi-cause degraded governance runs, the detail page MUST identify one dominant cause first and preserve additional causes in a secondary breakdown instead of flattening them into one generic state. +- **FR-220-005**: Next-step guidance on governance run detail MUST distinguish at least retry later, resume capture or generation, refresh prerequisite data, review scope or ambiguous matches, manually validate, and no further action when the persisted truth supports those distinctions. +- **FR-220-006**: Raw JSON, raw reason-code inventories, provider payloads, and low-level counters MUST remain available on governance run detail but MUST not be the first explanatory block. +- **FR-220-007**: The same cause class across covered governance run families MUST render with the same primary reading direction and next-step category on canonical run detail. +- **FR-220-008**: The first implementation slice MUST cover governance runs for baseline capture, baseline compare, evidence snapshot generation (`tenant.evidence.snapshot.generate`), tenant review composition (`tenant.review.compose`), and review-pack generation (`tenant.review_pack.generate`). +- **FR-220-009**: A governance run that completed technically but produced a degraded, blocked, stale, internal-only, or otherwise non-decision-grade artifact MUST explain that divergence explicitly and MUST NOT headline as unconditional success. +- **FR-220-010**: All-zero or zero-output governance runs MUST explain why no decision-grade result exists and MUST NOT read as neutral or implicit all-clear. +- **FR-220-011**: Humanized summaries, affected-scale cues, and related artifact links on canonical Monitoring run detail MUST remain tenant-safe and must not leak inaccessible tenant context or artifact hints. +- **FR-220-012**: This feature MUST NOT introduce new `OperationRun` statuses, outcomes, reason-code families, `summary_counts` keys, notification surfaces, or run-detail intervention controls. +- **FR-220-013**: Existing action inventory on operation-run detail MUST remain unchanged; humanized summaries must not add retry, cancel, force-fail, or other mutation controls. +- **FR-220-014**: Primary summary vocabulary on governance run detail MUST use the shared operator language established by Specs 156, 157, 158, 161, and 214 rather than implementation-first labels. + +## 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 | +|---|---|---|---|---|---|---|---|---|---|---| +| Canonical Monitoring operation run detail for governance operations | `apps/platform/app/Filament/Pages/Monitoring/Operations.php`; `apps/platform/app/Filament/Resources/OperationRunResource.php` | none added | Existing explicit navigation from the operations list or related links remains the only inspect model | none added | none | n/a | Existing related-artifact navigation remains; no new action labels introduced by this feature | n/a | no new audit behavior | Action Surface Contract remains satisfied. No redundant `View` action, no empty action groups, no destructive change. Existing diagnostic exception remains, but summary-first hierarchy becomes mandatory. | + +### Key Entities *(include if feature involves data)* + +- **Humanized Governance Run Summary**: A derived first-pass summary for one governance operation run containing the dominant artifact impact, short reason, affected scale, and next-step direction. +- **Dominant Cause Breakdown**: A derived secondary explanation that preserves additional causes when a governance run is degraded for more than one reason. +- **Artifact Impact Statement**: The operator-facing truth about whether the resulting artifact is trustworthy, limited, blocked, internal-only, stale, or otherwise unsuitable for immediate reliance, separate from execution success. + +## Assumptions & Dependencies + +- Specs 156, 157, 158, 161, and 214 remain the authoritative foundations for operator vocabulary, reason translation, artifact-truth semantics, explanation patterns, and governance-surface compression. +- The canonical Monitoring run viewer from Spec 144 remains the existing detail surface and data-access contract for this slice. +- Covered governance run families already persist enough reason and outcome data to drive a first-pass summary without adding new persistence. +- This spec intentionally stays on run detail and does not pull surrounding artifact list or detail surfaces back into scope. + +## Non-Goals + +- Redesign the operations list, Monitoring landing page, or dashboard attention surfaces. +- Add retry, cancel, force-fail, or reconcile-now controls to run detail. +- Remove raw JSON or low-level diagnostics from the run-detail page. +- Create a new lifecycle or status model for `OperationRun`. +- Expand the slice to every non-governance run family. + +## Success Criteria *(mandatory)* + +### Measurable Outcomes + +- **SC-220-001**: In seeded acceptance review, an operator can determine within 15 seconds from the canonical governance run-detail page what happened, whether the resulting artifact is trustworthy enough to act on, and what the next step is without opening raw diagnostics. +- **SC-220-002**: In automated coverage, 100% of covered scenarios where execution success diverges from artifact trust show those truths as separate visible statements with no contradictory headline. +- **SC-220-003**: In automated coverage, 100% of covered multi-cause degraded governance runs show one dominant cause first and preserve at least one additional cause in secondary detail. +- **SC-220-004**: In acceptance review and regression tests, raw JSON and low-level diagnostics are never the first explanatory block on the run-detail page for any covered governance run family. diff --git a/specs/220-governance-run-summaries/tasks.md b/specs/220-governance-run-summaries/tasks.md new file mode 100644 index 00000000..5d9a0031 --- /dev/null +++ b/specs/220-governance-run-summaries/tasks.md @@ -0,0 +1,146 @@ +# Tasks: Humanized Diagnostic Summaries for Governance Operations + +**Input**: Design documents from `/specs/220-governance-run-summaries/` +**Prerequisites**: `plan.md`, `spec.md`, `research.md`, `data-model.md`, `contracts/governance-run-summaries.logical.openapi.yaml`, `quickstart.md` + +**Tests**: Required. This feature changes runtime behavior on a Filament-backed Monitoring detail surface, so Pest feature and unit coverage must ship with the implementation. + +**Test Governance Checklist** + +- Lane assignment stays `fast-feedback` plus `confidence` and remains the narrowest sufficient proof for this surface change. +- New tests stay in focused Monitoring and unit suites; no heavy-governance or browser family is introduced. +- Shared helpers and fixtures remain opt-in, especially `BuildsGovernanceArtifactTruthFixtures`. +- Validation commands stay limited to the focused run-detail suites listed in `specs/220-governance-run-summaries/quickstart.md`. +- The declared surface profile remains `monitoring-state-page`. +- Any budget or escalation note stays inside this feature instead of becoming a follow-up spec. + +## Phase 1: Setup (Shared Test Scaffolding) + +**Purpose**: Create the focused test seams and fixture hooks the implementation will use. + +- [X] T001 [P] Create the focused canonical run-detail feature suite and local scenario helpers for zero-output and multi-cause runs in `apps/platform/tests/Feature/Monitoring/GovernanceOperationRunSummariesTest.php` +- [X] T002 [P] Create the focused summary-derivation unit suite in `apps/platform/tests/Unit/Support/OpsUx/GovernanceRunDiagnosticSummaryBuilderTest.php` +- [X] T003 [P] Extend only generic opt-in shared governance fixture builders for blocked, stale, and internal-only artifact cases in `apps/platform/tests/Feature/Concerns/BuildsGovernanceArtifactTruthFixtures.php` + +--- + +## Phase 2: Foundational (Blocking Prerequisites) + +**Purpose**: Establish the shared derived-summary seam that all user stories build on. + +**⚠️ CRITICAL**: No user story work should start until this phase is complete. + +- [X] T004 Create the derived summary value object in `apps/platform/app/Support/OpsUx/GovernanceRunDiagnosticSummary.php` +- [X] T005 Create the shared summary builder with canonical `OperationRun`, artifact-truth, reason, and explanation inputs in `apps/platform/app/Support/OpsUx/GovernanceRunDiagnosticSummaryBuilder.php` +- [X] T006 Wire memoized governance summary access into `apps/platform/app/Support/OpsUx/OperationUxPresenter.php` +- [X] T007 [P] Add guard coverage that summary derivation preserves canonical `summary_counts` meaning and does not invent new count keys in `apps/platform/tests/Unit/Support/OpsUx/GovernanceRunDiagnosticSummaryBuilderTest.php` +- [X] T008 [P] Extend canonical operator-language assertions and explicit next-step category matrix coverage for `retry later`, `resume capture or generation`, `refresh prerequisite data`, `review scope or ambiguous matches`, `manually validate`, and `no further action` in `apps/platform/tests/Unit/Support/OperatorExplanation/OperatorExplanationBuilderTest.php` and `apps/platform/tests/Unit/Support/OpsUx/GovernanceRunDiagnosticSummaryBuilderTest.php` + +**Checkpoint**: The shared summary seam exists, is memoized through the current Ops UX presenter, and is guarded against count-contract drift. + +--- + +## Phase 3: User Story 1 - Understand the dominant problem fast (Priority: P1) 🎯 MVP + +**Goal**: Make the canonical governance run-detail page explain the dominant problem, affected scale, and next step before any raw diagnostics. + +**Independent Test**: Open seeded baseline-capture and baseline-compare runs on `/admin/operations/{run}` and confirm the default-visible summary answers what happened and what to do next without opening diagnostic sections. + +### Tests for User Story 1 + +- [X] T009 [P] [US1] Add feature scenarios for baseline-capture and baseline-compare summary-first hierarchy, no new header actions, and zero-output messaging in `apps/platform/tests/Feature/Monitoring/GovernanceOperationRunSummariesTest.php` +- [X] T010 [P] [US1] Add unit cases for dominant headline, supporting reason, affected-scale cue, and next-step selection for baseline-capture and baseline-compare runs in `apps/platform/tests/Unit/Support/OpsUx/GovernanceRunDiagnosticSummaryBuilderTest.php` + +### Implementation for User Story 1 + +- [X] T011 [US1] Implement `baseline.capture` and `baseline.compare` summary mappings in `apps/platform/app/Support/OpsUx/GovernanceRunDiagnosticSummaryBuilder.php` +- [X] T012 [US1] Expose baseline summary facts through the memoized presenter API in `apps/platform/app/Support/OpsUx/OperationUxPresenter.php` +- [X] T013 [US1] Render the default-visible summary block before technical diagnostics in `apps/platform/app/Filament/Resources/OperationRunResource.php` +- [X] T014 [US1] Keep canonical context, lifecycle, and restore banners specialized without duplicating the dominant explanation in `apps/platform/app/Filament/Pages/Operations/TenantlessOperationRunViewer.php` +- [X] T015 [US1] Preserve summary-first page-shell order for canonical run detail in `apps/platform/resources/views/filament/pages/operations/tenantless-operation-run-viewer.blade.php` +- [X] T016 [US1] Update summary fallback expectations for the new first-read hierarchy in `apps/platform/tests/Feature/Monitoring/GovernanceRunExplanationFallbackTest.php` +- [X] T017 [US1] Update run-detail hierarchy assertions so diagnostics stay secondary in `apps/platform/tests/Feature/Monitoring/ArtifactTruthRunDetailTest.php` + +**Checkpoint**: Baseline capture and baseline compare runs are readable from the summary block alone, with diagnostics preserved but no longer leading the page. + +--- + +## Phase 4: User Story 2 - Separate processing success from artifact trust (Priority: P2) + +**Goal**: Keep execution completion visible while clearly separating whether the resulting artifact is trustworthy, limited, stale, or internal-only. + +**Independent Test**: Open seeded evidence-snapshot and review-pack runs where processing completed but the artifact is not decision-grade, and confirm the page shows those truths as separate visible statements. + +### Tests for User Story 2 + +- [X] T018 [P] [US2] Add feature scenarios for evidence-snapshot and review-pack runs that separate processing completion from artifact trust in `apps/platform/tests/Feature/Monitoring/GovernanceOperationRunSummariesTest.php` +- [X] T019 [P] [US2] Add regression assertions for execution-outcome versus artifact-impact separation in `apps/platform/tests/Feature/Filament/OperationRunBaselineTruthSurfaceTest.php` +- [X] T020 [P] [US2] Add positive and negative authorization coverage for tenant-safe summary rendering and related links in `apps/platform/tests/Feature/Authorization/OperatorExplanationSurfaceAuthorizationTest.php` + +### Implementation for User Story 2 + +- [X] T021 [US2] Implement `tenant.evidence.snapshot.generate` and `tenant.review_pack.generate` summary mappings with distinct execution and artifact-impact facts in `apps/platform/app/Support/OpsUx/GovernanceRunDiagnosticSummaryBuilder.php` +- [X] T022 [US2] Render separated execution outcome and artifact-impact facts in `apps/platform/app/Filament/Resources/OperationRunResource.php` +- [X] T023 [US2] Keep related artifact navigation and tenant-context continuity aligned with summary copy in `apps/platform/app/Filament/Pages/Operations/TenantlessOperationRunViewer.php` +- [X] T024 [US2] Extend canonical route isolation assertions for deny-as-not-found and in-scope `403` behavior in `apps/platform/tests/Feature/RunAuthorizationTenantIsolationTest.php` + +**Checkpoint**: A technically completed run can no longer read like unconditional success when the artifact itself is stale, limited, or internal-only. + +--- + +## Phase 5: User Story 3 - Read multi-cause degraded runs without flattening (Priority: P3) + +**Goal**: Keep degraded governance runs understandable by showing one dominant cause first while preserving secondary causes and affected-scale context. + +**Independent Test**: Open a seeded multi-cause tenant-review run on `/admin/operations/{run}` and confirm the page shows one dominant cause first, preserves secondary causes, and keeps the same ordering across reloads. + +### Tests for User Story 3 + +- [X] T025 [P] [US3] Add feature scenarios for tenant-review multi-cause degraded runs, stable dominant-cause ordering, and cross-family parity for the same cause class across at least two covered governance families in `apps/platform/tests/Feature/Monitoring/GovernanceOperationRunSummariesTest.php` +- [X] T026 [P] [US3] Add unit cases for dominant-cause ranking, secondary causes, and affected-scale confidence in `apps/platform/tests/Unit/Support/OpsUx/GovernanceRunDiagnosticSummaryBuilderTest.php` + +### Implementation for User Story 3 + +- [X] T027 [US3] Implement `tenant.review.compose` multi-cause summary mapping and shared ranking rules across covered governance families in `apps/platform/app/Support/OpsUx/GovernanceRunDiagnosticSummaryBuilder.php` +- [X] T028 [US3] Render secondary-cause breakdown and affected-scale detail without flattening the dominant explanation in `apps/platform/app/Filament/Resources/OperationRunResource.php` +- [X] T029 [US3] Suppress inaccessible tenant and artifact hints in summary text and related-navigation branches in `apps/platform/app/Support/OpsUx/GovernanceRunDiagnosticSummaryBuilder.php` +- [X] T030 [US3] Keep canonical run-detail banners and page-shell copy free of duplicated multi-cause messaging in `apps/platform/app/Filament/Pages/Operations/TenantlessOperationRunViewer.php` +- [X] T031 [US3] Extend authorization surface assertions so inaccessible related context never leaks through summary or navigation output in `apps/platform/tests/Feature/Authorization/OperatorExplanationSurfaceAuthorizationTest.php` + +**Checkpoint**: Multi-cause degraded runs stay human-readable, deterministically ordered, and tenant-safe. + +--- + +## Phase 6: Polish & Cross-Cutting Concerns + +**Purpose**: Final guardrail review, formatting, focused validation, and manual smoke. + +- [X] T032 [P] Review monitoring-state-page guardrail coverage, lane assignment, and fixture-cost notes against `specs/220-governance-run-summaries/plan.md` and `specs/220-governance-run-summaries/quickstart.md` +- [X] T033 [P] Format changed PHP and Blade files including `apps/platform/app/Support/OpsUx/GovernanceRunDiagnosticSummaryBuilder.php`, `apps/platform/app/Filament/Resources/OperationRunResource.php`, `apps/platform/app/Filament/Pages/Operations/TenantlessOperationRunViewer.php`, and `apps/platform/resources/views/filament/pages/operations/tenantless-operation-run-viewer.blade.php` +- [X] T034 Run the canonical proving commands for `apps/platform/tests/Feature/Monitoring/GovernanceOperationRunSummariesTest.php`, `apps/platform/tests/Feature/Filament/OperationRunBaselineTruthSurfaceTest.php`, `apps/platform/tests/Feature/Monitoring/ArtifactTruthRunDetailTest.php`, `apps/platform/tests/Feature/Authorization/OperatorExplanationSurfaceAuthorizationTest.php`, `apps/platform/tests/Feature/RunAuthorizationTenantIsolationTest.php`, `apps/platform/tests/Unit/Support/OpsUx/GovernanceRunDiagnosticSummaryBuilderTest.php`, and `apps/platform/tests/Unit/Support/OperatorExplanation/OperatorExplanationBuilderTest.php` +- [X] T035 [P] Execute the manual smoke checks for summary-first hierarchy, zero-output runs, multi-cause runs, cross-family parity, and tenant-safe related links in `specs/220-governance-run-summaries/quickstart.md` + +--- + +## Dependencies + +- Setup tasks `T001-T003` can begin immediately. +- Foundational tasks `T004-T008` depend on setup and block all story work. +- User Story 1 depends on Phase 2 and is the MVP slice. +- User Story 2 depends on Phase 2 and the shared summary rendering established in User Story 1 because it extends the same builder and canonical detail surface. +- User Story 3 depends on Phase 2 and should follow User Story 1 because it extends the same ranking and rendering seams; it can overlap with late User Story 2 test work once the shared builder contract is stable. +- Polish tasks depend on all user stories being complete. + +## Parallel Execution Examples + +- **US1**: Run `T009` and `T010` together; after `T011-T012`, split `T013`, `T014`, and `T015` across different files. +- **US2**: Run `T018`, `T019`, and `T020` together; after `T021`, split `T022`, `T023`, and `T024` across resource, page, and authorization files. +- **US3**: Run `T025` and `T026` together; after `T027`, split `T028`, `T029`, and `T030` while keeping `T031` as the final authorization proof. + +## Implementation Strategy + +- Finish Setup and Foundational phases first so the derived summary seam and opt-in fixtures are stable. +- Deliver User Story 1 as the MVP because it provides the first operator-visible improvement on canonical run detail. +- Extend the same seam through User Story 2 to separate execution success from artifact trust across additional governance families. +- Finish with User Story 3 to lock deterministic multi-cause ranking and no-leak summary behavior. +- Close with formatting, focused proving commands, and the manual smoke pass documented in `quickstart.md`. diff --git a/specs/221-findings-operator-inbox/checklists/requirements.md b/specs/221-findings-operator-inbox/checklists/requirements.md new file mode 100644 index 00000000..be74896e --- /dev/null +++ b/specs/221-findings-operator-inbox/checklists/requirements.md @@ -0,0 +1,36 @@ +# Specification Quality Checklist: Findings Operator Inbox V1 + +**Purpose**: Validate specification completeness and quality before proceeding to planning +**Created**: 2026-04-20 +**Feature**: [spec.md](../spec.md) + +## Content Quality + +- [x] No implementation details (languages, frameworks, APIs) +- [x] Focused on user value and business needs +- [x] Written for non-technical stakeholders +- [x] All mandatory sections completed + +## Requirement Completeness + +- [x] No [NEEDS CLARIFICATION] markers remain +- [x] Requirements are testable and unambiguous +- [x] Success criteria are measurable +- [x] Success criteria are technology-agnostic (no implementation details) +- [x] All acceptance scenarios are defined +- [x] Edge cases are identified +- [x] Scope is clearly bounded +- [x] Dependencies and assumptions identified + +## Feature Readiness + +- [x] All functional requirements have clear acceptance criteria +- [x] User scenarios cover primary flows +- [x] Feature meets measurable outcomes defined in Success Criteria +- [x] No implementation details leak into specification + +## Notes + +- Validated after initial spec drafting on 2026-04-20. +- No clarification markers remain. +- Candidate ledger was cleaned for the two findings candidates that are now specced as Spec 219 and Spec 221. \ No newline at end of file diff --git a/specs/221-findings-operator-inbox/contracts/findings-operator-inbox.logical.openapi.yaml b/specs/221-findings-operator-inbox/contracts/findings-operator-inbox.logical.openapi.yaml new file mode 100644 index 00000000..ce64ddc3 --- /dev/null +++ b/specs/221-findings-operator-inbox/contracts/findings-operator-inbox.logical.openapi.yaml @@ -0,0 +1,399 @@ +openapi: 3.1.0 +info: + title: Findings Operator Inbox Surface Contract + version: 1.0.0 + description: >- + Internal reference contract for the canonical My Findings inbox and the + workspace overview Assigned to me signal. The application continues to + return rendered HTML through Filament and Livewire. The vendor media types + below document the structured page models that must be derivable before + rendering. This is not a public API commitment. +paths: + /admin/findings/my-work: + get: + summary: Canonical personal findings inbox + description: >- + Returns the rendered admin-plane inbox for the current user's visible + assigned open findings. Personal assignment scope is fixed. Tenant + prefiltering may be derived from the active admin tenant context. + responses: + '302': + description: Redirects into the existing workspace or tenant chooser flow when membership exists but workspace context is not yet established + '200': + description: Rendered My Findings inbox page + content: + text/html: + schema: + type: string + application/vnd.tenantpilot.my-findings-inbox+json: + schema: + $ref: '#/components/schemas/MyFindingsInboxPage' + '404': + description: Workspace scope is not visible because membership is missing or out of scope + /admin: + get: + summary: Workspace overview with assigned-to-me signal + description: >- + Returns the rendered workspace overview. The vendor media type documents + the embedded personal findings signal used to decide whether assigned + findings work exists before opening the inbox. + responses: + '302': + description: Redirects into the existing workspace or tenant chooser flow when membership exists but workspace context is not yet established + '200': + description: Rendered workspace overview page + content: + text/html: + schema: + type: string + application/vnd.tenantpilot.workspace-overview-my-findings+json: + schema: + $ref: '#/components/schemas/WorkspaceOverviewMyFindingsSignalSurface' + '404': + description: Workspace scope is not visible because membership is missing or out of scope + /admin/t/{tenant}/findings/{finding}: + get: + summary: Tenant finding detail with inbox continuity support + description: >- + Returns the rendered tenant finding detail page. The logical contract + below documents only the continuity inputs required when the page is + opened from the My Findings inbox. + parameters: + - name: tenant + in: path + required: true + schema: + type: integer + - name: finding + in: path + required: true + schema: + type: integer + - name: nav + in: query + required: false + style: deepObject + explode: true + schema: + $ref: '#/components/schemas/CanonicalNavigationContext' + responses: + '200': + description: Rendered tenant finding detail page + content: + text/html: + schema: + type: string + application/vnd.tenantpilot.finding-detail-continuation+json: + schema: + $ref: '#/components/schemas/FindingDetailContinuation' + '403': + description: Viewer is in scope but lacks the existing findings capability for the tenant detail destination + '404': + description: Tenant or finding is not visible because workspace or tenant entitlement is missing +components: + schemas: + MyFindingsInboxPage: + type: object + required: + - header + - appliedScope + - availableFilters + - summaryCounts + - rows + - emptyState + properties: + header: + $ref: '#/components/schemas/InboxHeader' + appliedScope: + $ref: '#/components/schemas/InboxAppliedScope' + availableFilters: + description: Includes the fixed assignee-scope filter plus tenant, overdue, reopened, and high-severity filters. Tenant filter options are derived only from visible capability-eligible tenants. + type: array + items: + $ref: '#/components/schemas/InboxFilterDefinition' + summaryCounts: + $ref: '#/components/schemas/MyFindingsSummaryCounts' + rows: + description: Rows are ordered overdue first, reopened non-overdue second, then remaining findings. Within each bucket, rows with due dates sort by dueAt ascending, rows without due dates sort last, and remaining ties sort by findingId descending. + type: array + items: + $ref: '#/components/schemas/AssignedFindingInboxRow' + emptyState: + oneOf: + - $ref: '#/components/schemas/InboxEmptyState' + - type: 'null' + InboxHeader: + type: object + required: + - title + - description + properties: + title: + type: string + enum: + - My Findings + description: + type: string + clearTenantFilterAction: + oneOf: + - $ref: '#/components/schemas/ActionLink' + - type: 'null' + InboxAppliedScope: + type: object + required: + - workspaceScoped + - assigneeScope + - tenantPrefilterSource + properties: + workspaceScoped: + type: boolean + assigneeScope: + type: string + enum: + - current_user_only + tenantPrefilterSource: + type: string + enum: + - active_tenant_context + - explicit_filter + - none + tenantLabel: + type: + - string + - 'null' + InboxFilterDefinition: + type: object + required: + - key + - label + - fixed + properties: + key: + type: string + enum: + - assignee_scope + - tenant + - overdue + - reopened + - high_severity + label: + type: string + fixed: + type: boolean + options: + type: array + items: + $ref: '#/components/schemas/FilterOption' + FilterOption: + type: object + required: + - value + - label + properties: + value: + type: string + label: + type: string + MyFindingsSummaryCounts: + type: object + description: Counts derived from the currently visible inbox queue after the fixed assignee scope and any active tenant, overdue, reopened, or high-severity filters are applied. + required: + - openAssigned + - overdueAssigned + properties: + openAssigned: + type: integer + minimum: 0 + overdueAssigned: + type: integer + minimum: 0 + AssignedFindingInboxRow: + type: object + required: + - findingId + - tenantId + - tenantLabel + - summary + - severity + - status + - dueState + - detailUrl + properties: + findingId: + type: integer + tenantId: + type: integer + tenantLabel: + type: string + summary: + type: string + severity: + $ref: '#/components/schemas/Badge' + status: + $ref: '#/components/schemas/Badge' + dueAt: + type: + - string + - 'null' + format: date-time + dueState: + $ref: '#/components/schemas/DueState' + reopened: + type: boolean + ownerLabel: + type: + - string + - 'null' + detailUrl: + type: string + navigationContext: + oneOf: + - $ref: '#/components/schemas/CanonicalNavigationContext' + - type: 'null' + DueState: + type: object + required: + - label + - tone + properties: + label: + type: string + tone: + type: string + enum: + - calm + - warning + - danger + InboxEmptyState: + type: object + required: + - title + - body + - action + properties: + title: + type: string + body: + type: string + reason: + type: string + enum: + - no_visible_assigned_work + - active_tenant_prefilter_excludes_rows + action: + description: For `active_tenant_prefilter_excludes_rows`, clears the tenant prefilter and returns the queue to all visible tenants. For `no_visible_assigned_work`, opens the active tenant's findings list when tenant context exists; otherwise opens `/admin/choose-tenant` so the operator can establish tenant context before entering tenant findings. + $ref: '#/components/schemas/ActionLink' + WorkspaceOverviewMyFindingsSignalSurface: + type: object + required: + - workspaceId + - myFindingsSignal + properties: + workspaceId: + type: integer + myFindingsSignal: + $ref: '#/components/schemas/MyFindingsSignal' + MyFindingsSignal: + type: object + required: + - openAssignedCount + - overdueAssignedCount + - isCalm + - headline + - cta + properties: + openAssignedCount: + type: integer + minimum: 0 + overdueAssignedCount: + type: integer + minimum: 0 + isCalm: + type: boolean + headline: + type: string + enum: + - Assigned to me + description: + type: + - string + - 'null' + cta: + $ref: '#/components/schemas/OpenMyFindingsActionLink' + FindingDetailContinuation: + type: object + description: Continuity payload for tenant finding detail when it is opened from the My Findings inbox. The backLink is present whenever canonical inbox navigation context is provided and may be null only for direct entry without inbox continuity context. + required: + - findingId + - tenantId + properties: + findingId: + type: integer + tenantId: + type: integer + backLink: + description: Present when the detail page is reached from the My Findings inbox with canonical navigation context; null only for direct navigation that did not originate from the inbox. + oneOf: + - $ref: '#/components/schemas/BackToMyFindingsActionLink' + - type: 'null' + CanonicalNavigationContext: + type: object + required: + - source_surface + - canonical_route_name + properties: + source_surface: + type: string + canonical_route_name: + type: string + tenant_id: + type: + - integer + - 'null' + back_label: + type: + - string + - 'null' + back_url: + type: + - string + - 'null' + ActionLink: + type: object + required: + - label + - url + properties: + label: + type: string + url: + type: string + OpenMyFindingsActionLink: + allOf: + - $ref: '#/components/schemas/ActionLink' + - type: object + properties: + label: + type: string + enum: + - Open my findings + BackToMyFindingsActionLink: + allOf: + - $ref: '#/components/schemas/ActionLink' + - type: object + properties: + label: + type: string + enum: + - Back to my findings + Badge: + type: object + required: + - label + properties: + label: + type: string + color: + type: + - string + - 'null' \ No newline at end of file diff --git a/specs/221-findings-operator-inbox/data-model.md b/specs/221-findings-operator-inbox/data-model.md new file mode 100644 index 00000000..e2a41aa4 --- /dev/null +++ b/specs/221-findings-operator-inbox/data-model.md @@ -0,0 +1,169 @@ +# Data Model: Findings Operator Inbox V1 + +## Overview + +This feature does not add or modify persisted entities. It introduces two derived read models: + +- the canonical admin-plane `My Findings` inbox at `/admin/findings/my-work` +- one compact `Assigned to me` signal on `/admin` + +Both remain projections over existing finding, tenant membership, and workspace context truth. + +## Existing Persistent Inputs + +### 1. Finding + +- Purpose: Tenant-owned workflow record representing current governance or execution work. +- Key persisted fields used by this feature: + - `id` + - `workspace_id` + - `tenant_id` + - `status` + - `severity` + - `due_at` + - `subject_display_name` + - `owner_user_id` + - `assignee_user_id` + - `reopened_at` +- Relationships used by this feature: + - `tenant()` + - `ownerUser()` + - `assigneeUser()` + +Relevant existing semantics: + +- `Finding::openStatusesForQuery()` defines inbox inclusion for open work. +- `Finding::openStatuses()` and terminal statuses remain unchanged. +- Spec 219 defines assignee inclusion and owner-only exclusion. + +### 2. Tenant + +- Purpose: Tenant boundary for findings ownership and tenant-plane detail navigation. +- Key persisted fields used by this feature: + - `id` + - `workspace_id` + - `name` + - `external_id` + - `status` + +### 3. TenantMembership + +- Purpose: Per-tenant entitlement boundary for visibility. +- Key persisted fields used by this feature: + - `tenant_id` + - `user_id` + - `role` + +The inbox and overview signal must only materialize findings from tenants where the current user still has membership and findings-view entitlement. + +### 4. Workspace Context + +- Purpose: Active workspace selection in the admin plane. +- Source: Existing workspace session context, not a new persisted model for this feature. +- Effect on this feature: + - gates entry into the admin inbox + - constrains visible tenants to the current workspace + - feeds the workspace overview signal + +## Derived Presentation Entities + +### 1. AssignedFindingInboxRow + +Logical row model for `/admin/findings/my-work`. + +| Field | Meaning | Source | +|---|---|---| +| `findingId` | Target finding identifier | `Finding.id` | +| `tenantId` | Tenant route scope for detail drilldown | `Finding.tenant_id` | +| `tenantLabel` | Tenant name visible in the queue | `Tenant.name` | +| `summary` | Operator-facing finding summary | `Finding.subject_display_name` plus existing fallback logic | +| `severity` | Severity badge value | `Finding.severity` | +| `status` | Workflow/lifecycle badge value | `Finding.status` | +| `dueAt` | Due date if present | `Finding.due_at` | +| `dueState` | Derived urgency label such as overdue or due soon | existing finding due-state logic | +| `reopened` | Whether reopened context should be emphasized | `Finding.status === reopened` or existing lifecycle cues | +| `ownerLabel` | Accountable owner when different from assignee | `ownerUser.name` | +| `detailUrl` | Tenant finding detail route | derived from tenant finding view route | +| `navigationContext` | Return-path payload back to the inbox | derived from `CanonicalNavigationContext` | + +Validation rules: + +- Row inclusion requires all of the following: + - finding belongs to the current workspace + - finding belongs to a tenant the current user may inspect + - finding status is in `Finding::openStatusesForQuery()` + - `assignee_user_id` equals the current user ID +- Owner-only findings are excluded. +- Hidden-tenant findings produce no row, no count, and no filter option. + +### 2. MyFindingsInboxState + +Logical state model for the inbox page. + +| Field | Meaning | +|---|---| +| `workspaceId` | Current admin workspace scope | +| `assigneeUserId` | Fixed personal-work scope | +| `tenantFilter` | Optional active-tenant prefilter, defaulted from canonical admin tenant context | +| `overdueOnly` | Optional urgency narrowing | +| `reopenedOnly` | Optional reopened-work narrowing | +| `highSeverityOnly` | Optional severity narrowing | + +Rules: + +- `assigneeUserId` is fixed and cannot be cleared in v1. +- `tenantFilter` is clearable. +- `tenantFilter` values may only reference entitled tenants. +- Invalid or stale tenant filter state is discarded rather than widening visibility. +- Inbox summary counts reflect the currently visible queue after the fixed assignee scope and any active tenant, overdue, reopened, or high-severity filters are applied. + +### 3. MyFindingsSignal + +Logical summary model for the workspace overview. + +| Field | Meaning | Source | +|---|---|---| +| `openAssignedCount` | Count of visible open assigned findings | derived `Finding` count | +| `overdueAssignedCount` | Count of visible overdue assigned findings | derived `Finding` count with `due_at < now()` | +| `isCalm` | Whether the signal should render calm wording | derived from the two counts | +| `headline` | Operator-facing signal summary | derived presentation copy | +| `description` | Supporting copy clarifying visible-scope truth | derived presentation copy | +| `ctaLabel` | Explicit drill-in label | fixed vocabulary `Open my findings` | +| `ctaUrl` | Canonical inbox route | derived from the new admin page URL | + +Validation rules: + +- Counts are workspace-scoped and tenant-entitlement scoped. +- The signal never implies hidden or inaccessible work. +- Calm wording is only allowed when both visible counts are zero. + +## State And Ordering Rules + +### Inbox inclusion order + +1. Restrict to the current workspace. +2. Restrict to visible tenant IDs. +3. Restrict to `assignee_user_id = current user`. +4. Restrict to `Finding::openStatusesForQuery()`. +5. Apply optional tenant/overdue/reopened/high-severity filters. +6. Sort overdue work first, reopened non-overdue work next, then remaining work. +7. Within each urgency bucket, rows with due dates sort by `dueAt` ascending, rows without due dates sort last, and remaining ties sort by `findingId` descending. + +### Urgency semantics + +- Overdue work is the highest-priority urgency bucket. +- Reopened non-overdue work is the next urgency bucket. +- High-severity remains a filter and emphasis cue rather than a separate mandatory sort bucket. +- Terminal findings are never part of the inbox or signal. + +### Empty-state semantics + +- If no visible assigned open findings exist anywhere in scope, the inbox shows a calm empty state. +- If the active tenant prefilter causes the empty state while other visible tenants still have work, the empty state must explain the tenant boundary and provide a clear fallback CTA. + +## Authorization-sensitive Output + +- Tenant labels, filter values, rows, and counts are only derived from entitled tenants. +- The inbox itself is workspace-context dependent. +- Detail navigation remains tenant-scoped and must preserve existing `404`/`403` semantics on the destination. +- The derived signal and row model remain useful without ever revealing hidden tenant names or quantities. \ No newline at end of file diff --git a/specs/221-findings-operator-inbox/plan.md b/specs/221-findings-operator-inbox/plan.md new file mode 100644 index 00000000..73b04a93 --- /dev/null +++ b/specs/221-findings-operator-inbox/plan.md @@ -0,0 +1,245 @@ +# Implementation Plan: Findings Operator Inbox V1 + +**Branch**: `221-findings-operator-inbox` | **Date**: 2026-04-20 | **Spec**: `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/221-findings-operator-inbox/spec.md` +**Input**: Feature specification from `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/221-findings-operator-inbox/spec.md` + +**Note**: This plan keeps the work inside the existing admin workspace shell, tenant-owned `Finding` truth, and current tenant finding detail surface. The intended implementation is one new canonical `/admin` page plus one small workspace overview signal. It does not add persistence, capabilities, workflow states, queue automation, or a second mutation lane. + +## Summary + +Add one canonical admin-plane inbox at `/admin/findings/my-work` that shows the current user's open assigned findings across visible, capability-eligible tenants, keeps urgency first with a deterministic overdue-then-reopened ordering, and drills into the existing tenant finding detail with a preserved return path. Reuse existing `Finding` lifecycle and responsibility semantics, `CanonicalAdminTenantFilterState` for active-tenant prefiltering and applied-scope metadata, `CanonicalNavigationContext` for queue-to-detail continuity, and extend `WorkspaceOverviewBuilder` plus the workspace overview Blade view with one compact `Assigned to me` signal that exposes open and overdue counts with a single CTA into the inbox. + +## Technical Context + +**Language/Version**: PHP 8.4.15, Laravel 12, Filament v5, Livewire v4, Blade +**Primary Dependencies**: Filament admin panel pages, `Finding`, `FindingResource`, `WorkspaceOverviewBuilder`, `WorkspaceContext`, `WorkspaceCapabilityResolver`, `CapabilityResolver`, `CanonicalAdminTenantFilterState`, and `CanonicalNavigationContext` +**Storage**: PostgreSQL via existing `findings`, `tenants`, `tenant_memberships`, and workspace context session state; no schema changes planned +**Testing**: Pest v4 feature tests with Livewire/Filament page assertions +**Validation Lanes**: fast-feedback, confidence +**Target Platform**: Dockerized Laravel web application via Sail locally and Linux containers in deployment +**Project Type**: Laravel monolith inside the `wt-plattform` monorepo +**Performance Goals**: Keep inbox and overview rendering DB-only, eager-load tenant/owner/assignee display context, avoid N+1 queries, and keep the first operator scan within the 10-second acceptance target +**Constraints**: No Graph calls, no new `OperationRun`, no new workflow states or owner semantics, no new capabilities, no new queue mutations, no hidden-tenant leakage, no duplicate personal-work truth between inbox and overview, and no new assets +**Scale/Scope**: One admin page, one workspace overview signal, one derived cross-tenant personal-assignment query, three focused feature suites, and one focused extension to existing route-alignment coverage + +## UI / Surface Guardrail Plan + +- **Guardrail scope**: changed surfaces +- **Native vs custom classification summary**: native Filament page, table, empty-state, badge, and Blade summary primitives only +- **Shared-family relevance**: findings workflow family and workspace overview summary family +- **State layers in scope**: shell, page, detail, URL-query +- **Handling modes by drift class or surface**: review-mandatory +- **Repository-signal treatment**: review-mandatory +- **Special surface test profiles**: global-context-shell +- **Required tests or manual smoke**: functional-core, state-contract +- **Exception path and spread control**: none; the inbox remains a standard read-first admin page and the overview signal stays an embedded summary, not a second queue +- **Active feature PR close-out entry**: Guardrail + +## Constitution Check + +*GATE: Passed before Phase 0 research. Re-check after Phase 1 design.* + +| Principle | Pre-Research | Post-Design | Notes | +|-----------|--------------|-------------|-------| +| Inventory-first / snapshots-second | PASS | PASS | The feature reads live `Finding` assignment and lifecycle truth only; no new snapshot or backup semantics are introduced | +| Read/write separation | PASS | PASS | The inbox and overview signal are read-only; existing finding lifecycle mutations remain on tenant finding detail | +| Graph contract path | PASS | PASS | No Graph client, contract-registry, or external API work is added | +| Deterministic capabilities / RBAC-UX | PASS | PASS | Workspace membership gates the admin page, missing workspace context follows the existing chooser or resume flow, tenant entitlement plus existing findings capability gate row visibility, filter values, summary counts, overview counts, and detail drilldown, and non-members remain `404` | +| Workspace / tenant isolation | PASS | PASS | Admin-plane inbox is workspace-scoped while drilldown stays on `/admin/t/{tenant}/findings/{finding}` with tenant-safe continuity | +| Run observability / Ops-UX | PASS | PASS | No long-running work, no `OperationRun`, and no notification or progress-surface changes are introduced | +| Proportionality / no premature abstraction | PASS | PASS | The plan keeps the query logic inside the new page and existing workspace builder seams; no new shared registry or durable abstraction is required | +| Persisted truth / few layers | PASS | PASS | Inbox rows and overview counts remain direct derivations of existing `Finding`, tenant membership, and workspace context data | +| Badge semantics (BADGE-001) | PASS | PASS | Severity, lifecycle, and urgency cues reuse existing findings badge semantics rather than introducing local status language | +| Filament-native UI (UI-FIL-001) | PASS | PASS | Implementation stays within Filament page, table, header action, empty-state, and existing Blade summary composition | +| Action surface / inspect model | PASS | PASS | The inbox keeps one inspect model, full-row open to finding detail, no redundant `View`, and no dangerous queue actions | +| Decision-first / OPSURF | PASS | PASS | The inbox is the primary decision surface and the overview signal stays secondary with one CTA | +| Test governance (TEST-GOV-001) | PASS | PASS | Proof remains in three focused feature suites plus one focused extension to existing route-alignment coverage, with no browser or heavy-governance promotion | +| Filament v5 / Livewire v4 compliance | PASS | PASS | The design uses existing Filament v5 page patterns and Livewire v4-compatible page/table behavior only | +| Provider registration / global search / assets | PASS | PASS | Panel providers already live in `apps/platform/bootstrap/providers.php`; the new page extends `AdminPanelProvider` only, `FindingResource` global search behavior is unchanged and already has a View page, and no new assets are required | + +## Test Governance Check + +- **Test purpose / classification by changed surface**: `Feature` for the admin inbox page, authorization boundary behavior, and workspace overview signal +- **Affected validation lanes**: `fast-feedback`, `confidence` +- **Why this lane mix is the narrowest sufficient proof**: The feature is proven by visible operator behavior, capability-safe tenant filtering, continuity into existing tenant detail, empty-state fallback behavior, and overview-to-inbox truth alignment. Focused feature tests cover that without adding unit seams, browser automation, or heavy-governance breadth. +- **Narrowest proving command(s)**: + - `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Findings/MyWorkInboxTest.php tests/Feature/Authorization/MyWorkInboxAuthorizationTest.php` + - `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Dashboard/MyFindingsSignalTest.php` + - `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Filament/WorkspaceOverviewNavigationTest.php` +- **Fixture / helper / factory / seed / context cost risks**: Moderate. The tests need one workspace, multiple visible and hidden tenants, owner-versus-assignee combinations, open and terminal findings, and explicit workspace plus active-tenant session context. +- **Expensive defaults or shared helper growth introduced?**: no; the new suites should reuse existing `createUserWithTenant(...)` and `Finding::factory()` flows and keep any inbox-specific fixture helper local to the new tests +- **Heavy-family additions, promotions, or visibility changes**: none +- **Surface-class relief / special coverage rule**: named special profile `global-context-shell` is required because the inbox depends on workspace context, active tenant continuity, and admin-to-tenant drilldown behavior +- **Closing validation and reviewer handoff**: Reviewers should rely on the exact commands above and verify that hidden-tenant or capability-blocked findings never leak into rows, filter options, or counts, owner-only findings stay out of the queue, the active tenant prefilter is clearable without removing personal scope, the empty state offers the correct fallback CTA, and the detail page exposes a working return path to the inbox. +- **Budget / baseline / trend follow-up**: none +- **Review-stop questions**: Did the implementation stay read-only? Did the overview signal remain a signal instead of a second queue? Did any shared query abstraction appear without clear reuse pressure beyond this feature? Did row drilldown preserve tenant-safe continuity? +- **Escalation path**: document-in-feature unless a second cross-tenant findings surface or a new shared query framework is proposed, in which case split or follow up with a dedicated spec +- **Active feature PR close-out entry**: Guardrail +- **Why no dedicated follow-up spec is needed**: The feature remains bounded to one queue surface and one overview signal, with no new persistence, workflow family, or reusable platform abstraction + +## Project Structure + +### Documentation (this feature) + +```text +specs/221-findings-operator-inbox/ +├── plan.md +├── research.md +├── data-model.md +├── quickstart.md +├── contracts/ +│ └── findings-operator-inbox.logical.openapi.yaml +├── checklists/ +│ └── requirements.md +└── tasks.md +``` + +### Source Code (repository root) + +```text +apps/platform/ +├── app/ +│ ├── Filament/ +│ │ ├── Pages/ +│ │ │ └── Findings/ +│ │ │ └── MyFindingsInbox.php +│ │ └── Resources/ +│ │ └── FindingResource.php +│ ├── Providers/ +│ │ └── Filament/ +│ │ └── AdminPanelProvider.php +│ └── Support/ +│ ├── Filament/ +│ │ └── CanonicalAdminTenantFilterState.php +│ ├── Navigation/ +│ │ └── CanonicalNavigationContext.php +│ └── Workspaces/ +│ └── WorkspaceOverviewBuilder.php +├── resources/ +│ └── views/ +│ └── filament/ +│ └── pages/ +│ ├── findings/ +│ │ └── my-findings-inbox.blade.php +│ └── workspace-overview.blade.php +└── tests/ + └── Feature/ + ├── Authorization/ + │ └── MyWorkInboxAuthorizationTest.php + ├── Dashboard/ + │ └── MyFindingsSignalTest.php + ├── Filament/ + │ └── WorkspaceOverviewNavigationTest.php + └── Findings/ + └── MyWorkInboxTest.php +``` + +**Structure Decision**: Standard Laravel monolith. The feature stays inside the existing admin panel provider, finding domain model, workspace overview builder, and focused Pest feature suites. No new base directory, package, or persistent model is required. + +## Complexity Tracking + +| Violation | Why Needed | Simpler Alternative Rejected Because | +|-----------|------------|-------------------------------------| +| none | — | — | + +## Proportionality Review + +- **Current operator problem**: Assignee-based finding work exists in the data model but not as a trustworthy cross-tenant start-of-day surface. +- **Existing structure is insufficient because**: The tenant-local findings list can filter to `My assigned work`, but it still forces users to guess the correct tenant first and does not create one canonical admin-plane queue. +- **Narrowest correct implementation**: Add one admin page and one small workspace overview signal, both derived directly from existing finding assignment, lifecycle, due-date, severity, and entitlement truth. +- **Ownership cost created**: One new page/view pair, one small overview payload addition, three focused feature suites, and one focused extension to existing route-alignment coverage. +- **Alternative intentionally rejected**: A broader cross-tenant findings register, owner-plus-assignee mixed queue, or new shared findings query service. Those would increase semantics and maintenance cost beyond the current release need. +- **Release truth**: Current-release truth. The work operationalizes the already-shipped assignee concept now rather than preparing future queue or notification systems. + +## Phase 0 Research + +Research outcomes are captured in `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/221-findings-operator-inbox/research.md`. + +Key decisions: + +- Implement the inbox as an admin panel page with slug `findings/my-work` under the existing `AdminPanelProvider`, not as a tenant resource variant and not as a standalone controller route. +- Keep the cross-tenant personal queue fully derived from `Finding` plus existing workspace and tenant entitlements, using `Finding::openStatusesForQuery()` and eager-loaded tenant/owner/assignee relationships. +- Reuse `CanonicalAdminTenantFilterState` to apply the active tenant as the default prefilter, expose the applied-scope state, and preserve the clear-filter behavior without inventing a second context mechanism. +- Use `CanonicalNavigationContext` when building row drilldowns so tenant finding detail can expose `Back to my findings` with tenant-safe continuity. +- Add the workspace signal as a dedicated overview payload rendered in the existing `/admin` Blade page rather than overloading generic summary metrics, because the signal must show open count, overdue count, and one explicit CTA. +- Prove the feature with three focused Pest feature suites plus one focused extension to existing route-alignment coverage. + +## Phase 1 Design + +Design artifacts are created under `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/221-findings-operator-inbox/`: + +- `research.md`: routing, query, continuity, and workspace-signal decisions +- `data-model.md`: existing entities plus derived inbox row and workspace signal projections +- `contracts/findings-operator-inbox.logical.openapi.yaml`: internal logical contract for the inbox, overview signal, and detail continuity inputs +- `quickstart.md`: focused validation workflow for implementation and review + +Design decisions: + +- No schema migration is required; the inbox and overview signal remain fully derived. +- The canonical implementation seam is one new admin page plus one existing workspace overview builder/view extension, not a new shared findings-query subsystem. +- Active tenant context stays canonical through `CanonicalAdminTenantFilterState`, while detail continuity stays canonical through `CanonicalNavigationContext`. +- Existing tenant finding detail remains the only mutation surface for assignment and workflow actions. + +## Phase 1 Agent Context Update + +Run: + +- `.specify/scripts/bash/update-agent-context.sh copilot` + +## Constitution Check — Post-Design Re-evaluation + +- PASS — the design remains read-surface only, adds no writes, no Graph calls, no `OperationRun`, no new capability family, and no new assets. +- PASS — Livewire v4.0+ and Filament v5 constraints remain satisfied, panel provider registration stays in `apps/platform/bootstrap/providers.php`, and no global-search or destructive-action behavior changes are introduced. + +## Implementation Strategy + +### Phase A — Add The Canonical Admin Inbox Surface + +**Goal**: Create one workspace-scoped personal findings queue under `/admin/findings/my-work`. + +| Step | File | Change | +|------|------|--------| +| A.1 | `apps/platform/app/Filament/Pages/Findings/MyFindingsInbox.php` | Add a new Filament admin page with slug `findings/my-work`, `HasTable`, workspace membership access checks, and fixed assignee/open-status scope | +| A.2 | `apps/platform/resources/views/filament/pages/findings/my-findings-inbox.blade.php` | Render the page shell, page description, native Filament table, and branch-specific calm empty-state CTAs: clear the tenant prefilter when it alone excludes rows, otherwise open tenant findings for the active tenant or `/admin/choose-tenant` when no tenant context exists | +| A.3 | `apps/platform/app/Providers/Filament/AdminPanelProvider.php` | Register the new page in the existing admin panel; do not move provider registration because it already lives in `apps/platform/bootstrap/providers.php` | + +### Phase B — Derive Queue Truth From Existing Findings Semantics + +**Goal**: Keep inclusion, urgency, and owner-versus-assignee behavior aligned with Specs 111 and 219. + +| Step | File | Change | +|------|------|--------| +| B.1 | `apps/platform/app/Filament/Pages/Findings/MyFindingsInbox.php` | Build the table query and derived inbox counts from `Finding`, restricted to the current workspace, visible capability-eligible tenants, `assignee_user_id = auth()->id()`, and `Finding::openStatusesForQuery()` | +| B.2 | `apps/platform/app/Filament/Pages/Findings/MyFindingsInbox.php` and `apps/platform/app/Filament/Resources/FindingResource.php` | Reuse or mirror existing severity, lifecycle, overdue, reopened, and owner context presentation helpers without inventing new badge semantics | +| B.3 | `apps/platform/app/Filament/Pages/Findings/MyFindingsInbox.php` | Add the available-filters contract for fixed assignee scope, tenant, overdue, reopened, and high-severity filters, capability-safe tenant filter options, applied-scope metadata, summary counts, and deterministic urgency sorting with due-date and finding-ID tie-breakers while keeping personal assignment scope fixed and non-removable | + +### Phase C — Honor Active Tenant Context And Queue-to-Detail Continuity + +**Goal**: Make the inbox feel canonical inside the existing admin shell instead of a detached register. + +| Step | File | Change | +|------|------|--------| +| C.1 | `apps/platform/app/Filament/Pages/Findings/MyFindingsInbox.php` | Reuse `CanonicalAdminTenantFilterState` so the active tenant becomes the default prefilter and can be cleared via one header action | +| C.2 | `apps/platform/app/Filament/Pages/Findings/MyFindingsInbox.php` | Build row URLs to tenant finding detail with `CanonicalNavigationContext` carrying `Back to my findings` continuity | +| C.3 | `apps/platform/app/Filament/Resources/FindingResource.php` or existing detail page seam | Ensure the tenant detail page consumes the navigation context and exposes the inbox return link without changing mutation semantics | + +### Phase D — Add The Workspace Overview Signal Without Creating A Second Queue + +**Goal**: Make `/admin` show whether personal findings work exists and link into the inbox in one click. + +| Step | File | Change | +|------|------|--------| +| D.1 | `apps/platform/app/Support/Workspaces/WorkspaceOverviewBuilder.php` | Add a small `my_findings_signal` payload with visible capability-safe open assigned count, overdue count, calm state, and CTA URL | +| D.2 | `apps/platform/resources/views/filament/pages/workspace-overview.blade.php` | Render one compact assigned-to-me summary block ahead of the broader workspace metrics | +| D.3 | Existing workspace navigation helpers | Keep the CTA pointed at the canonical inbox route and avoid duplicating queue logic or urgency semantics in the overview itself | + +### Phase E — Protect The Feature With Focused Regression Coverage + +**Goal**: Lock down visibility, prioritization, tenant safety, and overview-to-inbox truth alignment. + +| Step | File | Change | +|------|------|--------| +| E.1 | `apps/platform/tests/Feature/Findings/MyWorkInboxTest.php` | Cover capability-safe visible rows only, owner-context rendering when owner differs from assignee, the full available-filters contract truth for fixed assignee scope plus tenant, overdue, reopened, and high-severity filters, tenant filter option truth, owner-only exclusion, deterministic urgency ordering with due-date and finding-ID tie-breakers, clearable tenant prefilter, applied-scope and summary-count truth, zero-visible-work and tenant-prefilter empty-state branches, and drilldown continuity | +| E.2 | `apps/platform/tests/Feature/Authorization/MyWorkInboxAuthorizationTest.php` | Cover admin-plane workspace recovery behavior for missing workspace context, member-without-capability disclosure boundaries, protected-destination `403`, and deny-as-not-found boundaries for non-members | +| E.3 | `apps/platform/tests/Feature/Dashboard/MyFindingsSignalTest.php` | Cover overview signal counts, calm state, capability-safe suppression, and CTA alignment with inbox truth | +| E.4 | `cd apps/platform && ./vendor/bin/sail bin pint --dirty --format agent` plus the focused Pest commands above | Run formatting and the narrowest proving suites before closing implementation | \ No newline at end of file diff --git a/specs/221-findings-operator-inbox/quickstart.md b/specs/221-findings-operator-inbox/quickstart.md new file mode 100644 index 00000000..636c4a9f --- /dev/null +++ b/specs/221-findings-operator-inbox/quickstart.md @@ -0,0 +1,147 @@ +# Quickstart: Findings Operator Inbox V1 + +## Goal + +Validate that `/admin/findings/my-work` gives the current user one trustworthy assigned-findings queue across visible tenants, and that `/admin` exposes a matching `Assigned to me` signal with one CTA into the inbox. + +## Prerequisites + +1. Start Sail if it is not already running. +2. Use a test user who is a member of one workspace and at least two tenants inside that workspace. +3. Seed or create findings for these cases: + - assigned open finding in tenant A + - assigned overdue finding in tenant B + - assigned reopened finding in tenant A + - assigned ordinary finding without a due date + - owner-only open finding where the user is not assignee + - assigned terminal finding + - assigned finding in a tenant the user is no longer entitled to inspect + - assigned finding in a tenant where the user remains a member but no longer has findings visibility capability +4. Ensure the workspace overview at `/admin` is reachable for the acting user. + +## Focused Automated Verification + +Run formatting first: + +```bash +cd apps/platform && ./vendor/bin/sail bin pint --dirty --format agent +``` + +Then run the focused proving set: + +```bash +cd apps/platform && ./vendor/bin/sail artisan test --compact \ + tests/Feature/Findings/MyWorkInboxTest.php \ + tests/Feature/Authorization/MyWorkInboxAuthorizationTest.php \ + tests/Feature/Dashboard/MyFindingsSignalTest.php +``` + +Then run the required route-alignment regression for the workspace shell CTA: + +```bash +cd apps/platform && ./vendor/bin/sail artisan test --compact \ + tests/Feature/Filament/WorkspaceOverviewNavigationTest.php +``` + +## Manual Validation Pass + +### 1. Workspace overview signal + +Open `/admin`. + +Confirm that: + +- the page shows an `Assigned to me` signal, +- the signal exposes visible open count and overdue count, +- the wording stays calm only when both counts are zero, +- and the CTA is labeled `Open my findings`. + +### 2. Canonical inbox route + +Open `/admin/findings/my-work`. + +Confirm that: + +- the page title and copy use finding-first vocabulary, +- rows show tenant, finding summary, severity, lifecycle, and due urgency, +- owner context is shown when owner differs from assignee, +- the page reflects fixed assigned-to-me scope and summary counts that match the visible rows, +- and no queue-level mutation actions appear. + +### 3. Assignee versus owner boundary + +With fixtures where the acting user is owner-only on one finding and assignee on another: + +Confirm that: + +- the assignee row is visible, +- the owner-only row is absent, +- a finding where the user is both owner and assignee remains visible, +- and owner context is shown only when it differs from the assignee. + +### 4. Active tenant prefilter + +Set an active tenant context before opening the inbox. + +Confirm that: + +- the queue defaults to that tenant, +- the personal assignment scope remains fixed, +- the applied scope reflects that tenant-prefilter source correctly, +- a `Clear tenant filter` affordance is available, +- summary counts continue to match the visible rows, +- and clearing the tenant filter returns the queue to all visible tenants. + +### 5. Hidden-tenant and capability suppression + +Remove the acting user's entitlement from one tenant that still contains assigned findings, and remove findings visibility capability from another tenant where membership still remains. + +Confirm that: + +- those findings disappear from the inbox, +- they do not contribute to the workspace signal, +- neither tenant appears as a filter value or empty-state hint, +- and protected destinations still reject in-scope users without findings visibility. + +### 6. Urgency ordering + +With ordinary, reopened, overdue, and undated assigned findings: + +Confirm that: + +- overdue work appears ahead of reopened and ordinary work, +- reopened non-overdue work appears ahead of ordinary work, +- within the same urgency bucket, due-dated rows appear ahead of undated rows, +- reopened context is visible, +- and terminal findings do not appear. + +### 7. Detail continuity + +Open a row from the inbox. + +Confirm that: + +- the destination is the existing tenant finding detail route, +- the tenant scope is correct, +- and the page offers a `Back to my findings` return path. + +### 8. Empty-state behavior + +Validate two empty states: + +- no visible assigned work anywhere +- no rows only because the active tenant prefilter narrows the queue + +Confirm that: + +- the zero-visible-work branch stays calm and offers a clear fallback CTA, +- the first state's CTA opens tenant findings when active tenant context exists and `/admin/choose-tenant` when it does not, +- the second state explains the tenant boundary instead of claiming there is no work anywhere, +- the second state's CTA clears the tenant prefilter back to all visible tenants, +- and neither state leaks hidden tenant information. + +## Final Verification Notes + +- The inbox is read-first only. Assignment and workflow mutations stay on tenant finding detail. +- The workspace overview signal must remain a signal, not a second queue. +- If a reviewer can infer hidden tenant work from counts, filter options, or empty-state copy, treat that as a release blocker. \ No newline at end of file diff --git a/specs/221-findings-operator-inbox/research.md b/specs/221-findings-operator-inbox/research.md new file mode 100644 index 00000000..9ad82b2d --- /dev/null +++ b/specs/221-findings-operator-inbox/research.md @@ -0,0 +1,49 @@ +# Research: Findings Operator Inbox V1 + +## Decision 1: Implement the inbox as an admin panel page with slug `findings/my-work` + +- **Decision**: Add a new Filament admin page under the existing `AdminPanelProvider` for the canonical inbox route `/admin/findings/my-work`. +- **Rationale**: The feature is explicitly an admin-plane, workspace-scoped surface. The admin panel already owns comparable custom pages such as `FindingExceptionsQueue`, and page registration gives the correct Filament middleware, route shape, and Livewire page lifecycle without creating a second routing model. +- **Alternatives considered**: + - Reuse the tenant-local `FindingResource` list as the canonical inbox. Rejected because it keeps the operator trapped in tenant-first navigation and does not answer the cross-tenant personal-work question. + - Add a standalone controller route in `routes/web.php`. Rejected because this is a normal admin panel surface, not a one-off shell route like `/admin` home. + +## Decision 2: Keep queue truth as a direct `Finding` query, not a new shared query subsystem + +- **Decision**: Build the inbox from `Finding` records scoped by current workspace, visible capability-eligible tenant IDs, `assignee_user_id = current user`, and `Finding::openStatusesForQuery()`, with eager-loaded `tenant`, `ownerUser`, and `assigneeUser` relationships. +- **Rationale**: The feature has two concrete consumers, but both are narrow and local: one queue page and one workspace signal. A direct query keeps the logic readable, honors Spec 111 and Spec 219 as-is, and avoids importing a new reusable abstraction before it is clearly needed. +- **Alternatives considered**: + - Introduce a new shared findings-query service immediately. Rejected because the scope is still small and the repo guidance prefers direct implementation until a second real abstraction pressure appears. + - Mix owner and assignee semantics into one queue query. Rejected because Spec 219 explicitly separates assignee work from owner accountability. + +## Decision 3: Reuse `CanonicalAdminTenantFilterState` for the default active-tenant prefilter + +- **Decision**: Let the inbox synchronize its tenant filter through `CanonicalAdminTenantFilterState`, so the active tenant becomes the default prefilter and can be cleared without removing personal assignment scope. +- **Rationale**: The repo already uses this helper on admin-panel lists and monitoring pages to keep active tenant context honest and clearable. Reusing it keeps the inbox aligned with existing admin context behavior and avoids inventing a page-specific prefilter mechanism. +- **Alternatives considered**: + - Drive tenant prefiltering only through explicit query parameters. Rejected because the feature requirement is about active tenant context, not just shareable URLs. + - Hard-lock the queue to the active tenant whenever tenant context exists. Rejected because the spec requires a clear path back to all visible tenants. + +## Decision 4: Add the workspace signal as a dedicated overview payload and Blade block + +- **Decision**: Extend `WorkspaceOverviewBuilder` with a compact `my_findings_signal` payload and render it directly in the existing workspace overview Blade view with one explicit CTA. +- **Rationale**: The signal must show open count, overdue count, calm state, and one named CTA. The existing generic `summary_metrics` lane is optimized for one metric value plus description and does not cleanly express the contract without distorting the overview metric family. +- **Alternatives considered**: + - Encode the signal as another generic summary metric. Rejected because the metric card does not naturally expose both counts and an explicit `Open my findings` CTA. + - Add a second standalone dashboard or queue widget. Rejected because the overview only needs a small drill-in signal, not another work surface. + +## Decision 5: Preserve queue-to-detail continuity through `CanonicalNavigationContext` + +- **Decision**: Append a `CanonicalNavigationContext` payload when an inbox row opens `/admin/t/{tenant}/findings/{finding}` so the detail page can render `Back to my findings`. +- **Rationale**: The repo already uses `CanonicalNavigationContext` for cross-surface return links on admin and tenant detail pages. Reusing it preserves a single continuity model and keeps the return path explicit instead of relying on fragile browser history. +- **Alternatives considered**: + - Depend on the browser referer only. Rejected because it is brittle across reloads, tabs, and copied links. + - Add a new inbox-specific controller just to set session return state. Rejected because the existing navigation context already solves this problem cleanly. + +## Decision 6: Prove the feature with three focused feature suites plus one route-alignment extension + +- **Decision**: Add `MyWorkInboxTest`, `MyWorkInboxAuthorizationTest`, and `MyFindingsSignalTest` as the three new focused suites, and extend `WorkspaceOverviewNavigationTest` for route-alignment proof. +- **Rationale**: The user-visible risk is visibility, prioritization, tenant safety, and overview-to-inbox truth alignment. Those are best proven through focused feature coverage using the existing workspace and tenant helpers, while the inbox CTA alignment belongs in the existing route-alignment regression instead of a fourth new suite. +- **Alternatives considered**: + - Add browser coverage. Rejected because the surface is simple and already well represented by Filament/Livewire feature assertions. + - Add a unit-only seam around queue counting. Rejected because the important risk is integrated scope behavior, not isolated arithmetic. \ No newline at end of file diff --git a/specs/221-findings-operator-inbox/spec.md b/specs/221-findings-operator-inbox/spec.md new file mode 100644 index 00000000..f4dbf74c --- /dev/null +++ b/specs/221-findings-operator-inbox/spec.md @@ -0,0 +1,236 @@ +# Feature Specification: Findings Operator Inbox V1 + +**Feature Branch**: `221-findings-operator-inbox` +**Created**: 2026-04-20 +**Status**: Draft +**Input**: User description: "Findings Operator Inbox v1" + +## Spec Candidate Check *(mandatory — SPEC-GATE-001)* + +- **Problem**: Findings can already be assigned, but the current assignee still has to reconstruct personal work from tenant-local findings lists and ad hoc filters. +- **Today's failure**: Assignment remains metadata instead of day-to-day workflow. Operators cannot answer "what is mine right now?" from one trustworthy surface, so they tenant-hop or miss overdue assigned work. +- **User-visible improvement**: One personal inbox shows the current user's open assigned findings across visible tenants, highlights urgency, and gives a direct path into the correct finding record. +- **Smallest enterprise-capable version**: A canonical read-first inbox for the current user's assigned open findings, urgency filters, tenant-safe drilldown into the existing finding detail, and one small workspace-overview signal that links into the inbox. +- **Explicit non-goals**: No owner-only accountability queue, no unassigned intake queue, no notifications or escalation, no comments or external ticketing, no team-routing logic, no bulk queue actions, and no new permission system. +- **Permanent complexity imported**: One canonical inbox page, one small workspace overview summary signal, one derived personal-assignment query contract, and focused regression coverage for visibility, context handoff, and empty-state behavior. +- **Why now**: Spec 219 made owner versus assignee semantics explicit. The next smallest findings execution slice is to make assignee-based work actually discoverable before intake, escalation, or hygiene hardening land. +- **Why not local**: A tenant-local `My assigned` filter still forces operators to guess which tenant to open first and does not create one trustworthy start-of-day queue across the workspace-visible tenant set. +- **Approval class**: Core Enterprise +- **Red flags triggered**: One mild `Many surfaces` risk because the slice touches the inbox and a workspace overview signal. The scope remains acceptable because both surfaces express the same personal-work truth and do not introduce a new meta-layer. +- **Score**: Nutzen: 2 | Dringlichkeit: 2 | Scope: 2 | Komplexität: 1 | Produktnähe: 2 | Wiederverwendung: 1 | **Gesamt: 10/12** +- **Decision**: approve + +## Spec Scope Fields *(mandatory)* + +- **Scope**: canonical-view +- **Primary Routes**: + - `/admin/findings/my-work` as the new canonical personal findings inbox + - `/admin` as the workspace overview where the assigned-to-me signal links into the inbox + - `/admin/t/{tenant}/findings` as the existing tenant findings list fallback + - `/admin/t/{tenant}/findings/{finding}` as the existing tenant finding detail drilldown +- **Data Ownership**: Tenant-owned findings remain the only source of truth. The inbox and workspace overview signal are derived views over existing finding assignment, lifecycle, severity, due-date, and tenant-entitlement truth. +- **RBAC**: Workspace membership is required to reach the canonical inbox in the admin plane. Every visible row and count additionally requires tenant entitlement plus the existing findings view capability for the referenced tenant. Non-members or out-of-scope users remain deny-as-not-found. In-scope users without the required capability remain forbidden on protected destinations. + +For canonical-view specs, the spec MUST define: + +- **Default filter behavior when tenant-context is active**: The inbox always applies `assignee = current user` and open-status scope. When an active tenant context exists, the page additionally prefilters to that tenant by default while allowing the operator to clear only the tenant prefilter, not the personal assignment scope. +- **Explicit entitlement checks preventing cross-tenant leakage**: Counts, rows, tenant filter values, and drilldown links materialize only from tenants the current user may already inspect. Hidden tenants contribute nothing to counts, labels, filter values, or empty-state hints. + +## UI / Surface Guardrail Impact *(mandatory when operator-facing surfaces are changed; otherwise write `N/A`)* + +| Surface / Change | Operator-facing surface change? | Native vs Custom | Shared-Family Relevance | State Layers Touched | Exception Needed? | Low-Impact / `N/A` Note | +|---|---|---|---|---|---|---| +| My Findings inbox | yes | Native Filament page + existing table, filter, and empty-state primitives | Same findings workflow family as tenant findings list and finding detail | table, filter state, urgency emphasis, return path | no | Read-first queue; no new mutation family on the inbox itself | +| Workspace overview assigned-to-me signal | yes | Native Filament widget or summary primitives | Same workspace-overview summary family as other `/admin` attention signals | embedded summary, drill-in CTA | no | Small entry signal only; not a second queue | + +## Decision-First Surface Role *(mandatory when operator-facing surfaces are changed)* + +| Surface | Decision Role | Human-in-the-loop Moment | Immediately Visible for First Decision | On-Demand Detail / Evidence | Why This Is Primary or Why Not | Workflow Alignment | Attention-load Reduction | +|---|---|---|---|---|---|---|---| +| My Findings inbox | Primary Decision Surface | The operator starts the work session or returns to outstanding assigned findings | Tenant, severity, lifecycle status, due or overdue state, and reopened cues for the operator's assigned work | Full finding detail, evidence, audit trail, and exception context after opening the finding | Primary because this is the first dedicated queue for assignee-based execution work | Aligns daily execution around assigned findings instead of tenant hopping | Removes repeated search across tenant dashboards and tenant findings lists | +| Workspace overview assigned-to-me signal | Secondary Context Surface | The operator lands on `/admin` and needs to know whether personal follow-up exists before choosing a domain | Open assigned count, overdue count, and one CTA into the inbox | Full inbox and finding detail after drill-in | Secondary because it points to work rather than hosting it | Keeps workspace home aligned with the assignee queue | Removes opening multiple pages just to discover personal work | + +## UI/UX Surface Classification *(mandatory when operator-facing surfaces are changed)* + +| Surface | Action Surface Class | Surface Type | Likely Next Operator Action | Primary Inspect/Open Model | Row Click | Secondary Actions Placement | Destructive Actions Placement | Canonical Collection Route | Canonical Detail Route | Scope Signals | Canonical Noun | Critical Truth Visible by Default | Exception Type / Justification | +|---|---|---|---|---|---|---|---|---|---|---|---|---|---| +| My Findings inbox | List / Table / Bulk | CRUD / List-first Resource | Open the most urgent assigned finding | Finding | required | Utility filters and fallback navigation stay outside row action noise | None on the inbox; dangerous lifecycle actions stay on finding detail | /admin/findings/my-work | /admin/t/{tenant}/findings/{finding} | Active workspace, optional active-tenant prefilter, tenant column, assigned-to-me scope | Findings / Finding | What is assigned to the current user, what is overdue, and which tenant it belongs to | Operationally this is a personal worklist, but the interaction model is list-first because row open is the only primary action and all mutation remains on the finding detail surface. | +| Workspace overview assigned-to-me signal | Utility / System | Read-only Registry / Report Surface | Open the inbox | Explicit summary CTA | forbidden | Summary strip only | none | /admin | /admin/findings/my-work | Active workspace and visible-scope counts | My findings | Whether the current user has assigned open or overdue work | Embedded summary drill-in that stays read-only and points into the canonical inbox rather than becoming its own queue surface. | + +## Operator Surface Contract *(mandatory when operator-facing surfaces are changed)* + +| Surface | Primary Persona | Decision / Operator Action Supported | Surface Type | Primary Operator Question | Default-visible Information | Diagnostics-only Information | Status Dimensions Used | Mutation Scope | Primary Actions | Dangerous Actions | +|---|---|---|---|---|---|---|---|---|---|---| +| My Findings inbox | Tenant operator or tenant manager | Decide which assigned finding to open and work next | List-first personal work surface | What is assigned to me right now across my visible tenants, and what needs attention first? | Tenant, finding summary, severity, lifecycle status, due date or overdue state, reopened cue, and owner when different from assignee | Raw evidence, run context, exception history, and full audit trail | lifecycle, due urgency, severity, responsibility role | none on the inbox itself; existing tenant detail surfaces keep their current mutation scopes | Open finding, apply filters, clear tenant prefilter | none | +| Workspace overview assigned-to-me signal | Workspace member with findings visibility | Decide whether personal findings work exists and drill into it | Summary drill-in | Do I have assigned findings work that needs attention right now? | Open assigned count, overdue count, and one CTA | none | queue presence, overdue urgency | none | Open my findings | none | + +## Proportionality Review *(mandatory when structural complexity is introduced)* + +- **New source of truth?**: no +- **New persisted entity/table/artifact?**: no +- **New abstraction?**: no +- **New enum/state/reason family?**: no +- **New cross-domain UI framework/taxonomy?**: no +- **Current operator problem**: Assigned findings are already present in the product, but there is no single trustworthy place where the assignee can start work across visible tenants. +- **Existing structure is insufficient because**: Tenant-local findings pages and one quick filter do not answer the cross-tenant personal-work question. They force the operator to search for assigned work instead of receiving a real queue. +- **Narrowest correct implementation**: One derived inbox page and one small workspace summary signal, both powered by existing assignee, severity, due-date, lifecycle, tenant, and entitlement truth. +- **Ownership cost**: One cross-tenant query shape, one context-prefilter rule, one return-path convention, and focused regression tests for visibility and empty-state behavior. +- **Alternative intentionally rejected**: A full cross-tenant findings register, an owner-plus-assignee mixed queue, or a team workboard was rejected because those shapes are broader than the current operator problem. +- **Release truth**: Current-release truth. This slice makes the already-shipped assignment concept operational now. + +### Compatibility posture + +This feature assumes a pre-production environment. + +Backward compatibility, legacy aliases, migration shims, historical fixtures, and compatibility-specific tests are out of scope unless explicitly required by this spec. + +Canonical replacement is preferred over preservation. + +## Testing / Lane / Runtime Impact *(mandatory for runtime behavior changes)* + +- **Test purpose / classification**: Feature +- **Validation lane(s)**: fast-feedback, confidence +- **Why this classification and these lanes are sufficient**: The change is proven by visible operator behavior on one canonical page and one workspace summary surface. Focused feature coverage is sufficient to prove personal-queue visibility, tenant-safe filtering, context handoff, and empty-state behavior without introducing heavy-governance or browser cost. +- **New or expanded test families**: Add focused coverage for the canonical inbox page, the workspace overview signal, positive and negative authorization, owner-only versus assignee-only visibility, and active-tenant prefilter behavior. +- **Fixture / helper cost impact**: Low to moderate. Tests need one workspace, multiple visible and hidden tenants, memberships, and findings in open and terminal states with explicit owner and assignee combinations. +- **Heavy-family visibility / justification**: none +- **Special surface test profile**: global-context-shell +- **Standard-native relief or required special coverage**: Ordinary feature coverage is sufficient, plus explicit assertions that active tenant context prefilters the inbox safely, that the tenant-prefilter empty-state CTA clears back to all visible tenants, and that row drilldown preserves a return path to the queue. +- **Reviewer handoff**: Reviewers must confirm that hidden-tenant findings never leak into rows or counts, owner-only findings are excluded from the personal queue, the queue remains read-first, and the workspace overview CTA lands on the same assigned-work truth shown by the inbox. +- **Budget / baseline / trend impact**: none +- **Escalation needed**: none +- **Active feature PR close-out entry**: Guardrail +- **Planned validation commands**: + - `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Findings/MyWorkInboxTest.php tests/Feature/Authorization/MyWorkInboxAuthorizationTest.php` + - `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Dashboard/MyFindingsSignalTest.php` + - `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Filament/WorkspaceOverviewNavigationTest.php` + +## User Scenarios & Testing *(mandatory)* + +### User Story 1 - See my assigned findings in one queue (Priority: P1) + +As a tenant operator, I want one personal findings queue across my visible tenants so I can start work without searching tenant by tenant. + +**Why this priority**: This is the core value. If the assignee still has to reconstruct personal work from multiple tenant pages, assignment remains metadata instead of workflow. + +**Independent Test**: Can be fully tested by seeding multiple visible tenants with findings where the current user is assignee, owner-only, or unrelated, then verifying that the inbox shows only open assigned work from entitled tenants. + +**Acceptance Scenarios**: + +1. **Given** the current user is assigned open findings across multiple visible tenants, **When** the user opens the inbox, **Then** the page shows only those open assigned findings with tenant and urgency context. +2. **Given** an active tenant context exists, **When** the user opens the inbox, **Then** the queue is prefiltered to that tenant while keeping the personal assignment scope intact. +3. **Given** the current user is owner but not assignee on an open finding, **When** the user opens the inbox, **Then** that finding does not appear in the personal queue. + +--- + +### User Story 2 - Prioritize urgent work and drill into the right finding (Priority: P1) + +As a tenant operator, I want the queue to surface overdue or otherwise urgent assigned work first and take me straight into the correct finding detail, so I can act without reconstructing tenant context. + +**Why this priority**: A queue that is complete but not prioritizable still slows operators down. The first work surface must make the next click obvious. + +**Independent Test**: Can be fully tested by seeding overdue, reopened, high-severity, and ordinary assigned findings, opening the inbox, and verifying both urgency ordering and drilldown behavior. + +**Acceptance Scenarios**: + +1. **Given** the current user has overdue, reopened, and ordinary assigned findings, **When** the inbox renders, **Then** overdue findings are presented first, reopened non-overdue findings are presented ahead of ordinary assigned work, and high severity remains a filter and emphasis cue rather than a separate mandatory sort bucket in v1. +2. **Given** the operator opens a finding from the inbox, **When** the destination page loads, **Then** it opens the existing tenant finding detail for the correct tenant and preserves a return path to the inbox. +3. **Given** no visible assigned findings exist because the active tenant prefilter excludes them, **When** the operator opens the inbox, **Then** the empty state explains that the current tenant filter is narrowing the queue and offers one clear fallback CTA that clears the tenant prefilter back to all visible tenants. + +--- + +### User Story 3 - Discover my assigned work from the workspace overview (Priority: P2) + +As a workspace member, I want a small assigned-to-me signal on the workspace overview so I can tell immediately whether personal findings work exists before I choose a tenant or open another domain. + +**Why this priority**: This is the smallest entry-point improvement that makes the new queue discoverable without turning `/admin` into a second findings page. + +**Independent Test**: Can be fully tested by seeding assigned and unassigned work, opening the workspace overview, and verifying that the signal matches the inbox truth and drills into the queue in one click. + +**Acceptance Scenarios**: + +1. **Given** the current user has visible assigned open findings, **When** the user opens the workspace overview, **Then** the assigned-to-me signal shows the open count, overdue count, and one CTA into the inbox. +2. **Given** the current user has no visible assigned open findings, **When** the user opens the workspace overview, **Then** the signal remains calm and does not imply missing or hidden work. + +### Edge Cases + +- A finding may still reference the current user as assignee in a tenant the user is no longer entitled to; the inbox and overview counts must not show it. +- An active tenant prefilter may produce an empty queue while other visible tenants still contain assigned work; the empty state must explain the filter boundary instead of claiming no work exists anywhere. +- A finding can move to a terminal state in another browser tab while the inbox is open; refresh behavior must remove or de-emphasize it without implying it is still active assigned work. +- A finding may have the current user as both owner and assignee; it remains visible because assignee governs inbox inclusion. +- A finding may have the current user as owner only; it remains out of scope for this personal queue until a later accountability-focused slice explicitly defines that workload. + +## Requirements *(mandatory)* + +**Constitution alignment (required):** This feature adds no Microsoft Graph calls, no new long-running work, and no new `OperationRun`. It introduces a derived read surface and a summary signal only. Existing tenant findings mutations, audit logging, and workflow confirmations remain governed by their current specs and surfaces. + +**Constitution alignment (RBAC-UX):** The feature operates in the admin `/admin` plane for the canonical inbox and workspace overview, with tenant entitlement enforced per referenced finding before disclosure and before drilldown to `/admin/t/{tenant}/findings/{finding}`. Non-members or out-of-scope users continue to receive `404`. When workspace membership exists but workspace context has not yet been established, the feature follows the existing chooser or resume flow instead of returning `404`. In-scope users lacking the existing findings view capability continue to receive `403` on protected destinations. No raw capability strings, role checks, or second permission system may be introduced. Global search behavior is unchanged. + +**Constitution alignment (UI-FIL-001):** The inbox and overview signal must use native Filament page, table, filter, stat, badge, and empty-state primitives or existing shared UI helpers. No local status language, ad hoc color system, or custom badge markup may be introduced for urgency or queue state. + +**Constitution alignment (UI-NAMING-001):** The canonical operator-facing vocabulary is `My Findings`, `Assigned to me`, `Open my findings`, and `Open finding`. The page is about finding work, not a generic task engine. Terms such as `inbox item`, `work unit`, or `queue record` must not replace the finding domain language in primary labels. + +**Constitution alignment (DECIDE-001):** The inbox is a primary decision surface because it answers the assignee's first daily workflow question in one place. The workspace signal is secondary because it points into that work rather than replacing it. Default-visible content must be enough to choose the next finding without reconstructing tenant context elsewhere. + +**Constitution alignment (UI-CONST-001 / UI-SURF-001 / ACTSURF-001 / HDR-001):** The inbox has exactly one primary inspect model: the finding. Row click is required. There is no redundant `View` action. Utility controls such as tenant filter and clear-filter affordances stay outside the row action lane. Dangerous lifecycle actions remain on the existing finding detail instead of being promoted into the queue. The workspace overview signal remains a summary drill-in surface with one explicit CTA. + +**Constitution alignment (OPSURF-001):** Default-visible content on the inbox must stay operator-first: finding summary, tenant, severity, lifecycle state, and due urgency before diagnostics. The inbox itself is read-first and does not introduce a new mutation lane. Workspace and tenant scope must remain explicit through the page title, tenant column, active-tenant prefilter state, and drilldown routing. + +**Constitution alignment (UI-SEM-001 / LAYER-001 / TEST-TRUTH-001):** Direct reuse of the tenant-local `My assigned` filter is insufficient because it does not create one cross-tenant personal-work surface. This feature still avoids new semantic infrastructure by deriving the inbox directly from existing assignee, lifecycle, severity, due-date, and entitlement truth. Tests must prove the business consequences: visibility, prioritization, tenant safety, and drilldown continuity. + +### Functional Requirements + +- **FR-001**: The system MUST provide a canonical personal findings inbox at `/admin/findings/my-work` for the current user. +- **FR-002**: The inbox MUST include only findings that are in an open workflow status, are currently assigned to the current user, and belong to tenants the current user is entitled to inspect. +- **FR-003**: Findings where the current user is owner but not assignee MUST NOT appear in the inbox. +- **FR-004**: The inbox MUST show at minimum the tenant, finding summary, severity, lifecycle status, due date or overdue state, and reopened cue for each visible row. +- **FR-005**: If the finding owner differs from the assignee, the inbox MUST show that owner context without turning owner into the inclusion rule for the queue. +- **FR-006**: The inbox MUST prioritize urgent work ahead of ordinary assigned work using a deterministic rule: overdue findings first, reopened non-overdue findings next, then remaining assigned work. Within each bucket, rows with due dates sort by `due_at` ascending, rows without due dates sort last, and remaining ties sort by finding ID descending. High severity is a supported filter and emphasis cue in v1, but it does not create a separate mandatory sort bucket. +- **FR-007**: The inbox MUST expose available filters for fixed personal assignment scope, tenant, overdue state, reopened state, and high-severity work. The personal assignment scope is fixed and cannot be removed in v1, and tenant filter options MUST be limited to visible capability-eligible tenants. +- **FR-008**: When an active tenant context exists, the inbox MUST apply that tenant as a default prefilter and allow the operator to clear that tenant prefilter to return to all visible tenants. If the tenant prefilter alone causes the queue to become empty while other visible tenants still contain assigned work, the empty-state CTA for that branch MUST clear the tenant prefilter. +- **FR-009**: Opening a row from the inbox MUST navigate to the existing tenant finding detail for the correct tenant and preserve a return path back to the inbox. +- **FR-010**: The workspace overview at `/admin` MUST expose a small assigned-to-me signal that shows the current user's visible assigned open count and overdue count and links into the inbox. +- **FR-011**: Inbox rows, overview counts, tenant filter values, and inbox summary counts MUST be derived only from findings the current user is entitled and currently authorized through the existing findings-view capability to inspect and MUST NOT leak hidden or capability-blocked tenants through counts, labels, filter options, or empty-state hints. Inbox summary counts MUST reflect the currently visible queue after active filters are applied. +- **FR-012**: When the current user has no visible assigned open findings, the inbox MUST render a calm empty state that explains there is no assigned work and offers one clear CTA. When active tenant context exists, the CTA opens that tenant's findings list. When no active tenant context exists, the CTA opens `/admin/choose-tenant` so the operator can establish tenant context before opening tenant findings. +- **FR-013**: The feature MUST reuse the existing finding lifecycle semantics from Spec 111 and the owner-versus-assignee semantics from Spec 219 and MUST NOT introduce new workflow states, new owner semantics, or a second permission system. + +## 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 | +|---|---|---|---|---|---|---|---|---|---|---| +| My Findings inbox | `/admin/findings/my-work` | `Clear tenant filter` only when an active tenant prefilter is applied | Full-row open to `/admin/t/{tenant}/findings/{finding}` | none | none | `Clear tenant filter` when the tenant prefilter excludes rows; otherwise `Open tenant findings` with active tenant context or `Choose a tenant` | n/a | n/a | no direct audit because the surface is read-first | Action Surface Contract satisfied. One inspect model only, no redundant `View`, no dangerous queue actions, and no empty groups. | +| Workspace overview assigned-to-me signal | `/admin` workspace overview | none | Explicit summary CTA `Open my findings` | none | none | none | n/a | n/a | no | Summary drill-in only; not a second work surface | + +### Key Entities *(include if feature involves data)* + +- **Assigned finding**: An open tenant-owned finding where the current user is the assignee and is entitled to inspect the tenant that owns the finding. +- **My Findings inbox**: A derived canonical queue over assigned findings that emphasizes urgency and tenant-safe drilldown. +- **Assigned-to-me signal**: A derived workspace overview summary that counts visible assigned open findings and overdue assigned findings for the current user. + +## Success Criteria *(mandatory)* + +### Measurable Outcomes + +- **SC-001**: In acceptance review, an operator can determine within 10 seconds from `/admin` whether they have overdue assigned findings and open the inbox in one click. +- **SC-002**: 100% of covered automated tests show only current-user assigned open findings from visible tenants in the inbox and overview counts. +- **SC-003**: 100% of covered automated tests exclude owner-only, terminal, or hidden-tenant findings from the personal queue and assigned-to-me signal. +- **SC-004**: From the inbox, an operator can reach the target tenant finding detail in one interaction while preserving a clear return path to the queue. + +## Assumptions + +- Spec 219 is the authoritative contract for owner versus assignee semantics. +- The existing tenant finding detail remains the canonical mutation surface for finding lifecycle and assignment actions. +- The workspace overview at `/admin` can host one small additional summary signal without introducing a new landing-page architecture. + +## Non-Goals + +- Introduce a general cross-tenant findings register for all findings +- Introduce an owner-based accountability queue +- Add unassigned intake, claim flow, or team workboard behavior +- Add notification, escalation, or stale-work automation +- Add new bulk lifecycle actions or a second mutation lane on the inbox + +## Dependencies + +- Spec 111, Findings Workflow + SLA, remains the source of truth for open finding lifecycle, due-date behavior, and tenant findings workflow. +- Spec 219, Finding Ownership Semantics Clarification, remains the source of truth for assignee-based work versus owner-based accountability. diff --git a/specs/221-findings-operator-inbox/tasks.md b/specs/221-findings-operator-inbox/tasks.md new file mode 100644 index 00000000..d42b7292 --- /dev/null +++ b/specs/221-findings-operator-inbox/tasks.md @@ -0,0 +1,209 @@ +# Tasks: Findings Operator Inbox V1 + +**Input**: Design documents from `/specs/221-findings-operator-inbox/` +**Prerequisites**: `plan.md`, `spec.md`, `research.md`, `data-model.md`, `contracts/findings-operator-inbox.logical.openapi.yaml`, `quickstart.md` + +**Tests**: Required. This feature changes runtime behavior on new and existing Filament/Livewire surfaces, so Pest coverage must be added in `apps/platform/tests/Feature/Findings/MyWorkInboxTest.php`, `apps/platform/tests/Feature/Authorization/MyWorkInboxAuthorizationTest.php`, and `apps/platform/tests/Feature/Dashboard/MyFindingsSignalTest.php`, with route-alignment coverage extended in `apps/platform/tests/Feature/Filament/WorkspaceOverviewNavigationTest.php`. +**Operations**: No new `OperationRun`, audit flow, or long-running work is introduced. The inbox and overview signal remain DB-only, read-first surfaces. +**RBAC**: The inbox lives on the admin `/admin` plane and must preserve existing chooser or resume behavior when workspace context is missing, workspace-membership `404` semantics for out-of-scope users, capability-filtered tenant-safe row, filter, and count disclosure inside the queue and overview signal, and existing `404`/`403` behavior on drilldown to `/admin/t/{tenant}/findings/{finding}`. +**UI / Surface Guardrails**: `My Findings` remains the primary decision surface. The `/admin` assigned-to-me signal remains secondary and must not become a second queue. +**Filament UI Action Surfaces**: `MyFindingsInbox` gets one primary inspect model, one conditional `Clear tenant filter` header action, no row actions, no bulk actions, and one empty-state CTA. The workspace overview signal exposes only one CTA: `Open my findings`. +**Badges**: Existing finding severity, lifecycle, and due-state semantics remain authoritative. No page-local badge mappings are introduced. + +**Organization**: Tasks are grouped by user story so each slice stays independently testable. Recommended delivery order is `US1 -> US2 -> US3`, because US2 extends the same inbox surface and US3 links to the inbox truth. + +## Test Governance Checklist + +- [x] Lane assignment is named and is the narrowest sufficient proof for the changed behavior. +- [x] New or changed tests stay in the smallest honest family, and any heavy-governance or browser addition is explicit. +- [x] Shared helpers, factories, seeds, fixtures, and context defaults stay cheap by default; any widening is isolated or documented. +- [x] Planned validation commands cover the change without pulling in unrelated lane cost. +- [x] The declared surface test profile or `standard-native-filament` relief is explicit. +- [x] Any material budget, baseline, trend, or escalation note is recorded in the active spec or PR. + +## Phase 1: Setup (Inbox Scaffolding) + +**Purpose**: Prepare the new admin inbox files and focused regression suites used across all stories. + +- [x] T001 [P] Create the new inbox page scaffold in `apps/platform/app/Filament/Pages/Findings/MyFindingsInbox.php` +- [x] T002 [P] Create the new inbox page view scaffold in `apps/platform/resources/views/filament/pages/findings/my-findings-inbox.blade.php` +- [x] T003 [P] Create focused Pest scaffolding in `apps/platform/tests/Feature/Findings/MyWorkInboxTest.php`, `apps/platform/tests/Feature/Authorization/MyWorkInboxAuthorizationTest.php`, and `apps/platform/tests/Feature/Dashboard/MyFindingsSignalTest.php` + +**Checkpoint**: The new surface and focused test files exist and are ready for shared implementation work. + +--- + +## Phase 2: Foundational (Blocking Route And Scope Seams) + +**Purpose**: Establish the canonical admin route, page access shell, and base workspace/tenant scoping every story depends on. + +**⚠️ CRITICAL**: No user story work should begin until this phase is complete. + +- [x] T004 Register `MyFindingsInbox` in `apps/platform/app/Providers/Filament/AdminPanelProvider.php` and define the page slug and admin-plane access shell in `apps/platform/app/Filament/Pages/Findings/MyFindingsInbox.php` +- [x] T005 Implement workspace-membership gating, visible-tenant resolution, capability-filtered row and count disclosure, and eager-loaded base queue scoping in `apps/platform/app/Filament/Pages/Findings/MyFindingsInbox.php` +- [x] T006 Add foundational admin-plane page-entry coverage for missing workspace chooser or resume behavior and non-member `404` behavior in `apps/platform/tests/Feature/Authorization/MyWorkInboxAuthorizationTest.php` + +**Checkpoint**: The canonical inbox route exists, the page is workspace-scoped, and tenant-safe base access rules are covered. + +--- + +## Phase 3: User Story 1 - See My Assigned Findings In One Queue (Priority: P1) 🎯 MVP + +**Goal**: Give the current user one trustworthy cross-tenant queue for visible assigned open findings. + +**Independent Test**: Seed multiple visible and hidden tenants with assigned, owner-only, unrelated, and terminal findings, then verify `/admin/findings/my-work` shows only open assigned work from entitled tenants. + +### Tests for User Story 1 + +- [x] T007 [P] [US1] Add assigned-versus-owner, owner-context rendering, open-versus-terminal, entitled-versus-capability-eligible queue visibility, full available-filters contract coverage for fixed assignee scope plus tenant, overdue, reopened, and high-severity filters, applied-scope and summary-count truth, and zero-visible-work empty-state coverage in `apps/platform/tests/Feature/Findings/MyWorkInboxTest.php` +- [x] T008 [P] [US1] Add member-without-capability disclosure boundaries, hidden-tenant suppression, and protected-destination coverage in `apps/platform/tests/Feature/Authorization/MyWorkInboxAuthorizationTest.php` + +### Implementation for User Story 1 + +- [x] T009 [US1] Implement the fixed `assignee = current user` and open-status queue query in `apps/platform/app/Filament/Pages/Findings/MyFindingsInbox.php` +- [x] T010 [US1] Implement tenant, finding summary, severity, lifecycle status, due-state, reopened cue, owner context, and calm empty-state rendering with branch-specific CTAs that clear the tenant prefilter when it alone excludes rows or otherwise open active-tenant findings or `/admin/choose-tenant` when no tenant context exists in `apps/platform/app/Filament/Pages/Findings/MyFindingsInbox.php` and `apps/platform/resources/views/filament/pages/findings/my-findings-inbox.blade.php` +- [x] T011 [US1] Implement the available-filters contract with fixed assignee scope, capability-safe tenant filter options, active-tenant default prefilter sync, applied-scope metadata, inbox summary counts, and the conditional `Clear tenant filter` header action in `apps/platform/app/Filament/Pages/Findings/MyFindingsInbox.php` + +**Checkpoint**: User Story 1 is independently functional and the inbox answers “what is assigned to me right now?” without tenant hopping. + +--- + +## Phase 4: User Story 2 - Prioritize Urgent Work And Drill Into The Right Finding (Priority: P1) + +**Goal**: Make the queue surface urgent work first and preserve a clear return path after opening the tenant finding detail. + +**Independent Test**: Seed overdue, reopened, high-severity, and ordinary assigned findings, open the inbox, verify urgency ordering and filters, then open a row and confirm tenant finding detail plus inbox return continuity. + +### Tests for User Story 2 + +- [x] T012 [P] [US2] Add deterministic overdue-then-reopened ordering with due-date and finding-ID tie-break coverage, reopened/high-severity filters, and tenant-prefilter empty-state CTA coverage in `apps/platform/tests/Feature/Findings/MyWorkInboxTest.php` +- [x] T013 [P] [US2] Add queue-to-detail continuity and protected-destination coverage in `apps/platform/tests/Feature/Authorization/MyWorkInboxAuthorizationTest.php` + +### Implementation for User Story 2 + +- [x] T014 [US2] Implement tenant, overdue, reopened, and high-severity filters plus deterministic urgency sorting of overdue first, reopened next, then ordinary findings, with due-date ascending, undated rows last, and finding-ID tie-breaks in `apps/platform/app/Filament/Pages/Findings/MyFindingsInbox.php` +- [x] T015 [US2] Implement inbox row drilldown URLs with `CanonicalNavigationContext` in `apps/platform/app/Filament/Pages/Findings/MyFindingsInbox.php` +- [x] T016 [US2] Preserve `Back to my findings` continuity on tenant finding detail in `apps/platform/app/Filament/Resources/FindingResource.php` + +**Checkpoint**: User Story 2 is independently functional and the queue now highlights urgent work and lands on the correct finding detail with continuity preserved. + +--- + +## Phase 5: User Story 3 - Discover My Assigned Work From The Workspace Overview (Priority: P2) + +**Goal**: Make `/admin` show whether visible assigned findings work exists and link into the canonical inbox in one click. + +**Independent Test**: Seed assigned and unassigned work across visible and hidden tenants, open `/admin`, and verify the assigned-to-me signal matches inbox truth, stays calm when appropriate, and drills into `/admin/findings/my-work`. + +### Tests for User Story 3 + +- [x] T017 [P] [US3] Add open-count, overdue-count, calm-state, hidden-tenant suppression, and capability-safe count suppression coverage in `apps/platform/tests/Feature/Dashboard/MyFindingsSignalTest.php` +- [x] T018 [P] [US3] Extend inbox CTA and route-alignment coverage in `apps/platform/tests/Feature/Filament/WorkspaceOverviewNavigationTest.php` + +### Implementation for User Story 3 + +- [x] T019 [US3] Add the `my_findings_signal` payload with visible capability-safe open and overdue counts to `apps/platform/app/Support/Workspaces/WorkspaceOverviewBuilder.php` +- [x] T020 [US3] Render the `Assigned to me` summary block and `Open my findings` CTA in `apps/platform/resources/views/filament/pages/workspace-overview.blade.php` + +**Checkpoint**: User Story 3 is independently functional and the workspace overview exposes the same personal-work truth as the inbox. + +--- + +## Phase 6: Polish & Cross-Cutting Concerns + +**Purpose**: Finish guardrail alignment, formatting, and focused validation across the full feature. + +- [x] T021 Review operator-facing copy, action-surface discipline, and “signal not second queue” guardrails in `apps/platform/app/Filament/Pages/Findings/MyFindingsInbox.php`, `apps/platform/resources/views/filament/pages/findings/my-findings-inbox.blade.php`, `apps/platform/app/Support/Workspaces/WorkspaceOverviewBuilder.php`, and `apps/platform/resources/views/filament/pages/workspace-overview.blade.php` +- [x] T022 Run formatting with `cd apps/platform && ./vendor/bin/sail bin pint --dirty --format agent` +- [x] T023 Run the focused verification workflow from `specs/221-findings-operator-inbox/quickstart.md` against `apps/platform/tests/Feature/Findings/MyWorkInboxTest.php`, `apps/platform/tests/Feature/Authorization/MyWorkInboxAuthorizationTest.php`, `apps/platform/tests/Feature/Dashboard/MyFindingsSignalTest.php`, and `apps/platform/tests/Feature/Filament/WorkspaceOverviewNavigationTest.php` + +--- + +## Dependencies & Execution Order + +### Phase Dependencies + +- **Setup (Phase 1)**: Starts immediately and prepares the new page, view, and focused Pest files. +- **Foundational (Phase 2)**: Depends on Setup and blocks all user stories until the canonical route and base access shell exist. +- **User Story 1 (Phase 3)**: Depends on Foundational completion and is the recommended MVP cut. +- **User Story 2 (Phase 4)**: Depends on User Story 1 because it extends the same inbox query, filter, and drilldown surface. +- **User Story 3 (Phase 5)**: Depends on User Story 1 because the workspace overview signal must link to established inbox truth. +- **Polish (Phase 6)**: Depends on all desired user stories being complete. + +### User Story Dependencies + +- **US1**: No dependencies beyond Foundational. +- **US2**: Builds directly on the inbox surface introduced in US1. +- **US3**: Depends on the canonical inbox route and queue truth from US1, but not on the urgency and detail work from US2. + +### Within Each User Story + +- Write the story tests first and confirm they fail before implementation is considered complete. +- Keep page-query and authorization behavior in `MyFindingsInbox.php` authoritative before adjusting Blade copy. +- Finish story-level verification before moving to the next priority slice. + +### Parallel Opportunities + +- `T001`, `T002`, and `T003` can run in parallel during Setup. +- `T007` and `T008` can run in parallel for User Story 1. +- `T012` and `T013` can run in parallel for User Story 2. +- `T017` and `T018` can run in parallel for User Story 3. + +--- + +## Parallel Example: User Story 1 + +```bash +# User Story 1 tests in parallel +T007 apps/platform/tests/Feature/Findings/MyWorkInboxTest.php +T008 apps/platform/tests/Feature/Authorization/MyWorkInboxAuthorizationTest.php +``` + +## Parallel Example: User Story 2 + +```bash +# User Story 2 tests in parallel +T012 apps/platform/tests/Feature/Findings/MyWorkInboxTest.php +T013 apps/platform/tests/Feature/Authorization/MyWorkInboxAuthorizationTest.php +``` + +## Parallel Example: User Story 3 + +```bash +# User Story 3 tests in parallel +T017 apps/platform/tests/Feature/Dashboard/MyFindingsSignalTest.php +T018 apps/platform/tests/Feature/Filament/WorkspaceOverviewNavigationTest.php +``` + +--- + +## Implementation Strategy + +### MVP First (User Story 1 Only) + +1. Complete Phase 1: Setup. +2. Complete Phase 2: Foundational. +3. Complete Phase 3: User Story 1. +4. Validate the inbox against the focused US1 tests before widening the slice. + +### Incremental Delivery + +1. Ship US1 to establish the canonical personal queue. +2. Add US2 to prioritize work and preserve detail continuity. +3. Add US3 to make the queue discoverable from `/admin`. +4. Finish with copy review, formatting, and the focused verification pack. + +### Parallel Team Strategy + +1. One contributor can scaffold the page and view while another prepares the focused Pest suites. +2. After Foundational work lands, one contributor can drive inbox visibility and another can harden authorization boundaries. +3. Once US1 is stable, overview signal work can proceed separately from urgency-ordering refinements if needed. + +--- + +## Notes + +- `[P]` tasks target different files and can be worked independently once upstream blockers are cleared. +- `[US1]`, `[US2]`, and `[US3]` map directly to the feature specification user stories. +- The suggested MVP scope is Phase 1 through Phase 3 only. +- All implementation tasks above follow the required checklist format with task ID, optional parallel marker, story label where applicable, and exact file paths.