feat: finalize global shell context contract (#246)
Some checks failed
Main Confidence / confidence (push) Failing after 43s

## Summary
- consolidate workspace and tenant shell resolution behind a canonical resolved shell context
- align workspace switching, tenant selection, and tenant clearing with the new recovery and fallback rules
- add focused Pest coverage for shell resolution and update root dev orchestration so platform Vite starts correctly from repo-root commands

## Testing
- cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Monitoring/HeaderContextBarTest.php
- cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Workspaces/GlobalContextShellContractTest.php
- manual integrated-browser smoke for tenant-bound shell actions and context recovery flows
- validated corepack pnpm build:platform, corepack pnpm dev:platform, corepack pnpm dev:website, and corepack pnpm dev

## Notes
- Livewire v4 / Filament v5 remain unchanged and provider registration stays in bootstrap/providers.php
- no new globally searchable resources or destructive Filament actions were introduced

Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de>
Reviewed-on: #246
This commit is contained in:
ahmido 2026-04-18 14:00:49 +00:00
parent ea9ef9cb38
commit 3bdd27f747
39 changed files with 2876 additions and 270 deletions

View File

@ -7,6 +7,7 @@ ## Relocation override
- Human-facing commands should use `cd apps/platform && ...`.
- Repo-root tooling may delegate via `./scripts/platform-sail` when it cannot set a nested working directory.
- Repo-root JavaScript orchestration uses `corepack pnpm install`, `corepack pnpm dev:platform`, `corepack pnpm dev:website`, `corepack pnpm dev`, `corepack pnpm build:website`, and `corepack pnpm build:platform`.
- `corepack pnpm dev:platform` starts the platform Sail stack and the Laravel panel Vite watcher. `corepack pnpm dev` starts that platform watcher plus the website dev server.
- `apps/website` is a standalone Astro app, not a second Laravel runtime, so Boost MCP remains platform-only.
- If any generated technology note below conflicts with the current repo, trust `apps/platform/composer.json`, `apps/platform/package.json`, and the live Laravel application metadata over stale generated entries.
@ -202,6 +203,8 @@ ## Active Technologies
- SQLite `:memory:` for lane execution, filesystem artifacts under `apps/platform/storage/logs/test-lanes`, staged CI bundles under `.gitea-artifacts/<workflow-profile>`, bounded derived trend/history artifacts adjacent to current lane artifacts, and no new product database persistence (211-runtime-trend-recalibration)
- Markdown for repository governance artifacts, JSON Schema plus logical OpenAPI for planning contracts, and Bash-backed SpecKit scripts already present in the repo + `.specify/memory/constitution.md`, `.specify/templates/spec-template.md`, `.specify/templates/plan-template.md`, `.specify/templates/tasks-template.md`, `.specify/templates/checklist-template.md`, `.specify/README.md`, `README.md`, and the existing Specs 206 through 211 governance vocabulary (212-test-authoring-guardrails)
- Repository-owned markdown and contract artifacts under `.specify/`, `specs/212-test-authoring-guardrails/`, and root documentation files; no product database persistence (212-test-authoring-guardrails)
- PHP 8.4.15 + Laravel 12, Filament v5, Livewire v4, Pest v4, Tailwind CSS v4, existing `WorkspaceContext`, `OperateHubShell`, `EnsureFilamentTenantSelected`, `WorkspaceRedirectResolver`, `WorkspaceIntendedUrl`, `TenantPageCategory`, and `ResolvesPanelTenantContext` (199-global-context-shell-contract)
- PostgreSQL unchanged plus existing Laravel session keys `current_workspace_id`, `workspace_intended_url`, and `workspace_last_tenant_ids`; no schema change planned (199-global-context-shell-contract)
- PHP 8.4.15 (feat/005-bulk-operations)
@ -236,8 +239,8 @@ ## Code Style
PHP 8.4.15: Follow standard conventions
## Recent Changes
- 199-global-context-shell-contract: Added PHP 8.4.15 + Laravel 12, Filament v5, Livewire v4, Pest v4, Tailwind CSS v4, existing `WorkspaceContext`, `OperateHubShell`, `EnsureFilamentTenantSelected`, `WorkspaceRedirectResolver`, `WorkspaceIntendedUrl`, `TenantPageCategory`, and `ResolvesPanelTenantContext`
- 212-test-authoring-guardrails: Added Markdown for repository governance artifacts, JSON Schema plus logical OpenAPI for planning contracts, and Bash-backed SpecKit scripts already present in the repo + `.specify/memory/constitution.md`, `.specify/templates/spec-template.md`, `.specify/templates/plan-template.md`, `.specify/templates/tasks-template.md`, `.specify/templates/checklist-template.md`, `.specify/README.md`, `README.md`, and the existing Specs 206 through 211 governance vocabulary
- 211-runtime-trend-recalibration: Added PHP 8.4.15 for repo-truth governance logic, Bash for repo-root wrappers, GitHub-compatible Gitea Actions workflow YAML under `.gitea/workflows/`, plus JSON Schema and logical OpenAPI for repository contracts + Laravel 12, Pest v4, PHPUnit 12, Filament v5, Livewire v4, Laravel Sail, Gitea Actions backed by `act_runner`, uploaded artifact bundles, and the existing `Tests\Support\TestLaneManifest`, `TestLaneBudget`, and `TestLaneReport` seams
- 210-ci-matrix-budget-enforcement: Added PHP 8.4.15 for repo-truth test governance, Bash for repo-root wrappers, and GitHub-compatible Gitea Actions workflow YAML under `.gitea/workflows/` + Laravel 12, Pest v4, PHPUnit 12, Filament v5, Livewire v4, Laravel Sail, Gitea Actions backed by `act_runner`, and the existing `Tests\Support\TestLaneManifest`, `TestLaneBudget`, and `TestLaneReport` seams
<!-- MANUAL ADDITIONS START -->
<!-- MANUAL ADDITIONS END -->

View File

@ -293,6 +293,7 @@ ## Application Structure & Architecture
## Workspace Commands
- Repo-root JavaScript orchestration now uses `corepack pnpm install`, `corepack pnpm dev:platform`, `corepack pnpm dev:website`, `corepack pnpm dev`, `corepack pnpm build:website`, and `corepack pnpm build:platform`.
- `corepack pnpm dev:platform` starts the platform Sail stack and the Laravel panel Vite watcher. `corepack pnpm dev` starts that platform watcher plus the website dev server.
- `apps/website` is a standalone Astro app, not a second Laravel runtime, so Boost MCP remains platform-only.
## Frontend Bundling

View File

@ -722,6 +722,7 @@ ## Application Structure & Architecture
## Frontend Bundling
- Repo-root JavaScript orchestration now uses `corepack pnpm install`, `corepack pnpm dev:platform`, `corepack pnpm dev:website`, `corepack pnpm dev`, `corepack pnpm build:website`, and `corepack pnpm build:platform`.
- `corepack pnpm dev:platform` starts the platform Sail stack and the Laravel panel Vite watcher. `corepack pnpm dev` starts that platform watcher plus the website dev server.
- `apps/website` is a standalone Astro app, not a second Laravel runtime, so Boost MCP remains platform-only.
- If the user doesn't see a platform frontend change reflected in the UI, it could mean they need to run `cd apps/platform && ./vendor/bin/sail pnpm build`, `cd apps/platform && ./vendor/bin/sail pnpm dev`, or `cd apps/platform && ./vendor/bin/sail composer run dev`. Ask them.

View File

@ -560,6 +560,7 @@ ## Application Structure & Architecture
## Frontend Bundling
- Repo-root JavaScript orchestration now uses `corepack pnpm install`, `corepack pnpm dev:platform`, `corepack pnpm dev:website`, `corepack pnpm dev`, `corepack pnpm build:website`, and `corepack pnpm build:platform`.
- `corepack pnpm dev:platform` starts the platform Sail stack and the Laravel panel Vite watcher. `corepack pnpm dev` starts that platform watcher plus the website dev server.
- `apps/website` is a standalone Astro app, not a second Laravel runtime, so Boost MCP remains platform-only.
- If the user doesn't see a platform frontend change reflected in the UI, it could mean they need to run `cd apps/platform && ./vendor/bin/sail pnpm build`, `cd apps/platform && ./vendor/bin/sail pnpm dev`, or `cd apps/platform && ./vendor/bin/sail composer run dev`. Ask them.

View File

@ -15,11 +15,11 @@ ## Multi-App Topology
## Official Root Commands
- Install workspace-managed JavaScript dependencies: `corepack pnpm install`
- Start the platform stack: `corepack pnpm dev:platform`
- Start the platform stack and Laravel panel Vite watcher: `corepack pnpm dev:platform`
- Start the website dev server: `corepack pnpm dev:website`
- Start platform + website together: `corepack pnpm dev`
- Start platform Vite + website together: `corepack pnpm dev`
- Build the website: `corepack pnpm build:website`
- Build platform frontend assets: `corepack pnpm build:platform`
- Build platform frontend assets inside Sail: `corepack pnpm build:platform`
## App-Local Commands
@ -29,7 +29,7 @@ ### Platform
- Start Sail: `cd apps/platform && ./vendor/bin/sail up -d`
- Generate the app key: `cd apps/platform && ./vendor/bin/sail artisan key:generate`
- Run migrations and seeders: `cd apps/platform && ./vendor/bin/sail artisan migrate --seed`
- Run frontend watch/build inside Sail: `cd apps/platform && ./vendor/bin/sail pnpm dev` or `cd apps/platform && ./vendor/bin/sail pnpm build`
- Run frontend watch/build inside Sail: `corepack pnpm dev:platform`, `cd apps/platform && ./vendor/bin/sail pnpm dev`, or `cd apps/platform && ./vendor/bin/sail pnpm build`
- Run tests: `cd apps/platform && ./vendor/bin/sail artisan test --compact`
### Website

View File

@ -127,12 +127,14 @@ public function selectWorkspace(int $workspaceId): void
resourceId: (string) $workspace->getKey(),
);
$intendedUrl = WorkspaceIntendedUrl::consume(request());
/** @var WorkspaceRedirectResolver $resolver */
$resolver = app(WorkspaceRedirectResolver::class);
$redirectTarget = $intendedUrl ?: $resolver->resolve($workspace, $user);
$redirectTarget = $resolver->resolve(
$workspace,
$user,
WorkspaceIntendedUrl::consume(request()),
);
$this->redirect($redirectTarget);
}
@ -170,12 +172,14 @@ public function createWorkspace(array $data): void
->success()
->send();
$intendedUrl = WorkspaceIntendedUrl::consume(request());
/** @var WorkspaceRedirectResolver $resolver */
$resolver = app(WorkspaceRedirectResolver::class);
$redirectTarget = $intendedUrl ?: $resolver->resolve($workspace, $user);
$redirectTarget = $resolver->resolve(
$workspace,
$user,
WorkspaceIntendedUrl::consume(request()),
);
$this->redirect($redirectTarget);
}

View File

@ -69,15 +69,13 @@ public function __invoke(Request $request): RedirectResponse
resourceId: (string) $workspace->getKey(),
);
$intendedUrl = WorkspaceIntendedUrl::consume($request);
if ($intendedUrl !== null) {
return redirect()->to($intendedUrl);
}
/** @var WorkspaceRedirectResolver $resolver */
$resolver = app(WorkspaceRedirectResolver::class);
return redirect()->to($resolver->resolve($workspace, $user));
return redirect()->to($resolver->resolve(
$workspace,
$user,
WorkspaceIntendedUrl::consume($request),
));
}
}

View File

@ -8,18 +8,14 @@
use App\Filament\Resources\AlertRuleResource;
use App\Models\Tenant;
use App\Models\User;
use App\Models\Workspace;
use App\Models\WorkspaceMembership;
use App\Services\Auth\WorkspaceRoleCapabilityMap;
use App\Services\Tenants\TenantOperabilityService;
use App\Support\Auth\Capabilities;
use App\Support\OperateHub\OperateHubShell;
use App\Support\Tenants\TenantOperabilityQuestion;
use App\Support\Tenants\TenantPageCategory;
use App\Support\Workspaces\WorkspaceContext;
use Closure;
use Filament\Facades\Filament;
use Filament\Models\Contracts\HasTenants;
use Filament\Navigation\NavigationBuilder;
use Filament\Navigation\NavigationItem;
use Illuminate\Http\Request;
@ -33,6 +29,7 @@ class EnsureFilamentTenantSelected
public function handle(Request $request, Closure $next): Response
{
$panel = Filament::getCurrentOrDefaultPanel();
$resolvedContext = app(OperateHubShell::class)->resolvedContext($request);
$path = '/'.ltrim($request->path(), '/');
@ -85,75 +82,27 @@ public function handle(Request $request, Closure $next): Response
return $next($request);
}
$tenantParameter = null;
if ($request->route()?->hasParameter('tenant')) {
$tenantParameter = $request->route()->parameter('tenant');
} elseif (filled($request->query('tenant'))) {
$tenantParameter = $request->query('tenant');
}
if (
$tenantParameter === null
&& ! $this->hasCanonicalTenantSelection($request)
! $resolvedContext->hasTenant()
&& $this->adminPathRequiresTenantSelection($path)
) {
return redirect()->route('filament.admin.pages.choose-tenant');
}
if ($tenantParameter !== null) {
$user = $request->user();
if ($resolvedContext->pageCategory === TenantPageCategory::TenantBound && ! $resolvedContext->hasTenant()) {
abort(404);
}
if ($user === null) {
return $next($request);
}
if (! $user instanceof HasTenants) {
abort(404);
}
$tenant = $tenantParameter instanceof Tenant
? $tenantParameter
: Tenant::query()->withTrashed()->where('external_id', (string) $tenantParameter)->first();
if (! $tenant instanceof Tenant) {
abort(404);
}
if ($workspaceId === null) {
abort(404);
}
if ((int) $tenant->workspace_id !== (int) $workspaceId) {
abort(404);
}
$workspace = Workspace::query()->whereKey($workspaceId)->first();
if (! $workspace instanceof Workspace) {
abort(404);
}
if (! $user instanceof User || ! $workspaceContext->isMember($user, $workspace)) {
abort(404);
}
if (! $this->routeTenantIsAuthorized($tenant, $user, $workspaceId, $path)) {
abort(404);
}
if ($this->isWorkspaceScopedPageWithTenant($path)) {
$this->configureNavigationForRequest($panel);
return $next($request);
}
Filament::setTenant($tenant, true);
app(WorkspaceContext::class)->rememberTenantContext($tenant, $request);
$this->configureNavigationForRequest($panel);
return $next($request);
if (
$resolvedContext->hasTenant()
&& (
$panel?->getId() === 'tenant'
|| (! $this->isWorkspaceScopedPageWithTenant($path) && $resolvedContext->pageCategory === TenantPageCategory::TenantBound)
)
) {
Filament::setTenant($resolvedContext->tenant, true);
} elseif (! $resolvedContext->hasTenant()) {
Filament::setTenant(null, true);
}
if (
@ -291,27 +240,4 @@ private function adminPathRequiresTenantSelection(string $path): bool
return preg_match('#^/admin/(inventory|policies|policy-versions|backup-sets)(/|$)#', $path) === 1;
}
private function hasCanonicalTenantSelection(Request $request): bool
{
return app(OperateHubShell::class)->activeEntitledTenant($request) instanceof Tenant;
}
private function routeTenantIsAuthorized(Tenant $tenant, User $user, int $workspaceId, string $path): bool
{
$pageCategory = TenantPageCategory::fromPath($path);
$question = match ($pageCategory) {
TenantPageCategory::TenantBound => TenantOperabilityQuestion::TenantBoundViewability,
default => TenantOperabilityQuestion::AdministrativeDiscoverability,
};
return app(TenantOperabilityService::class)->outcomeFor(
tenant: $tenant,
question: $question,
actor: $user,
workspaceId: $workspaceId,
lane: $pageCategory->lane(),
selectedTenant: Filament::getTenant() instanceof Tenant ? Filament::getTenant() : null,
)->allowed;
}
}

View File

