# Feature Specification: Workspace Chooser v1 (Enterprise) + In-App Switch Entry Point

**Feature Branch**: `107-workspace-chooser`
**Created**: 2026-02-22
**Status**: Draft
**Input**: User description: "Workspace Chooser v1 (Enterprise) + In-App Switch Entry Point: Auto-Resume, Switch vs Manage separation, Enterprise metadata, Audit events"

---

## Spec Scope Fields *(mandatory)*

- **Scope**: workspace
- **Primary Routes**: `/admin/choose-workspace`, `/admin/*` (middleware), user menu switch entry point
- **Data Ownership**: workspace-owned (`workspaces`, `workspace_memberships`); user-owned (`users.last_workspace_id`)
- **RBAC**: Any workspace member may switch/select; `workspace.manage` capability required for "Manage workspaces" link visibility in chooser

---

## Context

TenantPilot is workspace-first: a Workspace groups one or more Microsoft Tenants (customer environments). After login, an active workspace must be set so that RBAC, scoping, operations, findings, and all tenant-level features function correctly.

### Current State (What Exists)

The codebase already has foundational infrastructure:

- **`WorkspaceContext`** (`app/Support/Workspaces/WorkspaceContext.php`) — manages `session.current_workspace_id`, `users.last_workspace_id`, and provides `resolveInitialWorkspaceFor()` with partial auto-resume logic (session → last-used → single membership).
- **`ChooseWorkspace`** page (`app/Filament/Pages/ChooseWorkspace.php`) — card grid with "Create workspace" header action, Livewire select, and POST-based form submit.
- **`WorkspaceMembership`** pivot model with `role` column.
- **Audit system** via `WorkspaceAuditLogger` + `AuditActionId` enum (workspace membership events already audited).
- **`Capabilities::WORKSPACE_MANAGE`** already defined in the capability registry.

### What's Missing (Motivation)

1. **Switch vs. Manage conflation**: "Create workspace" is prominently placed in the chooser alongside selection.
2. **No explicit auto-resume in middleware**: `resolveInitialWorkspaceFor()` exists but is not systematically called as middleware before every `/admin/*` request.
3. **No race-condition handling with user feedback**: stale `last_workspace_id` or revoked membership causes silent fallback, no warning notification.
4. **Minimal metadata**: chooser cards show name + "Last used" badge only — no role, no tenant count.
5. **No audit events for workspace selection/switch** (only membership changes are audited).

---

## User Scenarios & Testing *(mandatory)*

### User Story 1 — Auto-Resume: Single Workspace (Priority: P1)

A user with exactly one workspace membership logs in and is taken directly to their workspace dashboard without seeing the chooser screen.

**Why this priority**: Eliminates an unnecessary click for the majority of users (single-workspace scenario is the most common).

**Independent Test**: Create a user with one workspace membership, hit `/admin`, verify redirect to workspace dashboard without chooser.

**Acceptance Scenarios**:

1. **Given** a user with exactly 1 workspace membership and no session, **When** they visit `/admin`, **Then** they are redirected directly to the workspace dashboard and `session.current_workspace_id` is set.
2. **Given** a user with exactly 1 workspace membership, **When** auto-resume fires, **Then** an audit event `workspace.auto_selected` with reason `single_membership` is emitted.

---

### User Story 2 — Auto-Resume: Last Used Workspace (Priority: P1)

A user with multiple workspaces who has a valid `last_workspace_id` is taken directly to that workspace without the chooser.

**Why this priority**: Reduces friction for multi-workspace users (MSP/consulting scenario) on repeat visits.

**Independent Test**: Create a user with 2+ workspaces and a valid `last_workspace_id`, hit `/admin`, verify direct entry.

**Acceptance Scenarios**:

1. **Given** a user with 3 workspace memberships and `last_workspace_id` pointing to a valid membership, **When** they visit `/admin`, **Then** they land on that workspace's dashboard.
2. **Given** the auto-resume via last-used fires, **Then** an audit event `workspace.auto_selected` with reason `last_used` is emitted.

---

### User Story 3 — Chooser Fallback: Multiple Workspaces, No Default (Priority: P1)

A user with multiple workspaces and no valid `last_workspace_id` sees the chooser with enterprise metadata.

**Why this priority**: Core path — the chooser must show meaningful data to support quick selection.

**Independent Test**: Create a user with 3 workspaces (varying roles, tenant counts), clear `last_workspace_id`, visit `/admin`, verify chooser renders with metadata.

**Acceptance Scenarios**:

