plan: workspace chooser v1 — research, data-model, contracts, quickstart

This commit is contained in:
Ahmed Darrazi 2026-02-22 14:53:24 +01:00
parent 1f2ee8c8a3
commit dd4fb6071f
6 changed files with 570 additions and 2 deletions

View File

@ -35,6 +35,8 @@ ## Active Technologies
- PostgreSQL — no schema changes (103-ia-scope-filter-semantics)
- PHP 8.4 (Laravel 12) + Filament v5, Livewire v4, Pest v4 (104-provider-permission-posture)
- PostgreSQL (via Sail), JSONB for stored report payloads and finding evidence (104-provider-permission-posture)
- PHP 8.4 / Laravel 12 + Filament v5, Livewire v4, Tailwind CSS v4 (107-workspace-chooser)
- PostgreSQL (existing tables: `workspaces`, `workspace_memberships`, `users`, `audit_logs`) (107-workspace-chooser)
- PHP 8.4.15 (feat/005-bulk-operations)
@ -54,9 +56,8 @@ ## Code Style
PHP 8.4.15: Follow standard conventions
## Recent Changes
- 107-workspace-chooser: Added PHP 8.4 / Laravel 12 + Filament v5, Livewire v4, Tailwind CSS v4
- 106-required-permissions-sidebar-context: Middleware sidebar-context fix for workspace-scoped pages
- 105-entra-admin-roles-evidence-findings: Added PHP 8.4 (Laravel 12) + Filament v5, Livewire v4, Pest v4
- 104-provider-permission-posture: Added PHP 8.4 (Laravel 12) + Filament v5, Livewire v4, Pest v4
- 103-ia-scope-filter-semantics: Added PHP 8.4 (Laravel 12) + Filament v5, Livewire v4, `OperateHubShell` support class
<!-- MANUAL ADDITIONS START -->
<!-- MANUAL ADDITIONS END -->

View File

@ -0,0 +1,126 @@
# Routes Contract: Workspace Chooser v1
**Feature**: 107-workspace-chooser | **Date**: 2026-02-22
## Routes (existing, behavior changes)
### `GET /admin` (named: `admin.home`)
**Change**: After workspace auto-resume, redirect uses the shared `WorkspaceRedirectResolver` instead of inline branching.
**Middleware**: `web`, `panel:admin`, `ensure-correct-guard:web`, `FilamentAuthenticate`, `ensure-workspace-selected`
**Behavior (updated)**:
1. `ensure-workspace-selected` middleware handles auto-resume (may set workspace + redirect before this handler runs).
2. If workspace is resolved, apply tenant-count branching.
3. If no workspace, redirect to `/admin/choose-workspace`.
---
### `GET /admin/choose-workspace` (Filament Page: `ChooseWorkspace`)
**Change**: Page now displays metadata (role, tenant count), cleaner empty state, "Manage workspaces" link instead of "Create workspace" header action.
**Middleware**: Standard admin panel middleware. `ensure-workspace-selected` allows this path (exempted in `isWorkspaceOptionalPath()`).
**Query params**:
- `?choose=1` — forces chooser display (bypasses auto-resume). The middleware redirects here when this param is present.
**Response**: Filament page with workspace cards.
**Livewire actions**:
- `selectWorkspace(int $workspaceId)` — validates membership, sets workspace context, emits audit event, redirects via tenant-count branching.
---
### `POST /admin/switch-workspace` (named: `admin.switch-workspace`)
**Change**: No structural change. This route continues to serve the context-bar dropdown workspace switcher. In a future iteration, audit logging may be added here as well.
**Controller**: `SwitchWorkspaceController`
**Request body**: `workspace_id` (required, integer)
**Middleware**: `web`, `auth`, `ensure-correct-guard:web`
---
## Middleware Contract: `ensure-workspace-selected`
### Algorithm (v1 — 7-step)
```
Step 1: If path is workspace-optional → ALLOW (no redirect)
Step 2: If query has `choose=1` → REDIRECT to /admin/choose-workspace?choose=1
Step 3: If session.current_workspace_id is set:
- If membership valid + not archived → ALLOW
- Else: clear session + flash warning → REDIRECT to chooser
Step 4: Load user's selectable workspace memberships (not archived)
Step 5: If exactly 1 → auto-select, audit log (single_membership) → REDIRECT via tenant branching
Step 6: If last_workspace_id set:
- If valid membership + selectable → auto-select, audit log (last_used) → REDIRECT via tenant branching
- Else: clear last_workspace_id + flash warning → REDIRECT to chooser
Step 7: Else → REDIRECT to chooser
```
### Exempt Paths (workspace-optional)
- `/admin/workspaces*`
- `/admin/choose-workspace`
- `/admin/no-access`
- `/admin/onboarding`
- `/admin/settings/workspace`
- `/admin/operations/{id}` (existing exemption)
- `/admin/t/*` (tenant-scoped routes)
- Routes with `.auth.` in name
---
## User Menu Contract
### "Switch workspace" menu item
**Location**: Admin panel user menu (registered via `AdminPanelProvider::panel()``->userMenuItems()`)
**Visibility**: Only when current user has > 1 workspace membership.
**URL**: `/admin/choose-workspace?choose=1`
**Icon**: `heroicon-o-arrows-right-left`
---
## Audit Event Contracts
### `workspace.auto_selected`
**Trigger**: Middleware auto-resume (steps 5 or 6).
**Payload** (in `audit_logs.metadata`):
```json
{
"method": "auto",
"reason": "single_membership" | "last_used",
"prev_workspace_id": null
}
```
### `workspace.selected`
**Trigger**: Manual selection from chooser (via `selectWorkspace()`).
**Payload** (in `audit_logs.metadata`):
```json
{
"method": "manual",
"reason": "chooser",
"prev_workspace_id": 42
}
```
Both events use `WorkspaceAuditLogger::log()` with:
- `action`: `AuditActionId::WorkspaceAutoSelected->value` or `AuditActionId::WorkspaceSelected->value`
- `resource_type`: `'workspace'`
- `resource_id`: `(string) $workspace->getKey()`