@ -7,9 +7,9 @@
use App\Filament\Pages\TenantDashboard;
use App\Models\Tenant;
use App\Models\User;
use App\Models\Workspace;
use App\Services\Auth\CapabilityResolver;
use App\Services\Tenants\TenantOperabilityService;
use App\Support\Tenants\TenantInteractionLane;
use App\Support\Tenants\TenantOperabilityQuestion;
use App\Support\Tenants\TenantPageCategory;
use App\Support\Workspaces\WorkspaceContext;
@ -19,6 +19,8 @@
final class OperateHubShell
{
private const string REQUEST_ATTRIBUTE = 'tenantpilot.resolved_shell_context';
public function __construct(
private WorkspaceContext $workspaceContext,
private CapabilityResolver $capabilityResolver,
@ -83,7 +85,7 @@ public function headerActions(
public function activeEntitledTenant(?Request $request = null): ?Tenant
{
return $this->resolveActiveTenant($request);
return $this->resolvedContext($request)->tenant;
}
public function tenantOwnedPanelContext(?Request $request = null): ?Tenant
@ -91,42 +93,162 @@ public function tenantOwnedPanelContext(?Request $request = null): ?Tenant
return $this->activeEntitledTenant($request);
}
private function resolveActiveTenant(?Request $request = null): ?Tenant
public function resolvedContext(?Request $request = null): ResolvedShellContext
{
$pageCategory = $this->pageCategory($request);
$routeTenant = $this->resolveRouteTenant($request, $pageCategory);
$request ??= request();
if ($routeTenant instanceof Tenant) {
return $routeTenant;
if ($request instanceof Request) {
$cached = $request->attributes->get(self::REQUEST_ATTRIBUTE);
if ($cached instanceof ResolvedShellContext) {
return $cached;
}
}
$tenant = $this->resolveValidatedFilamentTenant($request, $pageCategory);
$resolved = $this->buildResolvedContext($request);
if ($tenant instanceof Tenant) {
return $tenant;
if ($request instanceof Request) {
$request->attributes->set(self::REQUEST_ATTRIBUTE, $resolved);
}
if ($pageCategory === TenantPageCategory::TenantBound) {
return null;
}
$rememberedTenant = $this->workspaceContext->rememberedTenant($request);
if (! $rememberedTenant instanceof Tenant) {
return null;
}
if (! $this->isRememberedTenantValid($rememberedTenant, $request)) {
$this->workspaceContext->clearRememberedTenantContext($request);
return null;
}
return $rememberedTenant;
return $resolved;
}
private function resolveValidatedFilamentTenant(?Request $request = null, ?TenantPageCategory $pageCategory = null): ?Tenant
private function buildResolvedContext(?Request $request = null): ResolvedShellContext
{
$pageCategory = $this->pageCategory($request);
$routeTenantCandidate = $this->resolveRouteTenantCandidate($request, $pageCategory);
$sessionWorkspace = $this->workspaceContext->currentWorkspace($request);
$workspace = $this->workspaceContext->currentWorkspaceOrTenantWorkspace($routeTenantCandidate, $request);
$workspaceSource = match (true) {
$workspace instanceof Workspace && $sessionWorkspace instanceof Workspace && $workspace->is($sessionWorkspace) => 'session_workspace',
$workspace instanceof Workspace && $routeTenantCandidate instanceof Tenant && (int) $routeTenantCandidate->workspace_id === (int) $workspace->getKey() => 'route',
default => 'none',
};
if (! $workspace instanceof Workspace) {
return new ResolvedShellContext(
workspace: null,
tenant: null,
pageCategory: $pageCategory,
state: 'missing_workspace',
displayMode: 'recovery',
recoveryAction: $pageCategory === TenantPageCategory::WorkspaceChooserException ? 'none' : 'redirect_choose_workspace',
recoveryDestination: '/admin/choose-workspace',
recoveryReason: 'missing_workspace',
);
}
$routeTenant = $this->resolveValidatedRouteTenant($routeTenantCandidate, $workspace, $request, $pageCategory);
if ($routeTenant['tenant'] instanceof Tenant) {
return new ResolvedShellContext(
workspace: $workspace,
tenant: $routeTenant['tenant'],
pageCategory: $pageCategory,
state: 'tenant_scoped',
displayMode: 'tenant_scoped',
workspaceSource: $workspaceSource,
tenantSource: 'route',
);
}
$recoveryReason = $routeTenant['reason'];
if ($pageCategory === TenantPageCategory::TenantBound && $recoveryReason !== null) {
return new ResolvedShellContext(
workspace: $workspace,
tenant: null,
pageCategory: $pageCategory,
state: 'invalid_tenant',
displayMode: 'recovery',
workspaceSource: $workspaceSource,
recoveryAction: 'abort_not_found',
recoveryReason: $recoveryReason,
);
}
$queryHintTenant = $this->resolveValidatedQueryHintTenant($request, $workspace, $pageCategory);
if ($queryHintTenant['tenant'] instanceof Tenant) {
return new ResolvedShellContext(
workspace: $workspace,
tenant: $queryHintTenant['tenant'],
pageCategory: $pageCategory,
state: 'tenant_scoped',
displayMode: 'tenant_scoped',
workspaceSource: $workspaceSource,
tenantSource: 'query_hint',
);
}
$recoveryReason ??= $queryHintTenant['reason'];
$tenant = $this->resolveValidatedFilamentTenant($request, $pageCategory, $workspace);
if ($tenant instanceof Tenant) {
return new ResolvedShellContext(
workspace: $workspace,
tenant: $tenant,
pageCategory: $pageCategory,
state: 'tenant_scoped',
displayMode: 'tenant_scoped',
workspaceSource: $workspaceSource,
tenantSource: 'filament_tenant',
);
}
if ($pageCategory->allowsRememberedTenantRestore()) {
$rememberedTenant = $this->workspaceContext->rememberedTenant($request);
if ($rememberedTenant instanceof Tenant) {
return new ResolvedShellContext(
workspace: $workspace,
tenant: $rememberedTenant,
pageCategory: $pageCategory,
state: 'tenant_scoped',
displayMode: 'tenant_scoped',
workspaceSource: $workspaceSource,
tenantSource: 'remembered',
);
}
}
if ($pageCategory->requiresExplicitTenant()) {
return new ResolvedShellContext(
workspace: $workspace,
tenant: null,
pageCategory: $pageCategory,
state: 'missing_tenant',
displayMode: 'recovery',
workspaceSource: $workspaceSource,
recoveryAction: $pageCategory === TenantPageCategory::TenantScopedEvidence
? 'redirect_evidence_overview'
: 'abort_not_found',
recoveryDestination: $pageCategory === TenantPageCategory::TenantScopedEvidence
? '/admin/evidence/overview'
: null,
recoveryReason: $recoveryReason ?? 'missing_tenant',
);
}
return new ResolvedShellContext(
workspace: $workspace,
tenant: null,
pageCategory: $pageCategory,
state: 'tenantless_workspace',
displayMode: 'tenantless',
workspaceSource: $workspaceSource,
recoveryReason: $recoveryReason,
);
}
private function resolveValidatedFilamentTenant(
?Request $request = null,
?TenantPageCategory $pageCategory = null,
?Workspace $workspace = null,
): ?Tenant {
$tenant = Filament::getTenant();
if (! $tenant instanceof Tenant) {
@ -134,8 +256,9 @@ private function resolveValidatedFilamentTenant(?Request $request = null, ?Tenan
}
$pageCategory ??= $this->pageCategory($request);
$workspace ??= $this->workspaceContext->currentWorkspaceOrTenantWorkspace($tenant, $request);
if ($this->isContextTenantEntitled($tenant, $request, $pageCategory)) {
if ($workspace instanceof Workspace && $this->tenantValidationReason($tenant, $workspace, $request, $pageCategory) === null) {
return $tenant;
}
@ -144,19 +267,58 @@ private function resolveValidatedFilamentTenant(?Request $request = null, ?Tenan
return null;
}
private function resolveRouteTenant(?Request $request = null, ?TenantPageCategory $pageCategory = null): ?Tenant
private function resolveValidatedRouteTenant(
?Tenant $tenant,
Workspace $workspace,
?Request $request = null,
?TenantPageCategory $pageCategory = null,
): array {
$pageCategory ??= $this->pageCategory($request);
if (! $tenant instanceof Tenant) {
return ['tenant' => null, 'reason' => null];
}
$reason = $this->tenantValidationReason($tenant, $workspace, $request, $pageCategory);
if ($reason !== null) {
return ['tenant' => null, 'reason' => $reason];
}
return ['tenant' => $tenant, 'reason' => null];
}
private function resolveValidatedQueryHintTenant(
?Request $request,
Workspace $workspace,
TenantPageCategory $pageCategory,
): array {
if (! $pageCategory->allowsQueryTenantHints()) {
return ['tenant' => null, 'reason' => null];
}
$queryTenant = $this->resolveQueryTenantHint($request);
if (! $queryTenant instanceof Tenant) {
return ['tenant' => null, 'reason' => null];
}
$reason = $this->tenantValidationReason($queryTenant, $workspace, $request, $pageCategory);
if ($reason !== null) {
return ['tenant' => null, 'reason' => $reason];
}
return ['tenant' => $queryTenant, 'reason' => null];
}
private function resolveRouteTenantCandidate(?Request $request = null, ?TenantPageCategory $pageCategory = null): ?Tenant
{
$route = $request?->route();
$pageCategory ??= $this->pageCategory($request);
if ($route?->hasParameter('tenant')) {
$tenant = $this->resolveTenantRouteParameter($route->parameter('tenant'));
if (! $tenant instanceof Tenant || ! $this->isRouteTenantEntitled($tenant, $request, $pageCategory)) {
return null;
}
return $tenant;
return $this->resolveTenantIdentifier($route->parameter('tenant'));
}
if (
@ -167,24 +329,35 @@ private function resolveRouteTenant(?Request $request = null, ?TenantPageCategor
return null;
}
$tenant = $this->resolveTenantRouteParameter($route->parameter('record'));
if (! $tenant instanceof Tenant || ! $this->isRouteTenantEntitled($tenant, $request, $pageCategory)) {
return null;
}
return $tenant;
return $this->resolveTenantIdentifier($route->parameter('record'));
}
private function resolveTenantRouteParameter(mixed $routeTenant): ?Tenant
private function resolveQueryTenantHint(?Request $request = null): ?Tenant
{
if ($routeTenant instanceof Tenant) {
return $routeTenant;
$queryTenant = $request?->query('tenant');
if (filled($queryTenant)) {
return $this->resolveTenantIdentifier($queryTenant);
}
$routeTenant = trim((string) $routeTenant);
$queryTenantId = $request?->query('tenant_id');
if ($routeTenant === '') {
if (filled($queryTenantId)) {
return $this->resolveTenantIdentifier($queryTenantId);
}
return null;
}
private function resolveTenantIdentifier(mixed $tenantIdentifier): ?Tenant
{
if ($tenantIdentifier instanceof Tenant) {
return $tenantIdentifier;
}
$tenantIdentifier = trim((string) $tenantIdentifier);
if ($tenantIdentifier === '') {
return null;
}
@ -192,94 +365,58 @@ private function resolveTenantRouteParameter(mixed $routeTenant): ?Tenant
return Tenant::query()
->withTrashed()
->where(static function ($query) use ($routeTenant, $tenantKeyColumn): void {
$query->where('external_id', $routeTenant);
->where(static function ($query) use ($tenantIdentifier, $tenantKeyColumn): void {
$query->where('external_id', $tenantIdentifier);
if (ctype_digit($routeTenant)) {
$query->orWhere($tenantKeyColumn, (int) $routeTenant);
if (ctype_digit($tenantIdentifier)) {
$query->orWhere($tenantKeyColumn, (int) $tenantIdentifier);
}
})
->first();
}
private function isRouteTenantEntitled(Tenant $tenant, ?Request $request = null, ?TenantPageCategory $pageCategory = null): bool
{
$pageCategory ??= TenantPageCategory::fromRequest($request);
private function tenantValidationReason(
Tenant $tenant,
Workspace $workspace,
?Request $request = null,
?TenantPageCategory $pageCategory = null,
): ?string {
$pageCategory ??= $this->pageCategory($request);
if ($pageCategory !== TenantPageCategory::TenantBound) {
return $this->isContextTenantEntitled($tenant, $request, $pageCategory);
if ((int) $tenant->workspace_id !== (int) $workspace->getKey()) {
return 'mismatched_workspace';
}
return $this->evaluateOutcome(
tenant: $tenant,
request: $request,
question: TenantOperabilityQuestion::TenantBoundViewability,
lane: TenantInteractionLane::AdministrativeManagement,
);
}
private function isContextTenantEntitled(Tenant $tenant, ?Request $request = null, ?TenantPageCategory $pageCategory = null): bool
{
$pageCategory ??= TenantPageCategory::fromRequest($request);
return match ($pageCategory) {
TenantPageCategory::TenantBound => $this->evaluateOutcome(
tenant: $tenant,
request: $request,
question: TenantOperabilityQuestion::TenantBoundViewability,
lane: TenantInteractionLane::AdministrativeManagement,
),
TenantPageCategory::CanonicalWorkspaceRecordViewer,
TenantPageCategory::OnboardingWorkflow,
TenantPageCategory::WorkspaceScoped => $this->evaluateOutcome(
tenant: $tenant,
request: $request,
question: TenantOperabilityQuestion::AdministrativeDiscoverability,
lane: TenantInteractionLane::AdministrativeManagement,
),
};
}
private function isRememberedTenantValid(Tenant $tenant, ?Request $request = null): bool
{
return $this->evaluateOutcome(
tenant: $tenant,
request: $request,
question: TenantOperabilityQuestion::RememberedContextValidity,
lane: TenantInteractionLane::StandardActiveOperating,
);
}
private function evaluateOutcome(
Tenant $tenant,
?Request $request,
TenantOperabilityQuestion $question,
TenantInteractionLane $lane,
): bool {
$user = auth()->user();
if (! $user instanceof User) {
return false;
}
$workspaceId = $this->workspaceContext->currentWorkspaceId($request);
if ($workspaceId !== null && (int) $tenant->workspace_id !== (int) $workspaceId) {
return false;
return 'not_member';
}
if (! $this->capabilityResolver->isMember($user, $tenant)) {
return false;
return 'not_member';
}
return $this->tenantOperabilityService->outcomeFor(
$question = $pageCategory === TenantPageCategory::TenantBound
? TenantOperabilityQuestion::TenantBoundViewability
: TenantOperabilityQuestion::AdministrativeDiscoverability;
$allowed = $this->tenantOperabilityService->outcomeFor(
tenant: $tenant,
question: $question,
actor: $user,
workspaceId: $workspaceId,
lane: $lane,
workspaceId: (int) $workspace->getKey(),
lane: $pageCategory->lane(),
selectedTenant: Filament::getTenant() instanceof Tenant ? Filament::getTenant() : null,
)->allowed;
if ($allowed) {
return null;
}
return $pageCategory === TenantPageCategory::TenantBound
? 'inaccessible'
: 'not_operable';
}
private function pageCategory(?Request $request = null): TenantPageCategory

View File

@ -0,0 +1,40 @@
<?php
declare(strict_types=1);
namespace App\Support\OperateHub;
use App\Models\Tenant;
use App\Models\Workspace;
use App\Support\Tenants\TenantPageCategory;
final readonly class ResolvedShellContext
{
public function __construct(
public ?Workspace $workspace,
public ?Tenant $tenant,
public TenantPageCategory $pageCategory,
public string $state,
public string $displayMode,
public string $workspaceSource = 'none',
public string $tenantSource = 'none',
public string $recoveryAction = 'none',
public ?string $recoveryDestination = null,
public ?string $recoveryReason = null,
) {}
public function hasWorkspace(): bool
{
return $this->workspace instanceof Workspace;
}
public function hasTenant(): bool
{
return $this->tenant instanceof Tenant;
}
public function showsRecoveryNotice(): bool
{
return $this->displayMode === 'recovery' || $this->recoveryReason !== null;
}
}

View File

@ -15,9 +15,11 @@ public static function fromPageCategory(TenantPageCategory $pageCategory): self
{
return match ($pageCategory) {
TenantPageCategory::OnboardingWorkflow => self::OnboardingWorkflow,
TenantPageCategory::TenantBound => self::AdministrativeManagement,
TenantPageCategory::TenantBound,
TenantPageCategory::TenantScopedEvidence => self::AdministrativeManagement,
TenantPageCategory::CanonicalWorkspaceRecordViewer => self::CanonicalWorkspaceRecord,
TenantPageCategory::WorkspaceScoped => self::StandardActiveOperating,
TenantPageCategory::WorkspaceScoped,
TenantPageCategory::WorkspaceChooserException => self::StandardActiveOperating,
};
}
}

View File

@ -9,7 +9,9 @@
enum TenantPageCategory: string
{
case WorkspaceScoped = 'workspace_scoped';
case WorkspaceChooserException = 'workspace_chooser_exception';
case TenantBound = 'tenant_bound';
case TenantScopedEvidence = 'tenant_scoped_evidence';
case OnboardingWorkflow = 'onboarding_workflow';
case CanonicalWorkspaceRecordViewer = 'canonical_workspace_record_viewer';
@ -26,10 +28,21 @@ public static function fromPath(string $path): self
{
$normalizedPath = '/'.ltrim($path, '/');
if ($normalizedPath === '/admin/choose-workspace') {
return self::WorkspaceChooserException;
}
if (preg_match('#^/admin/operations/[^/]+$#', $normalizedPath) === 1) {
return self::CanonicalWorkspaceRecordViewer;
}
if (
str_starts_with($normalizedPath, '/admin/evidence/')
&& ! str_starts_with($normalizedPath, '/admin/evidence/overview')
) {
return self::TenantScopedEvidence;
}
if (preg_match('#^/admin/onboarding(?:/[^/]+)?$#', $normalizedPath) === 1) {
return self::OnboardingWorkflow;
}
@ -44,6 +57,41 @@ public static function fromPath(string $path): self
return self::WorkspaceScoped;
}
public function allowsQueryTenantHints(): bool
{
return match ($this) {
self::WorkspaceScoped, self::OnboardingWorkflow => true,
default => false,
};
}
public function allowsRememberedTenantRestore(): bool
{
return match ($this) {
self::WorkspaceScoped, self::OnboardingWorkflow, self::CanonicalWorkspaceRecordViewer => true,
default => false,
};
}
public function allowsTenantlessState(): bool
{
return match ($this) {
self::WorkspaceScoped,
self::WorkspaceChooserException,
self::OnboardingWorkflow,
self::CanonicalWorkspaceRecordViewer => true,
default => false,
};
}
public function requiresExplicitTenant(): bool
{
return match ($this) {
self::TenantBound, self::TenantScopedEvidence => true,
default => false,
};
}
public function lane(): TenantInteractionLane
{
return TenantInteractionLane::fromPageCategory($this);

View File

@ -55,6 +55,33 @@ public function currentWorkspace(?Request $request = null): ?Workspace
return $workspace;
}
public function currentWorkspaceOrTenantWorkspace(?Tenant $tenant = null, ?Request $request = null): ?Workspace
{
$workspace = $this->currentWorkspace($request);
if ($workspace instanceof Workspace) {
return $workspace;
}
if (! $tenant instanceof Tenant) {
return null;
}
$workspace = $tenant->workspace()->first();
if (! $workspace instanceof Workspace || ! $this->isWorkspaceSelectable($workspace)) {
return null;
}
$user = $request?->user() instanceof User ? $request->user() : auth()->user();
if (! $user instanceof User || ! $this->isMember($user, $workspace) || ! $this->userCanAccessTenant($tenant, $request)) {
return null;
}
return $workspace;
}
public function setCurrentWorkspace(Workspace $workspace, ?User $user = null, ?Request $request = null): void
{
$session = ($request && $request->hasSession()) ? $request->session() : session();

View File

@ -7,6 +7,7 @@
use App\Filament\Pages\ChooseTenant;
use App\Filament\Pages\ChooseWorkspace;
use App\Filament\Pages\TenantDashboard;
use App\Models\Tenant;
use App\Models\User;
use App\Models\Workspace;
use App\Services\Tenants\TenantOperabilityService;
@ -30,8 +31,12 @@ public function __construct(
*
* Returns a fully qualified URL string.
*/
public function resolve(Workspace $workspace, User $user): string
public function resolve(Workspace $workspace, User $user, ?string $intendedUrl = null): string
{
if (is_string($intendedUrl) && $this->intendedUrlMatchesWorkspace($intendedUrl, $workspace, $user)) {
return $intendedUrl;
}
$selectableTenants = $this->tenantOperabilityService->filterSelectable($user->tenants()
->where('workspace_id', $workspace->getKey())
->orderBy('name')
@ -71,4 +76,45 @@ public function resolveFromId(int $workspaceId, User $user): string
return $this->resolve($workspace, $user);
}
private function intendedUrlMatchesWorkspace(string $intendedUrl, Workspace $workspace, User $user): bool
{
$path = '/'.ltrim((string) (parse_url($intendedUrl, PHP_URL_PATH) ?? ''), '/');
if (! str_starts_with($path, '/admin')) {
return false;
}
if (preg_match('#^/admin/(?:t|tenants)/([^/]+)(?:/|$)#', $path, $matches) === 1) {
return $this->tenantIdentifierMatchesWorkspace($matches[1], $workspace, $user);
}
parse_str((string) (parse_url($intendedUrl, PHP_URL_QUERY) ?? ''), $query);
$tenantIdentifier = $query['tenant'] ?? $query['tenant_id'] ?? null;
if ($tenantIdentifier !== null && ! $this->tenantIdentifierMatchesWorkspace((string) $tenantIdentifier, $workspace, $user)) {
return false;
}
return true;
}
private function tenantIdentifierMatchesWorkspace(string $identifier, Workspace $workspace, User $user): bool
{
$tenant = Tenant::query()
->withTrashed()
->where(static function ($query) use ($identifier): void {
$query->where('external_id', $identifier);
if (ctype_digit($identifier)) {
$query->orWhereKey((int) $identifier);
}
})
->first();
return $tenant instanceof Tenant
&& (int) $tenant->workspace_id === (int) $workspace->getKey()
&& $user->canAccessTenant($tenant);
}
}

View File

@ -4,14 +4,14 @@
use App\Models\Tenant;
use App\Models\User;
use App\Support\OperateHub\OperateHubShell;
use App\Support\Tenants\TenantPageCategory;
use App\Support\Workspaces\WorkspaceContext;
use Filament\Facades\Filament;
/** @var WorkspaceContext $workspaceContext */
$workspaceContext = app(WorkspaceContext::class);
$resolvedContext = app(OperateHubShell::class)->resolvedContext(request());
$workspace = $workspaceContext->currentWorkspace(request());
$workspace = $resolvedContext->workspace;
$user = auth()->user();
@ -22,29 +22,17 @@
->values();
}
$operateHubShell = app(OperateHubShell::class);
$currentTenant = $operateHubShell->activeEntitledTenant(request());
$currentTenant = $resolvedContext->tenant;
$currentTenantId = $currentTenant instanceof Tenant ? (int) $currentTenant->getKey() : null;
$currentTenantName = $currentTenant instanceof Tenant ? $currentTenant->getFilamentName() : null;
$hasAnyFilamentTenantContext = Filament::getTenant() instanceof Tenant;
$route = request()->route();
$routeName = (string) ($route?->getName() ?? '');
$pageCategory = TenantPageCategory::fromRequest(request());
$tenantQuery = request()->query('tenant');
$hasTenantQuery = is_string($tenantQuery) && trim($tenantQuery) !== '';
$isTenantScopedRoute = $pageCategory === TenantPageCategory::TenantBound
|| ($hasTenantQuery && str_starts_with($routeName, 'filament.admin.'));
$lastTenantId = $workspaceContext->lastTenantId(request());
$canClearTenantContext = $hasAnyFilamentTenantContext || $lastTenantId !== null;
$canClearTenantContext = $currentTenant instanceof Tenant || $lastTenantId !== null;
@endphp
@php
$tenantLabel = $currentTenantName ?? 'All tenants';
$workspaceLabel = $workspace?->name ?? 'Select workspace';
$tenantLabel = $currentTenantName ?? 'No tenant selected';
$workspaceLabel = $workspace?->name ?? 'Choose workspace';
$hasActiveTenant = $currentTenantName !== null;
$managedTenantsUrl = $workspace
? route('admin.workspace.managed-tenants.index', ['workspace' => $workspace])
@ -52,7 +40,7 @@
$workspaceUrl = $workspace
? route('admin.home')
: ChooseWorkspace::getUrl(panel: 'admin');
$tenantTriggerLabel = $workspace ? ($hasActiveTenant ? $tenantLabel : 'No tenant selected') : 'Select tenant';
$tenantTriggerLabel = $workspace ? $tenantLabel : 'Choose workspace';
@endphp
<div class="inline-flex items-center gap-0 rounded-lg border border-gray-200 bg-white text-sm dark:border-white/10 dark:bg-white/5">
@ -88,6 +76,18 @@ class="inline-flex items-center gap-1.5 rounded-r-lg px-2 py-1.5 transition hove
<x-filament::dropdown.list>
<div class="space-y-3 px-3 py-2" x-data="{ query: '' }">
@if ($resolvedContext->showsRecoveryNotice())
<div class="rounded-lg border border-amber-200 bg-amber-50 px-3 py-2 text-xs text-amber-800 dark:border-amber-500/30 dark:bg-amber-500/10 dark:text-amber-200">
<div class="font-semibold">Context unavailable</div>
@if ($workspace)
<div>The requested scope could not be restored. The shell is showing a valid workspace state instead.</div>
@else
<div>Choose a workspace to continue with a valid admin context.</div>
@endif
</div>
@endif
{{-- Workspace section --}}
<div class="space-y-1">
<div class="text-xs font-semibold uppercase tracking-wider text-gray-400 dark:text-gray-500">
@ -128,16 +128,28 @@ class="flex items-center gap-2 rounded-lg px-3 py-2 text-sm text-gray-700 transi
</div>
</div>
@if ($isTenantScopedRoute)
<div class="flex items-center gap-2 rounded-lg bg-primary-50 px-3 py-2 dark:bg-primary-950/30">
<x-filament::icon icon="heroicon-o-building-office-2" class="h-4 w-4 text-primary-600 dark:text-primary-400" />
<span class="text-sm font-medium text-primary-700 dark:text-primary-300">{{ $currentTenantName ?? 'Tenant' }}</span>
<a
href="{{ ChooseTenant::getUrl(panel: 'admin') }}"
class="ml-auto text-xs font-medium text-primary-600 hover:text-primary-500 dark:text-primary-400 dark:hover:text-primary-300"
>
Switch tenant
</a>
@if ($resolvedContext->pageCategory->requiresExplicitTenant() && $hasActiveTenant)
<div class="space-y-2">
<div class="flex items-center gap-2 rounded-lg bg-primary-50 px-3 py-2 dark:bg-primary-950/30">
<x-filament::icon icon="heroicon-o-building-office-2" class="h-4 w-4 text-primary-600 dark:text-primary-400" />
<span class="text-sm font-medium text-primary-700 dark:text-primary-300">{{ $currentTenantName }}</span>
<a
href="{{ ChooseTenant::getUrl(panel: 'admin') }}"
class="ml-auto text-xs font-medium text-primary-600 hover:text-primary-500 dark:text-primary-400 dark:hover:text-primary-300"
>
Switch tenant
</a>
</div>
@if ($canClearTenantContext)
<form method="POST" action="{{ route('admin.clear-tenant-context') }}">
@csrf
<button type="submit" class="w-full rounded-lg px-3 py-1.5 text-left text-xs font-medium text-gray-500 transition hover:bg-gray-50 hover:text-gray-700 dark:text-gray-400 dark:hover:bg-white/5 dark:hover:text-gray-200">
Clear tenant scope
</button>
</form>
@endif
</div>
@else
@if ($tenants->isEmpty())

View File

@ -0,0 +1,41 @@
<?php
declare(strict_types=1);
use App\Models\Tenant;
use App\Models\User;
use App\Support\Workspaces\WorkspaceContext;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
it('shows a recovery label when workspace-scoped surfaces render without an active workspace', function (): void {
$tenant = Tenant::factory()->active()->create(['name' => 'Recovery Tenant']);
[$user] = createUserWithTenant(tenant: $tenant, role: 'owner');
session()->forget(WorkspaceContext::SESSION_KEY);
$this->actingAs($user)
->get('/admin/workspaces')
->assertOk()
->assertSee('Context unavailable')
->assertSee('Choose workspace');
});
it('shows explicit recovery wording when an invalid tenant hint is discarded on a workspace route', function (): void {
$validTenant = Tenant::factory()->active()->create(['name' => 'Valid Workspace Tenant']);
[$user, $validTenant] = createUserWithTenant(tenant: $validTenant, role: 'owner');
$foreignTenant = Tenant::factory()->active()->create(['name' => 'Foreign Workspace Tenant']);
createUserWithTenant(tenant: $foreignTenant, user: User::factory()->create(), role: 'owner');
$this->actingAs($user)
->withSession([
WorkspaceContext::SESSION_KEY => (int) $validTenant->workspace_id,
])
->get(route('admin.operations.index', ['tenant' => $foreignTenant->external_id]))
->assertOk()
->assertSee('Context unavailable')
->assertSee('No tenant selected')
->assertDontSee('Tenant scope: '.$foreignTenant->name);
});

View File

@ -5,6 +5,7 @@
use App\Models\Workspace;
use App\Models\WorkspaceMembership;
use App\Support\Workspaces\WorkspaceContext;
use Filament\Facades\Filament;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
@ -63,3 +64,36 @@
->assertSee($workspace->name)
->assertDontSee('name="workspace_id"', escape: false);
});
test('workspace-scoped operations honor a valid tenant query hint over remembered tenant context', function () {
$rememberedTenant = Tenant::factory()->create([
'workspace_id' => null,
'status' => 'active',
'name' => 'Remembered Topbar Tenant',
]);
[$user, $rememberedTenant] = createUserWithTenant(tenant: $rememberedTenant, role: 'owner');
$hintedTenant = Tenant::factory()->create([
'workspace_id' => (int) $rememberedTenant->workspace_id,
'status' => 'active',
'name' => 'Hinted Topbar Tenant',
]);
createUserWithTenant(tenant: $hintedTenant, user: $user, role: 'owner');
Filament::setTenant(null, true);
$workspaceId = (int) $rememberedTenant->workspace_id;
$this->actingAs($user)
->withSession([
WorkspaceContext::SESSION_KEY => $workspaceId,
WorkspaceContext::LAST_TENANT_IDS_SESSION_KEY => [
(string) $workspaceId => (int) $rememberedTenant->getKey(),
],
])
->get(route('admin.operations.index', ['tenant' => $hintedTenant->external_id]))
->assertOk()
->assertSee('Tenant scope: Hinted Topbar Tenant')
->assertDontSee('Tenant scope: Remembered Topbar Tenant');
});

View File

@ -65,13 +65,11 @@
$this->actingAs($user)
->get('/admin/workspaces')
->assertOk()
->assertSee('Select workspace')
->assertSee('Select tenant')
->assertSee('Choose a workspace first.')
->assertDontSee('Search tenants…');
});
it('renders the tenant indicator read-only on tenant-scoped pages (Filament tenant menu is primary)', function (): void {
it('keeps tenant-scoped pages selector read-only while exposing the clear tenant scope action', function (): void {
$tenant = Tenant::factory()->create(['status' => 'active']);
[$user, $tenant] = createUserWithTenant($tenant, role: 'owner');
@ -82,12 +80,10 @@
->get(route('filament.admin.resources.tenants.index', filamentTenantRouteParams($tenant)))
->assertOk()
->assertSee($tenant->getFilamentName())
->assertDontSee('Search tenants…')
->assertDontSee('admin/select-tenant')
->assertDontSee('Clear tenant scope');
->assertSee('Clear tenant scope');
});
it('renders the routed tenant as read-only context on tenant resource view pages', function (): void {
it('renders routed tenant resource view pages with a clear tenant scope action but no inline selector list', function (): void {
$currentTenant = Tenant::factory()->create([
'status' => 'active',
'name' => 'YPTW2',
@ -117,9 +113,7 @@
->assertOk()
->assertSee($routedTenant->getFilamentName())
->assertSee('Switch tenant')
->assertDontSee('Search tenants…')
->assertDontSee('admin/select-tenant')
->assertDontSee('Clear tenant scope');
->assertSee('Clear tenant scope');
});
it('filters the header tenant picker to tenants the user can access', function (): void {

View File

@ -0,0 +1,43 @@
<?php
declare(strict_types=1);
use App\Filament\Pages\TenantDashboard;
use App\Models\Tenant;
use App\Models\User;
use App\Support\Workspaces\WorkspaceContext;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
it('shows the routed workspace and tenant truth on tenant-panel entry without relying on session workspace state', function (): void {
$tenant = Tenant::factory()->active()->create(['name' => 'Tenant Panel Entry']);
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner');
session()->forget(WorkspaceContext::SESSION_KEY);
$this->actingAs($user)
->get(TenantDashboard::getUrl(panel: 'tenant', tenant: $tenant))
->assertOk()
->assertSee($tenant->workspace()->firstOrFail()->name)
->assertSee('Tenant Panel Entry')
->assertSee('Switch tenant')
->assertSee('Clear tenant scope')
->assertDontSee('Search tenants…')
->assertDontSee('admin/select-tenant');
});
it('keeps workspace-scoped routes tenantless when a cross-workspace tenant hint is rejected', function (): void {
$workspaceTenant = Tenant::factory()->active()->create(['name' => 'Workspace Tenant']);
[$user, $workspaceTenant] = createUserWithTenant(tenant: $workspaceTenant, role: 'owner');
$foreignTenant = Tenant::factory()->active()->create(['name' => 'Rejected Foreign Tenant']);
createUserWithTenant(tenant: $foreignTenant, user: User::factory()->create(), role: 'owner');
$this->actingAs($user)
->withSession([WorkspaceContext::SESSION_KEY => (int) $workspaceTenant->workspace_id])
->get(route('admin.operations.index', ['tenant' => $foreignTenant->external_id]))
->assertOk()
->assertSee('No tenant selected')
->assertDontSee('Tenant scope: Rejected Foreign Tenant');
});

View File

@ -87,3 +87,43 @@
])
->assertRedirect('/admin/choose-workspace');
});
it('returns 404 when selecting a tenant from another workspace', function (): void {
$activeTenant = Tenant::factory()->active()->create(['name' => 'Current Workspace Tenant']);
[$user, $activeTenant] = createUserWithTenant(tenant: $activeTenant, role: 'owner');
$foreignTenant = Tenant::factory()->active()->create(['name' => 'Foreign Workspace Tenant']);
createUserWithTenant(tenant: $foreignTenant, user: $user, role: 'owner');
$this->actingAs($user)
->withSession([WorkspaceContext::SESSION_KEY => (int) $activeTenant->workspace_id])
->post(route('admin.select-tenant'), [
'tenant_id' => (int) $foreignTenant->getKey(),
])
->assertNotFound();
expect(session()->get(WorkspaceContext::LAST_TENANT_IDS_SESSION_KEY, []))
->not->toHaveKey((string) $activeTenant->workspace_id);
});
it('returns 404 when selecting a tenant the user cannot access', function (): void {
$user = User::factory()->create();
$workspace = Workspace::factory()->create();
WorkspaceMembership::factory()->create([
'workspace_id' => (int) $workspace->getKey(),
'user_id' => (int) $user->getKey(),
'role' => 'owner',
]);
$tenant = Tenant::factory()->active()->create([
'workspace_id' => (int) $workspace->getKey(),
]);
$this->actingAs($user)
->withSession([WorkspaceContext::SESSION_KEY => (int) $workspace->getKey()])
->post(route('admin.select-tenant'), [
'tenant_id' => (int) $tenant->getKey(),
])
->assertNotFound();
});

View File

@ -0,0 +1,34 @@
<?php
declare(strict_types=1);
use App\Filament\Pages\TenantDashboard;
use App\Models\Tenant;
use App\Support\Workspaces\WorkspaceContext;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
it('ignores an intended tenant route that is not valid in the target workspace', function (): void {
$sourceTenant = Tenant::factory()->active()->create(['name' => 'Source Tenant']);
[$user, $sourceTenant] = createUserWithTenant(tenant: $sourceTenant, role: 'owner');
$targetWorkspaceTenant = Tenant::factory()->active()->create([
'name' => 'Target Tenant',
]);
createUserWithTenant(tenant: $targetWorkspaceTenant, user: $user, role: 'owner');
$targetWorkspace = $targetWorkspaceTenant->workspace()->firstOrFail();
$response = $this->actingAs($user)
->withSession([
WorkspaceContext::SESSION_KEY => (int) $sourceTenant->workspace_id,
WorkspaceContext::INTENDED_URL_SESSION_KEY => "/admin/t/{$sourceTenant->external_id}",
])
->post(route('admin.switch-workspace'), [
'workspace_id' => (int) $targetWorkspace->getKey(),
]);
$response->assertRedirect(TenantDashboard::getUrl(panel: 'tenant', tenant: $targetWorkspaceTenant));
expect(session(WorkspaceContext::SESSION_KEY))->toBe((int) $targetWorkspace->getKey());
});

View File

@ -119,3 +119,59 @@
expect($url)->toBe($expectedRoute);
});
it('preserves a safe intended admin url that targets a tenant in the selected workspace', function (): void {
$user = User::factory()->create();
$workspace = Workspace::factory()->create();
WorkspaceMembership::factory()->create([
'workspace_id' => $workspace->getKey(),
'user_id' => $user->getKey(),
'role' => 'owner',
]);
$tenant = Tenant::factory()->create([
'workspace_id' => $workspace->getKey(),
'status' => 'active',
]);
$user->tenants()->syncWithoutDetaching([
$tenant->getKey() => ['role' => 'owner'],
]);
$intendedUrl = route('admin.operations.index', ['tenant' => $tenant->external_id]);
$url = $this->resolver->resolve($workspace, $user, $intendedUrl);
expect($url)->toBe($intendedUrl);
});
it('rejects an unsafe intended admin url when its tenant hint targets another workspace', function (): void {
$user = User::factory()->create();
$workspace = Workspace::factory()->create();
WorkspaceMembership::factory()->create([
'workspace_id' => $workspace->getKey(),
'user_id' => $user->getKey(),
'role' => 'owner',
]);
$tenant = Tenant::factory()->create([
'workspace_id' => $workspace->getKey(),
'status' => 'active',
]);
$user->tenants()->syncWithoutDetaching([
$tenant->getKey() => ['role' => 'owner'],
]);
$foreignTenant = Tenant::factory()->create([
'status' => 'active',
]);
$intendedUrl = route('admin.operations.index', ['tenant' => $foreignTenant->external_id]);
$url = $this->resolver->resolve($workspace, $user, $intendedUrl);
expect($url)->toBe(TenantDashboard::getUrl(panel: 'tenant', tenant: $tenant));
});

View File

@ -45,7 +45,7 @@
$this->actingAs($user)
->get('/admin/workspaces')
->assertOk()
->assertSee('Select workspace')
->assertSee('Choose workspace')
->assertSee('Choose a workspace first.');
});

View File

@ -0,0 +1,102 @@
<?php
declare(strict_types=1);
use App\Models\Tenant;
use App\Models\User;
use App\Support\OperateHub\OperateHubShell;
use App\Support\Workspaces\WorkspaceContext;
use Filament\Facades\Filament;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Http\Request;
uses(RefreshDatabase::class);
it('prefers a valid tenant query hint over remembered tenant state on workspace-scoped admin routes', function (): void {
$rememberedTenant = Tenant::factory()->active()->create(['name' => 'Remembered Tenant']);
[$user, $rememberedTenant] = createUserWithTenant(tenant: $rememberedTenant, role: 'owner');
$hintedTenant = Tenant::factory()->active()->create([
'workspace_id' => (int) $rememberedTenant->workspace_id,
'name' => 'Hinted Tenant',
]);
createUserWithTenant(tenant: $hintedTenant, user: $user, role: 'owner');
$this->actingAs($user);
Filament::setTenant(null, true);
$workspaceId = (int) $rememberedTenant->workspace_id;
session()->put(WorkspaceContext::SESSION_KEY, $workspaceId);
session()->put(WorkspaceContext::LAST_TENANT_IDS_SESSION_KEY, [
(string) $workspaceId => (int) $rememberedTenant->getKey(),
]);
$request = Request::create(route('admin.operations.index', ['tenant' => $hintedTenant->external_id]));
$request->setLaravelSession(app('session.store'));
$request->setUserResolver(static fn () => $user);
$route = app('router')->getRoutes()->match($request);
$request->setRouteResolver(static fn () => $route);
$resolved = app(OperateHubShell::class)->resolvedContext($request);
expect($resolved->workspace?->getKey())->toBe($workspaceId)
->and($resolved->tenant?->is($hintedTenant))->toBeTrue()
->and($resolved->tenantSource)->toBe('query_hint')
->and($resolved->state)->toBe('tenant_scoped');
});
it('falls back to a tenantless workspace state when a tenant query hint targets another workspace', function (): void {
$workspaceTenant = Tenant::factory()->active()->create(['name' => 'Current Workspace Tenant']);
[$user, $workspaceTenant] = createUserWithTenant(tenant: $workspaceTenant, role: 'owner');
$foreignTenant = Tenant::factory()->active()->create(['name' => 'Foreign Tenant']);
createUserWithTenant(tenant: $foreignTenant, user: User::factory()->create(), role: 'owner');
$this->actingAs($user);
Filament::setTenant(null, true);
$workspaceId = (int) $workspaceTenant->workspace_id;
session()->put(WorkspaceContext::SESSION_KEY, $workspaceId);
$request = Request::create(route('admin.operations.index', ['tenant' => $foreignTenant->external_id]));
$request->setLaravelSession(app('session.store'));
$request->setUserResolver(static fn () => $user);
$route = app('router')->getRoutes()->match($request);
$request->setRouteResolver(static fn () => $route);
$resolved = app(OperateHubShell::class)->resolvedContext($request);
expect($resolved->workspace?->getKey())->toBe($workspaceId)
->and($resolved->tenant)->toBeNull()
->and($resolved->state)->toBe('tenantless_workspace')
->and($resolved->recoveryReason)->toBe('mismatched_workspace');
});
it('uses the routed tenant workspace when the tenant panel is entered without a selected workspace session', function (): void {
$tenant = Tenant::factory()->active()->create(['name' => 'Tenant Panel Scope']);
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner');
$this->actingAs($user);
Filament::setTenant(null, true);
session()->forget(WorkspaceContext::SESSION_KEY);
$request = Request::create("/admin/t/{$tenant->external_id}");
$request->setLaravelSession(app('session.store'));
$request->setUserResolver(static fn () => $user);
$route = app('router')->getRoutes()->match($request);
$request->setRouteResolver(static fn () => $route);
$resolved = app(OperateHubShell::class)->resolvedContext($request);
expect($resolved->workspace?->getKey())->toBe((int) $tenant->workspace_id)
->and($resolved->tenant?->is($tenant))->toBeTrue()
->and($resolved->workspaceSource)->toBe('route')
->and($resolved->tenantSource)->toBe('route');
});

View File

@ -11,9 +11,12 @@
expect(TenantPageCategory::fromPath($path))->toBe($expected);
})->with([
'workspace overview' => ['/admin', TenantPageCategory::WorkspaceScoped],
'workspace chooser exception' => ['/admin/choose-workspace', TenantPageCategory::WorkspaceChooserException],
'tenant chooser' => ['/admin/choose-tenant', TenantPageCategory::WorkspaceScoped],
'tenant detail' => ['/admin/tenants/tenant-123', TenantPageCategory::TenantBound],
'tenant panel route' => ['/admin/t/tenant-123', TenantPageCategory::TenantBound],
'tenant scoped evidence detail' => ['/admin/evidence/123', TenantPageCategory::TenantScopedEvidence],
'evidence overview' => ['/admin/evidence/overview', TenantPageCategory::WorkspaceScoped],
'onboarding index' => ['/admin/onboarding', TenantPageCategory::OnboardingWorkflow],
'onboarding draft' => ['/admin/onboarding/42', TenantPageCategory::OnboardingWorkflow],
'operations index' => ['/admin/operations', TenantPageCategory::WorkspaceScoped],

View File

@ -635,10 +635,11 @@ ### Key Commands
```bash
# Local dev
corepack pnpm install # Install workspace JS dependencies
corepack pnpm dev:platform # Start the platform stack
corepack pnpm dev:platform # Start the platform stack + panel Vite watcher
corepack pnpm dev:website # Start the website
corepack pnpm dev # Start platform Vite + website together
corepack pnpm build:website # Build the website
cd apps/platform && ./vendor/bin/sail pnpm build # Build platform frontend
corepack pnpm build:platform # Build platform frontend inside Sail
# Testing
cd apps/platform && ./vendor/bin/sail artisan test --compact # Full suite

View File

@ -6,10 +6,10 @@
"node": ">=20.0.0"
},
"scripts": {
"dev": "./scripts/platform-sail up -d && corepack pnpm dev:website",
"dev:platform": "./scripts/platform-sail up -d",
"dev": "bash ./scripts/dev-workspace",
"dev:platform": "bash ./scripts/dev-platform",
"dev:website": "WEBSITE_PORT=${WEBSITE_PORT:-4321} corepack pnpm --filter @tenantatlas/website dev",
"build:platform": "corepack pnpm --filter @tenantatlas/platform build",
"build:platform": "./scripts/platform-sail pnpm build",
"build:website": "corepack pnpm --filter @tenantatlas/website build"
}
}

15
scripts/dev-platform Normal file
View File

@ -0,0 +1,15 @@
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
"${SCRIPT_DIR}/platform-sail" up -d
if curl --silent --fail --max-time 2 http://127.0.0.1:5173/@vite/client >/dev/null; then
echo "Platform Vite dev server already running at http://localhost:5173"
exit 0
fi
exec bash "${SCRIPT_DIR}/platform-vite-dev"

28
scripts/dev-workspace Normal file
View File

@ -0,0 +1,28 @@
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
ROOT_DIR="$(cd "${SCRIPT_DIR}/.." && pwd)"
platform_pid=''
cleanup() {
if [[ -n "${platform_pid}" ]]; then
kill "${platform_pid}" 2>/dev/null || true
wait "${platform_pid}" 2>/dev/null || true
fi
}
trap cleanup EXIT INT TERM
"${SCRIPT_DIR}/platform-sail" up -d
if curl --silent --fail --max-time 2 http://127.0.0.1:5173/@vite/client >/dev/null; then
echo "Platform Vite dev server already running at http://localhost:5173"
else
bash "${SCRIPT_DIR}/platform-vite-dev" &
platform_pid=$!
fi
cd "${ROOT_DIR}"
WEBSITE_PORT="${WEBSITE_PORT:-4321}" corepack pnpm --filter @tenantatlas/website dev

View File

@ -11,4 +11,6 @@ APP_DIR="${SCRIPT_DIR}/../apps/platform"
cd "${APP_DIR}"
export COMPOSE_PROJECT_NAME="${COMPOSE_PROJECT_NAME:-tenantatlas}"
exec ./vendor/bin/sail "$@"

View File

@ -0,0 +1,7 @@
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
exec "${SCRIPT_DIR}/platform-sail" pnpm dev

View File

@ -0,0 +1,36 @@
# Specification Quality Checklist: Global Context Shell Contract
**Purpose**: Validate specification completeness and quality before proceeding to planning
**Created**: 2026-04-18
**Feature**: [spec.md](../spec.md)
## Content Quality
- [x] No implementation details (languages, frameworks, APIs)
- [x] Focused on user value and business needs
- [x] Written for non-technical stakeholders
- [x] All mandatory sections completed
## Requirement Completeness
- [x] No [NEEDS CLARIFICATION] markers remain
- [x] Requirements are testable and unambiguous
- [x] Success criteria are measurable
- [x] Success criteria are technology-agnostic (no implementation details)
- [x] All acceptance scenarios are defined
- [x] Edge cases are identified
- [x] Scope is clearly bounded
- [x] Dependencies and assumptions identified
## Feature Readiness
- [x] All functional requirements have clear acceptance criteria
- [x] User scenarios cover primary flows
- [x] Feature meets measurable outcomes defined in Success Criteria
- [x] No implementation details leak into specification
## Notes
- Specification reviewed against the repo spec template and constitution prompts on 2026-04-18.
- No `[NEEDS CLARIFICATION]` markers remain.
- Route and shell-surface references are included only to bound the affected product surfaces and do not prescribe implementation structure.

View File

@ -0,0 +1,467 @@
openapi: 3.1.0
info:
title: Global Context Shell Logical Contract
version: 0.1.0
summary: Logical HTTP contract for workspace and tenant shell context resolution and mutation flows
description: >-
This is a logical contract for Spec 199. The real routes render HTML and redirects,
but the schemas below define the canonical request-scoped shell context and the
expected redirect or recovery outcomes for shared workspace and tenant shell flows.
servers:
- url: /
description: Application root for admin and tenant shell entry surfaces
tags:
- name: shell-context
- name: workspace-switch
- name: tenant-select
- name: tenant-clear
paths:
/admin:
get:
tags: [shell-context]
summary: Resolve workspace-scoped shell entry
description: Resolve the active workspace and optional tenant context for a workspace-scoped admin route, including query-backed tenant hints only where the contract explicitly allows them.
parameters:
- name: tenant
in: query
required: false
schema:
type: string
description: Optional tenant external-ID hint on routes that explicitly allow query-backed shell resolution.
- name: tenant_id
in: query
required: false
schema:
oneOf:
- type: integer
- type: string
description: Optional tenant identifier hint on workspace-scoped routes that explicitly allow query-backed context hints.
responses:
'200':
description: Workspace-scoped shell entry resolved successfully.
content:
application/json:
schema:
$ref: '#/components/schemas/ResolvedShellContextEnvelope'
examples:
tenantless:
value:
resolvedContext:
state: tenantless_workspace
displayMode: tenantless
pageCategory: workspace_scoped
workspaceSource: session_workspace
tenantSource: none
workspace:
id: 42
slug: alpha-workspace
name: Alpha Workspace
tenant: null
recoveryDirective:
action: none
reason: null
destination: null
preserveIntendedUrl: false
rememberedTenant:
value:
resolvedContext:
state: tenant_scoped
displayMode: tenant_scoped
pageCategory: workspace_scoped
workspaceSource: session_workspace
tenantSource: remembered
workspace:
id: 42
slug: alpha-workspace
name: Alpha Workspace
tenant:
id: 7
externalId: tenant-7
name: Tenant Seven
recoveryDirective:
action: none
reason: null
destination: null
preserveIntendedUrl: false
queryHintTenant:
value:
resolvedContext:
state: tenant_scoped
displayMode: tenant_scoped
pageCategory: workspace_scoped
workspaceSource: session_workspace
tenantSource: query_hint
workspace:
id: 42
slug: alpha-workspace
name: Alpha Workspace
tenant:
id: 7
externalId: tenant-7
name: Tenant Seven
requestedContext:
workspaceIdentifier: null
tenantIdentifier: tenant-7
source: query_hint
pageCategory: workspace_scoped
recoveryDirective:
action: none
reason: null
destination: null
preserveIntendedUrl: false
'302':
description: No valid workspace could be resolved and the user must be redirected to a chooser or safe fallback.
'404':
description: The requested context implies inaccessible or invalid workspace-bound data that cannot be widened safely.
/admin/choose-workspace:
get:
tags: [shell-context]
summary: Resolve the explicit workspace chooser exception route
description: Render the explicit workspace chooser exception route used when no workspace truth can be recovered or when the operator must select a workspace directly.
responses:
'200':
description: Workspace chooser rendered as the explicit recovery route.
content:
application/json:
schema:
$ref: '#/components/schemas/ResolvedShellContextEnvelope'
examples:
chooser:
value:
resolvedContext:
state: missing_workspace
displayMode: recovery
pageCategory: workspace_chooser_exception
workspaceSource: none
tenantSource: none
workspace: null
tenant: null
recoveryDirective:
action: none
reason: missing_workspace
destination: /admin/choose-workspace
preserveIntendedUrl: true
/admin/choose-tenant:
get:
tags: [shell-context]
summary: Resolve the explicit choose-tenant route after workspace selection
description: Render the explicit choose-tenant route used when a resolved workspace has multiple selectable tenants.
responses:
'200':
description: Choose-tenant route rendered successfully.
content:
application/json:
schema:
$ref: '#/components/schemas/ResolvedShellContextEnvelope'
examples:
chooseTenant:
value:
resolvedContext:
state: tenantless_workspace
displayMode: tenantless
pageCategory: workspace_scoped
workspaceSource: session_workspace
tenantSource: none
workspace:
id: 42
slug: alpha-workspace
name: Alpha Workspace
tenant: null
recoveryDirective:
action: none
reason: null
destination: /admin/choose-tenant
preserveIntendedUrl: false
/admin/t/{external_id}:
get:
tags: [shell-context]
summary: Resolve tenant-bound shell entry
description: Resolve tenant context for a tenant-bound route where explicit tenant routing is required.
parameters:
- name: external_id
in: path
required: true
schema:
type: string
responses:
'200':
description: Tenant-bound shell entry resolved successfully.
content:
application/json:
schema:
$ref: '#/components/schemas/ResolvedShellContextEnvelope'
examples:
tenantBound:
value:
resolvedContext:
state: tenant_scoped
displayMode: tenant_scoped
pageCategory: tenant_bound
workspaceSource: session_workspace
tenantSource: route
workspace:
id: 42
slug: alpha-workspace
name: Alpha Workspace
tenant:
id: 7
externalId: tenant-7
name: Tenant Seven
recoveryDirective:
action: none
reason: null
destination: null
preserveIntendedUrl: false
'404':
description: The route tenant is invalid, inaccessible, or incompatible with the active workspace.
/admin/switch-workspace:
post:
tags: [workspace-switch]
summary: Switch the active workspace
description: Set the active workspace, re-evaluate tenant compatibility, and redirect to a safe concrete destination such as an intended `/admin...` URL, `admin.workspace.managed-tenants.index`, `/admin/choose-tenant`, or the tenant dashboard.
requestBody:
required: true
content:
application/x-www-form-urlencoded:
schema:
type: object
required: [workspace_id]
properties:
workspace_id:
type: integer
responses:
'302':
description: Workspace switch accepted and redirected to intended URL or resolver destination.
headers:
Location:
schema:
type: string
description: Safe destination for the resolved workspace and resulting tenant state, currently an intended `/admin...` URL, `admin.workspace.managed-tenants.index`, `/admin/choose-tenant`, or a tenant dashboard route.
'404':
description: Workspace does not exist, is archived, or is not accessible to the current user.
'422':
description: Request body failed validation.
/admin/select-tenant:
post:
tags: [tenant-select]
summary: Select the active tenant inside the resolved workspace
description: Explicitly activate a tenant that belongs to the current workspace and passes entitlement and operability checks, then redirect to the deterministic tenant entry route for that tenant.
requestBody:
required: true
content:
application/x-www-form-urlencoded:
schema:
type: object
required: [tenant_id]
properties:
tenant_id:
type: integer
responses:
'302':
description: Tenant selection accepted and redirected to the deterministic tenant entry route for the selected tenant.
headers:
Location:
schema:
type: string
'404':
description: Tenant is missing, inaccessible, incompatible with the active workspace, or fails operability rules.
'422':
description: Request body failed validation.
/admin/clear-tenant-context:
post:
tags: [tenant-clear]
summary: Clear active tenant context
description: Remove remembered and panel tenant state, then resolve according to page category and route compatibility to either same-route tenantless workspace state or one of the documented concrete destinations: `admin.operations.index`, `admin.evidence.overview`, `admin.workspace.managed-tenants.index`, `admin.operations.view`, or `admin.home`.
responses:
'302':
description: Tenant context cleared and request resolved to tenantless workspace state on the current route or redirected to one of the documented concrete workspace-safe fallbacks.
headers:
Location:
schema:
type: string
'404':
description: The current route cannot recover safely because scope is no longer accessible.
components:
schemas:
RequestedContext:
type: object
properties:
workspaceIdentifier:
oneOf:
- type: integer
- type: string
- type: 'null'
tenantIdentifier:
oneOf:
- type: integer
- type: string
- type: 'null'
source:
$ref: '#/components/schemas/ContextSource'
pageCategory:
$ref: '#/components/schemas/PageCategory'
RememberedContextCandidate:
type: object
properties:
workspaceId:
type: integer
tenantId:
type:
- integer
- 'null'
source:
$ref: '#/components/schemas/ContextSource'
eligible:
type: boolean
invalidReason:
type:
- string
- 'null'
ResolvedShellContextEnvelope:
type: object
required: [resolvedContext]
properties:
resolvedContext:
$ref: '#/components/schemas/ResolvedShellContext'
ResolvedShellContext:
type: object
required:
- state
- displayMode
- pageCategory
- workspaceSource
- tenantSource
- workspace
- tenant
- recoveryDirective
properties:
state:
$ref: '#/components/schemas/ShellState'
displayMode:
type: string
enum:
- tenant_scoped
- tenantless
- recovery
pageCategory:
$ref: '#/components/schemas/PageCategory'
workspaceSource:
$ref: '#/components/schemas/ContextSource'
tenantSource:
$ref: '#/components/schemas/ContextSource'
workspace:
oneOf:
- $ref: '#/components/schemas/WorkspaceReference'
- type: 'null'
tenant:
oneOf:
- $ref: '#/components/schemas/TenantReference'
- type: 'null'
requestedContext:
oneOf:
- $ref: '#/components/schemas/RequestedContext'
- type: 'null'
rememberedContext:
oneOf:
- $ref: '#/components/schemas/RememberedContextCandidate'
- type: 'null'
recoveryDirective:
$ref: '#/components/schemas/RecoveryDirective'
RecoveryDirective:
type: object
required: [action, reason, destination, preserveIntendedUrl]
properties:
action:
$ref: '#/components/schemas/RecoveryAction'
reason:
type:
- string
- 'null'
destination:
type:
- string
- 'null'
preserveIntendedUrl:
type: boolean
WorkspaceReference:
type: object
required: [id, slug, name]
properties:
id:
type: integer
slug:
type: string
name:
type: string
TenantReference:
type: object
required: [id, externalId, name]
properties:
id:
type: integer
externalId:
type: string
name:
type: string
ContextSource:
type: string
enum:
- route
- explicit_switch
- explicit_select
- session_workspace
- filament_tenant
- remembered
- query_hint
- none
PageCategory:
type: string
enum:
- workspace_scoped
- workspace_chooser_exception
- tenant_bound
- tenant_scoped_evidence
- canonical_workspace_record_viewer
ShellState:
type: string
enum:
- tenant_scoped
- tenantless_workspace
- missing_workspace
- invalid_workspace
- missing_tenant
- invalid_tenant
- inaccessible_tenant
- incompatible_tenant
RecoveryAction:
type: string
enum:
- none
- render_tenantless_workspace
- redirect_choose_workspace
- redirect_operations_index
- redirect_evidence_overview
- redirect_workspace_home
- redirect_workspace_managed_tenants
- redirect_workspace_record_fallback
- abort_not_found

View File

@ -0,0 +1,227 @@
# Data Model: Global Context Shell Contract
## Persistence Impact
- No new database tables, columns, or persisted shell artifacts are introduced.
- Existing session-backed fields remain the only durable support state used by the contract:
- `current_workspace_id`
- `workspace_intended_url`
- `workspace_last_tenant_ids`
- Existing user-level fields such as `users.last_workspace_id` and `users.last_tenant_id` or `user_tenant_preferences.last_used_at` remain support inputs only and do not become active shell truth.
## Context Source Inventory
| Source | Context facet | Source role | Owning seam | Validation / notes |
|---|---|---|---|---|
| Explicit workspace switch request | Workspace | leading | `SwitchWorkspaceController` + `WorkspaceContext` | Must point to an accessible, selectable workspace before it can replace current workspace truth. |
| Current session workspace | Workspace | leading | `WorkspaceContext` | Remains the default current-workspace truth when no stronger explicit request exists and membership is still valid. |
| Remembered last workspace | Workspace | supporting | `WorkspaceContext` | Restore-only candidate used when no valid current session workspace exists and the entry flow allows restore. |
| Route tenant parameter | Tenant | leading | Route + `OperateHubShell` | Strongest tenant source on tenant-bound routes. Must belong to the resolved workspace and remain entitled. |
| Explicit tenant selection request | Tenant | leading | `SelectTenantController` + `OperateHubShell` | Can activate tenant scope only inside an already resolved workspace. |
| Filament panel tenant state | Tenant | supporting | `ResolvesPanelTenantContext` | May support resolution only after workspace compatibility and entitlement checks succeed. |
| Remembered tenant for resolved workspace | Tenant | supporting | `WorkspaceContext` | Restore-only candidate on tenantless-capable workspace pages. Never valid on its own for tenant-bound routes. |
| Query hint | Workspace or Tenant | supporting only when contract explicitly allows it | `OperateHubShell` | Must never become effective truth unless the contract explicitly names the query-backed flow. |
| View-local shell inference | Workspace or Tenant | never-leading | Shared shell partials and page-local views | Rendering surfaces may display resolved truth only. They cannot evaluate precedence or recovery. |
## Runtime Entities
| Entity | Kind | Fields | Validation / Notes |
|---|---|---|---|
| RequestedWorkspaceContext | Derived runtime input | `workspaceIdentifier`, `source`, `intendedUrl`, `pageCategory` | Represents a workspace request from route, explicit switch flow, or initial restore path before validation. |
| RequestedTenantContext | Derived runtime input | `tenantIdentifier`, `source`, `pageCategory`, `requiresExplicitTenant` | Represents route tenant, explicit tenant select, query hint, Filament tenant, or remembered tenant before validation. |
| RememberedContextCandidate | Derived support state | `workspaceId`, `tenantId`, `source`, `eligible` | Represents stored last-used tenant for the active workspace or last-used workspace during initial resolution. Never leading by itself. |
| ResolvedShellContext | Canonical request-scoped truth | `workspace`, `tenant`, `pageCategory`, `workspaceSource`, `tenantSource`, `state`, `recoveryDirective`, `displayMode` | The only context object shell UI and server-side consumers should trust for the current request. |
| RecoveryDirective | Derived outcome | `action`, `destination`, `reason`, `preserveIntendedUrl` | Encodes whether the request renders tenantless state, redirects, or aborts. |
| InvalidContext | Derived runtime outcome | `kind`, `source`, `reason`, `requestedWorkspaceIdentifier`, `requestedTenantIdentifier` | Captures why a requested or remembered context could not become active truth. |
## Supporting Enums / Value Domains
### ContextSource
- `route`
- `explicit_switch`
- `explicit_select`
- `session_workspace`
- `filament_tenant`
- `remembered`
- `query_hint`
- `none`
### ShellState
- `tenant_scoped`
- `tenantless_workspace`
- `missing_workspace`
- `invalid_workspace`
- `missing_tenant`
- `invalid_tenant`
- `inaccessible_tenant`
- `incompatible_tenant`
### RecoveryAction
- `none`
- `render_tenantless_workspace`
- `redirect_choose_workspace`
- `redirect_operations_index`
- `redirect_evidence_overview`
- `redirect_workspace_home`
- `redirect_workspace_managed_tenants`
- `redirect_workspace_record_fallback`
- `abort_not_found`
### PageCategory
- `workspace_scoped`
- `workspace_chooser_exception`
- `tenant_bound`
- `tenant_scoped_evidence`
- `canonical_workspace_record_viewer`
## Entity Details
### RequestedWorkspaceContext
| Field | Type | Required | Notes |
|---|---|---|---|
| `workspaceIdentifier` | string or int | yes | May come from explicit workspace switch flow or a safe intended URL restore path. |
| `source` | `ContextSource` | yes | `explicit_switch`, `session_workspace`, or `remembered` are the main inputs today. |
| `intendedUrl` | string or null | no | Safe `/admin...` path captured via `WorkspaceIntendedUrl`. |
| `pageCategory` | `PageCategory` | yes | Needed to determine if a tenantless fallback is valid after workspace resolution. |
**Validation rules**:
- Workspace must exist.
- Workspace must not be archived or otherwise unselectable.
- User must be a member of the workspace.
- Cross-plane routes remain out of scope; only `web`-guarded admin and tenant routes participate.
### RequestedTenantContext
| Field | Type | Required | Notes |
|---|---|---|---|
| `tenantIdentifier` | string or int | yes | May come from route param, explicit tenant selection, query hint, Filament panel state, or remembered session state. |
| `source` | `ContextSource` | yes | Route and explicit selection are strongest; remembered is weakest. |
| `pageCategory` | `PageCategory` | yes | Determines whether tenant fallback is valid, optional, or forbidden. |
| `requiresExplicitTenant` | bool | yes | `true` for tenant-bound pages; `false` for workspace-scoped pages that can remain tenantless. |
**Validation rules**:
- Tenant must exist.
- Tenant must belong to the resolved workspace.
- User must be entitled to the tenant.
- Tenant must satisfy the relevant operability question for the current lane.
- Tenant must be compatible with the current route type.
### RememberedContextCandidate
| Field | Type | Required | Notes |
|---|---|---|---|
| `workspaceId` | int | yes | Key for the remembered tenant map. |
| `tenantId` | int or null | no | Candidate tenant for restore. |
| `source` | `ContextSource` | yes | Today this is `remembered`, with supporting user-level last-used values. |
| `eligible` | bool | yes | `false` once access, operability, or workspace match fails. |
| `invalidReason` | string or null | no | Captures why the remembered candidate became ineligible during validation or cleanup. |
**Validation rules**:
- Candidate tenant must still exist.
- Candidate tenant must still belong to the active workspace.
- Candidate tenant must still be accessible to the user.
- Candidate tenant must still pass `RememberedContextValidity` for the current lane.
- Ineligible remembered context is cleared immediately and cannot survive as visible shell truth.
### ResolvedShellContext
| Field | Type | Required | Notes |
|---|---|---|---|
| `workspace` | Workspace or null | yes | Null only when recovery requires chooser or not-found handling. |
| `tenant` | Tenant or null | yes | Null is valid only in tenantless workspace state or before a hard recovery redirect. |
| `pageCategory` | `PageCategory` | yes | Controls whether tenantless state is valid. |
| `workspaceSource` | `ContextSource` | yes | Records which source actually won for workspace resolution. |
| `tenantSource` | `ContextSource` | yes | Records which source actually won for tenant resolution, or `none`. |
| `state` | `ShellState` | yes | The user-visible shell state. |
| `recoveryDirective` | `RecoveryDirective` | yes | Defines what to render or where to redirect if the request cannot continue as requested. |
| `displayMode` | string | yes | `tenant_scoped`, `tenantless`, or `recovery`. |
**Invariants**:
- A resolved tenant cannot exist without a resolved workspace.
- Remembered context cannot become active if a stronger valid source exists.
- The shell display must derive only from `ResolvedShellContext`.
- Tenant-bound pages cannot render a remembered-tenant fallback as though it were an explicit route tenant.
### InvalidContext
| Field | Type | Required | Notes |
|---|---|---|---|
| `kind` | string | yes | `workspace` or `tenant` |
| `source` | `ContextSource` | yes | Identifies whether route, panel, remembered, or query input failed. |
| `reason` | string | yes | `missing`, `inaccessible`, `incompatible`, `not_operable`, `not_member`, `archived`, or `mismatched_workspace` |
| `requestedWorkspaceIdentifier` | string or int or null | no | Included for diagnostics and testing only. |
| `requestedTenantIdentifier` | string or int or null | no | Included for diagnostics and testing only. |
## Relationships
- `ResolvedShellContext` is composed from zero or one `RequestedWorkspaceContext`, zero or one `RequestedTenantContext`, zero or one `RememberedContextCandidate`, and zero or one `InvalidContext` plus `RecoveryDirective`.
- `RecoveryDirective` is downstream of `ResolvedShellContext.state` and `PageCategory`.
- `RememberedContextCandidate.workspaceId` is always keyed to the resolved workspace candidate; it is not global across workspaces.
## Resolution Rules
### Workspace Resolution
1. Try a valid explicit workspace request when the current entry flow provides one.
2. Otherwise use the current session workspace if it remains valid.
3. Otherwise allow a valid last-used workspace restore only during initial resolution.
4. Otherwise emit `missing_workspace` or `invalid_workspace` with a chooser-oriented recovery directive.
### Tenant Resolution
1. On tenant-bound pages, validate route tenant first and fail if it is missing, inaccessible, or incompatible.
2. On workspace-scoped pages, accept a valid route tenant or explicit tenant-selection request first.
3. Next accept a validated query-backed tenant hint only on routes where the contract explicitly allows query-backed shell resolution.
4. Next accept validated `Filament::getTenant()` only if it matches the resolved workspace and remains entitled.
5. Next accept remembered tenant only when the page category permits tenantless fallback and no stronger valid tenant source exists.
6. Otherwise resolve to tenantless workspace state or to an explicit recovery directive depending on page category.
## Recovery Matrix
| Page category | Invalid workspace | Invalid explicit tenant | Missing tenant after clear | Invalid remembered tenant |
|---|---|---|---|---|
| `workspace_scoped` | redirect to chooser, or to `admin.operations.index` when cleanup is referrer-free or sentinel-driven | render tenantless workspace or clear request | render tenantless workspace on the current route, or use `admin.operations.index` when no safe prior route exists | clear remembered tenant and remain tenantless |
| `workspace_chooser_exception` | remain on `/admin/choose-workspace` | not applicable | remain on `/admin/choose-workspace` until the user selects a workspace | not applicable |
| `tenant_bound` | 404 or redirect to chooser if no workspace can be re-established | 404 when route tenant is invalid or inaccessible | redirect to `admin.workspace.managed-tenants.index` for the current workspace, else `admin.home` | ignored as active truth; route still governs |
| `tenant_scoped_evidence` | redirect to chooser if workspace truth cannot be re-established | redirect to `admin.evidence.overview` when tenant detail context is no longer valid | redirect to `admin.evidence.overview` | clear remembered tenant and return to `admin.evidence.overview` |
| `canonical_workspace_record_viewer` | 404 if the record itself is no longer entitled | 404 if tenant-scoped record access fails | remain on `admin.operations.view` when it is still workspace-safe, otherwise use the documented workspace fallback | clear remembered tenant and keep record-only rules |
## Documented Recovery Destinations
| RecoveryAction | Route target | When it applies |
|---|---|---|
| `redirect_choose_workspace` | `/admin/choose-workspace` | Missing or unrecoverable workspace truth at shell entry or restore time |
| `redirect_operations_index` | `admin.operations.index` | External or missing referrer, clear-flow sentinel path, and generic workspace-safe fallback for tenantless monitoring entry |
| `redirect_evidence_overview` | `admin.evidence.overview` | Tenant-scoped evidence paths that must return to a workspace-safe evidence landing |
| `redirect_workspace_managed_tenants` | `admin.workspace.managed-tenants.index` | Tenant-bound cleanup or workspace switch flows that must return to tenant selection inside the resolved workspace |
| `redirect_workspace_home` | `admin.home` | Tenant-bound cleanup when no current workspace truth remains available |
| `redirect_workspace_record_fallback` | `admin.operations.view` in the current-release scope | Canonical workspace record viewers that stay in workspace scope without reviving tenant truth |
## State Transitions
| Trigger | From | To | Notes |
|---|---|---|---|
| Workspace switch | `tenant_scoped` | `tenant_scoped` or `tenantless_workspace` | Existing tenant survives only after compatibility re-check in target workspace. |
| Tenant select | `tenantless_workspace` | `tenant_scoped` | Explicit user action; requires entitlement and operability validation. |
| Tenant clear on workspace page | `tenant_scoped` | `tenantless_workspace` | Valid only on workspace-scoped pages. |
| Tenant clear on tenant-bound page | `tenant_scoped` | `tenantless_workspace` plus redirect | Redirect destination depends on page category and route family. |
| Remembered tenant invalidation | `tenant_scoped` or restore candidate | `tenantless_workspace` | Candidate is cleared and cannot stay visible. |
| Workspace invalidation | any | `missing_workspace` or `invalid_workspace` | Recovery goes to chooser or not-found depending on entry path. |
## Display Semantics
| Resolved state | Workspace label | Tenant label | Action affordances |
|---|---|---|---|
| `tenant_scoped` | Active workspace name | Active tenant name | Switch workspace, Select tenant, Clear tenant context |
| `tenantless_workspace` | Active workspace name | `No tenant selected` | Switch workspace, Select tenant |
| `missing_workspace` / `invalid_workspace` | `Choose workspace` or recovery label | hidden or disabled | Choose workspace, optional safe return |
| `invalid_tenant` / `inaccessible_tenant` / `incompatible_tenant` | Active workspace name if valid | no stale tenant name shown | Recovery action only; no stale tenant truth |

View File

@ -0,0 +1,323 @@
# Implementation Plan: Global Context Shell Contract
**Branch**: `199-global-context-shell-contract` | **Date**: 2026-04-18 | **Spec**: `specs/199-global-context-shell-contract/spec.md`
**Input**: Feature specification from `specs/199-global-context-shell-contract/spec.md`
**Note**: This plan keeps the work inside the existing Filament v5 / Livewire v4 admin and tenant panels, reuses `WorkspaceContext` as the session-backed storage owner, promotes one request-scoped shell resolution contract over the current split logic, and explicitly avoids new persistence, panel proliferation, or a generic context framework.
## Summary
Cut one explicit workspace-first global shell contract for `/admin` and `/admin/t/{external_id}` by keeping workspace session ownership in `WorkspaceContext`, consolidating active workspace and tenant resolution behind one request-scoped shell contract consumed by `OperateHubShell`, aligning switch/select/clear controllers and `EnsureFilamentTenantSelected` to the same precedence and fallback rules, and reducing the shared `context-bar` partial to a pure consumer and dispatcher of resolved context. Preserve tenant-safe global search, deny-as-not-found isolation, intended-URL handling, and existing Filament panel topology while replacing scattered partial, controller, middleware, and panel-state heuristics with one documented source-of-truth hierarchy and one explicit invalid-context recovery model.
## Contract Ownership
- **Source inventory owner**: `specs/199-global-context-shell-contract/data-model.md` contains the canonical `Context Source Inventory` for every in-scope workspace and tenant context source.
- **Documented recovery destinations**:
- missing or unrecoverable workspace truth falls back to `/admin/choose-workspace`
- generic referrer-free or sentinel cleanup falls back to `admin.operations.index`
- tenant-scoped evidence cleanup falls back to `admin.evidence.overview`
- tenant-bound cleanup with a valid workspace falls back to `admin.workspace.managed-tenants.index`
- tenant-bound cleanup without recoverable workspace falls back to `admin.home`
- tenantless-capable workspace routes and canonical workspace record viewers remain on their current route when entitlement remains valid
- **Workspace switch destination set**:
- safe intended `/admin...` URL when present and still valid
- `admin.workspace.managed-tenants.index` when the resolved workspace has zero selectable tenants
- `/admin/choose-tenant` when the resolved workspace has multiple selectable tenants
- tenant dashboard route under `/admin/t/{external_id}` when the resolved workspace has exactly one selectable tenant
- **Explicit page-category exceptions**:
- `/admin/choose-workspace` is the `workspace_chooser_exception` route
- `/admin/evidence/...` except `/admin/evidence/overview` is treated as `tenant_scoped_evidence` for recovery behavior
## Technical Context
**Language/Version**: PHP 8.4.15
**Primary Dependencies**: Laravel 12, Filament v5, Livewire v4, Pest v4, Tailwind CSS v4, existing `WorkspaceContext`, `OperateHubShell`, `EnsureFilamentTenantSelected`, `WorkspaceRedirectResolver`, `WorkspaceIntendedUrl`, `TenantPageCategory`, and `ResolvesPanelTenantContext`
**Storage**: PostgreSQL unchanged plus existing Laravel session keys `current_workspace_id`, `workspace_intended_url`, and `workspace_last_tenant_ids`; no schema change planned
**Testing**: Pest unit and feature tests, existing Filament or Livewire page tests, and manual shell smoke validation through Laravel Sail; browser automation remains optional and not the proving default
**Validation Lanes**: fast-feedback, confidence
**Target Platform**: Laravel monolith web application under `apps/platform`, running via Sail locally, with shared operator shells on `/admin` and `/admin/t/{external_id}` and isolated `/system` remaining out of scope
**Project Type**: web application
**Performance Goals**: Keep shell context resolution request-scoped, DB-and-session-only at render time, avoid any outbound HTTP or queued work during shell hydration, avoid additional N+1 tenant lookups in the topbar, and keep context-bar rendering within existing operator-page latency expectations
**Constraints**: No new persisted truth, no generic context engine, no cross-plane auth redesign, no hidden page-state ownership inside the shell contract, no global navigation rewrite, no dependency changes, and no new asset pipeline requirements
**Scale/Scope**: 2 shared operator panels, 1 shared context-bar partial, 3 context mutation endpoints, 5 existing core support classes or middleware seams, targeted updates across existing workspace, monitoring, RBAC, and tenant-RBAC feature seams, and 2 to 4 new narrow regression files for shell resolution and recovery
## Constitution Check
*GATE: Passed before Phase 0 research. Re-checked after Phase 1 design and still passing.*
| Principle | Pre-Research | Post-Design | Notes |
|-----------|--------------|-------------|-------|
| Inventory-first / snapshots-second | PASS | PASS | The feature changes shell context truth only. It does not alter inventory, snapshot, or backup product truth. |
| Read/write separation | PASS | PASS | The work changes session-backed scope selection and recovery behavior only. No new Microsoft tenant write, queued work, or operational mutation is introduced. |
| Graph contract path | N/A | N/A | No Microsoft Graph call path is added or modified. |
| Deterministic capabilities | PASS | PASS | Existing workspace and tenant entitlement checks remain authoritative. No new capability strings or auth planes are introduced. |
| Workspace + tenant isolation | PASS | PASS | The design strengthens workspace-first isolation by preventing tenant truth from surviving outside a valid workspace and by standardizing 404 versus tenantless fallback rules. |
| RBAC-UX authorization semantics | PASS | PASS | Non-members remain 404, members without capability remain 403 only after scope is established, and shell context cleanup must not surface inaccessible tenant or workspace truth. |
| Run observability / Ops-UX | PASS | PASS | No `OperationRun` is introduced or changed. Shell resolution and display remain synchronous request work. |
| Data minimization | PASS | PASS | No new persistence, cache mirror, or derived artifact is added; remembered values remain support state only. |
| Proportionality / anti-bloat | PASS WITH JUSTIFIED SOURCE CONTRACT | PASS WITH JUSTIFIED SOURCE CONTRACT | The feature introduces one bounded source-of-truth hierarchy and one bounded runtime state vocabulary because multiple existing classes already resolve the same context with competing rules. It explicitly avoids a generic engine or persisted context model. |
| UI semantics / few layers | PASS | PASS | The plan keeps one thin request-scoped contract and removes partial-owned context logic instead of adding a presenter or UI framework layer. |
| Filament-native UI | PASS | PASS | Existing Filament panels, routes, middleware, and shared partials remain the implementation path. No hand-built alternate shell system is introduced. |
| Filament v5 / Livewire v4 compliance | PASS | PASS | All touched surfaces remain on Filament v5 and Livewire v4 semantics only. |
| Provider registration location | PASS | PASS | No provider registration change is required. Laravel 11+ provider registration remains in `bootstrap/providers.php`. |
| Global search hard rule | PASS | PASS | No new globally searchable resource is introduced. Existing searchable resources keep their View or Edit pages, and the contract explicitly preserves tenant-safe global search behavior under resolved shell scope. |
| Destructive action safety | PASS | PASS | No new destructive Filament action is added. Context reset via `clear-tenant-context` remains a scope action, not a record-destruction action. |
| Asset strategy | PASS | PASS | No new assets or build steps are planned. Existing `cd apps/platform && php artisan filament:assets` deployment handling remains sufficient. |
## Filament-Specific Compliance Notes
- **Livewire v4.0+ compliance**: The implementation stays on Filament v5 and Livewire v4 pages, middleware, support classes, and shared Blade partials. No legacy Livewire or Filament APIs are introduced.
- **Provider registration location**: No panel or provider registration changes are planned. Provider registration remains in `bootstrap/providers.php`.
- **Global search**: No new searchable resources are added. Existing searchable resources remain `TenantResource` and `PolicyResource`, both of which already have View pages, and the shell contract must preserve existing tenant-safe and workspace-safe global search semantics.
- **Destructive actions**: The feature introduces no destructive record actions. Existing shell actions `Switch workspace`, `Select tenant`, and `Clear tenant context` remain scope-setting flows only. Any existing destructive actions elsewhere remain unchanged and continue to require confirmation and authorization under current resource contracts.
- **Asset strategy**: No new JS or CSS assets are planned. Existing deployment handling of `cd apps/platform && php artisan filament:assets` remains unchanged.
- **Testing plan**: Extend or reuse `WorkspaceContextRememberedTenantTest`, `WorkspaceContextTopbarAndTenantSelectionTest`, `WorkspaceContextRecoveryDisplayTest`, `SelectTenantControllerTest`, `ChooseTenantPageTest`, `ChooseWorkspacePageTest`, `ChooseWorkspaceRedirectsToChooseTenantTest`, `WorkspaceRedirectResolverTest`, `WorkspaceSwitchUserMenuTest`, `SwitchWorkspaceRedirectsToTenantRegistrationWhenNoTenantsTest`, `SwitchWorkspaceControllerTest`, `GlobalContextShellContractTest`, `EnsureWorkspaceSelectedMiddlewareTest`, `WorkspacesResourceIsTenantlessTest`, `OperationsIndexHeaderTest`, `AdminGlobalSearchContextSafetyTest`, `TenantSwitcherScopeTest`, `TenantActionSurfaceConsistencyTest`, `OperationsDbOnlyRenderTest`, and `OperationsActionsEnqueueRunTest` so the contract is proven without introducing a new browser or heavy-governance family.
## Test Governance Check
- **Test purpose / classification by changed surface**: Unit for runtime context resolution and invalidation rules in support classes; Feature for controller flows, middleware behavior, panel entry routes, and shared shell rendering.
- **Affected validation lanes**: fast-feedback, confidence.
- **Why this lane mix is the narrowest sufficient proof**: The feature changes request-time scope resolution, redirects, session mutation, and rendered shell truth. These are best proven with unit tests around the support layer plus feature tests over routes and rendered pages. A browser lane is not required unless later implementation introduces client-only shell behavior that feature tests cannot observe.
- **Narrowest proving command(s)**:
- `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Unit/Support/Workspaces/WorkspaceContextRememberedTenantTest.php`
- `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Unit/Support/OperateHub/OperateHubShellResolutionTest.php`
- `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Filament/WorkspaceContextTopbarAndTenantSelectionTest.php`
- `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Workspaces/SelectTenantControllerTest.php`
- `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Workspaces/ChooseTenantPageTest.php`
- `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Workspaces/ChooseWorkspacePageTest.php`
- `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Workspaces/WorkspaceRedirectResolverTest.php`
- `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Spec085/OperationsIndexHeaderTest.php`
- `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Monitoring/OperationsDbOnlyRenderTest.php`
- `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Monitoring/OperationsActionsEnqueueRunTest.php`
- **Fixture / helper / factory / seed / context cost risks**: Moderate but bounded. Tests need workspace membership, tenant membership, workspace session state, remembered tenant session state, and selective Filament tenant seeding. No new provider bootstraps, seeds, queues, or browser fixtures are expected.
- **Expensive defaults or shared helper growth introduced?**: No. Existing helper seams such as `createUserWithTenant()` and targeted session seeding stay opt-in. The plan should avoid introducing a global full-shell fixture as the new default.
- **Heavy-family additions, promotions, or visibility changes**: none.
- **Non-functional shell-render proof**: Reuse `OperationsDbOnlyRenderTest` and `OperationsActionsEnqueueRunTest` so the shell-anchor workspace surfaces stay DB-only and non-enqueuing during render.
- **Closing validation and reviewer handoff**: Reviewers should confirm that new tests stay in unit and feature lanes, that invalid-context recovery is proven without browser escalation, that shell display and actual resolved context match, and that remembered-tenant invalidation remains explicit in test names and assertions.
- **Budget / baseline / trend follow-up**: none beyond a small increase in focused unit and feature runtime.
- **Review-stop questions**: Is the new proof actually feature-level? Did any helper make full workspace or tenant context implicit by default? Did the implementation create a second source of truth in tests or UI? Did any browser-only assertion sneak in without necessity?
- **Escalation path**: document-in-feature.
- **Why no dedicated follow-up spec is needed**: Test cost remains feature-local as long as the work stays in the existing support layer, controllers, middleware, and shared shell partials without introducing a new test harness or a new browser family.
## Phase 0 Research
Research outcomes are captured in `specs/199-global-context-shell-contract/research.md`.
Key decisions:
- Keep `WorkspaceContext` as the session-backed owner of workspace selection and remembered tenant storage, but stop treating it and `OperateHubShell` as parallel visible truths.
- Use one request-scoped resolved shell contract to unify route tenant, Filament tenant, remembered tenant, and tenantless fallback semantics instead of repeating those rules in controllers, partials, and middleware.
- Preserve the existing effective precedence for active tenant on admin surfaces: valid route tenant first, then explicit tenant selection, then validated query-backed tenant hints only on explicitly allowed workspace-scoped routes, then validated Filament tenant, then remembered tenant only on workspace-scoped pages, with tenant-bound pages rejecting query-hint and remembered fallback.
- Reduce `context-bar.blade.php` to a pure consumer and dispatcher of resolved context instead of letting it re-discover state on its own.
- Make invalid-context recovery explicit and page-category-aware so missing workspace, missing tenant, incompatible tenant, and inaccessible tenant produce deterministic fallback rather than mixed 404, silent clear, or stale shell display behavior.
- Extend existing unit and feature seams instead of introducing a browser-first shell test family.
## Phase 1 Design
Design artifacts are created under `specs/199-global-context-shell-contract/`:
- `research.md`: shell source-of-truth decisions, risks, and rejected alternatives
- `data-model.md`: runtime shell-context entities, validation rules, and state transitions
- `contracts/global-context-shell.logical.openapi.yaml`: internal logical HTTP contract for shell context entry and mutation flows
- `quickstart.md`: implementation and verification workflow for Spec 199
Design highlights:
- Keep the contract derived and request-scoped. No new table or persisted shell artifact is introduced.
- Preserve `WorkspaceContext` as the storage owner and existing route controllers as explicit mutation entry points, but make them consume one resolved shell contract instead of each re-defining fallback behavior.
- Treat `OperateHubShell` as the canonical shared shell resolver for admin-facing context, with tenant-panel-native semantics remaining route-bound and panel-native where appropriate.
- Keep the workspace chooser flow as the explicit current-release `workspace_chooser_exception` instead of letting missing-workspace handling stay implicit.
- Encode invalid-context recovery as explicit outcome types tied to route requirements and `TenantPageCategory`, instead of leaving recovery scattered across `context-bar.blade.php`, `ClearTenantContextController`, and middleware heuristics.
- Keep page-local filters, tabs, inspect state, and other page-state concerns out of the shell contract so tenant-prefilter behavior remains explicit and opt-in.
- Keep `EnsureFilamentTenantSelected` and `ResolvesPanelTenantContext` as consumers of the contract so shared panel behavior does not drift.
## Phase 1 - Agent Context Update
Executed command:
- `.specify/scripts/bash/update-agent-context.sh copilot`
This feature does not add a new language or framework, but the agent-context refresh still runs after design artifacts are complete so the current feature context is recorded in the agent guidance files.
## Project Structure
### Documentation (this feature)
```text
specs/199-global-context-shell-contract/
├── spec.md
├── plan.md
├── research.md
├── data-model.md
├── quickstart.md
├── contracts/
│ └── global-context-shell.logical.openapi.yaml
├── checklists/
│ └── requirements.md
└── tasks.md
```
### Source Code (repository root)
```text
apps/platform/
├── app/
│ ├── Filament/
│ │ └── Concerns/
│ │ ├── ResolvesPanelTenantContext.php # MODIFY
│ │ └── ScopesGlobalSearchToTenant.php # REUSE / possible small adjust
│ ├── Http/
│ │ └── Controllers/
│ │ ├── SwitchWorkspaceController.php # MODIFY
│ │ ├── SelectTenantController.php # MODIFY
│ │ └── ClearTenantContextController.php # MODIFY
│ ├── Providers/
│ │ └── Filament/
│ │ ├── AdminPanelProvider.php # REUSE / possible small adjust
│ │ └── TenantPanelProvider.php # REUSE / possible small adjust
│ └── Support/
│ ├── Middleware/
│ │ └── EnsureFilamentTenantSelected.php # MODIFY
│ ├── OperateHub/
│ │ └── OperateHubShell.php # MODIFY
│ ├── Tenants/
│ │ └── TenantPageCategory.php # MODIFY
│ └── Workspaces/
│ ├── WorkspaceContext.php # MODIFY
│ ├── WorkspaceIntendedUrl.php # REUSE / possible small adjust
│ └── WorkspaceRedirectResolver.php # MODIFY
├── resources/
│ └── views/
│ └── filament/
│ └── partials/
│ └── context-bar.blade.php # MODIFY
└── tests/
├── Unit/
│ └── Support/
│ ├── OperateHub/
│ │ └── OperateHubShellResolutionTest.php # NEW
│ └── Workspaces/
│ └── WorkspaceContextRememberedTenantTest.php # MODIFY
└── Feature/
├── Filament/
│ ├── WorkspaceContextTopbarAndTenantSelectionTest.php # MODIFY
│ └── WorkspaceContextRecoveryDisplayTest.php # NEW
├── Monitoring/
│ ├── OperationsActionsEnqueueRunTest.php # MODIFY / VERIFY
│ └── OperationsDbOnlyRenderTest.php # MODIFY / VERIFY
├── Rbac/
│ ├── AdminGlobalSearchContextSafetyTest.php # MODIFY
│ └── TenantActionSurfaceConsistencyTest.php # MODIFY
├── Spec085/
│ └── OperationsIndexHeaderTest.php # MODIFY
├── TenantRBAC/
│ └── TenantSwitcherScopeTest.php # MODIFY
└── Workspaces/
├── ChooseTenantPageTest.php # MODIFY
├── ChooseWorkspacePageTest.php # MODIFY
├── ChooseWorkspaceRedirectsToChooseTenantTest.php # MODIFY
├── EnsureWorkspaceSelectedMiddlewareTest.php # MODIFY
├── GlobalContextShellContractTest.php # NEW
├── SelectTenantControllerTest.php # MODIFY
├── SwitchWorkspaceControllerTest.php # NEW
├── WorkspaceRedirectResolverTest.php # MODIFY
├── SwitchWorkspaceRedirectsToTenantRegistrationWhenNoTenantsTest.php # MODIFY
├── WorkspaceSwitchUserMenuTest.php # MODIFY
└── WorkspacesResourceIsTenantlessTest.php # MODIFY
```
**Structure Decision**: Keep the work entirely inside the existing Laravel and Filament monolith under `apps/platform`. Reuse current support classes, controllers, middleware, panel providers, and the shared `context-bar` partial. Add only narrow new tests and, if the implementation proves it necessary, a tiny request-scoped result structure inside the existing support layer rather than a new framework directory.
## Complexity Tracking
| Violation | Why Needed | Simpler Alternative Rejected Because |
|-----------|------------|-------------------------------------|
| Bounded new source-of-truth contract and runtime shell-context taxonomy | Multiple existing classes and the shared shell partial already resolve active workspace and tenant context with competing precedence and recovery rules. The feature needs one explicit request-scoped contract to stop drift now. | Pure controller or partial cleanup would keep resolution logic split across `WorkspaceContext`, `OperateHubShell`, middleware, panel state, and the Blade partial, which would preserve the exact ambiguity Spec 199 exists to remove. |
## Proportionality Review
- **Current operator problem**: Operators and reviewers cannot reliably trust the shell to answer where they are operating, which tenant is active, or what a switch or clear action will do.
- **Existing structure is insufficient because**: The current rules live across `WorkspaceContext`, `OperateHubShell`, controllers, middleware, panel tenancy, and `context-bar.blade.php`, so local cleanup in one place cannot produce one shared source of truth.
- **Narrowest correct implementation**: Keep workspace and remembered-tenant storage in the current session-backed support layer, introduce one explicit resolved shell contract for the request, and make existing shell consumers use it. Do not add persistence or a generic engine.
- **Ownership cost created**: Reviewers must maintain one shared shell source hierarchy, one invalid-context taxonomy, and focused unit and feature regression coverage for switch, select, clear, restore, and recovery.
- **Alternative intentionally rejected**: A generic multi-panel context framework was rejected as overproduction, and a partial-only cleanup was rejected as insufficient.
- **Release truth**: current-release operator trust and scope clarity
## Implementation Strategy
### Phase A - Canonicalize one resolved shell context
**Goal**: Replace competing runtime context truths with one request-scoped resolved contract while keeping existing session-backed storage ownership intact.
| Step | File | Change |
|------|------|--------|
| A.0 | `specs/199-global-context-shell-contract/data-model.md` | Maintain the canonical `Context Source Inventory` so every in-scope source has one declared role, one owner, and one validation note. |
| A.1 | `apps/platform/app/Support/OperateHub/OperateHubShell.php` | Expand the shell support seam so it can resolve one canonical shell context for the current request, including workspace, tenant, page category, source precedence, tenantless validity, and invalid-context recovery metadata. |
| A.2 | `apps/platform/app/Support/Workspaces/WorkspaceContext.php` | Keep workspace and remembered-tenant session ownership here, but align helper methods to the canonical contract by making remembered values restore-only and by making invalidation rules explicit and reusable. |
| A.3 | `apps/platform/app/Filament/Concerns/ResolvesPanelTenantContext.php` | Make admin-panel tenant context consumption route through the canonical resolved shell contract instead of ad hoc tenant lookup. |
| A.4 | `apps/platform/tests/Unit/Support/OperateHub/OperateHubShellResolutionTest.php` and `apps/platform/tests/Unit/Support/Workspaces/WorkspaceContextRememberedTenantTest.php` | Add or extend unit coverage for route-first, Filament-tenant, remembered-tenant, tenantless, and invalid remembered-context branches. |
### Phase B - Align explicit scope mutation flows
**Goal**: Make switch, select, clear, restore, and intended-return behavior follow the same source hierarchy and fallback matrix.
| Step | File | Change |
|------|------|--------|
| B.1 | `apps/platform/app/Http/Controllers/SwitchWorkspaceController.php` and `apps/platform/app/Support/Workspaces/WorkspaceRedirectResolver.php` | Ensure workspace switching re-evaluates tenant compatibility deterministically, clears incompatible tenant state, and uses one redirect strategy after intended-URL consumption. |
| B.2 | `apps/platform/app/Http/Controllers/SelectTenantController.php` | Keep explicit tenant selection as the only user-driven tenant activation flow on workspace-level pages, but align it with the canonical shell contract, selector-operability rules, and recovery semantics. |
| B.3 | `apps/platform/app/Http/Controllers/ClearTenantContextController.php` and `apps/platform/app/Support/Tenants/TenantPageCategory.php` | Standardize tenant-clear recovery and route compatibility rules so tenant-required pages, workspace pages, evidence paths, and canonical record viewers resolve either to same-route tenantless workspace state or to the documented destinations `admin.workspace.managed-tenants.index`, `admin.evidence.overview`, `admin.operations.index`, `admin.operations.view`, or `admin.home` as appropriate. |
| B.4 | `apps/platform/app/Support/Workspaces/WorkspaceIntendedUrl.php` | Reuse or slightly refine intended-URL handling so switch and recovery flows return to safe shell-compatible destinations only. |
| B.5 | `apps/platform/tests/Feature/Workspaces/SwitchWorkspaceControllerTest.php`, `SwitchWorkspaceRedirectsToTenantRegistrationWhenNoTenantsTest.php`, `WorkspaceRedirectResolverTest.php`, `SelectTenantControllerTest.php`, and `ChooseWorkspacePageTest.php` | Cover workspace switch redirects, explicit tenant selection, workspace-independent chooser exceptions, tenant compatibility, and invalid or inaccessible context requests. |
### Phase C - Make the shell surfaces consume the contract
**Goal**: Reduce the shared shell UI to one truthful display and action entry point.
| Step | File | Change |
|------|------|--------|
| C.1 | `apps/platform/resources/views/filament/partials/context-bar.blade.php` | Remove partial-owned source precedence and make the topbar display and controls derive only from the canonical resolved shell context and explicit available actions. |
| C.2 | `apps/platform/app/Providers/Filament/AdminPanelProvider.php` and `TenantPanelProvider.php` | Keep the shared render-hook strategy, but adjust only if needed so both panels consume the same shared shell contract without panel-specific truth drift. |
| C.3 | `apps/platform/tests/Feature/Filament/WorkspaceContextTopbarAndTenantSelectionTest.php`, `OperationsIndexHeaderTest.php`, and `WorkspaceContextRecoveryDisplayTest.php` | Cover active tenant display, explicit tenantless display, stale or inaccessible remembered context clearing, and panel-consistent shell output. |
### Phase D - Harden page-category, middleware, and scope-safety behavior
**Goal**: Ensure that route type and access boundaries determine whether tenantless fallback, redirect, or 404 is correct.
| Step | File | Change |
|------|------|--------|
| D.1 | `apps/platform/app/Support/Middleware/EnsureFilamentTenantSelected.php` | Replace ad hoc tenant-selection and navigation heuristics with canonical shell-context checks while preserving tenant-bound route enforcement and workspace isolation. |
| D.2 | `apps/platform/app/Support/Tenants/TenantPageCategory.php` | Tighten route categorization only where current path-pattern rules are too implicit for the new invalid-context matrix, including the explicit `workspace_chooser_exception` and `tenant_scoped_evidence` cases. |
| D.3 | `apps/platform/tests/Feature/Workspaces/GlobalContextShellContractTest.php` | Add focused feature coverage for missing workspace, missing tenant, invalid tenant, inaccessible tenant, tenant-bound route fallback, and workspace-scoped tenantless behavior. |
### Phase E - Close with regression protection and operator verification
**Goal**: Leave the repo with one documented shell contract, narrow regression coverage, and a clear manual validation path.
| Step | File | Change |
|------|------|--------|
| E.1 | Existing unit and feature suites listed above | Extend current tests instead of creating a browser-heavy new family. Keep resolution, redirect, and display assertions explicit in names and expectations. |
| E.2 | `apps/platform/tests/Feature/Monitoring/OperationsDbOnlyRenderTest.php` and `apps/platform/tests/Feature/Monitoring/OperationsActionsEnqueueRunTest.php` | Keep shell-anchor workspace rendering DB-only and non-enqueuing so the contract does not widen runtime behavior accidentally. |
| E.3 | `specs/199-global-context-shell-contract/quickstart.md` | Record implementation order, manual smoke steps, documented fallback targets, and exact verification commands for workspace switch, tenant select, tenant clear, invalid recovery, panel parity, and non-functional render proof. |
| E.4 | `specs/199-global-context-shell-contract/tasks.md` | Break work into dependency-ordered tasks after this plan is accepted. |
## Key Design Decisions
### D-001 - `WorkspaceContext` remains the storage owner, not the visible shell owner
The session-backed workspace and remembered-tenant state already live in `WorkspaceContext`. The right move is to keep it as the storage owner and validation seam, not replace it with persistence or a new framework.
### D-002 - One request-scoped shell contract must replace partial-owned precedence
The shell currently re-discovers scope inside Blade, middleware, and controllers. The plan centralizes that into one request-scoped resolved contract consumed everywhere else.
### D-003 - Route-bound tenant remains strongest on tenant-required surfaces
The existing effective precedence already treats route tenant as strongest, then validated panel tenant, then remembered tenant only on workspace-scoped pages. The plan keeps that precedence but makes it explicit and testable.
### D-004 - Tenant clear and invalid-context recovery need the same route-compatibility matrix
The product currently mixes previous-URL redirecting, silent remembered-context clearing, and 404 behavior. The plan aligns tenant clear and invalid recovery under one page-category-aware outcome matrix.
### D-005 - The context bar becomes a consumer and dispatcher only
The shared shell partial should show the resolved contract and expose explicit switch, select, and clear actions, but it should not be allowed to own a second context truth.

View File

@ -0,0 +1,173 @@
# Quickstart: Global Context Shell Contract
## Goal
Implement Spec 199 by making workspace and tenant shell context resolve from one request-scoped contract, then verify that switch, select, clear, restore, and invalid-context flows all produce the same truth the shell displays.
## Prerequisites
1. Start the app stack:
```bash
cd apps/platform && ./vendor/bin/sail up -d
```
2. Confirm the working branch:
```bash
git branch --show-current
```
3. Keep the current scope of work bounded to the existing Laravel and Filament monolith under `apps/platform`.
## Recommended Implementation Order
1. **Canonicalize resolution first**
- Align `WorkspaceContext` and `OperateHubShell` so they can produce one resolved shell contract.
- Make remembered context restore-only and remove any equal-ranking shell truth outside the resolved contract.
2. **Align explicit mutation flows second**
- Update `SwitchWorkspaceController`, `SelectTenantController`, `ClearTenantContextController`, and `WorkspaceRedirectResolver` to consume the same contract rules.
- Keep safe intended-URL behavior via `WorkspaceIntendedUrl`.
3. **Convert shell surfaces third**
- Update `context-bar.blade.php` and any panel concern or middleware consumer to render only the resolved contract.
- Preserve tenantless workspace behavior on routes that support it.
4. **Close with regression coverage**
- Extend current unit and feature seams before adding any new test family.
- Use browser testing only if a client-only shell behavior appears that feature tests cannot observe.
## Context Source Inventory Owner
Keep the canonical source inventory in `specs/199-global-context-shell-contract/data-model.md` under `Context Source Inventory`. Any new source or fallback seam added during implementation must be recorded there before tasks are considered complete.
## Documented Recovery Destinations
- Missing or unrecoverable workspace truth goes to `/admin/choose-workspace`.
- Generic workspace-safe recovery with no trustworthy prior route goes to `admin.operations.index`.
- Tenant-scoped evidence cleanup goes to `admin.evidence.overview`.
- Tenant-bound cleanup with a valid workspace goes to `admin.workspace.managed-tenants.index`.
- Tenant-bound cleanup with no recoverable workspace goes to `admin.home`.
- Tenantless-capable workspace routes and canonical workspace record viewers stay on their current route when entitlement remains valid.
## Explicit Page-Category Exceptions
- `/admin/choose-workspace` is the explicit `workspace_chooser_exception` route.
- Tenant-scoped evidence paths under `/admin/evidence/...` except `/admin/evidence/overview` are explicit `tenant_scoped_evidence` routes for recovery purposes.
## Documented Workspace Switch Destinations
- A safe intended `/admin...` URL wins when it is still valid.
- Workspaces with zero selectable tenants land on `admin.workspace.managed-tenants.index`.
- Workspaces with multiple selectable tenants land on `/admin/choose-tenant`.
- Workspaces with exactly one selectable tenant land on the tenant dashboard route under `/admin/t/{external_id}`.
## Focused Validation Commands
Run the narrowest commands that prove the contract:
```bash
cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Unit/Support/OperateHub/OperateHubShellResolutionTest.php
cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Unit/Support/Workspaces/WorkspaceContextRememberedTenantTest.php
cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Filament/WorkspaceContextTopbarAndTenantSelectionTest.php
cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Filament/WorkspaceContextRecoveryDisplayTest.php
cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Workspaces/SelectTenantControllerTest.php
cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Workspaces/ChooseTenantPageTest.php
cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Workspaces/ChooseWorkspacePageTest.php
cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Workspaces/ChooseWorkspaceRedirectsToChooseTenantTest.php
cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Workspaces/EnsureWorkspaceSelectedMiddlewareTest.php
cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Workspaces/SwitchWorkspaceControllerTest.php
cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Workspaces/SwitchWorkspaceRedirectsToTenantRegistrationWhenNoTenantsTest.php
cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Workspaces/WorkspaceSwitchUserMenuTest.php
cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Workspaces/WorkspaceRedirectResolverTest.php
cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Workspaces/GlobalContextShellContractTest.php
cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Workspaces/WorkspacesResourceIsTenantlessTest.php
cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Spec085/OperationsIndexHeaderTest.php
cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Rbac/AdminGlobalSearchContextSafetyTest.php
cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Rbac/TenantActionSurfaceConsistencyTest.php
cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/TenantRBAC/TenantSwitcherScopeTest.php
cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Monitoring/OperationsDbOnlyRenderTest.php
cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Monitoring/OperationsActionsEnqueueRunTest.php
```
If new focused tests are added for Spec 199, run them directly as well. The two Monitoring commands above are the non-functional proof that the shell-anchor workspace surfaces remain DB-only and do not enqueue work while rendering.
## Formatting
Before closing the feature work:
```bash
cd apps/platform && ./vendor/bin/sail bin pint --dirty --format agent
```
## Manual Smoke Checklist
Use a simple 3-second timer for each first-look shell scan on the in-scope entry paths so the SC-001 review stays measurable.
### 1. Workspace-scoped tenantless entry
- Enter `/admin/operations` with a valid workspace and no active tenant.
- Confirm the shell shows the workspace name and `No tenant selected` or the approved tenantless wording.
- Confirm no stale tenant label appears.
### 2. Explicit tenant selection
- Select a valid active tenant from the shared shell surface.
- Confirm the destination route and shell both show the same tenant.
- Confirm the tenant belongs to the active workspace only.
### 3. Tenant clear from a workspace-scoped page
- Clear tenant context while on `/admin/operations` or another workspace-scoped page.
- Confirm the shell becomes tenantless and the page remains valid.
### 4. Tenant clear from a tenant-bound page
- Clear tenant context while on a tenant-bound route.
- Confirm the request does not leave the user in a half-valid tenant-bound route.
- Confirm the redirect lands in `admin.workspace.managed-tenants.index` for the current workspace, or `admin.home` when no workspace truth remains.
### 4a. Tenant clear from a tenant-scoped evidence path
- Clear tenant context while on a tenant-scoped evidence path under `/admin/evidence/...`.
- Confirm the redirect lands on `admin.evidence.overview` and no stale tenant label remains in the shell.
### 4b. Tenant clear from a canonical workspace record viewer
- Clear tenant context while on `/admin/operations/{run}`.
- Confirm the request stays on `admin.operations.view` when entitlement remains valid and does not widen into a different route unnecessarily.
### 5. Invalid remembered tenant
- Seed a remembered tenant that is inaccessible, missing, or incompatible.
- Confirm the remembered tenant is cleared automatically and does not reappear in the shell.
### 6. Workspace switch with stale tenant context
- Switch from one workspace to another where the prior tenant is not valid.
- Confirm the shell clears tenant context or replaces it only after validation in the new workspace.
### 7. Workspace-independent chooser route
- Enter the workspace chooser flow without an active workspace.
- Confirm the route remains available as an explicit exception and is not treated as a generic missing-workspace failure.
### 8. Admin versus tenant panel parity
- Resolve the same valid tenant scenario through `/admin` and `/admin/t/{external_id}`.
- Confirm the shared shell displays the same active truth and does not expose a competing panel-owned context label.
## Done Signal
Spec 199 is implementation-ready when:
- one resolved shell contract governs display and route behavior,
- switch, select, clear, restore, and invalid recovery follow one shared rule set,
- the shared shell renders only the resolved truth,
- targeted unit and feature tests pass,
- timed manual smoke checks confirm tenantless and tenant-scoped behavior are both explicit and understandable.

View File

@ -0,0 +1,81 @@
# Research: Global Context Shell Contract
## Decision 1 - Keep `WorkspaceContext` as the session-backed storage owner, but not as a competing visible shell truth
- **Decision**: `WorkspaceContext` remains the owner of `current_workspace_id`, `workspace_intended_url`, and `workspace_last_tenant_ids`, while one request-scoped shell contract becomes the only visible and consumable truth for the active workspace and tenant.
- **Rationale**: The current repo already uses `WorkspaceContext` correctly as the place where session-backed workspace and remembered-tenant state live. The ambiguity comes from visible shell truth being recomputed separately in `OperateHubShell`, middleware, controllers, and `context-bar.blade.php`, not from the storage location itself.
- **Alternatives considered**:
- Replace `WorkspaceContext` with a new persistence model: rejected because the spec explicitly disallows new persisted truth.
- Leave `WorkspaceContext` and `OperateHubShell` as parallel visible truths: rejected because that preserves the ambiguity Spec 199 exists to remove.
## Decision 2 - Preserve the current effective tenant precedence, but make it explicit and shared
- **Decision**: On admin-shell surfaces, active tenant resolution remains: valid route tenant first, then explicit tenant selection, then a validated query-backed tenant hint only on workspace-scoped routes that explicitly allow it, then validated `Filament::getTenant()`, then remembered tenant only on workspace-scoped pages, then explicit tenantless fallback. Tenant-bound pages do not accept query hints or remembered tenant fallback as normal active truth.
- **Rationale**: This is already the effective behavior in `OperateHubShell::resolveActiveTenant()`, and it matches the repo's workspace-first intent while keeping route-bound tenant requirements strongest where the route semantics demand them.
- **Alternatives considered**:
- Make remembered tenant equal to route or panel tenant: rejected because remembered context must remain support-only.
- Make Filament tenant always win even when route tenant is explicit: rejected because tenant-bound routes need explicit route authority.
## Decision 3 - Convert the context bar into a pure consumer and dispatcher of the resolved contract
- **Decision**: `context-bar.blade.php` should stop owning resolution rules and should render only the already resolved workspace and tenant state plus the explicit switch, select, and clear actions that mutate shell scope.
- **Rationale**: The current partial queries `WorkspaceContext`, `OperateHubShell`, `Filament::getTenant()`, route name, query tenant, and `TenantPageCategory` in one Blade file. That makes the shell UI a second source of truth and hides business rules in a rendering surface.
- **Alternatives considered**:
- Keep the partial as-is and only tweak labels: rejected because the issue is structural, not cosmetic.
- Move all logic into Alpine or client-side state: rejected because the authoritative context is server-side and request-scoped.
## Decision 4 - Invalid-context recovery must be explicit and page-category-aware
- **Decision**: Missing workspace, missing tenant, incompatible tenant, inaccessible tenant, and invalid remembered context should map to explicit recovery outcomes that depend on route type and page category rather than on ad hoc previous-URL heuristics.
- **Rationale**: The current repo mixes silent remembered-context clearing, page-category redirect logic in `ClearTenantContextController`, and 404 behavior in middleware and route guards. That inconsistency is the operator-facing confusion Spec 199 is meant to remove.
- **Alternatives considered**:
- Always 404 on any invalid tenant state: rejected because workspace-scoped pages intentionally support a valid tenantless state.
- Always redirect to `/admin/operations`: rejected because tenant-bound routes and canonical record viewers need different recovery behavior.
## Decision 5 - Keep the solution in the current support layer instead of introducing a generic context engine
- **Decision**: The implementation should stay inside the existing support layer around `WorkspaceContext`, `OperateHubShell`, middleware, and controllers. If a new runtime object is needed, it should be a narrow request-scoped result structure only.
- **Rationale**: The repo already has the needed building blocks. The problem is coordination and precedence, not lack of extension points.
- **Alternatives considered**:
- New multi-panel context framework with registries, strategies, or factories: rejected under ABSTR-001 and PROP-001.
- Panel-specific duplicated fixes: rejected because the same shell contract spans both admin and tenant panels.
## Decision 6 - Reuse existing unit and feature seams as the primary proof strategy
- **Decision**: The proving default for Spec 199 is unit coverage around runtime resolution and feature coverage around controllers, middleware, and rendered shell surfaces. Browser automation stays optional and secondary.
- **Rationale**: The current repo already has targeted tests for remembered-tenant invalidation, context-bar display, choose-tenant behavior, redirect resolution, and clear-tenant fallbacks. Extending these seams is cheaper and more precise than introducing a new browser-heavy shell suite.
- **Alternatives considered**:
- Browser-first shell regression family: rejected because the contract is server-driven and the narrowest sufficient proof already exists in unit and feature seams.
- Manual-only verification: rejected because Spec 199 changes request-time contract behavior that should be regression-protected.
## Decision 7 - Preserve panel topology and cross-plane boundaries unchanged
- **Decision**: `/admin` and `/admin/t/{external_id}` continue to share the `web` guard and shared shell contract, while `/system` remains out of scope and isolated under the `platform` guard.
- **Rationale**: The spec is about global admin and tenant shell truth, not about re-cutting panel or guard boundaries.
- **Alternatives considered**:
- Fold tenant-panel behavior into `/admin` only: rejected because tenant-panel-native routing and tenancy already exist and remain valid.
- Expand the feature to `/system`: rejected because cross-plane behavior is a different product boundary.
## Decision 8 - Keep global search tenant-safe under the new shell contract without changing searchable resources
- **Decision**: The shell contract must preserve existing tenant-safe and workspace-safe global search behavior, but it does not introduce or remove searchable resources.
- **Rationale**: A context contract that widens tenant-owned global search results on workspace-scoped surfaces would violate the existing RBAC and tenant isolation guarantees.
- **Alternatives considered**:
- Ignore global search as unrelated: rejected because the resolved shell context influences whether tenant-owned search results are safe to return.
- Expand searchable resource scope during shell cleanup: rejected because the spec is about context truth, not search surface growth.
## Decision 9 - Keep the explicit source inventory in the feature data model artifact
- **Decision**: The canonical source inventory for Spec 199 lives in `data-model.md` under `Context Source Inventory`, not in controller comments or scattered plan prose.
- **Rationale**: The feature needs one maintained place that lists every in-scope source, its source role, the seam that owns it, and the validation boundary it must pass.
- **Alternatives considered**:
- Keep the source inventory implicit in the plan only: rejected because task generation and implementation reviews need a concrete artifact that can be updated without re-reading the entire plan narrative.
- Split the inventory across multiple code comments: rejected because that would recreate the same drift problem inside the documentation layer.
## Decision 10 - Document fallback destinations from the current route families instead of inventing abstract recovery targets
- **Decision**: The contract documents the existing workspace-safe fallback routes used by the product today: `admin.operations.index`, `admin.evidence.overview`, `admin.workspace.managed-tenants.index`, `admin.home`, and route-stable workspace record viewers where they remain valid.
- **Rationale**: The repo already has concrete fallback behavior in `ClearTenantContextController`, `WorkspaceRedirectResolver`, and route families around monitoring, evidence, and tenant selection. The contract should reflect that real product behavior instead of describing a generic fallback abstraction.
- **Alternatives considered**:
- Keep fallback wording abstract as “workspace-level fallback”: rejected because that leaves implementers and reviewers guessing about the actual destination.
- Collapse all recovery into `/admin/operations`: rejected because tenant-bound and evidence-specific flows already use more precise workspace-safe landings.

View File

@ -0,0 +1,356 @@
# Feature Specification: Global Context Shell Contract
**Feature Branch**: `199-global-context-shell-contract`
**Created**: 2026-04-18
**Status**: Proposed
**Input**: User description: "Spec 199 — Global Context Shell Contract"
## Spec Candidate Check *(mandatory — SPEC-GATE-001)*
- **Problem**: The admin and tenant shell currently derive active workspace and tenant scope from a mix of route input, query hints, panel tenant state, session-backed workspace context, remembered tenant values, and shell-local rendering logic instead of one explicit product contract.
- **Today's failure**: Operators and developers cannot reliably answer the same basic question across the shell: what scope is actually active, what a switch or clear flow will do, whether a deeplink is authoritative, and which fallback wins when context inputs disagree or become invalid.
- **User-visible improvement**: Operators see one clear workspace-first shell truth, one predictable tenant truth inside that workspace, explicit tenantless states, and consistent switch, clear, restore, and fallback behavior across shared shell surfaces.
- **Smallest enterprise-capable version**: Map the current context sources and shell entry points, define one resolved context contract for workspace and tenant, align the context bar and switch/clear flows to that contract, add focused regression coverage for resolution and fallback, and document what remains page-state or constitution work.
- **Explicit non-goals**: No page-level tab/filter/inspect-state contract, no generic Filament nativity cleanup, no global navigation or IA rewrite, no detail micro-UI redesign, no new generic context platform engine, and no product-wide authorization redesign beyond shell context visibility and enforcement boundaries.
- **Permanent complexity imported**: A bounded shell-context taxonomy, an explicit source-of-truth hierarchy, documented switch/select/clear/fallback rules, a shared shell vocabulary for workspace and tenant state, and focused regression tests around those rules.
- **Why now**: The repo already has multiple context sources and shared shell surfaces across `/admin` and `/admin/t/{external_id}`. Adjacent specs intentionally leave this global shell context layer unresolved, so additional work will keep reintroducing drift unless the contract is cut now.
- **Why not local**: Local fixes inside one partial, one controller, or one panel would reduce a symptom, but would keep competing truths alive and would not give operators or reviewers a single product contract for scope resolution.
- **Approval class**: Core Enterprise
- **Red flags triggered**: Cross-panel contract breadth and source-of-truth consolidation. Defense: the scope is tightly bounded to workspace and tenant shell truth, introduces no new persistence, and explicitly excludes broader navigation, page-state, and micro-UI cleanup work.
- **Score**: Nutzen: 2 | Dringlichkeit: 2 | Scope: 2 | Komplexität: 1 | Produktnähe: 2 | Wiederverwendung: 2 | **Gesamt: 11/12**
- **Decision**: approve
## Spec Scope Fields *(mandatory)*
- **Scope**: workspace
- **Primary Routes**:
- `/admin`
- `/admin/choose-workspace`
- `/admin/choose-tenant`
- `/admin/switch-workspace`
- `/admin/select-tenant`
- `/admin/clear-tenant-context`
- `/admin/t/{external_id}/...`
- Shared shell surfaces rendered on the admin and tenant panels
- **Data Ownership**:
- This feature introduces no new tables, persisted entities, or storage truth.
- It standardizes the shell contract that governs how workspace-owned context and tenant-owned scope visibility are resolved on existing surfaces.
- Remembered workspace and tenant values remain convenience state only; they do not become independent product records.
- **RBAC**:
- Workspace membership is required before a workspace can become the active shell context.
- Tenant membership is required before a tenant can become the active tenant context.
- Non-membership remains deny-as-not-found and capability checks inside an established scope remain server-side authorization concerns.
For canonical-view specs, the spec MUST define:
- **Default filter behavior when tenant-context is active**: Workspace-level pages may opt into an explicit tenant prefilter, but the shell contract itself must never inject hidden page-local filters. Tenant-bound routes remain explicitly tenant-scoped.
- **Explicit entitlement checks preventing cross-tenant leakage**: Route, query, remembered, and switch requests must pass workspace and tenant entitlement checks before they become active shell context. Invalid or inaccessible context requests must be discarded without leaking unavailable tenant truth.
## 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 |
|---|---|---|---|---|---|---|---|
| Global context bar / shell header | Secondary Context Surface | Confirm or change current scope before navigating, reviewing, or mutating anything else | Active workspace, active tenant or explicit tenantless state, current shell scope, available switch affordance | Available workspaces, available tenants, and any intentionally surfaced recovery hint | Not primary because it frames decisions across the product rather than owning a domain-specific decision queue | Aligns `/admin` and `/admin/t/{external_id}` entry points around one scope model | Removes repeated “where am I?” reconstruction and hidden scope drift between pages |
| Context recovery shell state | Secondary Context Surface | Recover from missing, invalid, inaccessible, or incompatible context before work continues | Missing or invalid scope state, actual fallback scope, and the next required action | Optional explanation of why a requested or remembered context was discarded | Not primary because it is a recovery state of the shell contract, not a business workbench | Aligns missing-context behavior instead of letting each page improvise its own fallback | Prevents confusing half-active scope states and reduces retry-based navigation |
## 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 |
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| Global context bar / shell header | Navigation / Context / Shell | Global scope switcher | Confirm current scope or intentionally switch workspace or tenant | One inline shell context strip with one menu-driven switch model | forbidden | Inside the context controls only | none; tenant clear is a scope-reset action, not a data-destructive action | `/admin` and `/admin/t/{external_id}` shared shell | none | Active workspace, active tenant or `No tenant selected`, current panel scope | Context / workspace / tenant | The single resolved workspace and tenant truth governing the shell right now | none |
| Context recovery shell state | Navigation / Context / Recovery | Missing or invalid scope recovery | Choose a valid workspace, return to a workspace-level route, or clear invalid tenant context | One inline recovery state in the same shell contract | forbidden | Recovery controls inside the shell state only | none | `/admin` and `/admin/t/{external_id}` shared shell | none | Missing workspace, missing tenant, invalid request, incompatible tenant, inaccessible tenant | Context recovery | Why the requested scope cannot be honored and what valid scope remains | 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 |
|---|---|---|---|---|---|---|---|---|---|---|
| Global context bar / shell header | Workspace operator, tenant operator | Confirm current scope and intentionally switch workspace or tenant | Shell context strip | Where am I operating right now, and can I safely change scope? | Active workspace, active tenant or explicit tenantless state, current panel scope, switch affordances | Why a requested context was ignored, which remembered value was eligible, and any compatibility note that must stay secondary | workspace presence, tenant presence, validity, source resolution | context only | Switch workspace, Select tenant, Clear tenant context | none |
| Context recovery shell state | Workspace operator, tenant operator | Recover from missing, invalid, inaccessible, or incompatible context | Shell recovery prompt | Why is this scope unavailable, and what is the valid next step? | Missing or invalid state, current fallback scope, next valid action | Discarded request reason and any intentionally surfaced restore candidate | missing, invalid, inaccessible, incompatible, tenantless | context only | Choose workspace, Return to workspace-level page, Clear tenant request | none |
## Proportionality Review *(mandatory when structural complexity is introduced)*
- **New source of truth?**: yes
- **New persisted entity/table/artifact?**: no
- **New abstraction?**: no
- **New enum/state/reason family?**: yes
- **New cross-domain UI framework/taxonomy?**: no
- **Current operator problem**: Operators and reviewers face multiple competing answers for current scope, fallback, and restore behavior, so shell context cannot be trusted as a single product truth.
- **Existing structure is insufficient because**: The current shell can derive active context from route input, query hints, session state, panel state, and remembered values. Local cleanup in one place cannot define one shared switch, clear, restore, and fallback contract across panels.
- **Narrowest correct implementation**: Define one resolved shell-context contract over existing workspace and tenant context behavior, explicitly classify requested, remembered, resolved, tenantless, and invalid states, and align current shell surfaces and entry flows to it without new persistence or a generic engine.
- **Ownership cost**: The repo gains one source hierarchy to maintain, one shared shell vocabulary to preserve, and focused feature tests for context resolution, switch, clear, restore, and invalid-state handling.
- **Alternative intentionally rejected**: Local partial-only cleanup was rejected because it would preserve competing truths. A broader generic multi-panel context framework was rejected because it would import unnecessary abstraction for a bounded shell problem.
- **Release truth**: current-release operator truth and shell reliability
## Testing / Lane / Runtime Impact *(mandatory for runtime behavior changes)*
- **Test purpose / classification**: Unit + Feature
- **Validation lane(s)**: fast-feedback, confidence
- **Why this classification and these lanes are sufficient**: The change spans two proving purposes: unit tests for canonical source precedence, remembered-context invalidation, and page-category decisions in the support layer, plus feature tests for controller flows, middleware behavior, route recovery, and shared shell rendering. Together they prove runtime behavior without escalating to browser or heavy-governance lanes by default.
- **New or expanded test families**: Focused unit shell-context resolution tests, workspace-switch and tenant-selection controller tests, explicit workspace-chooser exception tests, and shared-shell display tests for admin and tenant panel entry paths.
- **Fixture / helper cost impact**: Minimal workspace, tenant, membership, entitlement, session, and remembered-context setup. No new providers, seeds, heavy browser harness, or long-running runtime fixtures are required.
- **Heavy-family visibility / justification**: none
- **Reviewer handoff**: Reviewers must confirm that coverage remains feature-level, that shell rendering is not accidentally pushed into a broad browser family, that invalid-context fallbacks are proven, and that the minimal commands below are enough to verify the contract.
- **Budget / baseline / trend impact**: Minor increase in focused feature-test runtime only.
- **Escalation needed**: none
- **Planned validation commands**:
- `cd apps/platform && ./vendor/bin/sail artisan test --compact --filter=GlobalContext`
- `cd apps/platform && ./vendor/bin/sail artisan test --compact --filter=WorkspaceSwitch`
- `cd apps/platform && ./vendor/bin/sail artisan test --compact --filter=TenantContext`
## User Scenarios & Testing *(mandatory)*
### User Story 1 - See The True Current Scope (Priority: P1)
As an operator, I want every shared shell surface to show the real active workspace and tenant state so I can trust where my next action will apply.
**Why this priority**: If the shell cannot answer the active scope question clearly, every downstream page remains harder to trust.
**Independent Test**: Open workspace-level and tenant-bound entry paths with valid and tenantless scenarios, then verify that the shell always shows the same resolved scope truth the target page is using.
**Acceptance Scenarios**:
1. **Given** a valid workspace and tenant context are active, **When** the shell renders on a shared admin or tenant panel surface, **Then** it shows that resolved workspace and tenant consistently.
2. **Given** a workspace-level page is active with no tenant selected, **When** the shell renders, **Then** it shows an explicit tenantless state instead of implying that a hidden tenant is still active.
---
### User Story 2 - Switch Workspace Without Stale Tenant Truth (Priority: P1)
As an operator, I want workspace switching to deterministically resolve what happens to tenant context so I do not carry a stale tenant into the wrong workspace or page.
**Why this priority**: Workspace is the primary scope. If workspace switching is ambiguous, the entire workspace-first model breaks down.
**Independent Test**: Start from a valid workspace and tenant, switch to a different workspace with compatible and incompatible tenant conditions, and verify the resulting scope, fallback, and destination page.
**Acceptance Scenarios**:
1. **Given** an operator switches to a different workspace and the prior tenant is not valid in that workspace, **When** the switch resolves, **Then** the shell clears tenant context and lands in a valid workspace-scoped state.
2. **Given** an operator switches to a workspace where a requested or remembered tenant is valid and allowed for the target surface, **When** the switch resolves, **Then** that tenant becomes active only after validation within the new workspace.
---
### User Story 3 - Select Or Clear Tenant Intentionally (Priority: P1)
As an operator, I want tenant selection and tenant clearing to behave like explicit scope decisions so I always understand whether I am entering tenant scope or returning to workspace-only scope.
**Why this priority**: Tenant is secondary but operationally critical. The select and clear flows are the most visible scope-changing actions in the shell.
**Independent Test**: Select a tenant from the shell, clear it from a workspace-level page, and clear it from a tenant-bound route to verify that the resulting shell truth and destination are deterministic.
**Acceptance Scenarios**:
1. **Given** an operator selects a valid tenant inside the active workspace, **When** the shell resolves the selection, **Then** the active tenant becomes that tenant and the shell shows the same tenant on the target page.
2. **Given** an operator clears tenant context while on a tenant-required route, **When** the clear flow resolves, **Then** the system redirects to the documented workspace-level fallback and the shell shows no active tenant.
---
### User Story 4 - Reject Invalid Or Stale Context Cleanly (Priority: P1)
As an operator, I want invalid, inaccessible, or stale requested and remembered context to fail cleanly so I do not operate under a false scope illusion.
**Why this priority**: Invalid context handling is where competing truths most often become visible and dangerous.
**Independent Test**: Enter the shell with invalid route, query, and remembered context combinations, then verify that the shell discards invalid inputs, lands in a valid fallback state, and never leaves stale scope indicators behind.
**Acceptance Scenarios**:
1. **Given** a route or query requests a tenant that does not belong to the resolved workspace, **When** the shell resolves context, **Then** that tenant request is rejected and the shell falls back to a valid workspace-scoped state.
2. **Given** a remembered tenant is no longer accessible or compatible, **When** the shell restores context, **Then** the remembered tenant is ignored and the operator sees an explicit valid fallback state.
---
### User Story 5 - Keep Shared Shell Logic Consistent Across Panels (Priority: P2)
As a reviewer, I want admin and tenant panel entry paths that share the shell contract to resolve context by the same rules so extensions do not create panel-specific truths.
**Why this priority**: The main risk is drift between shared shell surfaces and panel-specific context assumptions.
**Independent Test**: Resolve the same entitled workspace and tenant scenario through the workspace-level shell and tenant-bound shell entry paths, then verify that both surfaces display the same active truth and compatible fallback behavior.
**Acceptance Scenarios**:
1. **Given** the same workspace and tenant are active through different valid entry paths, **When** the shared shell renders, **Then** both panels show the same resolved scope truth.
2. **Given** a panel-specific context source disagrees with the resolved shell contract, **When** the shell renders, **Then** the panel-specific source is treated as supporting data only and does not become a competing visible truth.
### Edge Cases
- A route or query requests a tenant that belongs to a different workspace than the resolved workspace.
- Session-backed workspace state and requested workspace state disagree on first load.
- A remembered tenant exists for the prior workspace but not for the newly selected workspace.
- A tenant is cleared while the current page requires tenant scope.
- The explicit workspace chooser route is entered without workspace context and must not be mistaken for a generic missing-workspace error.
- A tenant becomes inaccessible between selection and the next request.
- A shell recovery state must distinguish between missing workspace, missing tenant, invalid tenant, and inaccessible tenant.
- Shared shell display and underlying page behavior disagree unless one explicit contract prevents the drift.
## Requirements *(mandatory)*
**Constitution alignment (required):** This feature introduces no new Microsoft Graph calls, no new persistent storage, and no new queued or scheduled operational workflow. It standardizes shell context resolution, display, and context-changing flows on existing surfaces.
**Constitution alignment (PROP-001 / ABSTR-001 / PERSIST-001 / STATE-001 / BLOAT-001):** This feature introduces one bounded source-of-truth hierarchy and one bounded state taxonomy for global shell context. It does not introduce new persistence or a generic context framework. The proportionality review above explains why local cleanup is insufficient and why broader abstraction is rejected.
**Constitution alignment (TEST-GOV-001):** This feature changes runtime behavior and targeted tests. The proving purpose is feature-level validation of shell resolution, switch, clear, restore, and fallback behavior. The narrowest sufficient lanes are fast-feedback and confidence. No new browser or heavy-governance family is justified, fixture cost remains limited to workspace, tenant, membership, and session state, and reviewers must treat accidental escalation beyond those bounds as a merge blocker.
**Constitution alignment (OPS-UX):** Existing `OperationRun` behavior remains unchanged. The feature does not create or rename run types, change service-owned run transitions, or alter progress or notification contracts.
**Constitution alignment (RBAC-UX):** The feature affects workspace-context routes on `/admin`, tenant-context routes on `/admin/t/{external_id}`, and shared shell context display. Non-members remain deny-as-not-found. Members without required capability remain authorization failures only after workspace and tenant entitlement are established. Context resolution must never surface inaccessible workspace or tenant truth, and global search must remain tenant-safe under the resolved shell contract. Positive and negative authorization regression coverage must prove that shell cleanup does not relax these rules.
**Constitution alignment (OPS-EX-AUTH-001):** Not applicable. The feature does not add synchronous authentication-handshake behavior.
**Constitution alignment (BADGE-001):** The feature does not introduce a new badge language. Existing status and severity badges remain centrally defined and must not be remapped ad hoc as part of the shell-context work.
**Constitution alignment (UI-FIL-001):** The feature must rely on existing panel shell surfaces, native Filament controls, and shared shell primitives already used by the repo. It must not introduce a new local status language or a second context widget family beside the shared shell contract.
**Constitution alignment (UI-NAMING-001):** Operator vocabulary must stay domain-first and consistent across shell labels, action labels, notifications, and recovery copy. Canonical terms include `Workspace`, `Tenant`, `No tenant selected`, `Switch workspace`, `Select tenant`, `Clear tenant context`, and `Context unavailable`.
**Constitution alignment (DECIDE-001):** The affected shell surfaces are secondary context surfaces. They exist to make the next operator decision trustworthy by showing current scope and offering explicit scope changes, not by becoming a separate workbench.
**Constitution alignment (UI-CONST-001 / UI-SURF-001 / ACTSURF-001 / UI-HARD-001 / UI-EX-001 / UI-REVIEW-001 / HDR-001):** The shared shell must expose one primary context display and switch model, one recovery model, explicit placement of scope-reset actions, and one canonical vocabulary for workspace and tenant truth. It must avoid competing header, modal, or partial-owned context truths.
**Constitution alignment (ACTSURF-001 - action hierarchy):** Context display, scope selection, and scope recovery must remain separated from destructive data actions and from unrelated page-local actions. Tenant clear remains a context-reset action grouped with tenant controls, not a destructive record action.
**Constitution alignment (OPSURF-001):** Default-visible shell content must stay operator-first: active workspace, tenant state, validity, and the next valid context action. Any diagnostic explanation of discarded requests or remembered candidates must stay secondary and only appear where necessary.
**Constitution alignment (UI-SEM-001 / LAYER-001 / TEST-TRUTH-001):** The feature replaces competing shell truth with one resolved context model and a bounded state vocabulary. It must not add redundant presenter or explanation layers, and tests must focus on visible outcomes such as displayed scope, redirects, clears, and invalid-state fallback.
**Constitution alignment (Filament Action Surfaces):** The Action Surface Contract is satisfied when the shared shell uses exactly one resolved context source for display, uses the same contract for switch, select, and clear flows, and avoids redundant alternate context widgets or empty grouped action placeholders. The UI Action Matrix below documents the shell surfaces.
**Constitution alignment (UX-001 — Layout & Information Architecture):** This feature changes shell context behavior, not page form architecture. Shared shell surfaces must remain calm, concise, and explicit, and missing-context recovery must not overload the header with unrelated information.
### Global Context Taxonomy
- **Requested Context**: Workspace or tenant input requested by route, query, explicit switch/select action, or other documented entry path before validation.
- **Resolved Workspace Context**: The one validated workspace scope that the shell and workspace-bound pages consume.
- **Resolved Tenant Context**: The one validated tenant scope inside the resolved workspace, or the explicit tenantless state when no tenant is active.
- **Remembered Context**: Non-leading convenience state used only as a restore candidate after validation.
- **Invalid / Unavailable Context**: Requested or remembered workspace or tenant input that cannot be honored because it is missing, inaccessible, incompatible, or no longer valid.
- **Tenantless Workspace State**: A valid workspace-scoped state where no tenant is active and the shell must say so explicitly.
### Context Source Hierarchy
| Context facet | Leading source | Supporting sources | Never-leading sources |
|---|---|---|---|
| Active workspace | Valid requested workspace for the current entry flow, otherwise the validated current workspace state | Remembered last workspace only when there is no active session workspace and the restore rule explicitly allows it | View-local fallbacks, raw query hints outside documented flows, raw tenant-panel state |
| Active tenant | Valid tenant required by the current route or explicitly selected by the operator inside the resolved workspace | Query-backed tenant hints only on explicitly allowed workspace-scoped routes after validation, then remembered tenant for the resolved workspace only when no higher-priority tenant is active and the target surface allows restore | Tenant from a different workspace, stale remembered tenant, partial-local shell state, raw panel tenant state treated as an independent truth |
| Shell display | The resolved context model only | none | Any partial-owned inference, convenience hint, or stale page-local context that is not part of the resolved context |
### Context Source Inventory Ownership
- The canonical inventory of in-scope workspace and tenant context sources is maintained in `data-model.md` under `Context Source Inventory`.
- Any new source that can influence shell context must be added there with its source role, owning seam, and validation note before implementation or cleanup work lands.
### Context Flow Summary
| Flow | Trigger | Required validation | Allowed outcome | Forbidden outcome |
|---|---|---|---|---|
| Workspace switch | Explicit shell action | Workspace entitlement, route compatibility, tenant compatibility re-evaluation | Resolved target workspace with valid tenant or explicit tenantless state | Carrying an incompatible tenant silently into the new workspace |
| Tenant select | Explicit shell action or required tenant entry route | Tenant entitlement, membership in resolved workspace, route compatibility | Resolved tenant inside the active workspace | Tenant becoming active without a valid workspace or despite incompatibility |
| Tenant clear | Explicit shell action | Current route compatibility with tenantless state | Valid tenantless workspace state or redirect to workspace-level fallback | Remaining on a tenant-required route with no valid tenant |
| Restore | First load or eligible return flow | Restore rule, workspace compatibility, tenant compatibility, entitlement | Valid restored workspace or tenant context | Remembered context overriding an explicit current truth or reviving invalid scope |
| Invalid-context recovery | Any invalid request or stale remembered state | Missing, inaccessible, incompatible, or unauthorized determination | Clear fallback state and explicit recovery path | Hidden failure or stale shell scope display |
### Documented Workspace-Safe Fallbacks
| Situation | Required fallback |
|---|---|
| External previous URL, missing referrer, or clear-flow sentinel path | `/admin/operations` via `admin.operations.index` |
| Missing or unrecoverable workspace truth at shell entry or restore time | `/admin/choose-workspace` |
| Tenant-bound route under `/admin/t/{external_id}/...` or `/admin/tenants/...` with a valid current workspace | `admin.workspace.managed-tenants.index` for the current workspace |
| Tenant-bound route cleanup after workspace truth is no longer available | `/admin` via `admin.home` |
| Tenant-scoped evidence path under `/admin/evidence/...` except `/admin/evidence/overview` | `/admin/evidence/overview` via `admin.evidence.overview` |
| Workspace-scoped route that is tenantless-capable | Remain on the same route in explicit tenantless state |
| Canonical workspace record viewer under `/admin/operations/{run}` with valid entitlement | Remain on the same viewer route in explicit tenantless or tenant-scoped state, whichever the resolved contract allows |
### Explicit Page-Category Exceptions
- `/admin/choose-workspace` is the explicit `workspace_chooser_exception` route. It remains reachable without an established workspace and must not be inferred from generic missing-workspace behavior.
- Tenant-scoped evidence paths under `/admin/evidence/...` except `/admin/evidence/overview` are explicit `tenant_scoped_evidence` routes for recovery purposes and must fall back to `admin.evidence.overview`.
### Documented Workspace Switch Destinations
| Switch outcome | Destination |
|---|---|
| Safe intended return inside `/admin...` | Intended URL wins when still valid for the resolved workspace |
| Resolved workspace with zero selectable tenants | `admin.workspace.managed-tenants.index` |
| Resolved workspace with multiple selectable tenants | `/admin/choose-tenant` |
| Resolved workspace with exactly one selectable tenant | Tenant dashboard route under `/admin/t/{external_id}` |
### Assumptions and Dependencies
- The product remains workspace-first: workspace context is the primary shell scope and tenant context stays subordinate to it.
- Existing admin and tenant panels continue to share shell-level context surfaces rather than splitting into unrelated context systems.
- Existing entitlement checks for workspace membership and tenant membership remain the security boundary the shell contract must respect.
- Current-release workspace-independent exception coverage is limited to the workspace chooser flow; it must stay explicit and must not be inferred from a generic missing-workspace failure path.
- Page-level tab, filter, inspect, and draft/apply semantics remain governed by the separate monitoring page-state contract unless this spec explicitly takes ownership.
### Functional Requirements
- **FR-199-001 Source inventory**: The product MUST maintain one explicit inventory of all workspace and tenant context sources that can affect the shared shell contract, and that inventory MUST remain the canonical feature artifact for source roles and ownership.
- **FR-199-002 Single resolved truth**: The shell MUST expose exactly one resolved workspace truth and exactly one resolved tenant truth for each request.
- **FR-199-003 Workspace primacy**: Workspace MUST remain the primary shell scope for the product.
- **FR-199-004 Tenant dependency**: Tenant context MUST never remain active without a valid resolved workspace.
- **FR-199-005 Source-role declaration**: Every context source in scope MUST be classified as leading, supporting, or never-leading.
- **FR-199-006 Requested-context validation**: Requested context from route, query, or explicit switch/select flows MUST be validated before it becomes active shell context.
- **FR-199-007 Query-role discipline**: Query-based context hints MUST only participate when their role is explicitly defined by the contract.
- **FR-199-008 Remembered-context role**: Remembered or last-used values MUST remain supporting restore candidates only.
- **FR-199-009 Remembered-context precedence**: Remembered context MUST NEVER outrank a valid route request, explicit operator selection, or already resolved current-request truth.
- **FR-199-010 Workspace switch contract**: Workspace switching MUST define the resulting tenant outcome, route compatibility outcome, and fallback behavior.
- **FR-199-011 Tenant re-evaluation on workspace switch**: When workspace changes, existing tenant context MUST be re-evaluated against the target workspace before it may remain active.
- **FR-199-012 Tenant select contract**: Tenant selection MUST only activate a tenant inside the currently resolved workspace and only after the route's selector-operability check succeeds.
- **FR-199-013 Tenant clear contract**: Tenant clear MUST be an explicit operator flow with deterministic resulting scope and redirect behavior.
- **FR-199-014 Tenant-required fallback**: Clearing or losing tenant context on a tenant-required route MUST redirect to a documented workspace-level fallback.
- **FR-199-015 Tenantless validity**: Workspace-level pages may operate without an active tenant only when they are explicitly defined as tenantless-capable.
- **FR-199-016 Workspace-required validity**: Workspace-bound pages MUST NOT behave as valid without a resolved workspace.
- **FR-199-017 Workspace-independent exception discipline**: If a route is workspace-independent, that exception MUST be explicit and MUST NOT be inferred from missing context.
- **FR-199-018 Distinct invalid-state handling**: Missing workspace, missing tenant, invalid tenant, inaccessible tenant, and incompatible tenant MUST be distinguishable in contract behavior where they imply different operator recovery paths.
- **FR-199-019 Visible scope parity**: Any workspace or tenant scope shown in the shell MUST match the scope actually governing the current request.
- **FR-199-020 Context-bar derivation**: The context bar MUST derive its display and affordances only from resolved shell context and MUST NOT keep a second implicit context model.
- **FR-199-021 Shared entry-rule consistency**: Shell entry through direct navigation, route-bound tenant context, query-backed request context, switch flows, and restore flows MUST use the same resolution rules.
- **FR-199-022 Page-state separation**: Global shell context MUST remain distinct from local page-state such as tabs, filters, inspect state, and draft/apply behavior.
- **FR-199-023 Redirect consistency**: Redirect and return behavior after switch, clear, or invalid-context recovery MUST be deterministic and documented.
- **FR-199-024 Restore entry discipline**: Restore behavior MUST explicitly define which entry flows may consult remembered workspace and tenant values and when restore is skipped.
- **FR-199-025 Invalid remembered-state cleanup**: Invalid remembered workspace or tenant context MUST be discarded from support state cleanly and MUST NOT revive stale shell truth.
- **FR-199-026 Panel consistency**: Relevant admin and tenant panel shell surfaces that share the contract MUST resolve and display context by the same rules.
- **FR-199-027 Supporting-state boundaries**: Raw panel tenant state, session convenience data, and view-local shell logic MAY support resolution but MUST NOT become independent active truth.
- **FR-199-028 Workspace and tenant compatibility**: Tenant context MUST always remain compatible with the resolved workspace and the current route type.
- **FR-199-029 Explicit tenantless display**: When no tenant is active, the shell MUST show an explicit tenantless state rather than implying remembered or hidden tenant scope.
- **FR-199-030 Recovery visibility**: Invalid or missing context recovery MUST provide a clear next action and MUST not strand operators in an ambiguous half-context state.
- **FR-199-031 Global search safety**: Shell-context resolution MUST preserve tenant-safe and workspace-safe search behavior so inaccessible tenant-owned results never become visible through context drift.
- **FR-199-032 No partial-owned authority**: Partial-local rendering logic MAY format resolved context, but it MUST NOT own precedence, validation, or recovery decisions.
- **FR-199-033 Regression coverage**: Automated tests MUST cover context resolution, workspace switch, tenant select, tenant clear, restore behavior, invalid-context handling, and shell display consistency.
- **FR-199-034 Manual shell smoke checks**: Manual smoke checks MUST confirm that operators can understand scope, switch scope, clear tenant context, and recover from invalid context without hidden state.
- **FR-199-035 Closure documentation**: Final documentation MUST record the source hierarchy, allowed switch/select/clear/restore rules, fallback behavior, and what remains out of scope for page-state or constitution specs.
- **FR-199-036 No navigation rewrite**: The implementation MUST standardize shell context truth without turning this feature into a general navigation or information-architecture rewrite.
### Non-Goals
- Standardizing page-level tabs, filters, inspect state, or draft/apply semantics that belong to the page-state contract.
- Redesigning shared detail micro-UI families or generic header action patterns outside shell-context needs.
- Rewriting product information architecture beyond what is required to make shell context truthful.
- Creating a generic shell platform, universal context engine, or speculative cross-product abstraction.
- Treating tenant as a globally independent truth outside the workspace-first model.
## UI Action Matrix *(mandatory when Filament is changed)*
| Surface | Location | Header Actions | Inspect Affordance (List/Table) | Row Actions (max 2 visible) | Bulk Actions (grouped) | Empty-State CTA(s) | View Header Actions | Create/Edit Save+Cancel | Audit log? | Notes / Exemptions |
|---|---|---|---|---|---|---|---|---|---|---|
| Global context bar / shell hook | Shared topbar shell surface on admin and tenant panels | `Switch workspace`, `Select tenant`, `Clear tenant context` | none | none | none | `Choose workspace` or `Select tenant` only when the contract requires recovery | none | n/a | no | Context actions change shell scope only. They must use one resolved context contract and must not introduce a second widget-owned truth. |
| Context recovery shell state | Shared shell recovery state | `Choose workspace`, `Return to workspace scope`, `Clear invalid tenant request` | none | none | none | Same recovery actions only | none | n/a | no | Recovery is a shell-state correction, not a record workflow. No destructive record action is introduced. |
### Key Entities *(include if feature involves data)*
- **Requested Context**: A requested workspace or tenant scope supplied by route, query, explicit switch/select action, or another documented entry path before validation.
- **Resolved Context**: The one validated shell truth consumed by the shared shell and the current page, composed of a resolved workspace and a resolved tenant or explicit tenantless state.
- **Remembered Context**: A non-leading convenience candidate representing last-used workspace or tenant values that may be considered for restore under documented rules.
- **Invalid / Unavailable Context**: Requested or remembered context that cannot be honored because it is missing, inaccessible, incompatible, or no longer valid.
- **Tenantless Workspace State**: A valid shell state in which a workspace is active but no tenant is active.
## Success Criteria *(mandatory)*
### Measurable Outcomes
- **SC-001**: In validation scenarios, operators can identify the active workspace and tenant or tenantless state from the shared shell within 3 seconds on all in-scope entry paths.
- **SC-002**: 100% of documented workspace-switch, tenant-select, tenant-clear, and invalid-context scenarios land in one valid resolved scope that matches what the shell displays.
- **SC-003**: 100% of tested invalid, inaccessible, or incompatible requested and remembered context scenarios fall back without leaving stale workspace or tenant indicators behind.
- **SC-004**: Shared admin-panel and tenant-panel shell entry paths show the same resolved scope truth for the same entitled scenario during validation.
- **SC-005**: Manual smoke validation confirms that operators never need trial-and-error navigation to understand whether they are in workspace-only or tenant scope on the covered shell flows.

View File

@ -0,0 +1,297 @@
# Tasks: Global Context Shell Contract
**Input**: Design documents from `/specs/199-global-context-shell-contract/`
**Prerequisites**: `plan.md` (required), `spec.md` (required for user stories), `research.md`, `data-model.md`, `contracts/`, `quickstart.md`
**Tests**: Tests are REQUIRED for this feature because it changes runtime shell resolution, session-backed workspace and tenant context behavior, redirect and recovery rules, shared Filament shell rendering, and authorization-sensitive scope fallbacks in a Laravel/Pest codebase.
**Operations**: This feature does not create a new `OperationRun`, background workflow, or audit-only DB mutation path. The work is limited to request-scoped shell context resolution, redirects, and shared shell rendering.
**RBAC**: Existing workspace membership, tenant entitlement, and 404 vs 403 semantics remain authoritative. Tasks must preserve deny-as-not-found for non-members or non-entitled scope, keep capability failures server-side after scope is established, and keep global search tenant-safe under the canonical shell contract.
**Operator Surfaces**: The shared `context-bar` shell surface and the shell recovery state remain secondary context surfaces. Tasks must keep them operator-first, truthful, and free of competing widget-owned scope state.
**Filament UI Action Surfaces**: No new destructive actions, Resources, or alternate shell widgets are introduced. `Switch workspace`, `Select tenant`, `Clear tenant context`, and recovery actions remain the only in-scope operator actions.
**Filament UI UX-001**: No new create, edit, or view page layout work is introduced. The feature is limited to shared shell rendering, route behavior, and context recovery.
**Badges**: No new badge language or badge mapping is introduced.
**Organization**: Tasks are grouped by user story so each story can be implemented and verified as an independent increment.
## Test Governance Checklist
- Lane assignment is named and is the narrowest sufficient proof for the changed behavior.
- New or changed tests stay in the smallest honest family, and any heavy-governance or browser addition is explicit.
- Shared helpers, factories, seeds, fixtures, and context defaults stay cheap by default; any widening is isolated or documented.
- Planned validation commands cover the change without pulling in unrelated lane cost.
- Any material budget, baseline, trend, or escalation note is recorded in the active spec or PR.
## Phase 1: Setup (Shell Contract Regression Scaffolding)
**Purpose**: Create the focused regression files, source-inventory baseline, and verification baseline needed to implement Spec 199 safely.
- [X] T001 Create shell-contract regression scaffolding in `apps/platform/tests/Unit/Support/OperateHub/OperateHubShellResolutionTest.php`, `apps/platform/tests/Feature/Workspaces/GlobalContextShellContractTest.php`, and `apps/platform/tests/Feature/Filament/WorkspaceContextRecoveryDisplayTest.php`
- [X] T002 [P] Create mutation-flow regression scaffolding in `apps/platform/tests/Feature/Workspaces/SwitchWorkspaceControllerTest.php` and extend `apps/platform/tests/Feature/Workspaces/SelectTenantControllerTest.php`
- [X] T003 [P] Confirm lane assignment, source-inventory ownership, performance-proof commands, and timed manual smoke coverage in `specs/199-global-context-shell-contract/plan.md`, `specs/199-global-context-shell-contract/data-model.md`, and `specs/199-global-context-shell-contract/quickstart.md`
---
## Phase 2: Foundational (Blocking Canonical Resolver Seams)
**Purpose**: Put the canonical shell-resolution seams in place before any story-level behavior is changed.
**CRITICAL**: No user story work should begin until this phase is complete.
- [X] T004 Implement canonical resolved shell-context precedence and recovery metadata in `apps/platform/app/Support/OperateHub/OperateHubShell.php`
- [X] T005 [P] Align session-backed workspace, remembered-tenant, and safe intended-url helpers with restore-only semantics in `apps/platform/app/Support/Workspaces/WorkspaceContext.php` and `apps/platform/app/Support/Workspaces/WorkspaceIntendedUrl.php`
- [X] T006 [P] Route admin-panel tenant consumption through the canonical shell contract in `apps/platform/app/Filament/Concerns/ResolvesPanelTenantContext.php`
- [X] T007 Update unit coverage for route-first, Filament-tenant, remembered-tenant, tenantless, and invalid remembered-context branches in `apps/platform/tests/Unit/Support/OperateHub/OperateHubShellResolutionTest.php` and `apps/platform/tests/Unit/Support/Workspaces/WorkspaceContextRememberedTenantTest.php`
**Checkpoint**: The shared shell resolver, storage semantics, and panel-consumption seam exist, so story work can proceed independently.
---
## Phase 3: User Story 1 - See The True Current Scope (Priority: P1)
**Goal**: Make every shared shell surface display the same truthful workspace and tenant state the request is actually using.
**Independent Test**: Open workspace-scoped and tenant-bound entry paths with tenant-scoped and tenantless states, then verify the shared shell displays the same resolved truth the page is operating under.
### Tests for User Story 1
- [X] T008 [P] [US1] Extend shared-shell truth display and no-hidden-page-state coverage for tenant-scoped and tenantless routes in `apps/platform/tests/Feature/Filament/WorkspaceContextTopbarAndTenantSelectionTest.php` and `apps/platform/tests/Feature/Spec085/OperationsIndexHeaderTest.php`
- [X] T009 [P] [US1] Add recovery-shell display assertions for missing workspace, missing tenant, and explicit tenantless states in `apps/platform/tests/Feature/Filament/WorkspaceContextRecoveryDisplayTest.php`
### Implementation for User Story 1
- [X] T010 [US1] Reduce the shared shell to a consumer-only resolved-context display and keep page-local filters, tabs, and inspect state out of the shell contract in `apps/platform/resources/views/filament/partials/context-bar.blade.php`
- [X] T011 [US1] Keep both panels rendering the same shared shell contract in `apps/platform/app/Providers/Filament/AdminPanelProvider.php` and `apps/platform/app/Providers/Filament/TenantPanelProvider.php`
- [X] T012 [US1] Run focused US1 verification against `apps/platform/tests/Feature/Filament/WorkspaceContextTopbarAndTenantSelectionTest.php`, `apps/platform/tests/Feature/Filament/WorkspaceContextRecoveryDisplayTest.php`, and `apps/platform/tests/Feature/Spec085/OperationsIndexHeaderTest.php`
**Checkpoint**: Shared shell surfaces now show one truthful scope model instead of competing display logic.
---
## Phase 4: User Story 2 - Switch Workspace Without Stale Tenant Truth (Priority: P1)
**Goal**: Make workspace switching deterministically re-evaluate tenant compatibility, fallback, and redirect behavior.
**Independent Test**: Start from a valid workspace and tenant, switch to compatible and incompatible target workspaces, and verify the resulting tenant state, redirect destination, and authorization behavior.
### Tests for User Story 2
- [X] T013 [P] [US2] Add switch regression coverage for compatible, incompatible, archived, and non-member target workspaces in `apps/platform/tests/Feature/Workspaces/SwitchWorkspaceControllerTest.php`, `apps/platform/tests/Feature/Workspaces/WorkspaceRedirectResolverTest.php`, and `apps/platform/tests/Feature/Workspaces/SwitchWorkspaceRedirectsToTenantRegistrationWhenNoTenantsTest.php`
- [X] T014 [P] [US2] Extend positive and negative workspace-switch affordance coverage in `apps/platform/tests/Feature/Workspaces/WorkspaceSwitchUserMenuTest.php` and `apps/platform/tests/Feature/Workspaces/ChooseWorkspaceRedirectsToChooseTenantTest.php`
### Implementation for User Story 2
- [X] T015 [US2] Make workspace switching re-evaluate tenant compatibility and clear incompatible tenant state in `apps/platform/app/Http/Controllers/SwitchWorkspaceController.php` and `apps/platform/app/Support/Workspaces/WorkspaceContext.php`
- [X] T016 [US2] Canonicalize post-switch destination rules and safe intended-url consumption in `apps/platform/app/Support/Workspaces/WorkspaceRedirectResolver.php` and `apps/platform/app/Support/Workspaces/WorkspaceIntendedUrl.php`
- [X] T017 [US2] Run focused US2 verification against `apps/platform/tests/Feature/Workspaces/SwitchWorkspaceControllerTest.php`, `apps/platform/tests/Feature/Workspaces/WorkspaceRedirectResolverTest.php`, `apps/platform/tests/Feature/Workspaces/SwitchWorkspaceRedirectsToTenantRegistrationWhenNoTenantsTest.php`, `apps/platform/tests/Feature/Workspaces/WorkspaceSwitchUserMenuTest.php`, and `apps/platform/tests/Feature/Workspaces/ChooseWorkspaceRedirectsToChooseTenantTest.php`
**Checkpoint**: Workspace switching can no longer carry stale tenant truth into the next workspace or route.
---
## Phase 5: User Story 3 - Select Or Clear Tenant Intentionally (Priority: P1)
**Goal**: Make explicit tenant selection and tenant clear flows behave like deterministic scope decisions instead of partial-local heuristics.
**Independent Test**: Select a tenant from the shared shell, clear tenant context from a workspace page, and clear it from a tenant-bound route to verify predictable scope and redirect outcomes.
### Tests for User Story 3
- [X] T018 [P] [US3] Extend explicit tenant-selection coverage for happy-path, non-operable, wrong-workspace, and unauthorized tenant requests in `apps/platform/tests/Feature/Workspaces/SelectTenantControllerTest.php` and `apps/platform/tests/Feature/Filament/WorkspaceContextTopbarAndTenantSelectionTest.php`
- [X] T019 [P] [US3] Extend clear-tenant route-compatibility coverage for workspace-scoped, tenant-bound, tenant-scoped evidence, and canonical workspace record viewer pages in `apps/platform/tests/Feature/Spec085/OperationsIndexHeaderTest.php`, `apps/platform/tests/Feature/Workspaces/ChooseTenantPageTest.php`, and `apps/platform/tests/Feature/Workspaces/GlobalContextShellContractTest.php`
### Implementation for User Story 3
- [X] T020 [US3] Align explicit tenant selection with the canonical shell contract, selector-operability rules, and remembered-context rules in `apps/platform/app/Http/Controllers/SelectTenantController.php` and `apps/platform/app/Support/OperateHub/OperateHubShell.php`
- [X] T021 [US3] Standardize clear-tenant recovery outcomes (same-route tenantless workspace state, `admin.operations.index`, `admin.evidence.overview`, `admin.workspace.managed-tenants.index`, `admin.operations.view`, `admin.home`) and route compatibility in `apps/platform/app/Http/Controllers/ClearTenantContextController.php` and `apps/platform/app/Support/Tenants/TenantPageCategory.php`
- [X] T022 [US3] Keep shell action labels and tenantless wording aligned to the approved vocabulary in `apps/platform/resources/views/filament/partials/context-bar.blade.php`
- [X] T023 [US3] Run focused US3 verification against `apps/platform/tests/Feature/Workspaces/SelectTenantControllerTest.php`, `apps/platform/tests/Feature/Spec085/OperationsIndexHeaderTest.php`, `apps/platform/tests/Feature/Workspaces/ChooseTenantPageTest.php`, and `apps/platform/tests/Feature/Workspaces/GlobalContextShellContractTest.php`
**Checkpoint**: Tenant selection and clear behavior now act as explicit scope changes with stable wording and recovery.
---
## Phase 6: User Story 4 - Reject Invalid Or Stale Context Cleanly (Priority: P1)
**Goal**: Make invalid route, query, and remembered context fail cleanly without leaving stale scope visible or widening access.
**Independent Test**: Enter the shell with invalid route, query-hint, and remembered context combinations, then verify the request falls back to a valid scope or 404 path with no stale shell truth left behind.
### Tests for User Story 4
- [X] T024 [P] [US4] Add valid and invalid query-hint coverage plus stale remembered-context coverage in `apps/platform/tests/Feature/Workspaces/GlobalContextShellContractTest.php` and `apps/platform/tests/Unit/Support/Workspaces/WorkspaceContextRememberedTenantTest.php`
- [X] T025 [P] [US4] Extend tenant-required fallback, workspace-required recovery, and explicit chooser-route exception coverage in `apps/platform/tests/Feature/Workspaces/ChooseTenantPageTest.php`, `apps/platform/tests/Feature/Workspaces/ChooseWorkspacePageTest.php`, and `apps/platform/tests/Feature/Workspaces/EnsureWorkspaceSelectedMiddlewareTest.php`
### Implementation for User Story 4
- [X] T026 [US4] Replace ad hoc tenant-selection heuristics with canonical invalid-context checks in `apps/platform/app/Support/Middleware/EnsureFilamentTenantSelected.php`
- [X] T027 [US4] Tighten page-category classification and invalid-context fallback mapping, including the explicit workspace-independent chooser-route exception, in `apps/platform/app/Support/Tenants/TenantPageCategory.php` and `apps/platform/app/Support/OperateHub/OperateHubShell.php`
- [X] T028 [US4] Preserve deny-as-not-found, forbidden, and no-stale-scope recovery semantics across `/admin` and `/admin/t/{external_id}` in `apps/platform/app/Support/Middleware/EnsureFilamentTenantSelected.php`, `apps/platform/app/Http/Controllers/ClearTenantContextController.php`, and `apps/platform/tests/Feature/Workspaces/GlobalContextShellContractTest.php`
- [X] T029 [US4] Run focused US4 verification against `apps/platform/tests/Feature/Workspaces/GlobalContextShellContractTest.php`, `apps/platform/tests/Feature/Workspaces/ChooseTenantPageTest.php`, `apps/platform/tests/Feature/Workspaces/ChooseWorkspacePageTest.php`, `apps/platform/tests/Feature/Workspaces/EnsureWorkspaceSelectedMiddlewareTest.php`, and `apps/platform/tests/Unit/Support/Workspaces/WorkspaceContextRememberedTenantTest.php`
**Checkpoint**: Invalid or stale context now recovers explicitly and never survives as a false active scope.
---
## Phase 7: User Story 5 - Keep Shared Shell Logic Consistent Across Panels (Priority: P2)
**Goal**: Keep admin and tenant panel entry paths, supporting panel state, and global search safety aligned to the same shell contract.
**Independent Test**: Resolve the same entitled workspace and tenant through admin and tenant panel entry paths, then verify both panels show the same active truth and preserve tenant-safe search behavior.
### Tests for User Story 5
- [X] T030 [P] [US5] Add admin-versus-tenant panel parity coverage for the same entitled workspace and tenant scenario in `apps/platform/tests/Feature/Filament/WorkspaceContextTopbarAndTenantSelectionTest.php` and `apps/platform/tests/Feature/Workspaces/WorkspacesResourceIsTenantlessTest.php`
- [X] T031 [P] [US5] Extend global-search context-safety coverage so tenant-owned results stay scoped under the canonical shell contract in `apps/platform/tests/Feature/Rbac/AdminGlobalSearchContextSafetyTest.php`, `apps/platform/tests/Feature/TenantRBAC/TenantSwitcherScopeTest.php`, and `apps/platform/tests/Feature/Rbac/TenantActionSurfaceConsistencyTest.php`
### Implementation for User Story 5
- [X] T032 [US5] Keep panel-specific context sources subordinate to the canonical shell contract in `apps/platform/app/Filament/Concerns/ResolvesPanelTenantContext.php`, `apps/platform/app/Providers/Filament/AdminPanelProvider.php`, and `apps/platform/app/Providers/Filament/TenantPanelProvider.php`
- [X] T033 [US5] Preserve tenant-safe global search scoping while the shell contract is consolidated in `apps/platform/app/Filament/Concerns/ScopesGlobalSearchToTenant.php`, `apps/platform/app/Filament/Resources/TenantResource.php`, and `apps/platform/app/Filament/Resources/PolicyResource.php`
- [X] T034 [US5] Run focused US5 verification against `apps/platform/tests/Feature/Filament/WorkspaceContextTopbarAndTenantSelectionTest.php`, `apps/platform/tests/Feature/Workspaces/WorkspacesResourceIsTenantlessTest.php`, `apps/platform/tests/Feature/Rbac/AdminGlobalSearchContextSafetyTest.php`, `apps/platform/tests/Feature/Rbac/TenantActionSurfaceConsistencyTest.php`, and `apps/platform/tests/Feature/TenantRBAC/TenantSwitcherScopeTest.php`
**Checkpoint**: Shared shell logic, panel state, and search safety remain aligned across admin and tenant entry paths.
---
## Phase 8: Polish & Cross-Cutting Concerns
**Purpose**: Finish validation, documentation parity, non-functional render proof, and operator smoke coverage across all stories.
- [X] T035 [P] Reconcile final source inventory, source hierarchy, recovery vocabulary, fallback matrix, and verification commands in `specs/199-global-context-shell-contract/plan.md`, `specs/199-global-context-shell-contract/research.md`, `specs/199-global-context-shell-contract/data-model.md`, `specs/199-global-context-shell-contract/contracts/global-context-shell.logical.openapi.yaml`, and `specs/199-global-context-shell-contract/quickstart.md`
- [X] T036 [P] Run the focused Pest validation pack from `specs/199-global-context-shell-contract/quickstart.md`, including DB-only render and no-enqueue shell proof
- [X] T037 Run formatting with `cd apps/platform && ./vendor/bin/sail bin pint --dirty --format agent`
- [X] T038 [P] Execute the timed 3-second manual smoke checklist from `specs/199-global-context-shell-contract/quickstart.md` for tenantless entry, workspace switch, tenant select, tenant clear, evidence fallback, canonical workspace record viewer fallback, invalid remembered tenant, explicit chooser-route exception handling, and panel parity
---
## Dependencies & Execution Order
### Phase Dependencies
- **Setup (Phase 1)**: Starts immediately and creates the focused regression scaffolding and verification baseline.
- **Foundational (Phase 2)**: Depends on Setup and blocks all story work until the canonical resolver seams are in place.
- **User Stories (Phase 3+)**: All depend on Foundational completion.
- **Polish (Phase 8)**: Depends on the desired user stories being complete.
### User Story Dependencies
- **US1**: Depends only on the foundational resolver seam and is the recommended MVP slice.
- **US2**: Depends on the foundational seam and can proceed independently of US1 once canonical workspace and tenant precedence exist.
- **US3**: Depends on the foundational seam and can proceed independently of US1 and US2, though it benefits from the shared shell display already being consumer-only.
- **US4**: Depends on the foundational seam and should land after the invalid-context matrix is stable, but it does not require US2 or US3 to be complete.
- **US5**: Depends on the foundational seam and benefits from at least one earlier story landing first so panel parity and search safety are verified against the implemented contract.
### Within Each User Story
- Story tests should be written before or alongside implementation and should fail before the story is considered complete.
- Resolver and storage seam updates must land before controller, middleware, or shell display changes are considered finished.
- Authorization-sensitive regressions must stay in Unit or Feature lanes only; no browser family should be added for this feature.
- Each story-level verification task should run after the story's implementation tasks are complete.
### Parallel Opportunities
- `T001`, `T002`, and `T003` can run in parallel during Setup.
- `T005` and `T006` can run in parallel during Foundational work.
- `T008` and `T009` can run in parallel for User Story 1.
- `T013` and `T014` can run in parallel for User Story 2.
- `T018` and `T019` can run in parallel for User Story 3.
- `T024` and `T025` can run in parallel for User Story 4.
- `T030` and `T031` can run in parallel for User Story 5.
- `T035`, `T036`, and `T038` can run in parallel after implementation is complete.
---
## Parallel Example: User Story 1
```bash
# User Story 1 tests in parallel:
Task: "T008 Extend shared-shell truth display and no-hidden-page-state coverage in apps/platform/tests/Feature/Filament/WorkspaceContextTopbarAndTenantSelectionTest.php and apps/platform/tests/Feature/Spec085/OperationsIndexHeaderTest.php"
Task: "T009 Add recovery-shell display assertions in apps/platform/tests/Feature/Filament/WorkspaceContextRecoveryDisplayTest.php"
# Then land the shared shell implementation:
Task: "T010 Reduce the shared shell to a consumer-only resolved-context display and keep page-local filters, tabs, and inspect state out of the shell contract in apps/platform/resources/views/filament/partials/context-bar.blade.php"
Task: "T011 Keep both panels rendering the same shared shell contract in apps/platform/app/Providers/Filament/AdminPanelProvider.php and apps/platform/app/Providers/Filament/TenantPanelProvider.php"
```
## Parallel Example: User Story 2
```bash
# User Story 2 tests in parallel:
Task: "T013 Add switch regression coverage in apps/platform/tests/Feature/Workspaces/SwitchWorkspaceControllerTest.php, apps/platform/tests/Feature/Workspaces/WorkspaceRedirectResolverTest.php, and apps/platform/tests/Feature/Workspaces/SwitchWorkspaceRedirectsToTenantRegistrationWhenNoTenantsTest.php"
Task: "T014 Extend workspace-switch affordance coverage in apps/platform/tests/Feature/Workspaces/WorkspaceSwitchUserMenuTest.php and apps/platform/tests/Feature/Workspaces/ChooseWorkspaceRedirectsToChooseTenantTest.php"
# Then land controller and redirect behavior:
Task: "T015 Make workspace switching re-evaluate tenant compatibility in apps/platform/app/Http/Controllers/SwitchWorkspaceController.php and apps/platform/app/Support/Workspaces/WorkspaceContext.php"
Task: "T016 Canonicalize post-switch destination rules in apps/platform/app/Support/Workspaces/WorkspaceRedirectResolver.php and apps/platform/app/Support/Workspaces/WorkspaceIntendedUrl.php"
```
## Parallel Example: User Story 3
```bash
# User Story 3 tests in parallel:
Task: "T018 Extend explicit tenant-selection coverage in apps/platform/tests/Feature/Workspaces/SelectTenantControllerTest.php and apps/platform/tests/Feature/Filament/WorkspaceContextTopbarAndTenantSelectionTest.php"
Task: "T019 Extend clear-tenant route-compatibility coverage in apps/platform/tests/Feature/Spec085/OperationsIndexHeaderTest.php and apps/platform/tests/Feature/Workspaces/ChooseTenantPageTest.php"
# Then land explicit scope-mutation behavior:
Task: "T020 Align explicit tenant selection with the canonical shell contract in apps/platform/app/Http/Controllers/SelectTenantController.php and apps/platform/app/Support/OperateHub/OperateHubShell.php"
Task: "T021 Standardize clear-tenant recovery destinations in apps/platform/app/Http/Controllers/ClearTenantContextController.php and apps/platform/app/Support/Tenants/TenantPageCategory.php"
```
## Parallel Example: User Story 4
```bash
# User Story 4 tests in parallel:
Task: "T024 Add invalid route, query-hint, and stale remembered-context coverage in apps/platform/tests/Feature/Workspaces/GlobalContextShellContractTest.php and apps/platform/tests/Unit/Support/Workspaces/WorkspaceContextRememberedTenantTest.php"
Task: "T025 Extend tenant-required fallback, workspace-required recovery, and explicit chooser-route exception coverage in apps/platform/tests/Feature/Workspaces/ChooseTenantPageTest.php, apps/platform/tests/Feature/Workspaces/ChooseWorkspacePageTest.php, and apps/platform/tests/Feature/Workspaces/EnsureWorkspaceSelectedMiddlewareTest.php"
# Then land middleware and fallback behavior:
Task: "T026 Replace ad hoc tenant-selection heuristics in apps/platform/app/Support/Middleware/EnsureFilamentTenantSelected.php"
Task: "T027 Tighten page-category classification and invalid-context fallback mapping, including the explicit workspace-independent chooser-route exception, in apps/platform/app/Support/Tenants/TenantPageCategory.php and apps/platform/app/Support/OperateHub/OperateHubShell.php"
```
## Parallel Example: User Story 5
```bash
# User Story 5 tests in parallel:
Task: "T030 Add admin-versus-tenant panel parity coverage in apps/platform/tests/Feature/Filament/WorkspaceContextTopbarAndTenantSelectionTest.php and apps/platform/tests/Feature/Workspaces/WorkspacesResourceIsTenantlessTest.php"
Task: "T031 Extend global-search context-safety coverage in apps/platform/tests/Feature/Rbac/AdminGlobalSearchContextSafetyTest.php, apps/platform/tests/Feature/TenantRBAC/TenantSwitcherScopeTest.php, and apps/platform/tests/Feature/Rbac/TenantActionSurfaceConsistencyTest.php"
# Then land panel-parity and search-scope behavior:
Task: "T032 Keep panel-specific context sources subordinate to the canonical shell contract in apps/platform/app/Filament/Concerns/ResolvesPanelTenantContext.php, apps/platform/app/Providers/Filament/AdminPanelProvider.php, and apps/platform/app/Providers/Filament/TenantPanelProvider.php"
Task: "T033 Preserve tenant-safe global search scoping in apps/platform/app/Filament/Concerns/ScopesGlobalSearchToTenant.php, apps/platform/app/Filament/Resources/TenantResource.php, and apps/platform/app/Filament/Resources/PolicyResource.php"
```
---
## Implementation Strategy
### MVP First (User Story 1 Only)
1. Complete Phase 1: Setup.
2. Complete Phase 2: Foundational.
3. Complete Phase 3: User Story 1.
4. Validate that the shared shell shows one truthful tenant-scoped and tenantless model before moving on.
### Incremental Delivery
1. Establish the canonical shell resolver and storage semantics.
2. Deliver truthful shared-shell display as the MVP.
3. Add deterministic workspace switching.
4. Add deterministic tenant select and clear flows.
5. Harden invalid-context recovery.
6. Close with cross-panel parity, search safety, and final validation.
### Parallel Team Strategy
1. One developer can land Setup plus Foundational resolver seams.
2. After Foundational work is complete, one developer can take US1 or US2 while another works on US3 or US4 because the primary file overlap is limited.
3. US5 should land after at least one earlier story so panel parity and global-search safety verify the real implemented contract.
---
## Notes
- `[P]` tasks are limited to work on different files or isolated test files with no incomplete dependency overlap.
- `[US1]` through `[US5]` map directly to the user stories in `spec.md`.
- The suggested MVP scope is Phase 1 through Phase 3 only.
- This task list preserves Filament v5 and Livewire v4 compliance, keeps provider registration unchanged in `bootstrap/providers.php`, keeps destructive-action rules unchanged because no destructive record action is introduced, and preserves existing tenant-safe global search behavior while the shell contract is consolidated.