diff --git a/.agents/skills/pest-testing/SKILL.md b/.agents/skills/pest-testing/SKILL.md
new file mode 100644
index 0000000..5619861
--- /dev/null
+++ b/.agents/skills/pest-testing/SKILL.md
@@ -0,0 +1,167 @@
+---
+name: pest-testing
+description: "Tests applications using the Pest 4 PHP framework. Activates when writing tests, creating unit or feature tests, adding assertions, testing Livewire components, browser testing, debugging test failures, working with datasets or mocking; or when the user mentions test, spec, TDD, expects, assertion, coverage, or needs to verify functionality works."
+license: MIT
+metadata:
+ author: laravel
+---
+
+# Pest Testing 4
+
+## When to Apply
+
+Activate this skill when:
+
+- Creating new tests (unit, feature, or browser)
+- Modifying existing tests
+- Debugging test failures
+- Working with browser testing or smoke testing
+- Writing architecture tests or visual regression tests
+
+## Documentation
+
+Use `search-docs` for detailed Pest 4 patterns and documentation.
+
+## Basic Usage
+
+### Creating Tests
+
+All tests must be written using Pest. Use `php artisan make:test --pest {name}`.
+
+### Test Organization
+
+- Unit/Feature tests: `tests/Feature` and `tests/Unit` directories.
+- Browser tests: `tests/Browser/` directory.
+- Do NOT remove tests without approval - these are core application code.
+
+### Basic Test Structure
+
+
+```php
+it('is true', function () {
+ expect(true)->toBeTrue();
+});
+```
+
+### Running Tests
+
+- Run minimal tests with filter before finalizing: `php artisan test --compact --filter=testName`.
+- Run all tests: `php artisan test --compact`.
+- Run file: `php artisan test --compact tests/Feature/ExampleTest.php`.
+
+## Assertions
+
+Use specific assertions (`assertSuccessful()`, `assertNotFound()`) instead of `assertStatus()`:
+
+
+```php
+it('returns all', function () {
+ $this->postJson('/api/docs', [])->assertSuccessful();
+});
+```
+
+| Use | Instead of |
+|-----|------------|
+| `assertSuccessful()` | `assertStatus(200)` |
+| `assertNotFound()` | `assertStatus(404)` |
+| `assertForbidden()` | `assertStatus(403)` |
+
+## Mocking
+
+Import mock function before use: `use function Pest\Laravel\mock;`
+
+## Datasets
+
+Use datasets for repetitive tests (validation rules, etc.):
+
+
+```php
+it('has emails', function (string $email) {
+ expect($email)->not->toBeEmpty();
+})->with([
+ 'james' => 'james@laravel.com',
+ 'taylor' => 'taylor@laravel.com',
+]);
+```
+
+## Pest 4 Features
+
+| Feature | Purpose |
+|---------|---------|
+| Browser Testing | Full integration tests in real browsers |
+| Smoke Testing | Validate multiple pages quickly |
+| Visual Regression | Compare screenshots for visual changes |
+| Test Sharding | Parallel CI runs |
+| Architecture Testing | Enforce code conventions |
+
+### Browser Test Example
+
+Browser tests run in real browsers for full integration testing:
+
+- Browser tests live in `tests/Browser/`.
+- Use Laravel features like `Event::fake()`, `assertAuthenticated()`, and model factories.
+- Use `RefreshDatabase` for clean state per test.
+- Interact with page: click, type, scroll, select, submit, drag-and-drop, touch gestures.
+- Test on multiple browsers (Chrome, Firefox, Safari) if requested.
+- Test on different devices/viewports (iPhone 14 Pro, tablets) if requested.
+- Switch color schemes (light/dark mode) when appropriate.
+- Take screenshots or pause tests for debugging.
+
+
+```php
+it('may reset the password', function () {
+ Notification::fake();
+
+ $this->actingAs(User::factory()->create());
+
+ $page = visit('/sign-in');
+
+ $page->assertSee('Sign In')
+ ->assertNoJavaScriptErrors()
+ ->click('Forgot Password?')
+ ->fill('email', 'nuno@laravel.com')
+ ->click('Send Reset Link')
+ ->assertSee('We have emailed your password reset link!');
+
+ Notification::assertSent(ResetPassword::class);
+});
+```
+
+### Smoke Testing
+
+Quickly validate multiple pages have no JavaScript errors:
+
+
+```php
+$pages = visit(['/', '/about', '/contact']);
+
+$pages->assertNoJavaScriptErrors()->assertNoConsoleLogs();
+```
+
+### Visual Regression Testing
+
+Capture and compare screenshots to detect visual changes.
+
+### Test Sharding
+
+Split tests across parallel processes for faster CI runs.
+
+### Architecture Testing
+
+Pest 4 includes architecture testing (from Pest 3):
+
+
+```php
+arch('controllers')
+ ->expect('App\Http\Controllers')
+ ->toExtendNothing()
+ ->toHaveSuffix('Controller');
+```
+
+## Common Pitfalls
+
+- Not importing `use function Pest\Laravel\mock;` before using mock
+- Using `assertStatus(200)` instead of `assertSuccessful()`
+- Forgetting datasets for repetitive validation tests
+- Deleting tests without approval
+- Forgetting `assertNoJavaScriptErrors()` in browser tests
\ No newline at end of file
diff --git a/.agents/skills/tailwindcss-development/SKILL.md b/.agents/skills/tailwindcss-development/SKILL.md
new file mode 100644
index 0000000..21a7e46
--- /dev/null
+++ b/.agents/skills/tailwindcss-development/SKILL.md
@@ -0,0 +1,129 @@
+---
+name: tailwindcss-development
+description: "Styles applications using Tailwind CSS v4 utilities. Activates when adding styles, restyling components, working with gradients, spacing, layout, flex, grid, responsive design, dark mode, colors, typography, or borders; or when the user mentions CSS, styling, classes, Tailwind, restyle, hero section, cards, buttons, or any visual/UI changes."
+license: MIT
+metadata:
+ author: laravel
+---
+
+# Tailwind CSS Development
+
+## When to Apply
+
+Activate this skill when:
+
+- Adding styles to components or pages
+- Working with responsive design
+- Implementing dark mode
+- Extracting repeated patterns into components
+- Debugging spacing or layout issues
+
+## Documentation
+
+Use `search-docs` for detailed Tailwind CSS v4 patterns and documentation.
+
+## Basic Usage
+
+- Use Tailwind CSS classes to style HTML. Check and follow existing Tailwind conventions in the project before introducing new patterns.
+- Offer to extract repeated patterns into components that match the project's conventions (e.g., Blade, JSX, Vue).
+- Consider class placement, order, priority, and defaults. Remove redundant classes, add classes to parent or child elements carefully to reduce repetition, and group elements logically.
+
+## Tailwind CSS v4 Specifics
+
+- Always use Tailwind CSS v4 and avoid deprecated utilities.
+- `corePlugins` is not supported in Tailwind v4.
+
+### CSS-First Configuration
+
+In Tailwind v4, configuration is CSS-first using the `@theme` directive — no separate `tailwind.config.js` file is needed:
+
+
+```css
+@theme {
+ --color-brand: oklch(0.72 0.11 178);
+}
+```
+
+### Import Syntax
+
+In Tailwind v4, import Tailwind with a regular CSS `@import` statement instead of the `@tailwind` directives used in v3:
+
+
+```diff
+- @tailwind base;
+- @tailwind components;
+- @tailwind utilities;
++ @import "tailwindcss";
+```
+
+### Replaced Utilities
+
+Tailwind v4 removed deprecated utilities. Use the replacements shown below. Opacity values remain numeric.
+
+| Deprecated | Replacement |
+|------------|-------------|
+| bg-opacity-* | bg-black/* |
+| text-opacity-* | text-black/* |
+| border-opacity-* | border-black/* |
+| divide-opacity-* | divide-black/* |
+| ring-opacity-* | ring-black/* |
+| placeholder-opacity-* | placeholder-black/* |
+| flex-shrink-* | shrink-* |
+| flex-grow-* | grow-* |
+| overflow-ellipsis | text-ellipsis |
+| decoration-slice | box-decoration-slice |
+| decoration-clone | box-decoration-clone |
+
+## Spacing
+
+Use `gap` utilities instead of margins for spacing between siblings:
+
+
+```html
+
+```
+
+## Dark Mode
+
+If existing pages and components support dark mode, new pages and components must support it the same way, typically using the `dark:` variant:
+
+
+```html
+
+ Content adapts to color scheme
+
+```
+
+## Common Patterns
+
+### Flexbox Layout
+
+
+```html
+
+
Left content
+
Right content
+
+```
+
+### Grid Layout
+
+
+```html
+
+
Card 1
+
Card 2
+
Card 3
+
+```
+
+## Common Pitfalls
+
+- Using deprecated v3 utilities (bg-opacity-*, flex-shrink-*, etc.)
+- Using `@tailwind` directives instead of `@import "tailwindcss"`
+- Trying to use `tailwind.config.js` instead of CSS `@theme` directive
+- Using margins for spacing between siblings instead of gap utilities
+- Forgetting to add dark mode variants when the project uses dark mode
\ No newline at end of file
diff --git a/.codex/config.toml b/.codex/config.toml
new file mode 100644
index 0000000..795d550
--- /dev/null
+++ b/.codex/config.toml
@@ -0,0 +1,4 @@
+[mcp_servers.laravel-boost]
+command = "vendor/bin/sail"
+args = ["artisan", "boost:mcp"]
+cwd = "/Users/ahmeddarrazi/Documents/projects/TenantAtlas"
diff --git a/.github/agents/copilot-instructions.md b/.github/agents/copilot-instructions.md
index c83cbd3..62ca367 100644
--- a/.github/agents/copilot-instructions.md
+++ b/.github/agents/copilot-instructions.md
@@ -30,6 +30,7 @@ ## Active Technologies
- PostgreSQL (Sail), SQLite in tests (087-legacy-runs-removal)
- PHP 8.4.x + Laravel 12, Filament v5, Livewire v4, Microsoft Graph integration via `GraphClientInterface` (095-graph-contracts-registry-completeness)
- PHP 8.4.15 (Laravel 12) + Filament v5, Livewire v4, Laravel Queue, Laravel Notifications (100-alert-target-test-actions)
+- PostgreSQL (Sail locally); SQLite is used in some tests (101-golden-master-baseline-governance-v1)
- PHP 8.4.15 (feat/005-bulk-operations)
@@ -49,7 +50,7 @@ ## Code Style
PHP 8.4.15: Follow standard conventions
## Recent Changes
+- 101-golden-master-baseline-governance-v1: Added PHP 8.4.x
- 100-alert-target-test-actions: Added PHP 8.4.15 (Laravel 12) + Filament v5, Livewire v4, Laravel Queue, Laravel Notifications
-- 095-graph-contracts-registry-completeness: Added PHP 8.4.x + Laravel 12, Filament v5, Livewire v4, Microsoft Graph integration via `GraphClientInterface`
diff --git a/.github/skills/pest-testing/SKILL.md b/.github/skills/pest-testing/SKILL.md
new file mode 100644
index 0000000..5619861
--- /dev/null
+++ b/.github/skills/pest-testing/SKILL.md
@@ -0,0 +1,167 @@
+---
+name: pest-testing
+description: "Tests applications using the Pest 4 PHP framework. Activates when writing tests, creating unit or feature tests, adding assertions, testing Livewire components, browser testing, debugging test failures, working with datasets or mocking; or when the user mentions test, spec, TDD, expects, assertion, coverage, or needs to verify functionality works."
+license: MIT
+metadata:
+ author: laravel
+---
+
+# Pest Testing 4
+
+## When to Apply
+
+Activate this skill when:
+
+- Creating new tests (unit, feature, or browser)
+- Modifying existing tests
+- Debugging test failures
+- Working with browser testing or smoke testing
+- Writing architecture tests or visual regression tests
+
+## Documentation
+
+Use `search-docs` for detailed Pest 4 patterns and documentation.
+
+## Basic Usage
+
+### Creating Tests
+
+All tests must be written using Pest. Use `php artisan make:test --pest {name}`.
+
+### Test Organization
+
+- Unit/Feature tests: `tests/Feature` and `tests/Unit` directories.
+- Browser tests: `tests/Browser/` directory.
+- Do NOT remove tests without approval - these are core application code.
+
+### Basic Test Structure
+
+
+```php
+it('is true', function () {
+ expect(true)->toBeTrue();
+});
+```
+
+### Running Tests
+
+- Run minimal tests with filter before finalizing: `php artisan test --compact --filter=testName`.
+- Run all tests: `php artisan test --compact`.
+- Run file: `php artisan test --compact tests/Feature/ExampleTest.php`.
+
+## Assertions
+
+Use specific assertions (`assertSuccessful()`, `assertNotFound()`) instead of `assertStatus()`:
+
+
+```php
+it('returns all', function () {
+ $this->postJson('/api/docs', [])->assertSuccessful();
+});
+```
+
+| Use | Instead of |
+|-----|------------|
+| `assertSuccessful()` | `assertStatus(200)` |
+| `assertNotFound()` | `assertStatus(404)` |
+| `assertForbidden()` | `assertStatus(403)` |
+
+## Mocking
+
+Import mock function before use: `use function Pest\Laravel\mock;`
+
+## Datasets
+
+Use datasets for repetitive tests (validation rules, etc.):
+
+
+```php
+it('has emails', function (string $email) {
+ expect($email)->not->toBeEmpty();
+})->with([
+ 'james' => 'james@laravel.com',
+ 'taylor' => 'taylor@laravel.com',
+]);
+```
+
+## Pest 4 Features
+
+| Feature | Purpose |
+|---------|---------|
+| Browser Testing | Full integration tests in real browsers |
+| Smoke Testing | Validate multiple pages quickly |
+| Visual Regression | Compare screenshots for visual changes |
+| Test Sharding | Parallel CI runs |
+| Architecture Testing | Enforce code conventions |
+
+### Browser Test Example
+
+Browser tests run in real browsers for full integration testing:
+
+- Browser tests live in `tests/Browser/`.
+- Use Laravel features like `Event::fake()`, `assertAuthenticated()`, and model factories.
+- Use `RefreshDatabase` for clean state per test.
+- Interact with page: click, type, scroll, select, submit, drag-and-drop, touch gestures.
+- Test on multiple browsers (Chrome, Firefox, Safari) if requested.
+- Test on different devices/viewports (iPhone 14 Pro, tablets) if requested.
+- Switch color schemes (light/dark mode) when appropriate.
+- Take screenshots or pause tests for debugging.
+
+
+```php
+it('may reset the password', function () {
+ Notification::fake();
+
+ $this->actingAs(User::factory()->create());
+
+ $page = visit('/sign-in');
+
+ $page->assertSee('Sign In')
+ ->assertNoJavaScriptErrors()
+ ->click('Forgot Password?')
+ ->fill('email', 'nuno@laravel.com')
+ ->click('Send Reset Link')
+ ->assertSee('We have emailed your password reset link!');
+
+ Notification::assertSent(ResetPassword::class);
+});
+```
+
+### Smoke Testing
+
+Quickly validate multiple pages have no JavaScript errors:
+
+
+```php
+$pages = visit(['/', '/about', '/contact']);
+
+$pages->assertNoJavaScriptErrors()->assertNoConsoleLogs();
+```
+
+### Visual Regression Testing
+
+Capture and compare screenshots to detect visual changes.
+
+### Test Sharding
+
+Split tests across parallel processes for faster CI runs.
+
+### Architecture Testing
+
+Pest 4 includes architecture testing (from Pest 3):
+
+
+```php
+arch('controllers')
+ ->expect('App\Http\Controllers')
+ ->toExtendNothing()
+ ->toHaveSuffix('Controller');
+```
+
+## Common Pitfalls
+
+- Not importing `use function Pest\Laravel\mock;` before using mock
+- Using `assertStatus(200)` instead of `assertSuccessful()`
+- Forgetting datasets for repetitive validation tests
+- Deleting tests without approval
+- Forgetting `assertNoJavaScriptErrors()` in browser tests
\ No newline at end of file
diff --git a/.github/skills/tailwindcss-development/SKILL.md b/.github/skills/tailwindcss-development/SKILL.md
new file mode 100644
index 0000000..21a7e46
--- /dev/null
+++ b/.github/skills/tailwindcss-development/SKILL.md
@@ -0,0 +1,129 @@
+---
+name: tailwindcss-development
+description: "Styles applications using Tailwind CSS v4 utilities. Activates when adding styles, restyling components, working with gradients, spacing, layout, flex, grid, responsive design, dark mode, colors, typography, or borders; or when the user mentions CSS, styling, classes, Tailwind, restyle, hero section, cards, buttons, or any visual/UI changes."
+license: MIT
+metadata:
+ author: laravel
+---
+
+# Tailwind CSS Development
+
+## When to Apply
+
+Activate this skill when:
+
+- Adding styles to components or pages
+- Working with responsive design
+- Implementing dark mode
+- Extracting repeated patterns into components
+- Debugging spacing or layout issues
+
+## Documentation
+
+Use `search-docs` for detailed Tailwind CSS v4 patterns and documentation.
+
+## Basic Usage
+
+- Use Tailwind CSS classes to style HTML. Check and follow existing Tailwind conventions in the project before introducing new patterns.
+- Offer to extract repeated patterns into components that match the project's conventions (e.g., Blade, JSX, Vue).
+- Consider class placement, order, priority, and defaults. Remove redundant classes, add classes to parent or child elements carefully to reduce repetition, and group elements logically.
+
+## Tailwind CSS v4 Specifics
+
+- Always use Tailwind CSS v4 and avoid deprecated utilities.
+- `corePlugins` is not supported in Tailwind v4.
+
+### CSS-First Configuration
+
+In Tailwind v4, configuration is CSS-first using the `@theme` directive — no separate `tailwind.config.js` file is needed:
+
+
+```css
+@theme {
+ --color-brand: oklch(0.72 0.11 178);
+}
+```
+
+### Import Syntax
+
+In Tailwind v4, import Tailwind with a regular CSS `@import` statement instead of the `@tailwind` directives used in v3:
+
+
+```diff
+- @tailwind base;
+- @tailwind components;
+- @tailwind utilities;
++ @import "tailwindcss";
+```
+
+### Replaced Utilities
+
+Tailwind v4 removed deprecated utilities. Use the replacements shown below. Opacity values remain numeric.
+
+| Deprecated | Replacement |
+|------------|-------------|
+| bg-opacity-* | bg-black/* |
+| text-opacity-* | text-black/* |
+| border-opacity-* | border-black/* |
+| divide-opacity-* | divide-black/* |
+| ring-opacity-* | ring-black/* |
+| placeholder-opacity-* | placeholder-black/* |
+| flex-shrink-* | shrink-* |
+| flex-grow-* | grow-* |
+| overflow-ellipsis | text-ellipsis |
+| decoration-slice | box-decoration-slice |
+| decoration-clone | box-decoration-clone |
+
+## Spacing
+
+Use `gap` utilities instead of margins for spacing between siblings:
+
+
+```html
+
+```
+
+## Dark Mode
+
+If existing pages and components support dark mode, new pages and components must support it the same way, typically using the `dark:` variant:
+
+
+```html
+
+ Content adapts to color scheme
+
+```
+
+## Common Patterns
+
+### Flexbox Layout
+
+
+```html
+
+
Left content
+
Right content
+
+```
+
+### Grid Layout
+
+
+```html
+
+
Card 1
+
Card 2
+
Card 3
+
+```
+
+## Common Pitfalls
+
+- Using deprecated v3 utilities (bg-opacity-*, flex-shrink-*, etc.)
+- Using `@tailwind` directives instead of `@import "tailwindcss"`
+- Trying to use `tailwind.config.js` instead of CSS `@theme` directive
+- Using margins for spacing between siblings instead of gap utilities
+- Forgetting to add dark mode variants when the project uses dark mode
\ No newline at end of file
diff --git a/.specify/memory/constitution.md b/.specify/memory/constitution.md
index e17ec6f..3b66104 100644
--- a/.specify/memory/constitution.md
+++ b/.specify/memory/constitution.md
@@ -1,22 +1,19 @@
# TenantPilot Constitution
@@ -179,7 +176,7 @@ ### Filament UI — Action Surface Contract (NON-NEGOTIABLE)
- Accepted forms: clickable rows via `recordUrl()` (preferred), a dedicated row “View” action, or a primary linked column.
- Rule: Do NOT render a lone “View” row action button. If View is the only row action, prefer clickable rows.
- View/Detail MUST define Header Actions (Edit + “More” group when applicable).
-- View/Detail SHOULD be sectioned (e.g., Infolist Sections / Cards); avoid long ungrouped field lists.
+- View/Detail MUST be sectioned (e.g., Infolist Sections / Cards); avoid long ungrouped field lists.
- Create/Edit MUST provide consistent Save/Cancel UX.
Grouping & safety
@@ -199,6 +196,38 @@ ### Filament UI — Action Surface Contract (NON-NEGOTIABLE)
- A change is not “Done” unless the Action Surface Contract is met OR an explicit exemption exists with documented reason.
- CI MUST run an automated Action Surface Contract check (test suite and/or command) that fails when required surfaces are missing.
+### Filament UI — Layout & Information Architecture Standards (UX-001)
+
+Goal: Demo-level, enterprise-grade admin UX. These rules are NON-NEGOTIABLE for new or modified Filament screens.
+
+Page layout
+- Create/Edit MUST default to a Main/Aside layout using a 3-column grid with `Main=columnSpan(2)` and `Aside=columnSpan(1)`.
+- All fields MUST be inside Sections/Cards. No “naked” inputs at the root schema level.
+- Main contains domain definition/content. Aside contains status/meta (status, version label, owner, scope selectors, timestamps).
+- Related data (assignments, relations, evidence, runs, findings, etc.) MUST render as separate Sections below the main/aside grid (or as tabs/sub-navigation), never mixed as unstructured long lists.
+
+View pages
+- View/Detail MUST be a read-only experience using Infolists (or equivalent), not disabled edit forms.
+- Status-like values MUST render as badges/chips using the centralized badge semantics (BADGE-001).
+- Long text MUST render as readable prose (not textarea styling).
+
+Empty states
+- Empty lists/tables MUST show: a specific title, one-sentence explanation, and exactly ONE primary CTA in the empty state.
+- When non-empty, the primary CTA MUST move to the table header (top-right) and MUST NOT be duplicated in the empty state.
+
+Actions & flows
+- Pages SHOULD expose at most 1 primary header action and 1 secondary action; all other actions MUST be grouped (ActionGroup / BulkActionGroup).
+- Multi-step or high-risk flows MUST use a Wizard (e.g., capture/compare/restore with preview + confirmation).
+- Destructive actions MUST remain non-primary and require confirmation (RBAC-UX-005).
+
+Table work-surface defaults
+- Tables SHOULD provide search (when the dataset can grow), a meaningful default sort, and filters for core dimensions (status/severity/type/tenant/time-range).
+- Tables MUST render key statuses as badges/chips (BADGE-001) and avoid ad-hoc status mappings.
+
+Enforcement
+- New resources/pages SHOULD use shared layout builders (e.g., `MainAsideForm`, `MainAsideInfolist`, `StandardTableDefaults`) to keep screens consistent.
+- A change is not “Done” unless UX-001 is satisfied OR an explicit exemption exists with a documented rationale in the spec/PR.
+
Spec Scope Fields (SCOPE-002)
- Every feature spec MUST declare:
@@ -245,4 +274,4 @@ ### Versioning Policy (SemVer)
- **MINOR**: new principle/section or materially expanded guidance.
- **MAJOR**: removing/redefining principles in a backward-incompatible way.
-**Version**: 1.8.2 | **Ratified**: 2026-01-03 | **Last Amended**: 2026-02-14
+**Version**: 1.9.0 | **Ratified**: 2026-01-03 | **Last Amended**: 2026-02-19
diff --git a/.specify/templates/plan-template.md b/.specify/templates/plan-template.md
index a0dd948..17afc7a 100644
--- a/.specify/templates/plan-template.md
+++ b/.specify/templates/plan-template.md
@@ -44,8 +44,7 @@ ## Constitution Check
- Automation: queued/scheduled ops use locks + idempotency; handle 429/503 with backoff+jitter
- Data minimization: Inventory stores metadata + whitelisted meta; logs contain no secrets/tokens
- Badge semantics (BADGE-001): status-like badges use `BadgeCatalog` / `BadgeRenderer`; no ad-hoc mappings; new values include tests
-- Filament UI Action Surface Contract: for any new/modified Filament Resource/RelationManager/Page, define Header/Row/Bulk/Empty-State actions, ensure every List/Table has a record inspection affordance (prefer `recordUrl()` clickable rows; do not render a lone View row action), keep max 2 visible row actions with the rest in “More”, group bulk actions, require confirmations for destructive actions (typed confirmation for large/bulk where applicable), write audit logs for mutations, enforce RBAC via central helpers (non-member 404, member missing capability 403), and ensure CI blocks merges if the contract is violated or not explicitly exempted
-
+- Filament UI Action Surface Contract: for any new/modified Filament Resource/RelationManager/Page, define Header/Row/Bulk/Empty-State actions, ensure every List/Table has a record inspection affordance (prefer `recordUrl()` clickable rows; do not render a lone View row action), keep max 2 visible row actions with the rest in “More”, group bulk actions, require confirmations for destructive actions (typed confirmation for large/bulk where applicable), write audit logs for mutations, enforce RBAC via central helpers (non-member 404, member missing capability 403), and ensure CI blocks merges if the contract is violated or not explicitly exempted- Filament UI UX-001 (Layout & IA): Create/Edit uses Main/Aside (3-col grid, Main=columnSpan(2), Aside=columnSpan(1)); all fields inside Sections/Cards (no naked inputs); View uses Infolists (not disabled edit forms); status badges use BADGE-001; empty states have specific title + explanation + 1 CTA; max 1 primary + 1 secondary header action; tables provide search/sort/filters for core dimensions; shared layout builders preferred for consistency
## Project Structure
### Documentation (this feature)
diff --git a/.specify/templates/spec-template.md b/.specify/templates/spec-template.md
index 2fe1fe8..ce057d6 100644
--- a/.specify/templates/spec-template.md
+++ b/.specify/templates/spec-template.md
@@ -115,7 +115,11 @@ ## Requirements *(mandatory)*
**Constitution alignment (Filament Action Surfaces):** If this feature adds or modifies any Filament Resource / RelationManager / Page,
the spec MUST include a “UI Action Matrix” (see below) and explicitly state whether the Action Surface Contract is satisfied.
If the contract is not satisfied, the spec MUST include an explicit exemption with rationale.
-
+**Constitution alignment (UX-001 — Layout & Information Architecture):** If this feature adds or modifies any Filament screen,
+the spec MUST describe compliance with UX-001: Create/Edit uses Main/Aside layout (3-col grid), all fields inside Sections/Cards
+(no naked inputs), View pages use Infolists (not disabled edit forms), status badges use BADGE-001, empty states have a specific
+title + explanation + exactly 1 CTA, and tables provide search/sort/filters for core dimensions.
+If UX-001 is not fully satisfied, the spec MUST include an explicit exemption with documented rationale.
+```php
protected function isAccessible(User $user, ?string $path = null): bool
{
...
}
-
-
-## Comments
-- Prefer PHPDoc blocks over inline comments. Never use comments within the code itself unless there is something very complex going on.
-
-## PHPDoc Blocks
-- Add useful array shape type definitions for arrays when appropriate.
+```
## Enums
+
- Typically, keys in an Enum should be TitleCase. For example: `FavoritePerson`, `BestLake`, `Monthly`.
+## Comments
+
+- Prefer PHPDoc blocks over inline comments. Never use comments within the code itself unless the logic is exceptionally complex.
+
+## PHPDoc Blocks
+
+- Add useful array shape type definitions when appropriate.
+
=== sail rules ===
-## Laravel Sail
+# Laravel Sail
- This project runs inside Laravel Sail's Docker containers. You MUST execute all commands through Sail.
- Start services using `vendor/bin/sail up -d` and stop them with `vendor/bin/sail stop`.
@@ -770,20 +821,21 @@ ## Laravel Sail
=== tests rules ===
-## Test Enforcement
+# Test Enforcement
- Every change must be programmatically tested. Write a new test or update an existing test, then run the affected tests to make sure they pass.
- Run the minimum number of tests needed to ensure code quality and speed. Use `vendor/bin/sail artisan test --compact` with a specific filename or filter.
=== laravel/core rules ===
-## Do Things the Laravel Way
+# Do Things the Laravel Way
- Use `vendor/bin/sail artisan make:` commands to create new files (i.e. migrations, controllers, models, etc.). You can list available Artisan commands using the `list-artisan-commands` tool.
- If you're creating a generic PHP class, use `vendor/bin/sail artisan make:class`.
- Pass `--no-interaction` to all Artisan commands to ensure they work without user input. You should also pass the correct `--options` to ensure correct behavior.
-### Database
+## Database
+
- Always use proper Eloquent relationship methods with return type hints. Prefer relationship methods over raw queries or manual joins.
- Use Eloquent models and relationships before suggesting raw database queries.
- Avoid `DB::`; prefer `Model::query()`. Generate code that leverages Laravel's ORM capabilities rather than bypassing them.
@@ -791,43 +843,53 @@ ### Database
- Use Laravel's query builder for very complex database operations.
### Model Creation
+
- When creating new models, create useful factories and seeders for them too. Ask the user if they need any other things, using `list-artisan-commands` to check the available options to `vendor/bin/sail artisan make:model`.
### APIs & Eloquent Resources
+
- For APIs, default to using Eloquent API Resources and API versioning unless existing API routes do not, then you should follow existing application convention.
-### Controllers & Validation
+## Controllers & Validation
+
- Always create Form Request classes for validation rather than inline validation in controllers. Include both validation rules and custom error messages.
- Check sibling Form Requests to see if the application uses array or string based validation rules.
-### Queues
-- Use queued jobs for time-consuming operations with the `ShouldQueue` interface.
+## Authentication & Authorization
-### Authentication & Authorization
- Use Laravel's built-in authentication and authorization features (gates, policies, Sanctum, etc.).
-### URL Generation
+## URL Generation
+
- When generating links to other pages, prefer named routes and the `route()` function.
-### Configuration
+## Queues
+
+- Use queued jobs for time-consuming operations with the `ShouldQueue` interface.
+
+## Configuration
+
- Use environment variables only in configuration files - never use the `env()` function directly outside of config files. Always use `config('app.name')`, not `env('APP_NAME')`.
-### Testing
+## Testing
+
- When creating models for tests, use the factories for the models. Check if the factory has custom states that can be used before manually setting up the model.
- Faker: Use methods such as `$this->faker->word()` or `fake()->randomDigit()`. Follow existing conventions whether to use `$this->faker` or `fake()`.
- When creating tests, make use of `vendor/bin/sail artisan make:test [options] {name}` to create a feature test, and pass `--unit` to create a unit test. Most tests should be feature tests.
-### Vite Error
+## Vite Error
+
- If you receive an "Illuminate\Foundation\ViteException: Unable to locate file in Vite manifest" error, you can run `vendor/bin/sail npm run build` or ask the user to run `vendor/bin/sail npm run dev` or `vendor/bin/sail composer run dev`.
=== laravel/v12 rules ===
-## Laravel 12
+# Laravel 12
-- Use the `search-docs` tool to get version-specific documentation.
+- CRITICAL: ALWAYS use `search-docs` tool for version-specific Laravel documentation and updated code examples.
- Since Laravel 11, Laravel has a new streamlined file structure which this project uses.
-### Laravel 12 Structure
+## Laravel 12 Structure
+
- In Laravel 12, middleware are no longer registered in `app/Http/Kernel.php`.
- Middleware are configured declaratively in `bootstrap/app.php` using `Application::configure()->withMiddleware()`.
- `bootstrap/app.php` is the file to register middleware, exceptions, and routing files.
@@ -835,224 +897,39 @@ ### Laravel 12 Structure
- The `app\Console\Kernel.php` file no longer exists; use `bootstrap/app.php` or `routes/console.php` for console configuration.
- Console commands in `app/Console/Commands/` are automatically available and do not require manual registration.
-### Database
+## Database
+
- When modifying a column, the migration must include all of the attributes that were previously defined on the column. Otherwise, they will be dropped and lost.
- Laravel 12 allows limiting eagerly loaded records natively, without external packages: `$query->latest()->limit(10);`.
### Models
+
- Casts can and likely should be set in a `casts()` method on a model rather than the `$casts` property. Follow existing conventions from other models.
-=== livewire/core rules ===
-
-## Livewire
-
-- Use the `search-docs` tool to find exact version-specific documentation for how to write Livewire and Livewire tests.
-- Use the `vendor/bin/sail artisan make:livewire [Posts\CreatePost]` Artisan command to create new components.
-- State should live on the server, with the UI reflecting it.
-- All Livewire requests hit the Laravel backend; they're like regular HTTP requests. Always validate form data and run authorization checks in Livewire actions.
-
-## Livewire Best Practices
-- Livewire components require a single root element.
-- Use `wire:loading` and `wire:dirty` for delightful loading states.
-- Add `wire:key` in loops:
-
- ```blade
- @foreach ($items as $item)
-
- {{ $item->name }}
-
- @endforeach
- ```
-
-- Prefer lifecycle hooks like `mount()`, `updatedFoo()` for initialization and reactive side effects:
-
-
- public function mount(User $user) { $this->user = $user; }
- public function updatedSearch() { $this->resetPage(); }
-
-
-## Testing Livewire
-
-
- Livewire::test(Counter::class)
- ->assertSet('count', 0)
- ->call('increment')
- ->assertSet('count', 1)
- ->assertSee(1)
- ->assertStatus(200);
-
-
-
- $this->get('/posts/create')
- ->assertSeeLivewire(CreatePost::class);
-
-
=== pint/core rules ===
-## Laravel Pint Code Formatter
+# Laravel Pint Code Formatter
-- You must run `vendor/bin/sail bin pint --dirty` before finalizing changes to ensure your code matches the project's expected style.
-- Do not run `vendor/bin/sail bin pint --test`, simply run `vendor/bin/sail bin pint` to fix any formatting issues.
+- You must run `vendor/bin/sail bin pint --dirty --format agent` before finalizing changes to ensure your code matches the project's expected style.
+- Do not run `vendor/bin/sail bin pint --test --format agent`, simply run `vendor/bin/sail bin pint --format agent` to fix any formatting issues.
=== pest/core rules ===
## Pest
-### Testing
-- If you need to verify a feature is working, write or update a Unit / Feature test.
-### Pest Tests
-- All tests must be written using Pest. Use `vendor/bin/sail artisan make:test --pest {name}`.
-- You must not remove any tests or test files from the tests directory without approval. These are not temporary or helper files - these are core to the application.
-- Tests should test all of the happy paths, failure paths, and weird paths.
-- Tests live in the `tests/Feature` and `tests/Unit` directories.
-- Pest tests look and behave like this:
-
-it('is true', function () {
- expect(true)->toBeTrue();
-});
-
-
-### Running Tests
-- Run the minimal number of tests using an appropriate filter before finalizing code edits.
-- To run all tests: `vendor/bin/sail artisan test --compact`.
-- To run all tests in a file: `vendor/bin/sail artisan test --compact tests/Feature/ExampleTest.php`.
-- To filter on a particular test name: `vendor/bin/sail artisan test --compact --filter=testName` (recommended after making a change to a related file).
-- When the tests relating to your changes are passing, ask the user if they would like to run the entire test suite to ensure everything is still passing.
-
-### Pest Assertions
-- When asserting status codes on a response, use the specific method like `assertForbidden` and `assertNotFound` instead of using `assertStatus(403)` or similar, e.g.:
-
-it('returns all', function () {
- $response = $this->postJson('/api/docs', []);
-
- $response->assertSuccessful();
-});
-
-
-### Mocking
-- Mocking can be very helpful when appropriate.
-- When mocking, you can use the `Pest\Laravel\mock` Pest function, but always import it via `use function Pest\Laravel\mock;` before using it. Alternatively, you can use `$this->mock()` if existing tests do.
-- You can also create partial mocks using the same import or self method.
-
-### Datasets
-- Use datasets in Pest to simplify tests that have a lot of duplicated data. This is often the case when testing validation rules, so consider this solution when writing tests for validation rules.
-
-
-it('has emails', function (string $email) {
- expect($email)->not->toBeEmpty();
-})->with([
- 'james' => 'james@laravel.com',
- 'taylor' => 'taylor@laravel.com',
-]);
-
-
-=== pest/v4 rules ===
-
-## Pest 4
-
-- Pest 4 is a huge upgrade to Pest and offers: browser testing, smoke testing, visual regression testing, test sharding, and faster type coverage.
-- Browser testing is incredibly powerful and useful for this project.
-- Browser tests should live in `tests/Browser/`.
-- Use the `search-docs` tool for detailed guidance on utilizing these features.
-
-### Browser Testing
-- You can use Laravel features like `Event::fake()`, `assertAuthenticated()`, and model factories within Pest 4 browser tests, as well as `RefreshDatabase` (when needed) to ensure a clean state for each test.
-- Interact with the page (click, type, scroll, select, submit, drag-and-drop, touch gestures, etc.) when appropriate to complete the test.
-- If requested, test on multiple browsers (Chrome, Firefox, Safari).
-- If requested, test on different devices and viewports (like iPhone 14 Pro, tablets, or custom breakpoints).
-- Switch color schemes (light/dark mode) when appropriate.
-- Take screenshots or pause tests for debugging when appropriate.
-
-### Example Tests
-
-
-it('may reset the password', function () {
- Notification::fake();
-
- $this->actingAs(User::factory()->create());
-
- $page = visit('/sign-in'); // Visit on a real browser...
-
- $page->assertSee('Sign In')
- ->assertNoJavascriptErrors() // or ->assertNoConsoleLogs()
- ->click('Forgot Password?')
- ->fill('email', 'nuno@laravel.com')
- ->click('Send Reset Link')
- ->assertSee('We have emailed your password reset link!')
-
- Notification::assertSent(ResetPassword::class);
-});
-
-
-
-$pages = visit(['/', '/about', '/contact']);
-
-$pages->assertNoJavascriptErrors()->assertNoConsoleLogs();
-
+- This project uses Pest for testing. Create tests: `vendor/bin/sail artisan make:test --pest {name}`.
+- Run tests: `vendor/bin/sail artisan test --compact` or filter: `vendor/bin/sail artisan test --compact --filter=testName`.
+- Do NOT delete tests without approval.
+- CRITICAL: ALWAYS use `search-docs` tool for version-specific Pest documentation and updated code examples.
+- IMPORTANT: Activate `pest-testing` every time you're working with a Pest or testing-related task.
=== tailwindcss/core rules ===
-## Tailwind CSS
+# Tailwind CSS
-- Use Tailwind CSS classes to style HTML; check and use existing Tailwind conventions within the project before writing your own.
-- Offer to extract repeated patterns into components that match the project's conventions (i.e. Blade, JSX, Vue, etc.).
-- Think through class placement, order, priority, and defaults. Remove redundant classes, add classes to parent or child carefully to limit repetition, and group elements logically.
-- You can use the `search-docs` tool to get exact examples from the official documentation when needed.
-
-### Spacing
-- When listing items, use gap utilities for spacing; don't use margins.
-
-
-
-
Superior
-
Michigan
-
Erie
-
-
-
-### Dark Mode
-- If existing pages and components support dark mode, new pages and components must support dark mode in a similar way, typically using `dark:`.
-
-=== tailwindcss/v4 rules ===
-
-## Tailwind CSS 4
-
-- Always use Tailwind CSS v4; do not use the deprecated utilities.
-- `corePlugins` is not supported in Tailwind v4.
-- In Tailwind v4, configuration is CSS-first using the `@theme` directive — no separate `tailwind.config.js` file is needed.
-
-
-@theme {
- --color-brand: oklch(0.72 0.11 178);
-}
-
-
-- In Tailwind v4, you import Tailwind using a regular CSS `@import` statement, not using the `@tailwind` directives used in v3:
-
-
- - @tailwind base;
- - @tailwind components;
- - @tailwind utilities;
- + @import "tailwindcss";
-
-
-### Replaced Utilities
-- Tailwind v4 removed deprecated utilities. Do not use the deprecated option; use the replacement.
-- Opacity values are still numeric.
-
-| Deprecated | Replacement |
-|------------+--------------|
-| bg-opacity-* | bg-black/* |
-| text-opacity-* | text-black/* |
-| border-opacity-* | border-black/* |
-| divide-opacity-* | divide-black/* |
-| ring-opacity-* | ring-black/* |
-| placeholder-opacity-* | placeholder-black/* |
-| flex-shrink-* | shrink-* |
-| flex-grow-* | grow-* |
-| overflow-ellipsis | text-ellipsis |
-| decoration-slice | box-decoration-slice |
-| decoration-clone | box-decoration-clone |
+- Always use existing Tailwind conventions; check project patterns before adding new ones.
+- IMPORTANT: Always use `search-docs` tool for version-specific Tailwind CSS documentation and updated code examples. Never rely on training data.
+- IMPORTANT: Activate `tailwindcss-development` every time you're working with a Tailwind CSS or styling-related task.
## Active Technologies
diff --git a/GEMINI.md b/GEMINI.md
index 1ed6555..f03eea5 100644
--- a/GEMINI.md
+++ b/GEMINI.md
@@ -229,6 +229,7 @@ ## Reference Materials
=== .ai/filament-v5-blueprint rules ===
## Source of Truth
+
If any Filament behavior is uncertain, lookup the exact section in:
- docs/research/filament-v5-notes.md
and prefer that over guesses.
@@ -238,6 +239,7 @@ # SECTION B — FILAMENT V5 BLUEPRINT (EXECUTABLE RULES)
# Filament Blueprint (v5)
## 1) Non-negotiables
+
- Filament v5 requires Livewire v4.0+.
- Laravel 11+: register panel providers in `bootstrap/providers.php` (never `bootstrap/app.php`).
- Global search hard rule: If a Resource should appear in Global Search, it must have an Edit or View page; otherwise it will return no results.
@@ -253,6 +255,7 @@ ## 1) Non-negotiables
- https://filamentphp.com/docs/5.x/styling/css-hooks
## 2) Directory & naming conventions
+
- Default to Filament discovery conventions for Resources/Pages/Widgets unless you adopt modular architecture.
- Clusters: directory layout is recommended, not mandatory; functional behavior depends on `$cluster`.
@@ -261,6 +264,7 @@ ## 2) Directory & naming conventions
- https://filamentphp.com/docs/5.x/advanced/modular-architecture
## 3) Panel setup defaults
+
- Default to a single `/admin` panel unless multiple audiences/configs demand multiple panels.
- Verify provider registration (Laravel 11+: `bootstrap/providers.php`) when adding a panel.
- Use `path()` carefully; treat `path('')` as a high-risk change requiring route conflict review.
@@ -274,6 +278,7 @@ ## 3) Panel setup defaults
- https://filamentphp.com/docs/5.x/advanced/assets
## 4) Navigation & information architecture
+
- Use nav groups + sort order intentionally; apply conditional visibility for clarity, but enforce authorization separately.
- Use clusters to introduce hierarchy and sub-navigation when sidebar complexity grows.
- Treat cluster code structure as a recommendation (organizational benefit), not a required rule.
@@ -287,6 +292,7 @@ ## 4) Navigation & information architecture
- https://filamentphp.com/docs/5.x/navigation/user-menu
## 5) Resource patterns
+
- Default to Resources for CRUD; use custom pages for non-CRUD tools/workflows.
- Global search:
- If a resource is intended for global search: ensure Edit/View page exists.
@@ -299,6 +305,7 @@ ## 5) Resource patterns
- https://filamentphp.com/docs/5.x/resources/global-search
## 6) Page lifecycle & query rules
+
- Treat relationship-backed rendering in aggregate contexts (global search details, list summaries) as requiring eager loading.
- Prefer render hooks for layout injection; avoid publishing internal views.
@@ -307,6 +314,7 @@ ## 6) Page lifecycle & query rules
- https://filamentphp.com/docs/5.x/advanced/render-hooks
## 7) Infolists vs RelationManagers (decision tree)
+
- Interactive CRUD / attach / detach under owner record → RelationManager.
- Pick existing related record(s) inside owner form → Select / CheckboxList relationship fields.
- Inline CRUD inside owner form → Repeater.
@@ -317,6 +325,7 @@ ## 7) Infolists vs RelationManagers (decision tree)
- https://filamentphp.com/docs/5.x/infolists/overview
## 8) Form patterns (validation, reactivity, state)
+
- Default: minimize server-driven reactivity; only use it when schema/visibility/requirements must change server-side.
- Prefer “on blur” semantics for chatty inputs when using reactive behavior (per docs patterns).
- Custom field views must obey state binding modifiers.
@@ -326,6 +335,7 @@ ## 8) Form patterns (validation, reactivity, state)
- https://filamentphp.com/docs/5.x/forms/custom-fields
## 9) Table & action patterns
+
- Tables: always define a meaningful empty state (and empty-state actions where appropriate).
- Actions:
- Execution actions use `->action(...)`.
@@ -338,6 +348,7 @@ ## 9) Table & action patterns
- https://filamentphp.com/docs/5.x/actions/modals
## 10) Authorization & security
+
- Enforce panel access in non-local environments as documented.
- UI visibility is not security; enforce policies/access checks in addition to hiding UI.
- Bulk operations: explicitly decide between “Any” policy methods vs per-record authorization.
@@ -347,6 +358,7 @@ ## 10) Authorization & security
- https://filamentphp.com/docs/5.x/resources/deleting-records
## 11) Notifications & UX feedback
+
- Default to explicit success/error notifications for user-triggered mutations that aren’t instantly obvious.
- Treat polling as a cost; set intervals intentionally where polling is used.
@@ -355,6 +367,7 @@ ## 11) Notifications & UX feedback
- https://filamentphp.com/docs/5.x/widgets/stats-overview
## 12) Performance defaults
+
- Heavy assets: prefer on-demand loading (`loadedOnRequest()` + `x-load-css` / `x-load-js`) for heavy dependencies.
- Styling overrides use CSS hook classes; layout injection uses render hooks; avoid view publishing.
@@ -364,6 +377,7 @@ ## 12) Performance defaults
- https://filamentphp.com/docs/5.x/advanced/render-hooks
## 13) Testing requirements
+
- Test pages/relation managers/widgets as Livewire components.
- Test actions using Filament’s action testing guidance.
- Do not mount non-Livewire classes in Livewire tests.
@@ -373,6 +387,7 @@ ## 13) Testing requirements
- https://filamentphp.com/docs/5.x/testing/testing-actions
## 14) Forbidden patterns
+
- Mixing Filament v3/v4 APIs into v5 code.
- Any mention of Livewire v3 for Filament v5.
- Registering panel providers in `bootstrap/app.php` on Laravel 11+.
@@ -387,6 +402,7 @@ ## 14) Forbidden patterns
- https://filamentphp.com/docs/5.x/advanced/assets
## 15) Agent output contract
+
For any implementation request, the agent must explicitly state:
1) Livewire v4.0+ compliance.
2) Provider registration location (Laravel 11+: `bootstrap/providers.php`).
@@ -407,6 +423,7 @@ ## 15) Agent output contract
# SECTION C — AI REVIEW CHECKLIST (STRICT CHECKBOXES)
## Version Safety
+
- [ ] Filament v5 explicitly targets Livewire v4.0+ (no Livewire v3 references anywhere).
- Source: https://filamentphp.com/docs/5.x/upgrade-guide — “Upgrading Livewire”
- [ ] All references are Filament `/docs/5.x/` only (no v3/v4 docs, no legacy APIs).
@@ -414,6 +431,7 @@ ## Version Safety
- Source: https://filamentphp.com/docs/5.x/upgrade-guide — “New requirements”
## Panel & Navigation
+
- [ ] Laravel 11+: panel providers are registered in `bootstrap/providers.php` (not `bootstrap/app.php`).
- Source: https://filamentphp.com/docs/5.x/panel-configuration — “Creating a new panel”
- [ ] Panel `path()` choices are intentional and do not conflict with existing routes (especially `path('')`).
@@ -428,6 +446,7 @@ ## Panel & Navigation
- Source: https://filamentphp.com/docs/5.x/navigation/user-menu — “Introduction”
## Resource Structure
+
- [ ] `$recordTitleAttribute` is set for any resource intended for global search.
- Source: https://filamentphp.com/docs/5.x/resources/overview — “Record titles”
- [ ] Hard rule enforced: every globally searchable resource has an Edit or View page; otherwise global search is disabled for it.
@@ -436,18 +455,21 @@ ## Resource Structure
- Source: https://filamentphp.com/docs/5.x/resources/global-search — “Adding extra details to global search results”
## Infolists & Relations
+
- [ ] Each relationship uses the correct tool (RelationManager vs Select/CheckboxList vs Repeater) based on required interaction.
- Source: https://filamentphp.com/docs/5.x/resources/managing-relationships — “Choosing the right tool for the job”
- [ ] RelationManagers remain lazy-loaded by default unless there’s an explicit UX justification.
- Source: https://filamentphp.com/docs/5.x/resources/managing-relationships — “Disabling lazy loading”
## Forms
+
- [ ] Server-driven reactivity is minimal; chatty inputs do not trigger network requests unnecessarily.
- Source: https://filamentphp.com/docs/5.x/forms/overview — “Reactive fields on blur”
- [ ] Custom field views obey state binding modifiers (no hardcoded `wire:model` without modifiers).
- Source: https://filamentphp.com/docs/5.x/forms/custom-fields — “Obeying state binding modifiers”
## Tables & Actions
+
- [ ] Tables define a meaningful empty state (and empty-state actions where appropriate).
- Source: https://filamentphp.com/docs/5.x/tables/empty-state — “Adding empty state actions”
- [ ] All destructive actions execute via `->action(...)` and include `->requiresConfirmation()`.
@@ -456,6 +478,7 @@ ## Tables & Actions
- Source: https://filamentphp.com/docs/5.x/actions/modals — “Confirmation modals”
## Authorization & Security
+
- [ ] Panel access is enforced for non-local environments as documented.
- Source: https://filamentphp.com/docs/5.x/users/overview — “Authorizing access to the panel”
- [ ] UI visibility is not treated as authorization; policies/access checks still enforce boundaries.
@@ -463,24 +486,28 @@ ## Authorization & Security
- Source: https://filamentphp.com/docs/5.x/resources/deleting-records — “Authorization”
## UX & Notifications
+
- [ ] User-triggered mutations provide explicit success/error notifications when outcomes aren’t instantly obvious.
- Source: https://filamentphp.com/docs/5.x/notifications/overview — “Introduction”
- [ ] Polling (widgets/notifications) is configured intentionally (interval set or disabled) to control load.
- Source: https://filamentphp.com/docs/5.x/widgets/stats-overview — “Live updating stats (polling)”
## Performance
+
- [ ] Heavy frontend assets are loaded on-demand using `loadedOnRequest()` + `x-load-css` / `x-load-js` where appropriate.
- Source: https://filamentphp.com/docs/5.x/advanced/assets — “Lazy loading CSS” / “Lazy loading JavaScript”
- [ ] Styling overrides use CSS hook classes discovered via DevTools (no brittle selectors by default).
- Source: https://filamentphp.com/docs/5.x/styling/css-hooks — “Discovering hook classes”
## Testing
+
- [ ] Livewire tests mount Filament pages/relation managers/widgets (Livewire components), not static resource classes.
- Source: https://filamentphp.com/docs/5.x/testing/overview — “What is a Livewire component when using Filament?”
- [ ] Actions that mutate data are covered using Filament’s action testing guidance.
- Source: https://filamentphp.com/docs/5.x/testing/testing-actions — “Testing actions”
## Deployment / Ops
+
- [ ] `php artisan filament:assets` is included in the deployment process when using registered assets.
- Source: https://filamentphp.com/docs/5.x/advanced/assets — “The FilamentAsset facade”
@@ -488,12 +515,13 @@ ## Deployment / Ops
# Laravel Boost Guidelines
-The Laravel Boost guidelines are specifically curated by Laravel maintainers for this application. These guidelines should be followed closely to enhance the user's satisfaction building Laravel applications.
+The Laravel Boost guidelines are specifically curated by Laravel maintainers for this application. These guidelines should be followed closely to ensure the best experience when building Laravel applications.
## Foundational Context
+
This application is a Laravel application and its main Laravel ecosystems package & versions are below. You are an expert with them all. Ensure you abide by these specific packages & versions.
-- php - 8.4.15
+- php - 8.4.1
- filament/filament (FILAMENT) - v5
- laravel/framework (LARAVEL) - v12
- laravel/prompts (PROMPTS) - v0
@@ -506,56 +534,73 @@ ## Foundational Context
- phpunit/phpunit (PHPUNIT) - v12
- tailwindcss (TAILWINDCSS) - v4
+## Skills Activation
+
+This project has domain-specific skills available. You MUST activate the relevant skill whenever you work in that domain—don't wait until you're stuck.
+
+- `pest-testing` — Tests applications using the Pest 4 PHP framework. Activates when writing tests, creating unit or feature tests, adding assertions, testing Livewire components, browser testing, debugging test failures, working with datasets or mocking; or when the user mentions test, spec, TDD, expects, assertion, coverage, or needs to verify functionality works.
+- `tailwindcss-development` — Styles applications using Tailwind CSS v4 utilities. Activates when adding styles, restyling components, working with gradients, spacing, layout, flex, grid, responsive design, dark mode, colors, typography, or borders; or when the user mentions CSS, styling, classes, Tailwind, restyle, hero section, cards, buttons, or any visual/UI changes.
+
## Conventions
+
- You must follow all existing code conventions used in this application. When creating or editing a file, check sibling files for the correct structure, approach, and naming.
- Use descriptive names for variables and methods. For example, `isRegisteredForDiscounts`, not `discount()`.
- Check for existing components to reuse before writing a new one.
## Verification Scripts
-- Do not create verification scripts or tinker when tests cover that functionality and prove it works. Unit and feature tests are more important.
+
+- Do not create verification scripts or tinker when tests cover that functionality and prove they work. Unit and feature tests are more important.
## Application Structure & Architecture
+
- Stick to existing directory structure; don't create new base folders without approval.
- Do not change the application's dependencies without approval.
## Frontend Bundling
+
- If the user doesn't see a frontend change reflected in the UI, it could mean they need to run `vendor/bin/sail npm run build`, `vendor/bin/sail npm run dev`, or `vendor/bin/sail composer run dev`. Ask them.
-## Replies
-- Be concise in your explanations - focus on what's important rather than explaining obvious details.
-
## Documentation Files
+
- You must only create documentation files if explicitly requested by the user.
+## Replies
+
+- Be concise in your explanations - focus on what's important rather than explaining obvious details.
+
=== boost rules ===
-## Laravel Boost
+# Laravel Boost
+
- Laravel Boost is an MCP server that comes with powerful tools designed specifically for this application. Use them.
## Artisan
+
- Use the `list-artisan-commands` tool when you need to call an Artisan command to double-check the available parameters.
## URLs
+
- Whenever you share a project URL with the user, you should use the `get-absolute-url` tool to ensure you're using the correct scheme, domain/IP, and port.
## Tinker / Debugging
+
- You should use the `tinker` tool when you need to execute PHP to debug code or query Eloquent models directly.
- Use the `database-query` tool when you only need to read from the database.
+- Use the `database-schema` tool to inspect table structure before writing migrations or models.
## Reading Browser Logs With the `browser-logs` Tool
+
- You can read browser logs, errors, and exceptions using the `browser-logs` tool from Boost.
- Only recent browser logs will be useful - ignore old logs.
## Searching Documentation (Critically Important)
-- Boost comes with a powerful `search-docs` tool you should use before any other approaches when dealing with Laravel or Laravel ecosystem packages. This tool automatically passes a list of installed packages and their versions to the remote Boost API, so it returns only version-specific documentation for the user's circumstance. You should pass an array of packages to filter on if you know you need docs for particular packages.
-- The `search-docs` tool is perfect for all Laravel-related packages, including Laravel, Inertia, Livewire, Filament, Tailwind, Pest, Nova, Nightwatch, etc.
-- You must use this tool to search for Laravel ecosystem documentation before falling back to other approaches.
+
+- Boost comes with a powerful `search-docs` tool you should use before trying other approaches when working with Laravel or Laravel ecosystem packages. This tool automatically passes a list of installed packages and their versions to the remote Boost API, so it returns only version-specific documentation for the user's circumstance. You should pass an array of packages to filter on if you know you need docs for particular packages.
- Search the documentation before making code changes to ensure we are taking the correct approach.
-- Use multiple, broad, simple, topic-based queries to start. For example: `['rate limiting', 'routing rate limiting', 'routing']`.
+- Use multiple, broad, simple, topic-based queries at once. For example: `['rate limiting', 'routing rate limiting', 'routing']`. The most relevant results will be returned first.
- Do not add package names to queries; package information is already shared. For example, use `test resource table`, not `filament 4 test resource table`.
### Available Search Syntax
-- You can and should pass multiple queries at once. The most relevant results will be returned first.
1. Simple Word Searches with auto-stemming - query=authentication - finds 'authenticate' and 'auth'.
2. Multiple Words (AND Logic) - query=rate limit - finds knowledge containing both "rate" AND "limit".
@@ -565,38 +610,44 @@ ### Available Search Syntax
=== php rules ===
-## PHP
+# PHP
-- Always use curly braces for control structures, even if it has one line.
+- Always use curly braces for control structures, even for single-line bodies.
+
+## Constructors
-### Constructors
- Use PHP 8 constructor property promotion in `__construct()`.
- - public function __construct(public GitHub $github) { }
+ - `public function __construct(public GitHub $github) { }`
- Do not allow empty `__construct()` methods with zero parameters unless the constructor is private.
-### Type Declarations
+## Type Declarations
+
- Always use explicit return type declarations for methods and functions.
- Use appropriate PHP type hints for method parameters.
-
+
+```php
protected function isAccessible(User $user, ?string $path = null): bool
{
...
}
-
-
-## Comments
-- Prefer PHPDoc blocks over inline comments. Never use comments within the code itself unless there is something very complex going on.
-
-## PHPDoc Blocks
-- Add useful array shape type definitions for arrays when appropriate.
+```
## Enums
+
- Typically, keys in an Enum should be TitleCase. For example: `FavoritePerson`, `BestLake`, `Monthly`.
+## Comments
+
+- Prefer PHPDoc blocks over inline comments. Never use comments within the code itself unless the logic is exceptionally complex.
+
+## PHPDoc Blocks
+
+- Add useful array shape type definitions when appropriate.
+
=== sail rules ===
-## Laravel Sail
+# Laravel Sail
- This project runs inside Laravel Sail's Docker containers. You MUST execute all commands through Sail.
- Start services using `vendor/bin/sail up -d` and stop them with `vendor/bin/sail stop`.
@@ -610,20 +661,21 @@ ## Laravel Sail
=== tests rules ===
-## Test Enforcement
+# Test Enforcement
- Every change must be programmatically tested. Write a new test or update an existing test, then run the affected tests to make sure they pass.
- Run the minimum number of tests needed to ensure code quality and speed. Use `vendor/bin/sail artisan test --compact` with a specific filename or filter.
=== laravel/core rules ===
-## Do Things the Laravel Way
+# Do Things the Laravel Way
- Use `vendor/bin/sail artisan make:` commands to create new files (i.e. migrations, controllers, models, etc.). You can list available Artisan commands using the `list-artisan-commands` tool.
- If you're creating a generic PHP class, use `vendor/bin/sail artisan make:class`.
- Pass `--no-interaction` to all Artisan commands to ensure they work without user input. You should also pass the correct `--options` to ensure correct behavior.
-### Database
+## Database
+
- Always use proper Eloquent relationship methods with return type hints. Prefer relationship methods over raw queries or manual joins.
- Use Eloquent models and relationships before suggesting raw database queries.
- Avoid `DB::`; prefer `Model::query()`. Generate code that leverages Laravel's ORM capabilities rather than bypassing them.
@@ -631,43 +683,53 @@ ### Database
- Use Laravel's query builder for very complex database operations.
### Model Creation
+
- When creating new models, create useful factories and seeders for them too. Ask the user if they need any other things, using `list-artisan-commands` to check the available options to `vendor/bin/sail artisan make:model`.
### APIs & Eloquent Resources
+
- For APIs, default to using Eloquent API Resources and API versioning unless existing API routes do not, then you should follow existing application convention.
-### Controllers & Validation
+## Controllers & Validation
+
- Always create Form Request classes for validation rather than inline validation in controllers. Include both validation rules and custom error messages.
- Check sibling Form Requests to see if the application uses array or string based validation rules.
-### Queues
-- Use queued jobs for time-consuming operations with the `ShouldQueue` interface.
+## Authentication & Authorization
-### Authentication & Authorization
- Use Laravel's built-in authentication and authorization features (gates, policies, Sanctum, etc.).
-### URL Generation
+## URL Generation
+
- When generating links to other pages, prefer named routes and the `route()` function.
-### Configuration
+## Queues
+
+- Use queued jobs for time-consuming operations with the `ShouldQueue` interface.
+
+## Configuration
+
- Use environment variables only in configuration files - never use the `env()` function directly outside of config files. Always use `config('app.name')`, not `env('APP_NAME')`.
-### Testing
+## Testing
+
- When creating models for tests, use the factories for the models. Check if the factory has custom states that can be used before manually setting up the model.
- Faker: Use methods such as `$this->faker->word()` or `fake()->randomDigit()`. Follow existing conventions whether to use `$this->faker` or `fake()`.
- When creating tests, make use of `vendor/bin/sail artisan make:test [options] {name}` to create a feature test, and pass `--unit` to create a unit test. Most tests should be feature tests.
-### Vite Error
+## Vite Error
+
- If you receive an "Illuminate\Foundation\ViteException: Unable to locate file in Vite manifest" error, you can run `vendor/bin/sail npm run build` or ask the user to run `vendor/bin/sail npm run dev` or `vendor/bin/sail composer run dev`.
=== laravel/v12 rules ===
-## Laravel 12
+# Laravel 12
-- Use the `search-docs` tool to get version-specific documentation.
+- CRITICAL: ALWAYS use `search-docs` tool for version-specific Laravel documentation and updated code examples.
- Since Laravel 11, Laravel has a new streamlined file structure which this project uses.
-### Laravel 12 Structure
+## Laravel 12 Structure
+
- In Laravel 12, middleware are no longer registered in `app/Http/Kernel.php`.
- Middleware are configured declaratively in `bootstrap/app.php` using `Application::configure()->withMiddleware()`.
- `bootstrap/app.php` is the file to register middleware, exceptions, and routing files.
@@ -675,224 +737,39 @@ ### Laravel 12 Structure
- The `app\Console\Kernel.php` file no longer exists; use `bootstrap/app.php` or `routes/console.php` for console configuration.
- Console commands in `app/Console/Commands/` are automatically available and do not require manual registration.
-### Database
+## Database
+
- When modifying a column, the migration must include all of the attributes that were previously defined on the column. Otherwise, they will be dropped and lost.
- Laravel 12 allows limiting eagerly loaded records natively, without external packages: `$query->latest()->limit(10);`.
### Models
+
- Casts can and likely should be set in a `casts()` method on a model rather than the `$casts` property. Follow existing conventions from other models.
-=== livewire/core rules ===
-
-## Livewire
-
-- Use the `search-docs` tool to find exact version-specific documentation for how to write Livewire and Livewire tests.
-- Use the `vendor/bin/sail artisan make:livewire [Posts\CreatePost]` Artisan command to create new components.
-- State should live on the server, with the UI reflecting it.
-- All Livewire requests hit the Laravel backend; they're like regular HTTP requests. Always validate form data and run authorization checks in Livewire actions.
-
-## Livewire Best Practices
-- Livewire components require a single root element.
-- Use `wire:loading` and `wire:dirty` for delightful loading states.
-- Add `wire:key` in loops:
-
- ```blade
- @foreach ($items as $item)
-
- {{ $item->name }}
-
- @endforeach
- ```
-
-- Prefer lifecycle hooks like `mount()`, `updatedFoo()` for initialization and reactive side effects:
-
-
- public function mount(User $user) { $this->user = $user; }
- public function updatedSearch() { $this->resetPage(); }
-
-
-## Testing Livewire
-
-
- Livewire::test(Counter::class)
- ->assertSet('count', 0)
- ->call('increment')
- ->assertSet('count', 1)
- ->assertSee(1)
- ->assertStatus(200);
-
-
-
- $this->get('/posts/create')
- ->assertSeeLivewire(CreatePost::class);
-
-
=== pint/core rules ===
-## Laravel Pint Code Formatter
+# Laravel Pint Code Formatter
-- You must run `vendor/bin/sail bin pint --dirty` before finalizing changes to ensure your code matches the project's expected style.
-- Do not run `vendor/bin/sail bin pint --test`, simply run `vendor/bin/sail bin pint` to fix any formatting issues.
+- You must run `vendor/bin/sail bin pint --dirty --format agent` before finalizing changes to ensure your code matches the project's expected style.
+- Do not run `vendor/bin/sail bin pint --test --format agent`, simply run `vendor/bin/sail bin pint --format agent` to fix any formatting issues.
=== pest/core rules ===
## Pest
-### Testing
-- If you need to verify a feature is working, write or update a Unit / Feature test.
-### Pest Tests
-- All tests must be written using Pest. Use `vendor/bin/sail artisan make:test --pest {name}`.
-- You must not remove any tests or test files from the tests directory without approval. These are not temporary or helper files - these are core to the application.
-- Tests should test all of the happy paths, failure paths, and weird paths.
-- Tests live in the `tests/Feature` and `tests/Unit` directories.
-- Pest tests look and behave like this:
-
-it('is true', function () {
- expect(true)->toBeTrue();
-});
-
-
-### Running Tests
-- Run the minimal number of tests using an appropriate filter before finalizing code edits.
-- To run all tests: `vendor/bin/sail artisan test --compact`.
-- To run all tests in a file: `vendor/bin/sail artisan test --compact tests/Feature/ExampleTest.php`.
-- To filter on a particular test name: `vendor/bin/sail artisan test --compact --filter=testName` (recommended after making a change to a related file).
-- When the tests relating to your changes are passing, ask the user if they would like to run the entire test suite to ensure everything is still passing.
-
-### Pest Assertions
-- When asserting status codes on a response, use the specific method like `assertForbidden` and `assertNotFound` instead of using `assertStatus(403)` or similar, e.g.:
-
-it('returns all', function () {
- $response = $this->postJson('/api/docs', []);
-
- $response->assertSuccessful();
-});
-
-
-### Mocking
-- Mocking can be very helpful when appropriate.
-- When mocking, you can use the `Pest\Laravel\mock` Pest function, but always import it via `use function Pest\Laravel\mock;` before using it. Alternatively, you can use `$this->mock()` if existing tests do.
-- You can also create partial mocks using the same import or self method.
-
-### Datasets
-- Use datasets in Pest to simplify tests that have a lot of duplicated data. This is often the case when testing validation rules, so consider this solution when writing tests for validation rules.
-
-
-it('has emails', function (string $email) {
- expect($email)->not->toBeEmpty();
-})->with([
- 'james' => 'james@laravel.com',
- 'taylor' => 'taylor@laravel.com',
-]);
-
-
-=== pest/v4 rules ===
-
-## Pest 4
-
-- Pest 4 is a huge upgrade to Pest and offers: browser testing, smoke testing, visual regression testing, test sharding, and faster type coverage.
-- Browser testing is incredibly powerful and useful for this project.
-- Browser tests should live in `tests/Browser/`.
-- Use the `search-docs` tool for detailed guidance on utilizing these features.
-
-### Browser Testing
-- You can use Laravel features like `Event::fake()`, `assertAuthenticated()`, and model factories within Pest 4 browser tests, as well as `RefreshDatabase` (when needed) to ensure a clean state for each test.
-- Interact with the page (click, type, scroll, select, submit, drag-and-drop, touch gestures, etc.) when appropriate to complete the test.
-- If requested, test on multiple browsers (Chrome, Firefox, Safari).
-- If requested, test on different devices and viewports (like iPhone 14 Pro, tablets, or custom breakpoints).
-- Switch color schemes (light/dark mode) when appropriate.
-- Take screenshots or pause tests for debugging when appropriate.
-
-### Example Tests
-
-
-it('may reset the password', function () {
- Notification::fake();
-
- $this->actingAs(User::factory()->create());
-
- $page = visit('/sign-in'); // Visit on a real browser...
-
- $page->assertSee('Sign In')
- ->assertNoJavascriptErrors() // or ->assertNoConsoleLogs()
- ->click('Forgot Password?')
- ->fill('email', 'nuno@laravel.com')
- ->click('Send Reset Link')
- ->assertSee('We have emailed your password reset link!')
-
- Notification::assertSent(ResetPassword::class);
-});
-
-
-
-$pages = visit(['/', '/about', '/contact']);
-
-$pages->assertNoJavascriptErrors()->assertNoConsoleLogs();
-
+- This project uses Pest for testing. Create tests: `vendor/bin/sail artisan make:test --pest {name}`.
+- Run tests: `vendor/bin/sail artisan test --compact` or filter: `vendor/bin/sail artisan test --compact --filter=testName`.
+- Do NOT delete tests without approval.
+- CRITICAL: ALWAYS use `search-docs` tool for version-specific Pest documentation and updated code examples.
+- IMPORTANT: Activate `pest-testing` every time you're working with a Pest or testing-related task.
=== tailwindcss/core rules ===
-## Tailwind CSS
+# Tailwind CSS
-- Use Tailwind CSS classes to style HTML; check and use existing Tailwind conventions within the project before writing your own.
-- Offer to extract repeated patterns into components that match the project's conventions (i.e. Blade, JSX, Vue, etc.).
-- Think through class placement, order, priority, and defaults. Remove redundant classes, add classes to parent or child carefully to limit repetition, and group elements logically.
-- You can use the `search-docs` tool to get exact examples from the official documentation when needed.
-
-### Spacing
-- When listing items, use gap utilities for spacing; don't use margins.
-
-
-
-
Superior
-
Michigan
-
Erie
-
-
-
-### Dark Mode
-- If existing pages and components support dark mode, new pages and components must support dark mode in a similar way, typically using `dark:`.
-
-=== tailwindcss/v4 rules ===
-
-## Tailwind CSS 4
-
-- Always use Tailwind CSS v4; do not use the deprecated utilities.
-- `corePlugins` is not supported in Tailwind v4.
-- In Tailwind v4, configuration is CSS-first using the `@theme` directive — no separate `tailwind.config.js` file is needed.
-
-
-@theme {
- --color-brand: oklch(0.72 0.11 178);
-}
-
-
-- In Tailwind v4, you import Tailwind using a regular CSS `@import` statement, not using the `@tailwind` directives used in v3:
-
-
- - @tailwind base;
- - @tailwind components;
- - @tailwind utilities;
- + @import "tailwindcss";
-
-
-### Replaced Utilities
-- Tailwind v4 removed deprecated utilities. Do not use the deprecated option; use the replacement.
-- Opacity values are still numeric.
-
-| Deprecated | Replacement |
-|------------+--------------|
-| bg-opacity-* | bg-black/* |
-| text-opacity-* | text-black/* |
-| border-opacity-* | border-black/* |
-| divide-opacity-* | divide-black/* |
-| ring-opacity-* | ring-black/* |
-| placeholder-opacity-* | placeholder-black/* |
-| flex-shrink-* | shrink-* |
-| flex-grow-* | grow-* |
-| overflow-ellipsis | text-ellipsis |
-| decoration-slice | box-decoration-slice |
-| decoration-clone | box-decoration-clone |
+- Always use existing Tailwind conventions; check project patterns before adding new ones.
+- IMPORTANT: Always use `search-docs` tool for version-specific Tailwind CSS documentation and updated code examples. Never rely on training data.
+- IMPORTANT: Activate `tailwindcss-development` every time you're working with a Tailwind CSS or styling-related task.
## Recent Changes
diff --git a/app/Filament/Pages/BaselineCompareLanding.php b/app/Filament/Pages/BaselineCompareLanding.php
new file mode 100644
index 0000000..7bde82e
--- /dev/null
+++ b/app/Filament/Pages/BaselineCompareLanding.php
@@ -0,0 +1,298 @@
+|null */
+ public ?array $severityCounts = null;
+
+ public ?string $lastComparedAt = null;
+
+ public static function canAccess(): bool
+ {
+ $user = auth()->user();
+
+ if (! $user instanceof User) {
+ return false;
+ }
+
+ $tenant = Tenant::current();
+
+ if (! $tenant instanceof Tenant) {
+ return false;
+ }
+
+ $resolver = app(CapabilityResolver::class);
+
+ return $resolver->can($user, $tenant, Capabilities::TENANT_VIEW);
+ }
+
+ public function mount(): void
+ {
+ $tenant = Tenant::current();
+
+ if (! $tenant instanceof Tenant) {
+ $this->state = 'no_tenant';
+ $this->message = 'No tenant selected.';
+
+ return;
+ }
+
+ $assignment = BaselineTenantAssignment::query()
+ ->where('tenant_id', $tenant->getKey())
+ ->first();
+
+ if (! $assignment instanceof BaselineTenantAssignment) {
+ $this->state = 'no_assignment';
+ $this->message = 'This tenant has no baseline assignment. A workspace manager can assign a baseline profile to this tenant.';
+
+ return;
+ }
+
+ $profile = $assignment->baselineProfile;
+
+ if ($profile === null) {
+ $this->state = 'no_assignment';
+ $this->message = 'The assigned baseline profile no longer exists.';
+
+ return;
+ }
+
+ $this->profileName = (string) $profile->name;
+ $this->profileId = (int) $profile->getKey();
+ $this->snapshotId = $profile->active_snapshot_id !== null ? (int) $profile->active_snapshot_id : null;
+
+ if ($this->snapshotId === null) {
+ $this->state = 'no_snapshot';
+ $this->message = 'The baseline profile has no active snapshot yet. A workspace manager needs to capture a snapshot first.';
+
+ return;
+ }
+
+ $latestRun = OperationRun::query()
+ ->where('tenant_id', $tenant->getKey())
+ ->where('type', 'baseline_compare')
+ ->latest('id')
+ ->first();
+
+ if ($latestRun instanceof OperationRun && in_array($latestRun->status, ['queued', 'running'], true)) {
+ $this->state = 'comparing';
+ $this->operationRunId = (int) $latestRun->getKey();
+ $this->message = 'A baseline comparison is currently in progress.';
+
+ return;
+ }
+
+ if ($latestRun instanceof OperationRun && $latestRun->finished_at !== null) {
+ $this->lastComparedAt = $latestRun->finished_at->diffForHumans();
+ }
+
+ $scopeKey = 'baseline_profile:'.$profile->getKey();
+
+ $findingsQuery = Finding::query()
+ ->where('tenant_id', $tenant->getKey())
+ ->where('finding_type', Finding::FINDING_TYPE_DRIFT)
+ ->where('source', 'baseline.compare')
+ ->where('scope_key', $scopeKey);
+
+ $totalFindings = (int) (clone $findingsQuery)->count();
+
+ if ($totalFindings > 0) {
+ $this->state = 'ready';
+ $this->findingsCount = $totalFindings;
+ $this->severityCounts = [
+ 'high' => (int) (clone $findingsQuery)->where('severity', Finding::SEVERITY_HIGH)->count(),
+ 'medium' => (int) (clone $findingsQuery)->where('severity', Finding::SEVERITY_MEDIUM)->count(),
+ 'low' => (int) (clone $findingsQuery)->where('severity', Finding::SEVERITY_LOW)->count(),
+ ];
+
+ if ($latestRun instanceof OperationRun) {
+ $this->operationRunId = (int) $latestRun->getKey();
+ }
+
+ return;
+ }
+
+ if ($latestRun instanceof OperationRun && $latestRun->status === 'completed' && $latestRun->outcome === 'succeeded') {
+ $this->state = 'ready';
+ $this->findingsCount = 0;
+ $this->operationRunId = (int) $latestRun->getKey();
+ $this->message = 'No drift findings for this baseline comparison. The tenant matches the baseline.';
+
+ return;
+ }
+
+ $this->state = 'idle';
+ $this->message = 'Baseline profile is assigned and has a snapshot. Run "Compare Now" to check for drift.';
+ }
+
+ public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
+ {
+ return ActionSurfaceDeclaration::forPage(ActionSurfaceProfile::ListOnlyReadOnly)
+ ->satisfy(ActionSurfaceSlot::ListHeader, 'Header action: Compare Now (confirmation modal, capability-gated).')
+ ->exempt(ActionSurfaceSlot::InspectAffordance, 'This is a tenant-scoped landing page, not a record inspect surface.')
+ ->exempt(ActionSurfaceSlot::ListRowMoreMenu, 'This page does not render table rows with secondary actions.')
+ ->exempt(ActionSurfaceSlot::ListBulkMoreGroup, 'This page has no bulk actions.')
+ ->satisfy(ActionSurfaceSlot::ListEmptyState, 'Page renders explicit empty states for missing tenant, missing assignment, and missing snapshot, with guidance messaging.')
+ ->exempt(ActionSurfaceSlot::DetailHeader, 'This page does not have a record detail header; it uses a page header action instead.');
+ }
+
+ /**
+ * @return array
+ */
+ protected function getHeaderActions(): array
+ {
+ return [
+ $this->compareNowAction(),
+ ];
+ }
+
+ private function compareNowAction(): Action
+ {
+ return Action::make('compareNow')
+ ->label('Compare Now')
+ ->icon('heroicon-o-play')
+ ->requiresConfirmation()
+ ->modalHeading('Start baseline comparison')
+ ->modalDescription('This will compare the current tenant inventory against the assigned baseline snapshot and generate drift findings.')
+ ->visible(fn (): bool => $this->canCompare())
+ ->disabled(fn (): bool => ! in_array($this->state, ['idle', 'ready'], true))
+ ->action(function (): void {
+ $user = auth()->user();
+
+ if (! $user instanceof User) {
+ Notification::make()->title('Not authenticated')->danger()->send();
+
+ return;
+ }
+
+ $tenant = Tenant::current();
+
+ if (! $tenant instanceof Tenant) {
+ Notification::make()->title('No tenant context')->danger()->send();
+
+ return;
+ }
+
+ $service = app(BaselineCompareService::class);
+ $result = $service->startCompare($tenant, $user);
+
+ if (! ($result['ok'] ?? false)) {
+ Notification::make()
+ ->title('Cannot start comparison')
+ ->body('Reason: '.($result['reason_code'] ?? 'unknown'))
+ ->danger()
+ ->send();
+
+ return;
+ }
+
+ $run = $result['run'] ?? null;
+
+ if ($run instanceof OperationRun) {
+ $this->operationRunId = (int) $run->getKey();
+ }
+
+ $this->state = 'comparing';
+
+ Notification::make()
+ ->title('Baseline comparison started')
+ ->body('A background job will compute drift against the baseline snapshot.')
+ ->success()
+ ->send();
+ });
+ }
+
+ private function canCompare(): bool
+ {
+ $user = auth()->user();
+
+ if (! $user instanceof User) {
+ return false;
+ }
+
+ $tenant = Tenant::current();
+
+ if (! $tenant instanceof Tenant) {
+ return false;
+ }
+
+ $resolver = app(CapabilityResolver::class);
+
+ return $resolver->can($user, $tenant, Capabilities::TENANT_SYNC);
+ }
+
+ public function getFindingsUrl(): ?string
+ {
+ $tenant = Tenant::current();
+
+ if (! $tenant instanceof Tenant) {
+ return null;
+ }
+
+ return FindingResource::getUrl('index', tenant: $tenant);
+ }
+
+ public function getRunUrl(): ?string
+ {
+ if ($this->operationRunId === null) {
+ return null;
+ }
+
+ $tenant = Tenant::current();
+
+ if (! $tenant instanceof Tenant) {
+ return null;
+ }
+
+ return OperationRunLinks::view($this->operationRunId, $tenant);
+ }
+}
diff --git a/app/Filament/Pages/DriftLanding.php b/app/Filament/Pages/DriftLanding.php
index 2d6ecfe..ac45c7b 100644
--- a/app/Filament/Pages/DriftLanding.php
+++ b/app/Filament/Pages/DriftLanding.php
@@ -28,7 +28,7 @@ class DriftLanding extends Page
{
protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-arrows-right-left';
- protected static string|UnitEnum|null $navigationGroup = 'Drift';
+ protected static string|UnitEnum|null $navigationGroup = 'Governance';
protected static ?string $navigationLabel = 'Drift';
diff --git a/app/Filament/Pages/TenantDashboard.php b/app/Filament/Pages/TenantDashboard.php
index defca63..bacefac 100644
--- a/app/Filament/Pages/TenantDashboard.php
+++ b/app/Filament/Pages/TenantDashboard.php
@@ -4,6 +4,7 @@
namespace App\Filament\Pages;
+use App\Filament\Widgets\Dashboard\BaselineCompareNow;
use App\Filament\Widgets\Dashboard\DashboardKpis;
use App\Filament\Widgets\Dashboard\NeedsAttention;
use App\Filament\Widgets\Dashboard\RecentDriftFindings;
@@ -31,6 +32,7 @@ public function getWidgets(): array
return [
DashboardKpis::class,
NeedsAttention::class,
+ BaselineCompareNow::class,
RecentDriftFindings::class,
RecentOperations::class,
];
diff --git a/app/Filament/Resources/BaselineProfileResource.php b/app/Filament/Resources/BaselineProfileResource.php
new file mode 100644
index 0000000..564fb13
--- /dev/null
+++ b/app/Filament/Resources/BaselineProfileResource.php
@@ -0,0 +1,396 @@
+getId() !== 'admin') {
+ return false;
+ }
+
+ return parent::shouldRegisterNavigation();
+ }
+
+ public static function canViewAny(): bool
+ {
+ $user = auth()->user();
+
+ if (! $user instanceof User) {
+ return false;
+ }
+
+ $workspace = self::resolveWorkspace();
+
+ if (! $workspace instanceof Workspace) {
+ return false;
+ }
+
+ $resolver = app(\App\Services\Auth\WorkspaceCapabilityResolver::class);
+
+ return $resolver->isMember($user, $workspace)
+ && $resolver->can($user, $workspace, Capabilities::WORKSPACE_BASELINES_VIEW);
+ }
+
+ public static function canCreate(): bool
+ {
+ return self::hasManageCapability();
+ }
+
+ public static function canEdit(Model $record): bool
+ {
+ return self::hasManageCapability();
+ }
+
+ public static function canDelete(Model $record): bool
+ {
+ return self::hasManageCapability();
+ }
+
+ public static function canView(Model $record): bool
+ {
+ return self::canViewAny();
+ }
+
+ public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
+ {
+ return ActionSurfaceDeclaration::forResource(ActionSurfaceProfile::CrudListAndView)
+ ->satisfy(ActionSurfaceSlot::ListHeader, 'Header action: Create baseline profile (capability-gated).')
+ ->satisfy(ActionSurfaceSlot::InspectAffordance, ActionSurfaceInspectAffordance::ViewAction->value)
+ ->satisfy(ActionSurfaceSlot::ListRowMoreMenu, 'Secondary row actions (edit, archive) under "More".')
+ ->exempt(ActionSurfaceSlot::ListBulkMoreGroup, 'No bulk mutations for baseline profiles in v1.')
+ ->satisfy(ActionSurfaceSlot::ListEmptyState, 'List defines empty-state create CTA.')
+ ->satisfy(ActionSurfaceSlot::DetailHeader, 'View page provides capture + edit actions.');
+ }
+
+ public static function getEloquentQuery(): Builder
+ {
+ $workspaceId = app(WorkspaceContext::class)->currentWorkspaceId(request());
+
+ return parent::getEloquentQuery()
+ ->with(['activeSnapshot', 'createdByUser'])
+ ->when(
+ $workspaceId !== null,
+ fn (Builder $query): Builder => $query->where('workspace_id', (int) $workspaceId),
+ )
+ ->when(
+ $workspaceId === null,
+ fn (Builder $query): Builder => $query->whereRaw('1 = 0'),
+ );
+ }
+
+ public static function form(Schema $schema): Schema
+ {
+ return $schema
+ ->schema([
+ TextInput::make('name')
+ ->required()
+ ->maxLength(255),
+ Textarea::make('description')
+ ->rows(3)
+ ->maxLength(1000),
+ TextInput::make('version_label')
+ ->label('Version label')
+ ->maxLength(50),
+ Select::make('status')
+ ->required()
+ ->options([
+ BaselineProfile::STATUS_DRAFT => 'Draft',
+ BaselineProfile::STATUS_ACTIVE => 'Active',
+ BaselineProfile::STATUS_ARCHIVED => 'Archived',
+ ])
+ ->default(BaselineProfile::STATUS_DRAFT)
+ ->native(false),
+ Select::make('scope_jsonb.policy_types')
+ ->label('Policy type scope')
+ ->multiple()
+ ->options(self::policyTypeOptions())
+ ->helperText('Leave empty to include all policy types.')
+ ->native(false),
+ ]);
+ }
+
+ public static function infolist(Schema $schema): Schema
+ {
+ return $schema
+ ->schema([
+ Section::make('Profile')
+ ->schema([
+ TextEntry::make('name'),
+ TextEntry::make('status')
+ ->badge()
+ ->formatStateUsing(BadgeRenderer::label(BadgeDomain::BaselineProfileStatus))
+ ->color(BadgeRenderer::color(BadgeDomain::BaselineProfileStatus))
+ ->icon(BadgeRenderer::icon(BadgeDomain::BaselineProfileStatus)),
+ TextEntry::make('version_label')
+ ->label('Version')
+ ->placeholder('—'),
+ TextEntry::make('description')
+ ->placeholder('No description')
+ ->columnSpanFull(),
+ ])
+ ->columns(2)
+ ->columnSpanFull(),
+ Section::make('Scope')
+ ->schema([
+ TextEntry::make('scope_jsonb.policy_types')
+ ->label('Policy type scope')
+ ->badge()
+ ->formatStateUsing(function (string $state): string {
+ $options = self::policyTypeOptions();
+
+ return $options[$state] ?? $state;
+ })
+ ->placeholder('All policy types'),
+ ])
+ ->columnSpanFull(),
+ Section::make('Metadata')
+ ->schema([
+ TextEntry::make('createdByUser.name')
+ ->label('Created by')
+ ->placeholder('—'),
+ TextEntry::make('activeSnapshot.captured_at')
+ ->label('Last snapshot')
+ ->dateTime()
+ ->placeholder('No snapshot yet'),
+ TextEntry::make('created_at')
+ ->dateTime(),
+ TextEntry::make('updated_at')
+ ->dateTime(),
+ ])
+ ->columns(2)
+ ->columnSpanFull(),
+ ]);
+ }
+
+ public static function table(Table $table): Table
+ {
+ $workspace = self::resolveWorkspace();
+
+ return $table
+ ->defaultSort('name')
+ ->columns([
+ TextColumn::make('name')
+ ->searchable()
+ ->sortable(),
+ TextColumn::make('status')
+ ->badge()
+ ->formatStateUsing(BadgeRenderer::label(BadgeDomain::BaselineProfileStatus))
+ ->color(BadgeRenderer::color(BadgeDomain::BaselineProfileStatus))
+ ->icon(BadgeRenderer::icon(BadgeDomain::BaselineProfileStatus))
+ ->sortable(),
+ TextColumn::make('version_label')
+ ->label('Version')
+ ->placeholder('—'),
+ TextColumn::make('activeSnapshot.captured_at')
+ ->label('Last snapshot')
+ ->dateTime()
+ ->placeholder('No snapshot'),
+ TextColumn::make('created_at')
+ ->dateTime()
+ ->sortable()
+ ->toggleable(isToggledHiddenByDefault: true),
+ ])
+ ->actions([
+ Action::make('view')
+ ->label('View')
+ ->url(fn (BaselineProfile $record): string => static::getUrl('view', ['record' => $record]))
+ ->icon('heroicon-o-eye'),
+ ActionGroup::make([
+ Action::make('edit')
+ ->label('Edit')
+ ->url(fn (BaselineProfile $record): string => static::getUrl('edit', ['record' => $record]))
+ ->icon('heroicon-o-pencil-square')
+ ->visible(fn (): bool => self::hasManageCapability()),
+ self::archiveTableAction($workspace),
+ ])->label('More'),
+ ])
+ ->bulkActions([
+ BulkActionGroup::make([])->label('More'),
+ ])
+ ->emptyStateHeading('No baseline profiles')
+ ->emptyStateDescription('Create a baseline profile to define what "good" looks like for your tenants.')
+ ->emptyStateActions([
+ Action::make('create')
+ ->label('Create baseline profile')
+ ->url(fn (): string => static::getUrl('create'))
+ ->icon('heroicon-o-plus')
+ ->visible(fn (): bool => self::hasManageCapability()),
+ ]);
+ }
+
+ public static function getRelations(): array
+ {
+ return [
+ BaselineProfileResource\RelationManagers\BaselineTenantAssignmentsRelationManager::class,
+ ];
+ }
+
+ public static function getPages(): array
+ {
+ return [
+ 'index' => Pages\ListBaselineProfiles::route('/'),
+ 'create' => Pages\CreateBaselineProfile::route('/create'),
+ 'view' => Pages\ViewBaselineProfile::route('/{record}'),
+ 'edit' => Pages\EditBaselineProfile::route('/{record}/edit'),
+ ];
+ }
+
+ /**
+ * @return array
+ */
+ public static function policyTypeOptions(): array
+ {
+ return collect(InventoryPolicyTypeMeta::all())
+ ->filter(fn (array $row): bool => filled($row['type'] ?? null))
+ ->mapWithKeys(fn (array $row): array => [
+ (string) $row['type'] => (string) ($row['label'] ?? $row['type']),
+ ])
+ ->sort()
+ ->all();
+ }
+
+ /**
+ * @param array $metadata
+ */
+ public static function audit(BaselineProfile $record, AuditActionId $actionId, array $metadata): void
+ {
+ $workspace = $record->workspace;
+
+ if ($workspace === null) {
+ return;
+ }
+
+ $actor = auth()->user();
+
+ app(WorkspaceAuditLogger::class)->log(
+ workspace: $workspace,
+ action: $actionId->value,
+ context: ['metadata' => $metadata],
+ actor: $actor instanceof User ? $actor : null,
+ resourceType: 'baseline_profile',
+ resourceId: (string) $record->getKey(),
+ );
+ }
+
+ private static function resolveWorkspace(): ?Workspace
+ {
+ $workspaceId = app(WorkspaceContext::class)->currentWorkspaceId(request());
+
+ if ($workspaceId === null) {
+ return null;
+ }
+
+ return Workspace::query()->whereKey($workspaceId)->first();
+ }
+
+ private static function hasManageCapability(): bool
+ {
+ $user = auth()->user();
+ $workspace = self::resolveWorkspace();
+
+ if (! $user instanceof User || ! $workspace instanceof Workspace) {
+ return false;
+ }
+
+ $resolver = app(\App\Services\Auth\WorkspaceCapabilityResolver::class);
+
+ return $resolver->isMember($user, $workspace)
+ && $resolver->can($user, $workspace, Capabilities::WORKSPACE_BASELINES_MANAGE);
+ }
+
+ private static function archiveTableAction(?Workspace $workspace): Action
+ {
+ $action = Action::make('archive')
+ ->label('Archive')
+ ->icon('heroicon-o-archive-box')
+ ->color('warning')
+ ->requiresConfirmation()
+ ->modalHeading('Archive baseline profile')
+ ->modalDescription('Archiving is permanent in v1. This profile can no longer be used for captures or compares.')
+ ->visible(fn (BaselineProfile $record): bool => $record->status !== BaselineProfile::STATUS_ARCHIVED && self::hasManageCapability())
+ ->action(function (BaselineProfile $record): void {
+ if (! self::hasManageCapability()) {
+ throw new AuthorizationException;
+ }
+
+ $record->forceFill(['status' => BaselineProfile::STATUS_ARCHIVED])->save();
+
+ self::audit($record, AuditActionId::BaselineProfileArchived, [
+ 'baseline_profile_id' => (int) $record->getKey(),
+ 'name' => (string) $record->name,
+ ]);
+
+ Notification::make()
+ ->title('Baseline profile archived')
+ ->success()
+ ->send();
+ });
+
+ if ($workspace instanceof Workspace) {
+ $action = WorkspaceUiEnforcement::forTableAction($action, $workspace)
+ ->requireCapability(Capabilities::WORKSPACE_BASELINES_MANAGE)
+ ->destructive()
+ ->apply();
+ }
+
+ return $action;
+ }
+}
diff --git a/app/Filament/Resources/BaselineProfileResource/Pages/CreateBaselineProfile.php b/app/Filament/Resources/BaselineProfileResource/Pages/CreateBaselineProfile.php
new file mode 100644
index 0000000..475259b
--- /dev/null
+++ b/app/Filament/Resources/BaselineProfileResource/Pages/CreateBaselineProfile.php
@@ -0,0 +1,56 @@
+ $data
+ * @return array
+ */
+ protected function mutateFormDataBeforeCreate(array $data): array
+ {
+ $workspaceId = app(WorkspaceContext::class)->currentWorkspaceId(request());
+ $data['workspace_id'] = (int) $workspaceId;
+
+ $user = auth()->user();
+ $data['created_by_user_id'] = $user instanceof User ? $user->getKey() : null;
+
+ $policyTypes = $data['scope_jsonb']['policy_types'] ?? [];
+ $data['scope_jsonb'] = ['policy_types' => is_array($policyTypes) ? array_values($policyTypes) : []];
+
+ return $data;
+ }
+
+ protected function afterCreate(): void
+ {
+ $record = $this->record;
+
+ if (! $record instanceof BaselineProfile) {
+ return;
+ }
+
+ BaselineProfileResource::audit($record, AuditActionId::BaselineProfileCreated, [
+ 'baseline_profile_id' => (int) $record->getKey(),
+ 'name' => (string) $record->name,
+ 'status' => (string) $record->status,
+ ]);
+
+ Notification::make()
+ ->title('Baseline profile created')
+ ->success()
+ ->send();
+ }
+}
diff --git a/app/Filament/Resources/BaselineProfileResource/Pages/EditBaselineProfile.php b/app/Filament/Resources/BaselineProfileResource/Pages/EditBaselineProfile.php
new file mode 100644
index 0000000..49284ec
--- /dev/null
+++ b/app/Filament/Resources/BaselineProfileResource/Pages/EditBaselineProfile.php
@@ -0,0 +1,48 @@
+ $data
+ * @return array
+ */
+ protected function mutateFormDataBeforeSave(array $data): array
+ {
+ $policyTypes = $data['scope_jsonb']['policy_types'] ?? [];
+ $data['scope_jsonb'] = ['policy_types' => is_array($policyTypes) ? array_values($policyTypes) : []];
+
+ return $data;
+ }
+
+ protected function afterSave(): void
+ {
+ $record = $this->record;
+
+ if (! $record instanceof BaselineProfile) {
+ return;
+ }
+
+ BaselineProfileResource::audit($record, AuditActionId::BaselineProfileUpdated, [
+ 'baseline_profile_id' => (int) $record->getKey(),
+ 'name' => (string) $record->name,
+ 'status' => (string) $record->status,
+ ]);
+
+ Notification::make()
+ ->title('Baseline profile updated')
+ ->success()
+ ->send();
+ }
+}
diff --git a/app/Filament/Resources/BaselineProfileResource/Pages/ListBaselineProfiles.php b/app/Filament/Resources/BaselineProfileResource/Pages/ListBaselineProfiles.php
new file mode 100644
index 0000000..7abf445
--- /dev/null
+++ b/app/Filament/Resources/BaselineProfileResource/Pages/ListBaselineProfiles.php
@@ -0,0 +1,23 @@
+label('Create baseline profile')
+ ->disabled(fn (): bool => ! BaselineProfileResource::canCreate()),
+ ];
+ }
+}
diff --git a/app/Filament/Resources/BaselineProfileResource/Pages/ViewBaselineProfile.php b/app/Filament/Resources/BaselineProfileResource/Pages/ViewBaselineProfile.php
new file mode 100644
index 0000000..1691b05
--- /dev/null
+++ b/app/Filament/Resources/BaselineProfileResource/Pages/ViewBaselineProfile.php
@@ -0,0 +1,142 @@
+captureAction(),
+ EditAction::make()
+ ->visible(fn (): bool => $this->hasManageCapability()),
+ ];
+ }
+
+ private function captureAction(): Action
+ {
+ return Action::make('capture')
+ ->label('Capture Snapshot')
+ ->icon('heroicon-o-camera')
+ ->color('primary')
+ ->visible(fn (): bool => $this->hasManageCapability())
+ ->disabled(fn (): bool => ! $this->hasManageCapability())
+ ->tooltip(fn (): ?string => ! $this->hasManageCapability() ? 'You need manage permission to capture snapshots.' : null)
+ ->requiresConfirmation()
+ ->modalHeading('Capture Baseline Snapshot')
+ ->modalDescription('Select the source tenant whose current inventory will be captured as the baseline snapshot.')
+ ->form([
+ Select::make('source_tenant_id')
+ ->label('Source Tenant')
+ ->options(fn (): array => $this->getWorkspaceTenantOptions())
+ ->required()
+ ->searchable(),
+ ])
+ ->action(function (array $data): void {
+ $user = auth()->user();
+
+ if (! $user instanceof User || ! $this->hasManageCapability()) {
+ Notification::make()
+ ->title('Permission denied')
+ ->danger()
+ ->send();
+
+ return;
+ }
+
+ /** @var BaselineProfile $profile */
+ $profile = $this->getRecord();
+ $sourceTenant = Tenant::query()->find((int) $data['source_tenant_id']);
+
+ if (! $sourceTenant instanceof Tenant) {
+ Notification::make()
+ ->title('Source tenant not found')
+ ->danger()
+ ->send();
+
+ return;
+ }
+
+ $service = app(BaselineCaptureService::class);
+ $result = $service->startCapture($profile, $sourceTenant, $user);
+
+ if (! $result['ok']) {
+ Notification::make()
+ ->title('Cannot start capture')
+ ->body('Reason: '.str_replace('.', ' ', (string) ($result['reason_code'] ?? 'unknown')))
+ ->danger()
+ ->send();
+
+ return;
+ }
+
+ Notification::make()
+ ->title('Capture enqueued')
+ ->body('Baseline snapshot capture has been started.')
+ ->success()
+ ->send();
+ });
+ }
+
+ /**
+ * @return array
+ */
+ private function getWorkspaceTenantOptions(): array
+ {
+ $workspaceId = app(WorkspaceContext::class)->currentWorkspaceId(request());
+
+ if ($workspaceId === null) {
+ return [];
+ }
+
+ return Tenant::query()
+ ->where('workspace_id', $workspaceId)
+ ->orderBy('name')
+ ->pluck('name', 'id')
+ ->all();
+ }
+
+ private function hasManageCapability(): bool
+ {
+ $user = auth()->user();
+
+ if (! $user instanceof User) {
+ return false;
+ }
+
+ $workspaceId = app(WorkspaceContext::class)->currentWorkspaceId(request());
+
+ if ($workspaceId === null) {
+ return false;
+ }
+
+ $workspace = Workspace::query()->whereKey($workspaceId)->first();
+
+ if (! $workspace instanceof Workspace) {
+ return false;
+ }
+
+ $resolver = app(\App\Services\Auth\WorkspaceCapabilityResolver::class);
+
+ return $resolver->isMember($user, $workspace)
+ && $resolver->can($user, $workspace, Capabilities::WORKSPACE_BASELINES_MANAGE);
+ }
+}
diff --git a/app/Filament/Resources/BaselineProfileResource/RelationManagers/BaselineTenantAssignmentsRelationManager.php b/app/Filament/Resources/BaselineProfileResource/RelationManagers/BaselineTenantAssignmentsRelationManager.php
new file mode 100644
index 0000000..adb5e98
--- /dev/null
+++ b/app/Filament/Resources/BaselineProfileResource/RelationManagers/BaselineTenantAssignmentsRelationManager.php
@@ -0,0 +1,246 @@
+satisfy(ActionSurfaceSlot::ListHeader, 'Header action: Assign tenant (manage-gated).')
+ ->satisfy(ActionSurfaceSlot::InspectAffordance, ActionSurfaceInspectAffordance::ClickableRow->value)
+ ->exempt(ActionSurfaceSlot::ListRowMoreMenu, 'v1 assignments have no row-level actions beyond delete.')
+ ->exempt(ActionSurfaceSlot::ListBulkMoreGroup, 'No bulk mutations for assignments in v1.')
+ ->satisfy(ActionSurfaceSlot::ListEmptyState, 'Empty state encourages assigning a tenant.');
+ }
+
+ public function table(Table $table): Table
+ {
+ return $table
+ ->columns([
+ Tables\Columns\TextColumn::make('tenant.name')
+ ->label('Tenant')
+ ->searchable(),
+ Tables\Columns\TextColumn::make('assignedByUser.name')
+ ->label('Assigned by')
+ ->placeholder('—'),
+ Tables\Columns\TextColumn::make('created_at')
+ ->label('Assigned at')
+ ->dateTime()
+ ->sortable(),
+ ])
+ ->headerActions([
+ $this->assignTenantAction(),
+ ])
+ ->actions([
+ $this->removeAssignmentAction(),
+ ])
+ ->emptyStateHeading('No tenants assigned')
+ ->emptyStateDescription('Assign a tenant to compare its state against this baseline profile.')
+ ->emptyStateActions([
+ $this->assignTenantAction(),
+ ]);
+ }
+
+ private function assignTenantAction(): Action
+ {
+ return Action::make('assign')
+ ->label('Assign Tenant')
+ ->icon('heroicon-o-plus')
+ ->visible(fn (): bool => $this->hasManageCapability())
+ ->form([
+ Select::make('tenant_id')
+ ->label('Tenant')
+ ->options(fn (): array => $this->getAvailableTenantOptions())
+ ->required()
+ ->searchable(),
+ ])
+ ->action(function (array $data): void {
+ $user = auth()->user();
+
+ if (! $user instanceof User || ! $this->hasManageCapability()) {
+ Notification::make()
+ ->title('Permission denied')
+ ->danger()
+ ->send();
+
+ return;
+ }
+
+ /** @var BaselineProfile $profile */
+ $profile = $this->getOwnerRecord();
+ $tenantId = (int) $data['tenant_id'];
+
+ $existing = BaselineTenantAssignment::query()
+ ->where('workspace_id', $profile->workspace_id)
+ ->where('tenant_id', $tenantId)
+ ->first();
+
+ if ($existing instanceof BaselineTenantAssignment) {
+ Notification::make()
+ ->title('Tenant already assigned')
+ ->body('This tenant already has a baseline assignment in this workspace.')
+ ->warning()
+ ->send();
+
+ return;
+ }
+
+ $assignment = BaselineTenantAssignment::create([
+ 'workspace_id' => (int) $profile->workspace_id,
+ 'tenant_id' => $tenantId,
+ 'baseline_profile_id' => (int) $profile->getKey(),
+ 'assigned_by_user_id' => (int) $user->getKey(),
+ ]);
+
+ $this->auditAssignment($profile, $assignment, $user, 'created');
+
+ Notification::make()
+ ->title('Tenant assigned')
+ ->success()
+ ->send();
+ });
+ }
+
+ private function removeAssignmentAction(): Action
+ {
+ return Action::make('remove')
+ ->label('Remove')
+ ->icon('heroicon-o-trash')
+ ->color('danger')
+ ->visible(fn (): bool => $this->hasManageCapability())
+ ->requiresConfirmation()
+ ->modalHeading('Remove tenant assignment')
+ ->modalDescription('Are you sure you want to remove this tenant assignment? This will not delete any existing findings.')
+ ->action(function (BaselineTenantAssignment $record): void {
+ $user = auth()->user();
+
+ if (! $user instanceof User || ! $this->hasManageCapability()) {
+ Notification::make()
+ ->title('Permission denied')
+ ->danger()
+ ->send();
+
+ return;
+ }
+
+ /** @var BaselineProfile $profile */
+ $profile = $this->getOwnerRecord();
+
+ $this->auditAssignment($profile, $record, $user, 'removed');
+
+ $record->delete();
+
+ Notification::make()
+ ->title('Assignment removed')
+ ->success()
+ ->send();
+ });
+ }
+
+ /**
+ * @return array
+ */
+ private function getAvailableTenantOptions(): array
+ {
+ /** @var BaselineProfile $profile */
+ $profile = $this->getOwnerRecord();
+
+ $assignedTenantIds = BaselineTenantAssignment::query()
+ ->where('workspace_id', $profile->workspace_id)
+ ->pluck('tenant_id')
+ ->all();
+
+ $query = Tenant::query()
+ ->where('workspace_id', $profile->workspace_id)
+ ->orderBy('name');
+
+ if (! empty($assignedTenantIds)) {
+ $query->whereNotIn('id', $assignedTenantIds);
+ }
+
+ return $query->pluck('name', 'id')->all();
+ }
+
+ private function auditAssignment(
+ BaselineProfile $profile,
+ BaselineTenantAssignment $assignment,
+ User $user,
+ string $action,
+ ): void {
+ $workspace = Workspace::query()->find($profile->workspace_id);
+
+ if (! $workspace instanceof Workspace) {
+ return;
+ }
+
+ $tenant = Tenant::query()->find($assignment->tenant_id);
+
+ $auditLogger = app(WorkspaceAuditLogger::class);
+
+ $auditLogger->log(
+ workspace: $workspace,
+ action: 'baseline.assignment.'.$action,
+ context: [
+ 'baseline_profile_id' => (int) $profile->getKey(),
+ 'baseline_profile_name' => (string) $profile->name,
+ 'tenant_id' => (int) $assignment->tenant_id,
+ 'tenant_name' => $tenant instanceof Tenant ? (string) $tenant->display_name : '—',
+ ],
+ actor: $user,
+ resourceType: 'baseline_profile',
+ resourceId: (string) $profile->getKey(),
+ );
+ }
+
+ private function hasManageCapability(): bool
+ {
+ $user = auth()->user();
+
+ if (! $user instanceof User) {
+ return false;
+ }
+
+ $workspaceId = app(WorkspaceContext::class)->currentWorkspaceId(request());
+
+ if ($workspaceId === null) {
+ return false;
+ }
+
+ $workspace = Workspace::query()->whereKey($workspaceId)->first();
+
+ if (! $workspace instanceof Workspace) {
+ return false;
+ }
+
+ $resolver = app(\App\Services\Auth\WorkspaceCapabilityResolver::class);
+
+ return $resolver->isMember($user, $workspace)
+ && $resolver->can($user, $workspace, Capabilities::WORKSPACE_BASELINES_MANAGE);
+ }
+}
diff --git a/app/Filament/Resources/FindingResource.php b/app/Filament/Resources/FindingResource.php
index e8118c0..6daa396 100644
--- a/app/Filament/Resources/FindingResource.php
+++ b/app/Filament/Resources/FindingResource.php
@@ -46,7 +46,7 @@ class FindingResource extends Resource
protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-exclamation-triangle';
- protected static string|UnitEnum|null $navigationGroup = 'Drift';
+ protected static string|UnitEnum|null $navigationGroup = 'Governance';
protected static ?string $navigationLabel = 'Findings';
diff --git a/app/Filament/Widgets/Dashboard/BaselineCompareNow.php b/app/Filament/Widgets/Dashboard/BaselineCompareNow.php
new file mode 100644
index 0000000..e5f59f3
--- /dev/null
+++ b/app/Filament/Widgets/Dashboard/BaselineCompareNow.php
@@ -0,0 +1,90 @@
+
+ */
+ protected function getViewData(): array
+ {
+ $tenant = Filament::getTenant();
+
+ $empty = [
+ 'hasAssignment' => false,
+ 'profileName' => null,
+ 'findingsCount' => 0,
+ 'highCount' => 0,
+ 'mediumCount' => 0,
+ 'lowCount' => 0,
+ 'lastComparedAt' => null,
+ 'landingUrl' => null,
+ ];
+
+ if (! $tenant instanceof Tenant) {
+ return $empty;
+ }
+
+ $assignment = BaselineTenantAssignment::query()
+ ->where('tenant_id', $tenant->getKey())
+ ->with('baselineProfile')
+ ->first();
+
+ if (! $assignment instanceof BaselineTenantAssignment || $assignment->baselineProfile === null) {
+ return $empty;
+ }
+
+ $profile = $assignment->baselineProfile;
+ $scopeKey = 'baseline_profile:'.$profile->getKey();
+
+ $findingsQuery = Finding::query()
+ ->where('tenant_id', $tenant->getKey())
+ ->where('finding_type', Finding::FINDING_TYPE_DRIFT)
+ ->where('source', 'baseline.compare')
+ ->where('scope_key', $scopeKey)
+ ->where('status', Finding::STATUS_NEW);
+
+ $findingsCount = (int) (clone $findingsQuery)->count();
+ $highCount = (int) (clone $findingsQuery)
+ ->where('severity', Finding::SEVERITY_HIGH)
+ ->count();
+ $mediumCount = (int) (clone $findingsQuery)
+ ->where('severity', Finding::SEVERITY_MEDIUM)
+ ->count();
+ $lowCount = (int) (clone $findingsQuery)
+ ->where('severity', Finding::SEVERITY_LOW)
+ ->count();
+
+ $latestRun = BaselineCompareRun::query()
+ ->where('tenant_id', $tenant->getKey())
+ ->where('baseline_profile_id', $profile->getKey())
+ ->whereNotNull('finished_at')
+ ->latest('finished_at')
+ ->first();
+
+ return [
+ 'hasAssignment' => true,
+ 'profileName' => (string) $profile->name,
+ 'findingsCount' => $findingsCount,
+ 'highCount' => $highCount,
+ 'mediumCount' => $mediumCount,
+ 'lowCount' => $lowCount,
+ 'lastComparedAt' => $latestRun?->finished_at?->diffForHumans(),
+ 'landingUrl' => \App\Filament\Pages\BaselineCompareLanding::getUrl(tenant: $tenant),
+ ];
+ }
+}
diff --git a/app/Jobs/CaptureBaselineSnapshotJob.php b/app/Jobs/CaptureBaselineSnapshotJob.php
new file mode 100644
index 0000000..7909911
--- /dev/null
+++ b/app/Jobs/CaptureBaselineSnapshotJob.php
@@ -0,0 +1,285 @@
+operationRun = $run;
+ }
+
+ /**
+ * @return array
+ */
+ public function middleware(): array
+ {
+ return [new TrackOperationRun];
+ }
+
+ public function handle(
+ BaselineSnapshotIdentity $identity,
+ AuditLogger $auditLogger,
+ OperationRunService $operationRunService,
+ ): void {
+ if (! $this->operationRun instanceof OperationRun) {
+ $this->fail(new RuntimeException('OperationRun context is required for CaptureBaselineSnapshotJob.'));
+
+ return;
+ }
+
+ $context = is_array($this->operationRun->context) ? $this->operationRun->context : [];
+ $profileId = (int) ($context['baseline_profile_id'] ?? 0);
+ $sourceTenantId = (int) ($context['source_tenant_id'] ?? 0);
+
+ $profile = BaselineProfile::query()->find($profileId);
+
+ if (! $profile instanceof BaselineProfile) {
+ throw new RuntimeException("BaselineProfile #{$profileId} not found.");
+ }
+
+ $sourceTenant = Tenant::query()->find($sourceTenantId);
+
+ if (! $sourceTenant instanceof Tenant) {
+ throw new RuntimeException("Source Tenant #{$sourceTenantId} not found.");
+ }
+
+ $initiator = $this->operationRun->user_id
+ ? User::query()->find($this->operationRun->user_id)
+ : null;
+
+ $effectiveScope = BaselineScope::fromJsonb($context['effective_scope'] ?? null);
+
+ $this->auditStarted($auditLogger, $sourceTenant, $profile, $initiator);
+
+ $snapshotItems = $this->collectSnapshotItems($sourceTenant, $effectiveScope, $identity);
+
+ $identityHash = $identity->computeIdentity($snapshotItems);
+
+ $snapshot = $this->findOrCreateSnapshot(
+ $profile,
+ $identityHash,
+ $snapshotItems,
+ );
+
+ $wasNewSnapshot = $snapshot->wasRecentlyCreated;
+
+ if ($profile->status === BaselineProfile::STATUS_ACTIVE) {
+ $profile->update(['active_snapshot_id' => $snapshot->getKey()]);
+ }
+
+ $summaryCounts = [
+ 'total' => count($snapshotItems),
+ 'processed' => count($snapshotItems),
+ 'succeeded' => count($snapshotItems),
+ 'failed' => 0,
+ ];
+
+ $operationRunService->updateRun(
+ $this->operationRun,
+ status: OperationRunStatus::Completed->value,
+ outcome: OperationRunOutcome::Succeeded->value,
+ summaryCounts: $summaryCounts,
+ );
+
+ $updatedContext = is_array($this->operationRun->context) ? $this->operationRun->context : [];
+ $updatedContext['result'] = [
+ 'snapshot_id' => (int) $snapshot->getKey(),
+ 'snapshot_identity_hash' => $identityHash,
+ 'was_new_snapshot' => $wasNewSnapshot,
+ 'items_captured' => count($snapshotItems),
+ ];
+ $this->operationRun->update(['context' => $updatedContext]);
+
+ $this->auditCompleted($auditLogger, $sourceTenant, $profile, $snapshot, $initiator, $snapshotItems);
+ }
+
+ /**
+ * @return array}>
+ */
+ private function collectSnapshotItems(
+ Tenant $sourceTenant,
+ BaselineScope $scope,
+ BaselineSnapshotIdentity $identity,
+ ): array {
+ $query = InventoryItem::query()
+ ->where('tenant_id', $sourceTenant->getKey());
+
+ if (! $scope->isEmpty()) {
+ $query->whereIn('policy_type', $scope->policyTypes);
+ }
+
+ $items = [];
+
+ $query->orderBy('policy_type')
+ ->orderBy('external_id')
+ ->chunk(500, function ($inventoryItems) use (&$items, $identity): void {
+ foreach ($inventoryItems as $inventoryItem) {
+ $metaJsonb = is_array($inventoryItem->meta_jsonb) ? $inventoryItem->meta_jsonb : [];
+ $baselineHash = $identity->hashItemContent($metaJsonb);
+
+ $items[] = [
+ 'subject_type' => 'policy',
+ 'subject_external_id' => (string) $inventoryItem->external_id,
+ 'policy_type' => (string) $inventoryItem->policy_type,
+ 'baseline_hash' => $baselineHash,
+ 'meta_jsonb' => [
+ 'display_name' => $inventoryItem->display_name,
+ 'category' => $inventoryItem->category,
+ 'platform' => $inventoryItem->platform,
+ ],
+ ];
+ }
+ });
+
+ return $items;
+ }
+
+ /**
+ * @param array}> $snapshotItems
+ */
+ private function findOrCreateSnapshot(
+ BaselineProfile $profile,
+ string $identityHash,
+ array $snapshotItems,
+ ): BaselineSnapshot {
+ $existing = BaselineSnapshot::query()
+ ->where('workspace_id', $profile->workspace_id)
+ ->where('baseline_profile_id', $profile->getKey())
+ ->where('snapshot_identity_hash', $identityHash)
+ ->first();
+
+ if ($existing instanceof BaselineSnapshot) {
+ return $existing;
+ }
+
+ $snapshot = BaselineSnapshot::create([
+ 'workspace_id' => (int) $profile->workspace_id,
+ 'baseline_profile_id' => (int) $profile->getKey(),
+ 'snapshot_identity_hash' => $identityHash,
+ 'captured_at' => now(),
+ 'summary_jsonb' => [
+ 'total_items' => count($snapshotItems),
+ 'policy_type_counts' => $this->countByPolicyType($snapshotItems),
+ ],
+ ]);
+
+ foreach (array_chunk($snapshotItems, 100) as $chunk) {
+ $rows = array_map(
+ fn (array $item): array => [
+ 'baseline_snapshot_id' => (int) $snapshot->getKey(),
+ 'subject_type' => $item['subject_type'],
+ 'subject_external_id' => $item['subject_external_id'],
+ 'policy_type' => $item['policy_type'],
+ 'baseline_hash' => $item['baseline_hash'],
+ 'meta_jsonb' => json_encode($item['meta_jsonb']),
+ 'created_at' => now(),
+ 'updated_at' => now(),
+ ],
+ $chunk,
+ );
+
+ BaselineSnapshotItem::insert($rows);
+ }
+
+ return $snapshot;
+ }
+
+ /**
+ * @param array $items
+ * @return array
+ */
+ private function countByPolicyType(array $items): array
+ {
+ $counts = [];
+
+ foreach ($items as $item) {
+ $type = (string) $item['policy_type'];
+ $counts[$type] = ($counts[$type] ?? 0) + 1;
+ }
+
+ ksort($counts);
+
+ return $counts;
+ }
+
+ private function auditStarted(
+ AuditLogger $auditLogger,
+ Tenant $tenant,
+ BaselineProfile $profile,
+ ?User $initiator,
+ ): void {
+ $auditLogger->log(
+ tenant: $tenant,
+ action: 'baseline.capture.started',
+ context: [
+ 'metadata' => [
+ 'operation_run_id' => (int) $this->operationRun->getKey(),
+ 'baseline_profile_id' => (int) $profile->getKey(),
+ 'baseline_profile_name' => (string) $profile->name,
+ ],
+ ],
+ actorId: $initiator?->id,
+ actorEmail: $initiator?->email,
+ actorName: $initiator?->name,
+ resourceType: 'baseline_profile',
+ resourceId: (string) $profile->getKey(),
+ );
+ }
+
+ private function auditCompleted(
+ AuditLogger $auditLogger,
+ Tenant $tenant,
+ BaselineProfile $profile,
+ BaselineSnapshot $snapshot,
+ ?User $initiator,
+ array $snapshotItems,
+ ): void {
+ $auditLogger->log(
+ tenant: $tenant,
+ action: 'baseline.capture.completed',
+ context: [
+ 'metadata' => [
+ 'operation_run_id' => (int) $this->operationRun->getKey(),
+ 'baseline_profile_id' => (int) $profile->getKey(),
+ 'baseline_profile_name' => (string) $profile->name,
+ 'snapshot_id' => (int) $snapshot->getKey(),
+ 'snapshot_identity_hash' => (string) $snapshot->snapshot_identity_hash,
+ 'items_captured' => count($snapshotItems),
+ 'was_new_snapshot' => $snapshot->wasRecentlyCreated,
+ ],
+ ],
+ actorId: $initiator?->id,
+ actorEmail: $initiator?->email,
+ actorName: $initiator?->name,
+ resourceType: 'operation_run',
+ resourceId: (string) $this->operationRun->getKey(),
+ );
+ }
+}
diff --git a/app/Jobs/CompareBaselineToTenantJob.php b/app/Jobs/CompareBaselineToTenantJob.php
new file mode 100644
index 0000000..2d0cb1b
--- /dev/null
+++ b/app/Jobs/CompareBaselineToTenantJob.php
@@ -0,0 +1,394 @@
+operationRun = $run;
+ }
+
+ /**
+ * @return array
+ */
+ public function middleware(): array
+ {
+ return [new TrackOperationRun];
+ }
+
+ public function handle(
+ DriftHasher $driftHasher,
+ BaselineSnapshotIdentity $snapshotIdentity,
+ AuditLogger $auditLogger,
+ OperationRunService $operationRunService,
+ ): void {
+ if (! $this->operationRun instanceof OperationRun) {
+ $this->fail(new RuntimeException('OperationRun context is required for CompareBaselineToTenantJob.'));
+
+ return;
+ }
+
+ $context = is_array($this->operationRun->context) ? $this->operationRun->context : [];
+ $profileId = (int) ($context['baseline_profile_id'] ?? 0);
+ $snapshotId = (int) ($context['baseline_snapshot_id'] ?? 0);
+
+ $profile = BaselineProfile::query()->find($profileId);
+
+ if (! $profile instanceof BaselineProfile) {
+ throw new RuntimeException("BaselineProfile #{$profileId} not found.");
+ }
+
+ $tenant = Tenant::query()->find($this->operationRun->tenant_id);
+
+ if (! $tenant instanceof Tenant) {
+ throw new RuntimeException("Tenant #{$this->operationRun->tenant_id} not found.");
+ }
+
+ $initiator = $this->operationRun->user_id
+ ? User::query()->find($this->operationRun->user_id)
+ : null;
+
+ $effectiveScope = BaselineScope::fromJsonb($context['effective_scope'] ?? null);
+ $scopeKey = 'baseline_profile:' . $profile->getKey();
+
+ $this->auditStarted($auditLogger, $tenant, $profile, $initiator);
+
+ $baselineItems = $this->loadBaselineItems($snapshotId);
+ $currentItems = $this->loadCurrentInventory($tenant, $effectiveScope, $snapshotIdentity);
+
+ $driftResults = $this->computeDrift($baselineItems, $currentItems);
+
+ $upsertedCount = $this->upsertFindings(
+ $driftHasher,
+ $tenant,
+ $profile,
+ $scopeKey,
+ $driftResults,
+ );
+
+ $severityBreakdown = $this->countBySeverity($driftResults);
+
+ $summaryCounts = [
+ 'total' => count($driftResults),
+ 'processed' => count($driftResults),
+ 'succeeded' => $upsertedCount,
+ 'failed' => count($driftResults) - $upsertedCount,
+ 'high' => $severityBreakdown[Finding::SEVERITY_HIGH] ?? 0,
+ 'medium' => $severityBreakdown[Finding::SEVERITY_MEDIUM] ?? 0,
+ 'low' => $severityBreakdown[Finding::SEVERITY_LOW] ?? 0,
+ ];
+
+ $operationRunService->updateRun(
+ $this->operationRun,
+ status: OperationRunStatus::Completed->value,
+ outcome: OperationRunOutcome::Succeeded->value,
+ summaryCounts: $summaryCounts,
+ );
+
+ $updatedContext = is_array($this->operationRun->context) ? $this->operationRun->context : [];
+ $updatedContext['result'] = [
+ 'findings_total' => count($driftResults),
+ 'findings_upserted' => $upsertedCount,
+ 'severity_breakdown' => $severityBreakdown,
+ ];
+ $this->operationRun->update(['context' => $updatedContext]);
+
+ $this->auditCompleted($auditLogger, $tenant, $profile, $initiator, $summaryCounts);
+ }
+
+ /**
+ * Load baseline snapshot items keyed by "policy_type|subject_external_id".
+ *
+ * @return array}>
+ */
+ private function loadBaselineItems(int $snapshotId): array
+ {
+ $items = [];
+
+ BaselineSnapshotItem::query()
+ ->where('baseline_snapshot_id', $snapshotId)
+ ->orderBy('id')
+ ->chunk(500, function ($snapshotItems) use (&$items): void {
+ foreach ($snapshotItems as $item) {
+ $key = $item->policy_type . '|' . $item->subject_external_id;
+ $items[$key] = [
+ 'subject_type' => (string) $item->subject_type,
+ 'subject_external_id' => (string) $item->subject_external_id,
+ 'policy_type' => (string) $item->policy_type,
+ 'baseline_hash' => (string) $item->baseline_hash,
+ 'meta_jsonb' => is_array($item->meta_jsonb) ? $item->meta_jsonb : [],
+ ];
+ }
+ });
+
+ return $items;
+ }
+
+ /**
+ * Load current inventory items keyed by "policy_type|external_id".
+ *
+ * @return array}>
+ */
+ private function loadCurrentInventory(
+ Tenant $tenant,
+ BaselineScope $scope,
+ BaselineSnapshotIdentity $snapshotIdentity,
+ ): array {
+ $query = InventoryItem::query()
+ ->where('tenant_id', $tenant->getKey());
+
+ if (! $scope->isEmpty()) {
+ $query->whereIn('policy_type', $scope->policyTypes);
+ }
+
+ $items = [];
+
+ $query->orderBy('policy_type')
+ ->orderBy('external_id')
+ ->chunk(500, function ($inventoryItems) use (&$items, $snapshotIdentity): void {
+ foreach ($inventoryItems as $inventoryItem) {
+ $metaJsonb = is_array($inventoryItem->meta_jsonb) ? $inventoryItem->meta_jsonb : [];
+ $currentHash = $snapshotIdentity->hashItemContent($metaJsonb);
+
+ $key = $inventoryItem->policy_type . '|' . $inventoryItem->external_id;
+ $items[$key] = [
+ 'subject_external_id' => (string) $inventoryItem->external_id,
+ 'policy_type' => (string) $inventoryItem->policy_type,
+ 'current_hash' => $currentHash,
+ 'meta_jsonb' => [
+ 'display_name' => $inventoryItem->display_name,
+ 'category' => $inventoryItem->category,
+ 'platform' => $inventoryItem->platform,
+ ],
+ ];
+ }
+ });
+
+ return $items;
+ }
+
+ /**
+ * Compare baseline items vs current inventory and produce drift results.
+ *
+ * @param array}> $baselineItems
+ * @param array}> $currentItems
+ * @return array}>
+ */
+ private function computeDrift(array $baselineItems, array $currentItems): array
+ {
+ $drift = [];
+
+ foreach ($baselineItems as $key => $baselineItem) {
+ if (! array_key_exists($key, $currentItems)) {
+ $drift[] = [
+ 'change_type' => 'missing_policy',
+ 'severity' => Finding::SEVERITY_HIGH,
+ 'subject_type' => $baselineItem['subject_type'],
+ 'subject_external_id' => $baselineItem['subject_external_id'],
+ 'policy_type' => $baselineItem['policy_type'],
+ 'baseline_hash' => $baselineItem['baseline_hash'],
+ 'current_hash' => '',
+ 'evidence' => [
+ 'change_type' => 'missing_policy',
+ 'policy_type' => $baselineItem['policy_type'],
+ 'display_name' => $baselineItem['meta_jsonb']['display_name'] ?? null,
+ ],
+ ];
+
+ continue;
+ }
+
+ $currentItem = $currentItems[$key];
+
+ if ($baselineItem['baseline_hash'] !== $currentItem['current_hash']) {
+ $drift[] = [
+ 'change_type' => 'different_version',
+ 'severity' => Finding::SEVERITY_MEDIUM,
+ 'subject_type' => $baselineItem['subject_type'],
+ 'subject_external_id' => $baselineItem['subject_external_id'],
+ 'policy_type' => $baselineItem['policy_type'],
+ 'baseline_hash' => $baselineItem['baseline_hash'],
+ 'current_hash' => $currentItem['current_hash'],
+ 'evidence' => [
+ 'change_type' => 'different_version',
+ 'policy_type' => $baselineItem['policy_type'],
+ 'display_name' => $baselineItem['meta_jsonb']['display_name'] ?? null,
+ 'baseline_hash' => $baselineItem['baseline_hash'],
+ 'current_hash' => $currentItem['current_hash'],
+ ],
+ ];
+ }
+ }
+
+ foreach ($currentItems as $key => $currentItem) {
+ if (! array_key_exists($key, $baselineItems)) {
+ $drift[] = [
+ 'change_type' => 'unexpected_policy',
+ 'severity' => Finding::SEVERITY_LOW,
+ 'subject_type' => 'policy',
+ 'subject_external_id' => $currentItem['subject_external_id'],
+ 'policy_type' => $currentItem['policy_type'],
+ 'baseline_hash' => '',
+ 'current_hash' => $currentItem['current_hash'],
+ 'evidence' => [
+ 'change_type' => 'unexpected_policy',
+ 'policy_type' => $currentItem['policy_type'],
+ 'display_name' => $currentItem['meta_jsonb']['display_name'] ?? null,
+ ],
+ ];
+ }
+ }
+
+ return $drift;
+ }
+
+ /**
+ * Upsert drift findings using stable fingerprints.
+ *
+ * @param array}> $driftResults
+ */
+ private function upsertFindings(
+ DriftHasher $driftHasher,
+ Tenant $tenant,
+ BaselineProfile $profile,
+ string $scopeKey,
+ array $driftResults,
+ ): int {
+ $upsertedCount = 0;
+ $tenantId = (int) $tenant->getKey();
+
+ foreach ($driftResults as $driftItem) {
+ $fingerprint = $driftHasher->fingerprint(
+ tenantId: $tenantId,
+ scopeKey: $scopeKey,
+ subjectType: $driftItem['subject_type'],
+ subjectExternalId: $driftItem['subject_external_id'],
+ changeType: $driftItem['change_type'],
+ baselineHash: $driftItem['baseline_hash'],
+ currentHash: $driftItem['current_hash'],
+ );
+
+ Finding::query()->updateOrCreate(
+ [
+ 'tenant_id' => $tenantId,
+ 'fingerprint' => $fingerprint,
+ ],
+ [
+ 'finding_type' => Finding::FINDING_TYPE_DRIFT,
+ 'source' => 'baseline.compare',
+ 'scope_key' => $scopeKey,
+ 'subject_type' => $driftItem['subject_type'],
+ 'subject_external_id' => $driftItem['subject_external_id'],
+ 'severity' => $driftItem['severity'],
+ 'status' => Finding::STATUS_NEW,
+ 'evidence_jsonb' => $driftItem['evidence'],
+ 'baseline_operation_run_id' => null,
+ 'current_operation_run_id' => (int) $this->operationRun->getKey(),
+ ],
+ );
+
+ $upsertedCount++;
+ }
+
+ return $upsertedCount;
+ }
+
+ /**
+ * @param array $driftResults
+ * @return array
+ */
+ private function countBySeverity(array $driftResults): array
+ {
+ $counts = [];
+
+ foreach ($driftResults as $item) {
+ $severity = $item['severity'];
+ $counts[$severity] = ($counts[$severity] ?? 0) + 1;
+ }
+
+ return $counts;
+ }
+
+ private function auditStarted(
+ AuditLogger $auditLogger,
+ Tenant $tenant,
+ BaselineProfile $profile,
+ ?User $initiator,
+ ): void {
+ $auditLogger->log(
+ tenant: $tenant,
+ action: 'baseline.compare.started',
+ context: [
+ 'metadata' => [
+ 'operation_run_id' => (int) $this->operationRun->getKey(),
+ 'baseline_profile_id' => (int) $profile->getKey(),
+ 'baseline_profile_name' => (string) $profile->name,
+ ],
+ ],
+ actorId: $initiator?->id,
+ actorEmail: $initiator?->email,
+ actorName: $initiator?->name,
+ resourceType: 'baseline_profile',
+ resourceId: (string) $profile->getKey(),
+ );
+ }
+
+ private function auditCompleted(
+ AuditLogger $auditLogger,
+ Tenant $tenant,
+ BaselineProfile $profile,
+ ?User $initiator,
+ array $summaryCounts,
+ ): void {
+ $auditLogger->log(
+ tenant: $tenant,
+ action: 'baseline.compare.completed',
+ context: [
+ 'metadata' => [
+ 'operation_run_id' => (int) $this->operationRun->getKey(),
+ 'baseline_profile_id' => (int) $profile->getKey(),
+ 'baseline_profile_name' => (string) $profile->name,
+ 'findings_total' => $summaryCounts['total'] ?? 0,
+ 'high' => $summaryCounts['high'] ?? 0,
+ 'medium' => $summaryCounts['medium'] ?? 0,
+ 'low' => $summaryCounts['low'] ?? 0,
+ ],
+ ],
+ actorId: $initiator?->id,
+ actorEmail: $initiator?->email,
+ actorName: $initiator?->name,
+ resourceType: 'operation_run',
+ resourceId: (string) $this->operationRun->getKey(),
+ );
+ }
+}
diff --git a/app/Models/BaselineProfile.php b/app/Models/BaselineProfile.php
new file mode 100644
index 0000000..27abca8
--- /dev/null
+++ b/app/Models/BaselineProfile.php
@@ -0,0 +1,50 @@
+ 'array',
+ ];
+
+ public function workspace(): BelongsTo
+ {
+ return $this->belongsTo(Workspace::class);
+ }
+
+ public function createdByUser(): BelongsTo
+ {
+ return $this->belongsTo(User::class, 'created_by_user_id');
+ }
+
+ public function activeSnapshot(): BelongsTo
+ {
+ return $this->belongsTo(BaselineSnapshot::class, 'active_snapshot_id');
+ }
+
+ public function snapshots(): HasMany
+ {
+ return $this->hasMany(BaselineSnapshot::class);
+ }
+
+ public function tenantAssignments(): HasMany
+ {
+ return $this->hasMany(BaselineTenantAssignment::class);
+ }
+}
diff --git a/app/Models/BaselineSnapshot.php b/app/Models/BaselineSnapshot.php
new file mode 100644
index 0000000..70aa411
--- /dev/null
+++ b/app/Models/BaselineSnapshot.php
@@ -0,0 +1,35 @@
+ 'array',
+ 'captured_at' => 'datetime',
+ ];
+
+ public function workspace(): BelongsTo
+ {
+ return $this->belongsTo(Workspace::class);
+ }
+
+ public function baselineProfile(): BelongsTo
+ {
+ return $this->belongsTo(BaselineProfile::class);
+ }
+
+ public function items(): HasMany
+ {
+ return $this->hasMany(BaselineSnapshotItem::class);
+ }
+}
diff --git a/app/Models/BaselineSnapshotItem.php b/app/Models/BaselineSnapshotItem.php
new file mode 100644
index 0000000..29f1172
--- /dev/null
+++ b/app/Models/BaselineSnapshotItem.php
@@ -0,0 +1,23 @@
+ 'array',
+ ];
+
+ public function snapshot(): BelongsTo
+ {
+ return $this->belongsTo(BaselineSnapshot::class, 'baseline_snapshot_id');
+ }
+}
diff --git a/app/Models/BaselineTenantAssignment.php b/app/Models/BaselineTenantAssignment.php
new file mode 100644
index 0000000..14a42bc
--- /dev/null
+++ b/app/Models/BaselineTenantAssignment.php
@@ -0,0 +1,40 @@
+ 'array',
+ ];
+
+ public function workspace(): BelongsTo
+ {
+ return $this->belongsTo(Workspace::class);
+ }
+
+ public function tenant(): BelongsTo
+ {
+ return $this->belongsTo(Tenant::class);
+ }
+
+ public function baselineProfile(): BelongsTo
+ {
+ return $this->belongsTo(BaselineProfile::class);
+ }
+
+ public function assignedByUser(): BelongsTo
+ {
+ return $this->belongsTo(User::class, 'assigned_by_user_id');
+ }
+}
diff --git a/app/Providers/Filament/AdminPanelProvider.php b/app/Providers/Filament/AdminPanelProvider.php
index 5196825..4a2d9be 100644
--- a/app/Providers/Filament/AdminPanelProvider.php
+++ b/app/Providers/Filament/AdminPanelProvider.php
@@ -12,6 +12,7 @@
use App\Filament\Resources\AlertDeliveryResource;
use App\Filament\Resources\AlertDestinationResource;
use App\Filament\Resources\AlertRuleResource;
+use App\Filament\Resources\BaselineProfileResource;
use App\Filament\Resources\InventoryItemResource;
use App\Filament\Resources\PolicyResource;
use App\Filament\Resources\ProviderConnectionResource;
@@ -151,6 +152,7 @@ public function panel(Panel $panel): Panel
AlertRuleResource::class,
AlertDeliveryResource::class,
WorkspaceResource::class,
+ BaselineProfileResource::class,
])
->discoverClusters(in: app_path('Filament/Clusters'), for: 'App\\Filament\\Clusters')
->discoverResources(in: app_path('Filament/Resources'), for: 'App\\Filament\\Resources')
diff --git a/app/Services/Auth/WorkspaceRoleCapabilityMap.php b/app/Services/Auth/WorkspaceRoleCapabilityMap.php
index 3257ee8..796fbc3 100644
--- a/app/Services/Auth/WorkspaceRoleCapabilityMap.php
+++ b/app/Services/Auth/WorkspaceRoleCapabilityMap.php
@@ -36,6 +36,8 @@ class WorkspaceRoleCapabilityMap
Capabilities::WORKSPACE_SETTINGS_MANAGE,
Capabilities::ALERTS_VIEW,
Capabilities::ALERTS_MANAGE,
+ Capabilities::WORKSPACE_BASELINES_VIEW,
+ Capabilities::WORKSPACE_BASELINES_MANAGE,
],
WorkspaceRole::Manager->value => [
@@ -54,6 +56,8 @@ class WorkspaceRoleCapabilityMap
Capabilities::WORKSPACE_SETTINGS_MANAGE,
Capabilities::ALERTS_VIEW,
Capabilities::ALERTS_MANAGE,
+ Capabilities::WORKSPACE_BASELINES_VIEW,
+ Capabilities::WORKSPACE_BASELINES_MANAGE,
],
WorkspaceRole::Operator->value => [
@@ -66,12 +70,14 @@ class WorkspaceRoleCapabilityMap
Capabilities::WORKSPACE_MANAGED_TENANT_ONBOARD_BOOTSTRAP_BACKUP_BOOTSTRAP,
Capabilities::WORKSPACE_SETTINGS_VIEW,
Capabilities::ALERTS_VIEW,
+ Capabilities::WORKSPACE_BASELINES_VIEW,
],
WorkspaceRole::Readonly->value => [
Capabilities::WORKSPACE_VIEW,
Capabilities::WORKSPACE_SETTINGS_VIEW,
Capabilities::ALERTS_VIEW,
+ Capabilities::WORKSPACE_BASELINES_VIEW,
],
];
diff --git a/app/Services/Baselines/BaselineCaptureService.php b/app/Services/Baselines/BaselineCaptureService.php
new file mode 100644
index 0000000..f596176
--- /dev/null
+++ b/app/Services/Baselines/BaselineCaptureService.php
@@ -0,0 +1,79 @@
+validatePreconditions($profile, $sourceTenant);
+
+ if ($precondition !== null) {
+ return ['ok' => false, 'reason_code' => $precondition];
+ }
+
+ $effectiveScope = BaselineScope::fromJsonb(
+ is_array($profile->scope_jsonb) ? $profile->scope_jsonb : null,
+ );
+
+ $context = [
+ 'baseline_profile_id' => (int) $profile->getKey(),
+ 'source_tenant_id' => (int) $sourceTenant->getKey(),
+ 'effective_scope' => $effectiveScope->toJsonb(),
+ ];
+
+ $run = $this->runs->ensureRunWithIdentity(
+ tenant: $sourceTenant,
+ type: 'baseline_capture',
+ identityInputs: [
+ 'baseline_profile_id' => (int) $profile->getKey(),
+ ],
+ context: $context,
+ initiator: $initiator,
+ );
+
+ if ($run->wasRecentlyCreated) {
+ CaptureBaselineSnapshotJob::dispatch($run);
+ }
+
+ return ['ok' => true, 'run' => $run];
+ }
+
+ private function validatePreconditions(BaselineProfile $profile, Tenant $sourceTenant): ?string
+ {
+ if ($profile->status !== BaselineProfile::STATUS_ACTIVE) {
+ return BaselineReasonCodes::CAPTURE_PROFILE_NOT_ACTIVE;
+ }
+
+ if ($sourceTenant->workspace_id === null) {
+ return BaselineReasonCodes::CAPTURE_MISSING_SOURCE_TENANT;
+ }
+
+ if ((int) $sourceTenant->workspace_id !== (int) $profile->workspace_id) {
+ return BaselineReasonCodes::CAPTURE_MISSING_SOURCE_TENANT;
+ }
+
+ return null;
+ }
+}
diff --git a/app/Services/Baselines/BaselineCompareService.php b/app/Services/Baselines/BaselineCompareService.php
new file mode 100644
index 0000000..a4cc940
--- /dev/null
+++ b/app/Services/Baselines/BaselineCompareService.php
@@ -0,0 +1,97 @@
+where('workspace_id', $tenant->workspace_id)
+ ->where('tenant_id', $tenant->getKey())
+ ->first();
+
+ if (! $assignment instanceof BaselineTenantAssignment) {
+ return ['ok' => false, 'reason_code' => BaselineReasonCodes::COMPARE_NO_ASSIGNMENT];
+ }
+
+ $profile = BaselineProfile::query()->find($assignment->baseline_profile_id);
+
+ if (! $profile instanceof BaselineProfile) {
+ return ['ok' => false, 'reason_code' => BaselineReasonCodes::COMPARE_PROFILE_NOT_ACTIVE];
+ }
+
+ $precondition = $this->validatePreconditions($profile);
+
+ if ($precondition !== null) {
+ return ['ok' => false, 'reason_code' => $precondition];
+ }
+
+ $snapshotId = (int) $profile->active_snapshot_id;
+
+ $profileScope = BaselineScope::fromJsonb(
+ is_array($profile->scope_jsonb) ? $profile->scope_jsonb : null,
+ );
+ $overrideScope = $assignment->override_scope_jsonb !== null
+ ? BaselineScope::fromJsonb(is_array($assignment->override_scope_jsonb) ? $assignment->override_scope_jsonb : null)
+ : null;
+
+ $effectiveScope = BaselineScope::effective($profileScope, $overrideScope);
+
+ $context = [
+ 'baseline_profile_id' => (int) $profile->getKey(),
+ 'baseline_snapshot_id' => $snapshotId,
+ 'effective_scope' => $effectiveScope->toJsonb(),
+ ];
+
+ $run = $this->runs->ensureRunWithIdentity(
+ tenant: $tenant,
+ type: 'baseline_compare',
+ identityInputs: [
+ 'baseline_profile_id' => (int) $profile->getKey(),
+ ],
+ context: $context,
+ initiator: $initiator,
+ );
+
+ if ($run->wasRecentlyCreated) {
+ CompareBaselineToTenantJob::dispatch($run);
+ }
+
+ return ['ok' => true, 'run' => $run];
+ }
+
+ private function validatePreconditions(BaselineProfile $profile): ?string
+ {
+ if ($profile->status !== BaselineProfile::STATUS_ACTIVE) {
+ return BaselineReasonCodes::COMPARE_PROFILE_NOT_ACTIVE;
+ }
+
+ if ($profile->active_snapshot_id === null) {
+ return BaselineReasonCodes::COMPARE_NO_ACTIVE_SNAPSHOT;
+ }
+
+ return null;
+ }
+}
diff --git a/app/Services/Baselines/BaselineSnapshotIdentity.php b/app/Services/Baselines/BaselineSnapshotIdentity.php
new file mode 100644
index 0000000..c761f92
--- /dev/null
+++ b/app/Services/Baselines/BaselineSnapshotIdentity.php
@@ -0,0 +1,59 @@
+ $items
+ */
+ public function computeIdentity(array $items): string
+ {
+ if ($items === []) {
+ return hash('sha256', '[]');
+ }
+
+ $normalized = array_map(
+ fn (array $item): string => implode('|', [
+ trim((string) ($item['subject_type'] ?? '')),
+ trim((string) ($item['subject_external_id'] ?? '')),
+ trim((string) ($item['policy_type'] ?? '')),
+ trim((string) ($item['baseline_hash'] ?? '')),
+ ]),
+ $items,
+ );
+
+ sort($normalized, SORT_STRING);
+
+ return hash('sha256', implode("\n", $normalized));
+ }
+
+ /**
+ * Compute a stable content hash for a single inventory item's metadata.
+ *
+ * Strips volatile OData keys and normalizes for stable comparison.
+ */
+ public function hashItemContent(mixed $metaJsonb): string
+ {
+ return $this->hasher->hashNormalized($metaJsonb);
+ }
+}
diff --git a/app/Support/Audit/AuditActionId.php b/app/Support/Audit/AuditActionId.php
index 6a10bfb..fd6d8a6 100644
--- a/app/Support/Audit/AuditActionId.php
+++ b/app/Support/Audit/AuditActionId.php
@@ -46,4 +46,17 @@ enum AuditActionId: string
case WorkspaceSettingUpdated = 'workspace_setting.updated';
case WorkspaceSettingReset = 'workspace_setting.reset';
+
+ case BaselineProfileCreated = 'baseline_profile.created';
+ case BaselineProfileUpdated = 'baseline_profile.updated';
+ case BaselineProfileArchived = 'baseline_profile.archived';
+ case BaselineCaptureStarted = 'baseline_capture.started';
+ case BaselineCaptureCompleted = 'baseline_capture.completed';
+ case BaselineCaptureFailed = 'baseline_capture.failed';
+ case BaselineCompareStarted = 'baseline_compare.started';
+ case BaselineCompareCompleted = 'baseline_compare.completed';
+ case BaselineCompareFailed = 'baseline_compare.failed';
+ case BaselineAssignmentCreated = 'baseline_assignment.created';
+ case BaselineAssignmentUpdated = 'baseline_assignment.updated';
+ case BaselineAssignmentDeleted = 'baseline_assignment.deleted';
}
diff --git a/app/Support/Auth/Capabilities.php b/app/Support/Auth/Capabilities.php
index 1046dd4..033e3ee 100644
--- a/app/Support/Auth/Capabilities.php
+++ b/app/Support/Auth/Capabilities.php
@@ -96,6 +96,11 @@ class Capabilities
public const PROVIDER_RUN = 'provider.run';
+ // Workspace baselines (Golden Master governance)
+ public const WORKSPACE_BASELINES_VIEW = 'workspace_baselines.view';
+
+ public const WORKSPACE_BASELINES_MANAGE = 'workspace_baselines.manage';
+
// Audit
public const AUDIT_VIEW = 'audit.view';
diff --git a/app/Support/Badges/BadgeCatalog.php b/app/Support/Badges/BadgeCatalog.php
index e32fde5..6481460 100644
--- a/app/Support/Badges/BadgeCatalog.php
+++ b/app/Support/Badges/BadgeCatalog.php
@@ -39,6 +39,7 @@ final class BadgeCatalog
BadgeDomain::VerificationReportOverall->value => Domains\VerificationReportOverallBadge::class,
BadgeDomain::AlertDeliveryStatus->value => Domains\AlertDeliveryStatusBadge::class,
BadgeDomain::AlertDestinationLastTestStatus->value => Domains\AlertDestinationLastTestStatusBadge::class,
+ BadgeDomain::BaselineProfileStatus->value => Domains\BaselineProfileStatusBadge::class,
];
/**
diff --git a/app/Support/Badges/BadgeDomain.php b/app/Support/Badges/BadgeDomain.php
index 9ce4b34..6f8db9b 100644
--- a/app/Support/Badges/BadgeDomain.php
+++ b/app/Support/Badges/BadgeDomain.php
@@ -31,4 +31,5 @@ enum BadgeDomain: string
case VerificationReportOverall = 'verification_report_overall';
case AlertDeliveryStatus = 'alert_delivery_status';
case AlertDestinationLastTestStatus = 'alert_destination_last_test_status';
+ case BaselineProfileStatus = 'baseline_profile_status';
}
diff --git a/app/Support/Badges/Domains/BaselineProfileStatusBadge.php b/app/Support/Badges/Domains/BaselineProfileStatusBadge.php
new file mode 100644
index 0000000..bdc2945
--- /dev/null
+++ b/app/Support/Badges/Domains/BaselineProfileStatusBadge.php
@@ -0,0 +1,25 @@
+ new BadgeSpec('Draft', 'gray', 'heroicon-m-pencil-square'),
+ BaselineProfile::STATUS_ACTIVE => new BadgeSpec('Active', 'success', 'heroicon-m-check-circle'),
+ BaselineProfile::STATUS_ARCHIVED => new BadgeSpec('Archived', 'warning', 'heroicon-m-archive-box'),
+ default => BadgeSpec::unknown(),
+ };
+ }
+}
diff --git a/app/Support/Baselines/BaselineReasonCodes.php b/app/Support/Baselines/BaselineReasonCodes.php
new file mode 100644
index 0000000..0dabf27
--- /dev/null
+++ b/app/Support/Baselines/BaselineReasonCodes.php
@@ -0,0 +1,24 @@
+ $policyTypes
+ */
+ public function __construct(
+ public readonly array $policyTypes = [],
+ ) {}
+
+ /**
+ * Create from the scope_jsonb column value.
+ *
+ * @param array|null $scopeJsonb
+ */
+ public static function fromJsonb(?array $scopeJsonb): self
+ {
+ if ($scopeJsonb === null) {
+ return new self;
+ }
+
+ $policyTypes = $scopeJsonb['policy_types'] ?? [];
+
+ return new self(
+ policyTypes: is_array($policyTypes) ? array_values(array_filter($policyTypes, 'is_string')) : [],
+ );
+ }
+
+ /**
+ * Normalize the effective scope by intersecting profile scope with an optional override.
+ *
+ * Override can only narrow the profile scope (subset enforcement).
+ * If the profile scope is empty (all types), the override becomes the effective scope.
+ * If the override is empty or null, the profile scope is used as-is.
+ */
+ public static function effective(self $profileScope, ?self $overrideScope): self
+ {
+ if ($overrideScope === null || $overrideScope->isEmpty()) {
+ return $profileScope;
+ }
+
+ if ($profileScope->isEmpty()) {
+ return $overrideScope;
+ }
+
+ $intersected = array_values(array_intersect($profileScope->policyTypes, $overrideScope->policyTypes));
+
+ return new self(policyTypes: $intersected);
+ }
+
+ /**
+ * An empty scope means "all types".
+ */
+ public function isEmpty(): bool
+ {
+ return $this->policyTypes === [];
+ }
+
+ /**
+ * Check if a policy type is included in this scope.
+ */
+ public function includes(string $policyType): bool
+ {
+ if ($this->isEmpty()) {
+ return true;
+ }
+
+ return in_array($policyType, $this->policyTypes, true);
+ }
+
+ /**
+ * Validate that override is a subset of the profile scope.
+ */
+ public static function isValidOverride(self $profileScope, self $overrideScope): bool
+ {
+ if ($overrideScope->isEmpty()) {
+ return true;
+ }
+
+ if ($profileScope->isEmpty()) {
+ return true;
+ }
+
+ foreach ($overrideScope->policyTypes as $type) {
+ if (! in_array($type, $profileScope->policyTypes, true)) {
+ return false;
+ }
+ }
+
+ return true;
+ }
+
+ /**
+ * @return array
+ */
+ public function toJsonb(): array
+ {
+ return [
+ 'policy_types' => $this->policyTypes,
+ ];
+ }
+}
diff --git a/app/Support/OperationCatalog.php b/app/Support/OperationCatalog.php
index c7ab739..a99b234 100644
--- a/app/Support/OperationCatalog.php
+++ b/app/Support/OperationCatalog.php
@@ -45,6 +45,8 @@ public static function labels(): array
'policy_version.force_delete' => 'Delete policy versions',
'alerts.evaluate' => 'Alerts evaluation',
'alerts.deliver' => 'Alerts delivery',
+ 'baseline_capture' => 'Baseline capture',
+ 'baseline_compare' => 'Baseline compare',
];
}
@@ -72,6 +74,8 @@ public static function expectedDurationSeconds(string $operationType): ?int
'assignments.fetch', 'assignments.restore' => 60,
'ops.reconcile_adapter_runs' => 120,
'alerts.evaluate', 'alerts.deliver' => 120,
+ 'baseline_capture' => 120,
+ 'baseline_compare' => 120,
default => null,
};
}
diff --git a/app/Support/OpsUx/OperationSummaryKeys.php b/app/Support/OpsUx/OperationSummaryKeys.php
index 0892dd6..7452a1f 100644
--- a/app/Support/OpsUx/OperationSummaryKeys.php
+++ b/app/Support/OpsUx/OperationSummaryKeys.php
@@ -24,6 +24,9 @@ public static function all(): array
'deleted',
'items',
'tenants',
+ 'high',
+ 'medium',
+ 'low',
];
}
}
diff --git a/boost.json b/boost.json
index 0a16d2e..c2de208 100644
--- a/boost.json
+++ b/boost.json
@@ -5,12 +5,12 @@
"gemini",
"opencode"
],
- "editors": [
- "codex",
- "gemini",
- "opencode",
- "vscode"
- ],
- "guidelines": [],
- "sail": true
+ "guidelines": true,
+ "herd_mcp": false,
+ "mcp": true,
+ "sail": true,
+ "skills": [
+ "pest-testing",
+ "tailwindcss-development"
+ ]
}
diff --git a/composer.json b/composer.json
index e883eae..8c2ca94 100644
--- a/composer.json
+++ b/composer.json
@@ -17,7 +17,7 @@
"require-dev": {
"barryvdh/laravel-debugbar": "^3.16",
"fakerphp/faker": "^1.23",
- "laravel/boost": "^1.8",
+ "laravel/boost": "^2.1",
"laravel/pail": "^1.2.2",
"laravel/pint": "^1.24",
"laravel/sail": "^1.41",
diff --git a/composer.lock b/composer.lock
index 38d1177..74a276e 100644
--- a/composer.lock
+++ b/composer.lock
@@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically"
],
- "content-hash": "f1060097603f7d29074d1b2a1dd53bf8",
+ "content-hash": "83257f7b8eac0f483898bdf2d4adfb7e",
"packages": [
{
"name": "anourvalar/eloquent-serialize",
@@ -9312,33 +9312,33 @@
},
{
"name": "laravel/boost",
- "version": "v1.8.10",
+ "version": "v2.1.2",
"source": {
"type": "git",
"url": "https://github.com/laravel/boost.git",
- "reference": "aad8b2a423b0a886c2ce7ee92abbfde69992ff32"
+ "reference": "81ecf79e82c979efd92afaeac012605cc7b2f31f"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/laravel/boost/zipball/aad8b2a423b0a886c2ce7ee92abbfde69992ff32",
- "reference": "aad8b2a423b0a886c2ce7ee92abbfde69992ff32",
+ "url": "https://api.github.com/repos/laravel/boost/zipball/81ecf79e82c979efd92afaeac012605cc7b2f31f",
+ "reference": "81ecf79e82c979efd92afaeac012605cc7b2f31f",
"shasum": ""
},
"require": {
"guzzlehttp/guzzle": "^7.9",
- "illuminate/console": "^10.49.0|^11.45.3|^12.41.1",
- "illuminate/contracts": "^10.49.0|^11.45.3|^12.41.1",
- "illuminate/routing": "^10.49.0|^11.45.3|^12.41.1",
- "illuminate/support": "^10.49.0|^11.45.3|^12.41.1",
+ "illuminate/console": "^11.45.3|^12.41.1",
+ "illuminate/contracts": "^11.45.3|^12.41.1",
+ "illuminate/routing": "^11.45.3|^12.41.1",
+ "illuminate/support": "^11.45.3|^12.41.1",
"laravel/mcp": "^0.5.1",
- "laravel/prompts": "0.1.25|^0.3.6",
+ "laravel/prompts": "^0.3.10",
"laravel/roster": "^0.2.9",
- "php": "^8.1"
+ "php": "^8.2"
},
"require-dev": {
- "laravel/pint": "^1.20.0",
+ "laravel/pint": "^1.27.0",
"mockery/mockery": "^1.6.12",
- "orchestra/testbench": "^8.36.0|^9.15.0|^10.6",
+ "orchestra/testbench": "^9.15.0|^10.6",
"pestphp/pest": "^2.36.0|^3.8.4|^4.1.5",
"phpstan/phpstan": "^2.1.27",
"rector/rector": "^2.1"
@@ -9374,7 +9374,7 @@
"issues": "https://github.com/laravel/boost/issues",
"source": "https://github.com/laravel/boost"
},
- "time": "2026-01-14T14:51:16+00:00"
+ "time": "2026-02-10T17:40:45+00:00"
},
{
"name": "laravel/mcp",
@@ -12482,5 +12482,5 @@
"php": "^8.2"
},
"platform-dev": {},
- "plugin-api-version": "2.9.0"
+ "plugin-api-version": "2.6.0"
}
diff --git a/database/factories/BaselineProfileFactory.php b/database/factories/BaselineProfileFactory.php
new file mode 100644
index 0000000..a9ae350
--- /dev/null
+++ b/database/factories/BaselineProfileFactory.php
@@ -0,0 +1,61 @@
+
+ */
+class BaselineProfileFactory extends Factory
+{
+ protected $model = BaselineProfile::class;
+
+ /**
+ * @return array
+ */
+ public function definition(): array
+ {
+ return [
+ 'workspace_id' => Workspace::factory(),
+ 'name' => fake()->unique()->words(3, true),
+ 'description' => fake()->optional()->sentence(),
+ 'version_label' => fake()->optional()->numerify('v#.#'),
+ 'status' => BaselineProfile::STATUS_DRAFT,
+ 'scope_jsonb' => ['policy_types' => []],
+ 'active_snapshot_id' => null,
+ 'created_by_user_id' => null,
+ ];
+ }
+
+ public function active(): static
+ {
+ return $this->state(fn (): array => [
+ 'status' => BaselineProfile::STATUS_ACTIVE,
+ ]);
+ }
+
+ public function archived(): static
+ {
+ return $this->state(fn (): array => [
+ 'status' => BaselineProfile::STATUS_ARCHIVED,
+ ]);
+ }
+
+ public function withScope(array $policyTypes): static
+ {
+ return $this->state(fn (): array => [
+ 'scope_jsonb' => ['policy_types' => $policyTypes],
+ ]);
+ }
+
+ public function createdBy(User $user): static
+ {
+ return $this->state(fn (): array => [
+ 'created_by_user_id' => $user->getKey(),
+ ]);
+ }
+}
diff --git a/database/factories/BaselineSnapshotFactory.php b/database/factories/BaselineSnapshotFactory.php
new file mode 100644
index 0000000..dae6764
--- /dev/null
+++ b/database/factories/BaselineSnapshotFactory.php
@@ -0,0 +1,30 @@
+
+ */
+class BaselineSnapshotFactory extends Factory
+{
+ protected $model = BaselineSnapshot::class;
+
+ /**
+ * @return array
+ */
+ public function definition(): array
+ {
+ return [
+ 'workspace_id' => Workspace::factory(),
+ 'baseline_profile_id' => BaselineProfile::factory(),
+ 'snapshot_identity_hash' => hash('sha256', fake()->uuid()),
+ 'captured_at' => now(),
+ 'summary_jsonb' => ['total_items' => 0],
+ ];
+ }
+}
diff --git a/database/factories/BaselineSnapshotItemFactory.php b/database/factories/BaselineSnapshotItemFactory.php
new file mode 100644
index 0000000..4c2216d
--- /dev/null
+++ b/database/factories/BaselineSnapshotItemFactory.php
@@ -0,0 +1,30 @@
+
+ */
+class BaselineSnapshotItemFactory extends Factory
+{
+ protected $model = BaselineSnapshotItem::class;
+
+ /**
+ * @return array
+ */
+ public function definition(): array
+ {
+ return [
+ 'baseline_snapshot_id' => BaselineSnapshot::factory(),
+ 'subject_type' => 'policy',
+ 'subject_external_id' => fake()->uuid(),
+ 'policy_type' => 'deviceConfiguration',
+ 'baseline_hash' => hash('sha256', fake()->uuid()),
+ 'meta_jsonb' => ['display_name' => fake()->words(3, true)],
+ ];
+ }
+}
diff --git a/database/factories/BaselineTenantAssignmentFactory.php b/database/factories/BaselineTenantAssignmentFactory.php
new file mode 100644
index 0000000..287d2d4
--- /dev/null
+++ b/database/factories/BaselineTenantAssignmentFactory.php
@@ -0,0 +1,31 @@
+
+ */
+class BaselineTenantAssignmentFactory extends Factory
+{
+ protected $model = BaselineTenantAssignment::class;
+
+ /**
+ * @return array
+ */
+ public function definition(): array
+ {
+ return [
+ 'workspace_id' => Workspace::factory(),
+ 'tenant_id' => Tenant::factory(),
+ 'baseline_profile_id' => BaselineProfile::factory(),
+ 'override_scope_jsonb' => null,
+ 'assigned_by_user_id' => null,
+ ];
+ }
+}
diff --git a/database/migrations/2026_02_19_100001_create_baseline_profiles_table.php b/database/migrations/2026_02_19_100001_create_baseline_profiles_table.php
new file mode 100644
index 0000000..2bcf26e
--- /dev/null
+++ b/database/migrations/2026_02_19_100001_create_baseline_profiles_table.php
@@ -0,0 +1,32 @@
+id();
+ $table->foreignId('workspace_id')->constrained('workspaces')->cascadeOnDelete();
+ $table->string('name');
+ $table->text('description')->nullable();
+ $table->string('version_label')->nullable();
+ $table->string('status')->default('draft');
+ $table->jsonb('scope_jsonb');
+ $table->unsignedBigInteger('active_snapshot_id')->nullable();
+ $table->foreignId('created_by_user_id')->nullable()->constrained('users')->nullOnDelete();
+ $table->timestamps();
+
+ $table->index(['workspace_id', 'status']);
+ $table->unique(['workspace_id', 'name']);
+ });
+ }
+
+ public function down(): void
+ {
+ Schema::dropIfExists('baseline_profiles');
+ }
+};
diff --git a/database/migrations/2026_02_19_100002_create_baseline_snapshots_table.php b/database/migrations/2026_02_19_100002_create_baseline_snapshots_table.php
new file mode 100644
index 0000000..ab9d776
--- /dev/null
+++ b/database/migrations/2026_02_19_100002_create_baseline_snapshots_table.php
@@ -0,0 +1,40 @@
+id();
+ $table->foreignId('workspace_id')->constrained('workspaces')->cascadeOnDelete();
+ $table->foreignId('baseline_profile_id')->constrained('baseline_profiles')->cascadeOnDelete();
+ $table->string('snapshot_identity_hash', 64);
+ $table->timestampTz('captured_at');
+ $table->jsonb('summary_jsonb')->nullable();
+ $table->timestamps();
+
+ $table->unique(['workspace_id', 'baseline_profile_id', 'snapshot_identity_hash'], 'baseline_snapshots_dedupe_unique');
+ $table->index(['workspace_id', 'baseline_profile_id', 'captured_at'], 'baseline_snapshots_lookup_idx');
+ });
+
+ Schema::table('baseline_profiles', function (Blueprint $table): void {
+ $table->foreign('active_snapshot_id')
+ ->references('id')
+ ->on('baseline_snapshots')
+ ->nullOnDelete();
+ });
+ }
+
+ public function down(): void
+ {
+ Schema::table('baseline_profiles', function (Blueprint $table): void {
+ $table->dropForeign(['active_snapshot_id']);
+ });
+
+ Schema::dropIfExists('baseline_snapshots');
+ }
+};
diff --git a/database/migrations/2026_02_19_100003_create_baseline_snapshot_items_table.php b/database/migrations/2026_02_19_100003_create_baseline_snapshot_items_table.php
new file mode 100644
index 0000000..ef6df08
--- /dev/null
+++ b/database/migrations/2026_02_19_100003_create_baseline_snapshot_items_table.php
@@ -0,0 +1,33 @@
+id();
+ $table->foreignId('baseline_snapshot_id')->constrained('baseline_snapshots')->cascadeOnDelete();
+ $table->string('subject_type');
+ $table->string('subject_external_id');
+ $table->string('policy_type');
+ $table->string('baseline_hash', 64);
+ $table->jsonb('meta_jsonb')->nullable();
+ $table->timestamps();
+
+ $table->unique(
+ ['baseline_snapshot_id', 'subject_type', 'subject_external_id'],
+ 'baseline_snapshot_items_subject_unique'
+ );
+ $table->index(['baseline_snapshot_id', 'policy_type'], 'baseline_snapshot_items_policy_type_idx');
+ });
+ }
+
+ public function down(): void
+ {
+ Schema::dropIfExists('baseline_snapshot_items');
+ }
+};
diff --git a/database/migrations/2026_02_19_100004_create_baseline_tenant_assignments_table.php b/database/migrations/2026_02_19_100004_create_baseline_tenant_assignments_table.php
new file mode 100644
index 0000000..dcdd6bc
--- /dev/null
+++ b/database/migrations/2026_02_19_100004_create_baseline_tenant_assignments_table.php
@@ -0,0 +1,29 @@
+id();
+ $table->foreignId('workspace_id')->constrained('workspaces')->cascadeOnDelete();
+ $table->foreignId('tenant_id')->constrained('tenants')->cascadeOnDelete();
+ $table->foreignId('baseline_profile_id')->constrained('baseline_profiles')->cascadeOnDelete();
+ $table->jsonb('override_scope_jsonb')->nullable();
+ $table->foreignId('assigned_by_user_id')->nullable()->constrained('users')->nullOnDelete();
+ $table->timestamps();
+
+ $table->unique(['workspace_id', 'tenant_id']);
+ $table->index(['workspace_id', 'baseline_profile_id']);
+ });
+ }
+
+ public function down(): void
+ {
+ Schema::dropIfExists('baseline_tenant_assignments');
+ }
+};
diff --git a/database/migrations/2026_02_19_100005_add_source_to_findings_table.php b/database/migrations/2026_02_19_100005_add_source_to_findings_table.php
new file mode 100644
index 0000000..5071b39
--- /dev/null
+++ b/database/migrations/2026_02_19_100005_add_source_to_findings_table.php
@@ -0,0 +1,24 @@
+string('source')->nullable()->after('finding_type');
+ $table->index(['tenant_id', 'source']);
+ });
+ }
+
+ public function down(): void
+ {
+ Schema::table('findings', function (Blueprint $table): void {
+ $table->dropIndex(['tenant_id', 'source']);
+ $table->dropColumn('source');
+ });
+ }
+};
diff --git a/resources/views/filament/pages/baseline-compare-landing.blade.php b/resources/views/filament/pages/baseline-compare-landing.blade.php
new file mode 100644
index 0000000..6edc020
--- /dev/null
+++ b/resources/views/filament/pages/baseline-compare-landing.blade.php
@@ -0,0 +1,188 @@
+
+ {{-- Row 1: Stats Overview --}}
+ @if (in_array($state, ['ready', 'idle', 'comparing']))
+
+ {{-- Stat: Assigned Baseline --}}
+
+
+
Assigned Baseline
+
{{ $profileName ?? '—' }}
+ @if ($snapshotId)
+
+ Snapshot #{{ $snapshotId }}
+
+ @endif
+
+
+
+ {{-- Stat: Total Findings --}}
+
+
+
Total Findings
+
+ {{ $findingsCount ?? 0 }}
+
+ @if ($state === 'comparing')
+
+
+ Comparing…
+
+ @elseif (($findingsCount ?? 0) === 0 && $state === 'ready')
+
All clear
+ @endif
+
+
+
+ {{-- Stat: Last Compared --}}
+
+
+
Last Compared
+
+ {{ $lastComparedAt ?? 'Never' }}
+
+ @if ($this->getRunUrl())
+
+ View run
+
+ @endif
+
+
+
+ @endif
+
+ {{-- Critical drift banner --}}
+ @if ($state === 'ready' && ($severityCounts['high'] ?? 0) > 0)
+
+
+
+
+
+ Critical Drift Detected
+
+
+ The current tenant state deviates from baseline {{ $profileName }}.
+ {{ $severityCounts['high'] }} high-severity {{ Str::plural('finding', $severityCounts['high']) }} require immediate attention.
+
+
+
+
+ @endif
+
+ {{-- State: No tenant / no assignment / no snapshot --}}
+ @if (in_array($state, ['no_tenant', 'no_assignment', 'no_snapshot']))
+
+
+ @if ($state === 'no_tenant')
+
+
No Tenant Selected
+ @elseif ($state === 'no_assignment')
+
+
No Baseline Assigned
+ @elseif ($state === 'no_snapshot')
+
+
No Snapshot Available
+ @endif
+
{{ $message }}
+
+
+ @endif
+
+ {{-- Severity breakdown + actions --}}
+ @if ($state === 'ready' && ($findingsCount ?? 0) > 0)
+
+
+ {{ $findingsCount }} {{ Str::plural('Finding', $findingsCount) }}
+
+
+ The tenant configuration drifted from the baseline profile.
+
+
+
+
+ @if (($severityCounts['high'] ?? 0) > 0)
+
+ {{ $severityCounts['high'] }} High
+
+ @endif
+
+ @if (($severityCounts['medium'] ?? 0) > 0)
+
+ {{ $severityCounts['medium'] }} Medium
+
+ @endif
+
+ @if (($severityCounts['low'] ?? 0) > 0)
+
+ {{ $severityCounts['low'] }} Low
+
+ @endif
+
+
+
+ @if ($this->getFindingsUrl())
+
+ View all findings
+
+ @endif
+
+ @if ($this->getRunUrl())
+
+ Review last run
+
+ @endif
+
+
+
+ @endif
+
+ {{-- Ready: no drift --}}
+ @if ($state === 'ready' && ($findingsCount ?? 0) === 0)
+
+
+
+
No Drift Detected
+
+ The tenant configuration matches the baseline profile. Everything looks good.
+
+ @if ($this->getRunUrl())
+
+ Review last run
+
+ @endif
+
+
+ @endif
+
+ {{-- Idle state --}}
+ @if ($state === 'idle')
+
+
+
+
Ready to Compare
+
+ {{ $message }}
+
+
+
+ @endif
+
diff --git a/resources/views/filament/widgets/dashboard/baseline-compare-now.blade.php b/resources/views/filament/widgets/dashboard/baseline-compare-now.blade.php
new file mode 100644
index 0000000..5e4089e
--- /dev/null
+++ b/resources/views/filament/widgets/dashboard/baseline-compare-now.blade.php
@@ -0,0 +1,68 @@
+
+
+
Baseline Governance
+ @if ($landingUrl)
+
+ Details
+
+ @endif
+
+
+ @if (! $hasAssignment)
+
+
+
+
No Baseline Assigned
+
Assign a baseline profile to start monitoring drift.
+
+
+ @else
+ {{-- Profile + last compared --}}
+
+
+ Baseline: {{ $profileName }}
+
+ @if ($lastComparedAt)
+
{{ $lastComparedAt }}
+ @endif
+
+
+ {{-- Findings summary --}}
+ @if ($findingsCount > 0)
+ {{-- Critical banner (inline) --}}
+ @if ($highCount > 0)
+
+
+
+ {{ $highCount }} high-severity {{ Str::plural('finding', $highCount) }}
+
+
+ @endif
+
+
+
+
+ {{ $findingsCount }} {{ Str::plural('finding', $findingsCount) }}
+
+
+ @if ($mediumCount > 0)
+
+ {{ $mediumCount }} medium
+
+ @endif
+
+ @if ($lowCount > 0)
+
+ {{ $lowCount }} low
+
+ @endif
+
+
+ @else
+
+
+ No open drift — baseline compliant
+
+ @endif
+ @endif
+
diff --git a/specs/101-golden-master-baseline-governance-v1/checklists/requirements.md b/specs/101-golden-master-baseline-governance-v1/checklists/requirements.md
new file mode 100644
index 0000000..be0d1f2
--- /dev/null
+++ b/specs/101-golden-master-baseline-governance-v1/checklists/requirements.md
@@ -0,0 +1,50 @@
+# Specification Quality Checklist: Golden Master / Baseline Governance v1 (R1.1–R1.4)
+
+**Purpose**: Validate specification completeness and quality before proceeding to planning
+**Created**: 2026-02-19
+**Last Validated**: 2026-02-19 (post-analysis remediation)
+**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
+
+## Post-Analysis Remediation (2026-02-19)
+
+- [x] D1: Duplicate FR-003 removed (kept complete version with override clause)
+- [x] I1: Status lifecycle resolved — `draft ↔ active → archived` (deactivate = return to draft); spec.md + data-model.md aligned
+- [x] I3: "Delete" removed from UI Action Matrix — v1 is archive-only, no hard-delete
+- [x] C1: BadgeDomain task (T018a) added for BaselineProfileStatus badge compliance (BADGE-001)
+- [x] C2: Factory tasks (T018b) added for all 4 new models
+- [x] U1: `findings.source` default resolved — nullable, NULL default, legacy findings unaffected
+- [x] U2: Empty-scope edge case (EC-005) covered in T031 test description
+- [x] U3: Concurrent operation dedup (EC-004) covered in T032 + T040 test descriptions
+- [x] U4: T033 amended with explicit manage-capability gating via WorkspaceUiEnforcement
+- [x] U5: `scope_jsonb` schema defined in data-model.md (`{ "policy_types": [...] }`)
+- [x] E1: SC-001/SC-002 performance spot-check added to T060
+
+## Notes
+
+- All 12 analysis findings have been remediated
+- Task count updated: 60 → 62 (T018a, T018b added in Phase 2)
diff --git a/specs/101-golden-master-baseline-governance-v1/contracts/baseline-governance.openapi.yaml b/specs/101-golden-master-baseline-governance-v1/contracts/baseline-governance.openapi.yaml
new file mode 100644
index 0000000..c288211
--- /dev/null
+++ b/specs/101-golden-master-baseline-governance-v1/contracts/baseline-governance.openapi.yaml
@@ -0,0 +1,156 @@
+openapi: 3.0.3
+info:
+ title: Baseline Governance v1 (Golden Master)
+ version: 1.0.0
+ description: |
+ Conceptual HTTP contract for Baseline Governance actions.
+
+ Note: The implementation is Filament + Livewire; these endpoints describe the server-side behavior
+ (authorization, precondition failures, operation run creation) in a REST-like form for clarity.
+
+servers:
+ - url: /admin
+
+paths:
+ /workspaces/{workspaceId}/baselines:
+ get:
+ summary: List baseline profiles
+ parameters:
+ - $ref: '#/components/parameters/workspaceId'
+ responses:
+ '200':
+ description: OK
+
+ /workspaces/{workspaceId}/baselines/{baselineProfileId}:
+ get:
+ summary: View baseline profile
+ parameters:
+ - $ref: '#/components/parameters/workspaceId'
+ - $ref: '#/components/parameters/baselineProfileId'
+ responses:
+ '200':
+ description: OK
+ '404':
+ description: Not found (workspace not entitled)
+ '403':
+ description: Forbidden (missing capability)
+
+ /workspaces/{workspaceId}/baselines/{baselineProfileId}/capture:
+ post:
+ summary: Capture immutable baseline snapshot from a tenant
+ parameters:
+ - $ref: '#/components/parameters/workspaceId'
+ - $ref: '#/components/parameters/baselineProfileId'
+ requestBody:
+ required: true
+ content:
+ application/json:
+ schema:
+ type: object
+ required: [source_tenant_id]
+ properties:
+ source_tenant_id:
+ type: integer
+ responses:
+ '202':
+ description: Enqueued (OperationRun created/reused)
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/OperationRunStartResponse'
+ '422':
+ description: Precondition failure (no OperationRun created)
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/PreconditionFailure'
+ examples:
+ missingSourceTenant:
+ value:
+ reason_code: baseline.capture.missing_source_tenant
+ '404':
+ description: Not found (workspace not entitled)
+ '403':
+ description: Forbidden (missing capability)
+
+ /tenants/{tenantId}/baseline-compare:
+ post:
+ summary: Compare tenant state to assigned baseline and generate drift findings
+ parameters:
+ - $ref: '#/components/parameters/tenantId'
+ responses:
+ '202':
+ description: Enqueued (OperationRun created/reused)
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/OperationRunStartResponse'
+ '422':
+ description: Precondition failure (no OperationRun created)
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/PreconditionFailure'
+ examples:
+ noAssignment:
+ value:
+ reason_code: baseline.compare.no_assignment
+ profileNotActive:
+ value:
+ reason_code: baseline.compare.profile_not_active
+ noActiveSnapshot:
+ value:
+ reason_code: baseline.compare.no_active_snapshot
+ '404':
+ description: Not found (tenant/workspace not entitled)
+ '403':
+ description: Forbidden (missing capability)
+
+ /tenants/{tenantId}/baseline-compare/latest:
+ get:
+ summary: Fetch latest baseline compare summary for tenant
+ parameters:
+ - $ref: '#/components/parameters/tenantId'
+ responses:
+ '200':
+ description: OK
+
+components:
+ parameters:
+ workspaceId:
+ name: workspaceId
+ in: path
+ required: true
+ schema:
+ type: integer
+ tenantId:
+ name: tenantId
+ in: path
+ required: true
+ schema:
+ type: integer
+ baselineProfileId:
+ name: baselineProfileId
+ in: path
+ required: true
+ schema:
+ type: integer
+
+ schemas:
+ OperationRunStartResponse:
+ type: object
+ required: [operation_run_id]
+ properties:
+ operation_run_id:
+ type: integer
+ reused:
+ type: boolean
+ description: True if an already-queued/running run was returned
+
+ PreconditionFailure:
+ type: object
+ required: [reason_code]
+ properties:
+ reason_code:
+ type: string
+ description: Stable code for UI + support triage
diff --git a/specs/101-golden-master-baseline-governance-v1/data-model.md b/specs/101-golden-master-baseline-governance-v1/data-model.md
new file mode 100644
index 0000000..f22c437
--- /dev/null
+++ b/specs/101-golden-master-baseline-governance-v1/data-model.md
@@ -0,0 +1,142 @@
+# Phase 1 — Data Model
+
+This is the proposed data model for **101 — Golden Master / Baseline Governance v1**.
+
+## Ownership Model
+
+- **Workspace-owned**
+ - Baseline profiles
+ - Baseline snapshots + snapshot items (data-minimized, reusable across tenants)
+
+- **Tenant-owned**
+ - Tenant assignments (joins a tenant to the workspace baseline standard)
+ - Operation runs for capture/compare
+ - Findings produced by compares
+
+This follows constitution SCOPE-001 conventions: tenant-owned tables include `workspace_id` + `tenant_id` NOT NULL; workspace-owned tables include `workspace_id` and do not include `tenant_id`.
+
+## Entities
+
+## 1) baseline_profiles (workspace-owned)
+
+**Purpose**: Defines the baseline (“what good looks like”) and its scope.
+
+**Fields**
+- `id` (pk)
+- `workspace_id` (fk workspaces, NOT NULL)
+- `name` (string, NOT NULL)
+- `description` (text, nullable)
+- `version_label` (string, nullable)
+- `status` (string enum: `draft|active|archived`, NOT NULL)
+- `scope_jsonb` (jsonb, NOT NULL)
+ - v1 schema: `{ "policy_types": ["string", ...] }` — array of policy type keys from `InventoryPolicyTypeMeta`
+ - An empty array means "all types" (no filtering); each string must be a known policy type key
+ - Future versions may add additional filter dimensions (e.g., `platforms`, `tags`)
+- `active_snapshot_id` (fk baseline_snapshots, nullable)
+- `created_by_user_id` (fk users, nullable)
+- timestamps
+
+**Indexes/constraints**
+- index: `(workspace_id, status)`
+- uniqueness: `(workspace_id, name)` (optional but recommended if UI expects names unique per workspace)
+
+**Validation notes**
+- status transitions: `draft ↔ active → archived` (archived is terminal in v1; deactivate returns active → draft)
+
+## 2) baseline_snapshots (workspace-owned)
+
+**Purpose**: Immutable baseline snapshot captured from a tenant.
+
+**Fields**
+- `id` (pk)
+- `workspace_id` (fk workspaces, NOT NULL)
+- `baseline_profile_id` (fk baseline_profiles, NOT NULL)
+- `snapshot_identity_hash` (string(64), NOT NULL)
+ - sha256 of normalized captured content
+- `captured_at` (timestamp tz, NOT NULL)
+- `summary_jsonb` (jsonb, nullable)
+ - counts/metadata (e.g., total items)
+- timestamps
+
+**Indexes/constraints**
+- unique: `(workspace_id, baseline_profile_id, snapshot_identity_hash)` (dedupe)
+- index: `(workspace_id, baseline_profile_id, captured_at desc)`
+
+## 3) baseline_snapshot_items (workspace-owned)
+
+**Purpose**: Immutable items within a snapshot.
+
+**Fields**
+- `id` (pk)
+- `baseline_snapshot_id` (fk baseline_snapshots, NOT NULL)
+- `subject_type` (string, NOT NULL) — e.g. `policy`
+- `subject_external_id` (string, NOT NULL) — stable key for the policy within the tenant inventory
+- `policy_type` (string, NOT NULL) — for filtering and summary
+- `baseline_hash` (string(64), NOT NULL) — stable content hash for the baseline version
+- `meta_jsonb` (jsonb, nullable) — minimized display metadata (no secrets)
+- timestamps
+
+**Indexes/constraints**
+- unique: `(baseline_snapshot_id, subject_type, subject_external_id)`
+- index: `(baseline_snapshot_id, policy_type)`
+
+## 4) baseline_tenant_assignments (tenant-owned)
+
+**Purpose**: Assigns exactly one baseline profile per tenant (v1), with optional scope override that can only narrow.
+
+**Fields**
+- `id` (pk)
+- `workspace_id` (fk workspaces, NOT NULL)
+- `tenant_id` (fk tenants, NOT NULL)
+- `baseline_profile_id` (fk baseline_profiles, NOT NULL)
+- `override_scope_jsonb` (jsonb, nullable)
+- `assigned_by_user_id` (fk users, nullable)
+- timestamps
+
+**Indexes/constraints**
+- unique: `(workspace_id, tenant_id)`
+- index: `(workspace_id, baseline_profile_id)`
+
+**Validation notes**
+- Override must be subset of profile scope (enforced server-side; store the final effective scope hash in the compare run context for traceability).
+
+## 5) findings additions (tenant-owned)
+
+**Purpose**: Persist baseline compare drift findings using existing findings system.
+
+**Required v1 additions**
+- add `findings.source` (string, **nullable**, default `NULL`)
+ - baseline compare uses `source='baseline.compare'`
+ - existing findings receive `NULL`; legacy drift-generate findings are queried with `whereNull('source')` or unconditionally
+- index: `(tenant_id, source)`
+
+**Baseline compare finding conventions**
+- `finding_type = 'drift'`
+- `scope_key = 'baseline_profile:'`
+- `fingerprint = sha256(tenant_id|scope_key|subject_type|subject_external_id|change_type|baseline_hash|current_hash)` (using `DriftHasher`)
+- `evidence_jsonb` includes:
+ - `source` (until DB column exists)
+ - `baseline.profile_id`, `baseline.snapshot_id`
+ - `baseline.hash`, `current.hash`
+ - `change_type` (missing_policy|different_version|unexpected_policy)
+ - any minimized diff pointers required for UI
+
+## 6) operation_runs conventions
+
+**Operation types**
+- `baseline_capture`
+- `baseline_compare`
+
+**Run context** (json)
+- capture:
+ - `baseline_profile_id`
+ - `source_tenant_id`
+ - `effective_scope` / `selection_hash`
+- compare:
+ - `baseline_profile_id`
+ - `baseline_snapshot_id` (frozen at enqueue time)
+ - `effective_scope` / `selection_hash`
+
+**Summary counts**
+- compare should set a compact breakdown for dashboards:
+ - totals and severity breakdowns
diff --git a/specs/101-golden-master-baseline-governance-v1/plan.md b/specs/101-golden-master-baseline-governance-v1/plan.md
new file mode 100644
index 0000000..a7cc95b
--- /dev/null
+++ b/specs/101-golden-master-baseline-governance-v1/plan.md
@@ -0,0 +1,166 @@
+#+#+#+#+
+# Implementation Plan: Golden Master / Baseline Governance v1 (R1.1–R1.4)
+
+**Branch**: `101-golden-master-baseline-governance-v1` | **Date**: 2026-02-19 | **Spec**: [spec.md](spec.md)
+**Input**: Feature specification from [spec.md](spec.md)
+
+## Summary
+
+Implement a workspace-owned **Golden Master baseline** that can be captured from a tenant into an immutable snapshot, assigned to tenants, and compared (“Soll vs Ist”) to generate drift findings + a compact operational summary.
+
+This feature reuses existing patterns:
+- tenant-scoped `OperationRun` for capture/compare observability + idempotency
+- `findings` for drift tracking with sha256 fingerprints
+
+## Technical Context
+
+**Language/Version**: PHP 8.4.x
+**Framework**: Laravel 12
+**Admin UI**: Filament v5 (requires Livewire v4.0+)
+**Storage**: PostgreSQL (Sail locally); SQLite is used in some tests
+**Testing**: Pest v4 (`vendor/bin/sail artisan test`)
+**Target Platform**: Docker via Laravel Sail (macOS dev)
+**Project Type**: Laravel monolith (server-rendered Filament + Livewire)
+**Performance Goals**:
+- Compare completes within ~2 minutes for typical tenants (≤ 500 in-scope policies)
+**Constraints**:
+- Baseline/monitoring pages must be DB-only at render time (no outbound HTTP)
+- Operation start surfaces enqueue-only (no remote work inline)
+**Scale/Scope**:
+- Multi-tenant, workspace isolation is an authorization boundary
+
+## Constitution Check
+
+*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.*
+
+- Inventory-first: compare operates on “last observed” inventory/policy versions; baseline snapshot is an explicit immutable capture.
+- Read/write separation: capture/compare are operational jobs; any mutations are confirmed, audited, and tested.
+- Graph contract path: no Graph calls on UI surfaces; any Graph usage (if introduced) must go through `GraphClientInterface` and `config/graph_contracts.php`.
+- Deterministic capabilities: new baseline capabilities are added to the canonical registry and role maps.
+- RBAC-UX semantics: non-member is 404; member but missing capability is 403.
+- Run observability: capture/compare are always `OperationRun`-tracked; start surfaces only create/reuse runs and enqueue work.
+- Data minimization: workspace-owned baseline snapshots are minimized and must not store tenant secrets.
+- Badge semantics (BADGE-001): finding severity/status uses existing `BadgeCatalog` mapping.
+- Filament Action Surface Contract: resources/pages created or modified must define Header/Row/Bulk/Empty-State + inspect affordance.
+
+✅ Phase 0 and Phase 1 design choices satisfy the constitution. No violations are required.
+
+## Project Structure
+
+### Documentation (this feature)
+
+```text
+specs/101-golden-master-baseline-governance-v1/
+├── plan.md
+├── spec.md
+├── research.md
+├── data-model.md
+├── quickstart.md
+└── contracts/
+ └── baseline-governance.openapi.yaml
+```
+
+### Source Code (repository root)
+
+```text
+app/
+├── Filament/
+├── Jobs/
+├── Models/
+├── Policies/
+├── Services/
+└── Support/
+
+database/
+└── migrations/
+
+resources/
+└── views/
+
+tests/
+├── Feature/
+└── Unit/
+```
+
+**Structure Decision**: Implement baseline governance as standard Laravel models, migrations, services, jobs, and Filament resources/pages under the existing `app/` conventions.
+
+## Phase 0 — Research (completed)
+
+Outputs:
+- [research.md](research.md)
+
+Key decisions captured:
+- Workspace-owned, data-minimized baseline snapshots (reusable across tenants)
+- `OperationRun` types `baseline_capture` and `baseline_compare` using `ensureRunWithIdentity()` for idempotent starts
+- Precondition failures return 422 with stable `reason_code` and do not create runs
+- Findings reuse `findings` with sha256 fingerprints; Phase 2 adds `findings.source` to satisfy `source = baseline.compare`
+
+## Phase 1 — Design & Contracts (completed)
+
+Outputs:
+- [data-model.md](data-model.md)
+- [contracts/baseline-governance.openapi.yaml](contracts/baseline-governance.openapi.yaml)
+- [quickstart.md](quickstart.md)
+
+### UI Surfaces (Filament v5)
+
+- Workspace-context: Governance → Baselines
+ - Resource for baseline profile CRUD
+ - Capture action (manage capability)
+ - Tenant assignment management (manage capability)
+- Tenant-context: “Soll vs Ist” landing page
+ - Compare-now action (view capability)
+ - Links to latest compare run + findings
+
+### Authorization & capabilities
+
+- Add new workspace capabilities:
+ - `workspace_baselines.view`
+ - `workspace_baselines.manage`
+- Enforce membership as 404; capability denial as 403.
+
+### Operation types
+
+- `baseline_capture` (tenant = source tenant)
+- `baseline_compare` (tenant = target/current tenant)
+
+### Agent context update (completed)
+
+The agent context update script was run during planning. It should be re-run after this plan update if the automation depends on parsed plan values.
+
+## Phase 2 — Execution Plan (to be translated into tasks.md)
+
+1) **Migrations + models**
+ - Add baseline tables (`baseline_profiles`, `baseline_snapshots`, `baseline_snapshot_items`, `baseline_tenant_assignments`)
+ - Add `findings.source` column + index to meet FR-015
+
+2) **Services**
+ - Baseline scope resolution (profile scope ∩ assignment override)
+ - Snapshot identity calculation and dedupe strategy
+ - Compare engine to produce drift items and severity mapping (FR-009)
+
+3) **Jobs (queued)**
+ - `CaptureBaselineSnapshotJob` (creates snapshot, items, updates active snapshot when applicable)
+ - `CompareBaselineToTenantJob` (creates/updates findings + writes run summary_counts)
+
+4) **Filament UI**
+ - Baseline profiles resource (list/view/edit) with action surfaces per spec matrix
+ - Tenant assignment UI surface (v1: single baseline per tenant)
+ - “Soll vs Ist” tenant landing page and dashboard card action
+
+5) **RBAC + policies**
+ - Capability registry + role maps + UI enforcement
+ - Regression tests for 404 vs 403 semantics
+
+6) **Tests (Pest)**
+ - CRUD authorization + action surface expectations
+ - Capture snapshot dedupe
+ - Compare finding fingerprint idempotency
+ - Precondition failures return 422 and create no `OperationRun`
+
+7) **Formatting**
+ - Run `vendor/bin/sail bin pint --dirty`
+
+## Complexity Tracking
+
+None.
diff --git a/specs/101-golden-master-baseline-governance-v1/quickstart.md b/specs/101-golden-master-baseline-governance-v1/quickstart.md
new file mode 100644
index 0000000..66dda14
--- /dev/null
+++ b/specs/101-golden-master-baseline-governance-v1/quickstart.md
@@ -0,0 +1,60 @@
+# Phase 1 — Quickstart (Developer)
+
+This quickstart is for exercising Baseline Governance v1 locally.
+
+## Prereqs
+- Docker running
+- Laravel Sail available
+
+## Setup
+1. Start containers: `vendor/bin/sail up -d`
+2. Install deps (if needed): `vendor/bin/sail composer install`
+3. Migrate: `vendor/bin/sail artisan migrate`
+4. Build frontend assets (if UI changes aren’t visible): `vendor/bin/sail npm run dev`
+
+## Happy path walkthrough
+
+### 1) Create a baseline profile
+- Navigate to Admin → Governance → Baselines
+- Create a profile with:
+ - name
+ - status = draft
+ - scope filter (policy types/domains)
+
+### 2) Capture from a source tenant
+- From the Baseline Profile view page, trigger “Capture from tenant”
+- Select a source tenant
+- Confirm the action
+- You should see a queued notification with “View run” that links to Monitoring → Operations
+
+Expected:
+- An `OperationRun` of type `baseline_capture` is created (or reused if one is already queued/running)
+- On success, an immutable `baseline_snapshot` is created and the profile’s `active_snapshot_id` is updated (when profile is active)
+
+### 3) Assign baseline to a tenant
+- Navigate to the tenant context (Admin → choose tenant)
+- Assign the baseline profile to the tenant (v1: exactly one baseline per tenant)
+- Optionally define an override filter that narrows scope
+
+### 4) Compare now (Soll vs Ist)
+- Navigate to the “Soll vs Ist” landing page for the tenant
+- Click “Compare now”
+
+Expected:
+- An `OperationRun` of type `baseline_compare` is created/reused
+- Findings are created/updated with stable fingerprints
+- The compare run summary is persisted (totals + severity breakdown)
+
+## Precondition failure checks
+
+These should return **HTTP 422** with `reason_code`, and must **not** create an `OperationRun`:
+- compare with no assignment: `baseline.compare.no_assignment`
+- compare when profile not active: `baseline.compare.profile_not_active`
+- compare when no active snapshot: `baseline.compare.no_active_snapshot`
+- capture with missing source tenant: `baseline.capture.missing_source_tenant`
+
+## Test focus (when implementation lands)
+- BaselineProfile CRUD + RBAC (404 vs 403)
+- Capture idempotency (dedupe snapshot identity)
+- Compare idempotency (dedupe finding fingerprint)
+- Action surfaces comply with the Filament Action Surface Contract
diff --git a/specs/101-golden-master-baseline-governance-v1/research.md b/specs/101-golden-master-baseline-governance-v1/research.md
new file mode 100644
index 0000000..14d68ec
--- /dev/null
+++ b/specs/101-golden-master-baseline-governance-v1/research.md
@@ -0,0 +1,101 @@
+# Phase 0 — Research
+
+This document records the key technical decisions for **101 — Golden Master / Baseline Governance v1 (R1.1–R1.4)**, grounded in existing TenantPilot patterns.
+
+## Existing System Constraints (confirmed in repo)
+
+### Operation runs are tenant-scoped and deduped at DB level
+- `OperationRunService::ensureRunWithIdentity()` requires a `Tenant` with a non-null `workspace_id`, and always creates runs with `workspace_id` + `tenant_id`.
+- Active-run idempotency is enforced via the `operation_runs_active_unique` partial unique index (queued/running).
+
+**Implication**: Baseline capture/compare must always be executed as **tenant-owned `OperationRun` records**, even though baseline profiles are workspace-owned.
+
+### Findings fingerprinting expects sha256 (64 chars)
+- `findings.fingerprint` is `string(64)` and unique by `(tenant_id, fingerprint)`.
+- `DriftHasher` already implements a stable sha256 fingerprint scheme.
+
+**Implication**: Any baseline-compare “finding key” should be hashed before storage, and must be stable across repeated compares.
+
+## Decisions
+
+### D-001 — Baseline snapshot storage is workspace-owned (and data-minimized)
+**Decision**: Store `baseline_snapshots` and `baseline_snapshot_items` as **workspace-owned** tables (`workspace_id` NOT NULL, **no `tenant_id`**) so a golden master snapshot can be used across tenants without requiring access to the source tenant.
+
+**Rationale**:
+- The product intent is a workspace-level standard (“Golden Master”) reusable across multiple tenants.
+- Treat the snapshot as a **standard artifact**, not tenant evidence, and enforce strict data-minimization so we do not leak tenant-specific content.
+
+**Guardrails**:
+- Snapshot items store only policy identity + stable content hashes and minimal display metadata.
+- Any tenant identifiers (e.g., “captured from tenant”) live in the **capture `OperationRun.context`** and audit logs, not on workspace-owned snapshot rows.
+
+**Alternatives considered**:
+- Tenant-owned baseline snapshot (include `tenant_id`): rejected because it would require cross-tenant reads of tenant-owned records to compare other tenants, which would either violate tenant isolation or force “must be member of the source tenant” semantics.
+
+### D-002 — OperationRun types and identity inputs
+**Decision**:
+- Introduce `OperationRun.type` values:
+ - `baseline_capture`
+ - `baseline_compare`
+- Use `OperationRunService::ensureRunWithIdentity()` for idempotent start surfaces.
+
+**Identity inputs**:
+- `baseline_capture`: identity inputs include `baseline_profile_id`.
+- `baseline_compare`: identity inputs include `baseline_profile_id`.
+
+**Rationale**:
+- Guarantees one active run per tenant+baseline profile (matches partial unique index behavior).
+- Keeps identity stable even if the active snapshot is switched mid-flight; the run context should freeze `baseline_snapshot_id` at enqueue time for determinism.
+
+**Alternatives considered**:
+- Include `baseline_snapshot_id` in identity: rejected for v1 because we primarily want “single active compare per tenant/profile”, not “single active compare per snapshot”.
+
+### D-003 — Precondition failures return 422 and do not create OperationRuns
+**Decision**: Enforce FR-014 exactly:
+- The start surface validates preconditions **before** calling `OperationRunService`.
+- If unmet, return **HTTP 422** with a stable `reason_code` and **do not** create an `OperationRun`.
+
+**Rationale**:
+- Aligns with spec clarifications and avoids polluting Monitoring → Operations with non-startable attempts.
+
+### D-004 — Findings storage uses existing `findings` table; add a source discriminator
+**Decision**:
+- Store baseline-compare drift as `Finding::FINDING_TYPE_DRIFT`.
+- Persist `source = baseline.compare` per FR-015.
+
+**Implementation note**:
+- The current `findings` schema does not have a `source` column.
+- In Phase 2 implementation we should add `findings.source` (string, **nullable**, default `NULL`) with an index `(tenant_id, source)` and use `source='baseline.compare'`.
+- Existing findings receive `NULL` — legacy drift-generate findings are queried with `whereNull('source')` or unconditionally.
+- A future backfill migration may set `source='drift.generate'` for historical findings if needed for reporting.
+
+**Alternatives considered**:
+- Store `source` only in `evidence_jsonb`: workable, but makes filtering and long-term reporting harder and is less explicit.
+- Non-null default `'drift.generate'`: rejected because retroactively tagging all existing findings requires careful validation and is a separate concern.
+
+### D-005 — Baseline compare scope_key strategy
+**Decision**: Use `findings.scope_key = 'baseline_profile:' . baseline_profile_id` for baseline-compare findings.
+
+**Rationale**:
+- Keeps a stable grouping key for tenant UI (“Soll vs Ist” for the assigned baseline).
+- Avoids over-coupling to inventory selection hashes in v1.
+
+### D-006 — Authorization model (404 vs 403)
+**Decision**:
+- Membership is enforced as deny-as-not-found (404) via existing membership checks.
+- Capability denials are 403 after membership is established.
+
+**Capabilities**:
+- Add workspace capabilities:
+ - `workspace_baselines.view`
+ - `workspace_baselines.manage`
+
+**Rationale**:
+- Matches the feature spec’s two-capability requirement.
+- Keeps baseline governance controlled at workspace plane, while still enforcing tenant membership for tenant-context pages/actions.
+
+**Alternatives considered**:
+- Add a tenant-plane capability for compare start: rejected for v1 to keep to the two-capability spec and avoid introducing a second permission axis for the same action.
+
+## Open Questions (none blocking Phase 1)
+- None.
diff --git a/specs/101-golden-master-baseline-governance-v1/spec.md b/specs/101-golden-master-baseline-governance-v1/spec.md
new file mode 100644
index 0000000..dd93765
--- /dev/null
+++ b/specs/101-golden-master-baseline-governance-v1/spec.md
@@ -0,0 +1,167 @@
+# Feature Specification: Golden Master / Baseline Governance v1 (R1.1–R1.4)
+
+**Feature Branch**: `101-golden-master-baseline-governance-v1`
+**Created**: 2026-02-19
+**Status**: Draft
+**Input**: Introduce a workspace-owned “Golden Master” baseline that can be captured from a tenant, compared against the current tenant state, and surfaced in the UI as “Soll vs Ist” with drift findings and an operational summary.
+
+## Spec Scope Fields *(mandatory)*
+
+- **Scope**: workspace
+- **Primary Routes**: Admin Governance “Baselines” area; tenant-facing dashboard card; drift comparison landing (“Soll vs Ist”)
+- **Data Ownership**: Baselines and snapshots are workspace-owned; tenant assignments are tenant-owned (workspace_id + tenant_id); drift findings are tenant-owned (workspace_id + tenant_id)
+- **RBAC**: workspace membership required for any visibility; capability gating for view vs manage (see Requirements)
+
+## Clarifications
+
+### Session 2026-02-19
+
+- Q: Which v1 drift severity mapping should we use? → A: Fixed mapping: missing_policy=high, different_version=medium, unexpected_policy=low.
+- Q: Which snapshot should compare runs use in v1? → A: Always use BaselineProfile.active_snapshot; if missing, block compare with a clear reason.
+- Q: How should compare/capture handle precondition failures? → A: UI disables where possible AND server blocks start with 422 + stable reason_code; no OperationRun is created for failed preconditions.
+- Q: Where should drift findings be stored in v1? → A: Use existing findings storage with source=baseline.compare; fingerprint/idempotency lives there.
+- Q: How should tenant override filters combine with the profile filter? → A: Override narrows scope; effective filter = profile ∩ override.
+
+## User Scenarios & Testing *(mandatory)*
+
+### User Story 1 - Create and manage a baseline profile (Priority: P1)
+
+As a workspace governance owner/manager, I can define what “good” looks like by creating a Baseline Profile, controlling its status (draft/active/archived), and scoping which policy domains are included.
+
+**Why this priority**: Without a baseline profile, there is nothing to capture or compare against.
+
+**Independent Test**: A user with baseline manage rights can create/edit/archive a baseline profile and see it listed; a read-only user can list/view but cannot mutate.
+
+**Acceptance Scenarios**:
+
+1. **Given** I am a workspace member with baseline manage capability, **When** I create a baseline profile with a name, optional description, optional version label, and a policy-domain filter, **Then** the profile is persisted and visible in the Baselines list.
+2. **Given** I am a workspace member with baseline view-only capability, **When** I open a baseline profile, **Then** I can view its details but cannot edit/archive/delete it.
+3. **Given** I am not a member of the workspace, **When** I attempt to access baseline pages, **Then** I receive a not-found response (deny-as-not-found).
+
+---
+
+### User Story 2 - Capture an immutable baseline snapshot from a tenant (Priority: P2)
+
+As a baseline manager, I can capture a snapshot of the current tenant configuration that the baseline profile covers, producing an immutable “baseline snapshot” that can later be compared.
+
+**Why this priority**: The baseline must be based on a real, point-in-time state to be meaningful and auditable.
+
+**Independent Test**: Capturing twice with unchanged tenant state reuses the same snapshot identity and does not create duplicates.
+
+**Acceptance Scenarios**:
+
+1. **Given** a baseline profile exists and I have baseline manage capability, **When** I trigger “Capture from tenant” and choose a source tenant, **Then** a new capture operation is created and eventually produces an immutable snapshot.
+2. **Given** a capture was already completed for the same baseline profile and the tenant’s relevant policies are unchanged, **When** I capture again, **Then** the system reuses the existing snapshot (idempotent/deduped).
+3. **Given** the baseline profile is active, **When** a capture completes successfully, **Then** the profile’s “active snapshot” points to the captured snapshot.
+
+---
+
+### User Story 3 - Compare baseline vs current tenant to detect drift (Priority: P3)
+
+As an operator/manager, I can run “Compare now” for a tenant, and the system produces drift findings and a summary that can be used for assurance and triage.
+
+**Why this priority**: Drift detection is the core governance signal; it makes the baseline actionable.
+
+**Independent Test**: A compare run produces findings for missing policies, different versions, and unexpected policies, and stores a compact summary.
+
+**Acceptance Scenarios**:
+
+1. **Given** a tenant is assigned to an active baseline profile with an active snapshot, **When** I run “Compare now”, **Then** a compare operation runs and produces drift findings and a drift summary.
+2. **Given** the same drift item is detected in repeated compares, **When** compares are run multiple times, **Then** the same finding is updated (idempotent fingerprint) rather than duplicated.
+3. **Given** I am a workspace member without baseline view capability, **When** I try to start a compare, **Then** the request is forbidden.
+
+### Edge Cases
+
+- Baseline profile is draft or archived: compare is blocked; users are told what must be changed (e.g., “activate baseline”).
+- Tenant has no baseline assignment: compare button is disabled and the UI explains why.
+- Baseline profile has no active snapshot yet: compare is blocked with a clear reason.
+- Concurrent operation starts: the system prevents multiple “active” captures/compares for the same scope.
+- Baseline filter yields no relevant policies: capture creates an empty snapshot and compare returns “no items checked”, without errors.
+
+## Requirements *(mandatory)*
+
+### Functional Requirements
+
+- **FR-001 (Baseline Profiles)**: The system MUST allow workspace baseline managers to create, edit, activate, deactivate (return to draft), and archive baseline profiles. Status transitions: draft ↔ active → archived (archived is terminal in v1).
+- **FR-002 (Scope Control)**: Each baseline profile MUST define which policy domains/types are included in the baseline.
+- **FR-003 (Tenant Assignment)**: The system MUST support assigning exactly one baseline profile per tenant per workspace (v1), with an optional per-tenant override of the profile’s scope; in v1 the override may only narrow scope (effective filter = profile ∩ override).
+- **FR-004 (Capture as Operation)**: Capturing a baseline snapshot MUST be tracked as an observable operation with a clear lifecycle (started/completed/failed).
+- **FR-005 (Immutable Snapshots)**: Baseline snapshots and their snapshot items MUST be immutable once created.
+- **FR-006 (Capture Idempotency)**: Captures MUST be deduplicated so that repeated captures with the same effective content reuse the existing snapshot identity.
+- **FR-007 (Compare as Operation)**: Comparing a tenant against its baseline MUST be tracked as an observable operation with a clear lifecycle.
+- **FR-008 (Drift Findings)**: Compare MUST produce drift findings using at least these drift types: missing baseline policy, different version, and unexpected policy within the baseline scope.
+- **FR-009 (Severity Rules)**: Drift findings MUST be assigned severities using centrally defined rules so that severity is consistent and testable; v1 fixed mapping is: missing baseline policy = high, different version = medium, unexpected policy = low.
+- **FR-010 (Finding Idempotency)**: Drift findings MUST be deduplicated with a stable fingerprint so repeated compares update existing open findings instead of creating duplicates.
+- **FR-011 (Summary Output)**: Each compare operation MUST persist a summary containing totals and severity breakdowns suitable for dashboards.
+- **FR-012 (UI “Soll vs Ist”)**: The UI MUST allow selecting a baseline profile and viewing the latest compare runs and drift findings for a tenant.
+- **FR-013 (Compare Snapshot Selection)**: Compare runs MUST always use the baseline profile’s active snapshot; if no active snapshot exists, compare MUST be blocked with a clear reason.
+- **FR-014 (Precondition Failure Contract)**: When capture/compare cannot start due to unmet preconditions, the UI MUST disable the action where possible, and the server MUST reject the request with HTTP 422 containing a stable `reason_code`; in this case, the system MUST NOT create an OperationRun.
+- **FR-015 (Findings Storage)**: Drift findings produced by compare MUST be persisted using the existing findings system with `source = baseline.compare`.
+
+### Precondition `reason_code` (v1)
+
+- `baseline.compare.no_assignment`
+- `baseline.compare.profile_not_active`
+- `baseline.compare.no_active_snapshot`
+- `baseline.capture.missing_source_tenant`
+
+### Constitution Alignment: Safety, Isolation, Observability
+
+- **Ops/Observability**: Capture and compare MUST be observable and auditable operations, and surfaced in the existing operations monitoring experience.
+- **DB-only UI**: Baseline pages and drift pages MUST NOT require outbound network calls during page render or user clicks that only start/view operations; external calls (if any) must happen in background work.
+- **Tenant Isolation / RBAC semantics**:
+ - non-member of workspace or tenant scope → deny-as-not-found (404)
+ - member but missing capability → forbidden (403)
+- **Least privilege**: Two capabilities MUST exist and be enforced:
+ - baseline view: can view baselines and start compare operations
+ - baseline manage: can manage baselines, assignments, and capture snapshots
+- **Auditability**: Baseline-related operations MUST emit audit log entries for started/completed/failed events.
+- **Safe logging**: Failures MUST not include secrets or sensitive tenant data; failure reasons must be stable and suitable for support triage.
+
+## 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 |
+|---|---|---|---|---|---|---|---|---|---|---|
+| Baseline Profiles | Admin → Governance → Baselines | Create baseline profile | View/Edit page available | View, Edit | Archive (grouped) | Create baseline profile | Capture from tenant; Activate/Deactivate; Assign to tenants | Save, Cancel | Yes | Destructive actions require confirmation; mutations are manage-gated; hard-delete is out of scope for v1 (archive only) |
+| Drift Landing (“Soll vs Ist”) | Tenant view | Run compare now | Links to last compare operation and findings list | — | — | — | — | — | Yes | Starting compare is view-gated; results visible only to entitled users |
+| Tenant Dashboard Card | Tenant dashboard | Run compare now | Click to drift landing and/or last compare operation | — | — | — | — | — | Yes | Button disabled with explanation when no assignment or no active snapshot |
+
+## Key Entities *(include if feature involves data)*
+
+- **Baseline Profile**: A workspace-owned definition of what should be in-scope for governance, with a lifecycle status (draft/active/archived).
+- **Tenant Assignment**: A workspace-managed mapping that declares which baseline applies to a tenant, optionally overriding scope.
+- **Baseline Snapshot**: An immutable point-in-time record of the baseline’s in-scope policy references captured from a tenant.
+- **Snapshot Item**: A single baseline entry representing one in-scope policy reference in the snapshot.
+- **Drift Finding**: A record representing an observed deviation between baseline and tenant state, deduplicated by a stable fingerprint.
+- **Drift Finding Source**: Drift findings produced by this feature use `source = baseline.compare`.
+- **Operation Summary**: A compact, persisted summary of a capture/compare run suitable for dashboard display.
+
+## Success Criteria *(mandatory)*
+
+### Measurable Outcomes
+
+- **SC-001**: A baseline manager can create a baseline profile and perform an initial capture in under 5 minutes.
+- **SC-002**: For a typical tenant (≤ 500 in-scope policies), a compare run completes and surfaces a summary within 2 minutes.
+- **SC-003**: Re-running compare within 24 hours for unchanged drift does not create duplicate findings (0 duplicate drift fingerprints).
+- **SC-004**: Unauthorized users (non-members) receive no baseline visibility (deny-as-not-found) and members without capability cannot mutate (forbidden).
+
+## Non-Goals (v1)
+
+- Evidence packs / stored reports for audit exports
+- Advanced findings workflow (exceptions, auto-closing, recurrence handling)
+- Cross-tenant portfolio comparisons
+- Baseline inheritance across organizations (e.g., MSP → customer)
+- Assignment/scope-tag baselines beyond the policy domains/types included in the profile scope
+
+## Assumptions
+
+- Tenants already have an inventory of policy versions that can be referenced for capture and compare.
+- An operations monitoring experience exists where capture/compare runs can be viewed.
+- A drift findings system exists that can store and display findings and severities.
+
+## Dependencies
+
+- Inventory + policy version history is available and trustworthy.
+- Operation run tracking and monitoring is available.
+- RBAC + UI enforcement semantics are already established (404 for non-member, 403 for missing capability).
+- Alerts are optional for v1; the feature remains valuable without alert integrations.
diff --git a/specs/101-golden-master-baseline-governance-v1/tasks.md b/specs/101-golden-master-baseline-governance-v1/tasks.md
new file mode 100644
index 0000000..9647c63
--- /dev/null
+++ b/specs/101-golden-master-baseline-governance-v1/tasks.md
@@ -0,0 +1,209 @@
+---
+
+description: "Task breakdown for implementing Feature 101"
+---
+
+# Tasks: Golden Master / Baseline Governance v1
+
+**Input**: Design documents from `specs/101-golden-master-baseline-governance-v1/`
+
+**Key constraints (from spec/plan)**
+- Filament v5 + Livewire v4.0+ only.
+- UI surfaces are DB-only at render time (no outbound HTTP).
+- Capture/compare start surfaces are enqueue-only (no remote work inline).
+- Precondition failures return **HTTP 422** with stable `reason_code` and **must not** create an `OperationRun`.
+- RBAC semantics: non-member → **404** (deny-as-not-found); member but missing capability → **403**.
+- Findings use sha256 64-char fingerprints (reuse `App\Services\Drift\DriftHasher`).
+
+---
+
+## Phase 1: Setup (Shared Infrastructure)
+
+**Purpose**: Ensure the repo is ready for implementation + verification.
+
+- [X] T001 Verify feature docs are in place in specs/101-golden-master-baseline-governance-v1/
+- [X] T002 Verify local dev prerequisites via quickstart in specs/101-golden-master-baseline-governance-v1/quickstart.md
+
+---
+
+## Phase 2: Foundational (Blocking Prerequisites)
+
+**Purpose**: Core primitives used across all user stories (schema, capabilities, operation UX).
+
+**Checkpoint**: After this phase, US1/US2/US3 work can proceed.
+
+- [X] T003 Add baseline capabilities to app/Support/Auth/Capabilities.php (`workspace_baselines.view`, `workspace_baselines.manage`)
+- [X] T004 Add baseline capabilities to app/Services/Auth/WorkspaceRoleCapabilityMap.php (Owner/Manager = manage; Operator/Readonly = view)
+- [X] T005 [P] Add operation labels for baseline_capture + baseline_compare in app/Support/OperationCatalog.php
+- [X] T006 [P] Extend summary keys to include severity breakdown keys in app/Support/OpsUx/OperationSummaryKeys.php (add `high`, `medium`, `low`)
+- [X] T007 Create baseline schema migrations in database/migrations/*_create_baseline_profiles_table.php (workspace-owned)
+- [X] T008 Create baseline schema migrations in database/migrations/*_create_baseline_snapshots_table.php (workspace-owned)
+- [X] T009 Create baseline schema migrations in database/migrations/*_create_baseline_snapshot_items_table.php (workspace-owned)
+- [X] T010 Create baseline schema migrations in database/migrations/*_create_baseline_tenant_assignments_table.php (tenant-owned)
+- [X] T011 Create findings source migration in database/migrations/*_add_source_to_findings_table.php
+- [X] T012 [P] Add BaselineProfile model in app/Models/BaselineProfile.php (casts for scope_jsonb; status constants/enum)
+- [X] T013 [P] Add BaselineSnapshot model in app/Models/BaselineSnapshot.php (casts for summary_jsonb)
+- [X] T014 [P] Add BaselineSnapshotItem model in app/Models/BaselineSnapshotItem.php (casts for meta_jsonb)
+- [X] T015 [P] Add BaselineTenantAssignment model in app/Models/BaselineTenantAssignment.php (casts for override_scope_jsonb)
+- [X] T016 [P] Update Finding model for new column in app/Models/Finding.php (fillable/casts for `source` as needed by existing conventions)
+- [X] T017 [P] Add baseline scope value object / helpers in app/Support/Baselines/BaselineScope.php (normalize + intersect profile/override)
+- [X] T018 [P] Add baseline reason codes in app/Support/Baselines/BaselineReasonCodes.php (constants for 422 responses)
+- [X] T018a [P] Add BadgeDomain::BaselineProfileStatus case to app/Support/Badges/BadgeDomain.php + create domain mapper class in app/Support/Badges/Domains/BaselineProfileStatusBadges.php + register in BadgeCatalog (BADGE-001 compliance for draft/active/archived)
+- [X] T018b [P] Add model factories: database/factories/BaselineProfileFactory.php, BaselineSnapshotFactory.php, BaselineSnapshotItemFactory.php, BaselineTenantAssignmentFactory.php (required for Pest tests in Phase 3–5)
+
+---
+
+## Phase 3: User Story 1 — Baseline Profile CRUD (Priority: P1) 🎯 MVP
+
+**Goal**: Workspace managers define baseline profiles (scope + lifecycle) with correct RBAC and action surfaces.
+
+**Independent Test**:
+- As a member with `workspace_baselines.manage`, create/edit/archive a profile.
+- As a member with `workspace_baselines.view`, list/view but cannot mutate.
+- As a non-member, baseline pages/actions deny-as-not-found (404).
+
+### Tests (required in this repo)
+
+- [X] T019 [P] [US1] Add feature test scaffolding in tests/Feature/Baselines/BaselineProfileAuthorizationTest.php
+- [X] T020 [P] [US1] Add 404 vs 403 semantics tests in tests/Feature/Baselines/BaselineProfileAuthorizationTest.php
+- [X] T021 [P] [US1] Add Action Surface contract coverage for the new resource in tests/Feature/Guards/ActionSurfaceValidatorTest.php (resource discovered or explicitly whitelisted per existing test conventions)
+
+### Implementation
+
+- [X] T022 [US1] Create Baseline Profile Filament resource in app/Filament/Resources/BaselineProfileResource.php (navigation group = Governance)
+- [X] T023 [P] [US1] Add resource pages in app/Filament/Resources/BaselineProfileResource/Pages/ListBaselineProfiles.php
+- [X] T024 [P] [US1] Add resource pages in app/Filament/Resources/BaselineProfileResource/Pages/CreateBaselineProfile.php
+- [X] T025 [P] [US1] Add resource pages in app/Filament/Resources/BaselineProfileResource/Pages/ViewBaselineProfile.php
+- [X] T026 [P] [US1] Add resource pages in app/Filament/Resources/BaselineProfileResource/Pages/EditBaselineProfile.php
+- [X] T027 [US1] Implement form/table schema in app/Filament/Resources/BaselineProfileResource.php (scope editor backed by App\Support\Inventory\InventoryPolicyTypeMeta)
+- [X] T028 [US1] Implement RBAC (404 vs 403) and UI enforcement in app/Filament/Resources/BaselineProfileResource.php using App\Services\Auth\WorkspaceCapabilityResolver + App\Support\Rbac\WorkspaceUiEnforcement
+- [X] T029 [US1] Implement audit logging for baseline profile mutations in app/Filament/Resources/BaselineProfileResource.php using App\Services\Audit\WorkspaceAuditLogger
+- [X] T030 [US1] Ensure the resource is safe for global search (either keep View/Edit pages enabled OR disable global search explicitly) in app/Filament/Resources/BaselineProfileResource.php
+
+**Checkpoint**: Baseline profiles CRUD works and is independently testable.
+
+---
+
+## Phase 4: User Story 2 — Capture Immutable Baseline Snapshot (Priority: P2)
+
+**Goal**: Managers can enqueue capture from a tenant to create (or reuse) an immutable, workspace-owned snapshot.
+
+**Independent Test**:
+- Trigger capture twice for unchanged effective content → the same `snapshot_identity_hash` is reused (no duplicates).
+- If profile is active, capture success updates `active_snapshot_id`.
+
+### Tests (required in this repo)
+
+- [X] T031 [P] [US2] Add capture enqueue + precondition tests in tests/Feature/Baselines/BaselineCaptureTest.php (include: empty-scope capture produces empty snapshot without errors [EC-005])
+- [X] T032 [P] [US2] Add snapshot dedupe test in tests/Feature/Baselines/BaselineCaptureTest.php (include: concurrent capture request for same scope reuses active run [EC-004])
+
+### Implementation
+
+- [X] T033 [P] [US2] Add capture action UI to app/Filament/Resources/BaselineProfileResource/Pages/ViewBaselineProfile.php (Action::make()->action()->requiresConfirmation(); select source tenant; gated by `workspace_baselines.manage` via WorkspaceUiEnforcement — disabled with tooltip for view-only members, server-side 403 on execution)
+- [X] T034 [US2] Add capture start service in app/Services/Baselines/BaselineCaptureService.php (precondition validation before OperationRun creation; enqueue via OperationRunService)
+- [X] T035 [P] [US2] Add snapshot identity helper in app/Services/Baselines/BaselineSnapshotIdentity.php (sha256 over normalized items; reuse DriftHasher::hashNormalized where appropriate)
+- [X] T036 [P] [US2] Add capture job in app/Jobs/CaptureBaselineSnapshotJob.php (DB-only reads; creates snapshot + items; updates profile active snapshot when applicable)
+- [X] T037 [US2] Add audit logging for capture started/completed/failed in app/Jobs/CaptureBaselineSnapshotJob.php using App\Services\Intune\AuditLogger
+- [X] T038 [US2] Persist run context + summary_counts for capture in app/Jobs/CaptureBaselineSnapshotJob.php (use only allowed summary keys)
+
+**Checkpoint**: Capture creates immutable snapshots and dedupes repeated capture.
+
+---
+
+## Phase 5: User Story 3 — Assign + Compare (“Soll vs Ist”) (Priority: P3)
+
+**Goal**: Operators can assign a baseline to a tenant (v1: exactly one), then enqueue compare-now to generate drift findings + summary.
+
+**Independent Test**:
+- With assignment + active snapshot: compare enqueues an operation and produces findings + summary.
+- Re-running compare updates existing findings (same fingerprint) rather than duplicating.
+
+### Tests (required in this repo)
+
+- [X] T039 [P] [US3] Add assignment CRUD tests (RBAC + uniqueness) in tests/Feature/Baselines/BaselineAssignmentTest.php
+- [X] T040 [P] [US3] Add compare precondition 422 tests (no OperationRun created) in tests/Feature/Baselines/BaselineComparePreconditionsTest.php (include: concurrent compare reuses active run [EC-004]; draft/archived profile blocks compare [EC-001])
+- [X] T041 [P] [US3] Add compare idempotent finding fingerprint tests in tests/Feature/Baselines/BaselineCompareFindingsTest.php
+- [X] T042 [P] [US3] Add compare summary_counts severity breakdown tests in tests/Feature/Baselines/BaselineCompareFindingsTest.php
+
+### Implementation — Assignment (v1)
+
+- [X] T043 [P] [US3] Add BaselineTenantAssignments relation manager in app/Filament/Resources/BaselineProfileResource/RelationManagers/BaselineTenantAssignmentsRelationManager.php
+- [X] T044 [US3] Enforce assignment RBAC (404 vs 403) and audits in app/Filament/Resources/BaselineProfileResource/RelationManagers/BaselineTenantAssignmentsRelationManager.php (manage-gated mutations; view-gated visibility)
+- [X] T045 [US3] Implement effective scope validation (override narrows only) in app/Services/Baselines/BaselineScope.php
+
+### Implementation — Tenant landing + dashboard card
+
+- [X] T046 [P] [US3] Add tenant landing page in app/Filament/Pages/BaselineCompareLanding.php (navigation label “Soll vs Ist”; DB-only)
+- [X] T047 [P] [US3] Add landing view in resources/views/filament/pages/baseline-compare-landing.blade.php (shows assignment state, last run link, findings link)
+- [X] T048 [P] [US3] Add tenant dashboard card widget in app/Filament/Widgets/Dashboard/BaselineCompareNow.php
+- [X] T049 [US3] Wire the widget into app/Filament/Pages/TenantDashboard.php (add widget class to getWidgets())
+
+### Implementation — Compare engine + job
+
+- [X] T050 [US3] Add compare start service in app/Services/Baselines/BaselineCompareService.php (preconditions; enqueue OperationRun; freeze snapshot_id in run context)
+- [X] T051 [P] [US3] Add compare job in app/Jobs/CompareBaselineToTenantJob.php (DB-only; compute drift items; upsert findings with `source='baseline.compare'`)
+- [X] T052 [US3] Implement fingerprinting + idempotent upsert in app/Jobs/CompareBaselineToTenantJob.php (use App\Services\Drift\DriftHasher::fingerprint)
+- [X] T053 [US3] Implement severity mapping (missing=high, different=medium, unexpected=low) in app/Jobs/CompareBaselineToTenantJob.php
+- [X] T054 [US3] Persist run summary_counts with totals + severity breakdown in app/Jobs/CompareBaselineToTenantJob.php (requires T006)
+- [X] T055 [US3] Add audit logging for compare started/completed/failed in app/Jobs/CompareBaselineToTenantJob.php using App\Services\Intune\AuditLogger
+
+**Checkpoint**: Tenant “Soll vs Ist” UI works end-to-end and compare generates deduped findings.
+
+---
+
+## Phase 6: Polish & Cross-Cutting Concerns
+
+- [X] T056 [P] Add operation duration hinting for new operation types in app/Support/OperationCatalog.php (expectedDurationSeconds for baseline_capture/baseline_compare)
+- [X] T057 Ensure all destructive actions have confirmation in app/Filament/Resources/BaselineProfileResource.php and app/Filament/Resources/BaselineProfileResource/RelationManagers/BaselineTenantAssignmentsRelationManager.php
+- [X] T058 Run formatting on touched files via `vendor/bin/sail bin pint --dirty`
+- [X] T059 Run focused test suite via `vendor/bin/sail artisan test --compact tests/Feature/Baselines`
+- [X] T060 Run the quickstart walkthrough in specs/101-golden-master-baseline-governance-v1/quickstart.md and adjust any mismatches; spot-check SC-001 (create+capture < 5 min) and SC-002 (compare < 2 min for ≤ 500 policies)
+
+---
+
+## Dependencies & Execution Order
+
+### Phase Dependencies
+
+- Phase 1 (Setup) → Phase 2 (Foundational) → US phases
+- Phase 2 blocks US1/US2/US3.
+
+### User Story Dependencies
+
+- **US1 (P1)** is the MVP and enables profile CRUD.
+- **US2 (P2)** depends on US1 (needs profiles) and Phase 2 schema/services.
+- **US3 (P3)** depends on US1 (needs profiles) and Phase 2 schema; compare also depends on US2 having produced an active snapshot.
+
+---
+
+## Parallel Execution Examples
+
+### US1 parallelizable work
+
+- T023, T024, T025, T026 (resource pages) can be implemented in parallel.
+- T019 and T020 (authorization tests) can be implemented in parallel with the resource skeleton.
+
+### US2 parallelizable work
+
+- T035 (snapshot identity helper) and T036 (capture job) can be implemented in parallel once the schema exists.
+- T031–T032 tests can be written before the job/service implementation.
+
+### US3 parallelizable work
+
+- T046–T048 (landing page + view + widget) can be done in parallel with T050–T054 (compare service/job).
+- T039–T042 tests can be written before implementation.
+
+---
+
+## Implementation Strategy
+
+### MVP First (US1 only)
+
+1. Complete Phase 1 + Phase 2
+2. Deliver Phase 3 (US1)
+3. Run: `vendor/bin/sail artisan test --compact tests/Feature/Baselines/BaselineProfileAuthorizationTest.php`
+
+### Incremental delivery
+
+- Add US2 (capture) → validate dedupe + active snapshot updates
+- Add US3 (assign + compare) → validate 422 contract + idempotent findings + summary output
diff --git a/tests/Feature/Baselines/BaselineAssignmentTest.php b/tests/Feature/Baselines/BaselineAssignmentTest.php
new file mode 100644
index 0000000..64df738
--- /dev/null
+++ b/tests/Feature/Baselines/BaselineAssignmentTest.php
@@ -0,0 +1,126 @@
+active()->create([
+ 'workspace_id' => $tenant->workspace_id,
+ 'scope_jsonb' => ['policy_types' => ['deviceConfiguration']],
+ ]);
+
+ $assignment = BaselineTenantAssignment::create([
+ 'workspace_id' => (int) $tenant->workspace_id,
+ 'tenant_id' => (int) $tenant->getKey(),
+ 'baseline_profile_id' => (int) $profile->getKey(),
+ 'assigned_by_user_id' => (int) $user->getKey(),
+ ]);
+
+ expect($assignment)->toBeInstanceOf(BaselineTenantAssignment::class);
+ expect($assignment->workspace_id)->toBe((int) $tenant->workspace_id);
+ expect($assignment->tenant_id)->toBe((int) $tenant->getKey());
+ expect($assignment->baseline_profile_id)->toBe((int) $profile->getKey());
+ expect($assignment->assigned_by_user_id)->toBe((int) $user->getKey());
+});
+
+it('prevents duplicate assignments for the same workspace+tenant', function () {
+ [$user, $tenant] = createUserWithTenant(role: 'owner');
+
+ $profile1 = BaselineProfile::factory()->active()->create([
+ 'workspace_id' => $tenant->workspace_id,
+ ]);
+ $profile2 = BaselineProfile::factory()->active()->create([
+ 'workspace_id' => $tenant->workspace_id,
+ ]);
+
+ BaselineTenantAssignment::create([
+ 'workspace_id' => (int) $tenant->workspace_id,
+ 'tenant_id' => (int) $tenant->getKey(),
+ 'baseline_profile_id' => (int) $profile1->getKey(),
+ 'assigned_by_user_id' => (int) $user->getKey(),
+ ]);
+
+ // Attempting to assign the same tenant in the same workspace should fail
+ // due to the unique constraint on (workspace_id, tenant_id)
+ expect(fn () => BaselineTenantAssignment::create([
+ 'workspace_id' => (int) $tenant->workspace_id,
+ 'tenant_id' => (int) $tenant->getKey(),
+ 'baseline_profile_id' => (int) $profile2->getKey(),
+ 'assigned_by_user_id' => (int) $user->getKey(),
+ ]))->toThrow(\Illuminate\Database\QueryException::class);
+});
+
+it('allows the same tenant to be assigned in different workspaces', function () {
+ [$user1, $tenant1] = createUserWithTenant(role: 'owner');
+ [$user2, $tenant2] = createUserWithTenant(role: 'owner');
+
+ $profile1 = BaselineProfile::factory()->active()->create([
+ 'workspace_id' => $tenant1->workspace_id,
+ ]);
+ $profile2 = BaselineProfile::factory()->active()->create([
+ 'workspace_id' => $tenant2->workspace_id,
+ ]);
+
+ $a1 = BaselineTenantAssignment::create([
+ 'workspace_id' => (int) $tenant1->workspace_id,
+ 'tenant_id' => (int) $tenant1->getKey(),
+ 'baseline_profile_id' => (int) $profile1->getKey(),
+ ]);
+ $a2 = BaselineTenantAssignment::create([
+ 'workspace_id' => (int) $tenant2->workspace_id,
+ 'tenant_id' => (int) $tenant2->getKey(),
+ 'baseline_profile_id' => (int) $profile2->getKey(),
+ ]);
+
+ expect($a1->exists)->toBeTrue();
+ expect($a2->exists)->toBeTrue();
+});
+
+it('deletes an assignment without deleting related models', function () {
+ [$user, $tenant] = createUserWithTenant(role: 'owner');
+
+ $profile = BaselineProfile::factory()->active()->create([
+ 'workspace_id' => $tenant->workspace_id,
+ ]);
+
+ $assignment = BaselineTenantAssignment::create([
+ 'workspace_id' => (int) $tenant->workspace_id,
+ 'tenant_id' => (int) $tenant->getKey(),
+ 'baseline_profile_id' => (int) $profile->getKey(),
+ 'assigned_by_user_id' => (int) $user->getKey(),
+ ]);
+
+ $assignmentId = $assignment->getKey();
+ $assignment->delete();
+
+ expect(BaselineTenantAssignment::query()->find($assignmentId))->toBeNull();
+ expect(BaselineProfile::query()->find($profile->getKey()))->not->toBeNull();
+ expect(Tenant::query()->find($tenant->getKey()))->not->toBeNull();
+});
+
+it('loads the baseline profile relationship from assignment', function () {
+ [$user, $tenant] = createUserWithTenant(role: 'owner');
+
+ $profile = BaselineProfile::factory()->active()->create([
+ 'workspace_id' => $tenant->workspace_id,
+ 'name' => 'Test Profile',
+ ]);
+
+ $assignment = BaselineTenantAssignment::create([
+ 'workspace_id' => (int) $tenant->workspace_id,
+ 'tenant_id' => (int) $tenant->getKey(),
+ 'baseline_profile_id' => (int) $profile->getKey(),
+ ]);
+
+ $loaded = $assignment->baselineProfile;
+
+ expect($loaded)->not->toBeNull();
+ expect($loaded->name)->toBe('Test Profile');
+});
diff --git a/tests/Feature/Baselines/BaselineCaptureTest.php b/tests/Feature/Baselines/BaselineCaptureTest.php
new file mode 100644
index 0000000..b9f69fc
--- /dev/null
+++ b/tests/Feature/Baselines/BaselineCaptureTest.php
@@ -0,0 +1,349 @@
+active()->create([
+ 'workspace_id' => $tenant->workspace_id,
+ 'scope_jsonb' => ['policy_types' => ['deviceConfiguration']],
+ ]);
+
+ /** @var BaselineCaptureService $service */
+ $service = app(BaselineCaptureService::class);
+ $result = $service->startCapture($profile, $tenant, $user);
+
+ expect($result['ok'])->toBeTrue();
+ expect($result)->toHaveKey('run');
+
+ /** @var OperationRun $run */
+ $run = $result['run'];
+ expect($run->type)->toBe('baseline_capture');
+ expect($run->status)->toBe('queued');
+ expect($run->tenant_id)->toBe((int) $tenant->getKey());
+
+ $context = is_array($run->context) ? $run->context : [];
+ expect($context['baseline_profile_id'])->toBe((int) $profile->getKey());
+ expect($context['source_tenant_id'])->toBe((int) $tenant->getKey());
+
+ Queue::assertPushed(CaptureBaselineSnapshotJob::class);
+});
+
+it('rejects capture for a draft profile with reason code', function () {
+ Queue::fake();
+
+ [$user, $tenant] = createUserWithTenant(role: 'owner');
+
+ $profile = BaselineProfile::factory()->create([
+ 'workspace_id' => $tenant->workspace_id,
+ 'status' => BaselineProfile::STATUS_DRAFT,
+ ]);
+
+ $service = app(BaselineCaptureService::class);
+ $result = $service->startCapture($profile, $tenant, $user);
+
+ expect($result['ok'])->toBeFalse();
+ expect($result['reason_code'])->toBe('baseline.capture.profile_not_active');
+
+ Queue::assertNotPushed(CaptureBaselineSnapshotJob::class);
+ expect(OperationRun::query()->where('type', 'baseline_capture')->count())->toBe(0);
+});
+
+it('rejects capture for an archived profile with reason code', function () {
+ Queue::fake();
+
+ [$user, $tenant] = createUserWithTenant(role: 'owner');
+
+ $profile = BaselineProfile::factory()->archived()->create([
+ 'workspace_id' => $tenant->workspace_id,
+ ]);
+
+ $service = app(BaselineCaptureService::class);
+ $result = $service->startCapture($profile, $tenant, $user);
+
+ expect($result['ok'])->toBeFalse();
+ expect($result['reason_code'])->toBe('baseline.capture.profile_not_active');
+
+ Queue::assertNotPushed(CaptureBaselineSnapshotJob::class);
+ expect(OperationRun::query()->where('type', 'baseline_capture')->count())->toBe(0);
+});
+
+it('rejects capture for a tenant from a different workspace', function () {
+ Queue::fake();
+
+ [$user, $tenant] = createUserWithTenant(role: 'owner');
+ [$otherUser, $otherTenant] = createUserWithTenant(role: 'owner');
+
+ $profile = BaselineProfile::factory()->active()->create([
+ 'workspace_id' => $tenant->workspace_id,
+ ]);
+
+ $service = app(BaselineCaptureService::class);
+ $result = $service->startCapture($profile, $otherTenant, $user);
+
+ expect($result['ok'])->toBeFalse();
+ expect($result['reason_code'])->toBe('baseline.capture.missing_source_tenant');
+
+ Queue::assertNotPushed(CaptureBaselineSnapshotJob::class);
+});
+
+// --- T032: Concurrent capture reuses active run [EC-004] ---
+
+it('reuses an existing active run for the same profile/tenant instead of creating a new one', function () {
+ Queue::fake();
+
+ [$user, $tenant] = createUserWithTenant(role: 'owner');
+
+ $profile = BaselineProfile::factory()->active()->create([
+ 'workspace_id' => $tenant->workspace_id,
+ 'scope_jsonb' => ['policy_types' => ['deviceConfiguration']],
+ ]);
+
+ $service = app(BaselineCaptureService::class);
+
+ $result1 = $service->startCapture($profile, $tenant, $user);
+ $result2 = $service->startCapture($profile, $tenant, $user);
+
+ expect($result1['ok'])->toBeTrue();
+ expect($result2['ok'])->toBeTrue();
+
+ expect($result1['run']->getKey())->toBe($result2['run']->getKey());
+ expect(OperationRun::query()->where('type', 'baseline_capture')->count())->toBe(1);
+});
+
+// --- Snapshot dedupe + capture job execution ---
+
+it('creates a snapshot with items when the capture job executes', function () {
+ [$user, $tenant] = createUserWithTenant(role: 'owner');
+
+ $profile = BaselineProfile::factory()->active()->create([
+ 'workspace_id' => $tenant->workspace_id,
+ 'scope_jsonb' => ['policy_types' => ['deviceConfiguration']],
+ ]);
+
+ InventoryItem::factory()->count(3)->create([
+ 'tenant_id' => $tenant->getKey(),
+ 'workspace_id' => $tenant->workspace_id,
+ 'policy_type' => 'deviceConfiguration',
+ 'meta_jsonb' => ['odata_type' => '#microsoft.graph.deviceConfiguration'],
+ ]);
+
+ $opService = app(OperationRunService::class);
+ $run = $opService->ensureRunWithIdentity(
+ tenant: $tenant,
+ type: 'baseline_capture',
+ identityInputs: ['baseline_profile_id' => (int) $profile->getKey()],
+ context: [
+ 'baseline_profile_id' => (int) $profile->getKey(),
+ 'source_tenant_id' => (int) $tenant->getKey(),
+ 'effective_scope' => ['policy_types' => ['deviceConfiguration']],
+ ],
+ initiator: $user,
+ );
+
+ $job = new CaptureBaselineSnapshotJob($run);
+ $job->handle(
+ app(BaselineSnapshotIdentity::class),
+ app(AuditLogger::class),
+ $opService,
+ );
+
+ $run->refresh();
+ expect($run->status)->toBe('completed');
+ expect($run->outcome)->toBe('succeeded');
+
+ $counts = is_array($run->summary_counts) ? $run->summary_counts : [];
+ expect((int) ($counts['total'] ?? 0))->toBe(3);
+ expect((int) ($counts['succeeded'] ?? 0))->toBe(3);
+
+ $snapshot = BaselineSnapshot::query()
+ ->where('baseline_profile_id', $profile->getKey())
+ ->first();
+
+ expect($snapshot)->not->toBeNull();
+ expect(BaselineSnapshotItem::query()->where('baseline_snapshot_id', $snapshot->getKey())->count())->toBe(3);
+
+ $profile->refresh();
+ expect($profile->active_snapshot_id)->toBe((int) $snapshot->getKey());
+});
+
+it('dedupes snapshots when content is unchanged', function () {
+ [$user, $tenant] = createUserWithTenant(role: 'owner');
+
+ $profile = BaselineProfile::factory()->active()->create([
+ 'workspace_id' => $tenant->workspace_id,
+ 'scope_jsonb' => ['policy_types' => ['deviceConfiguration']],
+ ]);
+
+ InventoryItem::factory()->count(2)->create([
+ 'tenant_id' => $tenant->getKey(),
+ 'workspace_id' => $tenant->workspace_id,
+ 'policy_type' => 'deviceConfiguration',
+ 'meta_jsonb' => ['odata_type' => '#microsoft.graph.deviceConfiguration', 'stable_field' => 'value'],
+ ]);
+
+ $opService = app(OperationRunService::class);
+ $idService = app(BaselineSnapshotIdentity::class);
+ $auditLogger = app(AuditLogger::class);
+
+ $run1 = $opService->ensureRunWithIdentity(
+ tenant: $tenant,
+ type: 'baseline_capture',
+ identityInputs: ['baseline_profile_id' => (int) $profile->getKey()],
+ context: [
+ 'baseline_profile_id' => (int) $profile->getKey(),
+ 'source_tenant_id' => (int) $tenant->getKey(),
+ 'effective_scope' => ['policy_types' => ['deviceConfiguration']],
+ ],
+ initiator: $user,
+ );
+
+ $job1 = new CaptureBaselineSnapshotJob($run1);
+ $job1->handle($idService, $auditLogger, $opService);
+
+ $snapshotCountAfterFirst = BaselineSnapshot::query()
+ ->where('baseline_profile_id', $profile->getKey())
+ ->count();
+
+ expect($snapshotCountAfterFirst)->toBe(1);
+
+ $run2 = OperationRun::create([
+ 'workspace_id' => (int) $tenant->workspace_id,
+ 'tenant_id' => (int) $tenant->getKey(),
+ 'user_id' => (int) $user->getKey(),
+ 'initiator_name' => $user->name,
+ 'type' => 'baseline_capture',
+ 'status' => 'queued',
+ 'outcome' => 'pending',
+ 'run_identity_hash' => hash('sha256', 'second-run-' . now()->timestamp),
+ 'context' => [
+ 'baseline_profile_id' => (int) $profile->getKey(),
+ 'source_tenant_id' => (int) $tenant->getKey(),
+ 'effective_scope' => ['policy_types' => ['deviceConfiguration']],
+ ],
+ ]);
+
+ $job2 = new CaptureBaselineSnapshotJob($run2);
+ $job2->handle($idService, $auditLogger, $opService);
+
+ $snapshotCountAfterSecond = BaselineSnapshot::query()
+ ->where('baseline_profile_id', $profile->getKey())
+ ->count();
+
+ expect($snapshotCountAfterSecond)->toBe(1);
+});
+
+// --- EC-005: Empty scope produces empty snapshot without errors ---
+
+it('captures an empty snapshot when no inventory items match the scope', function () {
+ [$user, $tenant] = createUserWithTenant(role: 'owner');
+
+ $profile = BaselineProfile::factory()->active()->create([
+ 'workspace_id' => $tenant->workspace_id,
+ 'scope_jsonb' => ['policy_types' => ['nonExistentPolicyType']],
+ ]);
+
+ $opService = app(OperationRunService::class);
+ $run = $opService->ensureRunWithIdentity(
+ tenant: $tenant,
+ type: 'baseline_capture',
+ identityInputs: ['baseline_profile_id' => (int) $profile->getKey()],
+ context: [
+ 'baseline_profile_id' => (int) $profile->getKey(),
+ 'source_tenant_id' => (int) $tenant->getKey(),
+ 'effective_scope' => ['policy_types' => ['nonExistentPolicyType']],
+ ],
+ initiator: $user,
+ );
+
+ $job = new CaptureBaselineSnapshotJob($run);
+ $job->handle(
+ app(BaselineSnapshotIdentity::class),
+ app(AuditLogger::class),
+ $opService,
+ );
+
+ $run->refresh();
+ expect($run->status)->toBe('completed');
+ expect($run->outcome)->toBe('succeeded');
+
+ $counts = is_array($run->summary_counts) ? $run->summary_counts : [];
+ expect((int) ($counts['total'] ?? 0))->toBe(0);
+ expect((int) ($counts['failed'] ?? 0))->toBe(0);
+
+ $snapshot = BaselineSnapshot::query()
+ ->where('baseline_profile_id', $profile->getKey())
+ ->first();
+
+ expect($snapshot)->not->toBeNull();
+ expect(BaselineSnapshotItem::query()->where('baseline_snapshot_id', $snapshot->getKey())->count())->toBe(0);
+});
+
+it('captures all inventory items when scope has empty policy_types (all types)', function () {
+ [$user, $tenant] = createUserWithTenant(role: 'owner');
+
+ $profile = BaselineProfile::factory()->active()->create([
+ 'workspace_id' => $tenant->workspace_id,
+ 'scope_jsonb' => ['policy_types' => []],
+ ]);
+
+ InventoryItem::factory()->create([
+ 'tenant_id' => $tenant->getKey(),
+ 'workspace_id' => $tenant->workspace_id,
+ 'policy_type' => 'deviceConfiguration',
+ ]);
+
+ InventoryItem::factory()->create([
+ 'tenant_id' => $tenant->getKey(),
+ 'workspace_id' => $tenant->workspace_id,
+ 'policy_type' => 'compliancePolicy',
+ ]);
+
+ $opService = app(OperationRunService::class);
+ $run = $opService->ensureRunWithIdentity(
+ tenant: $tenant,
+ type: 'baseline_capture',
+ identityInputs: ['baseline_profile_id' => (int) $profile->getKey()],
+ context: [
+ 'baseline_profile_id' => (int) $profile->getKey(),
+ 'source_tenant_id' => (int) $tenant->getKey(),
+ 'effective_scope' => ['policy_types' => []],
+ ],
+ initiator: $user,
+ );
+
+ $job = new CaptureBaselineSnapshotJob($run);
+ $job->handle(
+ app(BaselineSnapshotIdentity::class),
+ app(AuditLogger::class),
+ $opService,
+ );
+
+ $run->refresh();
+ expect($run->status)->toBe('completed');
+
+ $counts = is_array($run->summary_counts) ? $run->summary_counts : [];
+ expect((int) ($counts['total'] ?? 0))->toBe(2);
+
+ $snapshot = BaselineSnapshot::query()
+ ->where('baseline_profile_id', $profile->getKey())
+ ->first();
+
+ expect($snapshot)->not->toBeNull();
+ expect(BaselineSnapshotItem::query()->where('baseline_snapshot_id', $snapshot->getKey())->count())->toBe(2);
+});
diff --git a/tests/Feature/Baselines/BaselineCompareFindingsTest.php b/tests/Feature/Baselines/BaselineCompareFindingsTest.php
new file mode 100644
index 0000000..bbf8704
--- /dev/null
+++ b/tests/Feature/Baselines/BaselineCompareFindingsTest.php
@@ -0,0 +1,414 @@
+active()->create([
+ 'workspace_id' => $tenant->workspace_id,
+ 'scope_jsonb' => ['policy_types' => ['deviceConfiguration']],
+ ]);
+
+ $snapshot = BaselineSnapshot::factory()->create([
+ 'workspace_id' => $tenant->workspace_id,
+ 'baseline_profile_id' => $profile->getKey(),
+ ]);
+
+ $profile->update(['active_snapshot_id' => $snapshot->getKey()]);
+
+ // Baseline has policyA and policyB
+ BaselineSnapshotItem::factory()->create([
+ 'baseline_snapshot_id' => $snapshot->getKey(),
+ 'subject_type' => 'policy',
+ 'subject_external_id' => 'policy-a-uuid',
+ 'policy_type' => 'deviceConfiguration',
+ 'baseline_hash' => hash('sha256', 'content-a'),
+ 'meta_jsonb' => ['display_name' => 'Policy A'],
+ ]);
+ BaselineSnapshotItem::factory()->create([
+ 'baseline_snapshot_id' => $snapshot->getKey(),
+ 'subject_type' => 'policy',
+ 'subject_external_id' => 'policy-b-uuid',
+ 'policy_type' => 'deviceConfiguration',
+ 'baseline_hash' => hash('sha256', 'content-b'),
+ 'meta_jsonb' => ['display_name' => 'Policy B'],
+ ]);
+
+ // Tenant has policyA (different content) and policyC (unexpected)
+ InventoryItem::factory()->create([
+ 'tenant_id' => $tenant->getKey(),
+ 'workspace_id' => $tenant->workspace_id,
+ 'external_id' => 'policy-a-uuid',
+ 'policy_type' => 'deviceConfiguration',
+ 'meta_jsonb' => ['different_content' => true],
+ 'display_name' => 'Policy A modified',
+ ]);
+ InventoryItem::factory()->create([
+ 'tenant_id' => $tenant->getKey(),
+ 'workspace_id' => $tenant->workspace_id,
+ 'external_id' => 'policy-c-uuid',
+ 'policy_type' => 'deviceConfiguration',
+ 'meta_jsonb' => ['new_policy' => true],
+ 'display_name' => 'Policy C unexpected',
+ ]);
+
+ $opService = app(OperationRunService::class);
+ $run = $opService->ensureRunWithIdentity(
+ tenant: $tenant,
+ type: 'baseline_compare',
+ identityInputs: ['baseline_profile_id' => (int) $profile->getKey()],
+ context: [
+ 'baseline_profile_id' => (int) $profile->getKey(),
+ 'baseline_snapshot_id' => (int) $snapshot->getKey(),
+ 'effective_scope' => ['policy_types' => ['deviceConfiguration']],
+ ],
+ initiator: $user,
+ );
+
+ $job = new CompareBaselineToTenantJob($run);
+ $job->handle(
+ app(DriftHasher::class),
+ app(BaselineSnapshotIdentity::class),
+ app(AuditLogger::class),
+ $opService,
+ );
+
+ $run->refresh();
+ expect($run->status)->toBe('completed');
+ expect($run->outcome)->toBe('succeeded');
+
+ $scopeKey = 'baseline_profile:' . $profile->getKey();
+
+ $findings = Finding::query()
+ ->where('tenant_id', $tenant->getKey())
+ ->where('source', 'baseline.compare')
+ ->where('scope_key', $scopeKey)
+ ->get();
+
+ // policyB missing (high), policyA different (medium), policyC unexpected (low) = 3 findings
+ expect($findings->count())->toBe(3);
+
+ $severities = $findings->pluck('severity')->sort()->values()->all();
+ expect($severities)->toContain(Finding::SEVERITY_HIGH);
+ expect($severities)->toContain(Finding::SEVERITY_MEDIUM);
+ expect($severities)->toContain(Finding::SEVERITY_LOW);
+});
+
+it('produces idempotent fingerprints so re-running compare updates existing findings', function () {
+ [$user, $tenant] = createUserWithTenant(role: 'owner');
+
+ $profile = BaselineProfile::factory()->active()->create([
+ 'workspace_id' => $tenant->workspace_id,
+ 'scope_jsonb' => ['policy_types' => ['deviceConfiguration']],
+ ]);
+
+ $snapshot = BaselineSnapshot::factory()->create([
+ 'workspace_id' => $tenant->workspace_id,
+ 'baseline_profile_id' => $profile->getKey(),
+ ]);
+
+ $profile->update(['active_snapshot_id' => $snapshot->getKey()]);
+
+ BaselineSnapshotItem::factory()->create([
+ 'baseline_snapshot_id' => $snapshot->getKey(),
+ 'subject_type' => 'policy',
+ 'subject_external_id' => 'policy-x-uuid',
+ 'policy_type' => 'deviceConfiguration',
+ 'baseline_hash' => hash('sha256', 'baseline-content'),
+ 'meta_jsonb' => ['display_name' => 'Policy X'],
+ ]);
+
+ // Tenant does NOT have policy-x → missing_policy finding
+ $opService = app(OperationRunService::class);
+
+ // First run
+ $run1 = $opService->ensureRunWithIdentity(
+ tenant: $tenant,
+ type: 'baseline_compare',
+ identityInputs: ['baseline_profile_id' => (int) $profile->getKey()],
+ context: [
+ 'baseline_profile_id' => (int) $profile->getKey(),
+ 'baseline_snapshot_id' => (int) $snapshot->getKey(),
+ 'effective_scope' => ['policy_types' => ['deviceConfiguration']],
+ ],
+ initiator: $user,
+ );
+
+ $job1 = new CompareBaselineToTenantJob($run1);
+ $job1->handle(
+ app(DriftHasher::class),
+ app(BaselineSnapshotIdentity::class),
+ app(AuditLogger::class),
+ $opService,
+ );
+
+ $scopeKey = 'baseline_profile:' . $profile->getKey();
+ $countAfterFirst = Finding::query()
+ ->where('tenant_id', $tenant->getKey())
+ ->where('source', 'baseline.compare')
+ ->where('scope_key', $scopeKey)
+ ->count();
+
+ expect($countAfterFirst)->toBe(1);
+
+ // Second run - new OperationRun so we can dispatch again
+ // Mark first run as completed so ensureRunWithIdentity creates a new one
+ $run1->update(['status' => 'completed', 'completed_at' => now()]);
+
+ $run2 = $opService->ensureRunWithIdentity(
+ tenant: $tenant,
+ type: 'baseline_compare',
+ identityInputs: ['baseline_profile_id' => (int) $profile->getKey()],
+ context: [
+ 'baseline_profile_id' => (int) $profile->getKey(),
+ 'baseline_snapshot_id' => (int) $snapshot->getKey(),
+ 'effective_scope' => ['policy_types' => ['deviceConfiguration']],
+ ],
+ initiator: $user,
+ );
+
+ $job2 = new CompareBaselineToTenantJob($run2);
+ $job2->handle(
+ app(DriftHasher::class),
+ app(BaselineSnapshotIdentity::class),
+ app(AuditLogger::class),
+ $opService,
+ );
+
+ $countAfterSecond = Finding::query()
+ ->where('tenant_id', $tenant->getKey())
+ ->where('source', 'baseline.compare')
+ ->where('scope_key', $scopeKey)
+ ->count();
+
+ // Same fingerprint → same finding updated, not duplicated
+ expect($countAfterSecond)->toBe(1);
+});
+
+it('creates zero findings when baseline matches tenant inventory exactly', function () {
+ [$user, $tenant] = createUserWithTenant(role: 'owner');
+
+ $profile = BaselineProfile::factory()->active()->create([
+ 'workspace_id' => $tenant->workspace_id,
+ 'scope_jsonb' => ['policy_types' => ['deviceConfiguration']],
+ ]);
+
+ $snapshot = BaselineSnapshot::factory()->create([
+ 'workspace_id' => $tenant->workspace_id,
+ 'baseline_profile_id' => $profile->getKey(),
+ ]);
+
+ $profile->update(['active_snapshot_id' => $snapshot->getKey()]);
+
+ // Baseline item
+ $metaContent = ['policy_key' => 'value123'];
+ $driftHasher = app(DriftHasher::class);
+ $contentHash = $driftHasher->hashNormalized($metaContent);
+
+ BaselineSnapshotItem::factory()->create([
+ 'baseline_snapshot_id' => $snapshot->getKey(),
+ 'subject_type' => 'policy',
+ 'subject_external_id' => 'matching-uuid',
+ 'policy_type' => 'deviceConfiguration',
+ 'baseline_hash' => $contentHash,
+ 'meta_jsonb' => ['display_name' => 'Matching Policy'],
+ ]);
+
+ // Tenant inventory with same content → same hash
+ InventoryItem::factory()->create([
+ 'tenant_id' => $tenant->getKey(),
+ 'workspace_id' => $tenant->workspace_id,
+ 'external_id' => 'matching-uuid',
+ 'policy_type' => 'deviceConfiguration',
+ 'meta_jsonb' => $metaContent,
+ 'display_name' => 'Matching Policy',
+ ]);
+
+ $opService = app(OperationRunService::class);
+ $run = $opService->ensureRunWithIdentity(
+ tenant: $tenant,
+ type: 'baseline_compare',
+ identityInputs: ['baseline_profile_id' => (int) $profile->getKey()],
+ context: [
+ 'baseline_profile_id' => (int) $profile->getKey(),
+ 'baseline_snapshot_id' => (int) $snapshot->getKey(),
+ 'effective_scope' => ['policy_types' => ['deviceConfiguration']],
+ ],
+ initiator: $user,
+ );
+
+ $job = new CompareBaselineToTenantJob($run);
+ $job->handle(
+ app(DriftHasher::class),
+ app(BaselineSnapshotIdentity::class),
+ app(AuditLogger::class),
+ $opService,
+ );
+
+ $run->refresh();
+ expect($run->status)->toBe('completed');
+ expect($run->outcome)->toBe('succeeded');
+
+ $counts = is_array($run->summary_counts) ? $run->summary_counts : [];
+ expect((int) ($counts['total'] ?? -1))->toBe(0);
+
+ $findings = Finding::query()
+ ->where('tenant_id', $tenant->getKey())
+ ->where('source', 'baseline.compare')
+ ->count();
+
+ expect($findings)->toBe(0);
+});
+
+// --- T042: Summary counts severity breakdown tests ---
+
+it('writes severity breakdown in summary_counts', function () {
+ [$user, $tenant] = createUserWithTenant(role: 'owner');
+
+ $profile = BaselineProfile::factory()->active()->create([
+ 'workspace_id' => $tenant->workspace_id,
+ 'scope_jsonb' => ['policy_types' => ['deviceConfiguration']],
+ ]);
+
+ $snapshot = BaselineSnapshot::factory()->create([
+ 'workspace_id' => $tenant->workspace_id,
+ 'baseline_profile_id' => $profile->getKey(),
+ ]);
+
+ $profile->update(['active_snapshot_id' => $snapshot->getKey()]);
+
+ // 2 baseline items: one will be missing (high), one will be different (medium)
+ BaselineSnapshotItem::factory()->create([
+ 'baseline_snapshot_id' => $snapshot->getKey(),
+ 'subject_type' => 'policy',
+ 'subject_external_id' => 'missing-uuid',
+ 'policy_type' => 'deviceConfiguration',
+ 'baseline_hash' => hash('sha256', 'missing-content'),
+ 'meta_jsonb' => ['display_name' => 'Missing Policy'],
+ ]);
+ BaselineSnapshotItem::factory()->create([
+ 'baseline_snapshot_id' => $snapshot->getKey(),
+ 'subject_type' => 'policy',
+ 'subject_external_id' => 'changed-uuid',
+ 'policy_type' => 'deviceConfiguration',
+ 'baseline_hash' => hash('sha256', 'original-content'),
+ 'meta_jsonb' => ['display_name' => 'Changed Policy'],
+ ]);
+
+ // Tenant only has changed-uuid with different content + extra-uuid (unexpected)
+ InventoryItem::factory()->create([
+ 'tenant_id' => $tenant->getKey(),
+ 'workspace_id' => $tenant->workspace_id,
+ 'external_id' => 'changed-uuid',
+ 'policy_type' => 'deviceConfiguration',
+ 'meta_jsonb' => ['modified_content' => true],
+ 'display_name' => 'Changed Policy',
+ ]);
+ InventoryItem::factory()->create([
+ 'tenant_id' => $tenant->getKey(),
+ 'workspace_id' => $tenant->workspace_id,
+ 'external_id' => 'extra-uuid',
+ 'policy_type' => 'deviceConfiguration',
+ 'meta_jsonb' => ['extra_content' => true],
+ 'display_name' => 'Extra Policy',
+ ]);
+
+ $opService = app(OperationRunService::class);
+ $run = $opService->ensureRunWithIdentity(
+ tenant: $tenant,
+ type: 'baseline_compare',
+ identityInputs: ['baseline_profile_id' => (int) $profile->getKey()],
+ context: [
+ 'baseline_profile_id' => (int) $profile->getKey(),
+ 'baseline_snapshot_id' => (int) $snapshot->getKey(),
+ 'effective_scope' => ['policy_types' => ['deviceConfiguration']],
+ ],
+ initiator: $user,
+ );
+
+ $job = new CompareBaselineToTenantJob($run);
+ $job->handle(
+ app(DriftHasher::class),
+ app(BaselineSnapshotIdentity::class),
+ app(AuditLogger::class),
+ $opService,
+ );
+
+ $run->refresh();
+ $counts = is_array($run->summary_counts) ? $run->summary_counts : [];
+
+ expect((int) ($counts['total'] ?? -1))->toBe(3);
+ expect((int) ($counts['high'] ?? -1))->toBe(1); // missing-uuid
+ expect((int) ($counts['medium'] ?? -1))->toBe(1); // changed-uuid
+ expect((int) ($counts['low'] ?? -1))->toBe(1); // extra-uuid
+});
+
+it('writes result context with findings breakdown', function () {
+ [$user, $tenant] = createUserWithTenant(role: 'owner');
+
+ $profile = BaselineProfile::factory()->active()->create([
+ 'workspace_id' => $tenant->workspace_id,
+ 'scope_jsonb' => ['policy_types' => ['deviceConfiguration']],
+ ]);
+
+ $snapshot = BaselineSnapshot::factory()->create([
+ 'workspace_id' => $tenant->workspace_id,
+ 'baseline_profile_id' => $profile->getKey(),
+ ]);
+
+ $profile->update(['active_snapshot_id' => $snapshot->getKey()]);
+
+ // One missing policy
+ BaselineSnapshotItem::factory()->create([
+ 'baseline_snapshot_id' => $snapshot->getKey(),
+ 'subject_type' => 'policy',
+ 'subject_external_id' => 'gone-uuid',
+ 'policy_type' => 'deviceConfiguration',
+ 'baseline_hash' => hash('sha256', 'gone-content'),
+ ]);
+
+ $opService = app(OperationRunService::class);
+ $run = $opService->ensureRunWithIdentity(
+ tenant: $tenant,
+ type: 'baseline_compare',
+ identityInputs: ['baseline_profile_id' => (int) $profile->getKey()],
+ context: [
+ 'baseline_profile_id' => (int) $profile->getKey(),
+ 'baseline_snapshot_id' => (int) $snapshot->getKey(),
+ 'effective_scope' => ['policy_types' => ['deviceConfiguration']],
+ ],
+ initiator: $user,
+ );
+
+ $job = new CompareBaselineToTenantJob($run);
+ $job->handle(
+ app(DriftHasher::class),
+ app(BaselineSnapshotIdentity::class),
+ app(AuditLogger::class),
+ $opService,
+ );
+
+ $run->refresh();
+ $context = is_array($run->context) ? $run->context : [];
+ $result = $context['result'] ?? [];
+
+ expect($result)->toHaveKey('findings_total');
+ expect($result)->toHaveKey('findings_upserted');
+ expect($result)->toHaveKey('severity_breakdown');
+ expect((int) $result['findings_total'])->toBe(1);
+ expect((int) $result['findings_upserted'])->toBe(1);
+});
diff --git a/tests/Feature/Baselines/BaselineComparePreconditionsTest.php b/tests/Feature/Baselines/BaselineComparePreconditionsTest.php
new file mode 100644
index 0000000..b8df465
--- /dev/null
+++ b/tests/Feature/Baselines/BaselineComparePreconditionsTest.php
@@ -0,0 +1,178 @@
+startCompare($tenant, $user);
+
+ expect($result['ok'])->toBeFalse();
+ expect($result['reason_code'])->toBe(BaselineReasonCodes::COMPARE_NO_ASSIGNMENT);
+
+ Queue::assertNotPushed(CompareBaselineToTenantJob::class);
+ expect(OperationRun::query()->where('type', 'baseline_compare')->count())->toBe(0);
+});
+
+it('rejects compare when assigned profile is in draft status', function () {
+ Queue::fake();
+
+ [$user, $tenant] = createUserWithTenant(role: 'owner');
+
+ $profile = BaselineProfile::factory()->create([
+ 'workspace_id' => $tenant->workspace_id,
+ 'status' => BaselineProfile::STATUS_DRAFT,
+ ]);
+
+ BaselineTenantAssignment::create([
+ 'workspace_id' => (int) $tenant->workspace_id,
+ 'tenant_id' => (int) $tenant->getKey(),
+ 'baseline_profile_id' => (int) $profile->getKey(),
+ ]);
+
+ $service = app(BaselineCompareService::class);
+ $result = $service->startCompare($tenant, $user);
+
+ expect($result['ok'])->toBeFalse();
+ expect($result['reason_code'])->toBe(BaselineReasonCodes::COMPARE_PROFILE_NOT_ACTIVE);
+
+ Queue::assertNotPushed(CompareBaselineToTenantJob::class);
+ expect(OperationRun::query()->where('type', 'baseline_compare')->count())->toBe(0);
+});
+
+it('rejects compare when assigned profile is archived [EC-001]', function () {
+ Queue::fake();
+
+ [$user, $tenant] = createUserWithTenant(role: 'owner');
+
+ $profile = BaselineProfile::factory()->archived()->create([
+ 'workspace_id' => $tenant->workspace_id,
+ ]);
+
+ BaselineTenantAssignment::create([
+ 'workspace_id' => (int) $tenant->workspace_id,
+ 'tenant_id' => (int) $tenant->getKey(),
+ 'baseline_profile_id' => (int) $profile->getKey(),
+ ]);
+
+ $service = app(BaselineCompareService::class);
+ $result = $service->startCompare($tenant, $user);
+
+ expect($result['ok'])->toBeFalse();
+ expect($result['reason_code'])->toBe(BaselineReasonCodes::COMPARE_PROFILE_NOT_ACTIVE);
+
+ Queue::assertNotPushed(CompareBaselineToTenantJob::class);
+});
+
+it('rejects compare when profile has no active snapshot', function () {
+ Queue::fake();
+
+ [$user, $tenant] = createUserWithTenant(role: 'owner');
+
+ $profile = BaselineProfile::factory()->active()->create([
+ 'workspace_id' => $tenant->workspace_id,
+ 'active_snapshot_id' => null,
+ ]);
+
+ BaselineTenantAssignment::create([
+ 'workspace_id' => (int) $tenant->workspace_id,
+ 'tenant_id' => (int) $tenant->getKey(),
+ 'baseline_profile_id' => (int) $profile->getKey(),
+ ]);
+
+ $service = app(BaselineCompareService::class);
+ $result = $service->startCompare($tenant, $user);
+
+ expect($result['ok'])->toBeFalse();
+ expect($result['reason_code'])->toBe(BaselineReasonCodes::COMPARE_NO_ACTIVE_SNAPSHOT);
+
+ Queue::assertNotPushed(CompareBaselineToTenantJob::class);
+});
+
+it('enqueues compare successfully when all preconditions are met', function () {
+ Queue::fake();
+
+ [$user, $tenant] = createUserWithTenant(role: 'owner');
+
+ $profile = BaselineProfile::factory()->active()->create([
+ 'workspace_id' => $tenant->workspace_id,
+ 'scope_jsonb' => ['policy_types' => ['deviceConfiguration']],
+ ]);
+
+ $snapshot = BaselineSnapshot::factory()->create([
+ 'workspace_id' => $tenant->workspace_id,
+ 'baseline_profile_id' => $profile->getKey(),
+ ]);
+
+ $profile->update(['active_snapshot_id' => $snapshot->getKey()]);
+
+ BaselineTenantAssignment::create([
+ 'workspace_id' => (int) $tenant->workspace_id,
+ 'tenant_id' => (int) $tenant->getKey(),
+ 'baseline_profile_id' => (int) $profile->getKey(),
+ ]);
+
+ $service = app(BaselineCompareService::class);
+ $result = $service->startCompare($tenant, $user);
+
+ expect($result['ok'])->toBeTrue();
+ expect($result)->toHaveKey('run');
+
+ /** @var OperationRun $run */
+ $run = $result['run'];
+ expect($run->type)->toBe('baseline_compare');
+ expect($run->status)->toBe('queued');
+
+ $context = is_array($run->context) ? $run->context : [];
+ expect($context['baseline_profile_id'])->toBe((int) $profile->getKey());
+ expect($context['baseline_snapshot_id'])->toBe((int) $snapshot->getKey());
+
+ Queue::assertPushed(CompareBaselineToTenantJob::class);
+});
+
+// --- EC-004: Concurrent compare reuses active run ---
+
+it('reuses an existing active run for the same profile/tenant instead of creating a new one [EC-004]', function () {
+ Queue::fake();
+
+ [$user, $tenant] = createUserWithTenant(role: 'owner');
+
+ $profile = BaselineProfile::factory()->active()->create([
+ 'workspace_id' => $tenant->workspace_id,
+ ]);
+
+ $snapshot = BaselineSnapshot::factory()->create([
+ 'workspace_id' => $tenant->workspace_id,
+ 'baseline_profile_id' => $profile->getKey(),
+ ]);
+
+ $profile->update(['active_snapshot_id' => $snapshot->getKey()]);
+
+ BaselineTenantAssignment::create([
+ 'workspace_id' => (int) $tenant->workspace_id,
+ 'tenant_id' => (int) $tenant->getKey(),
+ 'baseline_profile_id' => (int) $profile->getKey(),
+ ]);
+
+ $service = app(BaselineCompareService::class);
+
+ $result1 = $service->startCompare($tenant, $user);
+ $result2 = $service->startCompare($tenant, $user);
+
+ expect($result1['ok'])->toBeTrue();
+ expect($result2['ok'])->toBeTrue();
+ expect($result1['run']->getKey())->toBe($result2['run']->getKey());
+ expect(OperationRun::query()->where('type', 'baseline_compare')->count())->toBe(1);
+});
diff --git a/tests/Feature/Baselines/BaselineProfileAuthorizationTest.php b/tests/Feature/Baselines/BaselineProfileAuthorizationTest.php
new file mode 100644
index 0000000..5817486
--- /dev/null
+++ b/tests/Feature/Baselines/BaselineProfileAuthorizationTest.php
@@ -0,0 +1,156 @@
+create();
+ $workspace = Workspace::factory()->create();
+
+ session()->put(WorkspaceContext::SESSION_KEY, (int) $workspace->getKey());
+
+ $response = $this->actingAs($user)
+ ->get(BaselineProfileResource::getUrl(panel: 'admin'));
+
+ expect($response->status())->toBeIn([403, 404, 302], 'Non-members should not get HTTP 200');
+ });
+
+ it('returns 404 for members accessing a profile from another workspace', function (): void {
+ [$user, $tenant] = createUserWithTenant(role: 'owner');
+
+ $otherWorkspace = Workspace::factory()->create();
+ $profile = BaselineProfile::factory()->create([
+ 'workspace_id' => (int) $otherWorkspace->getKey(),
+ ]);
+
+ $this->actingAs($user)
+ ->get(BaselineProfileResource::getUrl('view', ['record' => $profile], panel: 'admin'))
+ ->assertNotFound();
+ });
+
+ it('returns 200 for readonly members accessing list page', function (): void {
+ [$user] = createUserWithTenant(role: 'readonly');
+
+ $this->actingAs($user)
+ ->get(BaselineProfileResource::getUrl(panel: 'admin'))
+ ->assertOk();
+ });
+
+ it('returns 403 for members with mocked missing capability on list page', function (): void {
+ $workspace = Workspace::factory()->create();
+ $user = User::factory()->create();
+
+ WorkspaceMembership::factory()->create([
+ 'workspace_id' => (int) $workspace->getKey(),
+ 'user_id' => (int) $user->getKey(),
+ 'role' => 'readonly',
+ ]);
+
+ $resolver = \Mockery::mock(WorkspaceCapabilityResolver::class);
+ $resolver->shouldReceive('isMember')->andReturnTrue();
+ $resolver->shouldReceive('can')->andReturnFalse();
+ app()->instance(WorkspaceCapabilityResolver::class, $resolver);
+
+ session()->put(WorkspaceContext::SESSION_KEY, (int) $workspace->getKey());
+
+ $this->actingAs($user)
+ ->get(BaselineProfileResource::getUrl(panel: 'admin'))
+ ->assertForbidden();
+ });
+
+ it('returns 403 for readonly members accessing create page', function (): void {
+ [$user] = createUserWithTenant(role: 'readonly');
+
+ $this->actingAs($user)
+ ->get(BaselineProfileResource::getUrl('create', panel: 'admin'))
+ ->assertForbidden();
+ });
+
+ it('returns 200 for owner members accessing create page', function (): void {
+ [$user] = createUserWithTenant(role: 'owner');
+
+ $this->actingAs($user)
+ ->get(BaselineProfileResource::getUrl('create', panel: 'admin'))
+ ->assertOk();
+ });
+
+ it('returns 404 for members accessing profile from another workspace', function (): void {
+ [$user, $tenant] = createUserWithTenant(role: 'owner');
+
+ $otherWorkspace = Workspace::factory()->create();
+ $profile = BaselineProfile::factory()->create([
+ 'workspace_id' => (int) $otherWorkspace->getKey(),
+ ]);
+
+ $this->actingAs($user)
+ ->get(BaselineProfileResource::getUrl('view', ['record' => $profile], panel: 'admin'))
+ ->assertNotFound();
+ });
+
+ it('returns 403 for readonly members accessing edit page', function (): void {
+ [$user, $tenant] = createUserWithTenant(role: 'readonly');
+
+ $workspace = Workspace::query()->whereKey($tenant->workspace_id)->first();
+ $profile = BaselineProfile::factory()->create([
+ 'workspace_id' => (int) $workspace->getKey(),
+ ]);
+
+ $this->actingAs($user)
+ ->get(BaselineProfileResource::getUrl('edit', ['record' => $profile], panel: 'admin'))
+ ->assertForbidden();
+ });
+
+ it('returns 200 for owner members accessing edit page', function (): void {
+ [$user, $tenant] = createUserWithTenant(role: 'owner');
+
+ $workspace = Workspace::query()->whereKey($tenant->workspace_id)->first();
+ $profile = BaselineProfile::factory()->create([
+ 'workspace_id' => (int) $workspace->getKey(),
+ ]);
+
+ $this->actingAs($user)
+ ->get(BaselineProfileResource::getUrl('edit', ['record' => $profile], panel: 'admin'))
+ ->assertOk();
+ });
+});
+
+describe('BaselineProfile static authorization methods', function () {
+ it('canViewAny returns false for non-members', function (): void {
+ $user = User::factory()->create();
+ $workspace = Workspace::factory()->create();
+
+ $this->actingAs($user);
+ session()->put(WorkspaceContext::SESSION_KEY, (int) $workspace->getKey());
+
+ expect(BaselineProfileResource::canViewAny())->toBeFalse();
+ });
+
+ it('canViewAny returns true for members', function (): void {
+ [$user] = createUserWithTenant(role: 'readonly');
+
+ $this->actingAs($user);
+
+ expect(BaselineProfileResource::canViewAny())->toBeTrue();
+ });
+
+ it('canCreate returns true for managers and false for readonly', function (): void {
+ [$owner] = createUserWithTenant(role: 'owner');
+ $this->actingAs($owner);
+ expect(BaselineProfileResource::canCreate())->toBeTrue();
+
+ [$readonly] = createUserWithTenant(role: 'readonly');
+ $this->actingAs($readonly);
+ expect(BaselineProfileResource::canCreate())->toBeFalse();
+ });
+});
diff --git a/tests/Feature/Guards/ActionSurfaceContractTest.php b/tests/Feature/Guards/ActionSurfaceContractTest.php
index 5039e17..8189b42 100644
--- a/tests/Feature/Guards/ActionSurfaceContractTest.php
+++ b/tests/Feature/Guards/ActionSurfaceContractTest.php
@@ -2,6 +2,7 @@
declare(strict_types=1);
+use App\Filament\Resources\BaselineProfileResource;
use App\Filament\Resources\InventoryItemResource;
use App\Filament\Resources\InventoryItemResource\Pages\ListInventoryItems;
use App\Filament\Resources\OperationRunResource;
@@ -73,6 +74,24 @@
}
});
+it('discovers the baseline profile resource and validates its declaration', function (): void {
+ $components = collect(ActionSurfaceValidator::withBaselineExemptions()->discoveredComponents())
+ ->keyBy('className');
+
+ $baselineResource = $components->get(BaselineProfileResource::class);
+
+ expect($baselineResource)->not->toBeNull('BaselineProfileResource should be discovered by action surface discovery');
+ expect($baselineResource?->hasPanelScope(ActionSurfacePanelScope::Admin))->toBeTrue();
+
+ $declaration = BaselineProfileResource::actionSurfaceDeclaration();
+ $profiles = new ActionSurfaceProfileDefinition;
+
+ foreach ($profiles->requiredSlots($declaration->profile) as $slot) {
+ expect($declaration->slot($slot))
+ ->not->toBeNull("Missing required slot {$slot->value} in BaselineProfileResource declaration");
+ }
+});
+
it('ensures representative declarations satisfy required slots', function (): void {
$profiles = new ActionSurfaceProfileDefinition;
@@ -80,6 +99,7 @@
PolicyResource::class => PolicyResource::actionSurfaceDeclaration(),
OperationRunResource::class => OperationRunResource::actionSurfaceDeclaration(),
VersionsRelationManager::class => VersionsRelationManager::actionSurfaceDeclaration(),
+ BaselineProfileResource::class => BaselineProfileResource::actionSurfaceDeclaration(),
];
foreach ($declarations as $className => $declaration) {