View File

@ -0,0 +1,142 @@
# Data Model: Workspace Chooser v1
**Feature**: 107-workspace-chooser | **Date**: 2026-02-22
## Existing Entities (No Changes)
### workspaces
| Column | Type | Nullable | Notes |
|--------|------|----------|-------|
| id | bigint (PK) | NO | Auto-increment |
| name | varchar(255) | NO | Display name |
| slug | varchar(255) | YES | URL-safe identifier |
| archived_at | timestamp | YES | Soft-archive marker; non-null = archived |
| created_at | timestamp | NO | |
| updated_at | timestamp | NO | |
### workspace_memberships
| Column | Type | Nullable | Notes |
|--------|------|----------|-------|
| id | bigint (PK) | NO | Auto-increment |
| workspace_id | bigint (FK) | NO | → workspaces.id |
| user_id | bigint (FK) | NO | → users.id |
| role | varchar(255) | NO | 'owner', 'admin', 'member' |
| created_at | timestamp | NO | |
| updated_at | timestamp | NO | |
### users (relevant columns only)
| Column | Type | Nullable | Notes |
|--------|------|----------|-------|
| last_workspace_id | bigint (FK) | YES | → workspaces.id; auto-resume preference |
### audit_logs (relevant columns only)
| Column | Type | Nullable | Notes |
|--------|------|----------|-------|
| id | bigint (PK) | NO | |
| workspace_id | bigint | YES | → workspaces.id |
| tenant_id | bigint | YES | NULL for workspace-scoped events |
| actor_id | bigint | YES | → users.id |
| actor_email | varchar | YES | |
| actor_name | varchar | YES | |
| action | varchar | NO | stable action ID string |
| resource_type | varchar | YES | |
| resource_id | varchar | YES | |
| status | varchar | NO | 'success' / 'failure' |
| metadata | jsonb | YES | additional context (sanitized) |
| recorded_at | timestamp | NO | |
### Session (in-memory / store-backed)
| Key | Type | Notes |
|-----|------|-------|
| `current_workspace_id` | int | Set by `WorkspaceContext::setCurrentWorkspace()` |
## New Data (Enum Values Only)
### AuditActionId Enum — New Cases
```php
case WorkspaceAutoSelected = 'workspace.auto_selected';
case WorkspaceSelected = 'workspace.selected';
```
### Audit Log Metadata Schema (for workspace selection events)
```jsonc
{
"method": "auto" | "manual",
"reason": "single_membership" | "last_used" | "chooser",
"prev_workspace_id": 123 | null // previous workspace if switching
}
```
## Entity Relationships (relevant to this feature)
```text
User ──< WorkspaceMembership >── Workspace
│ │
└── last_workspace_id ────────────┘
User ──< AuditLog >── Workspace
```
## Validation Rules
| Field | Rule | Source |
|-------|------|--------|
| Workspace selectability | `archived_at IS NULL` | `WorkspaceContext::isWorkspaceSelectable()` |
| Membership check | `workspace_memberships WHERE user_id AND workspace_id` | `WorkspaceContext::isMember()` |
| `choose` param | `?choose=1` (truthy string) | Middleware step 2 |
| Non-member selection attempt | abort(404) | FR deny-as-not-found |
## State Transitions
The workspace selection flow is a session-context transition, not a data state machine.
```text
[No Session] ──auto-resume──> [Active Workspace Session]
[No Session] ──chooser──────> [Active Workspace Session]
[Active Session] ──switch───> [Active Workspace Session (different)]
[Active Session] ──revoked──> [No Session] + warning flash
[Active Session] ──archived─> [No Session] + warning flash
```
## Query Patterns
### Chooser Page Query (FR-003, FR-011)
```php
Workspace::query()
->whereIn('id', function ($query) use ($user) {
$query->from('workspace_memberships')
->select('workspace_id')
->where('user_id', $user->getKey());
})
->whereNull('archived_at')
->withCount('tenants')
->orderBy('name')
->get();
```
Joined via subquery (no N+1). `withCount('tenants')` adds a single correlated subquery. Result includes `tenants_count` attribute.
### Role Retrieval for Display
```php
// Eager-load membership pivot to get role per workspace
// Option A: Join workspace_memberships in the query
// Option B: Use $workspace->pivot->role when loaded via user relationship
// Preferred: load memberships separately keyed by workspace_id
$memberships = WorkspaceMembership::query()
->where('user_id', $user->getKey())
->pluck('role', 'workspace_id');
// Then in view: $memberships[$workspace->id] ?? 'member'
```
Single query, keyed by workspace_id, accessed in O(1) per card.