1. **Given** a user with 3 workspace memberships and no `last_workspace_id`, **When** they visit `/admin`, **Then** the chooser is displayed.
2. **Given** the chooser renders, **Then** each workspace row shows: Name, Role badge, Tenants count, and an "Open" action.
3. **Given** the chooser renders, **Then** "Create workspace" is not prominently shown. A "Manage workspaces" link appears only if the user has `workspace.manage` capability.

---

### User Story 4 — Stale Session / Revoked Membership (Priority: P2)

A user whose workspace membership was revoked between sessions sees a clear warning and is redirected to the chooser.

**Why this priority**: Race condition handling is essential for multi-tenant governance and prevents silent errors.

**Independent Test**: Set session to a workspace, delete the membership, visit `/admin`, verify warning + chooser.

**Acceptance Scenarios**:

1. **Given** a user with `session.current_workspace_id` pointing to a workspace where membership was revoked, **When** they visit `/admin`, **Then** the session is cleared and they are redirected to the chooser with a warning notification: "Your access to {workspace_name} was removed."
2. **Given** a user with `last_workspace_id` pointing to a revoked membership and no session, **When** they visit `/admin`, **Then** `last_workspace_id` is cleared, and the chooser is shown with a warning.

---

### User Story 5 — Manual Workspace Switch (Priority: P2)

A user can switch workspaces from within the app via the user menu, which takes them to the chooser.

**Why this priority**: Users managing multiple tenants need an explicit switch path. This is the foundation for the in-app switcher.

**Independent Test**: As a logged-in user with active workspace, click "Switch workspace" in user menu, verify chooser loads with `?choose=1`.

**Acceptance Scenarios**:

1. **Given** a user with an active workspace, **When** they click "Switch workspace" in the user menu, **Then** they are taken to `/admin/choose-workspace?choose=1`.
2. **Given** a user selects a different workspace from the chooser, **Then** `session.current_workspace_id` is updated, `users.last_workspace_id` is updated, and an audit event `workspace.selected` with reason `chooser` is emitted.
3. **Given** a user visits `/admin/choose-workspace?choose=1`, **Then** the chooser is shown regardless of auto-resume eligibility.

---

### User Story 6 — Audit Trail for Workspace Context Changes (Priority: P2)

Every workspace selection (auto or manual) produces an audit log entry for compliance.

**Why this priority**: MSP/compliance requirement — workspace context changes must be traceable.

**Independent Test**: Trigger auto-resume and manual selection, verify audit log entries with correct payloads.

**Acceptance Scenarios**:

1. **Given** any workspace selection occurs, **Then** an audit log entry is created with: `actor_id`, `workspace_id`, `method` (auto|manual), `reason`, and optional `prev_workspace_id`.
2. **Given** an auto-resume via single membership, **Then** the audit event reason is `single_membership`.
3. **Given** a manual selection from the chooser, **Then** the audit event reason is `chooser`.

---

### Edge Cases

- What happens when a user has **zero workspace memberships**? → Empty state: "You don't have access to any workspace yet." with optional "Manage workspaces" link (permission-gated).
- What happens when the workspace referenced by `last_workspace_id` is **archived**? → Treated as invalid, cleared, chooser shown.
- What happens when `?choose=1` is used by a user with **only 1 workspace**? → Chooser is shown anyway (forced mode).
- What happens when `session.current_workspace_id` is set but the workspace was **archived** between requests? → Session cleared, warning shown, chooser displayed.
- What happens when a user has **multiple browser tabs** open and switches workspace in one tab? → Session is the single source of truth. Other tabs reflect the new workspace on their next server request. No per-tab isolation in v1.

---

## Requirements *(mandatory)*

**Constitution alignment (RBAC-UX):**

- **Authorization plane**: workspace `/admin` scope.
- **Membership = switch-right**: no separate `workspace.switch` capability in v1. Any workspace member may select/switch to that workspace.
- **`workspace.manage`** gates: visibility of "Manage workspaces" link in chooser; access to Workspace CRUD (existing separate resource).
- **404 vs 403**: non-member attempting to select a workspace they're not in → 404 (deny-as-not-found); results in no selection change.
- **Server-side enforcement**: `EnsureWorkspaceSelected` middleware validates membership on every request; chooser only lists workspaces with valid membership.

**Constitution alignment (audit):**

- Workspace selection events (`workspace.auto_selected`, `workspace.selected`) are logged via `WorkspaceAuditLogger` using new `AuditActionId` enum values.
- No `OperationRun` needed — these are session-context changes, not long-running operations.

**Constitution alignment (UX-001):**

