TenantAtlas/specs/107-workspace-chooser/research.md

9.1 KiB

Research: Workspace Chooser v1

Feature: 107-workspace-chooser | Date: 2026-02-22

R1: Middleware Refactor Strategy

Question: Should we create a new EnsureActiveWorkspace middleware or refactor the existing EnsureWorkspaceSelected?

  • Decision: Refactor the existing EnsureWorkspaceSelected middleware in-place.
  • Rationale: The existing middleware is already registered in both AdminPanelProvider and TenantPanelProvider middleware stacks as 'ensure-workspace-selected', and referenced by alias in bootstrap/app.php. Creating a new class would require changing all registration points and updating existing tests. The current class already handles the same responsibilities — it just doesn't implement them according to the spec.
  • Alternatives considered:
    • New EnsureActiveWorkspace class: rejected because it would require renaming the middleware alias everywhere, with no functional benefit beyond a name change. The alias can remain ensure-workspace-selected for backward compatibility.

R2: Audit Event Integration Pattern

Question: How should workspace selection audit events be emitted?

  • Decision: Call WorkspaceAuditLogger::log() directly from the middleware (for auto-selections) and from ChooseWorkspace::selectWorkspace() (for manual selections). No events/listeners needed.
  • Rationale: WorkspaceAuditLogger is a simple synchronous service — no queue, no listener. The codebase pattern (used in workspace membership, settings, alert destinations, baselines, etc.) is direct $logger->log(...) calls at the mutation point. Workspace selection audit is similarly < 1ms DB insert.
  • Alternatives considered:
    • Laravel Events + Listeners: rejected — overkill for a synchronous log write. No other systems need to react to workspace selection events.
    • Observer on User model (last_workspace_id change): rejected — would miss cases where only the session changes (auto-resume from session), and would conflate preference persistence with audit semantics.

R3: AuditActionId Enum Values

Question: What enum values and string representations to use?

  • Decision: Add two cases:
    • WorkspaceAutoSelected = 'workspace.auto_selected' — for auto-resume (single membership or last-used).
    • WorkspaceSelected = 'workspace.selected' — for manual selection from chooser.
  • Rationale: Follows the existing naming pattern (case CamelName = 'snake.dotted_value'). The method (auto/manual) and reason (single_membership/last_used/chooser) are stored in the audit log's metadata JSONB, not in separate enum values.
  • Alternatives considered:
    • Three separate enum values (one per reason): rejected — metadata provides sufficient granularity; enum values should represent the action type, not the trigger.

R4: Redirect After Selection (Tenant-Count Branching)

Question: Where does redirect logic live? Should it be deduplicated?

  • Decision: Extract the tenant-count branching logic into a shared helper method on WorkspaceContext or a dedicated WorkspaceRedirectResolver to avoid duplicating it across:

    1. EnsureWorkspaceSelected middleware (auto-resume redirects)
    2. ChooseWorkspace::selectWorkspace() (manual selection redirect)
    3. SwitchWorkspaceController::__invoke() (POST switch redirect)
    4. routes/web.php /admin route handler

    Currently, the same branching logic (0 tenants → managed-tenants, 1 → tenant dashboard, >1 → choose-tenant) is copy-pasted in all four locations.

  • Rationale: DRY — the branching is identical in all cases and is the single authority for "where to go after workspace is set." A single method eliminates the risk of divergence as new conditions are added.

  • Alternatives considered:

    • Leave duplicated: rejected — 4 copies of the same logic is a maintenance hazard.
    • Put on ChooseWorkspace page: rejected — the middleware and controller both need it but don't have access to the page class.

R5: ?choose=1 Handling Location