View File

@ -0,0 +1,106 @@
# Implementation Plan: Workspace Chooser v1 (Enterprise) + In-App Switch Entry Point
**Branch**: `107-workspace-chooser` | **Date**: 2026-02-22 | **Spec**: [spec.md](spec.md)
**Input**: Feature specification from `/specs/107-workspace-chooser/spec.md`
## Summary
Refactor the workspace resolution flow to provide an enterprise-grade auto-resume, explicit switch/manage separation, enhanced metadata in the chooser, and audit events for all workspace selection transitions. The primary changes are:
1. **Refactor `EnsureWorkspaceSelected` middleware** to implement the spec's 7-step auto-resume algorithm with stale-membership detection and flash warnings.
2. **Upgrade the `ChooseWorkspace` page** with role badges, tenant counts, "Manage workspaces" link (capability-gated), and cleaned-up empty state (no "Create workspace" header action).
3. **Add audit events** for workspace auto-selection and manual selection via new `AuditActionId` enum cases + `WorkspaceAuditLogger` calls.
4. **Add "Switch workspace" user menu entry** visible only when user has >1 workspace membership.
5. **Support `?choose=1` forced chooser** bypass parameter in middleware.
No new tables, no new columns, no Microsoft Graph calls. All changes are DB-only, session-based, and synchronous.
## Technical Context
**Language/Version**: PHP 8.4 / Laravel 12
**Primary Dependencies**: Filament v5, Livewire v4, Tailwind CSS v4
**Storage**: PostgreSQL (existing tables: `workspaces`, `workspace_memberships`, `users`, `audit_logs`)
**Testing**: Pest v4 (feature tests as Livewire component tests + HTTP tests)
**Target Platform**: Web (Sail/Docker locally, Dokploy for staging/production)
**Project Type**: Web application (Laravel monolith)
**Performance Goals**: Chooser page < 200ms DB time with 50 workspace memberships; no N+1 queries
**Constraints**: Session-based workspace context (all tabs share); no new tables/columns
**Scale/Scope**: Single Filament page refactor + 1 middleware refactor + 2 enum values + user menu entry + ~17 tests
## Constitution Check
*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.*
- [x] **Inventory-first**: N/A — this feature does not interact with Inventory. Workspace selection is a session context operation.
- [x] **Read/write separation**: The only write is updating `users.last_workspace_id` (convenience preference) and creating audit log entries. No destructive mutations — no preview/confirmation needed for preference persistence. Audit events fire on every selection.
- [x] **Graph contract path**: N/A — no Microsoft Graph calls in this feature. All data is local (workspaces, memberships, session).
- [x] **Deterministic capabilities**: `Capabilities::WORKSPACE_MANAGE` is referenced via the canonical registry constant. No new capabilities introduced.
- [x] **RBAC-UX**: Feature operates in the `/admin` plane only. Non-member workspace selection returns 404 (deny-as-not-found) via `WorkspaceContext::isMember()`. "Manage workspaces" link gated by `workspace.manage` capability. No cross-plane access introduced.
- [x] **Workspace isolation**: Middleware ensures workspace membership on every `/admin/*` request. Stale sessions are cleared and redirected. Non-members get 404.
- [x] **Destructive actions**: No destructive actions in this feature. The re-selection is a non-destructive context switch.
- [x] **Global search**: No changes to global search behavior.
- [x] **Tenant isolation**: Not directly affected. After workspace selection, the existing tenant-count branching routes to tenant-scoped flows.
- [x] **Run observability**: N/A — workspace selection is a synchronous, DB-only, < 2s session operation. No `OperationRun` needed. Selection events are audit-logged.
- [x] **Automation**: N/A — no queued/scheduled operations.
- [x] **Data minimization**: Audit log stores only `actor_id`, `workspace_id`, `method`, `reason`, `prev_workspace_id` — no secrets/tokens/PII.
- [x] **Badge semantics (BADGE-001)**: Role badge in chooser renders the workspace membership role. Simple color-mapped Filament badge (no status-like semantics, just a label). The workspace membership role is a tag/category, not a status — exempt from `BadgeCatalog`. Verified: no `BadgeDomain` exists for workspace roles.
- [x] **Filament UI Action Surface Contract**: ChooseWorkspace is a custom context-selector page, not a CRUD Resource. Spec includes UI Action Matrix with explicit exemption documented. No header actions (v1), "Open" per workspace, empty state with specific title + CTA.
- [x] **Filament UI UX-001**: This is a context-selector page, not a Create/Edit/View resource page. UX-001 Main/Aside layout does not apply. Exemption documented in spec.
## Project Structure
### Documentation (this feature)
```text
specs/107-workspace-chooser/
├── plan.md # This file
├── research.md # Phase 0 output
├── data-model.md # Phase 1 output
├── quickstart.md # Phase 1 output
├── contracts/ # Phase 1 output
│ └── routes.md
└── tasks.md # Phase 2 output (created by /speckit.tasks)
```
### Source Code (repository root)
```text
app/
├── Http/
│ └── Middleware/
│ └── EnsureWorkspaceSelected.php # MODIFY — refactor to spec algorithm
├── Filament/
│ └── Pages/
│ └── ChooseWorkspace.php # MODIFY — metadata, remove Create action, audit
├── Providers/
│ └── Filament/
│ └── AdminPanelProvider.php # MODIFY — add user menu item
├── Support/
│ ├── Audit/
│ │ └── AuditActionId.php # MODIFY — add 2 enum cases
│ └── Workspaces/
│ └── WorkspaceContext.php # MODIFY — add clearSession + audit helper
resources/
└── views/
└── filament/
└── pages/
└── choose-workspace.blade.php # MODIFY — metadata cards, empty state, manage link
tests/
└── Feature/
└── Workspaces/
├── EnsureWorkspaceSelectedMiddlewareTest.php # NEW
├── ChooseWorkspacePageTest.php # NEW
└── WorkspaceSwitchUserMenuTest.php # NEW
```
**Structure Decision**: Standard Laravel monolith structure. All changes are in existing directories. No new folders needed.
## Complexity Tracking
> No Constitution Check violations. No justifications needed.
| Violation | Why Needed | Simpler Alternative Rejected Because |
|-----------|------------|-------------------------------------|
| — | — | — |