- The chooser page is a **context selector**, not a CRUD screen. UX-001 layout rules (Main/Aside, Sections) do not directly apply. Exemption: this is a custom selection page, not a Create/Edit/View resource page.
- Empty state follows UX-001: specific title + explanation + 1 CTA (permission-gated).

### Functional Requirements

- **FR-001**: System MUST auto-resume to an active workspace without showing the chooser when: (a) session is valid, (b) user has exactly 1 membership, or (c) `last_workspace_id` points to a valid membership.
- **FR-002**: System MUST show the chooser only when auto-resume cannot determine a valid workspace.
- **FR-003**: Chooser MUST display each workspace with: Name, Role badge, Tenants count (`withCount`), and a primary "Open" action.
- **FR-004**: System MUST clear stale session/preference values and show a warning notification when membership has been revoked for the referenced workspace.
- **FR-005**: System MUST emit an audit event for every workspace selection (auto or manual) with payload: `actor_id`, `workspace_id`, `method`, `reason`, optional `prev_workspace_id`.
- **FR-006**: Chooser MUST NOT prominently display "Create workspace". A "Manage workspaces" link MAY appear, gated by `workspace.manage` capability.
- **FR-007**: Route parameter `?choose=1` MUST force the chooser to display regardless of auto-resume eligibility.
- **FR-008**: User menu MUST contain a "Switch workspace" entry that links to `/admin/choose-workspace?choose=1`. Entry is visible only when the user has >1 workspace membership.
- **FR-009**: After workspace selection (auto or manual), the system MUST apply existing tenant-count branching: 0 tenants → Managed Tenants index, 1 tenant → Tenant Dashboard directly, >1 tenants → Choose Tenant page. No "smart redirect" to the last-visited page in v1.
- **FR-010**: The `EnsureWorkspaceSelected` middleware MUST run on all `/admin/*` routes except the chooser page itself, login/logout routes, and OAuth callbacks.
- **FR-011**: Chooser queries MUST NOT produce N+1 problems (eager load memberships + `withCount('tenants')`).

---

## UI Action Matrix *(mandatory when Filament is changed)*

| Surface | Location | Header Actions | Inspect Affordance | Row Actions | Bulk Actions | Empty-State CTA(s) | View Header Actions | Create/Edit Save+Cancel | Audit log? | Notes / Exemptions |
|---|---|---|---|---|---|---|---|---|---|---|
| ChooseWorkspace (Custom Page) | `app/Filament/Pages/ChooseWorkspace.php` | None (v1) | N/A — cards/rows | "Open" (primary) per workspace | N/A | "You don't have access to any workspace yet." + "Manage workspaces" (gated by `workspace.manage`) | N/A | N/A | Yes — `workspace.selected` / `workspace.auto_selected` | Context selector page, not CRUD. "Create workspace" removed from header actions; accessible only via "Manage workspaces" link. |

---

### Key Entities

- **Workspace**: The organizational context (portfolio/MSP account) that groups Microsoft Tenants. Key attributes: name, slug, archived_at.
- **WorkspaceMembership**: Pivot linking User to Workspace with a role. Determines selection eligibility.
- **User**: The authenticated actor. Stores `last_workspace_id` as a convenience preference for auto-resume.
- **AuditLog**: Existing audit infrastructure. New action IDs for workspace selection events.

---

## Success Criteria *(mandatory)*

### Measurable Outcomes

- **SC-001**: Users with a single workspace reach their dashboard in **zero extra clicks** after login (no chooser screen).
- **SC-002**: Users with a valid last-used workspace reach their dashboard in **zero extra clicks** after login.
- **SC-003**: 100% of workspace selection events (auto and manual) produce an audit log entry within the same request.
- **SC-004**: Chooser page loads in **under 200ms database time** with up to 50 workspace memberships (no N+1 queries).
- **SC-005**: Users whose membership was revoked see a **clear warning message** within 1 page load, never a broken/empty state.
- **SC-006**: "Create workspace" is **never visible** on the chooser page as a primary action; only "Manage workspaces" appears for authorized users.

---

## Terminology & Copy

- **"Workspace"** remains the product term (matches architecture: Workspace → contains Tenants).
- Chooser page title: **"Select workspace"**
- Chooser description: **"A workspace groups one or more Microsoft tenants (customer environments)."**
- Warning banner (revoked access): **"Your access to {workspace_name} was removed."**
- User menu entry: **"Switch workspace"**
- Button label: **"Open"** (not "Continue")

---

## Middleware: EnsureWorkspaceSelected (v1 Algorithm)

