Spec 314: enforce workspace hub navigation context contract (#369)

## Summary
- add a shared workspace hub registry for canonical workspace-scoped navigation entry
- keep sidebar and global workspace hub URLs free of inherited environment query and filter state
- add focused feature and browser coverage for workspace hub shell and data-scope contracts

## Validation
- 54 focused feature tests passed (205 assertions)
- 1 browser smoke test passed (361 assertions)
- `cd apps/platform && ./vendor/bin/sail bin pint --dirty --format agent`
- `git diff --check`

Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de>
Reviewed-on: #369
This commit is contained in:
ahmido 2026-05-16 09:54:29 +00:00
parent 2f7a521d5f
commit d85ef4cc1c
26 changed files with 2542 additions and 55 deletions

View File

@ -14,6 +14,7 @@
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;
@ -123,32 +124,16 @@ public static function canAccess(): bool
return false;
}
if (static::hasRequestedTenantPrefilter()) {
return true;
}
$visibleTenants = static::resolveVisibleDecisionTenantsFor($user, $workspace);
if ($visibleTenants === []) {
return false;
}
if (request()->query('register_state') === 'recently_closed') {
return true;
}
$counts = app(GovernanceDecisionRegisterBuilder::class)->build(
workspace: $workspace,
visibleTenants: $visibleTenants,
registerState: 'open',
)['counts'] ?? [];
return (int) ($counts['open'] ?? 0) > 0
|| (int) ($counts['recently_closed'] ?? 0) > 0;
return $visibleTenants !== [];
}
public function mount(): void
{
app(CanonicalAdminTenantFilterState::class)
->forgetEnvironmentLikeFiltersForCleanWorkspaceHubEntry($this->getTableFiltersSessionKey(), request());
$this->mountInteractsWithTable();
$this->authorizeWorkspaceMembership();
$this->applyRequestedTenantPrefilter();
@ -434,9 +419,8 @@ private function ensureRegisterIsVisible(): void
return;
}
if ((int) ($counts['open'] ?? 0) === 0) {
abort(403);
}
// A clean workspace register URL is a valid workspace hub entry, even when the
// current register has no rows. The table empty state owns that operator truth.
}
/**

View File

@ -12,6 +12,7 @@
use App\Support\Badges\BadgeCatalog;
use App\Support\Badges\BadgeDomain;
use App\Support\EnvironmentReviewStatus;
use App\Support\Filament\CanonicalAdminTenantFilterState;
use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration;
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceInspectAffordance;
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile;
@ -150,6 +151,8 @@ public static function monitoringPageStateContract(): array
public function mount(): void
{
$this->authorizeWorkspaceAccess();
app(CanonicalAdminTenantFilterState::class)
->forgetEnvironmentLikeFiltersForCleanWorkspaceHubEntry($this->getTableFiltersSessionKey(), request());
$this->seedTableStateFromQuery();
$this->rows = $this->rowsForState($this->tableFilters ?? [], $this->tableSearch)->values()->all();

View File

@ -192,6 +192,9 @@ public static function canAccess(): bool
public function mount(): void
{
app(CanonicalAdminTenantFilterState::class)
->forgetEnvironmentLikeFiltersForCleanWorkspaceHubEntry($this->getTableFiltersSessionKey(), request());
$this->mountInteractsWithTable();
$this->applyRequestedTenantPrefilter();
$requestedExceptionId = is_numeric(request()->query('exception')) ? (int) request()->query('exception') : null;

View File

@ -23,6 +23,7 @@
use App\Support\Navigation\CanonicalNavigationContext;
use App\Support\ReviewPackStatus;
use App\Support\EnvironmentReviewCompletenessState;
use App\Support\Filament\CanonicalAdminTenantFilterState;
use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration;
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceInspectAffordance;
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile;
@ -115,6 +116,8 @@ public static function tenantPrefilterUrl(ManagedEnvironment $tenant): string
public function mount(): void
{
$this->authorizePageAccess();
app(CanonicalAdminTenantFilterState::class)
->forgetEnvironmentLikeFiltersForCleanWorkspaceHubEntry($this->getTableFiltersSessionKey(), request());
$this->applyRequestedTenantPrefilter();
$this->mountInteractsWithTable();
$this->auditWorkspaceOpen();

View File

@ -1658,6 +1658,7 @@ public static function getUrl(?string $name = null, array $parameters = [], bool
{
$panel ??= 'admin';
$tenantExternalId = null;
$isIndexUrl = $name === null || $name === 'index';
if (array_key_exists('tenant', $parameters)) {
$tenantExternalId = static::normalizeTenantExternalId($parameters['tenant']);
@ -1676,7 +1677,7 @@ public static function getUrl(?string $name = null, array $parameters = [], bool
}
}
if ($tenantExternalId === null) {
if ($tenantExternalId === null && ! $isIndexUrl) {
$tenantExternalId = static::resolveScopedTenant()?->external_id;
}

View File

@ -40,6 +40,7 @@
use App\Support\Auth\Capabilities;
use App\Support\Filament\PanelThemeAsset;
use App\Support\Navigation\NavigationScope;
use App\Support\Navigation\WorkspaceHubRegistry;
use App\Support\OperationRunLinks;
use App\Support\Tenants\TenantPageCategory;
use App\Support\Workspaces\WorkspaceContext;
@ -106,13 +107,13 @@ public function panel(Panel $panel): Panel
->sort(10)
->visible(fn (): bool => NavigationScope::shouldRegisterEnvironmentNavigation() && EntraGroupResource::canViewAny()),
NavigationItem::make(fn (): string => __('localization.navigation.integrations'))
->url(fn (): string => route('filament.admin.resources.provider-connections.index'))
->url(fn (): string => WorkspaceHubRegistry::cleanUrl(route('filament.admin.resources.provider-connections.index')))
->icon('heroicon-o-link')
->group(fn (): string => __('localization.navigation.settings'))
->sort(15)
->visible(fn (): bool => ProviderConnectionResource::canViewAny()),
NavigationItem::make(fn (): string => __('localization.navigation.settings'))
->url(fn (): string => WorkspaceSettings::getUrl(panel: 'admin'))
->url(fn (): string => WorkspaceHubRegistry::cleanUrl(WorkspaceSettings::getUrl(panel: 'admin')))
->icon('heroicon-o-cog-6-tooth')
->group(fn (): string => __('localization.navigation.settings'))
->sort(20)
@ -143,7 +144,7 @@ public function panel(Panel $panel): Panel
}),
NavigationItem::make(fn (): string => __('localization.navigation.manage_workspaces'))
->url(function (): string {
return route('filament.admin.resources.workspaces.index');
return WorkspaceHubRegistry::cleanUrl(route('filament.admin.resources.workspaces.index'));
})
->icon('heroicon-o-squares-2x2')
->group(fn (): string => __('localization.navigation.settings'))
@ -163,17 +164,17 @@ public function panel(Panel $panel): Panel
->exists();
}),
NavigationItem::make(fn (): string => __('localization.navigation.operations'))
->url(fn (): string => OperationRunLinks::index())
->url(fn (): string => WorkspaceHubRegistry::cleanUrl(OperationRunLinks::index()))
->icon('heroicon-o-queue-list')
->group(fn (): string => __('localization.navigation.monitoring'))
->sort(10),
NavigationItem::make('Alerts')
->url(fn (): string => route('filament.admin.alerts'))
->url(fn (): string => WorkspaceHubRegistry::cleanUrl(route('filament.admin.alerts')))
->icon('heroicon-o-bell-alert')
->group(fn (): string => __('localization.navigation.monitoring'))
->sort(23),
NavigationItem::make(fn (): string => __('localization.navigation.audit_log'))
->url(fn (): string => route('admin.monitoring.audit-log'))
->url(fn (): string => WorkspaceHubRegistry::cleanUrl(route('admin.monitoring.audit-log')))
->icon('heroicon-o-clipboard-document-list')
->group(fn (): string => __('localization.navigation.monitoring'))
->sort(30),

View File

@ -5,6 +5,7 @@
namespace App\Support\Filament;
use App\Models\ManagedEnvironment;
use App\Support\Navigation\WorkspaceHubRegistry;
use App\Support\OperateHub\OperateHubShell;
use Illuminate\Http\Request;
use Illuminate\Session\Store;
@ -60,6 +61,14 @@ public function sync(
$persistedFilters = [];
}
if (WorkspaceHubRegistry::isCleanWorkspaceHubEntry($request)) {
$persistedFilters = $this->forgetEnvironmentLikeFilters(
$filtersSessionKey,
$request,
$persistedFilters,
);
}
$activeTenant = $this->operateHubShell->activeEntitledTenant($request);
$resolvedTenantId = $activeTenant instanceof ManagedEnvironment ? (string) $activeTenant->getKey() : null;
$stateKey = $this->stateKey($filtersSessionKey);
@ -93,6 +102,49 @@ public function sync(
$session->put($stateKey, $resolvedTenantId);
}
/**
* @param array<string, mixed>|null $persistedFilters
* @return array<string, mixed>
*/
public function forgetEnvironmentLikeFilters(
string $filtersSessionKey,
?Request $request = null,
?array $persistedFilters = null,
): array {
$session = $this->session($request);
$persistedFilters ??= $session->get($filtersSessionKey, []);
if (! is_array($persistedFilters)) {
$persistedFilters = [];
}
foreach (WorkspaceHubRegistry::environmentLikeFilterKeys() as $filterName) {
Arr::forget($persistedFilters, $filterName);
}
if ($persistedFilters === []) {
$session->forget($filtersSessionKey);
} else {
$session->put($filtersSessionKey, $persistedFilters);
}
$session->forget($this->stateKey($filtersSessionKey));
return $persistedFilters;
}
public function forgetEnvironmentLikeFiltersForCleanWorkspaceHubEntry(
string $filtersSessionKey,
?Request $request = null,
): void {
if (! WorkspaceHubRegistry::isCleanWorkspaceHubEntry($request)) {
return;
}
$this->forgetEnvironmentLikeFilters($filtersSessionKey, $request);
}
private function stateKey(string $filtersSessionKey): string
{
return self::STATE_PREFIX.'.'.md5($filtersSessionKey);

View File

@ -0,0 +1,353 @@
<?php
declare(strict_types=1);
namespace App\Support\Navigation;
use Illuminate\Http\Request;
final class WorkspaceHubRegistry
{
/**
* @var list<string>
*/
private const FORBIDDEN_QUERY_KEYS = [
'tenant',
'tenant_id',
'managed_environment_id',
'environment_id',
'tenant_scope',
'tableFilters',
];
/**
* @var list<string>
*/
private const ENVIRONMENT_FILTER_KEYS = [
'tenant',
'tenant_id',
'managed_environment_id',
'environment_id',
'environment',
'tenant_scope',
];
/**
* @var list<string>
*/
private const ENVIRONMENT_FILTER_QUERY_KEYS = [
'tenant',
'tenant_id',
'managed_environment_id',
'environment_id',
'environment',
];
/**
* @var array<string, array{label: string, path: string, pattern: string}>
*/
private const HUBS = [
'workspace_home' => [
'label' => 'Workspace Overview',
'path' => '/admin',
'pattern' => '#^/admin/?$#',
],
'workspace_overview' => [
'label' => 'Workspace Overview',
'path' => '/admin/workspaces/{workspace}/overview',
'pattern' => '#^/admin/workspaces/[^/]+/overview/?$#',
],
'operations' => [
'label' => 'Operations',
'path' => '/admin/workspaces/{workspace}/operations',
'pattern' => '#^/admin/workspaces/[^/]+/operations/?$#',
],
'provider_connections' => [
'label' => 'Provider Connections',
'path' => '/admin/provider-connections',
'pattern' => '#^/admin/provider-connections(?:/.*)?$#',
],
'finding_exceptions_queue' => [
'label' => 'Finding Exceptions Queue',
'path' => '/admin/finding-exceptions/queue',
'pattern' => '#^/admin/finding-exceptions/queue/?$#',
],
'evidence_overview' => [
'label' => 'Evidence Overview',
'path' => '/admin/evidence/overview',
'pattern' => '#^/admin/evidence/overview/?$#',
],
'review_register' => [
'label' => 'Review Register',
'path' => '/admin/reviews',
'pattern' => '#^/admin/reviews/?$#',
],
'customer_review_workspace' => [
'label' => 'Customer Review Workspace',
'path' => '/admin/reviews/workspace',
'pattern' => '#^/admin/reviews/workspace/?$#',
],
'governance_inbox' => [
'label' => 'Governance Inbox',
'path' => '/admin/governance/inbox',
'pattern' => '#^/admin/governance/inbox/?$#',
],
'decision_register' => [
'label' => 'Decision Register',
'path' => '/admin/governance/decisions',
'pattern' => '#^/admin/governance/decisions/?$#',
],
'audit_log' => [
'label' => 'Audit Log',
'path' => '/admin/audit-log',
'pattern' => '#^/admin/audit-log/?$#',
],
'alerts' => [
'label' => 'Alerts',
'path' => '/admin/alerts',
'pattern' => '#^/admin/alerts/?$#',
],
'alert_deliveries' => [
'label' => 'Alert Deliveries',
'path' => '/admin/alerts/alert-deliveries',
'pattern' => '#^/admin/alerts/alert-deliveries(?:/.*)?$#',
],
'alert_rules' => [
'label' => 'Alert Rules',
'path' => '/admin/alerts/alert-rules',
'pattern' => '#^/admin/alerts/alert-rules(?:/.*)?$#',
],
'alert_destinations' => [
'label' => 'Alert Destinations',
'path' => '/admin/alerts/alert-destinations',
'pattern' => '#^/admin/alerts/alert-destinations(?:/.*)?$#',
],
'workspace_settings' => [
'label' => 'Workspace Settings',
'path' => '/admin/settings/workspace',
'pattern' => '#^/admin/settings/workspace/?$#',
],
'manage_workspaces' => [
'label' => 'Manage Workspaces',
'path' => '/admin/workspaces',
'pattern' => '#^/admin/workspaces/?$#',
],
'managed_environments_landing' => [
'label' => 'Managed Environments Landing',
'path' => '/admin/workspaces/{workspace}/environments',
'pattern' => '#^/admin/workspaces/[^/]+/environments/?$#',
],
];
/**
* @var array<string, array{label: string, path: string, pattern: string}>
*/
private const EXCLUSIONS = [
'environment_dashboard' => [
'label' => 'Environment Dashboard',
'path' => '/admin/workspaces/{workspace}/environments/{environment}',
'pattern' => '#^/admin/workspaces/[^/]+/environments/[^/]+(?:/.*)?$#',
],
'stored_reports_environment_routes' => [
'label' => 'Stored Reports environment routes',
'path' => '/admin/workspaces/{workspace}/environments/{environment}/stored-reports',
'pattern' => '#^/admin/workspaces/[^/]+/environments/[^/]+/stored-reports(?:/.*)?$#',
],
'support_request_action_surface' => [
'label' => 'Support Request action surface',
'path' => 'modal/action-only',
'pattern' => '#^$#',
],
];
/**
* @return array<string, array{label: string, path: string, pattern: string}>
*/
public static function entries(): array
{
return self::HUBS;
}
/**
* @return array<string, array{label: string, path: string, pattern: string}>
*/
public static function exclusions(): array
{
return self::EXCLUSIONS;
}
/**
* @return list<string>
*/
public static function forbiddenQueryKeys(): array
{
return self::FORBIDDEN_QUERY_KEYS;
}
/**
* @return list<string>
*/
public static function environmentLikeFilterKeys(): array
{
return self::ENVIRONMENT_FILTER_KEYS;
}
public static function isWorkspaceHubPath(string $path): bool
{
$normalizedPath = self::normalizePath($path);
foreach (self::HUBS as $hub) {
if (preg_match($hub['pattern'], $normalizedPath) === 1) {
return true;
}
}
return false;
}
public static function isExplicitlyExcludedPath(string $path): bool
{
$normalizedPath = self::normalizePath($path);
foreach (self::EXCLUSIONS as $exclusion) {
if (preg_match($exclusion['pattern'], $normalizedPath) === 1) {
return true;
}
}
return false;
}
/**
* @param array<string, mixed> $query
* @return array<string, mixed>
*/
public static function cleanQuery(array $query): array
{
foreach (self::FORBIDDEN_QUERY_KEYS as $key) {
unset($query[$key]);
}
return $query;
}
/**
* @param array<string, mixed> $parameters
* @return array<string, mixed>
*/
public static function cleanParameters(array $parameters): array
{
return self::cleanQuery($parameters);
}
public static function cleanUrl(string $url): string
{
if (! str_contains($url, '?')) {
return $url;
}
$parts = parse_url($url);
if (! is_array($parts)) {
return $url;
}
$query = [];
parse_str((string) ($parts['query'] ?? ''), $query);
$parts['query'] = http_build_query(self::cleanQuery($query), '', '&', PHP_QUERY_RFC3986);
return self::buildUrl($parts);
}
public static function hasForbiddenQuery(string $url): bool
{
$parts = parse_url($url);
if (! is_array($parts) || ! isset($parts['query'])) {
return false;
}
$query = [];
parse_str((string) $parts['query'], $query);
foreach (self::FORBIDDEN_QUERY_KEYS as $key) {
if (array_key_exists($key, $query)) {
return true;
}
}
return false;
}
public static function isCleanWorkspaceHubEntry(?Request $request = null): bool
{
$request ??= request();
return self::isWorkspaceHubPath('/'.ltrim((string) $request->path(), '/'))
&& ! self::requestHasEnvironmentFilterQuery($request);
}
public static function requestHasEnvironmentFilterQuery(?Request $request = null): bool
{
$request ??= request();
foreach (self::ENVIRONMENT_FILTER_QUERY_KEYS as $key) {
if ($request->query->has($key) && filled($request->query($key))) {
return true;
}
}
return false;
}
private static function normalizePath(string $path): string
{
$parsedPath = parse_url($path, PHP_URL_PATH);
$path = is_string($parsedPath) && $parsedPath !== '' ? $parsedPath : $path;
return '/'.trim($path, '/');
}
/**
* @param array<string, mixed> $parts
*/
private static function buildUrl(array $parts): string
{
$url = '';
if (isset($parts['scheme'])) {
$url .= $parts['scheme'].'://';
}
if (isset($parts['user'])) {
$url .= $parts['user'];
if (isset($parts['pass'])) {
$url .= ':'.$parts['pass'];
}
$url .= '@';
}
if (isset($parts['host'])) {
$url .= $parts['host'];
}
if (isset($parts['port'])) {
$url .= ':'.$parts['port'];
}
$url .= $parts['path'] ?? '';
if (($parts['query'] ?? '') !== '') {
$url .= '?'.$parts['query'];
}
if (isset($parts['fragment'])) {
$url .= '#'.$parts['fragment'];
}
return $url;
}
}

View File

@ -39,72 +39,72 @@ public function build(): NavigationBuilder
NavigationGroup::make(__('localization.navigation.monitoring'))
->items($this->visibleItems([
NavigationItem::make(FindingExceptionsQueue::getNavigationLabel())
->url(fn (): string => FindingExceptionsQueue::getUrl(panel: 'admin'))
->url(fn (): string => $this->workspaceHubUrl(FindingExceptionsQueue::getUrl(panel: 'admin')))
->icon(FindingExceptionsQueue::getNavigationIcon())
->visible(fn (): bool => FindingExceptionsQueue::canAccess()),
NavigationItem::make(__('localization.navigation.operations'))
->url(fn (): string => OperationRunLinks::index())
->url(fn (): string => $this->workspaceHubUrl(OperationRunLinks::index()))
->icon('heroicon-o-queue-list')
->visible(fn (): bool => true),
NavigationItem::make(__('localization.navigation.alerts'))
->url(fn (): string => route('filament.admin.alerts'))
->url(fn (): string => $this->workspaceHubUrl(route('filament.admin.alerts')))
->icon('heroicon-o-bell-alert')
->visible(fn (): bool => Alerts::canAccess())
->childItems($this->visibleItems([
NavigationItem::make(AlertDestinationResource::getNavigationLabel())
->url(fn (): string => AlertDestinationResource::getUrl(panel: 'admin'))
->url(fn (): string => $this->workspaceHubUrl(AlertDestinationResource::getUrl(panel: 'admin')))
->icon(AlertDestinationResource::getNavigationIcon())
->visible(fn (): bool => AlertDestinationResource::canViewAny()),
NavigationItem::make(AlertRuleResource::getNavigationLabel())
->url(fn (): string => AlertRuleResource::getUrl(panel: 'admin'))
->url(fn (): string => $this->workspaceHubUrl(AlertRuleResource::getUrl(panel: 'admin')))
->icon(AlertRuleResource::getNavigationIcon())
->visible(fn (): bool => AlertRuleResource::canViewAny()),
NavigationItem::make(AlertDeliveryResource::getNavigationLabel())
->url(fn (): string => AlertDeliveryResource::getUrl(panel: 'admin'))
->url(fn (): string => $this->workspaceHubUrl(AlertDeliveryResource::getUrl(panel: 'admin')))
->icon(AlertDeliveryResource::getNavigationIcon())
->visible(fn (): bool => AlertDeliveryResource::canViewAny()),
])),
NavigationItem::make(__('localization.navigation.audit_log'))
->url(fn (): string => route('admin.monitoring.audit-log'))
->url(fn (): string => $this->workspaceHubUrl(route('admin.monitoring.audit-log')))
->icon('heroicon-o-clipboard-document-list'),
])),
NavigationGroup::make(__('localization.review.reporting'))
->items($this->visibleItems([
NavigationItem::make(ReviewRegister::getNavigationLabel())
->url(fn (): string => ReviewRegister::getUrl(panel: 'admin'))
->url(fn (): string => $this->workspaceHubUrl(ReviewRegister::getUrl(panel: 'admin')))
->icon(ReviewRegister::getNavigationIcon()),
NavigationItem::make(CustomerReviewWorkspace::getNavigationLabel())
->url(fn (): string => CustomerReviewWorkspace::getUrl(panel: 'admin'))
->url(fn (): string => $this->workspaceHubUrl(CustomerReviewWorkspace::getUrl(panel: 'admin')))
->icon(CustomerReviewWorkspace::getNavigationIcon()),
])),
NavigationGroup::make(__('localization.navigation.settings'))
->items($this->visibleItems([
NavigationItem::make(__('localization.navigation.manage_workspaces'))
->url(fn (): string => route('filament.admin.resources.workspaces.index'))
->url(fn (): string => $this->workspaceHubUrl(route('filament.admin.resources.workspaces.index')))
->icon(WorkspaceResource::getNavigationIcon())
->visible(fn (): bool => $this->canManageWorkspaces()),
NavigationItem::make(__('localization.navigation.integrations'))
->url(fn (): string => ProviderConnectionResource::getUrl('index', panel: 'admin'))
->url(fn (): string => $this->workspaceHubUrl(ProviderConnectionResource::getUrl('index', panel: 'admin')))
->icon(ProviderConnectionResource::getNavigationIcon())
->visible(fn (): bool => ProviderConnectionResource::canViewAny())
->childItems($this->visibleItems([
NavigationItem::make(ProviderConnectionResource::getNavigationLabel())
->url(fn (): string => ProviderConnectionResource::getUrl('index', panel: 'admin'))
->url(fn (): string => $this->workspaceHubUrl(ProviderConnectionResource::getUrl('index', panel: 'admin')))
->icon(ProviderConnectionResource::getNavigationIcon())
->visible(fn (): bool => ProviderConnectionResource::canViewAny()),
])),
NavigationItem::make(__('localization.navigation.settings'))
->url(fn (): string => WorkspaceSettings::getUrl(panel: 'admin'))
->url(fn (): string => $this->workspaceHubUrl(WorkspaceSettings::getUrl(panel: 'admin')))
->icon('heroicon-o-cog-6-tooth')
->visible(fn (): bool => $this->canViewWorkspaceSettings()),
])),
NavigationGroup::make(__('localization.navigation.governance'))
->items($this->visibleItems([
NavigationItem::make(GovernanceInbox::getNavigationLabel())
->url(fn (): string => GovernanceInbox::getUrl(panel: 'admin'))
->url(fn (): string => $this->workspaceHubUrl(GovernanceInbox::getUrl(panel: 'admin')))
->icon(GovernanceInbox::getNavigationIcon()),
NavigationItem::make(DecisionRegister::getNavigationLabel())
->url(fn (): string => DecisionRegister::getUrl(panel: 'admin'))
->url(fn (): string => $this->workspaceHubUrl(DecisionRegister::getUrl(panel: 'admin')))
->icon(DecisionRegister::getNavigationIcon())
->visible(fn (): bool => DecisionRegister::canAccess()),
])),
@ -123,6 +123,11 @@ private function visibleItems(array $items): array
));
}
private function workspaceHubUrl(string $url): string
{
return WorkspaceHubRegistry::cleanUrl($url);
}
private function canManageWorkspaces(): bool
{
$user = auth()->user();

View File

@ -4,6 +4,7 @@
namespace App\Support\Tenants;
use App\Support\Navigation\WorkspaceHubRegistry;
use Illuminate\Http\Request;
enum TenantPageCategory: string
@ -112,11 +113,7 @@ public function lane(): TenantInteractionLane
private static function isWorkspaceWideSurfacePath(string $normalizedPath): bool
{
if (preg_match('#^/admin/workspaces/[^/]+/(?:overview|operations)(?:/|$)#', $normalizedPath) === 1) {
return true;
}
return preg_match('#^/admin/(?:alerts|audit-log|evidence/overview|governance/(?:decisions|inbox)|provider-connections|reviews(?:/workspace)?)(?:/|$)#', $normalizedPath) === 1;
return WorkspaceHubRegistry::isWorkspaceHubPath($normalizedPath);
}
private static function effectivePath(Request $request): string

View File

@ -0,0 +1,110 @@
<?php
declare(strict_types=1);
use App\Filament\Pages\EnvironmentDashboard;
use App\Filament\Pages\Governance\DecisionRegister;
use App\Filament\Pages\Governance\GovernanceInbox;
use App\Filament\Pages\Monitoring\FindingExceptionsQueue;
use App\Filament\Pages\Reviews\CustomerReviewWorkspace;
use App\Filament\Pages\Reviews\ReviewRegister;
use App\Filament\Resources\ProviderConnectionResource;
use App\Models\ManagedEnvironment;
use App\Support\OperationRunLinks;
use App\Support\Workspaces\WorkspaceContext;
pest()->browser()->timeout(30_000);
it('Spec314 smokes clean workspace hub navigation context', function (): void {
$environment = ManagedEnvironment::factory()->active()->create([
'name' => 'Spec314 Browser Environment',
]);
[$user, $environment] = createUserWithTenant(tenant: $environment, role: 'owner');
$workspace = $environment->workspace()->firstOrFail();
$this->actingAs($user)->withSession([
WorkspaceContext::SESSION_KEY => (int) $workspace->getKey(),
WorkspaceContext::LAST_TENANT_IDS_SESSION_KEY => [
(string) $workspace->getKey() => (int) $environment->getKey(),
],
]);
session()->put(WorkspaceContext::SESSION_KEY, (int) $workspace->getKey());
session()->put(WorkspaceContext::LAST_TENANT_IDS_SESSION_KEY, [
(string) $workspace->getKey() => (int) $environment->getKey(),
]);
$hubUrls = [
ProviderConnectionResource::getUrl('index', panel: 'admin'),
FindingExceptionsQueue::getUrl(panel: 'admin'),
OperationRunLinks::index(),
DecisionRegister::getUrl(panel: 'admin'),
CustomerReviewWorkspace::getUrl(panel: 'admin'),
route('admin.evidence.overview'),
ReviewRegister::getUrl(panel: 'admin'),
GovernanceInbox::getUrl(panel: 'admin'),
];
$assertCleanWorkspaceHub = function (mixed $page, string $url): void {
$expectedPath = json_encode((string) parse_url($url, PHP_URL_PATH), JSON_THROW_ON_ERROR);
$page
->assertSee(__('localization.shell.no_environment_selected'))
->assertDontSee(__('localization.shell.environment_scope').': Spec314 Browser Environment')
->assertScript("window.location.pathname === {$expectedPath}", true)
->assertScript('! window.location.search.includes("tenant=")', true)
->assertScript('! window.location.search.includes("tenant_id=")', true)
->assertScript('! window.location.search.includes("managed_environment_id=")', true)
->assertScript('! window.location.search.includes("environment_id=")', true)
->assertScript('! window.location.search.includes("tenant_scope=")', true)
->assertScript('! window.location.search.includes("tableFilters")', true)
->assertNoJavaScriptErrors()
->assertNoConsoleLogs();
};
visit(EnvironmentDashboard::getUrl(panel: 'admin', tenant: $environment))
->assertSee('Spec314 Browser Environment')
->assertNoJavaScriptErrors()
->assertNoConsoleLogs();
foreach ($hubUrls as $url) {
$page = visit($url);
$assertCleanWorkspaceHub($page, $url);
$page->script('window.location.reload();');
$page->waitForText(__('localization.shell.no_environment_selected'));
$assertCleanWorkspaceHub($page, $url);
}
visit(route('admin.workspace.home', ['workspace' => $workspace]))
->waitForText('Workspace overview')
->assertNoJavaScriptErrors()
->assertNoConsoleLogs();
foreach ($hubUrls as $url) {
$page = visit($url);
$assertCleanWorkspaceHub($page, $url);
}
$historyPage = visit($hubUrls[0]);
$assertCleanWorkspaceHub($historyPage, $hubUrls[0]);
foreach (array_slice($hubUrls, 1, 4) as $url) {
$encodedUrl = json_encode($url, JSON_THROW_ON_ERROR);
$historyPage->script("window.location.assign({$encodedUrl});");
$historyPage->waitForText(__('localization.shell.no_environment_selected'));
$assertCleanWorkspaceHub($historyPage, $url);
}
$historyPage->script('window.history.back();');
$historyPage->waitForText(__('localization.shell.no_environment_selected'));
$assertCleanWorkspaceHub($historyPage, $hubUrls[3]);
$historyPage->script('window.history.forward();');
$historyPage->waitForText(__('localization.shell.no_environment_selected'));
$assertCleanWorkspaceHub($historyPage, $hubUrls[4]);
});

View File

@ -64,17 +64,18 @@
->assertNotFound();
});
it('returns 403 for workspace members with no visible decisions in the default unfiltered register', function (): void {
it('opens the default unfiltered register for authorized workspace members with no visible decisions', function (): void {
$tenant = ManagedEnvironment::factory()->create(['status' => 'active']);
[$user, $tenant] = createUserWithTenant($tenant, role: 'readonly', workspaceRole: 'readonly');
$this->actingAs($user)
->withSession([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id])
->get(DecisionRegister::getUrl(panel: 'admin'))
->assertForbidden();
->assertOk()
->assertSee('No open decisions match this filter right now.');
});
it('hides the decision register page when the default workspace register would resolve to 403', function (): void {
it('registers the decision register page for authorized workspace members even when the register is empty', function (): void {
$tenant = ManagedEnvironment::factory()->create(['status' => 'active']);
[$user, $tenant] = createUserWithTenant($tenant, role: 'readonly', workspaceRole: 'readonly');
@ -83,9 +84,9 @@
->get(route('admin.workspace.home', ['workspace' => $tenant->workspace]))
->assertOk();
$response->assertDontSee(DecisionRegister::getUrl(panel: 'admin'));
$response->assertSee(DecisionRegister::getUrl(panel: 'admin'), false);
expect(DecisionRegister::canAccess())->toBeFalse();
expect(DecisionRegister::canAccess())->toBeTrue();
});
it('returns 404 for explicit tenant filters outside the actor scope', function (): void {

View File

@ -0,0 +1,75 @@
<?php
declare(strict_types=1);
use App\Filament\Pages\Governance\DecisionRegister;
use App\Models\Finding;
use App\Models\FindingException;
use App\Models\FindingExceptionDecision;
use App\Models\ManagedEnvironment;
use App\Models\User;
use App\Support\Workspaces\WorkspaceContext;
function spec314DecisionException(ManagedEnvironment $environment, User $actor, string $reason): FindingException
{
$finding = Finding::factory()->for($environment)->create([
'workspace_id' => (int) $environment->workspace_id,
]);
$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' => $reason,
'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' => $reason,
'metadata' => [],
'decided_at' => now()->subDay(),
]);
$exception->forceFill(['current_decision_id' => (int) $decision->getKey()])->save();
return $exception->fresh(['currentDecision']);
}
it('Spec314 decision register clean workspace url opens for authorized workspace users', function (): void {
$environment = ManagedEnvironment::factory()->active()->create();
[$user, $environment] = createUserWithTenant(tenant: $environment, role: 'readonly', workspaceRole: 'readonly');
spec314DecisionException($environment, $user, 'Spec314 clean decision register');
$url = DecisionRegister::getUrl(panel: 'admin');
expect($url)->not->toContain('managed_environment_id');
$this->actingAs($user)
->withSession([WorkspaceContext::SESSION_KEY => (int) $environment->workspace_id])
->get($url)
->assertOk()
->assertSee('Decision register');
});
it('Spec314 decision register clean workspace url shows an empty state instead of 403', function (): void {
$environment = ManagedEnvironment::factory()->active()->create();
[$user, $environment] = createUserWithTenant(tenant: $environment, role: 'readonly', workspaceRole: 'readonly');
$this->actingAs($user)
->withSession([WorkspaceContext::SESSION_KEY => (int) $environment->workspace_id])
->get(DecisionRegister::getUrl(panel: 'admin'))
->assertOk()
->assertSee('Decision register')
->assertSee('No open decisions match this filter right now.');
});

View File

@ -0,0 +1,76 @@
<?php
declare(strict_types=1);
use App\Filament\Pages\Monitoring\EvidenceOverview;
use App\Filament\Resources\EvidenceSnapshotResource;
use App\Models\EvidenceSnapshot;
use App\Models\ManagedEnvironment;
use App\Support\Evidence\EvidenceCompletenessState;
use App\Support\Evidence\EvidenceSnapshotStatus;
use App\Support\Workspaces\WorkspaceContext;
use Livewire\Livewire;
it('Spec314 evidence overview clean workspace entry is workspace wide', function (): void {
$environmentA = ManagedEnvironment::factory()->active()->create(['name' => 'Evidence Environment A']);
[$user, $environmentA] = createUserWithTenant(tenant: $environmentA, role: 'owner');
$environmentB = ManagedEnvironment::factory()->active()->create([
'workspace_id' => (int) $environmentA->workspace_id,
'name' => 'Evidence Environment B',
]);
createUserWithTenant(tenant: $environmentB, user: $user, role: 'owner');
$snapshotA = spec314EvidenceSnapshot($environmentA, EvidenceCompletenessState::Complete->value);
$snapshotB = spec314EvidenceSnapshot($environmentB, EvidenceCompletenessState::Partial->value);
$this->actingAs($user)
->withSession([WorkspaceContext::SESSION_KEY => (int) $environmentA->workspace_id])
->get(route('admin.evidence.overview'))
->assertOk()
->assertSee(EvidenceSnapshotResource::getUrl('view', ['record' => $snapshotA], tenant: $environmentA, panel: 'admin'), false)
->assertSee(EvidenceSnapshotResource::getUrl('view', ['record' => $snapshotB], tenant: $environmentB, panel: 'admin'), false);
});
it('Spec314 evidence overview ignores stale persisted environment filters on clean entry', function (): void {
$environmentA = ManagedEnvironment::factory()->active()->create();
[$user, $environmentA] = createUserWithTenant(tenant: $environmentA, role: 'owner');
$environmentB = ManagedEnvironment::factory()->active()->create([
'workspace_id' => (int) $environmentA->workspace_id,
]);
createUserWithTenant(tenant: $environmentB, user: $user, role: 'owner');
$snapshotA = spec314EvidenceSnapshot($environmentA, EvidenceCompletenessState::Complete->value);
$snapshotB = spec314EvidenceSnapshot($environmentB, EvidenceCompletenessState::Partial->value);
$this->actingAs($user);
setAdminPanelContext();
session()->put(WorkspaceContext::SESSION_KEY, (int) $environmentA->workspace_id);
$component = Livewire::actingAs($user)->test(EvidenceOverview::class);
$filtersSessionKey = $component->instance()->getTableFiltersSessionKey();
session()->put($filtersSessionKey, [
'managed_environment_id' => ['value' => (string) $environmentA->getKey()],
]);
$this->get(route('admin.evidence.overview'))
->assertOk()
->assertSee(EvidenceSnapshotResource::getUrl('view', ['record' => $snapshotA], tenant: $environmentA, panel: 'admin'), false)
->assertSee(EvidenceSnapshotResource::getUrl('view', ['record' => $snapshotB], tenant: $environmentB, panel: 'admin'), false);
expect(data_get(session()->get($filtersSessionKey, []), 'managed_environment_id.value'))->toBeNull();
});
function spec314EvidenceSnapshot(ManagedEnvironment $environment, string $completenessState): EvidenceSnapshot
{
return EvidenceSnapshot::query()->create([
'managed_environment_id' => (int) $environment->getKey(),
'workspace_id' => (int) $environment->workspace_id,
'status' => EvidenceSnapshotStatus::Active->value,
'completeness_state' => $completenessState,
'summary' => ['missing_dimensions' => 0, 'stale_dimensions' => 0],
'generated_at' => now(),
]);
}

View File

@ -0,0 +1,111 @@
<?php
declare(strict_types=1);
use App\Filament\Pages\Monitoring\FindingExceptionsQueue;
use App\Models\Finding;
use App\Models\FindingException;
use App\Models\ManagedEnvironment;
use App\Models\User;
use App\Support\Workspaces\WorkspaceContext;
use Filament\Facades\Filament;
use Livewire\Livewire;
function spec314FindingException(ManagedEnvironment $environment, User $user, string $reason): FindingException
{
$finding = Finding::factory()
->for($environment)
->riskAccepted()
->create([
'workspace_id' => (int) $environment->workspace_id,
'subject_external_id' => str()->slug($reason),
]);
return 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) $user->getKey(),
'owner_user_id' => (int) $user->getKey(),
'status' => FindingException::STATUS_PENDING,
'current_validity_state' => FindingException::VALIDITY_MISSING_SUPPORT,
'request_reason' => $reason,
'requested_at' => now()->subDay(),
'review_due_at' => now()->addDay(),
'evidence_summary' => ['reference_count' => 0],
]);
}
it('Spec314 finding exceptions queue sidebar entry is workspace wide', function (): void {
$environmentA = ManagedEnvironment::factory()->active()->create([
'name' => 'Queue Environment A',
'external_id' => 'queue-environment-a',
]);
[$user, $environmentA] = createUserWithTenant(tenant: $environmentA, role: 'owner');
$environmentB = ManagedEnvironment::factory()->active()->create([
'workspace_id' => (int) $environmentA->workspace_id,
'name' => 'Queue Environment B',
'external_id' => 'queue-environment-b',
]);
createUserWithTenant(tenant: $environmentB, user: $user, role: 'owner');
$exceptionA = spec314FindingException($environmentA, $user, 'Spec314 Queue A');
$exceptionB = spec314FindingException($environmentB, $user, 'Spec314 Queue B');
Filament::setTenant($environmentA, true);
$url = FindingExceptionsQueue::getUrl(panel: 'admin');
expect($url)->not->toContain('tenant=');
$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(),
]);
Livewire::actingAs($user)
->test(FindingExceptionsQueue::class)
->assertCanSeeTableRecords([$exceptionA, $exceptionB]);
$this->get($url)
->assertOk()
->assertSee(__('localization.shell.no_environment_selected'))
->assertDontSee(__('localization.shell.environment_scope').': Queue Environment A');
});
it('Spec314 finding exceptions queue ignores stale environment table filters on clean entry', function (): void {
$environmentA = ManagedEnvironment::factory()->active()->create();
[$user, $environmentA] = createUserWithTenant(tenant: $environmentA, role: 'owner');
$environmentB = ManagedEnvironment::factory()->active()->create([
'workspace_id' => (int) $environmentA->workspace_id,
]);
createUserWithTenant(tenant: $environmentB, user: $user, role: 'owner');
$exceptionA = spec314FindingException($environmentA, $user, 'Spec314 Queue stale A');
$exceptionB = spec314FindingException($environmentB, $user, 'Spec314 Queue stale B');
$this->actingAs($user);
setAdminPanelContext();
session()->put(WorkspaceContext::SESSION_KEY, (int) $environmentA->workspace_id);
$component = Livewire::actingAs($user)->test(FindingExceptionsQueue::class);
$filtersSessionKey = $component->instance()->getTableFiltersSessionKey();
session()->put($filtersSessionKey, [
'managed_environment_id' => ['value' => (string) $environmentA->getKey()],
]);
$this->get(FindingExceptionsQueue::getUrl(panel: 'admin'))
->assertOk()
->assertSee(__('localization.shell.no_environment_selected'));
expect(data_get(session()->get($filtersSessionKey, []), 'managed_environment_id.value'))->toBeNull();
Livewire::actingAs($user)
->test(FindingExceptionsQueue::class)
->assertCanSeeTableRecords([$exceptionA, $exceptionB]);
});