View File

@ -0,0 +1,87 @@
# Quickstart: Workspace Chooser v1
**Feature**: 107-workspace-chooser | **Date**: 2026-02-22
## Prerequisites
- Branch: `107-workspace-chooser` checked out
- Sail running: `vendor/bin/sail up -d`
- Existing workspace + user fixtures (factory-based)
## Implementation Order
### Phase A: Foundation (no visible changes)
1. **Add `AuditActionId` enum cases**`WorkspaceAutoSelected`, `WorkspaceSelected`
2. **Extract `WorkspaceRedirectResolver`** — shared tenant-count branching helper (DRY the 4 current copies)
3. **Tests for redirect resolver** — verify 0/1/>1 tenant branching
### Phase B: Middleware Refactor (core behavior change)
4. **Refactor `EnsureWorkspaceSelected`** — implement 7-step algorithm from spec
- Step 1: workspace-optional path bypass (keep existing `isWorkspaceOptionalPath()`)
- Step 2: `?choose=1` handling (new)
- Step 3: stale session detection + flash warning (enhanced)
- Step 4-5: single membership auto-resume + audit (new)
- Step 6: `last_workspace_id` auto-resume + audit (new)
- Step 7: fallback to chooser (existing)
5. **Middleware tests** — all 7 steps covered
### Phase C: Chooser Page Upgrade (UI changes)
6. **Refactor `ChooseWorkspace` page**:
- Remove "Create workspace" header action
- Add `withCount('tenants')` to query
- Load membership roles keyed by workspace_id
- Expose `getWorkspaceRole()` and `getWorkspaceMemberships()` for Blade
7. **Update `choose-workspace.blade.php`**:
- Add role badge per card
- Add tenant count per card
- Add "Manage workspaces" link (capability-gated)
- Update empty state (spec copy)
- Replace form POST with `wire:click="selectWorkspace({{ $workspace->id }})"`
8. **Add audit logging in `selectWorkspace()`** — emit `workspace.selected` with metadata
9. **Chooser page tests** — metadata display, empty state, manage link visibility, audit events
### Phase D: User Menu Integration
10. **Register "Switch workspace" in `AdminPanelProvider`**`userMenuItems()` with visibility condition
11. **User menu tests** — visible when >1 workspace, hidden when 1
### Phase E: Cleanup & Verification
12. **Replace inline tenant-branching** in `SwitchWorkspaceController` and `routes/web.php` with `WorkspaceRedirectResolver`
13. **Run full test suite** — verify no regressions
14. **Pint formatting**`vendor/bin/sail bin pint --dirty`
15. **Commit + push**
## Key Files to Understand First
| File | Why |
|------|-----|
| `app/Http/Middleware/EnsureWorkspaceSelected.php` | The middleware being refactored |
| `app/Filament/Pages/ChooseWorkspace.php` | The page being upgraded |
| `app/Support/Workspaces/WorkspaceContext.php` | The workspace session manager |
| `app/Services/Audit/WorkspaceAuditLogger.php` | Where audit events are emitted |
| `app/Support/Audit/AuditActionId.php` | Where enum cases are added |
| `app/Http/Controllers/SwitchWorkspaceController.php` | POST switch (redirect resolver integration) |
| `routes/web.php` (lines 36-82) | `/admin` route with duplicated branching |
## Verification Commands
```bash
# Run workspace-related tests
vendor/bin/sail artisan test --compact tests/Feature/Workspaces/
# Run specific middleware test
vendor/bin/sail artisan test --compact --filter=EnsureWorkspaceSelected
# Run chooser page test
vendor/bin/sail artisan test --compact --filter=ChooseWorkspacePage
# Format
vendor/bin/sail bin pint --dirty
# Full suite
vendor/bin/sail artisan test --compact
```

View File

@ -0,0 +1,106 @@
# 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).