Question: Should the ?choose=1 forced-chooser parameter be handled in the middleware or in the page?

  • Decision: Handle in the middleware — step 2 of the algorithm. If choose=1 is present, redirect to /admin/choose-workspace?choose=1 and skip auto-resume logic.
  • Rationale: The middleware is the single entry point for all /admin/* requests. Handling it there prevents auto-resume from overriding the explicit user intent to see the chooser.
  • Alternatives considered:
    • Handle in ChooseWorkspace page: rejected — the middleware would auto-resume BEFORE the page loads, so the user would never see the chooser.

R6: User Menu Integration

Question: How to add "Switch workspace" to the Filament user menu?

  • Decision: Register via ->userMenuItems() in AdminPanelProvider::panel(). Use Filament\Navigation\MenuItem::make() with ->url('/admin/choose-workspace?choose=1') and a ->visible() callback that checks workspace membership count > 1.
  • Rationale: This is the documented Filament v5 pattern from the constitution + blueprint. The menu item is a navigation-only action (URL link), not a destructive action, so no confirmation needed.
  • Alternatives considered:
    • Context bar link only: rejected — specification explicitly requires a user menu entry (FR-008).
    • Adding to both user menu and context bar: the context bar already has "Switch workspace" — the user menu entry provides an additional discovery point per spec.

R7: Badge Rendering for Workspace Role

Question: Should workspace membership role badges use BadgeCatalog/BadgeDomain?

  • Decision: No. Workspace membership role is a tag/category (owner, admin, member), not a status-like value. Per constitution (BADGE-001), tag/category chips are not governed by BadgeCatalog. Use a simple Filament <x-filament::badge> with a color mapping (e.g., owner → primary, admin → warning, member → gray).
  • Rationale: The role is static metadata, not a state transition. No existing BadgeDomain for workspace roles. Adding one would be over-engineering for 3 static values.
  • Alternatives considered:
    • Create a WorkspaceRoleBadgeDomain: rejected — violates the "tag, not status" exemption in BADGE-001.

R8: Blade Template vs. Livewire Component for Chooser

Question: Should the chooser cards remain as a Blade template or be converted to a Livewire component?

  • Decision: Keep as Blade template rendered by the existing Livewire-backed ChooseWorkspace Filament Page. The page class already extends Filament\Pages\Page (which is Livewire). The wire:click for "Open" calls the existing selectWorkspace() method. No separate Livewire component needed.
  • Rationale: The existing pattern works. The page is already a Livewire component (all Filament Pages are). Converting to a separate Livewire component adds complexity with no benefit — the chooser has no real-time reactive needs.
  • Alternatives considered:
    • Separate WorkspaceChooserCard Livewire component: rejected — unnecessary abstraction for a simple card grid.

R9: Existing SwitchWorkspaceController Coexistence

Question: The chooser currently uses POST to SwitchWorkspaceController. Should we switch to Livewire wire:click or keep the POST?

  • Decision: Migrate the chooser page to use Livewire wire:click calling selectWorkspace($workspaceId) (which already exists). The SwitchWorkspaceController is still needed for the workspace-switcher.blade.php partial (context-bar dropdown) which uses a form POST. Both paths converge on WorkspaceContext::setCurrentWorkspace().
  • Rationale: The Livewire path already exists in ChooseWorkspace::selectWorkspace() — the blade template just needs to call it via wire:click instead of a form POST. This simplifies the chooser page and makes audit integration easier (audit logging happens in the PHP method, not in a controller).
  • Alternatives considered:
    • Keep form POST from chooser: rejected — the selectWorkspace() method is where we add audit logging. Using wire:click means a single code path.
    • Remove SwitchWorkspaceController entirely: deferred — the context-bar dropdown still uses it. Can be unified in a future PR.

R10: Flash Warning Implementation

Question: How to show "Your access to {workspace_name} was removed" when stale membership detected?

  • Decision: Use Filament database notifications or session flash + Filament\Notifications\Notification::make()->danger(). Since the middleware redirects to the chooser, and the chooser is a Filament page, Filament's notification system renders flash/database notifications automatically.
  • Rationale: The chooser is a Filament page — Filament's notification toast system is already wired. Session-based Notification::make() works for redirect→page scenarios.
  • Alternatives considered:
    • Custom Blade banner: rejected — Filament notifications already solve this and are consistent with the rest of the app.
    • Session flash only (no Filament notification): rejected — the Filament notification system provides better UX (auto-dismiss, consistent styling).