From dd4fb6071f93611f33f511e9e84a690894ea13d2 Mon Sep 17 00:00:00 2001 From: Ahmed Darrazi Date: Sun, 22 Feb 2026 14:53:24 +0100 Subject: [PATCH] =?UTF-8?q?plan:=20workspace=20chooser=20v1=20=E2=80=94=20?= =?UTF-8?q?research,=20data-model,=20contracts,=20quickstart?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/agents/copilot-instructions.md | 5 +- .../107-workspace-chooser/contracts/routes.md | 126 ++++++++++++++++ specs/107-workspace-chooser/data-model.md | 142 ++++++++++++++++++ specs/107-workspace-chooser/plan.md | 106 +++++++++++++ specs/107-workspace-chooser/quickstart.md | 87 +++++++++++ specs/107-workspace-chooser/research.md | 106 +++++++++++++ 6 files changed, 570 insertions(+), 2 deletions(-) create mode 100644 specs/107-workspace-chooser/contracts/routes.md create mode 100644 specs/107-workspace-chooser/data-model.md create mode 100644 specs/107-workspace-chooser/plan.md create mode 100644 specs/107-workspace-chooser/quickstart.md create mode 100644 specs/107-workspace-chooser/research.md diff --git a/.github/agents/copilot-instructions.md b/.github/agents/copilot-instructions.md index 5de2d9b..58bb7b6 100644 --- a/.github/agents/copilot-instructions.md +++ b/.github/agents/copilot-instructions.md @@ -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 diff --git a/specs/107-workspace-chooser/contracts/routes.md b/specs/107-workspace-chooser/contracts/routes.md new file mode 100644 index 0000000..87ce94d --- /dev/null +++ b/specs/107-workspace-chooser/contracts/routes.md @@ -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()` diff --git a/specs/107-workspace-chooser/data-model.md b/specs/107-workspace-chooser/data-model.md new file mode 100644 index 0000000..bddad7e --- /dev/null +++ b/specs/107-workspace-chooser/data-model.md @@ -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. diff --git a/specs/107-workspace-chooser/plan.md b/specs/107-workspace-chooser/plan.md new file mode 100644 index 0000000..1d22d51 --- /dev/null +++ b/specs/107-workspace-chooser/plan.md @@ -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 | +|-----------|------------|-------------------------------------| +| — | — | — | diff --git a/specs/107-workspace-chooser/quickstart.md b/specs/107-workspace-chooser/quickstart.md new file mode 100644 index 0000000..765d9b5 --- /dev/null +++ b/specs/107-workspace-chooser/quickstart.md @@ -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 +``` diff --git a/specs/107-workspace-chooser/research.md b/specs/107-workspace-chooser/research.md new file mode 100644 index 0000000..68e4a56 --- /dev/null +++ b/specs/107-workspace-chooser/research.md @@ -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 `` 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).