View File

@ -0,0 +1,85 @@
<?php
declare(strict_types=1);
use App\Filament\Pages\Monitoring\Operations;
use App\Models\ManagedEnvironment;
use App\Models\OperationRun;
use App\Support\OperationRunLinks;
use App\Support\Workspaces\WorkspaceContext;
use Filament\Facades\Filament;
use Livewire\Livewire;
it('Spec314 operations sidebar entry does not carry managed environment filters', function (): void {
$environment = ManagedEnvironment::factory()->active()->create();
[$user, $environment] = createUserWithTenant(tenant: $environment, role: 'owner');
$workspace = $environment->workspace()->firstOrFail();
Filament::setTenant($environment, true);
session()->put(WorkspaceContext::SESSION_KEY, (int) $workspace->getKey());
session()->put(WorkspaceContext::LAST_TENANT_IDS_SESSION_KEY, [
(string) $workspace->getKey() => (int) $environment->getKey(),
]);
$url = OperationRunLinks::index();
expect($url)->not->toContain('managed_environment_id')
->and($url)->not->toContain('tenant_scope')
->and($url)->not->toContain('tableFilters');
$this->actingAs($user)
->withSession([WorkspaceContext::SESSION_KEY => (int) $workspace->getKey()])
->get($url)
->assertOk()
->assertSee(__('localization.shell.no_environment_selected'))
->assertSee(__('localization.shell.all_environments'));
});
it('Spec314 operations clean workspace entry sees runs across entitled environments', function (): void {
$environmentA = ManagedEnvironment::factory()->active()->create();
[$user, $environmentA] = createUserWithTenant(tenant: $environmentA, role: 'owner');
$environmentB = ManagedEnvironment::factory()->active()->create([
'workspace_id' => (int) $environmentA->workspace_id,
]);
createUserWithTenant(tenant: $environmentB, user: $user, role: 'owner');
$runA = OperationRun::factory()->forTenant($environmentA)->create(['type' => 'policy.sync']);
$runB = OperationRun::factory()->forTenant($environmentB)->create(['type' => 'inventory_sync']);
$this->actingAs($user);
setAdminPanelContext();
session()->put(WorkspaceContext::SESSION_KEY, (int) $environmentA->workspace_id);
Livewire::test(Operations::class)
->assertCanSeeTableRecords([$runA, $runB]);
});
it('Spec314 operations ignores stale persisted environment filters on clean entry', function (): void {
$environmentA = ManagedEnvironment::factory()->active()->create();
[$user, $environmentA] = createUserWithTenant(tenant: $environmentA, role: 'owner');
$environmentB = ManagedEnvironment::factory()->active()->create([
'workspace_id' => (int) $environmentA->workspace_id,
]);
createUserWithTenant(tenant: $environmentB, user: $user, role: 'owner');
$runA = OperationRun::factory()->forTenant($environmentA)->create();
$runB = OperationRun::factory()->forTenant($environmentB)->create();
$this->actingAs($user);
setAdminPanelContext();
session()->put(WorkspaceContext::SESSION_KEY, (int) $environmentA->workspace_id);
$component = Livewire::test(Operations::class);
$filtersSessionKey = $component->instance()->getTableFiltersSessionKey();
session()->put($filtersSessionKey, [
'managed_environment_id' => ['value' => (string) $environmentA->getKey()],
]);
Livewire::test(Operations::class)
->assertCanSeeTableRecords([$runA, $runB]);
expect(data_get(session()->get($filtersSessionKey, []), 'managed_environment_id.value'))->toBeNull();
});

