From 9b097f97f92d3c9c93cbd5e76784f926bd99598e Mon Sep 17 00:00:00 2001 From: ahmido Date: Sat, 16 May 2026 14:52:18 +0000 Subject: [PATCH] Spec 316: implement workspace hub clear filter contract (#371) ## Summary - centralize workspace hub environment filter reset behavior across the affected Filament workspace hubs - add a shared page concern and resetter service to clear environment-like URL, Livewire, table, deferred, and persisted filter state consistently - update hub clear actions and clean-entry flows to route back to the canonical clean workspace hub state - add focused feature and browser coverage for the clear-filter contract - include Spec 316 artifacts for the workspace hub clear filter contract ## Testing - not run as part of this commit/push/PR workflow Co-authored-by: Ahmed Darrazi Reviewed-on: https://git.cloudarix.de/ahmido/TenantAtlas/pulls/371 --- ...earsWorkspaceHubEnvironmentFilterState.php | 76 +++ .../Pages/Governance/DecisionRegister.php | 7 +- .../Pages/Monitoring/EvidenceOverview.php | 18 +- .../Monitoring/FindingExceptionsQueue.php | 42 +- .../Filament/Pages/Monitoring/Operations.php | 16 +- .../Pages/Reviews/CustomerReviewWorkspace.php | 18 +- .../Filament/Pages/Reviews/ReviewRegister.php | 18 +- .../Pages/ListProviderConnections.php | 12 +- .../CanonicalAdminTenantFilterState.php | 17 +- .../WorkspaceHubFilterStateResetter.php | 159 ++++++ ...pec316WorkspaceHubClearFilterSmokeTest.php | 348 +++++++++++++ .../EnvironmentReviewRegisterTest.php | 8 +- .../Monitoring/FindingExceptionsQueueTest.php | 4 +- .../WorkspaceHubClearFilterContractTest.php | 335 ++++++++++++ .../checklists/requirements.md | 72 +++ .../plan.md | 342 +++++++++++++ .../spec.md | 480 ++++++++++++++++++ .../tasks.md | 111 ++++ 18 files changed, 2011 insertions(+), 72 deletions(-) create mode 100644 apps/platform/app/Filament/Concerns/ClearsWorkspaceHubEnvironmentFilterState.php create mode 100644 apps/platform/app/Support/Navigation/WorkspaceHubFilterStateResetter.php create mode 100644 apps/platform/tests/Browser/Spec316WorkspaceHubClearFilterSmokeTest.php create mode 100644 apps/platform/tests/Feature/Navigation/WorkspaceHubClearFilterContractTest.php create mode 100644 specs/316-workspace-hub-clear-filter-contract/checklists/requirements.md create mode 100644 specs/316-workspace-hub-clear-filter-contract/plan.md create mode 100644 specs/316-workspace-hub-clear-filter-contract/spec.md create mode 100644 specs/316-workspace-hub-clear-filter-contract/tasks.md diff --git a/apps/platform/app/Filament/Concerns/ClearsWorkspaceHubEnvironmentFilterState.php b/apps/platform/app/Filament/Concerns/ClearsWorkspaceHubEnvironmentFilterState.php new file mode 100644 index 00000000..42ff259f --- /dev/null +++ b/apps/platform/app/Filament/Concerns/ClearsWorkspaceHubEnvironmentFilterState.php @@ -0,0 +1,76 @@ +workspaceHubFilterStateResetter(); + $resetter->neutralizeEnvironmentLikeQueryState($request); + + if ( + ! $resetter->shouldResetForCleanWorkspaceHubEntry($request) + && WorkspaceHubRegistry::requestHasEnvironmentFilterQuery($request) + ) { + return; + } + + $this->clearWorkspaceHubEnvironmentFilterState($request); + } + + protected function clearWorkspaceHubEnvironmentFilterState(?Request $request = null): void + { + $this->forgetWorkspaceHubEnvironmentFilterSessionState($request); + $this->clearWorkspaceHubEnvironmentTableFilterState(); + } + + protected function cleanWorkspaceHubUrl(string $url): string + { + return $this->workspaceHubFilterStateResetter()->cleanUrl($url); + } + + protected function redirectToCleanWorkspaceHubUrl(string $url, ?Request $request = null): void + { + $this->clearWorkspaceHubEnvironmentFilterState($request); + $this->redirect($this->cleanWorkspaceHubUrl($url), navigate: true); + } + + private function forgetWorkspaceHubEnvironmentFilterSessionState(?Request $request = null): void + { + if (! method_exists($this, 'getTableFiltersSessionKey')) { + return; + } + + $this->workspaceHubFilterStateResetter() + ->forgetPersistedEnvironmentLikeFilters($this->getTableFiltersSessionKey(), $request); + } + + private function clearWorkspaceHubEnvironmentTableFilterState(): void + { + $resetter = $this->workspaceHubFilterStateResetter(); + + $tableFilters = $this->tableFilters ?? null; + + if (is_array($tableFilters)) { + $this->tableFilters = $resetter->clearLivewireTableFilterState($tableFilters); + } + + $tableDeferredFilters = $this->tableDeferredFilters ?? null; + + if (is_array($tableDeferredFilters)) { + $this->tableDeferredFilters = $resetter->clearLivewireTableFilterState($tableDeferredFilters); + } + } + + private function workspaceHubFilterStateResetter(): WorkspaceHubFilterStateResetter + { + return app(WorkspaceHubFilterStateResetter::class); + } +} diff --git a/apps/platform/app/Filament/Pages/Governance/DecisionRegister.php b/apps/platform/app/Filament/Pages/Governance/DecisionRegister.php index 73fbc389..046fec00 100644 --- a/apps/platform/app/Filament/Pages/Governance/DecisionRegister.php +++ b/apps/platform/app/Filament/Pages/Governance/DecisionRegister.php @@ -4,6 +4,7 @@ namespace App\Filament\Pages\Governance; +use App\Filament\Concerns\ClearsWorkspaceHubEnvironmentFilterState; use App\Filament\Resources\FindingExceptionResource; use App\Models\FindingException; use App\Models\ManagedEnvironment; @@ -14,7 +15,6 @@ use App\Support\Auth\Capabilities; use App\Support\Badges\BadgeDomain; use App\Support\Badges\BadgeRenderer; -use App\Support\Filament\CanonicalAdminTenantFilterState; use App\Support\Filament\TablePaginationProfiles; use App\Support\GovernanceDecisions\GovernanceDecisionRegisterBuilder; use App\Support\Navigation\CanonicalNavigationContext; @@ -40,6 +40,7 @@ class DecisionRegister extends Page implements HasTable { + use ClearsWorkspaceHubEnvironmentFilterState; use InteractsWithTable; protected static bool $isDiscovered = false; @@ -131,10 +132,10 @@ public static function canAccess(): bool public function mount(): void { - app(CanonicalAdminTenantFilterState::class) - ->forgetEnvironmentLikeFiltersForCleanWorkspaceHubEntry($this->getTableFiltersSessionKey(), request()); + $this->resetWorkspaceHubEnvironmentFilterStateForCleanEntry(request()); $this->mountInteractsWithTable(); + $this->resetWorkspaceHubEnvironmentFilterStateForCleanEntry(request()); $this->authorizeWorkspaceMembership(); $this->applyRequestedTenantPrefilter(); $this->registerState = $this->resolveRequestedRegisterState(); diff --git a/apps/platform/app/Filament/Pages/Monitoring/EvidenceOverview.php b/apps/platform/app/Filament/Pages/Monitoring/EvidenceOverview.php index 23a7c921..bff1a46d 100644 --- a/apps/platform/app/Filament/Pages/Monitoring/EvidenceOverview.php +++ b/apps/platform/app/Filament/Pages/Monitoring/EvidenceOverview.php @@ -4,6 +4,7 @@ namespace App\Filament\Pages\Monitoring; +use App\Filament\Concerns\ClearsWorkspaceHubEnvironmentFilterState; use App\Filament\Resources\EvidenceSnapshotResource; use App\Models\EnvironmentReview; use App\Models\EvidenceSnapshot; @@ -11,7 +12,6 @@ use App\Models\User; use App\Models\Workspace; use App\Support\EnvironmentReviewStatus; -use App\Support\Filament\CanonicalAdminTenantFilterState; use App\Support\Filament\TablePaginationProfiles; use App\Support\Navigation\WorkspaceHubEnvironmentFilter; use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration; @@ -41,6 +41,7 @@ class EvidenceOverview extends Page implements HasTable { + use ClearsWorkspaceHubEnvironmentFilterState; use InteractsWithTable; protected const MONITORING_PAGE_STATE_CONTRACT = [ @@ -152,14 +153,12 @@ public static function monitoringPageStateContract(): array public function mount(): void { $this->authorizeWorkspaceAccess(); - if (! request()->query->has('environment_id')) { - app(CanonicalAdminTenantFilterState::class) - ->forgetEnvironmentLikeFilters($this->getTableFiltersSessionKey(), request()); - } + $this->resetWorkspaceHubEnvironmentFilterStateForCleanEntry(request()); $this->seedTableStateFromQuery(); - $this->rows = $this->rowsForState($this->tableFilters ?? [], $this->tableSearch)->values()->all(); $this->mountInteractsWithTable(); + $this->resetWorkspaceHubEnvironmentFilterStateForCleanEntry(request()); + $this->rows = $this->rowsForState($this->tableFilters ?? [], $this->tableSearch)->values()->all(); } public function table(Table $table): Table @@ -252,10 +251,11 @@ public function clearOverviewFilters(): void $this->tableSearch = ''; $this->rows = $this->rowsForState($this->tableFilters, $this->tableSearch)->values()->all(); - session()->put($this->getTableFiltersSessionKey(), $this->tableFilters); + session()->forget($this->getTableFiltersSessionKey()); session()->put($this->getTableSearchSessionKey(), $this->tableSearch); + $this->clearWorkspaceHubEnvironmentFilterState(request()); - $this->redirect($this->overviewUrl(), navigate: true); + $this->redirectToCleanWorkspaceHubUrl($this->overviewUrl(), request()); } /** @@ -271,7 +271,7 @@ public function environmentFilterChip(): ?array return [ 'label' => (string) $tenant->name, - 'clear_url' => $this->overviewUrl(), + 'clear_url' => $this->cleanWorkspaceHubUrl($this->overviewUrl()), ]; } diff --git a/apps/platform/app/Filament/Pages/Monitoring/FindingExceptionsQueue.php b/apps/platform/app/Filament/Pages/Monitoring/FindingExceptionsQueue.php index a2ae151a..926e0a81 100644 --- a/apps/platform/app/Filament/Pages/Monitoring/FindingExceptionsQueue.php +++ b/apps/platform/app/Filament/Pages/Monitoring/FindingExceptionsQueue.php @@ -4,6 +4,7 @@ namespace App\Filament\Pages\Monitoring; +use App\Filament\Concerns\ClearsWorkspaceHubEnvironmentFilterState; use App\Filament\Resources\FindingExceptionResource; use App\Filament\Resources\FindingResource; use App\Models\FindingException; @@ -51,6 +52,7 @@ class FindingExceptionsQueue extends Page implements HasTable { + use ClearsWorkspaceHubEnvironmentFilterState; use InteractsWithTable; protected const MONITORING_PAGE_STATE_CONTRACT = [ @@ -193,12 +195,10 @@ public static function canAccess(): bool public function mount(): void { - if (! request()->query->has('environment_id')) { - app(CanonicalAdminTenantFilterState::class) - ->forgetEnvironmentLikeFilters($this->getTableFiltersSessionKey(), request()); - } + $this->resetWorkspaceHubEnvironmentFilterStateForCleanEntry(request()); $this->mountInteractsWithTable(); + $this->resetWorkspaceHubEnvironmentFilterStateForCleanEntry(request()); $this->applyRequestedTenantPrefilter(); $requestedExceptionId = is_numeric(request()->query('exception')) ? (int) request()->query('exception') : null; @@ -229,13 +229,7 @@ protected function getHeaderActions(): array ->icon('heroicon-o-x-mark') ->color('gray') ->visible(fn (): bool => $this->hasActiveQueueFilters()) - ->action(function (): void { - $this->removeTableFilter('managed_environment_id'); - $this->removeTableFilter('status'); - $this->removeTableFilter('current_validity_state'); - $this->selectedFindingExceptionId = null; - $this->resetTable(); - }); + ->action(fn (): mixed => $this->clearQueueFilters()); $actions[] = Action::make('view_tenant_register') ->label('View environment findings') @@ -448,13 +442,7 @@ public function table(Table $table): Table ->label('Clear filters') ->icon('heroicon-o-x-mark') ->color('gray') - ->action(function (): void { - $this->removeTableFilter('managed_environment_id'); - $this->removeTableFilter('status'); - $this->removeTableFilter('current_validity_state'); - $this->selectedFindingExceptionId = null; - $this->resetTable(); - }), + ->action(fn (): mixed => $this->clearQueueFilters()), ]); } @@ -520,6 +508,22 @@ public function clearSelectedException(): void $this->selectedFindingExceptionId = null; } + public function clearQueueFilters(): void + { + $hadEnvironmentFilter = $this->currentTenantFilterId() !== null; + + $this->removeTableFilter('managed_environment_id'); + $this->removeTableFilter('status'); + $this->removeTableFilter('current_validity_state'); + $this->selectedFindingExceptionId = null; + $this->clearWorkspaceHubEnvironmentFilterState(request()); + $this->resetTable(); + + if ($hadEnvironmentFilter) { + $this->redirectToCleanWorkspaceHubUrl(static::getUrl(panel: 'admin'), request()); + } + } + /** * @return array{label: string, clear_url: string}|null */ @@ -533,7 +537,7 @@ public function environmentFilterChip(): ?array return [ 'label' => (string) $tenant->name, - 'clear_url' => static::getUrl(panel: 'admin'), + 'clear_url' => $this->cleanWorkspaceHubUrl(static::getUrl(panel: 'admin')), ]; } diff --git a/apps/platform/app/Filament/Pages/Monitoring/Operations.php b/apps/platform/app/Filament/Pages/Monitoring/Operations.php index b3030f04..ff9a10fb 100644 --- a/apps/platform/app/Filament/Pages/Monitoring/Operations.php +++ b/apps/platform/app/Filament/Pages/Monitoring/Operations.php @@ -4,6 +4,7 @@ namespace App\Filament\Pages\Monitoring; +use App\Filament\Concerns\ClearsWorkspaceHubEnvironmentFilterState; use App\Filament\Resources\OperationRunResource; use App\Filament\Widgets\Operations\OperationsKpiHeader; use App\Models\ManagedEnvironment; @@ -42,6 +43,7 @@ class Operations extends Page implements HasForms, HasTable { + use ClearsWorkspaceHubEnvironmentFilterState; use InteractsWithForms; use InteractsWithTable; @@ -200,16 +202,10 @@ public function mount(): void $this->navigationContextPayload = is_array(request()->query('nav')) ? request()->query('nav') : null; - if (! request()->query->has('environment_id')) { - app(CanonicalAdminTenantFilterState::class) - ->forgetEnvironmentLikeFilters($this->getTableFiltersSessionKey(), request()); - } + $this->resetWorkspaceHubEnvironmentFilterStateForCleanEntry(request()); $this->mountInteractsWithTable(); - if (! request()->query->has('environment_id')) { - $this->tableFilters['managed_environment_id']['value'] = null; - $this->tableDeferredFilters['managed_environment_id']['value'] = null; - } + $this->resetWorkspaceHubEnvironmentFilterStateForCleanEntry(request()); $this->applyRequestedDashboardPrefilter(); } @@ -342,9 +338,9 @@ public function environmentFilterChip(): ?array return [ 'label' => (string) $tenant->name, - 'clear_url' => route('admin.operations.index', [ + 'clear_url' => $this->cleanWorkspaceHubUrl(route('admin.operations.index', [ 'workspace' => app(WorkspaceContext::class)->currentWorkspace(request()), - ]), + ])), ]; } diff --git a/apps/platform/app/Filament/Pages/Reviews/CustomerReviewWorkspace.php b/apps/platform/app/Filament/Pages/Reviews/CustomerReviewWorkspace.php index 529bbb1b..df91f4af 100644 --- a/apps/platform/app/Filament/Pages/Reviews/CustomerReviewWorkspace.php +++ b/apps/platform/app/Filament/Pages/Reviews/CustomerReviewWorkspace.php @@ -4,6 +4,7 @@ namespace App\Filament\Pages\Reviews; +use App\Filament\Concerns\ClearsWorkspaceHubEnvironmentFilterState; use App\Filament\Resources\EnvironmentReviewResource; use App\Models\EnvironmentReview; use App\Models\EvidenceSnapshot; @@ -18,7 +19,6 @@ use App\Support\Audit\AuditActionId; use App\Support\Auth\Capabilities; use App\Support\EnvironmentReviewCompletenessState; -use App\Support\Filament\CanonicalAdminTenantFilterState; use App\Support\Filament\TablePaginationProfiles; use App\Support\Findings\FindingOutcomeSemantics; use App\Support\Governance\Controls\ComplianceEvidenceMappingV1; @@ -50,6 +50,7 @@ class CustomerReviewWorkspace extends Page implements HasTable { + use ClearsWorkspaceHubEnvironmentFilterState; use InteractsWithTable; public const string DETAIL_CONTEXT_QUERY_KEY = 'customer_workspace'; @@ -113,12 +114,10 @@ public static function tenantPrefilterUrl(ManagedEnvironment $tenant): string public function mount(): void { $this->authorizePageAccess(); - if (! request()->query->has('environment_id')) { - app(CanonicalAdminTenantFilterState::class) - ->forgetEnvironmentLikeFilters($this->getTableFiltersSessionKey(), request()); - } + $this->resetWorkspaceHubEnvironmentFilterStateForCleanEntry(request()); $this->applyRequestedTenantPrefilter(); $this->mountInteractsWithTable(); + $this->resetWorkspaceHubEnvironmentFilterStateForCleanEntry(request()); $this->auditWorkspaceOpen(); } @@ -258,7 +257,7 @@ public function environmentFilterChip(): ?array return [ 'label' => (string) $tenant->name, - 'clear_url' => static::getUrl(panel: 'admin'), + 'clear_url' => $this->cleanWorkspaceHubUrl(static::getUrl(panel: 'admin')), ]; } @@ -431,7 +430,14 @@ private function hasActiveFilters(): bool private function clearWorkspaceFilters(): void { + $hadEnvironmentFilter = $this->currentTenantFilterId() !== null; + $this->removeTableFilters(); + $this->clearWorkspaceHubEnvironmentFilterState(request()); + + if ($hadEnvironmentFilter) { + $this->redirectToCleanWorkspaceHubUrl(static::getUrl(panel: 'admin'), request()); + } } private function workspaceEmptyStateHeading(): string diff --git a/apps/platform/app/Filament/Pages/Reviews/ReviewRegister.php b/apps/platform/app/Filament/Pages/Reviews/ReviewRegister.php index 32d8aa8e..62875559 100644 --- a/apps/platform/app/Filament/Pages/Reviews/ReviewRegister.php +++ b/apps/platform/app/Filament/Pages/Reviews/ReviewRegister.php @@ -4,6 +4,7 @@ namespace App\Filament\Pages\Reviews; +use App\Filament\Concerns\ClearsWorkspaceHubEnvironmentFilterState; use App\Filament\Resources\EnvironmentReviewResource; use App\Models\EnvironmentReview; use App\Models\ManagedEnvironment; @@ -16,7 +17,6 @@ use App\Support\Badges\BadgeDomain; use App\Support\Badges\BadgeRenderer; use App\Support\EnvironmentReviewCompletenessState; -use App\Support\Filament\CanonicalAdminTenantFilterState; use App\Support\Filament\FilterPresets; use App\Support\Filament\TablePaginationProfiles; use App\Support\Findings\FindingOutcomeSemantics; @@ -45,6 +45,7 @@ class ReviewRegister extends Page implements HasTable { + use ClearsWorkspaceHubEnvironmentFilterState; use InteractsWithTable; protected static bool $isDiscovered = false; @@ -80,13 +81,11 @@ public function mount(): void { $this->authorizePageAccess(); - if (! request()->query->has('environment_id')) { - app(CanonicalAdminTenantFilterState::class) - ->forgetEnvironmentLikeFilters($this->getTableFiltersSessionKey(), request()); - } + $this->resetWorkspaceHubEnvironmentFilterStateForCleanEntry(request()); $this->applyRequestedTenantPrefilter(); $this->mountInteractsWithTable(); + $this->resetWorkspaceHubEnvironmentFilterStateForCleanEntry(request()); } protected function getHeaderActions(): array @@ -241,7 +240,7 @@ public function environmentFilterChip(): ?array return [ 'label' => (string) $tenant->name, - 'clear_url' => static::getUrl(panel: 'admin'), + 'clear_url' => $this->cleanWorkspaceHubUrl(static::getUrl(panel: 'admin')), ]; } @@ -338,7 +337,14 @@ private function hasActiveFilters(): bool private function clearRegisterFilters(): void { + $hadEnvironmentFilter = $this->currentTenantFilterId() !== null; + $this->removeTableFilters(); + $this->clearWorkspaceHubEnvironmentFilterState(request()); + + if ($hadEnvironmentFilter) { + $this->redirectToCleanWorkspaceHubUrl(static::getUrl(panel: 'admin'), request()); + } } private function currentTenantFilterId(): ?int diff --git a/apps/platform/app/Filament/Resources/ProviderConnectionResource/Pages/ListProviderConnections.php b/apps/platform/app/Filament/Resources/ProviderConnectionResource/Pages/ListProviderConnections.php index b34d0c52..d8a1f896 100644 --- a/apps/platform/app/Filament/Resources/ProviderConnectionResource/Pages/ListProviderConnections.php +++ b/apps/platform/app/Filament/Resources/ProviderConnectionResource/Pages/ListProviderConnections.php @@ -2,12 +2,12 @@ namespace App\Filament\Resources\ProviderConnectionResource\Pages; +use App\Filament\Concerns\ClearsWorkspaceHubEnvironmentFilterState; use App\Filament\Resources\ProviderConnectionResource; use App\Models\ManagedEnvironment; use App\Models\User; use App\Services\Auth\CapabilityResolver; use App\Support\Auth\Capabilities; -use App\Support\Filament\CanonicalAdminTenantFilterState; use Filament\Actions; use Filament\Resources\Pages\ListRecords; use Filament\Schemas\Components\EmbeddedTable; @@ -18,16 +18,16 @@ class ListProviderConnections extends ListRecords { + use ClearsWorkspaceHubEnvironmentFilterState; + protected static string $resource = ProviderConnectionResource::class; public function mount(): void { - if (! request()->query->has('environment_id')) { - app(CanonicalAdminTenantFilterState::class) - ->forgetEnvironmentLikeFilters($this->getTableFiltersSessionKey(), request()); - } + $this->resetWorkspaceHubEnvironmentFilterStateForCleanEntry(request()); parent::mount(); + $this->resetWorkspaceHubEnvironmentFilterStateForCleanEntry(request()); } private function tableHasRecords(): bool @@ -256,7 +256,7 @@ private function environmentFilterChip(): ?array return [ 'label' => (string) $environment->name, - 'clear_url' => ProviderConnectionResource::getUrl('index', panel: 'admin'), + 'clear_url' => $this->cleanWorkspaceHubUrl(ProviderConnectionResource::getUrl('index', panel: 'admin')), ]; } diff --git a/apps/platform/app/Support/Filament/CanonicalAdminTenantFilterState.php b/apps/platform/app/Support/Filament/CanonicalAdminTenantFilterState.php index 9e682134..513b03d2 100644 --- a/apps/platform/app/Support/Filament/CanonicalAdminTenantFilterState.php +++ b/apps/platform/app/Support/Filament/CanonicalAdminTenantFilterState.php @@ -5,6 +5,7 @@ namespace App\Support\Filament; use App\Models\ManagedEnvironment; +use App\Support\Navigation\WorkspaceHubFilterStateResetter; use App\Support\Navigation\WorkspaceHubRegistry; use App\Support\OperateHub\OperateHubShell; use Illuminate\Http\Request; @@ -15,7 +16,10 @@ final class CanonicalAdminTenantFilterState { private const STATE_PREFIX = 'filament.admin_tenant_filter_state'; - public function __construct(private readonly OperateHubShell $operateHubShell) {} + public function __construct( + private readonly OperateHubShell $operateHubShell, + private readonly WorkspaceHubFilterStateResetter $workspaceHubFilterStateResetter, + ) {} public function currentFilterValue( string $filtersSessionKey, @@ -119,15 +123,8 @@ public function forgetEnvironmentLikeFilters( $persistedFilters = []; } - foreach (WorkspaceHubRegistry::environmentLikeFilterKeys() as $filterName) { - Arr::forget($persistedFilters, $filterName); - } - - if ($persistedFilters === []) { - $session->forget($filtersSessionKey); - } else { - $session->put($filtersSessionKey, $persistedFilters); - } + $persistedFilters = $this->workspaceHubFilterStateResetter + ->forgetPersistedEnvironmentLikeFilters($filtersSessionKey, $request, $persistedFilters); $session->forget($this->stateKey($filtersSessionKey)); diff --git a/apps/platform/app/Support/Navigation/WorkspaceHubFilterStateResetter.php b/apps/platform/app/Support/Navigation/WorkspaceHubFilterStateResetter.php new file mode 100644 index 00000000..fc4ce6e6 --- /dev/null +++ b/apps/platform/app/Support/Navigation/WorkspaceHubFilterStateResetter.php @@ -0,0 +1,159 @@ + + */ + public function environmentLikeFilterKeys(): array + { + return WorkspaceHubRegistry::environmentLikeFilterKeys(); + } + + public function shouldResetForCleanWorkspaceHubEntry(?Request $request = null): bool + { + return WorkspaceHubRegistry::isCleanWorkspaceHubEntry($request); + } + + public function neutralizeEnvironmentLikeQueryState(?Request $request = null): void + { + $request ??= request(); + $hasCanonicalEnvironmentFilter = WorkspaceHubRegistry::requestHasEnvironmentFilterQuery($request); + + foreach (WorkspaceHubRegistry::forbiddenQueryKeys() as $queryKey) { + if ($queryKey === 'environment_id' && $hasCanonicalEnvironmentFilter) { + continue; + } + + $request->query->remove($queryKey); + } + } + + /** + * @param array|null $persistedFilters + * @return array + */ + public function forgetPersistedEnvironmentLikeFilters( + string $filtersSessionKey, + ?Request $request = null, + ?array $persistedFilters = null, + ): array { + $session = $this->session($request); + + $persistedFilters ??= $session->get($filtersSessionKey, []); + + if (! is_array($persistedFilters)) { + $persistedFilters = []; + } + + $persistedFilters = $this->forgetEnvironmentLikeFilterPaths($persistedFilters); + + if ($persistedFilters === []) { + $session->forget($filtersSessionKey); + } else { + $session->put($filtersSessionKey, $persistedFilters); + } + + return $persistedFilters; + } + + public function forgetPersistedEnvironmentLikeFiltersForCleanWorkspaceHubEntry( + string $filtersSessionKey, + ?Request $request = null, + ): void { + if (! $this->shouldResetForCleanWorkspaceHubEntry($request)) { + return; + } + + $this->forgetPersistedEnvironmentLikeFilters($filtersSessionKey, $request); + } + + /** + * @param array|null $filters + * @return array + */ + public function clearLivewireTableFilterState(?array $filters): array + { + if (! is_array($filters)) { + return []; + } + + foreach ($this->environmentLikeFilterKeys() as $filterName) { + if (! array_key_exists($filterName, $filters)) { + continue; + } + + $filterState = $filters[$filterName]; + + if (is_array($filterState)) { + $filters[$filterName] = ['value' => null]; + + continue; + } + + Arr::forget($filters, $filterName); + } + + return $this->forgetNestedEnvironmentLikeFilterState($filters); + } + + /** + * @param array $parameters + * @return array + */ + public function cleanParameters(array $parameters): array + { + return WorkspaceHubRegistry::cleanParameters($parameters); + } + + public function cleanUrl(string $url): string + { + return WorkspaceHubRegistry::cleanUrl($url); + } + + /** + * @param array $state + * @return array + */ + private function forgetEnvironmentLikeFilterPaths(array $state): array + { + foreach ($this->environmentLikeFilterKeys() as $filterName) { + Arr::forget($state, $filterName); + } + + return $this->forgetNestedEnvironmentLikeFilterState($state); + } + + /** + * @param array $state + * @return array + */ + private function forgetNestedEnvironmentLikeFilterState(array $state): array + { + foreach ($this->environmentLikeFilterKeys() as $filterName) { + Arr::forget($state, "tableFilters.{$filterName}"); + Arr::forget($state, "tableDeferredFilters.{$filterName}"); + } + + foreach (['tableFilters', 'tableDeferredFilters'] as $tableStateKey) { + if (data_get($state, $tableStateKey) === []) { + Arr::forget($state, $tableStateKey); + } + } + + return $state; + } + + private function session(?Request $request = null): Store + { + return ($request && $request->hasSession()) ? $request->session() : app('session.store'); + } +} diff --git a/apps/platform/tests/Browser/Spec316WorkspaceHubClearFilterSmokeTest.php b/apps/platform/tests/Browser/Spec316WorkspaceHubClearFilterSmokeTest.php new file mode 100644 index 00000000..c0093f67 --- /dev/null +++ b/apps/platform/tests/Browser/Spec316WorkspaceHubClearFilterSmokeTest.php @@ -0,0 +1,348 @@ +browser()->timeout(60_000); + +it('Spec316 smokes filtered workspace hub clear and reload behavior', function (): void { + [$user, $environmentA, $environmentB] = spec316BrowserClearFilterWorkspace(); + $workspace = $environmentA->workspace()->firstOrFail(); + + $this->actingAs($user)->withSession([ + WorkspaceContext::SESSION_KEY => (int) $workspace->getKey(), + WorkspaceContext::LAST_TENANT_IDS_SESSION_KEY => [ + (string) $workspace->getKey() => (int) $environmentA->getKey(), + ], + ]); + + session()->put(WorkspaceContext::SESSION_KEY, (int) $workspace->getKey()); + session()->put(WorkspaceContext::LAST_TENANT_IDS_SESSION_KEY, [ + (string) $workspace->getKey() => (int) $environmentA->getKey(), + ]); + + $hubs = [ + 'operations' => [ + 'filtered_url' => OperationRunLinks::index($environmentA), + 'clean_url' => OperationRunLinks::index(), + 'wide_text' => 'Inventory sync', + ], + 'provider connections' => [ + 'filtered_url' => ProviderConnectionResource::getUrl('index', [ + 'environment_id' => (int) $environmentA->getKey(), + ], panel: 'admin'), + 'clean_url' => ProviderConnectionResource::getUrl('index', panel: 'admin'), + 'wide_text' => 'Spec316 Browser Provider B', + ], + 'finding exceptions queue' => [ + 'filtered_url' => FindingExceptionsQueue::getUrl(panel: 'admin', parameters: [ + 'environment_id' => (int) $environmentA->getKey(), + ]), + 'clean_url' => FindingExceptionsQueue::getUrl(panel: 'admin'), + 'wide_text' => $environmentB->name, + ], + 'evidence overview' => [ + 'filtered_url' => route('admin.evidence.overview', [ + 'environment_id' => (int) $environmentA->getKey(), + ]), + 'clean_url' => route('admin.evidence.overview'), + 'wide_text' => $environmentB->name, + ], + 'review register' => [ + 'filtered_url' => ReviewRegister::getUrl(panel: 'admin', parameters: [ + 'environment_id' => (int) $environmentA->getKey(), + ]), + 'clean_url' => ReviewRegister::getUrl(panel: 'admin'), + 'wide_text' => $environmentB->name, + ], + 'customer review workspace' => [ + 'filtered_url' => CustomerReviewWorkspace::tenantPrefilterUrl($environmentA), + 'clean_url' => CustomerReviewWorkspace::getUrl(panel: 'admin'), + 'wide_text' => $environmentB->name, + ], + 'governance inbox' => [ + 'filtered_url' => GovernanceInbox::getUrl(panel: 'admin', parameters: [ + 'environment_id' => (int) $environmentA->getKey(), + ]), + 'clean_url' => GovernanceInbox::getUrl(panel: 'admin'), + 'wide_text' => 'Spec316 Browser Governance B', + ], + 'decision register' => [ + 'filtered_url' => DecisionRegister::getUrl(panel: 'admin', parameters: [ + 'environment_id' => (int) $environmentA->getKey(), + ]), + 'clean_url' => DecisionRegister::getUrl(panel: 'admin'), + 'wide_text' => $environmentB->name, + ], + ]; + + foreach ($hubs as $hub) { + $cleanPath = json_encode((string) parse_url($hub['clean_url'], PHP_URL_PATH), JSON_THROW_ON_ERROR); + + $page = visit($hub['filtered_url']) + ->waitForText('Environment filter:') + ->assertSee($environmentA->name) + ->assertDontSee($hub['wide_text']) + ->assertNoJavaScriptErrors(); + + $page + ->click('[data-testid="workspace-hub-environment-filter-clear"]') + ->waitForText(__('localization.shell.no_environment_selected')) + ->assertDontSee('Environment filter:') + ->assertSee($hub['wide_text']) + ->assertScript("window.location.pathname === {$cleanPath}", true) + ->assertScript('! window.location.search.includes("environment_id=")', true) + ->assertScript('! window.location.search.includes("tenant=")', true) + ->assertScript('! window.location.search.includes("managed_environment_id=")', true) + ->assertScript('! window.location.search.includes("tenant_scope=")', true) + ->assertScript('! window.location.search.includes("tableFilters")', true) + ->assertNoJavaScriptErrors(); + + $page->script('window.location.reload();'); + + $page + ->waitForText(__('localization.shell.no_environment_selected')) + ->assertDontSee('Environment filter:') + ->assertSee($hub['wide_text']) + ->assertScript("window.location.pathname === {$cleanPath}", true) + ->assertScript('! window.location.search.includes("environment_id=")', true) + ->assertNoJavaScriptErrors(); + } +}); + +it('Spec316 smokes browser back and forward alignment for high risk hubs', function (): void { + [$user, $environmentA, $environmentB] = spec316BrowserClearFilterWorkspace(); + $workspace = $environmentA->workspace()->firstOrFail(); + + $this->actingAs($user)->withSession([ + WorkspaceContext::SESSION_KEY => (int) $workspace->getKey(), + ]); + + session()->put(WorkspaceContext::SESSION_KEY, (int) $workspace->getKey()); + + $hubs = [ + ProviderConnectionResource::getUrl('index', [ + 'environment_id' => (int) $environmentA->getKey(), + ], panel: 'admin'), + FindingExceptionsQueue::getUrl(panel: 'admin', parameters: [ + 'environment_id' => (int) $environmentA->getKey(), + ]), + CustomerReviewWorkspace::tenantPrefilterUrl($environmentA), + route('admin.evidence.overview', [ + 'environment_id' => (int) $environmentA->getKey(), + ]), + ]; + + foreach ($hubs as $filteredUrl) { + $page = visit($filteredUrl) + ->waitForText('Environment filter:') + ->assertSee($environmentA->name) + ->assertDontSee($environmentB->name); + + $page + ->click('[data-testid="workspace-hub-environment-filter-clear"]') + ->waitForText(__('localization.shell.no_environment_selected')) + ->assertDontSee('Environment filter:') + ->assertSee($environmentB->name); + + $page->script('window.history.back();'); + + $page + ->waitForText('Environment filter:') + ->assertSee($environmentA->name) + ->assertDontSee($environmentB->name) + ->assertScript('window.location.search.includes("environment_id=")', true); + + $page->script('window.history.forward();'); + + $page + ->waitForText(__('localization.shell.no_environment_selected')) + ->assertDontSee('Environment filter:') + ->assertSee($environmentB->name) + ->assertScript('! window.location.search.includes("environment_id=")', true) + ->assertNoJavaScriptErrors(); + } +}); + +it('Spec316 smokes persisted environment filters do not survive clean browser entry', function (): void { + [$user, $environmentA, $environmentB] = spec316BrowserClearFilterWorkspace(); + $workspace = $environmentA->workspace()->firstOrFail(); + + $this->actingAs($user)->withSession([ + WorkspaceContext::SESSION_KEY => (int) $workspace->getKey(), + ]); + + session()->put(WorkspaceContext::SESSION_KEY, (int) $workspace->getKey()); + + $cases = [ + [ + 'component' => ListProviderConnections::class, + 'url' => ProviderConnectionResource::getUrl('index', panel: 'admin'), + 'filter_name' => 'tenant', + 'filter_value' => (string) $environmentA->external_id, + 'wide_text' => 'Spec316 Browser Provider B', + ], + [ + 'component' => FindingExceptionsQueue::class, + 'url' => FindingExceptionsQueue::getUrl(panel: 'admin'), + 'filter_name' => 'managed_environment_id', + 'filter_value' => (string) $environmentA->getKey(), + 'wide_text' => $environmentB->name, + ], + [ + 'component' => CustomerReviewWorkspace::class, + 'url' => CustomerReviewWorkspace::getUrl(panel: 'admin'), + 'filter_name' => 'managed_environment_id', + 'filter_value' => (string) $environmentA->getKey(), + 'wide_text' => $environmentB->name, + ], + [ + 'component' => EvidenceOverview::class, + 'url' => route('admin.evidence.overview'), + 'filter_name' => 'managed_environment_id', + 'filter_value' => (string) $environmentA->getKey(), + 'wide_text' => $environmentB->name, + ], + ]; + + foreach ($cases as $case) { + $component = Livewire::actingAs($user)->test($case['component']); + $filtersSessionKey = $component->instance()->getTableFiltersSessionKey(); + + session()->put($filtersSessionKey, [ + $case['filter_name'] => ['value' => $case['filter_value']], + ]); + + visit($case['url']) + ->waitForText(__('localization.shell.no_environment_selected')) + ->assertDontSee('Environment filter:') + ->assertSee($case['wide_text']) + ->assertNoJavaScriptErrors(); + } +}); + +/** + * @return array{0: User, 1: ManagedEnvironment, 2: ManagedEnvironment} + */ +function spec316BrowserClearFilterWorkspace(): array +{ + $environmentA = ManagedEnvironment::factory()->active()->create([ + 'name' => 'Spec316 Browser Environment A', + 'external_id' => 'spec316-browser-environment-a', + ]); + [$user, $environmentA] = createUserWithTenant(tenant: $environmentA, role: 'owner', workspaceRole: 'manager'); + + $environmentB = ManagedEnvironment::factory()->active()->create([ + 'workspace_id' => (int) $environmentA->workspace_id, + 'name' => 'Spec316 Browser Environment B', + 'external_id' => 'spec316-browser-environment-b', + ]); + createUserWithTenant(tenant: $environmentB, user: $user, role: 'owner', workspaceRole: 'manager'); + + OperationRun::factory()->forTenant($environmentA)->create(['type' => 'policy.sync']); + OperationRun::factory()->forTenant($environmentB)->create(['type' => 'inventory_sync']); + + spec316BrowserFindingException($environmentA, $user, 'Spec316 Browser Governance A', 'Spec316 Browser Decision A'); + spec316BrowserFindingException($environmentB, $user, 'Spec316 Browser Governance B', 'Spec316 Browser Decision B'); + + ProviderConnection::factory()->create([ + 'workspace_id' => (int) $environmentA->workspace_id, + 'managed_environment_id' => (int) $environmentA->getKey(), + 'display_name' => 'Spec316 Browser Provider A', + ]); + ProviderConnection::factory()->create([ + 'workspace_id' => (int) $environmentB->workspace_id, + 'managed_environment_id' => (int) $environmentB->getKey(), + 'display_name' => 'Spec316 Browser Provider B', + ]); + + $snapshotA = spec316BrowserEvidenceSnapshot($environmentA); + $snapshotB = spec316BrowserEvidenceSnapshot($environmentB); + + spec316BrowserPublishedReview($environmentA, $user, $snapshotA); + spec316BrowserPublishedReview($environmentB, $user, $snapshotB); + + return [$user, $environmentA, $environmentB]; +} + +function spec316BrowserFindingException( + ManagedEnvironment $environment, + User $actor, + string $requestReason, + string $decisionReason, +): FindingException { + $finding = Finding::factory()->for($environment)->riskAccepted()->create([ + 'workspace_id' => (int) $environment->workspace_id, + 'subject_external_id' => str()->slug($requestReason), + ]); + + $exception = FindingException::query()->create([ + 'workspace_id' => (int) $environment->workspace_id, + 'managed_environment_id' => (int) $environment->getKey(), + 'finding_id' => (int) $finding->getKey(), + 'requested_by_user_id' => (int) $actor->getKey(), + 'owner_user_id' => (int) $actor->getKey(), + 'status' => FindingException::STATUS_PENDING, + 'current_validity_state' => FindingException::VALIDITY_MISSING_SUPPORT, + 'request_reason' => $requestReason, + 'requested_at' => now()->subDay(), + 'review_due_at' => now()->addDay(), + 'evidence_summary' => ['reference_count' => 0], + ]); + + $decision = $exception->decisions()->create([ + 'workspace_id' => (int) $environment->workspace_id, + 'managed_environment_id' => (int) $environment->getKey(), + 'actor_user_id' => (int) $actor->getKey(), + 'decision_type' => FindingExceptionDecision::TYPE_REQUESTED, + 'reason' => $decisionReason, + 'metadata' => [], + 'decided_at' => now()->subDay(), + ]); + + $exception->forceFill(['current_decision_id' => (int) $decision->getKey()])->save(); + + return $exception->fresh(['currentDecision']); +} + +function spec316BrowserEvidenceSnapshot(ManagedEnvironment $environment): EvidenceSnapshot +{ + return EvidenceSnapshot::query()->create([ + 'managed_environment_id' => (int) $environment->getKey(), + 'workspace_id' => (int) $environment->workspace_id, + 'status' => EvidenceSnapshotStatus::Active->value, + 'summary' => ['missing_dimensions' => 0, 'stale_dimensions' => 0], + 'generated_at' => now(), + ]); +} + +function spec316BrowserPublishedReview(ManagedEnvironment $environment, User $user, EvidenceSnapshot $snapshot): void +{ + $review = composeEnvironmentReviewForTest($environment, $user, $snapshot); + $review->forceFill([ + 'status' => EnvironmentReviewStatus::Published->value, + 'published_at' => now(), + 'published_by_user_id' => (int) $user->getKey(), + ])->save(); +} diff --git a/apps/platform/tests/Feature/EnvironmentReview/EnvironmentReviewRegisterTest.php b/apps/platform/tests/Feature/EnvironmentReview/EnvironmentReviewRegisterTest.php index 10b738c3..eafd7c36 100644 --- a/apps/platform/tests/Feature/EnvironmentReview/EnvironmentReviewRegisterTest.php +++ b/apps/platform/tests/Feature/EnvironmentReview/EnvironmentReviewRegisterTest.php @@ -101,7 +101,13 @@ $component ->callAction('clear_filters') - ->assertActionHidden('clear_filters') + ->assertRedirect(ReviewRegister::getUrl(panel: 'admin')); + + Livewire::withHeaders(['referer' => ReviewRegister::getUrl(panel: 'admin')]) + ->withQueryParams([]) + ->actingAs($user) + ->test(ReviewRegister::class) + ->assertSet('tableFilters.managed_environment_id.value', null) ->assertCanSeeTableRecords([$reviewA, $reviewB]); expect(app(WorkspaceContext::class)->lastTenantId())->toBe((int) $tenantA->getKey()); diff --git a/apps/platform/tests/Feature/Monitoring/FindingExceptionsQueueTest.php b/apps/platform/tests/Feature/Monitoring/FindingExceptionsQueueTest.php index c4f4018b..18b2dc05 100644 --- a/apps/platform/tests/Feature/Monitoring/FindingExceptionsQueueTest.php +++ b/apps/platform/tests/Feature/Monitoring/FindingExceptionsQueueTest.php @@ -81,8 +81,8 @@ 'tenant' => (string) $tenantB->external_id, ]) ->test(FindingExceptionsQueue::class) - ->assertSet('tableFilters.managed_environment_id.value', (string) $tenantB->getKey()) - ->assertActionVisible('view_tenant_register'); + ->assertSet('tableFilters.managed_environment_id.value', null) + ->assertActionHidden('view_tenant_register'); $filtersComponent = Livewire::test(FindingExceptionsQueue::class); $queueInstance = $filtersComponent->instance(); diff --git a/apps/platform/tests/Feature/Navigation/WorkspaceHubClearFilterContractTest.php b/apps/platform/tests/Feature/Navigation/WorkspaceHubClearFilterContractTest.php new file mode 100644 index 00000000..313a4955 --- /dev/null +++ b/apps/platform/tests/Feature/Navigation/WorkspaceHubClearFilterContractTest.php @@ -0,0 +1,335 @@ +put($filtersSessionKey, [ + 'tenant' => ['value' => 'legacy-tenant'], + 'tenant_id' => ['value' => '101'], + 'managed_environment_id' => ['value' => '101'], + 'environment_id' => ['value' => '101'], + 'environment' => ['value' => 'legacy-environment'], + 'tenant_scope' => ['value' => 'environment'], + 'tableFilters' => [ + 'tenant' => ['value' => 'legacy-tenant'], + 'managed_environment_id' => ['value' => '101'], + 'status' => ['value' => 'pending'], + ], + 'status' => ['value' => 'pending'], + ]); + + app(WorkspaceHubFilterStateResetter::class) + ->forgetPersistedEnvironmentLikeFilters($filtersSessionKey, request()); + + $persistedFilters = session()->get($filtersSessionKey); + + expect($persistedFilters) + ->toMatchArray([ + 'status' => ['value' => 'pending'], + 'tableFilters' => [ + 'status' => ['value' => 'pending'], + ], + ]); + + foreach (WorkspaceHubRegistry::environmentLikeFilterKeys() as $filterName) { + expect(data_get($persistedFilters, "{$filterName}.value"))->toBeNull() + ->and(data_get($persistedFilters, "tableFilters.{$filterName}.value"))->toBeNull(); + } +}); + +it('Spec316 clear filter result is reload safe across table backed workspace hubs', function (): void { + [$user, $environmentA, $environmentB, $records] = spec316ClearFilterWorkspace(); + + $this->actingAs($user); + setAdminPanelContext(); + session()->put(WorkspaceContext::SESSION_KEY, (int) $environmentA->workspace_id); + session()->put(WorkspaceContext::LAST_TENANT_IDS_SESSION_KEY, [ + (string) $environmentA->workspace_id => (int) $environmentA->getKey(), + ]); + + $cases = [ + 'operations' => [ + 'component' => Operations::class, + 'clean_url' => OperationRunLinks::index(), + 'filtered_records' => [$records['runA']], + 'hidden_records' => [$records['runB']], + 'wide_records' => [$records['runA'], $records['runB']], + 'session_environment_value' => (string) $environmentA->getKey(), + ], + 'finding exceptions queue' => [ + 'component' => FindingExceptionsQueue::class, + 'clean_url' => FindingExceptionsQueue::getUrl(panel: 'admin'), + 'filtered_records' => [$records['exceptionA']], + 'hidden_records' => [$records['exceptionB']], + 'wide_records' => [$records['exceptionA'], $records['exceptionB']], + 'session_environment_value' => (string) $environmentA->getKey(), + ], + 'provider connections' => [ + 'component' => ListProviderConnections::class, + 'clean_url' => ProviderConnectionResource::getUrl('index', panel: 'admin'), + 'filtered_records' => [$records['connectionA']], + 'hidden_records' => [$records['connectionB']], + 'wide_records' => [$records['connectionA'], $records['connectionB']], + 'session_environment_value' => (string) $environmentA->external_id, + ], + 'evidence overview' => [ + 'component' => EvidenceOverview::class, + 'clean_url' => route('admin.evidence.overview'), + 'filtered_records' => [(string) $records['snapshotA']->getKey()], + 'hidden_records' => [(string) $records['snapshotB']->getKey()], + 'wide_records' => [(string) $records['snapshotA']->getKey(), (string) $records['snapshotB']->getKey()], + 'session_environment_value' => (string) $environmentA->getKey(), + ], + 'review register' => [ + 'component' => ReviewRegister::class, + 'clean_url' => ReviewRegister::getUrl(panel: 'admin'), + 'filtered_records' => [$records['reviewA']->fresh()], + 'hidden_records' => [$records['reviewB']->fresh()], + 'wide_records' => [$records['reviewA']->fresh(), $records['reviewB']->fresh()], + 'session_environment_value' => (string) $environmentA->getKey(), + ], + 'customer review workspace' => [ + 'component' => CustomerReviewWorkspace::class, + 'clean_url' => CustomerReviewWorkspace::getUrl(panel: 'admin'), + 'filtered_records' => [$environmentA->fresh()], + 'hidden_records' => [$environmentB->fresh()], + 'wide_records' => [$environmentA->fresh(), $environmentB->fresh()], + 'session_environment_value' => (string) $environmentA->getKey(), + ], + ]; + + foreach ($cases as $case) { + $filteredComponent = Livewire::withQueryParams(['environment_id' => (int) $environmentA->getKey()]) + ->actingAs($user) + ->test($case['component']) + ->assertSee('Environment filter:') + ->assertSee('Clear filter') + ->assertCanSeeTableRecords($case['filtered_records']) + ->assertCanNotSeeTableRecords($case['hidden_records']); + + $filtersSessionKey = $filteredComponent->instance()->getTableFiltersSessionKey(); + spec316PersistLegacyEnvironmentFilterState($filtersSessionKey, $case['session_environment_value']); + + $this->get($case['clean_url']) + ->assertOk() + ->assertDontSee('Environment filter:'); + + spec316AssertEnvironmentLikeFiltersForgotten($filtersSessionKey); + + $this->get($case['clean_url']) + ->assertOk() + ->assertDontSee('Environment filter:'); + + Livewire::withQueryParams([]) + ->actingAs($user) + ->test($case['component']) + ->assertDontSee('Environment filter:') + ->assertCanSeeTableRecords($case['wide_records']); + } +}); + +it('Spec316 clear filter result is clean for governance and decision workspace hubs', function (): void { + [$user, $environmentA, $environmentB, $records] = spec316ClearFilterWorkspace(); + + $this->actingAs($user); + setAdminPanelContext(); + session()->put(WorkspaceContext::SESSION_KEY, (int) $environmentA->workspace_id); + + $this->get(GovernanceInbox::getUrl(panel: 'admin', parameters: ['environment_id' => (int) $environmentA->getKey()])) + ->assertOk() + ->assertSee('Environment filter:') + ->assertSee($environmentA->name) + ->assertDontSee('Spec316 Governance B'); + + $this->get(GovernanceInbox::getUrl(panel: 'admin')) + ->assertOk() + ->assertDontSee('Environment filter:') + ->assertSee('Spec316 Governance B'); + + $this->get(DecisionRegister::getUrl(panel: 'admin', parameters: ['environment_id' => (int) $environmentA->getKey()])) + ->assertOk() + ->assertSee('Environment filter:') + ->assertSee($environmentA->name) + ->assertDontSee('Spec316 Decision B'); + + $this->get(DecisionRegister::getUrl(panel: 'admin')) + ->assertOk() + ->assertDontSee('Environment filter:'); + + Livewire::withQueryParams([]) + ->actingAs($user) + ->test(DecisionRegister::class) + ->assertCanSeeTableRecords([$records['exceptionA'], $records['exceptionB']]); + + expect($environmentB->workspace_id)->toBe($environmentA->workspace_id); +}); + +/** + * @return array{0: User, 1: ManagedEnvironment, 2: ManagedEnvironment, 3: array} + */ +function spec316ClearFilterWorkspace(): array +{ + $environmentA = ManagedEnvironment::factory()->active()->create([ + 'name' => 'Spec316 Environment A', + 'external_id' => 'spec316-environment-a', + ]); + [$user, $environmentA] = createUserWithTenant(tenant: $environmentA, role: 'owner', workspaceRole: 'manager'); + + $environmentB = ManagedEnvironment::factory()->active()->create([ + 'workspace_id' => (int) $environmentA->workspace_id, + 'name' => 'Spec316 Environment B', + 'external_id' => 'spec316-environment-b', + ]); + createUserWithTenant(tenant: $environmentB, user: $user, role: 'owner', workspaceRole: 'manager'); + + $runA = OperationRun::factory()->forTenant($environmentA)->create(['type' => 'policy.sync']); + $runB = OperationRun::factory()->forTenant($environmentB)->create(['type' => 'inventory_sync']); + + $exceptionA = spec316ClearFilterFindingException($environmentA, $user, 'Spec316 Governance A', 'Spec316 Decision A'); + $exceptionB = spec316ClearFilterFindingException($environmentB, $user, 'Spec316 Governance B', 'Spec316 Decision B'); + + $connectionA = ProviderConnection::factory()->create([ + 'workspace_id' => (int) $environmentA->workspace_id, + 'managed_environment_id' => (int) $environmentA->getKey(), + 'display_name' => 'Spec316 Provider A', + ]); + $connectionB = ProviderConnection::factory()->create([ + 'workspace_id' => (int) $environmentB->workspace_id, + 'managed_environment_id' => (int) $environmentB->getKey(), + 'display_name' => 'Spec316 Provider B', + ]); + + $snapshotA = spec316ClearFilterEvidenceSnapshot($environmentA); + $snapshotB = spec316ClearFilterEvidenceSnapshot($environmentB); + + $reviewA = composeEnvironmentReviewForTest($environmentA, $user, $snapshotA); + $reviewA->forceFill([ + 'status' => EnvironmentReviewStatus::Published->value, + 'published_at' => now(), + 'published_by_user_id' => (int) $user->getKey(), + ])->save(); + + $reviewB = composeEnvironmentReviewForTest($environmentB, $user, $snapshotB); + $reviewB->forceFill([ + 'status' => EnvironmentReviewStatus::Published->value, + 'published_at' => now(), + 'published_by_user_id' => (int) $user->getKey(), + ])->save(); + + return [$user, $environmentA, $environmentB, compact( + 'runA', + 'runB', + 'exceptionA', + 'exceptionB', + 'connectionA', + 'connectionB', + 'snapshotA', + 'snapshotB', + 'reviewA', + 'reviewB', + )]; +} + +function spec316ClearFilterFindingException( + ManagedEnvironment $environment, + User $actor, + string $requestReason, + string $decisionReason, +): FindingException { + $finding = Finding::factory()->for($environment)->riskAccepted()->create([ + 'workspace_id' => (int) $environment->workspace_id, + 'subject_external_id' => str()->slug($requestReason), + ]); + + $exception = FindingException::query()->create([ + 'workspace_id' => (int) $environment->workspace_id, + 'managed_environment_id' => (int) $environment->getKey(), + 'finding_id' => (int) $finding->getKey(), + 'requested_by_user_id' => (int) $actor->getKey(), + 'owner_user_id' => (int) $actor->getKey(), + 'status' => FindingException::STATUS_PENDING, + 'current_validity_state' => FindingException::VALIDITY_MISSING_SUPPORT, + 'request_reason' => $requestReason, + 'requested_at' => now()->subDay(), + 'review_due_at' => now()->addDay(), + 'evidence_summary' => ['reference_count' => 0], + ]); + + $decision = $exception->decisions()->create([ + 'workspace_id' => (int) $environment->workspace_id, + 'managed_environment_id' => (int) $environment->getKey(), + 'actor_user_id' => (int) $actor->getKey(), + 'decision_type' => FindingExceptionDecision::TYPE_REQUESTED, + 'reason' => $decisionReason, + 'metadata' => [], + 'decided_at' => now()->subDay(), + ]); + + $exception->forceFill(['current_decision_id' => (int) $decision->getKey()])->save(); + + return $exception->fresh(['currentDecision']); +} + +function spec316ClearFilterEvidenceSnapshot(ManagedEnvironment $environment): EvidenceSnapshot +{ + return EvidenceSnapshot::query()->create([ + 'managed_environment_id' => (int) $environment->getKey(), + 'workspace_id' => (int) $environment->workspace_id, + 'status' => EvidenceSnapshotStatus::Active->value, + 'summary' => ['missing_dimensions' => 0, 'stale_dimensions' => 0], + 'generated_at' => now(), + ]); +} + +function spec316PersistLegacyEnvironmentFilterState(string $filtersSessionKey, string $environmentValue): void +{ + session()->put($filtersSessionKey, [ + 'tenant' => ['value' => $environmentValue], + 'tenant_id' => ['value' => $environmentValue], + 'managed_environment_id' => ['value' => $environmentValue], + 'environment_id' => ['value' => $environmentValue], + 'environment' => ['value' => $environmentValue], + 'tenant_scope' => ['value' => 'environment'], + 'tableFilters' => [ + 'tenant' => ['value' => $environmentValue], + 'managed_environment_id' => ['value' => $environmentValue], + ], + ]); +} + +function spec316AssertEnvironmentLikeFiltersForgotten(string $filtersSessionKey): void +{ + $persistedFilters = session()->get($filtersSessionKey, []); + + foreach (WorkspaceHubRegistry::environmentLikeFilterKeys() as $filterName) { + expect(data_get($persistedFilters, "{$filterName}.value"))->toBeNull() + ->and(data_get($persistedFilters, "tableFilters.{$filterName}.value"))->toBeNull(); + } +} diff --git a/specs/316-workspace-hub-clear-filter-contract/checklists/requirements.md b/specs/316-workspace-hub-clear-filter-contract/checklists/requirements.md new file mode 100644 index 00000000..81207616 --- /dev/null +++ b/specs/316-workspace-hub-clear-filter-contract/checklists/requirements.md @@ -0,0 +1,72 @@ +# Requirements Checklist: Workspace Hub Clear Filter Contract + +**Spec**: [spec.md](/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/316-workspace-hub-clear-filter-contract/spec.md) +**Plan**: [plan.md](/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/316-workspace-hub-clear-filter-contract/plan.md) +**Generated**: 2026-05-16 + +## Candidate Selection Gate + +- [x] Explicit user-provided Spec 316 request was selected as the source of truth for this preparation pass. +- [x] Completed-spec guardrail checked that no existing `specs/316-*` artifact was present before generation. +- [x] Specs 313, 314, and 315 were treated as completed historical baseline context, not rewritten. +- [x] Close alternatives were identified as follow-up specs 317 and 318 rather than merged into this spec. +- [x] The selected slice is clear-filter lifecycle hardening only. + +## Spec Readiness + +- [x] Problem statement is operator-visible and tied to stale clear-filter state. +- [x] Hard-cutover policy is explicit. +- [x] `environment_id` remains the only canonical Environment filter source. +- [x] Legacy keys are explicitly neutralized as filter sources. +- [x] URL, Livewire, Filament table, deferred, session/persisted, chip/header, data, reload, and back/forward layers are named. +- [x] Required hubs are named. +- [x] Optional hub handling is bounded and does not add new filter support. +- [x] Workspace isolation and authorization constraints are explicit. +- [x] Follow-up boundaries for Specs 317 and 318 are explicit. + +## Plan Readiness + +- [x] Laravel, Filament, Livewire, Pest, and PostgreSQL context is recorded. +- [x] No migration, seeder, package, env var, queue, scheduler, or storage change is planned. +- [x] Shared reset mechanism proportionality is justified. +- [x] Existing repo surfaces are named: `WorkspaceHubRegistry`, `WorkspaceHubEnvironmentFilter`, `CanonicalAdminTenantFilterState`, chip partial, and required hub pages/resources. +- [x] Provider/platform boundary is classified. +- [x] OperationRun impact is N/A. +- [x] Browser verification scope is defined. +- [x] Deployment impact is assessed. + +## Task Readiness + +- [x] Tasks are ordered from guardrails and tests through runtime changes and validation. +- [x] Tests are required before or alongside runtime work. +- [x] Critical hubs are named in tasks. +- [x] Optional hubs are classified instead of assumed. +- [x] Legacy alias neutralization has explicit tasks. +- [x] Spec 314 and Spec 315 regressions have explicit tasks. +- [x] Browser reload and back/forward flows have explicit tasks. +- [x] Non-tasks prevent scope creep into Specs 317 and 318. + +## Constitution / Guardrail Coverage + +- [x] Workspace isolation is covered. +- [x] RBAC and no-access behavior are covered. +- [x] No Graph write/read integration change is introduced. +- [x] No destructive action behavior is introduced. +- [x] No persisted truth is introduced. +- [x] No status/reason family is introduced. +- [x] Filament v5 / Livewire v4 compliance is recorded. +- [x] Native/shared UI primitive preference is recorded. +- [x] Test governance classification and lanes are recorded. +- [x] Proportionality review is completed for the shared reset helper. + +## Open Questions + +- [x] No blocking requirements questions remain for preparation. +- [x] Runtime implementation must still confirm exact Filament persisted filter/session keys per hub. +- [x] Runtime implementation must document optional pages intentionally excluded. +- [x] Browser back/forward limitations must be documented if tooling is unstable. + +## Review Outcome + +- [x] Spec artifacts are ready for `/spec-kit-implementation-loop` or equivalent implementation pass. +- [x] No application implementation was performed during preparation. diff --git a/specs/316-workspace-hub-clear-filter-contract/plan.md b/specs/316-workspace-hub-clear-filter-contract/plan.md new file mode 100644 index 00000000..2c36d2f5 --- /dev/null +++ b/specs/316-workspace-hub-clear-filter-contract/plan.md @@ -0,0 +1,342 @@ +# Implementation Plan: Workspace Hub Clear Filter Contract + +**Branch**: `316-workspace-hub-clear-filter-contract` | **Date**: 2026-05-16 | **Spec**: [spec.md](/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/316-workspace-hub-clear-filter-contract/spec.md) +**Input**: Feature specification from `/specs/316-workspace-hub-clear-filter-contract/spec.md` + +**Preparation status**: Specification artifacts only. No runtime implementation has been performed by this preparation step. + +## Summary + +Spec 316 completes the active Workspace Hub Environment filter lifecycle: + +```text +314: sidebar/global entry -> clean workspace-wide hub +315: Environment CTA -> workspace hub ?environment_id=... +316: Clear filter -> clean workspace-wide hub, reload-safe +``` + +The implementation must make clear-filter behavior shared, complete, and reliable across URL query state, Livewire/page state, Filament table/deferred filters, persisted/session filters, visible chip state, header/scope wording, rendered data scope, reload, and focused browser history flows. + +## Technical Context + +**Language/Version**: PHP 8.4.15, Laravel 12.52.0 +**Primary Dependencies**: Filament 5.2.1, Livewire 4.1.4, Laravel Sail, Laravel Socialite, Laravel MCP +**Storage**: PostgreSQL; no schema changes for this spec +**Testing**: Pest 4.3.1 / PHPUnit 12.5.4; focused browser smoke where applicable +**Validation Lanes**: fast-feedback, confidence for Filament/Livewire state, browser for reload/history smoke +**Target Platform**: Laravel admin application under `apps/platform`, local development through Sail, staging/production through Dokploy +**Project Type**: Web application, Laravel/Filament admin panel +**Performance Goals**: No material performance change. Reset logic should touch bounded session/filter arrays and existing page state only. +**Constraints**: No migrations, seeders, new packages, env vars, queues, scheduler, storage, compatibility aliases, compatibility redirects, or broad legacy cleanup. +**Scale/Scope**: Cross-cutting filter-state hardening across required Spec 315 workspace hubs. + +## UI / Surface Guardrail Plan + +- **Guardrail scope**: Changed operator-facing scope truth for clear-filter behavior on existing workspace hubs. +- **Native vs custom classification summary**: Native Filament/Livewire pages/resources with the existing shared chip partial. No redesign and no new styling system. +- **Shared-family relevance**: Scope signals, filter summaries, clear links/actions, workspace hub navigation, and table filter state. +- **State layers in scope**: URL query, Livewire public properties, Filament `tableFilters`, `tableDeferredFilters`, persisted/session table filters, page-derived state, chip/header wording, data scope, reload, and focused browser history. +- **Audience modes in scope**: Operator-MSP and support-platform. Customer-read-only applies only to existing Customer Review Workspace behavior. +- **Decision/diagnostic/raw hierarchy plan**: Filter truth stays default-visible. Existing diagnostics and raw/support evidence remain unchanged. +- **Raw/support gating plan**: No raw evidence exposure changes. +- **One-primary-action / duplicate-truth control**: When filtered, the chip is the single page-level truth and `Clear filter` is the exit. After clear, absence of chip plus workspace-wide rows is the truth. +- **Handling modes by drift class or surface**: Hard-stop for legacy aliases recreating Environment-filtered state; review-mandatory for any page-specific clear exception. +- **Repository-signal treatment**: Feature tests and focused browser screenshots/notes are required evidence. +- **Special surface test profiles**: global-context-shell, monitoring-state-page, standard-native-filament. +- **Required tests or manual smoke**: Clear-state contract tests, persisted-state tests, clean-entry equivalence tests, Spec 314/315 regression tests, reload smoke, and high-risk back/forward smoke. +- **Exception path and spread control**: Optional hubs are classified and excluded unless already Environment-filterable via `environment_id`. Any browser limitation is documented in the implementation report. +- **Active feature PR close-out entry**: Guardrail and Smoke Coverage. + +## Shared Pattern & System Fit + +- **Cross-cutting feature marker**: yes. +- **Systems touched**: `WorkspaceHubRegistry`, `WorkspaceHubEnvironmentFilter`, `CanonicalAdminTenantFilterState`, possible `WorkspaceHubFilterStateResetter`, workspace hub Filament pages/resources, shared chip view, table/session filter keys, and Pest/browser tests. +- **Shared abstractions reused**: `WorkspaceHubRegistry` for hub paths, clean URLs, and Environment-like key lists; `WorkspaceHubEnvironmentFilter` for valid `environment_id`; existing chip partial for visible state; existing Filament table state APIs/patterns. +- **New abstraction introduced? why?**: Prefer formalizing one shared reset service/helper only if existing helpers cannot safely own all required Environment-like state layers. The abstraction is justified by repeated current hubs and reload defects. +- **Why the existing abstraction was sufficient or insufficient**: Existing pieces solve entry and immediate visible filter display, but the reset path is not yet complete across table/deferred/session/page state. +- **Bounded deviation / spread control**: Page-specific data refresh can stay local, but Environment-like key removal and persisted filter/session reset must use the shared contract or a documented equivalent. + +## OperationRun UX Impact + +- **Touches OperationRun start/completion/link UX?**: no. +- **Central contract reused**: N/A. +- **Delegated UX behaviors**: N/A. +- **Surface-owned behavior kept local**: Existing Operations hub inspect/detail behavior remains unchanged. +- **Queued DB-notification policy**: N/A. +- **Terminal notification path**: N/A. +- **Exception path**: none. + +## Provider Boundary & Portability Fit + +- **Shared provider/platform boundary touched?**: yes. +- **Provider-owned seams**: Existing Provider Connection records and provider external tenant identifiers remain model/provider data only. +- **Platform-core seams**: Workspace hub query contract, Environment filter reset contract, session/table filter key cleanup, and scope wording. +- **Neutral platform terms / contracts preserved**: `Workspace`, `Environment`, `environment_id`, `Environment filter`, `Clear filter`. +- **Retained provider-specific semantics and why**: Provider-specific connection identity remains unchanged and cannot be fallback filter state. +- **Bounded extraction or follow-up path**: Broader old Tenant naming and compatibility seam cleanup is Spec 317. + +## Constitution Check + +*GATE: Must pass before implementation. Re-check after runtime changes.* + +- Inventory-first: no inventory/snapshot truth changes. +- Read/write separation: no Graph writes or destructive operations are added. +- Graph contract path: no Graph calls are introduced. +- Deterministic capabilities: existing page/resource capabilities remain; clear does not grant access. +- RBAC-UX: workspace/page authorization remains authoritative. Non-member access remains 404/safe no-access; member-missing-capability remains existing 403 behavior. +- Workspace isolation: clear keeps the current Workspace and does not infer or switch Environment shell context. +- Tenant isolation: Environment filters are never authorization substitutes; clearing widens only to the user's entitled workspace data. +- Run observability: no new OperationRun lifecycle behavior. +- Test governance (TEST-GOV-001): lane, fixture cost, browser scope, and reviewer handoff are explicit in spec/plan/tasks. +- Proportionality (PROP-001): shared reset behavior is justified by cross-hub current-release stale state. +- No premature abstraction (ABSTR-001): reset helper stays bounded to Environment-like workspace hub filter state. +- Persisted truth (PERSIST-001): no persisted truth is added. +- Behavioral state (STATE-001): no new state/status family is added. +- UI semantics (UI-SEM-001): chip/scope state is direct domain-to-UI mapping. +- Shared pattern first (XCUT-001): extend Spec 314/315 registry, resolver, chip, and existing filter helper behavior before adding local patterns. +- Provider boundary (PROV-001): provider external tenant IDs cannot become platform filter truth. +- V1 explicitness / few layers: direct hard cutover, bounded helper, page-local data refresh where needed. +- Spec discipline / bloat check: follow-up specs 317/318 remain separate. +- Filament-native UI (UI-FIL-001): no ad-hoc styling; existing Filament/Blade primitives only. +- UI/UX scope, truth, and naming: scope signals must distinguish Workspace shell from explicit Environment filter state. +- UI naming: visible clear action uses `Clear filter`; visible filter wording uses `Environment`. + +## Test Governance Check + +- **Test purpose / classification by changed surface**: Feature/Livewire for page/table/session state, Browser for reload/history rendered state. +- **Affected validation lanes**: fast-feedback, confidence, browser. +- **Why this lane mix is the narrowest sufficient proof**: Route/Livewire tests prove reset mechanics; browser smoke proves reload/history URL/chip/data alignment. +- **Narrowest proving command(s)**: + - `cd apps/platform && ./vendor/bin/sail artisan test --filter=WorkspaceHubClearFilter` + - `cd apps/platform && ./vendor/bin/sail artisan test --filter=WorkspaceHubEnvironmentFilter` + - `cd apps/platform && ./vendor/bin/sail artisan test --filter=WorkspaceHubNavigation` + - focused browser smoke command or manual browser verification documented in close-out +- **Fixture / helper / factory / seed / context cost risks**: Existing workspace/environment/member context and page-specific factories may be needed. Keep helper setup opt-in and avoid broad seeders. +- **Expensive defaults or shared helper growth introduced?**: no. Any shared reset helper is runtime behavior, not a test setup default. +- **Heavy-family additions, promotions, or visibility changes**: Focused browser smoke only. Durable browser infrastructure is deferred to Spec 318. +- **Surface-class relief / special coverage rule**: Native Filament/Livewire tests for page/table state; browser smoke for integrated UI state. +- **Closing validation and reviewer handoff**: Reviewers must verify no hidden Environment state survives clear/reload, no legacy aliases become canonical, no unrelated filters are over-cleared without reason, and browser limitations are documented. +- **Budget / baseline / trend follow-up**: none expected. +- **Review-stop questions**: Does any hub still restore `managed_environment_id`/`tenant` from session after clear? Does clean URL show filtered rows? Does back/forward mismatch URL and chip? Does clear switch Workspace or Environment shell context? +- **Escalation path**: Follow-up Spec 318 only for durable browser no-drift infrastructure; Spec 317 for broad legacy naming/query cleanup. +- **Active feature PR close-out entry**: Guardrail and Smoke Coverage. +- **Why no dedicated follow-up spec is needed**: This is the dedicated clear-filter spec. Only broad cleanup and durable browser infrastructure remain separate. + +## Project Structure + +### Documentation (this feature) + +```text +specs/316-workspace-hub-clear-filter-contract/ +|-- spec.md +|-- plan.md +|-- tasks.md +|-- checklists/ +| `-- requirements.md +`-- artifacts/ + `-- screenshots/ # created during implementation/browser verification if useful +``` + +No `research.md`, `data-model.md`, `quickstart.md`, or `contracts/` artifact is required for preparation because this feature introduces no data model, external API contract, or new workflow API. + +### Source Code (repository root) + +Likely runtime files to inspect or update during implementation: + +```text +apps/platform/app/Support/Navigation/WorkspaceHubRegistry.php +apps/platform/app/Support/Navigation/WorkspaceHubEnvironmentFilter.php +apps/platform/app/Support/Filament/CanonicalAdminTenantFilterState.php +apps/platform/app/Support/Navigation/WorkspaceHubFilterStateResetter.php # if new helper is required +apps/platform/app/Filament/Pages/Monitoring/Operations.php +apps/platform/app/Filament/Pages/Monitoring/FindingExceptionsQueue.php +apps/platform/app/Filament/Pages/Monitoring/EvidenceOverview.php +apps/platform/app/Filament/Pages/Governance/GovernanceInbox.php +apps/platform/app/Filament/Pages/Governance/DecisionRegister.php +apps/platform/app/Filament/Pages/Reviews/ReviewRegister.php +apps/platform/app/Filament/Pages/Reviews/CustomerReviewWorkspace.php +apps/platform/app/Filament/Resources/ProviderConnectionResource.php +apps/platform/resources/views/filament/partials/workspace-hub-environment-filter-chip.blade.php +apps/platform/tests/Feature/ +apps/platform/tests/Browser/ +``` + +Potential classification-only inspection areas: + +```text +apps/platform/app/Filament/Pages/Monitoring/AuditLog.php +apps/platform/app/Filament/Resources/Alert* +apps/platform/app/Filament/Resources/StoredReport* +apps/platform/app/Filament/Resources/SupportRequest* +``` + +**Structure Decision**: Laravel/Filament platform app under `apps/platform`. New runtime source, if needed, stays under existing `app/Support` boundaries. Tests stay in existing Pest feature/browser directories. + +## Complexity Tracking + +| Violation | Why Needed | Simpler Alternative Rejected Because | +|---|---|---| +| Shared reset service/helper if existing helper cannot own the contract | Clear state spans repeated current hubs and multiple state layers | Page-local clear handlers already caused inconsistent reload/session behavior | +| Possible page integration helper/trait | Repeated resolver/chip/reset wiring may otherwise drift | Eight local implementations would recreate the defect; a broad context framework is still rejected | + +## Phase 0: Discovery Completed During Preparation + +Relevant repository facts discovered before authoring this plan: + +- Current branch before creation was `platform-dev`, clean, at `eced9ad5 Spec 315: implement environment CTA explicit filter contract (#370)`. +- `specs/316-workspace-hub-clear-filter-contract` did not exist before this preparation. +- Related Specs 313, 314, and 315 contain completed/checklist/close-out signals and are historical context only. +- `WorkspaceHubRegistry` already centralizes hub paths, forbidden query keys, Environment-like keys, `cleanUrl()`, and clean workspace hub entry detection. +- `WorkspaceHubEnvironmentFilter` already resolves canonical `environment_id` inside the current Workspace and exposes display/query/clear helpers. +- `workspace-hub-environment-filter-chip.blade.php` already renders `Environment filter:` and `Clear filter`. +- `CanonicalAdminTenantFilterState` already handles some persisted filter cleanup for Environment-like keys on clean workspace hub entry. +- Operations, Finding Exceptions Queue, Evidence Overview, Review Register, Customer Review Workspace, Decision Register, Governance Inbox, and Provider Connections all contain Environment-like filter/page state that may need reset integration. +- Laravel Boost docs confirmed Filament v5 supports `persistFiltersInSession()` and deferred filters, and Livewire v4 URL-bound properties read/write query string state on page load. + +## Technical Approach + +### 1. Formalize the shared reset mechanism + +Preferred implementation: + +```text +apps/platform/app/Support/Navigation/WorkspaceHubFilterStateResetter.php +``` + +or a bounded extension/delegation of: + +```text +apps/platform/app/Support/Filament/CanonicalAdminTenantFilterState.php +``` + +Responsibilities: + +- identify the current workspace hub through `WorkspaceHubRegistry` +- clear Environment-like query/session/table/deferred keys +- clear legacy alias keys where present +- remove nested Environment-like Filament table filter entries +- support per-hub session key mapping where needed +- preserve unrelated user filters when safe +- never clear selected Workspace, auth/session, or unrelated table preferences + +### 2. Keep the canonical filter source narrow + +Only `WorkspaceHubEnvironmentFilter::fromRequest()` may create filtered state for workspace hubs. + +Invalid sources: + +```text +tenant +tenant_id +managed_environment_id +environment +tenant_scope +tableFilters +remembered Environment +Filament tenant +session last environment +lastTenantId / lastEnvironmentId +provider external tenant id +``` + +### 3. Standardize clear targets + +The final clear URL should be the clean hub URL with no query string by default: + +```text +/admin/workspaces/{workspace}/operations +/admin/governance/inbox +/admin/governance/decisions +/admin/finding-exceptions/queue +/admin/provider-connections +/admin/evidence/overview +/admin/reviews +/admin/reviews/workspace +``` + +If preserving unrelated query parameters is implemented, tests must prove stale Environment-like state cannot survive. + +### 4. Integrate required hubs + +Each required hub must: + +- resolve valid `environment_id` through `WorkspaceHubEnvironmentFilter` +- call shared reset behavior when `environment_id` is absent or clear entry is used +- clear local Livewire properties or table arrays that represent Environment filters +- clear persisted session filter keys for Environment-like entries +- refresh derived rows/counts/header state to workspace-wide +- render chip only when a valid filter is active +- keep shell context workspace-first + +### 5. Preserve Spec 314 and Spec 315 behavior + +Regression requirements: + +- sidebar/global workspace hub entries remain clean and workspace-wide +- Environment-owned CTAs still use `environment_id` +- legacy params remain noncanonical +- cross-workspace `environment_id` remains rejected +- Decision Register clean URL remains valid + +## Data Model + +No data model changes. + +No migrations, seeders, backfills, stored filter migrations, stored URL migrations, compatibility transforms, retention changes, queues, scheduler, or storage changes. + +## Security and RBAC + +- Clear filter never grants access. +- Clear filter widens only to the current user's authorized workspace-wide data. +- Existing page/resource policies and workspace membership checks remain authoritative. +- Non-member workspace/environment access remains deny-as-not-found. +- Member-without-capability behavior remains existing 403 where applicable. +- Clear filter must not reveal cross-workspace Environment existence. +- No provider external tenant ID fallback may become an authorization or filter boundary. + +## Deployment / Operations + +- No new environment variables. +- No migrations. +- No queues or scheduler changes. +- No storage persistence or volume changes. +- No package changes. +- No Dokploy-specific deployment changes expected. +- If implementation registers Filament assets unexpectedly, deployment must include `cd apps/platform && php artisan filament:assets`; no registered assets are expected. + +## Browser Verification Plan + +Use the in-app browser or existing Pest Browser tooling after runtime changes. + +Required hubs: + +- Operations +- Governance Inbox +- Decision Register +- Finding Exceptions Queue +- Provider Connections +- Evidence +- Reviews +- Customer Reviews + +Flows: + +1. Filter -> Clear -> Reload for every required hub. +2. Persisted Filter -> Sidebar Entry where a persisted filter can be applied. +3. Browser Back/Forward for Provider Connections, Finding Exceptions Queue, Customer Reviews, and Evidence. + +Screenshots may be saved under: + +```text +specs/316-workspace-hub-clear-filter-contract/artifacts/screenshots/ +``` + +## Implementation Notes + +- Use direct hard cuts. +- Do not add compatibility middleware, alias readers, or redirects. +- Do not broaden optional hubs. +- Do not update tests by broad rebaseline. +- Prefer removing Environment-like persisted state over preserving it. +- Keep any new helper narrow and repo-local. +- Document intentional exclusions and browser limitations in the final implementation report. diff --git a/specs/316-workspace-hub-clear-filter-contract/spec.md b/specs/316-workspace-hub-clear-filter-contract/spec.md new file mode 100644 index 00000000..76f7ad8a --- /dev/null +++ b/specs/316-workspace-hub-clear-filter-contract/spec.md @@ -0,0 +1,480 @@ +# Feature Specification: Workspace Hub Clear Filter Contract + +**Feature Branch**: `316-workspace-hub-clear-filter-contract` +**Created**: 2026-05-16 +**Status**: Draft +**Input**: User supplied Spec 316 draft for completing the Workspace Hub Environment filter lifecycle after Specs 314 and 315. + +## Spec Candidate Check *(mandatory - SPEC-GATE-001)* + +- **Problem**: Workspace hubs can now be entered cleanly through sidebar/global navigation and explicitly filtered through `environment_id`, but clearing that filter can leave stale URL, Livewire, Filament table, deferred, session, or derived page state behind. +- **Today's failure**: A user can click `Clear filter` and see a clean URL or no chip while rows, header wording, persisted filters, or reload state remain scoped to the old Environment. +- **User-visible improvement**: Clearing an Environment filter returns the hub to the same state as clean sidebar/global entry: Workspace hub, no Environment chip, workspace-wide data, reload safe. +- **Smallest enterprise-capable version**: Formalize one shared clear/reset contract for in-scope workspace hubs, wire it into existing page/table state where needed, and add focused feature/browser coverage for reload and back/forward safety. +- **Explicit non-goals**: No new Environment CTA filtering, no broad legacy naming cleanup, no durable browser no-drift infrastructure, no migrations, no seeders, no package/env/queue/scheduler/storage changes, and no compatibility aliases. +- **Permanent complexity imported**: One shared reset service/helper or formalized existing helper path, optional small page integration trait/helper if the existing page pattern cannot stay consistent, focused Pest and browser-smoke coverage. No persisted entity, enum/status family, queue, external API, or new source of truth is introduced. +- **Why now**: Spec 314 made clean workspace hub entry safe and Spec 315 made Environment CTA entry explicit. The remaining lifecycle gap is clear-filter exit; leaving it open keeps user-visible scope truth unreliable. +- **Why not local**: The same stale-state risk exists across Operations, Governance Inbox, Decision Register, Finding Exceptions Queue, Provider Connections, Evidence, Reviews, and Customer Reviews. Page-local clear code already caused drift. +- **Approval class**: Core Enterprise. +- **Red flags triggered**: Cross-cutting filter state reset and a possible shared helper abstraction. Defense: the helper is bounded to clearing Environment-like filter state for current workspace hubs and is required by more than two current surfaces. +- **Score**: Value: 2 | Urgency: 2 | Scope: 2 | Complexity: 1 | Product proximity: 2 | Reuse: 2 | **Total: 11/12** +- **Decision**: approve. + +## Spec Scope Fields *(mandatory)* + +- **Scope**: canonical-view. +- **Primary Routes**: In-scope workspace hub routes for Operations, Governance Inbox, Decision Register, Finding Exceptions Queue, Provider Connections, Evidence Overview, Review Register, and Customer Review Workspace. Optional Audit Log, Alerts, Reports/Stored Reports, Risk Exceptions, and Support Requests are in scope only if existing runtime already made them Environment-filterable through `environment_id`. +- **Data Ownership**: Workspace hubs remain workspace-owned/canonical-view pages. Existing Environment-owned records remain owned by `workspace_id` plus `managed_environment_id` or their current repo-real relationship. This spec changes filter-state handling only. +- **RBAC**: Existing workspace membership, environment entitlement, page/resource policies, and capability checks remain authoritative. Clear filter must not grant access, switch Workspace, activate Environment shell context, or reveal cross-workspace Environment existence. + +For canonical-view specs: + +- **Default filter behavior when tenant-context is active**: Clean workspace hub entry has no Environment filter, regardless of remembered Environment, Filament tenant state, session state, old table filters, or legacy query params. +- **Explicit entitlement checks preventing cross-tenant leakage**: A hub is Environment-filtered only when `WorkspaceHubEnvironmentFilter` resolves a valid `environment_id` inside the selected Workspace for the current user. No legacy source may create a valid filter. + +## Cross-Cutting / Shared Pattern Reuse *(mandatory)* + +- **Cross-cutting feature?**: yes. +- **Interaction class(es)**: workspace hub scope signals, visible Environment filter chips, clear-filter links/actions, Filament table filters, page-level derived state, persisted/session filter state, browser reload/back-forward state, and navigation entry contracts. +- **Systems touched**: `WorkspaceHubRegistry`, `WorkspaceHubEnvironmentFilter`, `CanonicalAdminTenantFilterState`, existing workspace hub Filament pages/resources, shared chip partial, page table state, session filter keys, and related Pest/browser tests. +- **Existing pattern(s) to extend**: Spec 314 clean hub registry and URL cleaning, Spec 315 canonical `environment_id` resolver and visible chip, current `CanonicalAdminTenantFilterState` persisted filter cleanup behavior, and each page's existing table/query state pattern. +- **Shared contract / presenter / builder / renderer to reuse**: Prefer formalizing a shared `WorkspaceHubFilterStateResetter` behavior around existing reset logic. Reuse `WorkspaceHubRegistry::environmentLikeFilterKeys()`, `WorkspaceHubRegistry::cleanUrl()`, `WorkspaceHubEnvironmentFilter`, and `workspace-hub-environment-filter-chip`. +- **Why the existing shared path is sufficient or insufficient**: The repo already has the correct entry-point pieces, but the current reset surface is not yet guaranteed across all relevant layers and pages. Spec 316 makes the reset behavior explicit, complete, and covered. +- **Allowed deviation and why**: Page-specific data refresh/query mechanics may remain local when pages use different data sources, but Environment-like state identification, URL cleanup, chip clear targets, and persisted table/session cleanup must delegate to shared behavior. +- **Consistency impact**: URL state, Livewire state, Filament table/deferred filters, persisted session filters, visible chip, header/scope wording, data scope, reload, and back/forward behavior must agree. +- **Review focus**: Verify no page-local clear implementation preserves stale Environment state, no legacy key becomes canonical, and no unrelated user filters are cleared unnecessarily unless the implementation cannot safely preserve them. + +## OperationRun UX Impact *(mandatory)* + +- **Touches OperationRun start/completion/link UX?**: no. +- **Shared OperationRun UX contract/layer reused**: N/A. +- **Delegated start/completion UX behaviors**: N/A. +- **Local surface-owned behavior that remains**: Existing Operations hub and OperationRun inspect/detail behavior remain unchanged. +- **Queued DB-notification policy**: N/A. +- **Terminal notification path**: N/A. +- **Exception required?**: none. + +## Provider Boundary / Platform Core Check *(mandatory)* + +- **Shared provider/platform boundary touched?**: yes. +- **Boundary classification**: platform-core filter contract with provider-adjacent surfaces. +- **Seams affected**: Workspace hub query keys, provider connection Environment filters, legacy Tenant/Environment aliases, persisted table filter state, and scope wording. +- **Neutral platform terms preserved or introduced**: `Workspace`, `Environment`, `Workspace hub`, `Environment filter`, `Clear filter`, `environment_id`. +- **Provider-specific semantics retained and why**: Existing Provider Connection model data and external provider tenant identifiers remain unchanged, but they cannot be filter-state fallbacks for workspace hubs. +- **Why this does not deepen provider coupling accidentally**: The only valid filter source remains platform `environment_id` resolved through `ManagedEnvironment` ownership inside a Workspace. +- **Follow-up path**: Spec 317 removes/quarantines broader legacy Tenant / Environment context names. Spec 318 adds durable browser no-drift infrastructure. + +## UI / Surface Guardrail Impact *(mandatory)* + +| Surface / Change | Operator-facing surface change? | Native vs Custom | Shared-Family Relevance | State Layers Touched | Exception Needed? | Low-Impact / `N/A` Note | +|---|---|---|---|---|---|---| +| Shared Environment filter chip clear behavior | yes | Existing shared Blade partial / Filament page region | scope signals, header utility action | URL, page, table/session, visible state | no | Wording stays `Clear filter` | +| Operations | yes | Existing Filament page/table | monitoring-state page | URL, Livewire properties, table filters, deferred filters, persisted filters, data query | no | Reset must equal clean Operations entry | +| Governance Inbox | yes | Existing Filament page/list | governance queue | URL, page property, derived state, visible chip | no | Existing `tenantId` internals may remain only as noncanonical implementation detail | +| Decision Register | yes | Existing Filament page/list | decision register | URL, page property, derived state, visible chip | no | Clean URL must remain authorized/open | +| Finding Exceptions Queue | yes | Existing Filament page/table | exception queue | URL, table filters, deferred filters, persisted filters, selected state | no | Previously high-risk reload restoration surface | +| Provider Connections | yes | Existing Filament resource/table | provider connection registry | URL, query state, table/session filters | no | No provider external tenant fallback | +| Evidence Overview | yes | Existing Filament page/table or in-memory list | evidence viewer | URL, in-memory row filter, table/search filters, persisted state | no | Reset must refresh rows to workspace-wide | +| Review Register | yes | Existing Filament page/table | review register | URL, table/deferred filters, persisted filters | no | Workspace-wide review list after clear | +| Customer Review Workspace | yes | Existing Filament page/table | customer-safe review workspace | URL, table/deferred filters, persisted filters, page state | no | Previously high-risk reload restoration surface | +| Optional hubs | maybe | Existing page/resource | audit/alerts/reports/support | Only where already Environment-filterable | no | Do not add new filter support here | + +## 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 | +|---|---|---|---|---|---|---|---| +| Cleared workspace hub | Secondary Context | Decide from workspace-wide data after removing an Environment filter | Workspace context, no Environment chip, workspace-wide header/scope wording, workspace-wide rows | Existing row/detail diagnostics | The hub remains the decision/workspace surface; this spec restores truthful scope | Completes sidebar -> CTA -> clear lifecycle | Removes hidden stale state after clear/reload | +| Browser back/forward across filtered and cleared states | Secondary Context | Avoid acting on a page whose URL, chip, and data disagree | URL, chip presence/absence, data scope | Existing page details | Not a new workflow, but critical state truth | Keeps browser navigation trustworthy | Prevents repeated manual scope reconstruction | + +## Audience-Aware Disclosure *(mandatory when operator-facing surfaces are changed)* + +| Surface | Audience Modes In Scope | Decision-First Default-Visible Content | Operator Diagnostics | Support / Raw Evidence | One Dominant Next Action | Hidden / Gated By Default | Duplicate-Truth Prevention | +|---|---|---|---|---|---|---|---| +| Workspace hub filter state | operator-MSP, support-platform, customer-read-only where existing Customer Review Workspace applies | Either visible `Environment filter: {display name}` with `Clear filter`, or no chip and workspace-wide wording | Existing page/table diagnostics | Existing support/raw surfaces | Clear filter when filtered; inspect rows otherwise | Query/table/session internals | Chip, URL, header, and data scope must say the same thing | + +## 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 | +|---|---|---|---|---|---|---|---|---|---|---|---|---|---| +| Operations | List / Table / Bulk | Monitoring/state page | Inspect operation or clear filter | Existing operations inspect pattern | existing | Existing page/table actions | Existing placement; no destructive changes | `/admin/workspaces/{workspace}/operations` | existing | Workspace-wide or Environment chip | Operations / Operation | Whether data is workspace-wide | none | +| Governance Inbox | List / Table / Bulk | Queue | Inspect governance item or clear filter | Existing inbox inspect pattern | existing | Existing page actions | Existing placement; no destructive changes | `/admin/governance/inbox` | existing | Workspace-wide or Environment chip | Governance Inbox | Whether data is workspace-wide | none | +| Decision Register | List / Table / Bulk | Register | Inspect decision or clear filter | Existing decision inspect pattern | existing | Existing page actions | Existing placement; no destructive changes | `/admin/governance/decisions` | existing | Workspace-wide or Environment chip | Decision Register | Whether data is workspace-wide | none | +| Finding Exceptions Queue | List / Table / Bulk | Queue | Inspect exception or clear filter | Existing exception inspect pattern | existing | Existing page/table actions | Existing placement; no destructive changes | `/admin/finding-exceptions/queue` | existing | Workspace-wide or Environment chip | Finding Exceptions | Whether data is workspace-wide | none | +| Provider Connections | List / Table / Bulk | Registry | Inspect provider connection or clear filter | Existing provider connection inspect pattern | existing | Existing resource actions | Existing placement; no destructive changes | `/admin/provider-connections` | existing | Workspace-wide or Environment chip | Provider Connections | Whether data is workspace-wide | none | +| Evidence Overview | List / Table / Bulk | Evidence viewer | Inspect evidence or clear filter | Existing evidence inspect pattern | existing | Existing page actions | Existing placement; no destructive changes | `/admin/evidence/overview` | existing | Workspace-wide or Environment chip | Evidence | Whether data is workspace-wide | none | +| Reviews / Customer Reviews | List / Table / Bulk | Review workspace | Inspect review or clear filter | Existing review inspect pattern | existing | Existing page/table actions | Existing placement; no destructive changes | `/admin/reviews`, `/admin/reviews/workspace` | existing | Workspace-wide or Environment chip | Reviews | Whether data is workspace-wide | 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 | +|---|---|---|---|---|---|---|---|---|---|---| +| Workspace hub after filter clear | TenantPilot operator | Continue from workspace-wide data after clearing a visible Environment filter | Existing list/table/page | Am I seeing all entitled workspace data again? | Clean URL, no Environment chip, workspace-wide rows/header/scope | Existing page diagnostics | Existing page-specific dimensions only | TenantPilot navigation/filter state only | Inspect rows, apply normal filters | None added or changed | + +## Proportionality Review *(mandatory when structural complexity is introduced)* + +- **New source of truth?**: no. +- **New persisted entity/table/artifact?**: no. +- **New abstraction?**: yes, if existing reset behavior cannot be formalized directly; expected as a narrow `WorkspaceHubFilterStateResetter` service/helper or equivalent shared reset contract. +- **New enum/state/reason family?**: no. +- **New cross-domain UI framework/taxonomy?**: no. +- **Current operator problem**: Clear-filter can leave hidden Environment state active after the visible filter is gone, causing operators to act on the wrong scope. +- **Existing structure is insufficient because**: Spec 315 gives a resolver and chip, and `CanonicalAdminTenantFilterState` has some persisted filter cleanup, but there is not yet one guaranteed cross-hub contract that clears URL, Livewire, Filament table, deferred, persisted/session, and derived page state together. +- **Narrowest correct implementation**: Formalize shared reset behavior for Environment-like keys and call it from in-scope hubs on clean entry/clear. Leave page-specific query builders local. +- **Ownership cost**: One shared reset owner, per-hub integration, feature/browser regression coverage, and reviewer attention to session/table key maps. +- **Alternative intentionally rejected**: Eight independent clear handlers or compatibility aliases would preserve drift. A broad legacy cleanup belongs to Spec 317. +- **Release truth**: Current-release truth. This completes the active 314/315/316 filter lifecycle before broader cleanup. + +### 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. Canonical replacement is required. Legacy Environment-like keys may be removed or neutralized; they must not be preserved as valid filter sources. + +## Testing / Lane / Runtime Impact *(mandatory for runtime behavior changes)* + +- **Test purpose / classification**: Feature/Livewire for clear-state contracts and page behavior; Browser for reload and high-risk back/forward rendered-state safety. +- **Validation lane(s)**: fast-feedback for focused Pest tests, confidence for Filament/Livewire state pages/resources, browser for integrated reload/back-forward smoke. +- **Why this classification and these lanes are sufficient**: The risk spans server-resolved query/session state and rendered browser truth. Feature tests prove reset/data contracts; browser smoke proves URL/chip/data alignment after reload and history navigation. +- **New or expanded test families**: Spec 316 workspace hub clear contract tests, persisted table filter reset tests, legacy state reset tests, clean-entry equivalence tests, and focused browser smoke for high-risk hubs. +- **Fixture / helper cost impact**: Existing Workspace, ManagedEnvironment, membership/capability, OperationRun, FindingException, ProviderConnection, EvidenceSnapshot, Review, and Customer Review factories/helpers may be used. Any helper widening must stay opt-in. +- **Heavy-family visibility / justification**: Browser coverage is explicit and limited to critical/higher-risk flows because reload/history state cannot be fully proven by route tests alone. +- **Special surface test profile**: global-context-shell, monitoring-state-page, standard-native-filament. +- **Standard-native relief or required special coverage**: Native Filament tests cover page/table behavior; browser smoke covers integrated rendered state and history. +- **Reviewer handoff**: Reviewers must confirm tests prove URL, chip, Livewire/table/session state, data scope, reload safety, and Spec 314/315 regressions without broad suite rebaseline. +- **Budget / baseline / trend impact**: No material long-term lane shift expected. Any durable browser suite expansion belongs to Spec 318. +- **Escalation needed**: none unless implementation discovers structural no-drift browser infrastructure is required; that becomes Spec 318 work. +- **Active feature PR close-out entry**: Guardrail and Smoke Coverage. +- **Planned validation commands**: Focused Pest filters for Spec 316 feature tests, existing Spec 314/315 regression tests, `git diff --check`, Pint for touched PHP files, and focused browser smoke/manual verification. + +## User Scenarios & Testing *(mandatory)* + +### User Story 1 - Clear returns a filtered workspace hub to workspace-wide state (Priority: P1) + +An operator opens a workspace hub with `environment_id`, sees the Environment chip, clicks `Clear filter`, and lands on the clean hub URL with workspace-wide data and no stale chip or scope wording. + +**Why this priority**: This is the core contract and the visible operator problem. + +**Independent Test**: For each required hub, start filtered by `environment_id`, assert chip and filtered data, clear the filter, and assert clean URL, no legacy params, no chip, workspace-wide header/scope wording, and workspace-wide seeded rows. + +**Acceptance Scenarios**: + +1. **Given** Operations is filtered by Environment A, **When** the operator clicks `Clear filter`, **Then** the final URL has no query string, the chip disappears, table filters no longer apply Environment A, and operations from other entitled environments are visible. +2. **Given** Governance Inbox is filtered by Environment A, **When** the operator clears the filter, **Then** the page-level Environment property is null and the inbox returns to workspace-wide rows. +3. **Given** Decision Register is filtered by Environment A, **When** the operator clears the filter, **Then** the clean register URL opens for the authorized workspace user and no 403 is caused by missing filter state. + +--- + +### User Story 2 - Clear removes persisted and deferred Environment-like state (Priority: P1) + +An operator has stale Filament/session filter state from an Environment filter. Clearing must remove the Environment-like persisted state so reload does not silently restore it. + +**Why this priority**: Previously observed defects included chip removal while table/session filters continued to apply. + +**Independent Test**: Persist Environment-like table filters for Finding Exceptions Queue, Customer Reviews, Evidence, and Provider Connections; visit filtered state; clear; reload; assert persisted keys are gone and workspace-wide rows are visible. + +**Acceptance Scenarios**: + +1. **Given** Finding Exceptions Queue has a persisted `managed_environment_id` table filter, **When** the operator clears the visible `environment_id` filter, **Then** the persisted table filter is removed and reload remains workspace-wide. +2. **Given** Customer Review Workspace has stale `tenant` or `managed_environment_id` persisted filter state, **When** the operator clears the filter, **Then** no stale review Environment filter is reapplied after reload. + +--- + +### User Story 3 - Legacy Environment aliases cannot recreate filtered state (Priority: P1) + +An operator or stale browser/session state contains legacy keys. Clean hub entry and clear results must neutralize those aliases instead of treating them as canonical filter state. + +**Why this priority**: Hard cutover is a product and constitution requirement in this pre-production repo. + +**Independent Test**: Seed stale query/session/table keys for `tenant`, `tenant_id`, `managed_environment_id`, `environment`, `tenant_scope`, and nested `tableFilters`; assert clear neutralizes them and reload does not reapply hidden filtering. + +**Acceptance Scenarios**: + +1. **Given** Provider Connections session state contains `tableFilters.tenant.value`, **When** the operator clears the Environment filter, **Then** the legacy key is removed or ignored and provider rows are workspace-wide. +2. **Given** Evidence Overview URL contains no `environment_id` but session state contains old `tenant_scope`, **When** the page renders through clean hub entry, **Then** no Environment chip appears and rows are not silently Environment-scoped. + +--- + +### User Story 4 - Clear result equals clean sidebar/global entry (Priority: P1) + +An operator clearing a filter and an operator entering through sidebar/global navigation should see the same hub state. + +**Why this priority**: This connects Specs 314, 315, and 316 into one lifecycle. + +**Independent Test**: Capture clean hub entry state, visit filtered URL, clear, and assert the final URL, chip/scope indicators, and data set match clean entry. + +**Acceptance Scenarios**: + +1. **Given** a user enters Evidence Overview through the sidebar, **When** the same user later opens Evidence with `environment_id` and clears it, **Then** the final state matches the sidebar entry state. + +--- + +### User Story 5 - Reload and browser history keep URL, chip, and data aligned (Priority: P2) + +An operator clears a filter, reloads, and uses browser back/forward. The browser must not show a clean URL with filtered data or a filtered URL with missing chip. + +**Why this priority**: The stale state defect is most visible after reload/history navigation, but durable no-drift infrastructure remains Spec 318. + +**Independent Test**: Browser-smoke Provider Connections, Finding Exceptions Queue, Customer Reviews, Evidence, and Operations for filter -> clear -> reload and high-risk back/forward alignment. + +**Acceptance Scenarios**: + +1. **Given** Customer Review Workspace was filtered and then cleared, **When** the browser reloads the clean URL, **Then** no Environment chip returns and workspace-wide reviews remain visible. +2. **Given** Provider Connections filtered URL is in browser history, **When** the operator goes back and forward, **Then** each visible state matches the current URL: filtered URL has chip and filtered data; clean URL has no chip and workspace-wide data. + +## Requirements *(mandatory)* + +### Functional Requirements + +- **FR-001**: A workspace hub is Environment-filtered only when `environment_id` is present, valid, workspace-owned, user-accessible, and resolved by `WorkspaceHubEnvironmentFilter`. +- **FR-002**: `tenant`, `tenant_id`, `managed_environment_id`, `environment`, `tenant_scope`, remembered Environment, Filament tenant state, session last environment, and `tableFilters` MUST NOT create canonical Environment-filtered hub state. +- **FR-003**: Clear filter final URLs MUST remove `environment_id`, `tenant`, `tenant_id`, `managed_environment_id`, `environment`, `tenant_scope`, and `tableFilters`. +- **FR-004**: The default final clear target SHOULD be the clean hub URL with no query string unless preserving unrelated query params is proven safe and intentional. +- **FR-005**: A shared reset mechanism MUST clear Environment-like persisted/session filter state for in-scope workspace hubs. +- **FR-006**: Environment-like keys for reset MUST include `environment_id`, `environment`, `tenant`, `tenant_id`, `managed_environment_id`, and `tenant_scope`. +- **FR-007**: Nested Filament table filters representing Environment selection MUST be removed or neutralized, including `tableFilters.environment.value`, `tableFilters.environment_id.value`, `tableFilters.tenant.value`, `tableFilters.tenant_id.value`, `tableFilters.managed_environment_id.value`, and `tableFilters.tenant_scope.value` where present. +- **FR-008**: Clear MUST reset Environment-related Livewire public properties or derived page state for the affected hub. +- **FR-009**: Clear MUST reset Environment-related Filament `tableFilters` state where the hub uses tables. +- **FR-010**: Clear MUST reset Environment-related `tableDeferredFilters` state where the hub uses deferred filters. +- **FR-011**: Clear MUST remove Environment-like persisted table/session filters without clearing unrelated safe user filters such as search/status/date unless the page implementation cannot safely preserve them. +- **FR-012**: Visible Environment filter chips MUST disappear after clear. +- **FR-013**: Header, scope, empty-state, and shell wording MUST return to workspace-wide/all-environments wording after clear where applicable. +- **FR-014**: Data MUST return to workspace-wide scope after clear. +- **FR-015**: Reload after clear MUST NOT restore Environment-filtered state. +- **FR-016**: Sidebar/global revisit after clear MUST remain clean and workspace-wide. +- **FR-017**: Browser back/forward MUST keep URL, chip, shell, and data scope aligned where feasible; any tooling limitation must be documented. +- **FR-018**: Clear MUST keep the selected Workspace active and MUST NOT activate or infer Environment shell context. +- **FR-019**: Clear MUST NOT weaken existing page authorization or use Environment filters as an authorization substitute. +- **FR-020**: Operations MUST clear `environment_id`, Operations-specific Environment/table state, deferred filters, persisted Environment-like table state, chip, and misleading filtered wording. +- **FR-021**: Governance Inbox MUST clear `environment_id`, page Environment property state, query-derived filter state, and chip. +- **FR-022**: Decision Register MUST clear `environment_id`, Environment-derived page state, chip, and any persisted/deferred state if applicable; clean URL remains authorized. +- **FR-023**: Finding Exceptions Queue MUST clear `environment_id`, Filament Environment table filter, deferred/persisted table filter state, old `tenant` persisted state, and chip. +- **FR-024**: Provider Connections MUST clear `environment_id`, provider/environment filter state, hidden query-level Environment filters, persisted table filters if present, and chip. +- **FR-025**: Evidence Overview MUST clear `environment_id`, in-memory Environment row filtering, table/search Environment filters, persisted state, and chip. +- **FR-026**: Review Register MUST clear `environment_id`, review-list Environment filters, persisted state, old `tenant` state if present, and chip. +- **FR-027**: Customer Review Workspace MUST clear `environment_id`, Customer Review Environment filters, page properties, persisted/session table state, old aliases if present, and chip. +- **FR-028**: Optional hubs MUST NOT receive new Environment filter support in this spec. They are included only if Spec 315 already made them Environment-filterable via `environment_id`. +- **FR-029**: Page-local clear implementations MUST be removed or delegated to shared reset behavior where they can drift. +- **FR-030**: No backwards compatibility layer, legacy alias preservation, migration, seeder, package, env var, queue, scheduler, or storage change may be introduced. + +### Non-Functional Requirements + +- **NFR-001**: The implementation MUST preserve workspace isolation and deny-as-not-found behavior for out-of-scope workspace/environment access. +- **NFR-002**: The implementation MUST target Filament v5.2.1 and Livewire v4.1.4 patterns; no Livewire v3 or Filament v3/v4 APIs are allowed. +- **NFR-003**: The shared reset mechanism MUST stay narrow and must not become a broad context/session framework. +- **NFR-004**: Tests MUST prove reload safety and persisted-state clearing for high-risk hubs. +- **NFR-005**: Browser smoke MUST stay focused; durable no-drift infrastructure is deferred to Spec 318. + +## Key Entities *(include if feature involves data)* + +- **Workspace**: Primary SaaS and operating context. It remains selected after clear. +- **ManagedEnvironment**: Secondary operational context inside a Workspace. It is valid as a hub filter only through canonical `environment_id`. +- **WorkspaceHubRegistry**: Existing hub registry and URL/query cleanup helper. It supplies Environment-like key lists and clean hub URL behavior. +- **WorkspaceHubEnvironmentFilter**: Existing canonical resolver for valid `environment_id`. +- **WorkspaceHubFilterStateResetter**: Proposed/formalized shared reset behavior for Environment-like URL, table, deferred, persisted, and page state. It is not persisted and is not a new source of truth. +- **CanonicalAdminTenantFilterState**: Existing persisted filter helper that may be reused, narrowed, or delegated to by the new shared reset behavior. + +## In Scope + +- Clear-filter behavior for required Spec 315 `environment_id` hubs. +- Shared reset mechanism for Environment-like query/table/session/page state. +- Existing visible chip clear target behavior. +- Reset of Livewire public properties and derived state where used for Environment filtering. +- Reset of Filament `tableFilters`, `tableDeferredFilters`, and persisted filter session entries where used for Environment filtering. +- Reload and focused back/forward safety. +- Spec 314 and Spec 315 regressions. +- Focused browser verification and screenshot artifacts where useful. + +## Out of Scope + +- New Environment CTA filtering. +- Retrofitting optional hubs that were not already Environment-filterable through `environment_id`. +- Broad legacy Tenant/Environment naming cleanup. +- Removing every `tenant` occurrence in the codebase. +- Durable browser regression/no-drift infrastructure beyond focused Spec 316 smoke. +- RBAC redesign. +- Data model, migration, seeder, backfill, package, env var, queue, scheduler, or storage changes. +- Compatibility redirects or old URL preservation. + +## Page-Specific Requirements + +### Operations + +- Clear removes `environment_id`, Operations Environment/table filter state, deferred filters, persisted Environment-like filters, chip, and misleading filtered header/scope state. +- After reload, Operations remains workspace-wide and no hidden Environment filter applies. + +### Governance Inbox + +- Clear removes `environment_id`, `$tenantId` or equivalent page Environment property, query-derived state, and chip. +- Existing internal `tenantId` naming may remain only where renaming is unnecessary for correctness; Spec 317 owns naming cleanup. + +### Decision Register + +- Clear removes `environment_id`, Environment-derived page state, visible chip, and persisted/deferred filters if applicable. +- Clean URL remains authorized and open for workspace users. + +### Finding Exceptions Queue + +- Clear removes `environment_id`, Filament Environment table filter, deferred filter, persisted table filter state, old `tenant` persisted state, and chip. +- Filtered queue -> clear -> reload equals workspace-wide queue with no Environment chip. + +### Provider Connections + +- Clear removes `environment_id`, provider/environment filter state, hidden query-level Environment filtering, persisted table filters if present, and chip. +- No remembered Environment inference and no provider external tenant fallback may survive clear. + +### Evidence Overview + +- Clear removes `environment_id`, in-memory row Environment filtering, table/search Environment filter state, persisted filter state, and chip. +- Reload remains workspace-wide. + +### Review Register + +- Clear removes `environment_id`, review-list Environment filter, persisted filter state, visible chip, and old `tenant` state if present. +- Reload remains workspace-wide. + +### Customer Review Workspace + +- Clear removes `environment_id`, customer-review Environment filter, page property state, persisted table/session state, visible chip, and old aliases if present. +- Customer Reviews filtered -> clear -> reload equals clean workspace-wide customer review workspace. + +## Required Tests + +- **Shared Clear State Contract**: `it_workspace_hub_clear_filter_removes_environment_id_and_state_layers` covering Operations, Finding Exceptions Queue, Provider Connections, Evidence, Reviews, Customer Reviews, Governance Inbox, and Decision Register. +- **Persisted Table Filter Reset**: `it_clear_filter_removes_persisted_environment_like_table_filters` covering Finding Exceptions Queue, Customer Reviews, Evidence, and Provider Connections. +- **Legacy Persisted State Reset**: `it_clear_filter_removes_legacy_environment_alias_state` covering legacy keys `tenant`, `tenant_id`, `managed_environment_id`, `environment`, `tenant_scope`, and nested `tableFilters` where repo supports them. +- **Clean Entry Equivalence**: `it_clear_filter_result_matches_clean_workspace_hub_entry` for critical hubs. +- **Browser Reload Safety**: `it_cleared_workspace_hub_environment_filter_does_not_restore_after_reload` for Provider Connections, Finding Exceptions Queue, Customer Reviews, and Operations. +- **Browser Back/Forward Safety**: `it_workspace_hub_clear_filter_does_not_create_mismatched_back_forward_state` for Provider Connections, Finding Exceptions Queue, Customer Reviews, and Evidence where tooling is stable. +- **Spec 314 Regression**: `it_sidebar_workspace_hub_entry_remains_clean_after_clear_filter_contract`. +- **Spec 315 Regression**: `it_environment_cta_filter_contract_still_uses_environment_id_after_clear_filter_contract`. + +## Browser Verification Required + +Required hubs: + +- Operations +- Governance Inbox +- Decision Register +- Finding Exceptions Queue +- Provider Connections +- Evidence +- Reviews +- Customer Reviews + +Required flows: + +1. **Filter -> Clear -> Reload**: Open with `?environment_id={id}`, assert chip, click `Clear filter`, assert clean URL/no chip/workspace-wide state, reload, assert chip does not return. +2. **Persisted Filter -> Sidebar Entry**: Apply/persist Environment filter where possible, navigate away, enter through sidebar/global, assert clean URL/no chip/no hidden data filter. +3. **Browser Back/Forward**: For Provider Connections, Finding Exceptions Queue, Customer Reviews, and Evidence, verify URL/chip/data scope alignment after back/forward. + +Screenshots may be saved under: + +```text +specs/316-workspace-hub-clear-filter-contract/artifacts/screenshots/ +``` + +Suggested screenshot names: + +```text +operations--filtered.png +operations--after-clear.png +operations--after-clear-reload.png +provider-connections--filtered.png +provider-connections--after-clear.png +provider-connections--after-clear-reload.png +finding-exceptions-queue--filtered.png +finding-exceptions-queue--after-clear.png +finding-exceptions-queue--after-clear-reload.png +customer-reviews--filtered.png +customer-reviews--after-clear.png +customer-reviews--after-clear-reload.png +``` + +## Success Criteria *(mandatory)* + +### Measurable Outcomes + +- **SC-001**: Clearing a valid `environment_id` filter removes all Environment-like URL/query state from the final URL. +- **SC-002**: Clearing resets Environment-related Livewire properties, Filament table filters, deferred filters, and persisted/session filters for required hubs. +- **SC-003**: Clearing removes the visible Environment chip and returns header/scope/empty-state wording to workspace-wide state. +- **SC-004**: Clearing returns data to workspace-wide scope and reload does not restore Environment-filtered data. +- **SC-005**: Sidebar/global entry remains clean and workspace-wide after clear. +- **SC-006**: Browser back/forward does not create a misleading URL/chip/data mismatch on high-risk hubs or limitations are documented. +- **SC-007**: Spec 314 sidebar/global clean-entry contract and Spec 315 Environment CTA `environment_id` contract still pass. + +## Assumptions + +- Specs 313, 314, and 315 are completed historical context and must not be rewritten. +- `WorkspaceHubRegistry`, `WorkspaceHubEnvironmentFilter`, and `CanonicalAdminTenantFilterState` are the starting points for implementation. +- There is no production data or production environment to preserve. +- Existing factories and feature/browser harnesses can support the required focused coverage. + +## Risks + +- **Session key discovery risk**: Filament persisted table filters may use page/resource-specific session keys. Mitigation: inspect actual `getTableFiltersSessionKey()` usage and test persisted reset per high-risk hub. +- **Over-clearing risk**: A blunt reset could erase unrelated user filters. Mitigation: remove only Environment-like keys by default and document any unavoidable full clean URL behavior. +- **Back/forward tool instability**: Browser history assertions can be flaky. Mitigation: keep required coverage focused and document limitations if tooling blocks deterministic proof. +- **Abstraction drift risk**: A resetter could become a broad context framework. Mitigation: scope it to Environment-like workspace hub filter state only. + +## Required Final Implementation Report + +When implementation is complete, report: + +```text +Spec 316 completed. + +Changed behavior: +... + +Shared clear mechanism: +... + +Hubs covered: +... + +Files changed: +... + +Tests: +- command: +- result: + +Browser verification: +... + +Known remaining issues: +... + +Remaining follow-ups: +- 317: +- 318: + +No migrations were created. +No seeders were changed. +No packages, env vars, queues, scheduler, or storage changes were made. +No backwards compatibility layer was introduced. +No legacy query alias preservation was added. +``` + +Also include: + +- list of clear-state layers covered +- list of hubs verified +- list of legacy state keys removed or neutralized +- pages intentionally excluded +- browser limitations +- unrelated residual test failures, if any + +## Follow-Up Specs + +- **317 - Legacy Tenant / Environment Context Cleanup**: Remove/quarantine old aliases, stale Tenant naming, remembered Environment as data boundary, `Filament::getTenant()` workspace hub usage, and compatibility seams. +- **318 - Browser Regression Coverage / No-Drift Guard**: Durable automated browser/regression coverage for sidebar/global entry, Environment CTA entry, clear filter, reload, back/forward, visible scope correctness, and hidden filter drift. + +## Filament v5 Output Contract + +1. **Livewire v4.0+ compliance**: This spec targets the current app stack: Filament v5.2.1 with Livewire v4.1.4. No Livewire v3 APIs or assumptions are allowed. +2. **Provider registration location**: No new Filament panel provider is expected. If implementation discovers provider registration work, Laravel 12 requires panel providers in `apps/platform/bootstrap/providers.php`, not `bootstrap/app.php`. +3. **Global search**: No resource is made globally searchable by this spec. Existing globally searchable resources must still have Edit/View pages; resources without safe View/Edit pages must keep global search disabled. +4. **Destructive actions**: No destructive actions are added or changed. Clear filter is a navigation/filter-state action, not a destructive mutation. If any implementation path touches a destructive action, it must use `->action(...)`, `->requiresConfirmation()`, authorization, and audit logging. +5. **Asset strategy**: No new heavy assets are expected. Reusing the existing chip partial requires no asset deployment change. If Filament assets are registered unexpectedly, deployment must include `cd apps/platform && php artisan filament:assets`. +6. **Testing plan**: Cover Filament pages/resources as Livewire components or feature routes following Pest 4 conventions. Required browser smoke remains focused on reload/history rendered-state safety. diff --git a/specs/316-workspace-hub-clear-filter-contract/tasks.md b/specs/316-workspace-hub-clear-filter-contract/tasks.md new file mode 100644 index 00000000..5e83336a --- /dev/null +++ b/specs/316-workspace-hub-clear-filter-contract/tasks.md @@ -0,0 +1,111 @@ +# Tasks: Workspace Hub Clear Filter Contract + +**Input**: [spec.md](/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/316-workspace-hub-clear-filter-contract/spec.md), [plan.md](/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/316-workspace-hub-clear-filter-contract/plan.md) +**Prerequisites**: Specs 313, 314, and 315 are completed historical baseline context. Do not rewrite them. + +**Important**: These tasks track the Spec 316 implementation, runtime verification, and close-out evidence. + +## Test Governance Checklist + +- [x] Lane assignment is named and is the narrowest sufficient proof for URL, Livewire, Filament, persisted/session, reload, and browser-history behavior. +- [x] New or changed tests stay in the smallest honest family, and any 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 (`global-context-shell`, `monitoring-state-page`, `standard-native-filament`) is explicit. +- [x] Any material budget, baseline, trend, browser limitation, or escalation note is recorded in the active spec or implementation close-out. + +## Phase 1: Guardrails and Baseline + +- [x] T001 Verify the implementation starts from branch `316-workspace-hub-clear-filter-contract` and the worktree has no unrelated user changes before runtime edits. +- [x] T002 Re-read Specs 313, 314, and 315 to confirm the completed baseline: clean sidebar/global entry, canonical `environment_id` CTA entry, visible chip, and clean clear target. +- [x] T003 Confirm Laravel/Filament/Livewire/Pest versions through Laravel Boost `application_info`. +- [x] T004 Confirm no migration, seeder, package, env var, queue, scheduler, storage, or deployment asset change is required. +- [x] T005 Inventory actual persisted/table/session state keys for Operations, Finding Exceptions Queue, Provider Connections, Evidence, Review Register, Customer Review Workspace, Governance Inbox, and Decision Register. +- [x] T006 Classify optional hubs and document that no new Environment filter support is added for optional pages. + +## Phase 2: Tests First - Shared Clear Contract + +- [x] T007 Add `it_workspace_hub_clear_filter_removes_environment_id_and_state_layers` covering Operations, Governance Inbox, Decision Register, Finding Exceptions Queue, Provider Connections, Evidence, Review Register, and Customer Review Workspace. +- [x] T008 Add assertions that each filtered hub starts with a valid `environment_id`, visible chip, clear action/link, and seeded filtered data where the page has data. +- [x] T009 Add assertions that after clear the URL has no `environment_id`, no legacy params, no chip, workspace-wide wording, and workspace-wide seeded rows. +- [x] T010 Add reload assertions to the shared clear contract where the test harness can revisit or rerender the page. +- [x] T011 Add `it_clear_filter_removes_persisted_environment_like_table_filters` covering Finding Exceptions Queue, Customer Review Workspace, Evidence Overview, and Provider Connections. +- [x] T012 Add `it_clear_filter_removes_legacy_environment_alias_state` for `tenant`, `tenant_id`, `managed_environment_id`, `environment`, `tenant_scope`, and nested `tableFilters` state where repo support exists. +- [x] T013 Add `it_clear_filter_result_matches_clean_workspace_hub_entry` for critical hubs by comparing clean entry and filtered-then-cleared state. +- [x] T014 Add or update `it_cleared_workspace_hub_environment_filter_does_not_restore_after_reload` for Provider Connections, Finding Exceptions Queue, Customer Review Workspace, and Operations. +- [x] T015 Add or update `it_workspace_hub_clear_filter_does_not_create_mismatched_back_forward_state` for Provider Connections, Finding Exceptions Queue, Customer Review Workspace, and Evidence, or document tooling limitations. + +## Phase 3: Shared Reset Mechanism + +- [x] T016 Create or formalize a shared reset mechanism such as `apps/platform/app/Support/Navigation/WorkspaceHubFilterStateResetter.php` or a bounded extension of `CanonicalAdminTenantFilterState`. +- [x] T017 Make the reset mechanism use `WorkspaceHubRegistry` to identify workspace hubs and Environment-like keys. +- [x] T018 Make the reset mechanism remove or neutralize `environment_id`, `environment`, `tenant`, `tenant_id`, `managed_environment_id`, and `tenant_scope`. +- [x] T019 Make the reset mechanism remove nested Environment-like table filter entries such as `environment.value`, `environment_id.value`, `tenant.value`, `tenant_id.value`, `managed_environment_id.value`, and `tenant_scope.value`. +- [x] T020 Make the reset mechanism handle persisted Filament/session filter arrays and forget empty session filter keys. +- [x] T021 Make the reset mechanism preserve unrelated safe filters such as search/status/date when possible. +- [x] T022 Ensure the reset mechanism never clears selected Workspace, auth/session, unrelated table preferences, or unrelated resource state. +- [x] T023 Add focused unit/feature coverage for the reset mechanism with representative nested filter arrays. + +## Phase 4: Shared Page Integration + +- [x] T024 Reuse or add a small shared page integration helper only if repeated reset/resolver/chip wiring cannot stay consistent locally. +- [x] T025 Ensure clean workspace hub entry with no valid `environment_id` triggers shared Environment-like state reset before data rendering. +- [x] T026 Ensure clear-link navigation ends on the clean hub URL and the entry pipeline clears persisted Environment-like state before rendering. +- [x] T027 Ensure `WorkspaceHubEnvironmentFilter` remains the only valid source of Environment-filtered hub state. +- [x] T028 Ensure legacy query params are removed/ignored and cannot hydrate Environment-related page properties. +- [x] T029 Ensure the shared chip still renders only for valid `environment_id` and uses `Clear filter` as the action label. + +## Phase 5: Required Hub Runtime Integration + +- [x] T030 Update Operations to delegate clear/reset behavior, clear `environment_id`, `managed_environment_id` table/deferred/persisted state, and refresh workspace-wide data/header state. +- [x] T031 Update Governance Inbox to clear `environment_id`, `$tenantId` or equivalent derived property, query-derived state, and chip state through shared behavior. +- [x] T032 Update Decision Register to clear `environment_id`, Environment-derived page state, chip state, and any persisted/deferred filters while keeping clean URL authorized. +- [x] T033 Update Finding Exceptions Queue to clear `environment_id`, Environment table filter, deferred filters, persisted table/session filters, old `tenant` state, and chip state. +- [x] T034 Update Provider Connections to clear `environment_id`, provider/environment filter state, hidden query-level filters, persisted table filters if present, and provider external tenant fallback behavior. +- [x] T035 Update Evidence Overview to clear `environment_id`, in-memory row Environment filtering, table/search filters, persisted filters, and chip state. +- [x] T036 Update Review Register to clear `environment_id`, review-list Environment filters, persisted filters, old `tenant` state if present, and chip state. +- [x] T037 Update Customer Review Workspace to clear `environment_id`, page properties, table/deferred/session filters, old aliases if present, and chip state. + +## Phase 6: Legacy Alias Neutralization + +- [x] T038 Search required hubs for `tenant`, `tenant_id`, `managed_environment_id`, `environment`, `tenant_scope`, `tableFilters`, remembered Environment, `Filament::getTenant()`, `lastTenantId`, and `lastEnvironmentId` as workspace hub filter sources. +- [x] T039 Remove or neutralize legacy alias handling where it can reapply workspace hub Environment filtering. +- [x] T040 Preserve unrelated legacy names only when they are outside workspace hub Environment filter behavior, and document them for Spec 317. +- [x] T041 Confirm Provider Connections does not infer filter state from provider external tenant identifiers after clear. +- [x] T042 Confirm clean hub entry never restores Environment filter state from persisted table/session keys. + +## Phase 7: Regression Safety + +- [x] T043 Re-run or update Spec 314 regression coverage proving sidebar/global workspace hub entry remains clean, has no Environment params, and shows workspace-wide data. +- [x] T044 Re-run or update Spec 315 regression coverage proving Environment-owned CTAs still use `environment_id`, visible chip still renders when filtered, legacy params remain noncanonical, and cross-workspace IDs are rejected. +- [x] T045 Add a Decision Register clean URL regression proving authorized users can open it after clear without 403 caused by missing filter state. +- [x] T046 Confirm clear behavior does not bypass page/resource authorization, does not reveal inaccessible Workspace/Environment existence, and preserves existing 404/403 semantics. +- [x] T047 Confirm no globally searchable resource or destructive action behavior changed; if a resource/action is touched, confirm Edit/View/global-search status remains valid or disabled and destructive actions still use `->action(...)`, `->requiresConfirmation()`, authorization, and audit behavior. + +## Phase 8: Browser Verification + +- [x] T048 Start the local platform stack using Sail or the repo's platform dev command. +- [x] T049 Run Flow A `Filter -> Clear -> Reload` for Operations, Governance Inbox, Decision Register, Finding Exceptions Queue, Provider Connections, Evidence, Review Register, and Customer Review Workspace. +- [x] T050 Run Flow B `Persisted Filter -> Sidebar Entry` for hubs where an Environment filter can be persisted. +- [x] T051 Run Flow C browser back/forward for Provider Connections, Finding Exceptions Queue, Customer Review Workspace, and Evidence. +- [x] T052 Save screenshots where useful under `specs/316-workspace-hub-clear-filter-contract/artifacts/screenshots/`. +- [x] T053 Document browser tooling limitations if back/forward or persisted-filter browser setup cannot be made deterministic. + +## Phase 9: Final Validation + +- [x] T054 Run focused Pest tests for the Spec 316 clear/reset contract. +- [x] T055 Run existing related Spec 314 and Spec 315 regression tests. +- [x] T056 Run formatting/static checks expected by the touched files, including Pint if PHP files changed. +- [x] T057 Run `git diff --check`. +- [x] T058 Prepare the final implementation report with changed behavior, shared clear mechanism, hubs covered, files changed, tests, browser verification, known issues, and remaining follow-ups. +- [x] T059 Confirm final report lists clear-state layers covered, hubs verified, legacy state keys removed/neutralized, pages intentionally excluded, browser limitations, and unrelated residual test failures if any. +- [x] T060 Confirm final report states no migrations, seeders, packages, env vars, queues, scheduler, storage changes, compatibility layer, or legacy query alias preservation were introduced. + +## Explicit Non-Tasks + +- [x] NT001 Do not implement new Environment CTA filtering beyond preserving Spec 315 behavior. +- [x] NT002 Do not retrofit optional pages that were not already Environment-filterable through `environment_id`. +- [x] NT003 Do not perform broad legacy Tenant / Environment naming cleanup; leave to Spec 317. +- [x] NT004 Do not build durable browser no-drift infrastructure; leave to Spec 318. +- [x] NT005 Do not add compatibility redirects, dual-param support, alias readers, or adapter layers. +- [x] NT006 Do not create migrations, seeders, packages, env vars, queues, scheduler changes, or storage changes.