The middleware runs on all `/admin/*` routes (except chooser, login/logout, OAuth callbacks).

**Algorithm (strict order):**

1. If request path is `/admin/choose-workspace` → **allow** (prevent redirect loop).
2. If query has `choose=1` → **redirect to chooser**.
3. If `session.current_workspace_id` is set:
   - If membership valid + workspace not archived → **allow**.
   - Else: clear session, set flash warning ("Your access to {name} was removed."), redirect to chooser.
4. Load user's workspace memberships (selectable only: not archived).
5. If exactly 1 → set active, emit audit (`auto_selected`, reason: `single_membership`), redirect via tenant-count branching (0→managed tenants, 1→tenant dashboard, >1→choose tenant).
6. If `users.last_workspace_id` set:
   - If membership valid + workspace selectable → set active, emit audit (`auto_selected`, reason: `last_used`), redirect via tenant-count branching.
   - Else: clear `last_workspace_id`, set flash warning, redirect to chooser.
7. Else → redirect to chooser.

---

## Data Model (v1)

### Existing (no changes needed)

- `workspaces` table (name, slug, archived_at)
- `workspace_memberships` pivot (workspace_id, user_id, role)
- `users.last_workspace_id` (nullable FK) — already exists, used by `WorkspaceContext::setCurrentWorkspace()`
- `session.current_workspace_id` — `WorkspaceContext::SESSION_KEY`

### New

- **`AuditActionId` enum values**: `WorkspaceAutoSelected`, `WorkspaceSelected` — to be added to existing enum.
- No new tables or columns required.

---

## Audit Events (v1)

| Event | AuditActionId | Method | Reason | Payload |
|---|---|---|---|---|
| Auto-resume: single membership | `workspace.auto_selected` | `auto` | `single_membership` | `actor_id`, `workspace_id`, `prev_workspace_id` (if any) |
| Auto-resume: last used | `workspace.auto_selected` | `auto` | `last_used` | `actor_id`, `workspace_id`, `prev_workspace_id` (if any) |
| Manual selection from chooser | `workspace.selected` | `manual` | `chooser` | `actor_id`, `workspace_id`, `prev_workspace_id` (if any) |
| Context-bar switch (dropdown) | `workspace.selected` | `manual` | `context_bar` | `actor_id`, `workspace_id`, `prev_workspace_id` (if any) |

---

## Test Plan (Feature/Integration)

- `it_skips_chooser_when_single_workspace_membership`
- `it_auto_resumes_to_last_used_workspace_when_membership_valid`
- `it_redirects_to_chooser_when_last_workspace_membership_revoked_and_shows_warning`
- `it_clears_session_when_active_workspace_membership_revoked`
- `it_only_lists_workspaces_user_is_member_of`
- `it_shows_name_role_and_tenants_count_per_workspace`
- `it_persists_last_used_workspace_on_manual_selection`
- `it_emits_audit_event_on_auto_selection_single_membership`
- `it_emits_audit_event_on_auto_selection_last_used`
- `it_emits_audit_event_on_manual_selection`
- `it_hides_manage_link_without_workspace_manage_capability`
- `it_shows_manage_link_with_workspace_manage_capability`
- `it_forces_chooser_with_choose_param`
- `it_shows_empty_state_when_no_memberships`
- `it_hides_switch_workspace_menu_when_single_workspace`
- `it_shows_switch_workspace_menu_when_multiple_workspaces`
- `it_has_no_n_plus_1_queries_in_chooser` (query count assertion)

---

## Clarifications

### Session 2026-02-22

- Q: Should v1 redirect always to a fixed dashboard, or preserve existing tenant-count branching (0→managed tenants, 1→tenant dashboard, >1→choose tenant)? → A: Preserve existing tenant-count branching — avoids UX regression for current users.
- Q: Should the middleware treat the session as single source of truth for all tabs, or add per-tab workspace isolation? → A: Session is single source of truth — all tabs share the workspace context. Switching in one tab is reflected in all others on next request. Matches existing `WorkspaceContext` design.

---

## v2 Backlog (Explicitly Deferred)

- Search/Sort/Favorites/Pins in chooser
- Environment Badges (Prod/Test/Staging) — requires data source
- Last Activity per workspace (max OperationRun timestamp)
- Smart Redirect after switch (return to last page if authorized in new workspace)
- Stateless API workspace scoping (header/token-based)
- Dropdown switcher in header (v1 = link to chooser page)
- `user_preferences` JSONB table (only if more preferences accumulate; v1 stays on `users.last_workspace_id`)