View File

@ -0,0 +1,60 @@
<?php
declare(strict_types=1);
use App\Support\Navigation\WorkspaceHubRegistry;
it('Spec314 lists workspace hubs and explicit exclusions', function (): void {
expect(array_keys(WorkspaceHubRegistry::entries()))
->toContain(
'workspace_home',
'workspace_overview',
'operations',
'provider_connections',
'finding_exceptions_queue',
'evidence_overview',
'review_register',
'customer_review_workspace',
'governance_inbox',
'decision_register',
'audit_log',
'alerts',
'alert_deliveries',
'alert_rules',
'alert_destinations',
'workspace_settings',
'manage_workspaces',
'managed_environments_landing',
)
->and(array_keys(WorkspaceHubRegistry::exclusions()))
->toContain(
'environment_dashboard',
'stored_reports_environment_routes',
'support_request_action_surface',
);
});
it('Spec314 classifies workspace hubs without classifying environment-owned pages', function (): void {
expect(WorkspaceHubRegistry::isWorkspaceHubPath('/admin'))->toBeTrue()
->and(WorkspaceHubRegistry::isWorkspaceHubPath('/admin/workspaces/1/overview'))->toBeTrue()
->and(WorkspaceHubRegistry::isWorkspaceHubPath('/admin/workspaces/1/operations'))->toBeTrue()
->and(WorkspaceHubRegistry::isWorkspaceHubPath('/admin/workspaces/1/environments'))->toBeTrue()
->and(WorkspaceHubRegistry::isWorkspaceHubPath('/admin/workspaces/1/environments/2'))->toBeFalse()
->and(WorkspaceHubRegistry::isExplicitlyExcludedPath('/admin/workspaces/1/environments/2'))->toBeTrue()
->and(WorkspaceHubRegistry::isWorkspaceHubPath('/admin/workspaces/1/environments/2/stored-reports'))->toBeFalse()
->and(WorkspaceHubRegistry::isExplicitlyExcludedPath('/admin/workspaces/1/environments/2/stored-reports'))->toBeTrue();
});
it('Spec314 owns forbidden query keys and environment-like filter keys', function (): void {
expect(WorkspaceHubRegistry::forbiddenQueryKeys())
->toBe(['tenant', 'tenant_id', 'managed_environment_id', 'environment_id', 'tenant_scope', 'tableFilters'])
->and(WorkspaceHubRegistry::environmentLikeFilterKeys())
->toBe(['tenant', 'tenant_id', 'managed_environment_id', 'environment_id', 'environment', 'tenant_scope']);
});
it('Spec314 strips forbidden workspace hub query keys without touching unrelated navigation state', function (): void {
$url = WorkspaceHubRegistry::cleanUrl('/admin/provider-connections?tenant=a&tenant_id=1&managed_environment_id=2&environment_id=3&tenant_scope=all&tableFilters%5Btenant%5D=x&activeTab=failed&family=alerts');
expect($url)->toBe('/admin/provider-connections?activeTab=failed&family=alerts')
->and(WorkspaceHubRegistry::hasForbiddenQuery($url))->toBeFalse();
});

View File

@ -0,0 +1,129 @@
<?php
declare(strict_types=1);
use App\Filament\Pages\Governance\DecisionRegister;
use App\Filament\Pages\Governance\GovernanceInbox;
use App\Filament\Pages\Monitoring\FindingExceptionsQueue;
use App\Filament\Pages\Reviews\CustomerReviewWorkspace;
use App\Filament\Pages\Reviews\ReviewRegister;
use App\Filament\Pages\Settings\WorkspaceSettings;
use App\Filament\Resources\AlertDeliveryResource;
use App\Filament\Resources\AlertDestinationResource;
use App\Filament\Resources\AlertRuleResource;
use App\Filament\Resources\ProviderConnectionResource;
use App\Models\ManagedEnvironment;
use App\Support\Navigation\WorkspaceHubRegistry;
use App\Support\OperationRunLinks;
use App\Support\Workspaces\WorkspaceContext;
use Filament\Facades\Filament;
function spec314QueryKeys(string $url): array
{
$query = [];
parse_str((string) parse_url($url, PHP_URL_QUERY), $query);
return array_keys($query);
}
function spec314NavigationItemUrls(array $items): array
{
$urls = [];
foreach ($items as $item) {
$url = $item->getUrl();
if (is_string($url) && $url !== '') {
$urls[] = $url;
}
$childItems = $item->getChildItems();
if ($childItems instanceof Traversable) {
$childItems = iterator_to_array($childItems);
}
if (is_array($childItems) && $childItems !== []) {
$urls = [
...$urls,
...spec314NavigationItemUrls($childItems),
];
}
}
return $urls;
}
it('Spec314 workspace hub sidebar urls do not include environment query params', function (string $hub, Closure $urlFactory): void {
$environment = ManagedEnvironment::factory()->active()->create([
'name' => 'Remembered Environment',
'external_id' => 'remembered-environment',
]);
[$user, $environment] = createUserWithTenant(tenant: $environment, role: 'owner');
$workspace = $environment->workspace()->firstOrFail();
$this->actingAs($user);
Filament::setTenant($environment, true);
session()->put(WorkspaceContext::SESSION_KEY, (int) $workspace->getKey());
session()->put(WorkspaceContext::LAST_TENANT_IDS_SESSION_KEY, [
(string) $workspace->getKey() => (int) $environment->getKey(),
]);
$url = $urlFactory($workspace);
expect(WorkspaceHubRegistry::cleanUrl($url))->toBe($url)
->and(spec314QueryKeys($url))
->not->toContain(...WorkspaceHubRegistry::forbiddenQueryKeys());
})->with([
'workspace home' => ['workspace_home', fn ($workspace): string => route('admin.home')],
'workspace overview' => ['workspace_overview', fn ($workspace): string => route('admin.workspace.home', ['workspace' => $workspace])],
'operations' => ['operations', fn ($workspace): string => OperationRunLinks::index()],
'provider connections' => ['provider_connections', fn ($workspace): string => ProviderConnectionResource::getUrl('index', panel: 'admin')],
'finding exceptions queue' => ['finding_exceptions_queue', fn ($workspace): string => FindingExceptionsQueue::getUrl(panel: 'admin')],
'evidence overview' => ['evidence_overview', fn ($workspace): string => route('admin.evidence.overview')],
'review register' => ['review_register', fn ($workspace): string => ReviewRegister::getUrl(panel: 'admin')],
'customer review workspace' => ['customer_review_workspace', fn ($workspace): string => CustomerReviewWorkspace::getUrl(panel: 'admin')],
'governance inbox' => ['governance_inbox', fn ($workspace): string => GovernanceInbox::getUrl(panel: 'admin')],
'decision register' => ['decision_register', fn ($workspace): string => DecisionRegister::getUrl(panel: 'admin')],
'audit log' => ['audit_log', fn ($workspace): string => route('admin.monitoring.audit-log')],
'alerts' => ['alerts', fn ($workspace): string => route('filament.admin.alerts')],
'alert deliveries' => ['alert_deliveries', fn ($workspace): string => AlertDeliveryResource::getUrl(panel: 'admin')],
'alert rules' => ['alert_rules', fn ($workspace): string => AlertRuleResource::getUrl(panel: 'admin')],
'alert destinations' => ['alert_destinations', fn ($workspace): string => AlertDestinationResource::getUrl(panel: 'admin')],
'workspace settings' => ['workspace_settings', fn ($workspace): string => WorkspaceSettings::getUrl(panel: 'admin')],
'manage workspaces' => ['manage_workspaces', fn ($workspace): string => route('filament.admin.resources.workspaces.index')],
'managed environments landing' => ['managed_environments_landing', fn ($workspace): string => route('admin.workspace.managed-environments.index', ['workspace' => $workspace])],
]);
it('Spec314 central panel navigation workspace hub URLs stay clean', function (): void {
$environment = ManagedEnvironment::factory()->active()->create([
'name' => 'Central Navigation Environment',
'external_id' => 'central-navigation-environment',
]);
[$user, $environment] = createUserWithTenant(tenant: $environment, role: 'owner', workspaceRole: 'owner');
$workspace = $environment->workspace()->firstOrFail();
$this->actingAs($user);
Filament::setTenant($environment, true);
session()->put(WorkspaceContext::SESSION_KEY, (int) $workspace->getKey());
session()->put(WorkspaceContext::LAST_TENANT_IDS_SESSION_KEY, [
(string) $workspace->getKey() => (int) $environment->getKey(),
]);
$this->get(route('admin.operations.index', ['workspace' => $workspace]))
->assertOk();
$workspaceHubUrls = collect(spec314NavigationItemUrls(Filament::getCurrentOrDefaultPanel()->getNavigationItems()))
->filter(static fn (string $url): bool => WorkspaceHubRegistry::isWorkspaceHubPath((string) parse_url($url, PHP_URL_PATH)))
->values();
expect($workspaceHubUrls)->not->toBeEmpty();
foreach ($workspaceHubUrls as $url) {
expect(WorkspaceHubRegistry::cleanUrl($url))->toBe($url)
->and(spec314QueryKeys($url))
->not->toContain(...WorkspaceHubRegistry::forbiddenQueryKeys());
}
});

View File

@ -0,0 +1,115 @@
<?php
declare(strict_types=1);
use App\Filament\Resources\ProviderConnectionResource;
use App\Filament\Resources\ProviderConnectionResource\Pages\ListProviderConnections;
use App\Models\ManagedEnvironment;
use App\Models\ProviderConnection;
use App\Support\Workspaces\WorkspaceContext;
use Filament\Facades\Filament;
use Livewire\Livewire;
it('Spec314 provider connections sidebar entry is workspace wide', function (): void {
$environmentA = ManagedEnvironment::factory()->active()->create([
'name' => 'Provider Environment A',
'external_id' => 'provider-environment-a',
]);
[$user, $environmentA] = createUserWithTenant(tenant: $environmentA, role: 'owner');
$environmentB = ManagedEnvironment::factory()->active()->create([
'workspace_id' => (int) $environmentA->workspace_id,
'name' => 'Provider Environment B',
'external_id' => 'provider-environment-b',
]);
createUserWithTenant(tenant: $environmentB, user: $user, role: 'owner');
ProviderConnection::factory()->create([
'workspace_id' => (int) $environmentA->workspace_id,
'managed_environment_id' => (int) $environmentA->getKey(),
'display_name' => 'Spec314 Provider A',
]);
ProviderConnection::factory()->create([
'workspace_id' => (int) $environmentB->workspace_id,
'managed_environment_id' => (int) $environmentB->getKey(),
'display_name' => 'Spec314 Provider B',
]);
Filament::setTenant($environmentA, true);
$url = ProviderConnectionResource::getUrl('index', panel: 'admin');
expect($url)->not->toContain('managed_environment_id');
$this->actingAs($user)
->withSession([
WorkspaceContext::SESSION_KEY => (int) $environmentA->workspace_id,
WorkspaceContext::LAST_TENANT_IDS_SESSION_KEY => [
(string) $environmentA->workspace_id => (int) $environmentA->getKey(),
],
])
->get($url)
->assertOk()
->assertSee('Spec314 Provider A')
->assertSee('Spec314 Provider B')
->assertSee(__('localization.shell.no_environment_selected'));
});
it('Spec314 provider connections keeps explicit environment CTA filters explicit', function (): void {
$environment = ManagedEnvironment::factory()->active()->create([
'external_id' => 'provider-explicit-environment',
]);
[$user, $environment] = createUserWithTenant(tenant: $environment, role: 'owner');
$url = ProviderConnectionResource::getUrl('index', [
'managed_environment_id' => (string) $environment->external_id,
], panel: 'admin');
expect($url)->toContain('managed_environment_id=provider-explicit-environment');
$this->actingAs($user)
->withSession([WorkspaceContext::SESSION_KEY => (int) $environment->workspace_id])
->get($url)
->assertOk();
});
it('Spec314 provider connections ignores stale persisted environment filters on clean entry', function (): void {
$environmentA = ManagedEnvironment::factory()->active()->create([
'external_id' => 'provider-stale-a',
]);
[$user, $environmentA] = createUserWithTenant(tenant: $environmentA, role: 'owner');
$environmentB = ManagedEnvironment::factory()->active()->create([
'workspace_id' => (int) $environmentA->workspace_id,
'external_id' => 'provider-stale-b',
]);
createUserWithTenant(tenant: $environmentB, user: $user, role: 'owner');
$connectionA = ProviderConnection::factory()->create([
'workspace_id' => (int) $environmentA->workspace_id,
'managed_environment_id' => (int) $environmentA->getKey(),
'display_name' => 'Spec314 Provider Stale A',
]);
$connectionB = ProviderConnection::factory()->create([
'workspace_id' => (int) $environmentB->workspace_id,
'managed_environment_id' => (int) $environmentB->getKey(),
'display_name' => 'Spec314 Provider Stale B',
]);
$this->actingAs($user);
setAdminPanelContext();
session()->put(WorkspaceContext::SESSION_KEY, (int) $environmentA->workspace_id);
$component = Livewire::actingAs($user)->test(ListProviderConnections::class);
$filtersSessionKey = $component->instance()->getTableFiltersSessionKey();
session()->put($filtersSessionKey, [
'tenant' => ['value' => (string) $environmentA->external_id],
]);
Livewire::actingAs($user)
->test(ListProviderConnections::class)
->assertCanSeeTableRecords([$connectionA, $connectionB]);
expect(data_get(session()->get($filtersSessionKey, []), 'tenant.value'))->toBeNull();
});

View File

@ -0,0 +1,94 @@
<?php
declare(strict_types=1);
use App\Filament\Pages\Reviews\CustomerReviewWorkspace;
use App\Models\ManagedEnvironment;
use App\Support\EnvironmentReviewStatus;
use App\Support\Workspaces\WorkspaceContext;
use Filament\Facades\Filament;
use Livewire\Livewire;
it('Spec314 customer review workspace sidebar entry is workspace wide', function (): void {
$environmentA = ManagedEnvironment::factory()->active()->create(['name' => 'Customer Review A']);
[$user, $environmentA] = createUserWithTenant(tenant: $environmentA, role: 'readonly');
$environmentB = ManagedEnvironment::factory()->active()->create([
'workspace_id' => (int) $environmentA->workspace_id,
'name' => 'Customer Review B',
]);
createUserWithTenant(tenant: $environmentB, user: $user, role: 'readonly');
$reviewA = composeEnvironmentReviewForTest($environmentA, $user, seedEnvironmentReviewEvidence($environmentA));
$reviewA->forceFill([
'status' => EnvironmentReviewStatus::Published->value,
'published_at' => now(),
'published_by_user_id' => (int) $user->getKey(),
])->save();
$reviewB = composeEnvironmentReviewForTest($environmentB, $user, seedEnvironmentReviewEvidence($environmentB));
$reviewB->forceFill([
'status' => EnvironmentReviewStatus::Published->value,
'published_at' => now(),
'published_by_user_id' => (int) $user->getKey(),
])->save();
Filament::setTenant($environmentA, true);
$url = CustomerReviewWorkspace::getUrl(panel: 'admin');
expect($url)->not->toContain('tenant=')
->and($url)->not->toContain('managed_environment_id');
$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(),
]);
Livewire::actingAs($user)
->test(CustomerReviewWorkspace::class)
->assertSet('tableFilters.managed_environment_id.value', null)
->assertCanSeeTableRecords([$environmentA->fresh(), $environmentB->fresh()]);
});
it('Spec314 customer review workspace ignores stale persisted environment filters on clean entry', function (): void {
$environmentA = ManagedEnvironment::factory()->active()->create();
[$user, $environmentA] = createUserWithTenant(tenant: $environmentA, role: 'readonly');
$environmentB = ManagedEnvironment::factory()->active()->create([
'workspace_id' => (int) $environmentA->workspace_id,
]);
createUserWithTenant(tenant: $environmentB, user: $user, role: 'readonly');
composeEnvironmentReviewForTest($environmentA, $user, seedEnvironmentReviewEvidence($environmentA))
->forceFill([
'status' => EnvironmentReviewStatus::Published->value,
'published_at' => now(),
'published_by_user_id' => (int) $user->getKey(),
])->save();
composeEnvironmentReviewForTest($environmentB, $user, seedEnvironmentReviewEvidence($environmentB))
->forceFill([
'status' => EnvironmentReviewStatus::Published->value,
'published_at' => now(),
'published_by_user_id' => (int) $user->getKey(),
])->save();
$this->actingAs($user);
setAdminPanelContext();
session()->put(WorkspaceContext::SESSION_KEY, (int) $environmentA->workspace_id);
$component = Livewire::actingAs($user)->test(CustomerReviewWorkspace::class);
$filtersSessionKey = $component->instance()->getTableFiltersSessionKey();
session()->put($filtersSessionKey, [
'managed_environment_id' => ['value' => (string) $environmentA->getKey()],
]);
$this->get(CustomerReviewWorkspace::getUrl(panel: 'admin'))
->assertOk()
->assertSee($environmentA->name)
->assertSee($environmentB->name);
expect(data_get(session()->get($filtersSessionKey, []), 'managed_environment_id.value'))->toBeNull();
});

View File

@ -0,0 +1,50 @@
<?php
declare(strict_types=1);
use App\Filament\Pages\Reviews\ReviewRegister;
use App\Models\ManagedEnvironment;
use App\Support\EnvironmentReviewStatus;
use App\Support\Workspaces\WorkspaceContext;
use Livewire\Livewire;
it('Spec314 review register ignores stale persisted environment filters on clean entry', function (): void {
$environmentA = ManagedEnvironment::factory()->active()->create();
[$user, $environmentA] = createUserWithTenant(tenant: $environmentA, role: 'readonly');
$environmentB = ManagedEnvironment::factory()->active()->create([
'workspace_id' => (int) $environmentA->workspace_id,
]);
createUserWithTenant(tenant: $environmentB, user: $user, role: 'readonly');
$reviewA = composeEnvironmentReviewForTest($environmentA, $user, seedEnvironmentReviewEvidence($environmentA));
$reviewA->forceFill([
'status' => EnvironmentReviewStatus::Published->value,
'published_at' => now(),
'published_by_user_id' => (int) $user->getKey(),
])->save();
$reviewB = composeEnvironmentReviewForTest($environmentB, $user, seedEnvironmentReviewEvidence($environmentB));
$reviewB->forceFill([
'status' => EnvironmentReviewStatus::Published->value,
'published_at' => now(),
'published_by_user_id' => (int) $user->getKey(),
])->save();
$this->actingAs($user);
setAdminPanelContext();
session()->put(WorkspaceContext::SESSION_KEY, (int) $environmentA->workspace_id);
$component = Livewire::actingAs($user)->test(ReviewRegister::class);
$filtersSessionKey = $component->instance()->getTableFiltersSessionKey();
session()->put($filtersSessionKey, [
'managed_environment_id' => ['value' => (string) $environmentA->getKey()],
]);
Livewire::actingAs($user)
->test(ReviewRegister::class)
->assertCanSeeTableRecords([$reviewA->fresh(), $reviewB->fresh()]);
expect(data_get(session()->get($filtersSessionKey, []), 'managed_environment_id.value'))->toBeNull();
});

View File

@ -0,0 +1,77 @@
<?php
declare(strict_types=1);
use App\Filament\Pages\Governance\DecisionRegister;
use App\Filament\Pages\Governance\GovernanceInbox;
use App\Filament\Pages\Monitoring\FindingExceptionsQueue;
use App\Filament\Pages\Reviews\CustomerReviewWorkspace;
use App\Filament\Resources\ProviderConnectionResource;
use App\Models\ManagedEnvironment;
use App\Support\Navigation\WorkspaceHubRegistry;
use App\Support\OperationRunLinks;
use App\Support\Workspaces\WorkspaceContext;
use Filament\Facades\Filament;
it('Spec314 sidebar from environment dashboard opens critical workspace hubs without environment context', function (string $hub, Closure $urlFactory): void {
$environment = ManagedEnvironment::factory()->active()->create([
'name' => 'Active Environment Context',
'external_id' => 'active-environment-context',
]);
[$user, $environment] = createUserWithTenant(tenant: $environment, role: 'owner');
$workspace = $environment->workspace()->firstOrFail();
Filament::setTenant($environment, true);
$url = $urlFactory($workspace);
expect(WorkspaceHubRegistry::hasForbiddenQuery($url))->toBeFalse();
$this->actingAs($user)
->withSession([
WorkspaceContext::SESSION_KEY => (int) $workspace->getKey(),
WorkspaceContext::LAST_TENANT_IDS_SESSION_KEY => [
(string) $workspace->getKey() => (int) $environment->getKey(),
],
])
->get($url)
->assertOk()
->assertSee($workspace->name)
->assertSee(__('localization.shell.no_environment_selected'))
->assertDontSee(__('localization.shell.environment_scope').': Active Environment Context');
})->with([
'provider connections' => ['provider_connections', fn ($workspace): string => ProviderConnectionResource::getUrl('index', panel: 'admin')],
'finding exceptions queue' => ['finding_exceptions_queue', fn ($workspace): string => FindingExceptionsQueue::getUrl(panel: 'admin')],
'operations' => ['operations', fn ($workspace): string => OperationRunLinks::index()],
'decision register' => ['decision_register', fn ($workspace): string => DecisionRegister::getUrl(panel: 'admin')],
'customer reviews' => ['customer_reviews', fn ($workspace): string => CustomerReviewWorkspace::getUrl(panel: 'admin')],
'governance inbox' => ['governance_inbox', fn ($workspace): string => GovernanceInbox::getUrl(panel: 'admin')],
]);
it('Spec314 remembered environment does not affect workspace hub sidebar urls or shell context', function (): void {
$rememberedEnvironment = ManagedEnvironment::factory()->active()->create([
'name' => 'Remembered Environment Boundary',
'external_id' => 'remembered-environment-boundary',
]);
[$user, $rememberedEnvironment] = createUserWithTenant(tenant: $rememberedEnvironment, role: 'owner');
$workspace = $rememberedEnvironment->workspace()->firstOrFail();
$this->actingAs($user);
Filament::setTenant($rememberedEnvironment, true);
session()->put(WorkspaceContext::SESSION_KEY, (int) $workspace->getKey());
session()->put(WorkspaceContext::LAST_TENANT_IDS_SESSION_KEY, [
(string) $workspace->getKey() => (int) $rememberedEnvironment->getKey(),
]);
$url = ProviderConnectionResource::getUrl('index', panel: 'admin');
expect($url)->not->toContain('managed_environment_id')
->and($url)->not->toContain('tenant=');
$this->get($url)
->assertOk()
->assertSee(__('localization.shell.no_environment_selected'))
->assertDontSee(__('localization.shell.environment_scope').': Remembered Environment Boundary');
});

View File

@ -0,0 +1,67 @@
# Requirements Checklist: Workspace Hub Navigation Context Contract
**Purpose**: Preparation-readiness checklist for Spec 314.
**Scope**: Spec Kit artifacts only. Runtime implementation runs later.
## Candidate Selection Gate
- [x] CHK001 The selected candidate was directly supplied by the user as Spec 314.
- [x] CHK002 The candidate is not an existing completed spec package.
- [x] CHK003 Related Specs 311, 312, and 313 are treated as completed historical context, not rewritten.
- [x] CHK004 The candidate aligns with Spec 313 recommended remediation order.
- [x] CHK005 The scope is narrowed to sidebar/global workspace hub navigation context hardening.
- [x] CHK006 Environment CTA standardization, universal clear-filter behavior, legacy cleanup, and durable browser guard work are deferred to Specs 315-318.
- [x] CHK007 The product-candidate queue note about no safe automatic target is respected because this is an explicit manual promotion from Spec 313 evidence.
## Spec Readiness
- [x] CHK008 `spec.md` exists.
- [x] CHK009 `plan.md` exists.
- [x] CHK010 `tasks.md` exists.
- [x] CHK011 Spec Candidate Check is completed.
- [x] CHK012 Functional requirements are behavior-oriented and testable.
- [x] CHK013 Acceptance criteria cover registry, sidebar URLs, shell/data scope, persisted filter safety, critical pages, tests, and browser verification.
- [x] CHK014 Hard cutover posture is explicit.
- [x] CHK015 No backwards compatibility layer or legacy sidebar/global query alias support is allowed.
- [x] CHK016 Decision Register clean workspace URL behavior is specified as in scope.
## Repo Alignment
- [x] CHK017 The plan identifies `WorkspaceSidebarNavigation`, `AdminPanelProvider`, `TenantPageCategory`, `NavigationScope`, `OperateHubShell`, `WorkspaceContext`, and `CanonicalAdminTenantFilterState`.
- [x] CHK018 The plan identifies critical pages/resources: Provider Connections, Finding Exceptions Queue, Operations, Decision Register, Customer Review Workspace, Review Register, Evidence Overview, Governance Inbox, Audit Log, and Alerts.
- [x] CHK019 Filament v5 / Livewire v4 compliance is stated.
- [x] CHK020 Provider registration location remains `apps/platform/bootstrap/providers.php`; no provider registration move is planned.
- [x] CHK021 Global search behavior is not modified.
- [x] CHK022 Destructive actions are not added or changed.
- [x] CHK023 Asset strategy is unchanged unless implementation unexpectedly registers Filament assets.
## Registry and Scope Readiness
- [x] CHK024 Workspace hub registry contents are listed.
- [x] CHK025 Explicit exclusions are listed for environment-owned pages, Stored Reports environment route, and Support Request action-only surface.
- [x] CHK026 The forbidden sidebar/global query params are named.
- [x] CHK027 Environment-like persisted filter keys are named.
- [x] CHK028 The registry proportionality review is completed.
## Test and Browser Readiness
- [x] CHK029 Required Pest Feature/Livewire tests are named.
- [x] CHK030 Focused Browser smoke scope is named.
- [x] CHK031 Planned validation commands are listed.
- [x] CHK032 Tests are scoped to contract behavior and do not require a broad suite rebaseline.
- [x] CHK033 Browser verification repeats focused Spec 313 critical flows.
## Safety
- [x] CHK034 No migration, seeder, package, env var, queue, scheduler, storage, or asset-bundle work is planned.
- [x] CHK035 Existing workspace and Managed Environment RBAC/isolation semantics remain required.
- [x] CHK036 Existing Provider Connections credential-adjacent action authorization remains untouched.
- [x] CHK037 The tasks include no-compatibility validation.
- [x] CHK038 The tasks require `git diff --check` and changed-file review.
## Review Outcome
- [x] CHK039 Review outcome class: `acceptable-special-case`.
- [x] CHK040 Workflow outcome: `keep`.
- [x] CHK041 Candidate Selection Gate passes.
- [x] CHK042 Spec Readiness Gate passes for preparation.

View File

@ -0,0 +1,416 @@
# Implementation Plan: Workspace Hub Navigation Context Contract
**Branch**: `314-workspace-hub-navigation-context-contract` | **Date**: 2026-05-16 | **Spec**: `specs/314-workspace-hub-navigation-context-contract/spec.md`
**Input**: Feature specification from `specs/314-workspace-hub-navigation-context-contract/spec.md`
## Summary
Implement a hard-cutover workspace hub navigation contract so sidebar/global navigation into workspace-scoped hubs always opens workspace-wide. The implementation creates a narrow workspace hub registry, centralizes clean sidebar/global URL generation, bypasses remembered Managed Environment state for workspace hub entry, and neutralizes environment-like persisted table filters where they can override sidebar/global intent.
Spec 313 is the source of truth for the problem evidence and high-risk pages. Spec 315/316/317 remain follow-ups and must not be pulled into this implementation.
## Technical Context
**Language/Version**: PHP 8.4.15, Laravel 12.52, Filament 5.2.1, Livewire 4.1.4
**Primary Dependencies**: Filament admin panel, Livewire, Pest 4.3.1, PostgreSQL through Sail
**Storage**: PostgreSQL; no migration or data model change planned
**Testing**: Pest Feature/Livewire tests plus focused Pest Browser smoke
**Validation Lanes**: confidence, browser, `git diff --check`
**Target Platform**: Laravel monolith in `apps/platform`
**Project Type**: web application
**Performance Goals**: navigation URL generation and registry lookups are static/lightweight; no additional DB queries in sidebar generation beyond existing visibility checks unless required for existing authorization
**Constraints**: no Graph calls, no queued work, no migrations, no legacy compatibility layer, no route alias support for sidebar/global workspace hub entry
**Scale/Scope**: all workspace hubs from Spec 313, with explicit exclusions for Stored Reports environment route and Support Request action-only surface
## UI / Surface Guardrail Plan
- **Guardrail scope**: workflow/context hardening for existing operator-facing workspace hubs.
- **Native vs custom classification summary**: native Filament navigation/pages/resources; no custom UI system.
- **Shared-family relevance**: navigation, shell/context state, table filter state, evidence/report viewers, governance lists.
- **State layers in scope**: shell, URL-query, page table filters, persisted/session filters, Livewire mount state, remembered environment state.
- **Audience modes in scope**: operator-MSP and support-platform only; customer/read-only rendered content is not changed except Customer Review Workspace default scope.
- **Decision/diagnostic/raw hierarchy plan**: no new decision hierarchy; preserve existing pages and make default scope truthful.
- **Raw/support gating plan**: unchanged.
- **One-primary-action / duplicate-truth control**: no action hierarchy change; prevent scope signal duplication by making shell and data scope agree.
- **Handling modes by drift class or surface**: hidden environment filters on workspace hub sidebar/global entry are hard-stop candidates; Environment CTA and full clear-filter drift are documented follow-ups.
- **Repository-signal treatment**: review-mandatory for any new page-specific context handling; prefer registry/helper path.
- **Special surface test profiles**: global-context-shell, monitoring-state-page, exception-coded-surface.
- **Required tests or manual smoke**: focused Feature/Livewire tests plus browser smoke for Spec 313 critical flows.
- **Exception path and spread control**: none for sidebar/global workspace hub entry.
- **Active feature PR close-out entry**: Guardrail / Exception / Smoke Coverage.
## Shared Pattern & System Fit
- **Cross-cutting feature marker**: yes.
- **Systems touched**:
- `apps/platform/app/Support/Navigation/WorkspaceSidebarNavigation.php`
- `apps/platform/app/Providers/Filament/AdminPanelProvider.php`
- `apps/platform/app/Support/Tenants/TenantPageCategory.php`
- `apps/platform/app/Support/Navigation/NavigationScope.php`
- `apps/platform/app/Support/OperateHub/OperateHubShell.php`
- `apps/platform/app/Support/Workspaces/WorkspaceContext.php`
- `apps/platform/app/Support/Filament/CanonicalAdminTenantFilterState.php`
- affected Filament pages/resources from Spec 313
- **Shared abstractions reused**: `TenantPageCategory`, `NavigationScope`, `WorkspaceContext`, `CanonicalAdminTenantFilterState`, existing Filament page/resource URL APIs where safe.
- **New abstraction introduced? why?**: likely `App\Support\Navigation\WorkspaceHubRegistry`, because more than two pages need the same route/page identity and environment-param stripping rule.
- **Why the existing abstraction was sufficient or insufficient**: existing helpers classify route scope or filter state, but no helper owns workspace hub identity, clean URL generation, and persisted environment-like filter neutralization together.
- **Bounded deviation / spread control**: registry may answer only workspace hub identity and entry cleanup rules; it must not become a product IA framework or provider registry.
## OperationRun UX Impact
- **Touches OperationRun start/completion/link UX?**: no.
- **Central contract reused**: `OperationRunLinks::index()` remains the Operations collection link helper; changes may be limited to clean workspace hub navigation.
- **Delegated UX behaviors**: N/A.
- **Surface-owned behavior kept local**: Operations table/filter UI remains local to `Operations`.
- **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**: ProviderConnection record actions, credentials, health/verification actions remain provider-owned and unchanged.
- **Platform-core seams**: workspace hub entry contract, workspace navigation URLs, shell/environment context, query-filter stripping.
- **Neutral platform terms / contracts preserved**: Workspace, Managed Environment, provider connection, workspace hub.
- **Retained provider-specific semantics and why**: Provider Connections may retain Microsoft-specific provider values where already stored; this spec must not add provider-specific platform navigation truth.
- **Bounded extraction or follow-up path**: document remaining explicit environment CTA/filter behavior for Spec 315; legacy tenant naming cleanup for Spec 317.
## Constitution Check
- Inventory-first: no inventory truth changes.
- Read/write separation: read/navigation only; no mutating behavior added.
- Graph contract path: no Graph calls.
- Deterministic capabilities: existing capability resolvers remain in force.
- RBAC-UX: workspace membership and capability checks remain server-side; non-members remain 404.
- Workspace isolation: workspace remains selected context and workspace-wide queries must enforce workspace membership.
- Tenant isolation: tenant-owned rows in workspace hubs must be limited to entitled Managed Environments.
- Run observability: no long-running, remote, queued, or scheduled work.
- Automation: N/A.
- Data minimization: no new sensitive data storage.
- Test governance: Feature/Livewire + browser lanes are explicit and bounded.
- Proportionality: new registry is justified by current Spec 313 evidence and more than two concrete surfaces.
- No premature abstraction: registry is allowed because navigation/scope correctness affects isolation and there are many concrete surfaces.
- Persisted truth: no persisted truth added.
- Behavioral state: no new status/state family.
- UI semantics: no badge/status taxonomy.
- Shared pattern first: registry attaches to existing navigation/shell/filter helpers.
- Provider boundary: hardens platform-core navigation and removes provider-adjacent hidden environment inference.
- V1 explicitness / few layers: direct registry + helper updates, no framework.
- Spec discipline / bloat check: proportionality review is in `spec.md`.
- Filament-native UI: existing native Filament pages/resources remain; no styling changes.
## Test Governance Check
- **Test purpose / classification by changed surface**:
- Unit/Feature: registry contents and URL cleanup helpers.
- Feature/Livewire: shell/data/filter state for workspace hubs.
- Browser: critical Spec 313 flows across navigation/reload/back-forward.
- **Affected validation lanes**: confidence and browser.
- **Why this lane mix is narrowest sufficient proof**: helper behavior is deterministic; Livewire proves table state; browser catches hydration/session/back-forward drift found by Spec 313.
- **Narrowest proving commands**:
- `cd apps/platform && ./vendor/bin/sail artisan test --filter=WorkspaceHub`
- `cd apps/platform && ./vendor/bin/sail artisan test --filter=Spec314WorkspaceHubNavigationContextSmoke`
- `git diff --check`
- **Fixture / helper / factory / seed / context cost risks**: tests need explicit workspaces, two Managed Environments, user membership/capability setup, and seeded rows for Provider Connections/Operations/Decision/Register pages where proving row scope.
- **Expensive defaults or shared helper growth introduced?**: no implicit global fixture widening; any new setup helper must be explicit and local to Spec 314 tests unless already reusable.
- **Heavy-family additions, promotions, or visibility changes**: one explicit browser smoke only.
- **Surface-class relief / special coverage rule**: no visual/styling test; global-context-shell and monitoring-state-page state coverage required.
- **Closing validation and reviewer handoff**: reviewer checks no broad rebaseline, no unbounded browser family, no hidden helper default expansion.
- **Budget / baseline / trend follow-up**: none expected.
- **Review-stop questions**: lane fit, hidden stale environment state, workspace entitlement leakage, compatibility shims.
- **Escalation path**: document-in-feature.
- **Active feature PR close-out entry**: Guardrail / Exception / Smoke Coverage.
- **Why no dedicated follow-up spec is needed**: sidebar/global workspace hub contract is the dedicated slice; CTA, clear-filter, legacy cleanup, and durable browser guards already have follow-up specs 315-318.
## Project Structure
### Documentation (this feature)
```text
specs/314-workspace-hub-navigation-context-contract/
|-- spec.md
|-- plan.md
|-- tasks.md
`-- checklists/
`-- requirements.md
```
### Source Code (repository root)
Likely runtime surfaces for later implementation:
```text
apps/platform/app/
|-- Providers/Filament/AdminPanelProvider.php
|-- Support/
| |-- Navigation/WorkspaceHubRegistry.php
| |-- Navigation/WorkspaceSidebarNavigation.php
| |-- Navigation/NavigationScope.php
| |-- Tenants/TenantPageCategory.php
| |-- OperateHub/OperateHubShell.php
| |-- Workspaces/WorkspaceContext.php
| |-- Filament/CanonicalAdminTenantFilterState.php
| |-- ManagedEnvironmentLinks.php
| `-- OperationRunLinks.php
|-- Filament/
| |-- Resources/ProviderConnectionResource.php
| `-- Pages/
| |-- Monitoring/Operations.php
| |-- Monitoring/FindingExceptionsQueue.php
| |-- Monitoring/EvidenceOverview.php
| |-- Governance/GovernanceInbox.php
| |-- Governance/DecisionRegister.php
| `-- Reviews/CustomerReviewWorkspace.php
`-- Http/Controllers/
|-- ClearEnvironmentContextController.php
`-- OpenFindingExceptionsQueueController.php
```
Likely tests for later implementation:
```text
apps/platform/tests/
|-- Feature/Navigation/
| |-- WorkspaceHubRegistryTest.php
| `-- WorkspaceHubSidebarUrlContractTest.php
|-- Feature/Workspaces/
| `-- WorkspaceHubContextContractTest.php
|-- Feature/ProviderConnections/
| `-- ProviderConnectionsWorkspaceHubContractTest.php
|-- Feature/Monitoring/
| |-- FindingExceptionsQueueWorkspaceHubContractTest.php
| |-- OperationsWorkspaceHubContractTest.php
| `-- EvidenceOverviewWorkspaceHubContractTest.php
|-- Feature/Governance/
| `-- DecisionRegisterWorkspaceHubContractTest.php
|-- Feature/Reviews/
| `-- CustomerReviewWorkspaceHubContractTest.php
`-- Browser/
`-- Spec314WorkspaceHubNavigationContextSmokeTest.php
```
**Structure Decision**: Laravel monolith under `apps/platform`; add one support class under `App\Support\Navigation` if implementation confirms no existing support class can own the contract cleanly.
## Complexity Tracking
| Violation | Why Needed | Simpler Alternative Rejected Because |
|---|---|---|
| New registry/support contract | Workspace hub identity and cleanup rules cross many pages and URL helpers | Page-by-page patches would preserve the fragmentation Spec 313 identified |
## Proportionality Review
- **Current operator problem**: stale environment context can silently filter workspace hubs reached from sidebar/global navigation.
- **Existing structure is insufficient because**: `TenantPageCategory`, `NavigationScope`, `WorkspaceSidebarNavigation`, `OperateHubShell`, page URL builders, and table filter helpers each own partial behavior.
- **Narrowest correct implementation**: one registry that lists workspace hubs and forbidden environment query/filter keys, then use it in central navigation and shared filter cleanup.
- **Ownership cost created**: maintain registry when workspace hubs are added/reclassified; keep contract tests updated.
- **Alternative intentionally rejected**: page-local query stripping, because Provider Connections, Operations, Finding Exceptions, Reviews, Evidence, Governance, Audit, and Alerts need the same rule.
- **Release truth**: current-release truth from Spec 313 browser evidence.
## Technical Approach
1. Add or formalize `WorkspaceHubRegistry`.
- Registry answers whether a route/page is a workspace hub.
- Registry exposes the forbidden sidebar/global environment query keys.
- Registry exposes environment-like persisted filter keys to neutralize.
- Registry includes all in-scope Spec 313 workspace hubs and explicit exclusions in tests/docs.
2. Centralize clean workspace hub sidebar/global URL generation.
- Update `WorkspaceSidebarNavigation` and `AdminPanelProvider` navigation items to use clean URL generation.
- Bypass resource/page `getUrl()` behavior if it injects remembered environment state.
- Ensure `OperationRunLinks::index()` is called for workspace-wide Operations without environment params.
3. Stop remembered environment from becoming workspace hub default scope.
- Review `OperateHubShell`, `TenantPageCategory`, `NavigationScope`, `WorkspaceContext`, and Provider Connection tenant inference.
- Keep remembered environment usable only for switcher convenience.
4. Neutralize persisted environment-like filters on sidebar/global workspace hub entry.
- Extend or wrap `CanonicalAdminTenantFilterState` rather than adding one-off page patches where possible.
- Apply targeted page fixes only where current Livewire/table lifecycle makes a shared helper insufficient.
5. Fix critical offenders.
- Provider Connections: no sidebar/global `managed_environment_id`; workspace-wide default query.
- Finding Exceptions Queue: no sidebar/global `tenant`; no stale persisted environment filter.
- Operations: no sidebar/global `managed_environment_id`, `tenant_scope`, or table filter mismatch.
- Decision Register: clean workspace URL opens for authorized users without environment filter.
- Customer Reviews/Reviews/Evidence/Governance/Audit/Alerts: sidebar/global entry is workspace-wide and safe.
6. Browser verify focused Spec 313 flows.
## Existing Repository Surfaces
Spec 313 code ownership map and current code inspection identify these hotspots:
- `WorkspaceSidebarNavigation::build()` emits many workspace hub URLs through page/resource helpers.
- `AdminPanelProvider::navigationItems()` duplicates several workspace hub navigation entries.
- `ProviderConnectionResource::getUrl()` currently can add `managed_environment_id` from scoped/remembered context.
- `ProviderConnectionResource::resolveContextTenantExternalId()` can use `WorkspaceContext::lastTenantId()`.
- `ManagedEnvironmentLinks::providerConnectionsUrl()` adds `managed_environment_id` from an environment.
- `OperationRunLinks::index()` adds `managed_environment_id`, `tenant_scope`, `activeTab`, `problemClass`, and nested `tableFilters`.
- `FindingExceptionsQueue` has `tenant` query state and a clear action that only removes table state.
- `OpenFindingExceptionsQueueController` redirects with `tenant=<external id>`.
- `CustomerReviewWorkspace` accepts `tenant` or `managed_environment_id`, converts it to a table filter, and clear only removes table filters.
- `EvidenceOverview` is a reference pattern because clear redirects to a clean URL and resets persisted search/filter state.
- `GovernanceInbox` and `DecisionRegister` accept environment query filters and use clean links for clear actions.
- `DecisionRegister::canAccess()` and `ensureRegisterIsVisible()` are currently data/query dependent and can reject clean workspace entry.
- `CanonicalAdminTenantFilterState::sync()` can persist or forget tenant-sensitive filters based on active shell tenant.
## Domain / Model Implications
- No model changes.
- No persisted entity changes.
- No migration.
- No seed data change.
- Workspace hub registry is code-level contract only.
## UI / Filament Implications
- Existing Filament pages/resources stay native.
- No page redesign.
- No new panels.
- Admin panel path remains `/admin`.
- Panel provider registration remains in `apps/platform/bootstrap/providers.php`.
- Global search behavior is unchanged; do not enable global search for Provider Connections or other sensitive resources.
- Destructive actions are not added or changed.
- No new assets expected.
## Livewire Implications
- Livewire page `mount()` and table hydration order matter for persisted filters.
- Tests must cover session-persisted table filters and `tableDeferredFilters` where pages use them.
- Livewire v4.1.4 APIs only.
- Avoid adding broad Livewire state machinery; use focused page/helper changes.
## RBAC / Policy Implications
- Preserve existing workspace membership and capability checks.
- Workspace-wide data scope must still apply Managed Environment access entitlement.
- Do not use hidden environment filters as authorization.
- Decision Register must distinguish workspace authorization from empty workspace state.
- Provider Connections credential-adjacent actions remain protected by existing policies/capabilities.
## Audit / Logging / Evidence Implications
- No new audit logging required for navigation/read behavior.
- Existing audit logs for mutating actions must remain unchanged.
- Browser verification screenshots may be stored under `specs/314-workspace-hub-navigation-context-contract/artifacts/screenshots/` if useful.
## Data / Migration Implications
- No migrations.
- No seeders.
- No backfills.
- No compatibility fields.
- No data cleanup.
## Implementation Phases
1. **Contract and tests first**
- Create registry contract tests for workspace hub entries and forbidden query params.
- Add sidebar/global URL tests covering the full registry.
- Add remembered-environment and persisted-filter regression tests for critical hubs.
2. **Registry and clean URL generation**
- Implement `WorkspaceHubRegistry`.
- Update `WorkspaceSidebarNavigation` and `AdminPanelProvider` to use registry-backed clean URL generation.
- Bypass page/resource `getUrl()` methods only when they inject environment scope.
3. **Shell and remembered environment isolation**
- Adjust shell/context resolution so workspace hubs from sidebar/global entry are tenantless.
- Ensure remembered environment is only switcher convenience for workspace hubs.
4. **Persisted filter safety**
- Extend shared helper for environment-like filter neutralization.
- Apply critical page integrations for Provider Connections, Finding Exceptions Queue, Operations, Customer Reviews, Reviews, Evidence, Governance Inbox, Decision Register, Audit Log, and Alerts.
5. **Critical page fixes**
- Fix Provider Connections, Finding Exceptions Queue, Operations, Decision Register, Customer Review Workspace, Reviews, Evidence Overview.
6. **Validation and browser verification**
- Run focused tests.
- Run focused browser smoke flows from Spec 313.
- Record remaining follow-ups for 315/316/317.
## Test Strategy
Required tests:
- `it_workspace_hub_sidebar_urls_do_not_include_environment_query_params`
- `it_sidebar_from_environment_dashboard_opens_workspace_hubs_without_environment_context`
- `it_remembered_environment_does_not_affect_workspace_hub_sidebar_urls`
- `it_provider_connections_sidebar_entry_is_workspace_wide`
- `it_finding_exceptions_queue_sidebar_entry_is_workspace_wide`
- `it_operations_sidebar_entry_does_not_carry_managed_environment_filter`
- `it_decision_register_clean_workspace_url_opens_for_authorized_workspace_user`
- `it_persisted_environment_table_filters_do_not_override_workspace_hub_sidebar_intent`
Recommended exact lanes:
```bash
cd apps/platform && ./vendor/bin/sail artisan test --filter=WorkspaceHub
cd apps/platform && ./vendor/bin/sail artisan test --filter=ProviderConnectionsWorkspaceHub
cd apps/platform && ./vendor/bin/sail artisan test --filter=FindingExceptionsQueueWorkspaceHub
cd apps/platform && ./vendor/bin/sail artisan test --filter=OperationsWorkspaceHub
cd apps/platform && ./vendor/bin/sail artisan test --filter=DecisionRegisterWorkspaceHub
cd apps/platform && ./vendor/bin/sail artisan test --filter=CustomerReviewWorkspaceHub
cd apps/platform && ./vendor/bin/sail artisan test --filter=EvidenceOverviewWorkspaceHub
cd apps/platform && ./vendor/bin/sail artisan test --filter=Spec314WorkspaceHubNavigationContextSmoke
git diff --check
```
## Browser Verification Plan
Required pages:
- Provider Connections
- Finding Exceptions Queue
- Operations
- Decision Register
- Customer Reviews
- Evidence
- Reviews
- Governance Inbox
Required flows:
- Environment Dashboard -> Sidebar -> Page
- Workspace Overview -> Sidebar -> Page
- Reload after sidebar entry
- Back/forward for Provider Connections, Customer Reviews, Finding Exceptions Queue, Operations, and Decision Register
Verify:
- shell context
- URL query params
- visible filters
- table filters
- row scope where seeded data proves it
Screenshots may be saved under:
```text
specs/314-workspace-hub-navigation-context-contract/artifacts/screenshots/
```
## Rollout Considerations
- Hard cutover only; no compatibility layer.
- No env vars.
- No migrations.
- No queues or scheduler changes.
- No storage/volume changes.
- Staging validation should run focused tests and browser smoke before production promotion once production exists.
## Risk Controls
- Add registry contract tests so future workspace hubs cannot leak environment query params silently.
- Keep Environment CTA filters out of this spec except where they block sidebar/global distinction.
- Preserve environment-owned route behavior.
- Keep Provider Connections action authorization untouched.
- Record any remaining URL/filter drift as 315/316/317 follow-up, not hidden implementation scope.
## Spec Preparation Notes
- Candidate was directly supplied by the user and is supported by Spec 313.
- `docs/product/spec-candidates.md` currently says no safe automatic next-best-prep target remains, but this is an explicit manual promotion from completed Spec 313 audit evidence.
- Existing Specs 311, 312, and 313 are completed/historical context and are not modified by this preparation.
- The repository provides Bash helpers for feature creation and plan setup, but no local `tasks` or `analyze` command was found. Preparation analysis is performed by checklist and consistency checks in this package.

View File

@ -0,0 +1,470 @@
# Feature Specification: Workspace Hub Navigation Context Contract
**Feature Branch**: `314-workspace-hub-navigation-context-contract`
**Created**: 2026-05-16
**Status**: Draft
**Input**: User-supplied Spec 314 draft plus Spec 313 audit results.
**Runtime posture**: Hard cutover. No backwards compatibility layer. No legacy query alias support for sidebar/global workspace hub entry.
## Spec Candidate Check *(mandatory - SPEC-GATE-001)*
- **Problem**: Workspace-scoped admin hubs can inherit stale Managed Environment context from shell state, remembered environment state, query params, Filament tenant state, Livewire table state, and persisted filters.
- **Today's failure**: Spec 313 browser evidence shows operators can see "No environment selected" or "All environments" while rows remain environment-filtered. Provider Connections, Finding Exceptions Queue, Customer Review Workspace, Operations, Review Register, Governance Inbox, and Decision Register are the highest-risk surfaces.
- **User-visible improvement**: Sidebar/global navigation into workspace hubs becomes deterministic: selected Workspace remains active, active Managed Environment context is cleared, URLs are clean, and rows are workspace-wide by default.
- **Smallest enterprise-capable version**: One canonical workspace hub contract used by sidebar/global navigation and the affected workspace hub pages to strip environment query state and neutralize environment-like persisted filters on workspace hub entry.
- **Explicit non-goals**: No Environment Dashboard CTA contract, no universal clear-filter contract, no product IA redesign, no route alias compatibility, no data migration, no seed/backfill work, no broad environment-page conversion.
- **Permanent complexity imported**: A narrow workspace hub registry/contract and focused Pest/Livewire/Browser coverage. No new persisted tables, models, enums, status families, OperationRun types, or asset bundles.
- **Why now**: Spec 313 completed browser verification and explicitly recommends Spec 314 first because sidebar/global workspace hub entry must be safe before Environment CTA filters and clear-filter semantics are standardized.
- **Why not local**: Page-by-page URL fixes would keep context ownership fragmented. The failure crosses navigation generation, shell resolution, query parameters, resource `getUrl()` overrides, and persisted Filament table filters.
- **Approval class**: Core Enterprise.
- **Red flags triggered**: New registry abstraction; provider-adjacent Provider Connections scope; broad surface count. Defense: the abstraction is narrow, current-release, and required for workspace isolation and navigation safety across more than two concrete surfaces.
- **Score**: Nutzen: 2 | Dringlichkeit: 2 | Scope: 2 | Komplexitaet: 1 | Produktnaehe: 2 | Wiederverwendung: 2 | **Gesamt: 11/12**
- **Decision**: approve.
## Spec Scope Fields *(mandatory)*
- **Scope**: canonical-view.
- **Primary routes**:
- `/admin`
- `/admin/workspaces/{workspace}/overview`
- `/admin/workspaces/{workspace}/operations`
- `/admin/provider-connections`
- `/admin/finding-exceptions/queue`
- `/admin/evidence/overview`
- `/admin/reviews`
- `/admin/reviews/workspace`
- `/admin/governance/inbox`
- `/admin/governance/decisions`
- `/admin/audit-log`
- `/admin/alerts`
- `/admin/alerts/alert-deliveries`
- `/admin/alerts/alert-rules`
- `/admin/alerts/alert-destinations`
- `/admin/settings/workspace`
- `/admin/workspaces`
- `/admin/workspaces/{workspace}/environments`
- **Data ownership**: No ownership model changes. Workspace-owned and tenant-owned records keep existing ownership. Workspace hub views must enforce workspace membership and managed-environment entitlement before revealing tenant-owned rows.
- **RBAC**: Workspace membership remains required. Existing capability checks remain in force. Non-member workspace/environment access remains deny-as-not-found. Member-but-missing-capability remains 403 where the current policy/capability contract uses 403.
For canonical-view specs:
- **Default filter behavior when tenant-context is active**: Sidebar/global workspace hub entry ignores active or remembered Managed Environment context and opens workspace-wide. Explicit Environment CTA filtering remains allowed only where already present and is standardized later in Spec 315.
- **Explicit entitlement checks preventing cross-tenant leakage**: Workspace-wide data queries must include current workspace scope and must limit environment-bound rows to the Managed Environments the actor is entitled to access.
## Summary
Implement the canonical Workspace Hub Navigation Context Contract for TenantPilot.
Workspace-scoped hubs must open workspace-wide when reached through sidebar/global navigation. They must not inherit Managed Environment context through route/query params, Filament tenant state, remembered environment state, Livewire mounted state, persisted Filament table filters, or page-specific URL builders.
Canonical behavior:
```text
Sidebar / global navigation -> workspace-scoped hub -> workspace-wide state
```
Environment-specific filtering from Environment Dashboard CTAs is not productized in this spec. It belongs to Spec 315.
## Product Context
TenantPilot is workspace-first. Workspace is the primary operating context. Managed Environment is a secondary operational context inside a Workspace.
Workspace hubs are portfolio/workspace surfaces. They may display tenant-owned rows, but their entry point is workspace-wide and entitlement-filtered, not environment-owned.
Environment pages remain environment-owned and are not converted by this spec.
## Cross-Cutting / Shared Pattern Reuse *(mandatory)*
- **Cross-cutting feature?**: yes.
- **Interaction class(es)**: navigation, shell/context state, table filter state, canonical links.
- **Systems touched**: `WorkspaceSidebarNavigation`, `AdminPanelProvider`, `TenantPageCategory`, `NavigationScope`, `OperateHubShell`, `WorkspaceContext`, `CanonicalAdminTenantFilterState`, page/resource URL builders, and high-risk Filament pages/resources from Spec 313.
- **Existing pattern(s) to extend**: existing workspace navigation builder, `TenantPageCategory::WorkspaceWideSurface`, `NavigationScope`, `WorkspaceContext`, and `CanonicalAdminTenantFilterState`.
- **Shared contract / presenter / builder / renderer to reuse**: reuse existing navigation and shell/context helpers where safe; introduce one narrow `WorkspaceHubRegistry` only for workspace hub identity and environment-param/filter neutralization rules.
- **Why the existing shared path is sufficient or insufficient**: existing paths know pieces of the contract but no single path answers whether a page is a workspace hub, whether environment params must be stripped, and whether environment-like persisted filters must be ignored on sidebar/global entry.
- **Allowed deviation and why**: introduce a narrow registry because more than two concrete workspace hubs need the same decision and because isolation/navigation correctness is security-relevant current-release truth.
- **Consistency impact**: all sidebar/global workspace hub URLs must use the same forbidden query-param list and the same remembered-environment bypass behavior.
- **Review focus**: verify no page-specific `getUrl()` override or helper can inject environment params into sidebar/global workspace hub URLs.
## OperationRun UX Impact *(mandatory)*
- **Touches OperationRun start/completion/link UX?**: no OperationRun start, completion, queuing, dedupe, status, outcome, terminal notification, or run lifecycle semantics are changed.
- **Shared OperationRun UX contract/layer reused**: `OperationRunLinks::index()` may be adjusted only for clean workspace hub navigation; OperationRun start UX is not in scope.
- **Delegated start/completion UX behaviors**: N/A.
- **Local surface-owned behavior that remains**: existing Operations list and operation detail behavior remain.
- **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, Provider Connections and provider-adjacent links are in scope.
- **Boundary classification**: mixed. Provider Connections records are provider-adjacent, but the navigation context contract is platform-core.
- **Seams affected**: Provider Connection list URL generation, provider connection environment prefilter behavior, `managed_environment_id` query semantics, remembered environment fallback.
- **Neutral platform terms preserved or introduced**: Workspace, Managed Environment, provider connection, workspace hub.
- **Provider-specific semantics retained and why**: Microsoft-specific provider details remain inside existing provider connection records/actions. This spec must not introduce Microsoft-shaped platform navigation truth.
- **Why this does not deepen provider coupling accidentally**: the contract removes hidden environment/provider-specific query inference from workspace hub navigation instead of adding a provider framework.
- **Follow-up path**: Spec 315 standardizes explicit Environment CTA filtering; Spec 317 removes remaining legacy tenant/environment naming and alias drift.
## 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 |
|---|---|---|---|---|---|---|
| Workspace hub sidebar/global entry | yes | Native Filament navigation + existing shell | navigation / shell / table filters | shell, URL-query, page, session filters | no | State-contract hardening only; no redesign. |
| Provider Connections / Integrations | yes | Native Filament Resource | navigation / provider connection list | URL-query, table query, remembered env | no | Must open workspace-wide from sidebar/global entry. |
| Finding Exceptions Queue | yes | Native Filament Page | navigation / queue | URL-query, table filters, session filters | no | Sidebar/global entry must not use `tenant`. |
| Operations | yes | Native Filament Page | monitoring state page | URL-query, table filters, shell | no | Sidebar/global entry must not carry `managed_environment_id`. |
| Decision Register / Governance Inbox | yes | Native Filament Pages | governance navigation | URL-query, access check, filters | no | Clean workspace Decision Register URL must open for authorized users. |
| Customer Reviews / Reviews / Evidence | yes | Native Filament Pages | reporting / evidence viewers | URL-query, table filters, session filters | no | Sidebar/global entry must neutralize stale environment filters. |
## 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 |
|---|---|---|---|---|---|---|---|
| Workspace hub sidebar/global navigation | Secondary Context | Operator moves between workspace work areas | Workspace selected, no environment selected, workspace-wide rows | Existing page diagnostics | Not a new decision surface; it ensures existing surfaces start from truthful scope | Workspace-first navigation | Removes hidden stale environment state from routine navigation. |
| Critical workspace hubs | Existing role unchanged | Existing per-page decisions remain | Existing per-page content, now with correct default scope | Existing page details | Spec changes entry scope, not page purpose | Workspace hubs remain workspace-wide | Prevents operators from reconstructing hidden filters. |
## 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 entry state | operator-MSP, support-platform | Workspace selected; no environment selected; no hidden environment filter | Existing filter controls and page diagnostics | Existing support/raw areas unchanged | Open selected workspace hub | Raw query/table state is not surfaced as a product feature | Shell and data scope must agree. |
## 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 |
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| Workspace hub navigation contract | Navigation / Context | Workspace hub entry | Open hub workspace-wide | Existing page-defined model | Existing behavior | Existing page-defined placement | Existing page-defined placement | See registry table | Existing page-defined routes | Workspace active, Environment none | Workspace hub | No hidden environment filter from sidebar/global entry | 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 entry state | MSP operator | Move from any page to a workspace hub without stale environment scope | Navigation/context contract | Which workspace context am I operating in? | Current workspace, no selected environment, workspace-wide list state | Existing page-specific diagnostics | Scope only | N/A | Open hub | None added or changed |
## Proportionality Review *(mandatory when structural complexity is introduced)*
- **New source of truth?**: no persisted source of truth.
- **New persisted entity/table/artifact?**: no.
- **New abstraction?**: yes, a narrow workspace hub registry/contract.
- **New enum/state/reason family?**: no.
- **New cross-domain UI framework/taxonomy?**: no.
- **Current operator problem**: sidebar/global navigation into workspace hubs can silently inherit stale environment context and show filtered rows under a workspace-wide shell.
- **Existing structure is insufficient because**: page category, shell resolution, navigation builders, URL helpers, and table filter helpers each own partial state decisions; no single shared contract owns workspace hub identity and entry cleanup.
- **Narrowest correct implementation**: one static/support registry listing workspace hub routes/pages and the environment-like query/filter keys to strip or ignore on sidebar/global entry.
- **Ownership cost**: maintain hub membership when workspace hubs are added or reclassified; add contract tests that fail when a workspace hub URL leaks environment-like params.
- **Alternative intentionally rejected**: page-by-page patches without a registry, because Spec 313 proves the same drift across Provider Connections, Operations, Finding Exceptions, Reviews, Evidence, Governance, Alerts, and Audit.
- **Release truth**: current-release truth. Spec 313 browser evidence makes this a present safety issue, not future preparation.
### Compatibility posture
This feature assumes a pre-production environment. Backward compatibility, legacy aliases, migration shims, historical fixtures, and compatibility-specific tests are out of scope. Canonical replacement is preferred over preservation.
No legacy `tenant`, `tenant_id`, `managed_environment_id`, `environment_id`, `tenant_scope`, or `tableFilters` behavior is preserved for sidebar/global workspace hub entry.
## Testing / Lane / Runtime Impact *(mandatory for runtime behavior changes)*
- **Test purpose / classification**: Feature, Filament/Livewire, and Browser.
- **Validation lane(s)**: confidence for focused Feature/Livewire tests; browser for critical user flows; `git diff --check`; focused route/navigation tests.
- **Why this classification and these lanes are sufficient**: URL generation and shell/data scope can be proven with Feature and Livewire tests; browser verification is required because Spec 313 showed persisted Filament table state, reload, and back/forward behavior can drift after hydration.
- **New or expanded test families**: bounded Spec 314 navigation context tests and one focused browser smoke. No broad suite rebaseline.
- **Fixture / helper cost impact**: tests may need explicit workspace, two Managed Environments, memberships, and rows for the covered pages. Helpers must stay feature-local or opt-in.
- **Heavy-family visibility / justification**: Browser coverage is explicit and limited to critical flows inherited from Spec 313.
- **Special surface test profile**: global-context-shell + monitoring-state-page + exception-coded-surface.
- **Standard-native relief or required special coverage**: no styling/layout verification; special state-contract coverage required.
- **Reviewer handoff**: reviewers must confirm lane fit, no hidden heavy helper defaults, and no broad browser family beyond the named Spec 314 smoke.
- **Budget / baseline / trend impact**: none expected; document if browser lane expands materially.
- **Escalation needed**: document-in-feature.
- **Active feature PR close-out entry**: Guardrail / Exception / Smoke Coverage.
- **Planned validation commands**:
- `cd apps/platform && ./vendor/bin/sail artisan test --filter=WorkspaceHub`
- `cd apps/platform && ./vendor/bin/sail artisan test --filter=ProviderConnectionsWorkspaceHub`
- `cd apps/platform && ./vendor/bin/sail artisan test --filter=FindingExceptionsQueueWorkspaceHub`
- `cd apps/platform && ./vendor/bin/sail artisan test --filter=OperationsWorkspaceHub`
- `cd apps/platform && ./vendor/bin/sail artisan test --filter=DecisionRegisterWorkspaceHub`
- `cd apps/platform && ./vendor/bin/sail artisan test --filter=CustomerReviewWorkspaceHub`
- `cd apps/platform && ./vendor/bin/sail artisan test --filter=EvidenceOverviewWorkspaceHub`
- `cd apps/platform && ./vendor/bin/sail artisan test --filter=Spec314WorkspaceHubNavigationContextSmoke`
- `git diff --check`
## User Scenarios & Testing *(mandatory)*
### User Story 1 - Sidebar workspace hubs always open workspace-wide (Priority: P1)
As an MSP operator working inside an active Managed Environment, I can click a workspace-scoped hub in the sidebar/global navigation and land on a workspace-wide version of that hub without hidden environment filters.
**Why this priority**: This removes the most dangerous Spec 313 mismatch: shell says no environment selected while page data is still environment-filtered.
**Independent Test**: From an Environment Dashboard state with a remembered environment, generate/open each registry workspace hub sidebar URL and assert it has no forbidden environment query params and renders with no active environment shell context.
**Acceptance Scenarios**:
1. **Given** a selected Workspace and remembered Managed Environment, **When** the operator opens Provider Connections through sidebar/global navigation, **Then** the URL is clean and provider connection rows are workspace-wide across entitled environments.
2. **Given** a selected Workspace and active Environment Dashboard state, **When** the operator opens Finding Exceptions Queue, Operations, Decision Register, Customer Reviews, Reviews, Evidence, Governance Inbox, Audit Log, Alerts, Workspace Settings, or Manage Workspaces through sidebar/global navigation, **Then** no environment query params or environment-like table filters define the default scope.
### User Story 2 - Remembered environment is not a workspace hub data boundary (Priority: P1)
As an MSP operator, my last used Managed Environment may still help the switcher, but it must not silently filter workspace hubs.
**Why this priority**: Spec 313 shows remembered environment state can leak into links and shell decisions.
**Independent Test**: Seed two environments in one workspace, remember Environment A, then open workspace hub URLs and assert Environment A is not used as a URL param, shell context, default table filter, or data query boundary.
**Acceptance Scenarios**:
1. **Given** Environment A is remembered for a workspace, **When** sidebar/global Provider Connections URL is generated, **Then** it does not include `managed_environment_id`.
2. **Given** Environment A is remembered and rows exist for Environment A and B, **When** a workspace hub opens from sidebar/global navigation, **Then** entitled rows are not filtered to Environment A.
### User Story 3 - Persisted environment filters cannot override sidebar intent (Priority: P2)
As an operator returning to a workspace hub after previous filtering, sidebar/global entry must be workspace-wide even if a page persisted an environment-like table filter earlier.
**Why this priority**: Spec 313 found reload and back/forward behavior can revive stale filters.
**Independent Test**: Persist an environment table filter for Customer Reviews, Finding Exceptions Queue, Evidence, Provider Connections, and Operations where applicable; navigate away; then enter through sidebar/global clean URL and assert environment-like persisted filters are absent or ignored.
**Acceptance Scenarios**:
1. **Given** Customer Reviews was filtered to Environment A, **When** the operator enters Customer Reviews through sidebar/global navigation, **Then** the page opens workspace-wide and reload remains workspace-wide.
2. **Given** Evidence Overview or Finding Exceptions Queue had a persisted environment-like table filter, **When** the operator enters through workspace hub navigation, **Then** that filter does not define the default row scope.
### User Story 4 - Decision Register clean workspace URL is a valid workspace hub entry (Priority: P2)
As an authorized workspace user, I can open Decision Register through its clean workspace URL without needing an environment filter.
**Why this priority**: Spec 313 found the clean Decision Register route returned 403 while a filtered URL opened.
**Independent Test**: Seed a workspace user with decision visibility and open `/admin/governance/decisions` without environment query params; assert it is not forbidden and does not require a Managed Environment query.
**Acceptance Scenarios**:
1. **Given** an authorized workspace user with visible decision register access, **When** they open the clean Decision Register URL, **Then** the page opens without `managed_environment_id`.
2. **Given** no open decisions exist but the user is authorized to the workspace register, **When** they open the clean Decision Register URL, **Then** the page shows a truthful empty workspace state rather than requiring an environment filter.
### Edge Cases
- A workspace hub direct URL is opened with forbidden query params. The page may still support explicit CTA filters until Spec 315, but sidebar/global navigation must not generate those params.
- Browser back returns to an old filtered URL. This spec verifies sidebar/global re-entry and focused back/forward behavior; full universal clear-filter behavior belongs to Spec 316.
- A page is actually environment-owned. It must not be put in the workspace hub registry; environment pages remain outside this spec.
- A user lacks workspace membership. Existing deny-as-not-found behavior remains.
- A user is a workspace member but lacks a capability needed for a hub. Existing capability semantics remain; this spec must not bypass authorization.
## Requirements *(mandatory)*
### Functional Requirements
- **FR-001**: The implementation MUST create or formalize one canonical workspace hub registry/contract.
- **FR-002**: The registry MUST identify whether a route/page is a workspace-scoped hub.
- **FR-003**: The registry MUST identify whether sidebar/global navigation must clear active Managed Environment shell context for that hub.
- **FR-004**: The registry MUST provide the forbidden sidebar/global query-param list: `tenant`, `tenant_id`, `managed_environment_id`, `environment_id`, `tenant_scope`, and `tableFilters`.
- **FR-005**: The registry MUST provide the environment-like persisted filter keys to neutralize for workspace hub entry: `tenant`, `tenant_id`, `managed_environment_id`, `environment_id`, `environment`, and `tenant_scope`.
- **FR-006**: Sidebar/global URLs for every registry workspace hub MUST be clean and MUST NOT include forbidden environment query params.
- **FR-007**: Sidebar/global workspace hub entry MUST clear or bypass active Managed Environment shell context while preserving selected Workspace context.
- **FR-008**: Remembered Managed Environment state MUST NOT be used as sidebar URL input, workspace hub default data filter, workspace hub authorization boundary, shell context, or fallback route parameter.
- **FR-009**: Workspace hubs MUST NOT use `Filament::getTenant()` or remembered environment as a hidden default data boundary on sidebar/global entry.
- **FR-010**: Environment-like persisted Filament table filters MUST NOT override sidebar/global workspace hub intent.
- **FR-011**: Provider Connections sidebar/global entry MUST be workspace-wide and MUST NOT inject `managed_environment_id` from remembered context.
- **FR-012**: Finding Exceptions Queue sidebar/global entry MUST be workspace-wide and MUST NOT use `tenant` query for sidebar/global entry.
- **FR-013**: Operations sidebar/global entry MUST be workspace-wide and MUST NOT carry `managed_environment_id`, `tenant_scope`, or `tableFilters`.
- **FR-014**: Decision Register clean workspace URL MUST open for authorized workspace users and MUST NOT require an environment filter.
- **FR-015**: Customer Reviews, Reviews, Evidence, Governance Inbox, Audit Log, Alerts, Workspace Settings, Manage Workspaces, and Workspace Overview MUST honor the workspace-wide sidebar/global entry contract.
- **FR-016**: Existing Environment Dashboard CTAs may keep explicit environment filters only where already present; this spec MUST NOT standardize CTA query naming or visible filter chips.
- **FR-017**: Environment-owned pages MUST remain environment-owned and MUST NOT be forced workspace-wide by the registry.
- **FR-018**: No legacy compatibility layer, alias adapter, dual query mapping, migration shim, backfill, or seeder change may be introduced for this contract.
### Workspace Hub Registry Contents
| Registry entry | Route/page identity | Status in Spec 314 |
|---|---|---|
| Workspace Overview | `/admin`, `/admin/workspaces/{workspace}/overview` | include |
| Operations | `/admin/workspaces/{workspace}/operations` | include |
| Provider Connections / Integrations | `/admin/provider-connections` | include |
| Finding Exceptions Queue | `/admin/finding-exceptions/queue` | include |
| Evidence Overview | `/admin/evidence/overview` | include |
| Review Register | `/admin/reviews` | include |
| Customer Review Workspace | `/admin/reviews/workspace` | include |
| Governance Inbox | `/admin/governance/inbox` | include |
| Decision Register | `/admin/governance/decisions` | include |
| Audit Log | `/admin/audit-log` | include |
| Alerts landing | `/admin/alerts` | include |
| Alert Deliveries | `/admin/alerts/alert-deliveries` | include |
| Alert Rules | `/admin/alerts/alert-rules` | include |
| Alert Destinations | `/admin/alerts/alert-destinations` | include |
| Workspace Settings | `/admin/settings/workspace` | include |
| Manage Workspaces | `/admin/workspaces` | include |
| Managed Environments Landing | `/admin/workspaces/{workspace}/environments` | include as workspace environment catalog, not environment-owned dashboard |
| Stored Reports | `/admin/workspaces/{workspace}/environments/{environment}/stored-reports` | exclude; Spec 313 found no workspace-wide reports hub |
| Support Request action | modal/action only | exclude; no workspace-owned list route discovered in Spec 313 |
| Environment Dashboard and child routes | `/admin/workspaces/{workspace}/environments/{environment}...` | exclude; environment-owned |
### Non-Functional Requirements
- **NFR-001**: The implementation MUST preserve workspace isolation and managed-environment entitlement checks for every workspace-wide query.
- **NFR-002**: The implementation MUST not introduce Graph calls, queued work, remote calls, or `OperationRun` lifecycle changes.
- **NFR-003**: The implementation MUST use Laravel 12, Filament 5.2.1, Livewire 4.1.4, and Pest 4 conventions.
- **NFR-004**: The implementation MUST prefer direct replacement over legacy compatibility, consistent with LEAN-001.
- **NFR-005**: Browser verification MUST cover the critical Spec 313 flows after runtime changes.
- **NFR-006**: Tests MUST stay focused and must not rebaseline broad navigation or browser suites.
### RBAC / Security Requirements
- **SEC-001**: Workspace non-members remain deny-as-not-found.
- **SEC-002**: Missing capability remains forbidden after workspace membership is established where current policies use 403.
- **SEC-003**: UI visibility must not replace server-side authorization.
- **SEC-004**: Provider Connections credential-adjacent actions are not changed, weakened, or newly exposed.
- **SEC-005**: Workspace-wide pages must never reveal tenant-owned rows from environments outside the actor's entitlement.
### Auditability / Observability Requirements
- **AUD-001**: No new audit events are required because this spec changes read/navigation context only.
- **AUD-002**: Existing audit behavior for mutating actions must remain unchanged.
- **AUD-003**: Implementation close-out must record exact tests, browser verification, and any remaining follow-up for Specs 315/316/317.
### Data / Truth Requirements
- **DATA-001**: No migrations, seeders, data backfills, or persisted compatibility fields are in scope.
- **DATA-002**: The registry is code truth for workspace hub identity, not database truth.
- **DATA-003**: Environment-like persisted filter state is UI/session state only; it must not become a domain model.
## Out of Scope
- Environment CTA explicit filter contract.
- Universal clear-filter behavior.
- Standardizing filter chips or visible scope chips.
- Route alias compatibility.
- Legacy `tenant`/`tenant_id` cleanup outside sidebar/global workspace hub entry.
- Reclassifying all ambiguous pages.
- Product IA redesign.
- Application data migration, seed data, or backfill work.
- New provider abstraction, provider registry, or multi-provider framework.
- New destructive actions or mutating workflows.
## Acceptance Criteria
### Workspace Hub Registry
- [ ] There is one canonical workspace hub registry/contract.
- [ ] All workspace hubs from Spec 313 are represented or explicitly excluded with reason.
- [ ] Registry distinguishes workspace hubs from environment-scoped pages.
- [ ] Registry is used by sidebar/global navigation or the equivalent central contract.
### Sidebar URL Contract
- [ ] Workspace hub sidebar/global URLs contain no `tenant`.
- [ ] Workspace hub sidebar/global URLs contain no `tenant_id`.
- [ ] Workspace hub sidebar/global URLs contain no `managed_environment_id`.
- [ ] Workspace hub sidebar/global URLs contain no `environment_id`.
- [ ] Workspace hub sidebar/global URLs contain no `tenant_scope`.
- [ ] Workspace hub sidebar/global URLs contain no `tableFilters`.
- [ ] Provider Connections no longer injects Environment query params from remembered context.
- [ ] Finding Exceptions Queue no longer uses `tenant` query for sidebar/global entry.
### Shell / Data Scope Contract
- [ ] Sidebar/global entry into workspace hubs clears active shell Managed Environment context.
- [ ] Selected Workspace remains active.
- [ ] Workspace hubs open workspace-wide by default.
- [ ] Shell and data scope do not diverge on critical pages.
- [ ] Remembered Environment is not used as workspace hub data scope.
- [ ] `Filament::getTenant()` is not used as a hidden workspace hub default filter source.
### Persisted Filter Safety
- [ ] Environment-like persisted table filters do not override sidebar/global workspace hub intent.
- [ ] Customer Reviews sidebar/global entry does not restore stale Environment filter.
- [ ] Finding Exceptions Queue sidebar/global entry does not restore stale Environment filter.
- [ ] Evidence sidebar/global entry does not restore stale Environment filter.
- [ ] Provider Connections sidebar/global entry does not restore stale Environment filter.
### Critical Page Fixes
- [ ] Provider Connections workspace hub sidebar/global entry is workspace-wide.
- [ ] Finding Exceptions Queue workspace hub sidebar/global entry is workspace-wide.
- [ ] Operations sidebar/global entry is workspace-wide and does not carry `managed_environment_id`.
- [ ] Decision Register clean workspace URL opens for authorized workspace users.
- [ ] Customer Reviews sidebar/global entry no longer reload-restores stale Environment filter.
- [ ] Evidence sidebar/global entry no longer silently uses stale Environment filter.
### Tests
- [ ] Required tests added.
- [ ] Existing relevant tests updated only if they asserted old broken behavior.
- [ ] No broad test rebaseline.
- [ ] Tests prove sidebar/global navigation contract.
- [ ] Tests prove remembered Environment does not affect workspace hub sidebar/global URLs.
- [ ] Tests prove persisted Environment filters do not override sidebar/global intent.
### Browser Verification
- [ ] Focused browser verification performed for critical pages.
- [ ] Screenshots saved for critical before/after flows where useful.
- [ ] Reload behavior verified.
- [ ] Back/forward behavior checked for high-risk pages.
- [ ] Remaining mismatch documented as follow-up for Spec 315/316/317.
## Filament v5 Output Contract
1. **Livewire v4.0+ compliance**: The app uses Livewire 4.1.4 with Filament 5.2.1. Implementation must not introduce Livewire v3 references or APIs.
2. **Provider registration location**: Admin panel provider remains registered through `apps/platform/bootstrap/providers.php`; do not register providers in `bootstrap/app.php`.
3. **Global search**: Workspace hub registry work must not enable global search. Changed resources must keep existing global-search posture; Provider Connections currently disables global search.
4. **Destructive actions**: No destructive actions are added or changed. Any existing destructive action must retain `->action(...)`, `->requiresConfirmation()`, server-side authorization, audit logging, and tests.
5. **Asset strategy**: No new assets are expected. If implementation unexpectedly registers Filament assets, deployment must include `cd apps/platform && php artisan filament:assets`.
6. **Testing plan**: Feature/Livewire tests for URL/shell/data scope; focused browser smoke for critical flows; no broad UI restyling tests.
## Risks
- Decision Register currently has data-dependent access behavior; the implementation must make clean workspace URL behavior truthful without hiding real authorization failures.
- Persisted Filament filters may have page-specific session keys; implementation must avoid fragile one-off clearing where a shared helper can express the contract.
- Provider Connections currently uses `managed_environment_id` as external id/slug in some paths and database id elsewhere; sidebar/global entry must not preserve this ambiguity.
- Existing Environment CTA links may still pass explicit filters; implementation must not accidentally remove all CTA filtering before Spec 315.
- Browser back/forward may revive old filtered URLs; this spec covers critical re-entry and high-risk flows, while full clear-filter semantics belong to Spec 316.
## Assumptions
- Spec 313 is completed audit context and is the source of truth for page inventory and critical findings.
- Specs 311 and 312 are completed historical dependencies and must not be rewritten.
- There is no production data or production environment to preserve.
- Workspace remains primary context; Managed Environment remains secondary context.
- Provider Connections is workspace/provider-level for sidebar/global entry.
- Decision Register is a workspace hub and clean workspace URL behavior is required.
## Follow-Up Spec Candidates
- **315 - Environment CTA Explicit Filter Contract**: canonical environment filter query key, identifier type, visible scope chips, CTA behavior, and distinction from sidebar/global entry.
- **316 - Workspace Hub Clear Filter Contract**: clear visible chip, query params, Livewire properties, Filament table filters, deferred filters, persisted/session state, reload safety.
- **317 - Legacy Tenant / Environment Context Cleanup**: old `tenant` aliases, hidden `Filament::getTenant()` workspace hub usage, remembered environment as data boundary, stale tenant naming.
- **318 - Browser Regression Coverage / No-Drift Guard**: durable browser/regression guard coverage for this context contract.
## Required Final Report
When implementation completes, report:
```text
Spec 314 completed.
Changed behavior:
...
Files changed:
...
Workspace hub registry contents:
...
Tests:
- command:
- result:
Browser verification:
...
Remaining follow-ups:
- 315:
- 316:
- 317:
Runtime files were changed: yes/no.
No migrations were created.
No seeders were changed.
No backwards compatibility layer was introduced.
No legacy query alias support was preserved for workspace hub sidebar/global entry.
```

View File

@ -0,0 +1,149 @@
# Tasks: Workspace Hub Navigation Context Contract
**Input**: `spec.md` and `plan.md` in `specs/314-workspace-hub-navigation-context-contract/`
**Prerequisites**: Spec 313 audit artifacts are completed context. Do not rewrite Specs 311, 312, or 313.
**Tests**: Required. Use Pest 4 Feature/Livewire tests and one focused Pest Browser smoke for critical flows.
## Test Governance Checklist
- [x] Lane assignment is named and is the narrowest sufficient proof for the changed behavior.
- [x] New or changed tests stay in the smallest honest family, and the Spec 314 browser smoke 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 is `global-context-shell` plus targeted `monitoring-state-page`/`exception-coded-surface`.
- [x] Any material budget, baseline, trend, or escalation note is recorded in the implementation close-out.
## Phase 1: Preparation and Guardrails
**Purpose**: Confirm scope and protect against accidental implementation drift before changing runtime code.
- [x] T001 Confirm current branch is `314-workspace-hub-navigation-context-contract` with `git status --short --branch`.
- [x] T002 Re-read `specs/314-workspace-hub-navigation-context-contract/spec.md`, `plan.md`, and this `tasks.md`.
- [x] T003 Re-read Spec 313 artifacts: `audit-report.md`, `surface-inventory.md`, `page-matrix.md`, `query-param-inventory.md`, `clear-filter-inventory.md`, and `code-ownership-map.md`.
- [x] T004 Confirm no application implementation from Specs 315, 316, 317, or 318 is being pulled into this spec.
- [x] T005 Confirm no migrations, seeders, packages, env vars, queues, scheduler, storage, or asset registration are needed.
- [x] T006 Document the implementation close-out target: `Guardrail / Exception / Smoke Coverage`.
## Phase 2: Contract Tests First
**Purpose**: Add failing tests that define the workspace hub navigation contract before runtime changes.
- [x] T007 [P] Add `apps/platform/tests/Feature/Navigation/WorkspaceHubRegistryTest.php` covering registry entries, explicit exclusions, forbidden query keys, and environment-like persisted filter keys.
- [x] T008 [P] Add `apps/platform/tests/Feature/Navigation/WorkspaceHubSidebarUrlContractTest.php` with table-driven coverage for every registry workspace hub URL.
- [x] T009 [P] In the sidebar URL contract test, assert every workspace hub URL excludes `tenant`, `tenant_id`, `managed_environment_id`, `environment_id`, `tenant_scope`, and `tableFilters`.
- [x] T010 [P] Add `apps/platform/tests/Feature/Workspaces/WorkspaceHubContextContractTest.php` covering Environment Dashboard state -> sidebar/global workspace hub entry clears active shell Managed Environment while preserving selected Workspace.
- [x] T011 [P] Add remembered-environment setup to `WorkspaceHubContextContractTest.php` proving `WorkspaceContext::LAST_TENANT_IDS_SESSION_KEY` does not affect workspace hub sidebar URLs or shell context.
- [x] T012 [P] Add persisted filter setup to `WorkspaceHubContextContractTest.php` proving environment-like filters do not override sidebar/global intent for at least Customer Reviews, Finding Exceptions Queue, Evidence, and Provider Connections.
## Phase 3: Critical Page Regression Tests
**Purpose**: Prove the concrete Spec 313 regressions before fixing them.
- [x] T013 [P] Add `apps/platform/tests/Feature/ProviderConnections/ProviderConnectionsWorkspaceHubContractTest.php` proving sidebar/global Provider Connections entry is workspace-wide and does not inject `managed_environment_id`.
- [x] T014 [P] In the Provider Connections test, seed two entitled Managed Environments with provider connections and assert both rows are visible from clean workspace entry.
- [x] T015 [P] Add a Provider Connections regression assertion that remembered Managed Environment does not influence `ProviderConnectionResource::getUrl('index', panel: 'admin')` when used for sidebar/global entry.
- [x] T016 [P] Add `apps/platform/tests/Feature/Monitoring/FindingExceptionsQueueWorkspaceHubContractTest.php` proving sidebar/global queue entry has no `tenant` query param and no remembered environment shell restoration.
- [x] T017 [P] In the Finding Exceptions Queue test, seed at least two entitled environments with pending exceptions where practical and assert workspace-wide row scope; if exact row proof is blocked by existing factories, assert table filter/session state and document the limitation in test comments.
- [x] T018 [P] Add `apps/platform/tests/Feature/Monitoring/OperationsWorkspaceHubContractTest.php` proving sidebar/global Operations entry has no `managed_environment_id`, `tenant_scope`, or `tableFilters`.
- [x] T019 [P] In the Operations test, seed operation runs for two entitled environments and assert sidebar/global entry sees both where fixtures allow.
- [x] T020 [P] Add `apps/platform/tests/Feature/Governance/DecisionRegisterWorkspaceHubContractTest.php` proving clean Decision Register URL opens for an authorized workspace user without environment query params.
- [x] T021 [P] In the Decision Register test, cover a truthful empty workspace register state instead of 403 when the user is authorized but no environment filter is supplied.
- [x] T022 [P] Add `apps/platform/tests/Feature/Reviews/CustomerReviewWorkspaceHubContractTest.php` proving sidebar/global Customer Review Workspace entry ignores stale environment query/session filters.
- [x] T023 [P] Add `apps/platform/tests/Feature/Monitoring/EvidenceOverviewWorkspaceHubContractTest.php` proving sidebar/global Evidence Overview entry ignores stale environment-like persisted filters.
## Phase 4: Workspace Hub Registry
**Purpose**: Implement the narrow code-level contract that owns workspace hub identity.
- [x] T024 Create `apps/platform/app/Support/Navigation/WorkspaceHubRegistry.php`.
- [x] T025 Include registry entries for Workspace Overview, Operations, Provider Connections, Finding Exceptions Queue, Evidence Overview, Review Register, Customer Review Workspace, Governance Inbox, Decision Register, Audit Log, Alerts, Alert Deliveries, Alert Rules, Alert Destinations, Workspace Settings, Manage Workspaces, and Managed Environments Landing.
- [x] T026 Encode explicit exclusions for Stored Reports environment routes, Support Request modal/action surface, Environment Dashboard, and environment child routes in tests or registry documentation.
- [x] T027 Add registry methods for route/page identity checks, clean workspace hub URL policy, forbidden query keys, and environment-like persisted filter keys.
- [x] T028 Keep the registry static/narrow; do not introduce database state, config persistence, provider frameworks, or product IA abstractions.
## Phase 5: Central Sidebar and Global URL Generation
**Purpose**: Ensure workspace hub URLs from central navigation are clean and deterministic.
- [x] T029 Update `apps/platform/app/Support/Navigation/WorkspaceSidebarNavigation.php` so workspace hub URLs are generated through the clean workspace hub contract.
- [x] T030 Update `apps/platform/app/Providers/Filament/AdminPanelProvider.php` duplicated navigation items to use the same clean workspace hub URL contract.
- [x] T031 Ensure Operations sidebar/global URL uses clean workspace route with only required workspace route parameter.
- [x] T032 Ensure Provider Connections sidebar/global URL bypasses any `ProviderConnectionResource::getUrl()` behavior that injects remembered environment context.
- [x] T033 Ensure Finding Exceptions Queue sidebar/global URL does not include `tenant`.
- [x] T034 Ensure Decision Register, Governance Inbox, Customer Reviews, Reviews, Evidence, Audit Log, Alerts, Workspace Settings, Manage Workspaces, and Workspace Overview sidebar/global URLs exclude all forbidden keys.
- [x] T035 Add or update tests proving `WorkspaceSidebarNavigation` and `AdminPanelProvider` navigation sources stay in parity.
## Phase 6: Shell and Remembered Environment Isolation
**Purpose**: Stop remembered or Filament tenant state from becoming a workspace hub default data boundary.
- [x] T036 Inspect `apps/platform/app/Support/Tenants/TenantPageCategory.php` and `apps/platform/app/Support/Navigation/NavigationScope.php` and adjust workspace hub classification only where needed.
- [x] T037 Update `apps/platform/app/Support/OperateHub/OperateHubShell.php` so sidebar/global workspace hub entry resolves as tenantless workspace shell context.
- [x] T038 Ensure `WorkspaceContext::rememberedTenant()` and `lastTenantId()` are not used as workspace hub sidebar URL inputs or data filters.
- [x] T039 Preserve remembered Managed Environment switcher convenience outside workspace hub sidebar/global data scope.
- [x] T040 Add assertions to existing or new shell context tests proving valid query hints do not make sidebar/global workspace hub shell environment-scoped.
## Phase 7: Persisted Filter Safety
**Purpose**: Prevent old environment table filters from overriding workspace-wide entry.
- [x] T041 Extend `apps/platform/app/Support/Filament/CanonicalAdminTenantFilterState.php` or a sibling helper so workspace hub entry can clear/ignore environment-like persisted filters.
- [x] T042 The helper must cover `tenant`, `tenant_id`, `managed_environment_id`, `environment_id`, `environment`, and `tenant_scope`.
- [x] T043 Apply the helper to Customer Review Workspace, Finding Exceptions Queue, Evidence Overview, Review Register, Operations, Audit Log, Alerts/Alert Deliveries, Governance Inbox, and Decision Register where each page uses persisted table/session filters.
- [x] T044 Avoid one-off page clearing unless Livewire lifecycle requires it; if one-off handling is required, document the bounded reason in code comments or implementation close-out.
- [x] T045 Add tests proving session-persisted environment filters are cleared or ignored on clean sidebar/global workspace hub entry.
## Phase 8: Critical Page Fixes
**Purpose**: Resolve the concrete Spec 313 critical/high findings in scope.
- [x] T046 Update `apps/platform/app/Filament/Resources/ProviderConnectionResource.php` so workspace hub sidebar/global entry is workspace-wide and not filtered by remembered context.
- [x] T047 Keep explicit Provider Connections environment filtering only for explicit CTA/query flows that already exist; do not standardize CTA naming in this spec.
- [x] T048 Update `apps/platform/app/Support/ManagedEnvironmentLinks.php` only where needed so sidebar/global Provider Connections entry is clean while environment-specific helper calls remain explicit.
- [x] T049 Update `apps/platform/app/Filament/Pages/Monitoring/FindingExceptionsQueue.php` so sidebar/global entry does not restore `tenant` or persisted environment filters.
- [x] T050 Update `apps/platform/app/Http/Controllers/OpenFindingExceptionsQueueController.php` only if necessary to preserve explicit environment entry while keeping sidebar/global entry clean.
- [x] T051 Update `apps/platform/app/Filament/Pages/Monitoring/Operations.php` and/or `apps/platform/app/Support/OperationRunLinks.php` so sidebar/global entry cannot carry `managed_environment_id`, `tenant_scope`, or environment `tableFilters`.
- [x] T052 Update `apps/platform/app/Filament/Pages/Governance/DecisionRegister.php` so clean workspace URL opens for authorized users and uses workspace/capability authorization rather than requiring an environment filter.
- [x] T053 Update `apps/platform/app/Filament/Pages/Reviews/CustomerReviewWorkspace.php` so sidebar/global entry is workspace-wide and stale environment filters do not reload-restore.
- [x] T054 Update `apps/platform/app/Filament/Pages/Reviews/ReviewRegister.php` so sidebar/global entry is workspace-wide and stale environment filters do not reload-restore.
- [x] T055 Update `apps/platform/app/Filament/Pages/Monitoring/EvidenceOverview.php` only where needed to preserve its existing clean clear behavior and enforce sidebar/global stale-filter safety.
- [x] T056 Update `apps/platform/app/Filament/Pages/Governance/GovernanceInbox.php` only where needed for sidebar/global clean entry; leave explicit CTA filter behavior for Spec 315.
## Phase 9: Browser Verification
**Purpose**: Repeat the focused Spec 313 browser flows after runtime fixes.
- [x] T057 Add `apps/platform/tests/Browser/Spec314WorkspaceHubNavigationContextSmokeTest.php` or run an equivalent existing browser-smoke pattern if the repo already has the right harness.
- [x] T058 Browser flow: Environment Dashboard -> Sidebar -> Provider Connections, Finding Exceptions Queue, Operations, Decision Register, Customer Reviews, Evidence, Reviews, and Governance Inbox.
- [x] T059 Browser flow: Workspace Overview -> Sidebar -> the same critical pages.
- [x] T060 Browser flow: reload after sidebar entry for Provider Connections, Finding Exceptions Queue, Operations, Decision Register, Customer Reviews, Evidence, Reviews, and Governance Inbox.
- [x] T061 Browser flow: back/forward for Provider Connections, Customer Reviews, Finding Exceptions Queue, Operations, and Decision Register.
- [x] T062 Save screenshots under `specs/314-workspace-hub-navigation-context-contract/artifacts/screenshots/` when useful for before/after evidence.
- [x] T063 Record any remaining mismatch as follow-up for Spec 315, 316, or 317 rather than broadening this spec.
## Phase 10: Validation and Close-Out
**Purpose**: Prove the contract and record implementation impact.
- [x] T064 Run `cd apps/platform && ./vendor/bin/sail artisan test --filter=WorkspaceHub`.
- [x] T065 Run `cd apps/platform && ./vendor/bin/sail artisan test --filter=ProviderConnectionsWorkspaceHub`.
- [x] T066 Run `cd apps/platform && ./vendor/bin/sail artisan test --filter=FindingExceptionsQueueWorkspaceHub`.
- [x] T067 Run `cd apps/platform && ./vendor/bin/sail artisan test --filter=OperationsWorkspaceHub`.
- [x] T068 Run `cd apps/platform && ./vendor/bin/sail artisan test --filter=DecisionRegisterWorkspaceHub`.
- [x] T069 Run `cd apps/platform && ./vendor/bin/sail artisan test --filter=CustomerReviewWorkspaceHub`.
- [x] T070 Run `cd apps/platform && ./vendor/bin/sail artisan test --filter=EvidenceOverviewWorkspaceHub`.
- [x] T071 Run `cd apps/platform && ./vendor/bin/sail artisan test --filter=Spec314WorkspaceHubNavigationContextSmoke` or document why browser lane could not run.
- [x] T072 Run `git diff --check`.
- [x] T073 Confirm `git diff --name-only` contains expected runtime/test files plus `specs/314-workspace-hub-navigation-context-contract/` only.
- [x] T074 Confirm no files under `apps/platform/database/migrations/` or seeders changed.
- [x] T075 Confirm no new legacy compatibility adapter, alias layer, dual-read, dual-write, migration shim, or backfill was introduced.
- [x] T076 Fill the final implementation report in the format required by `spec.md`.
## Explicit Non-Goals for Implementers
- [x] Do not implement Spec 315 Environment CTA Explicit Filter Contract.
- [x] Do not implement Spec 316 Workspace Hub Clear Filter Contract.
- [x] Do not implement Spec 317 Legacy Tenant / Environment Context Cleanup beyond sidebar/global workspace hub entry.
- [x] Do not implement Spec 318 durable browser regression guard beyond the focused Spec 314 smoke.
- [x] Do not redesign pages, tables, navigation IA, cards, chips, or copy beyond what is required for scope truth.
- [x] Do not change provider credential actions, destructive actions, Graph calls, queues, migrations, seeders, or packages.