Compare commits

...

5 Commits

Author SHA1 Message Date
74210bac2e feat: add baseline compare operator modes (#224)
## Summary
- add adaptive baseline compare presentation modes with `auto`, `dense`, and `compact` route handling on the existing matrix page
- compress support surfaces with staged filters, grouped legends, last-updated and passive refresh cues, compact single-tenant results, and dense multi-tenant scan rendering
- extend the matrix builder plus Pest and browser smoke coverage for visible-set-only compact and dense workflows

## Filament / Laravel notes
- Livewire v4 compliance preserved; no legacy Livewire v3 patterns introduced
- provider registration is unchanged; no `bootstrap/providers.php` changes were needed for this feature
- no globally searchable resources were changed by this branch
- no destructive actions were added; the existing compare action remains simulation-only and non-destructive
- asset strategy is unchanged; no new Filament assets were introduced

## Validation
- `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Filament/BaselineCompareMatrixPageTest.php tests/Feature/Baselines/BaselineCompareMatrixBuilderTest.php tests/Feature/Guards/ActionSurfaceContractTest.php tests/Browser/Spec190BaselineCompareMatrixSmokeTest.php`
- `80` tests passed with `673` assertions
- integrated browser smoke run on `http://localhost/admin/baseline-profiles/20/compare-matrix`

## Scope
- Spec 191 implementation
- spec contract updates in `spec.md`, `tasks.md`, and the logical OpenAPI contract

Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de>
Reviewed-on: #224
2026-04-11 15:48:22 +00:00
f7bbea2623 191-baseline-compare-operator-mode (#223)
## Summary
<!-- Kurz: Was ändert sich und warum? -->

## Spec-Driven Development (SDD)
- [ ] Es gibt eine Spec unter `specs/<NNN>-<feature>/`
- [ ] Enthaltene Dateien: `plan.md`, `tasks.md`, `spec.md`
- [ ] Spec beschreibt Verhalten/Acceptance Criteria (nicht nur Implementation)
- [ ] Wenn sich Anforderungen während der Umsetzung geändert haben: Spec/Plan/Tasks wurden aktualisiert

## Implementation
- [ ] Implementierung entspricht der Spec
- [ ] Edge cases / Fehlerfälle berücksichtigt
- [ ] Keine unbeabsichtigten Änderungen außerhalb des Scopes

## Tests
- [ ] Tests ergänzt/aktualisiert (Pest/PHPUnit)
- [ ] Relevante Tests lokal ausgeführt (`./vendor/bin/sail artisan test` oder `php artisan test`)

## Migration / Config / Ops (falls relevant)
- [ ] Migration(en) enthalten und getestet
- [ ] Rollback bedacht (rückwärts kompatibel, sichere Migration)
- [ ] Neue Env Vars dokumentiert (`.env.example` / Doku)
- [ ] Queue/cron/storage Auswirkungen geprüft

## UI (Filament/Livewire) (falls relevant)
- [ ] UI-Flows geprüft
- [ ] Screenshots/Notizen hinzugefügt

## Notes
<!-- Links, Screenshots, Follow-ups, offene Punkte -->

Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de>
Reviewed-on: #223
2026-04-11 12:51:46 +00:00
65e10a2020 Spec 190: tighten baseline compare matrix scanability (#222)
## Summary
- tighten the baseline compare matrix working surface with active filter scope summaries and clearer visible-set disclosure
- improve matrix scanability with a sticky subject column, calmer attention-first cell styling, and Filament form-based filter controls
- replace the misleading perpetual refresh loading state with a passive auto-refresh note and add focused regression coverage

## Testing
- `cd apps/platform && ./vendor/bin/sail bin pint --dirty --format agent`
- `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Filament/BaselineCompareMatrixPageTest.php`

## Notes
- this PR only contains the Spec 190 implementation changes on `190-baseline-compare-matrix`
- follow-up spec drafting for high-density operator mode was intentionally left out of this PR

Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de>
Reviewed-on: #222
2026-04-11 12:32:10 +00:00
eca19819d1 feat: add workspace baseline compare matrix (#221)
## Summary
- add a workspace-scoped baseline compare matrix page under baseline profiles
- derive matrix tenant summaries, subject rows, cell states, freshness, and trust from existing snapshots, compare runs, and findings
- add confirmation-gated `Compare assigned tenants` actions on the baseline detail and matrix surfaces without introducing a workspace umbrella run
- preserve matrix navigation context into tenant compare and finding drilldowns and add centralized matrix badge semantics
- include spec, plan, data model, contracts, quickstart, tasks, and focused feature/browser coverage for Spec 190

## Verification
- `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Unit/Badges/BaselineCompareMatrixBadgesTest.php tests/Feature/Baselines/BaselineCompareMatrixBuilderTest.php tests/Feature/Baselines/BaselineCompareMatrixCompareAllActionTest.php tests/Feature/Baselines/BaselineComparePerformanceGuardTest.php tests/Feature/Filament/BaselineCompareMatrixPageTest.php tests/Feature/Filament/BaselineProfileCompareStartSurfaceTest.php tests/Feature/Rbac/BaselineCompareMatrixAuthorizationTest.php tests/Feature/Guards/ActionSurfaceContractTest.php tests/Feature/Guards/NoAdHocStatusBadgesTest.php tests/Feature/Guards/NoDiagnosticWarningBadgesTest.php`
- `cd apps/platform && ./vendor/bin/sail bin pint --dirty --format agent`
- completed an integrated-browser smoke flow locally for matrix render, differ filter, finding drilldown round-trip, and `Compare assigned tenants` confirmation/action

Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de>
Reviewed-on: #221
2026-04-11 10:20:25 +00:00
2f45ff5a84 feat: add portfolio triage review state tracking (#220)
## Summary
- add tenant triage review-state persistence, fingerprinting, resolver logic, service layer, and migration for current affected-set tracking
- surface review-state and affected-set progress across tenant registry, tenant dashboard arrival continuity, and workspace overview
- extend RBAC, audit/badge support, specs, and test coverage for portfolio triage review-state workflows
- suppress expected hidden-page background transport failures in the global unhandled rejection logger while keeping visible-page failures logged

## Validation
- targeted Pest coverage added for tenant registry, workspace overview, arrival context, RBAC authorization, badges, fingerprinting, resolver behavior, and logger asset behavior
- code formatted with `cd apps/platform && ./vendor/bin/sail bin pint --dirty --format agent`

## Notes
- full suite was not re-run in this final step
- branch includes the spec artifacts under `specs/189-portfolio-triage-review-state/`

Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de>
Reviewed-on: #220
2026-04-10 21:35:17 +00:00
100 changed files with 12980 additions and 128 deletions

View File

@ -163,6 +163,12 @@ ## Active Technologies
- PostgreSQL unchanged; no new tables, caches, or durable workflow artifacts (187-portfolio-triage-arrival-context)
- PHP 8.4.15 + Laravel 12, Filament v5, Livewire v4, Pest v4, existing `ProviderConnection` model, `ProviderConnectionResolver`, `ProviderConnectionStateProjector`, `ProviderConnectionMutationService`, `ProviderConnectionHealthCheckJob`, `StartVerification`, `ProviderConnectionResource`, `TenantResource`, system directory pages, `BadgeCatalog`, `BadgeRenderer`, and shared provider-state Blade entries (188-provider-connection-state-cleanup)
- PostgreSQL with one narrow schema addition (`is_enabled`) followed by final removal of legacy `status` and `health_status` columns and their indexes (188-provider-connection-state-cleanup)
- PHP 8.4.15 + Laravel 12, Filament v5, Livewire v4, Pest v4, existing `WorkspaceOverviewBuilder`, `TenantResource`, `TenantDashboard`, `PortfolioArrivalContext`, `TenantBackupHealthResolver`, `RestoreSafetyResolver`, `BadgeCatalog`, `UiEnforcement`, and `AuditRecorder` patterns (189-portfolio-triage-review-state)
- PostgreSQL via Laravel Eloquent with one new table `tenant_triage_reviews` and no new external caches or background stores (189-portfolio-triage-review-state)
- PHP 8.4.15 + Laravel 12, Filament v5, Livewire v4, Pest v4, existing `BaselineCompareService`, `BaselineSnapshotTruthResolver`, `BaselineCompareStats`, `RelatedNavigationResolver`, `CanonicalNavigationContext`, `BadgeCatalog`, and `UiEnforcement` patterns (190-baseline-compare-matrix)
- PostgreSQL via existing `baseline_profiles`, `baseline_snapshots`, `baseline_snapshot_items`, `baseline_tenant_assignments`, `operation_runs`, and `findings` tables; no new persistence planned (190-baseline-compare-matrix)
- PHP 8.4.15 + Laravel 12, Filament v5, Livewire v4, Pest v4, Tailwind CSS v4, existing `BaselineCompareMatrixBuilder`, `BadgeCatalog`, `CanonicalNavigationContext`, and `UiEnforcement` patterns (191-baseline-compare-operator-mode)
- PostgreSQL via existing baseline, assignment, compare-run, and finding tables; no new persistence planned (191-baseline-compare-operator-mode)
- PHP 8.4.15 (feat/005-bulk-operations)
@ -197,8 +203,7 @@ ## Code Style
PHP 8.4.15: Follow standard conventions
## Recent Changes
- 188-provider-connection-state-cleanup: Added PHP 8.4.15 + Laravel 12, Filament v5, Livewire v4, Pest v4, existing `ProviderConnection` model, `ProviderConnectionResolver`, `ProviderConnectionStateProjector`, `ProviderConnectionMutationService`, `ProviderConnectionHealthCheckJob`, `StartVerification`, `ProviderConnectionResource`, `TenantResource`, system directory pages, `BadgeCatalog`, `BadgeRenderer`, and shared provider-state Blade entries
- 187-portfolio-triage-arrival-context: Added PHP 8.4.15 + Laravel 12, Filament v5, Livewire v4, Pest v4, existing `WorkspaceOverviewBuilder`, `TenantResource`, `TenantDashboard`, `CanonicalAdminTenantFilterState`, `TenantBackupHealthAssessment`, `RestoreSafetyResolver`, and continuity-aware backup or restore list pages
- 186-tenant-registry-recovery-triage: Added PHP 8.4, Laravel 12, Blade, Filament v5, Livewire v4 + Filament v5 resources and table filters, Livewire v4 `ListRecords`, Pest v4, Laravel Sail, existing `TenantResource`, `ListTenants`, `WorkspaceOverviewBuilder`, `TenantBackupHealthResolver`, `TenantBackupHealthAssessment`, `RestoreSafetyResolver`, `RecoveryReadiness`, and shared badge infrastructure
- 191-baseline-compare-operator-mode: Added PHP 8.4.15 + Laravel 12, Filament v5, Livewire v4, Pest v4, Tailwind CSS v4, existing `BaselineCompareMatrixBuilder`, `BadgeCatalog`, `CanonicalNavigationContext`, and `UiEnforcement` patterns
- 190-baseline-compare-matrix: Added PHP 8.4.15 + Laravel 12, Filament v5, Livewire v4, Pest v4, existing `BaselineCompareService`, `BaselineSnapshotTruthResolver`, `BaselineCompareStats`, `RelatedNavigationResolver`, `CanonicalNavigationContext`, `BadgeCatalog`, and `UiEnforcement` patterns
<!-- MANUAL ADDITIONS START -->
<!-- MANUAL ADDITIONS END -->

View File

@ -109,6 +109,15 @@ ### Mandatory Bloat Check for New Specs (BLOAT-001)
6. Is this current-release truth or future-release preparation?
- Specs that cannot answer these questions clearly MUST NOT merge.
### Spec Candidate Gate (SPEC-GATE-001)
- Every new spec candidate MUST pass the Spec Approval Rubric (`.specify/memory/spec-approval-rubric.md`) before progressing beyond Draft status.
- The spec MUST include a filled-out "Spec Candidate Check" section answering the 5 mandatory questions (operator workflow, trust/safety, smallest version, permanent complexity, why now).
- The spec MUST be classified into exactly one approval class: Core Enterprise, Workflow Compression, Cleanup, or Defer.
- The spec MUST include a scored evaluation (6 dimensions, 02 each). Specs scoring below 7/12 MUST NOT be approved without explicit scope reduction.
- If two or more red flags from the rubric are triggered, the spec MUST include an explicit defense justifying why it should proceed.
- Specs classified as "Defer" or scoring 03 MUST NOT be implemented.
- This gate applies to all spec-creating agents (speckit.specify, speckit.plan) and manual spec creation alike.
### Default Bias (BIAS-001)
- Default codebase bias is: derive before persist, map before frameworkize, localize before generalize, simplify before extend, replace before layer, explicit before generic, and present directly before interpreting recursively.

View File

@ -0,0 +1,236 @@
# TenantPilot Spec Approval Rubric (Anti-Overengineering Guardrails)
## Leitsatz
> Kein neuer Layer ohne klaren Operatorgewinn, und kein neuer Spec nur für interne semantische Schönheit.
Ein neuer Spec ist nur dann stark genug, wenn er **sichtbar mehr Produktwahrheit oder Operator-Wirkung** erzeugt als er dauerhafte Systemkomplexität importiert.
Jeder Spec muss zwei Dinge gleichzeitig beweisen:
1. Welches echte Problem wird gelöst?
2. Warum ist diese Lösung die kleinste enterprise-taugliche Form?
Wenn der Spec nur interne Eleganz, feinere Semantik oder mehr Konsistenz bringt, aber keinen klaren Workflow-, Trust- oder Audit-Gewinn, dann ist er **verdächtig**.
---
## 5 Pflichtfragen vor jeder Freigabe
Ein Spec darf nur weiterverfolgt werden, wenn diese 5 Fragen sauber beantwortet sind.
### A. Welcher konkrete Operator-Workflow wird besser?
Nicht abstrakt „Konsistenz verbessern", sondern konkret: welcher Nutzer, auf welcher Fläche, in welchem Schritt, mit welchem heutigen Schmerz, und was danach schneller, sicherer oder ehrlicher wird.
Wenn kein klarer Vorher/Nachher-Workflow benennbar ist → Spec ist zu abstrakt.
### B. Welche falsche oder gefährliche Produktaussage wird verhindert?
Legitime Antworten:
- Falscher „alles okay"-Eindruck
- Irreführende Recovery-Claims
- Unsaubere Ownership
- Fehlende nächste Aktion
- Fehlende Audit-Nachvollziehbarkeit
- Tenant/Workspace Leakage
- RBAC-Missverständnisse
Wenn ein Spec weder Workflow noch Trust verbessert → kaum zu rechtfertigen.
### C. Was ist die kleinste brauchbare Version?
Explizit benennen:
- Was ist die v1-Minimalversion?
- Welche Teile sind bewusst nicht enthalten?
- Welche Generalisierung wird absichtlich verschoben?
Wenn v1 wie ein Framework, eine Plattform oder eine universelle Taxonomie klingt → zu groß.
### D. Welche dauerhafte Komplexität entsteht?
Nicht nur Implementierungsaufwand, sondern Dauerfolgen:
- Neue Models / Tables?
- Neue Enums / Statusachsen?
- Neue UI-Semantik?
- Neue cross-surface Contracts?
- Neue Tests, die dauerhaft gepflegt werden müssen?
- Neue Begriffe, die jeder verstehen muss?
Wenn die Liste lang ist → Produktgewinn muss entsprechend hoch sein.
### E. Warum jetzt?
Legitime Gründe:
- Blockiert Kernworkflow
- Verhindert gefährliche Fehlinterpretation
- Ist Voraussetzung für unmittelbar folgende Hauptdomäne
- Beseitigt echten systemischen Widerspruch
- Wird bereits von mehreren Flächen schmerzhaft benötigt
Schwache Gründe:
- „wäre sauberer"
- „brauchen wir später bestimmt"
- „passt gut zur Architektur"
- „macht das Modell vollständiger"
---
## 4 Spec-Klassen
Jeden Kandidaten zwingend in genau eine Klasse einordnen.
### Klasse 1 — Core Enterprise Spec
Mindestens eins muss stimmen:
- Schützt echte System-/Tenant-/RBAC-Korrektheit
- Verhindert falsche Governance-/Recovery-/Audit-Aussagen
- Schließt klaren Workflow-Gap
- Beseitigt cross-surface Widerspruch mit realem Operator-Schaden
- Ist echte Voraussetzung für eine wichtige Produktfunktion
Dürfen Komplexität einführen, aber nur gezielt.
### Klasse 2 — Workflow Compression Spec
Gut, wenn sie:
- Klickpfade verkürzen
- Kontextverlust senken
- Return-/Drilldown-Kontinuität verbessern
- Triage-/Review-/Run-Bearbeitung beschleunigen
Nützlich, aber klein halten.
### Klasse 3 — Cleanup / Consolidation
- Vereinfachung, Zusammenführung, Entkopplung
- Entfernen von Legacy / Duplikaten
- Reduktion unnötiger Schichten
Explizit erwünscht als Gegengewicht zu Wachstum.
### Klasse 4 — Premature / Defer
Wenn der Kandidat hauptsächlich bringt:
- Neue Semantik, Frameworks, Taxonomien
- Generalisierung für künftige Fälle
- Infrastruktur ohne breite aktuelle Nutzung
→ Nicht freigeben. Verschieben oder brutal einkürzen.
---
## Rote Flaggen
Wenn **zwei oder mehr** zutreffen → Spec muss aktiv verteidigt werden.
| # | Rote Flagge | Prüffrage |
|---|---|---|
| 1 | **Neue Achsen** — neues Truth-Modell, Statusdimension, Taxonomie, Bewertungsachse | Braucht der Operator das wirklich, oder nur das Modell? |
| 2 | **Neue Meta-Infrastruktur** — Presenter, Resolver, Catalog, Matrix, Registry, Builder, Policy-Layer | Sehr hoher Beweiswert nötig. |
| 3 | **Viele Flächen, wenig Nutzerwert** — 6 Flächen „harmonisiert", kein klarer Nutzerflow besser | Architektur um ihrer selbst willen? |
| 4 | **Klingt nach Foundation** — foundation, framework, generalized, reusable, future-proof, canonical semantics | Fast immer erklärungsbedürftig. |
| 5 | **Mehr Begriffe als Outcomes** — lange semantische Erklärung, Nutzerverbesserung kaum in einem Satz | Verdächtig. |
| 6 | **Mehrere Mikrospecs für eine Domäne** — foundation + semantics + presentation + hardening + integration | Zu fein zerlegt. |
---
## Grüne Flaggen
- Löst klar beobachtbaren Operator-Schmerz
- Verbessert echte Entscheidungssituation
- Verhindert konkrete Fehlinterpretation
- Reduziert Navigation oder Denkaufwand
- Vereinfacht bereits existierende Komplexität
- Führt wenig neue Begriffe ein
- Hat klare Nicht-Ziele
- Ist in einer Sitzung gut erklärbar
- Braucht keine neue Meta-Schicht
- Macht mehrere Flächen einfacher statt abstrakter
---
## Bewertungsraster (02 pro Dimension)
| Dimension | 0 | 1 | 2 |
|---|---|---|---|
| **Nutzen** | unklar | lokal nützlich | klarer Workflow-/Trust-/Audit-Gewinn |
| **Dringlichkeit** | kann warten | sinnvoll bald | blockiert oder schützt Wichtiges jetzt |
| **Scope-Disziplin** | wirkt wie Framework/Plattform | etwas breit | klar begrenzte v1 |
| **Komplexitätslast** | hohe dauerhafte Last | mittel | niedrig / gut beherrschbar |
| **Produktnähe** | vor allem intern/architektonisch | gemischt | direkt spürbar für Operatoren |
| **Wiederverwendung belegt** | hypothetisch | wahrscheinlich | bereits an mehreren echten Stellen nötig |
### Auswertung
| Score | Entscheidung |
|---|---|
| **1012** | Freigabefähig |
| **79** | Nur freigeben wenn Scope enger gezogen wird |
| **46** | Verschieben oder zu Cleanup/Micro-Follow-up downgraden |
| **03** | Nicht freigeben |
---
## TenantPilot-spezifische Regeln
### Regel A — Keine neue semantische Achse ohne UI-Beweis
Wo wird sie sichtbar? Warum reichen bestehende Achsen nicht? Welche Fehlentscheidung bleibt ohne sie bestehen?
### Regel B — Keine neue Support-/Presentation-Schicht ohne ≥ 3 echte Verbraucher
Registry, Resolver, Catalog, Presenter, Matrix, Explanation-Layer → nur mit mindestens drei echten (nicht künstlich erzeugten) Verbrauchern. Sonst lokal lösen.
### Regel C — Keine Spec-Aufspaltung unterhalb Operator-Domäne
Wenn ein Thema nicht eigenständig als Operator-Problem beschrieben werden kann → kein eigener Spec.
### Regel D — Jeder neue Status braucht eine echte Folgehandlung
Neue Status/Outcome nur erlaubt wenn sie etwas Konkretes ändern: andere nächste Aktion, anderes Routing, andere Audit-Bedeutung, andere Workflow-Behandlung.
### Regel E — Consolidation ist ein legitimer Spec-Typ
Zusammenführen von Semantik, Reduktion von Komplexität, Entfernen von Parallelmodellen, Vereinfachung von Navigation/Resolvern, Rückbau unnötiger Zwischenlayer — aktiv Platz geben.
---
## Freigabe-Template (Pflichtabschnitt in spec.md)
```markdown
## Spec Candidate Check
- **Problem**: [Konkreter Operator-Schmerz oder Trust-Gap heute]
- **Today's failure**: [Welche Fehlentscheidung / Verlangsamung / irreführende Produktaussage passiert aktuell?]
- **User-visible improvement**: [Was wird konkret schneller, sicherer oder ehrlicher?]
- **Smallest enterprise-capable version**: [Kleinste Version die das Problem sauber löst]
- **Explicit non-goals**: [Was wird bewusst nicht modelliert/generalisiert/frameworkisiert?]
- **Permanent complexity imported**: [Neue Models, Status, Enums, Services, Support-Layer, Tests, UI-Konzepte, Begriffe]
- **Why now**: [Warum jetzt wichtiger als später?]
- **Why not local**: [Warum reicht keine lokale, schmale Lösung?]
- **Approval class**: [Core Enterprise / Workflow Compression / Cleanup / Defer]
- **Red flags triggered**: [Welche roten Flaggen treffen zu?]
- **Score**: [Nutzen: _ | Dringlichkeit: _ | Scope: _ | Komplexität: _ | Produktnähe: _ | Wiederverwendung: _ | **Gesamt: _/12**]
- **Decision**: [approve / shrink / merge / defer / reject]
```
---
## Erlaubt vs. Verdächtig (Schnellreferenz)
| Erlaubt | Verdächtig |
|---|---|
| Echte Workflow-Specs | Neue truth sub-axes |
| Governance-/Finding-/Review-Bearbeitbarkeit | Neue explanation frameworks |
| Trust-/Audit-/RBAC-Härtung | Neue presentation taxonomies |
| Portfolio-Operator-Durchsatzverbesserungen | Neue generalized support layers |
| Consolidation-Specs | Mikro-Specs für bereits stark zerlegte Domänen |

View File

@ -5,6 +5,24 @@ # Feature Specification: [FEATURE NAME]
**Status**: Draft
**Input**: User description: "$ARGUMENTS"
## Spec Candidate Check *(mandatory — SPEC-GATE-001)*
<!-- This section MUST be completed before the spec progresses beyond Draft.
See .specify/memory/spec-approval-rubric.md for the full rubric. -->
- **Problem**: [Konkreter Operator-Schmerz oder Trust-Gap heute]
- **Today's failure**: [Welche Fehlentscheidung / Verlangsamung / irreführende Produktaussage passiert aktuell?]
- **User-visible improvement**: [Was wird konkret schneller, sicherer oder ehrlicher?]
- **Smallest enterprise-capable version**: [Kleinste Version die das Problem sauber löst]
- **Explicit non-goals**: [Was wird bewusst nicht modelliert/generalisiert/frameworkisiert?]
- **Permanent complexity imported**: [Neue Models, Status, Enums, Services, Support-Layer, Tests, UI-Konzepte, Begriffe]
- **Why now**: [Warum jetzt wichtiger als später?]
- **Why not local**: [Warum reicht keine lokale, schmale Lösung?]
- **Approval class**: [Core Enterprise / Workflow Compression / Cleanup / Defer]
- **Red flags triggered**: [Welche roten Flaggen treffen zu? Wenn ≥ 2: explizite Verteidigung nötig]
- **Score**: [Nutzen: _ | Dringlichkeit: _ | Scope: _ | Komplexität: _ | Produktnähe: _ | Wiederverwendung: _ | **Gesamt: _/12**]
- **Decision**: [approve / shrink / merge / defer / reject]
## Spec Scope Fields *(mandatory)*
- **Scope**: [workspace | tenant | canonical-view]

View File

@ -15,6 +15,7 @@
use App\Support\Baselines\BaselineCaptureMode;
use App\Support\Baselines\BaselineCompareEvidenceGapDetails;
use App\Support\Baselines\BaselineCompareStats;
use App\Support\Navigation\CanonicalNavigationContext;
use App\Support\Baselines\TenantGovernanceAggregate;
use App\Support\Baselines\TenantGovernanceAggregateResolver;
use App\Support\OperationRunLinks;
@ -109,6 +110,13 @@ class BaselineCompareLanding extends Page
/** @var array<string, mixed>|null */
public ?array $summaryAssessment = null;
/** @var array<string, mixed>|null */
public ?array $navigationContextPayload = null;
public ?int $matrixBaselineProfileId = null;
public ?string $matrixSubjectKey = null;
public static function canAccess(): bool
{
$user = auth()->user();
@ -130,6 +138,12 @@ public static function canAccess(): bool
public function mount(): void
{
$this->navigationContextPayload = is_array(request()->query('nav')) ? request()->query('nav') : null;
$baselineProfileId = request()->query('baseline_profile_id');
$subjectKey = request()->query('subject_key');
$this->matrixBaselineProfileId = is_numeric($baselineProfileId) ? (int) $baselineProfileId : null;
$this->matrixSubjectKey = is_string($subjectKey) && trim($subjectKey) !== '' ? trim($subjectKey) : null;
$this->refreshStats();
}
@ -244,6 +258,9 @@ protected function getViewData(): array
}
return [
'navigationContext' => $this->navigationContext()?->toQuery()['nav'] ?? null,
'matrixBaselineProfileId' => $this->matrixBaselineProfileId,
'matrixSubjectKey' => $this->matrixSubjectKey,
'hasCoverageWarnings' => $hasCoverageWarnings,
'evidenceGapsCountValue' => $evidenceGapsCountValue,
'hasEvidenceGaps' => $hasEvidenceGaps,
@ -302,9 +319,19 @@ public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
*/
protected function getHeaderActions(): array
{
return [
$this->compareNowAction(),
];
$actions = [];
$navigationContext = $this->navigationContext();
if ($navigationContext?->backLinkLabel !== null && $navigationContext->backLinkUrl !== null) {
$actions[] = Action::make('backToOrigin')
->label($navigationContext->backLinkLabel)
->color('gray')
->url($navigationContext->backLinkUrl);
}
$actions[] = $this->compareNowAction();
return $actions;
}
private function compareNowAction(): Action
@ -389,7 +416,7 @@ private function compareNowAction(): Action
->actions($run instanceof OperationRun ? [
Action::make('view_run')
->label('Open operation')
->url(OperationRunLinks::view($run, $tenant)),
->url(OperationRunLinks::view($run, $tenant, $this->navigationContext())),
] : [])
->send();
});
@ -436,4 +463,15 @@ private function governanceAggregate(Tenant $tenant, BaselineCompareStats $stats
return $aggregate;
}
private function navigationContext(): ?CanonicalNavigationContext
{
if (! is_array($this->navigationContextPayload)) {
return CanonicalNavigationContext::fromRequest(request());
}
$request = request()->duplicate(query: ['nav' => $this->navigationContextPayload]);
return CanonicalNavigationContext::fromRequest($request);
}
}

View File

@ -0,0 +1,756 @@
<?php
declare(strict_types=1);
namespace App\Filament\Pages;
use App\Filament\Resources\BaselineProfileResource;
use App\Filament\Resources\FindingResource;
use App\Models\BaselineProfile;
use App\Models\Tenant;
use App\Models\User;
use App\Models\Workspace;
use App\Services\Auth\WorkspaceCapabilityResolver;
use App\Services\Baselines\BaselineCompareService;
use App\Support\Auth\Capabilities;
use App\Support\Baselines\BaselineCompareMatrixBuilder;
use App\Support\Navigation\CanonicalNavigationContext;
use App\Support\OperationRunLinks;
use App\Support\OpsUx\OperationUxPresenter;
use App\Support\OpsUx\OpsUxBrowserEvents;
use App\Support\Rbac\WorkspaceUiEnforcement;
use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration;
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile;
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceSlot;
use Filament\Actions\Action;
use Filament\Forms\Components\Select;
use Filament\Forms\Concerns\InteractsWithForms;
use Filament\Forms\Contracts\HasForms;
use Filament\Notifications\Notification;
use Filament\Resources\Pages\Concerns\InteractsWithRecord;
use Filament\Resources\Pages\Page;
use Filament\Schemas\Components\Grid;
use Filament\Schemas\Schema;
class BaselineCompareMatrix extends Page implements HasForms
{
use InteractsWithForms;
use InteractsWithRecord;
protected static bool $isDiscovered = false;
protected static bool $shouldRegisterNavigation = false;
protected static string $resource = BaselineProfileResource::class;
protected static ?string $breadcrumb = 'Compare matrix';
protected string $view = 'filament.pages.baseline-compare-matrix';
public string $requestedMode = 'auto';
/**
* @var list<string>
*/
public array $selectedPolicyTypes = [];
/**
* @var list<string>
*/
public array $selectedStates = [];
/**
* @var list<string>
*/
public array $selectedSeverities = [];
public string $tenantSort = 'tenant_name';
public string $subjectSort = 'deviation_breadth';
public ?string $focusedSubjectKey = null;
/**
* @var list<string>
*/
public array $draftSelectedPolicyTypes = [];
/**
* @var list<string>
*/
public array $draftSelectedStates = [];
/**
* @var list<string>
*/
public array $draftSelectedSeverities = [];
public string $draftTenantSort = 'tenant_name';
public string $draftSubjectSort = 'deviation_breadth';
/**
* @var array<string, mixed>
*/
public array $matrix = [];
public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
{
return ActionSurfaceDeclaration::forPage(ActionSurfaceProfile::ListOnlyReadOnly)
->satisfy(ActionSurfaceSlot::ListHeader, 'Header actions keep bounded navigation plus confirmation-gated compare fan-out for visible assigned tenants.')
->exempt(ActionSurfaceSlot::InspectAffordance, 'The matrix intentionally forbids row click; only explicit tenant, subject, cell, and run drilldowns are rendered.')
->exempt(ActionSurfaceSlot::ListRowMoreMenu, 'The matrix does not use a row-level secondary-actions menu.')
->exempt(ActionSurfaceSlot::ListBulkMoreGroup, 'The matrix has no bulk actions.')
->satisfy(ActionSurfaceSlot::ListEmptyState, 'Blocked, empty, no-visible-tenant, and no-filter-match states render as explicit matrix empty states.')
->exempt(ActionSurfaceSlot::DetailHeader, 'The matrix is a page-level scan surface rather than a record detail header.');
}
public function mount(int|string $record): void
{
$this->record = $this->resolveRecord($record);
$this->hydrateFiltersFromRequest();
$this->refreshMatrix();
$this->form->fill($this->filterFormState());
}
public function form(Schema $schema): Schema
{
return $schema
->schema([
Grid::make([
'default' => 1,
'xl' => 2,
])
->schema([
Grid::make([
'default' => 1,
'lg' => 5,
])
->schema([
Select::make('draftSelectedPolicyTypes')
->label('Policy types')
->options(fn (): array => $this->matrixOptions('policyTypeOptions'))
->multiple()
->searchable()
->preload()
->native(false)
->placeholder('All policy types')
->helperText(fn (): ?string => $this->matrixOptions('policyTypeOptions') === []
? 'Policy type filters appear after a usable reference snapshot is available.'
: null)
->extraFieldWrapperAttributes([
'data-testid' => 'matrix-policy-type-filter',
])
->columnSpan([
'lg' => 2,
]),
Select::make('draftSelectedStates')
->label('Technical states')
->options(fn (): array => $this->matrixOptions('stateOptions'))
->multiple()
->searchable()
->native(false)
->placeholder('All technical states')
->columnSpan([
'lg' => 2,
]),
Select::make('draftSelectedSeverities')
->label('Severity')
->options(fn (): array => $this->matrixOptions('severityOptions'))
->multiple()
->searchable()
->native(false)
->placeholder('All severities'),
])
->columnSpan([
'xl' => 1,
]),
Grid::make([
'default' => 1,
'md' => 2,
'xl' => 1,
])
->schema([
Select::make('draftTenantSort')
->label('Tenant sort')
->options(fn (): array => $this->matrixOptions('tenantSortOptions'))
->default('tenant_name')
->native(false)
->extraFieldWrapperAttributes(['data-testid' => 'matrix-tenant-sort'])
->extraInputAttributes(['data-testid' => 'matrix-tenant-sort']),
Select::make('draftSubjectSort')
->label('Subject sort')
->options(fn (): array => $this->matrixOptions('subjectSortOptions'))
->default('deviation_breadth')
->native(false)
->extraFieldWrapperAttributes(['data-testid' => 'matrix-subject-sort'])
->extraInputAttributes(['data-testid' => 'matrix-subject-sort']),
])
->columnSpan([
'xl' => 1,
]),
]),
]);
}
protected function authorizeAccess(): void
{
$user = auth()->user();
$workspace = $this->workspace();
if (! $user instanceof User || ! $workspace instanceof Workspace) {
abort(404);
}
/** @var WorkspaceCapabilityResolver $resolver */
$resolver = app(WorkspaceCapabilityResolver::class);
if (! $resolver->isMember($user, $workspace)) {
abort(404);
}
if (! $resolver->can($user, $workspace, Capabilities::WORKSPACE_BASELINES_VIEW)) {
abort(403);
}
}
public function getTitle(): string
{
/** @var BaselineProfile $profile */
$profile = $this->getRecord();
return 'Compare matrix: '.$profile->name;
}
/**
* @return array<Action>
*/
protected function getHeaderActions(): array
{
$profile = $this->getRecord();
$compareAssignedTenantsAction = Action::make('compareAssignedTenants')
->label('Compare assigned tenants')
->icon('heroicon-o-play')
->requiresConfirmation()
->modalHeading('Compare assigned tenants')
->modalDescription('Simulation only. This starts the normal tenant-owned baseline compare path for the visible assigned set. No workspace umbrella run is created.')
->disabled(fn (): bool => $this->compareAssignedTenantsDisabledReason() !== null)
->tooltip(fn (): ?string => $this->compareAssignedTenantsDisabledReason())
->action(fn (): mixed => $this->compareAssignedTenants());
$compareAssignedTenantsAction = WorkspaceUiEnforcement::forAction(
$compareAssignedTenantsAction,
fn (): ?Workspace => $this->workspace(),
)
->requireCapability(Capabilities::WORKSPACE_BASELINES_MANAGE)
->preserveDisabled()
->tooltip('You need workspace baseline manage access to compare the visible assigned set.')
->apply();
return [
Action::make('backToBaselineProfile')
->label('Back to baseline profile')
->color('gray')
->url(BaselineProfileResource::getUrl('view', ['record' => $profile], panel: 'admin')),
$compareAssignedTenantsAction,
];
}
public function applyFilters(): void
{
$this->selectedPolicyTypes = $this->normalizeQueryList($this->draftSelectedPolicyTypes);
$this->selectedStates = $this->normalizeQueryList($this->draftSelectedStates);
$this->selectedSeverities = $this->normalizeQueryList($this->draftSelectedSeverities);
$this->tenantSort = $this->normalizeTenantSort($this->draftTenantSort);
$this->subjectSort = $this->normalizeSubjectSort($this->draftSubjectSort);
$this->redirect($this->filterUrl(), navigate: true);
}
public function resetFilters(): void
{
$this->selectedPolicyTypes = [];
$this->selectedStates = [];
$this->selectedSeverities = [];
$this->tenantSort = 'tenant_name';
$this->subjectSort = 'deviation_breadth';
$this->focusedSubjectKey = null;
$this->draftSelectedPolicyTypes = [];
$this->draftSelectedStates = [];
$this->draftSelectedSeverities = [];
$this->draftTenantSort = 'tenant_name';
$this->draftSubjectSort = 'deviation_breadth';
$this->redirect($this->filterUrl(), navigate: true);
}
public function refreshMatrix(): void
{
$user = auth()->user();
abort_unless($user instanceof User, 403);
/** @var BaselineProfile $profile */
$profile = $this->getRecord();
$this->matrix = app(BaselineCompareMatrixBuilder::class)->build($profile, $user, [
'policyTypes' => $this->selectedPolicyTypes,
'states' => $this->selectedStates,
'severities' => $this->selectedSeverities,
'tenantSort' => $this->tenantSort,
'subjectSort' => $this->subjectSort,
'focusedSubjectKey' => $this->focusedSubjectKey,
]);
}
public function pollMatrix(): void
{
$this->refreshMatrix();
}
public function tenantCompareUrl(int $tenantId, ?string $subjectKey = null): ?string
{
$tenant = $this->tenant($tenantId);
if (! $tenant instanceof Tenant) {
return null;
}
return BaselineCompareLanding::getUrl(
parameters: $this->navigationContext($tenant, $subjectKey)->toQuery(),
panel: 'tenant',
tenant: $tenant,
);
}
public function findingUrl(int $tenantId, int $findingId, ?string $subjectKey = null): ?string
{
$tenant = $this->tenant($tenantId);
if (! $tenant instanceof Tenant) {
return null;
}
return FindingResource::getUrl(
'view',
[
'record' => $findingId,
...$this->navigationContext($tenant, $subjectKey)->toQuery(),
],
tenant: $tenant,
);
}
public function runUrl(int $runId, ?int $tenantId = null, ?string $subjectKey = null): string
{
return OperationRunLinks::tenantlessView(
$runId,
$this->navigationContext(
$tenantId !== null ? $this->tenant($tenantId) : null,
$subjectKey,
),
);
}
public function clearSubjectFocusUrl(): string
{
return static::getUrl($this->routeParameters([
'subject_key' => null,
]), panel: 'admin');
}
public function modeUrl(string $mode): string
{
return $this->filterUrl([
'mode' => $this->normalizeRequestedMode($mode),
]);
}
public function filterUrl(array $overrides = []): string
{
return static::getUrl($this->routeParameters($overrides), panel: 'admin');
}
public function activeFilterCount(): int
{
return count($this->selectedPolicyTypes)
+ count($this->selectedStates)
+ count($this->selectedSeverities)
+ ($this->focusedSubjectKey !== null ? 1 : 0);
}
public function hasStagedFilterChanges(): bool
{
return $this->draftFilterState() !== $this->appliedFilterState();
}
public function canUseCompactMode(): bool
{
return $this->visibleTenantCount() <= 1;
}
public function presentationModeLabel(string $mode): string
{
return match ($mode) {
'dense' => 'Dense mode',
'compact' => 'Compact mode',
default => 'Auto mode',
};
}
/**
* @return array<string, int|string>
*/
public function activeFilterSummary(): array
{
$summary = [];
if ($this->selectedPolicyTypes !== []) {
$summary['Policy types'] = count($this->selectedPolicyTypes);
}
if ($this->selectedStates !== []) {
$summary['Technical states'] = count($this->selectedStates);
}
if ($this->selectedSeverities !== []) {
$summary['Severity'] = count($this->selectedSeverities);
}
if ($this->focusedSubjectKey !== null) {
$summary['Focused subject'] = $this->focusedSubjectKey;
}
return $summary;
}
/**
* @return array<string, int|string>
*/
public function stagedFilterSummary(): array
{
$summary = [];
if ($this->draftSelectedPolicyTypes !== $this->selectedPolicyTypes) {
$summary['Policy types'] = count($this->draftSelectedPolicyTypes);
}
if ($this->draftSelectedStates !== $this->selectedStates) {
$summary['Technical states'] = count($this->draftSelectedStates);
}
if ($this->draftSelectedSeverities !== $this->selectedSeverities) {
$summary['Severity'] = count($this->draftSelectedSeverities);
}
if ($this->draftTenantSort !== $this->tenantSort) {
$summary['Tenant sort'] = $this->draftTenantSort;
}
if ($this->draftSubjectSort !== $this->subjectSort) {
$summary['Subject sort'] = $this->draftSubjectSort;
}
return $summary;
}
/**
* @return array<string, mixed>
*/
protected function getViewData(): array
{
return array_merge($this->matrix, [
'profile' => $this->getRecord(),
'currentFilters' => [
'mode' => $this->requestedMode,
'policy_type' => $this->selectedPolicyTypes,
'state' => $this->selectedStates,
'severity' => $this->selectedSeverities,
'tenant_sort' => $this->tenantSort,
'subject_sort' => $this->subjectSort,
'subject_key' => $this->focusedSubjectKey,
],
'draftFilters' => [
'policy_type' => $this->draftSelectedPolicyTypes,
'state' => $this->draftSelectedStates,
'severity' => $this->draftSelectedSeverities,
'tenant_sort' => $this->draftTenantSort,
'subject_sort' => $this->draftSubjectSort,
],
'presentationState' => $this->presentationState(),
]);
}
private function hydrateFiltersFromRequest(): void
{
$this->requestedMode = $this->normalizeRequestedMode(request()->query('mode', 'auto'));
$this->selectedPolicyTypes = $this->normalizeQueryList(request()->query('policy_type', []));
$this->selectedStates = $this->normalizeQueryList(request()->query('state', []));
$this->selectedSeverities = $this->normalizeQueryList(request()->query('severity', []));
$this->tenantSort = $this->normalizeTenantSort(request()->query('tenant_sort', 'tenant_name'));
$this->subjectSort = $this->normalizeSubjectSort(request()->query('subject_sort', 'deviation_breadth'));
$subjectKey = request()->query('subject_key');
$this->focusedSubjectKey = is_string($subjectKey) && trim($subjectKey) !== '' ? trim($subjectKey) : null;
$this->draftSelectedPolicyTypes = $this->selectedPolicyTypes;
$this->draftSelectedStates = $this->selectedStates;
$this->draftSelectedSeverities = $this->selectedSeverities;
$this->draftTenantSort = $this->tenantSort;
$this->draftSubjectSort = $this->subjectSort;
}
/**
* @return array<string, mixed>
*/
private function filterFormState(): array
{
return [
'draftSelectedPolicyTypes' => $this->draftSelectedPolicyTypes,
'draftSelectedStates' => $this->draftSelectedStates,
'draftSelectedSeverities' => $this->draftSelectedSeverities,
'draftTenantSort' => $this->draftTenantSort,
'draftSubjectSort' => $this->draftSubjectSort,
];
}
/**
* @return array<string, string>
*/
private function matrixOptions(string $key): array
{
$options = $this->matrix[$key] ?? null;
return is_array($options) ? $options : [];
}
/**
* @return array{
* selectedPolicyTypes: list<string>,
* selectedStates: list<string>,
* selectedSeverities: list<string>,
* tenantSort: string,
* subjectSort: string
* }
*/
private function draftFilterState(): array
{
return [
'selectedPolicyTypes' => $this->normalizeQueryList($this->draftSelectedPolicyTypes),
'selectedStates' => $this->normalizeQueryList($this->draftSelectedStates),
'selectedSeverities' => $this->normalizeQueryList($this->draftSelectedSeverities),
'tenantSort' => $this->normalizeTenantSort($this->draftTenantSort),
'subjectSort' => $this->normalizeSubjectSort($this->draftSubjectSort),
];
}
/**
* @return array{
* selectedPolicyTypes: list<string>,
* selectedStates: list<string>,
* selectedSeverities: list<string>,
* tenantSort: string,
* subjectSort: string
* }
*/
private function appliedFilterState(): array
{
return [
'selectedPolicyTypes' => $this->selectedPolicyTypes,
'selectedStates' => $this->selectedStates,
'selectedSeverities' => $this->selectedSeverities,
'tenantSort' => $this->tenantSort,
'subjectSort' => $this->subjectSort,
];
}
/**
* @return list<string>
*/
private function normalizeQueryList(mixed $value): array
{
$values = is_array($value) ? $value : [$value];
return array_values(array_unique(array_filter(array_map(static function (mixed $item): ?string {
if (! is_string($item)) {
return null;
}
$normalized = trim($item);
return $normalized !== '' ? $normalized : null;
}, $values))));
}
private function normalizeRequestedMode(mixed $value): string
{
return in_array((string) $value, ['auto', 'dense', 'compact'], true)
? (string) $value
: 'auto';
}
private function normalizeTenantSort(mixed $value): string
{
return in_array((string) $value, ['tenant_name', 'deviation_count', 'freshness_urgency'], true)
? (string) $value
: 'tenant_name';
}
private function normalizeSubjectSort(mixed $value): string
{
return in_array((string) $value, ['deviation_breadth', 'policy_type', 'display_name'], true)
? (string) $value
: 'deviation_breadth';
}
private function compareAssignedTenantsDisabledReason(): ?string
{
$reference = is_array($this->matrix['reference'] ?? null) ? $this->matrix['reference'] : [];
if (($reference['referenceState'] ?? null) !== 'ready') {
return 'Capture a complete baseline snapshot before comparing assigned tenants.';
}
if ((int) ($reference['visibleTenantCount'] ?? 0) === 0) {
return 'No visible assigned tenants are available for compare.';
}
return null;
}
private function compareAssignedTenants(): void
{
$user = auth()->user();
if (! $user instanceof User) {
abort(403);
}
/** @var BaselineProfile $profile */
$profile = $this->getRecord();
$result = app(BaselineCompareService::class)->startCompareForVisibleAssignments($profile, $user);
$summary = sprintf(
'%d queued, %d already queued, %d blocked across %d visible assigned tenant%s.',
(int) $result['queuedCount'],
(int) $result['alreadyQueuedCount'],
(int) $result['blockedCount'],
(int) $result['visibleAssignedTenantCount'],
(int) $result['visibleAssignedTenantCount'] === 1 ? '' : 's',
);
if ((int) $result['queuedCount'] > 0 || (int) $result['alreadyQueuedCount'] > 0) {
OpsUxBrowserEvents::dispatchRunEnqueued($this);
$toast = (int) $result['queuedCount'] > 0
? OperationUxPresenter::queuedToast('baseline_compare')
: OperationUxPresenter::alreadyQueuedToast('baseline_compare');
$toast
->body($summary.' Open Operations for progress and next steps.')
->actions([
Action::make('open_operations')
->label('Open operations')
->url(OperationRunLinks::index(
context: $this->navigationContext(),
allTenants: true,
)),
])
->send();
} else {
Notification::make()
->title('No baseline compares were started')
->body($summary)
->warning()
->send();
}
$this->refreshMatrix();
}
/**
* @param array<string, mixed> $overrides
* @return array<string, mixed>
*/
private function routeParameters(array $overrides = []): array
{
return array_filter([
'record' => $this->getRecord(),
'mode' => $this->requestedMode !== 'auto' ? $this->requestedMode : null,
'policy_type' => $this->selectedPolicyTypes,
'state' => $this->selectedStates,
'severity' => $this->selectedSeverities,
'tenant_sort' => $this->tenantSort !== 'tenant_name' ? $this->tenantSort : null,
'subject_sort' => $this->subjectSort !== 'deviation_breadth' ? $this->subjectSort : null,
'subject_key' => $this->focusedSubjectKey,
...$overrides,
], static fn (mixed $value): bool => $value !== null && $value !== [] && $value !== '');
}
private function navigationContext(?Tenant $tenant = null, ?string $subjectKey = null): CanonicalNavigationContext
{
/** @var BaselineProfile $profile */
$profile = $this->getRecord();
$subjectKey ??= $this->focusedSubjectKey;
return CanonicalNavigationContext::forBaselineCompareMatrix(
profile: $profile,
filters: $this->routeParameters(),
tenant: $tenant,
subjectKey: $subjectKey,
);
}
private function tenant(int $tenantId): ?Tenant
{
return Tenant::query()
->whereKey($tenantId)
->where('workspace_id', (int) $this->getRecord()->workspace_id)
->first();
}
private function workspace(): ?Workspace
{
return Workspace::query()->whereKey((int) $this->getRecord()->workspace_id)->first();
}
/**
* @return array<string, mixed>
*/
private function presentationState(): array
{
$resolvedMode = $this->resolvePresentationMode($this->visibleTenantCount());
return [
'requestedMode' => $this->requestedMode,
'resolvedMode' => $resolvedMode,
'visibleTenantCount' => $this->visibleTenantCount(),
'activeFilterCount' => $this->activeFilterCount(),
'hasStagedFilterChanges' => $this->hasStagedFilterChanges(),
'autoRefreshActive' => (bool) ($this->matrix['hasActiveRuns'] ?? false),
'lastUpdatedAt' => $this->matrix['lastUpdatedAt'] ?? null,
'canOverrideMode' => $this->visibleTenantCount() > 0,
'compactModeAvailable' => $this->canUseCompactMode(),
];
}
private function visibleTenantCount(): int
{
$reference = is_array($this->matrix['reference'] ?? null) ? $this->matrix['reference'] : [];
return (int) ($reference['visibleTenantCount'] ?? 0);
}
private function resolvePresentationMode(int $visibleTenantCount): string
{
if ($this->requestedMode === 'dense') {
return 'dense';
}
if ($this->requestedMode === 'compact' && $visibleTenantCount <= 1) {
return 'compact';
}
return $visibleTenantCount > 1 ? 'dense' : 'compact';
}
}

View File

@ -4,6 +4,7 @@
namespace App\Filament\Resources;
use App\Filament\Pages\BaselineCompareMatrix;
use App\Filament\Resources\BaselineProfileResource\Pages;
use App\Models\BaselineProfile;
use App\Models\BaselineSnapshot;
@ -135,7 +136,7 @@ public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
->satisfy(ActionSurfaceSlot::ListRowMoreMenu, 'Clickable-row inspection stays primary while Edit leads and archive trails inside "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.');
->satisfy(ActionSurfaceSlot::DetailHeader, 'View page provides capture, compare-now, open-matrix, compare-assigned-tenants, and edit actions.');
}
public static function getEloquentQuery(): Builder
@ -447,10 +448,16 @@ public static function getPages(): array
'index' => Pages\ListBaselineProfiles::route('/'),
'create' => Pages\CreateBaselineProfile::route('/create'),
'view' => Pages\ViewBaselineProfile::route('/{record}'),
'compare-matrix' => BaselineCompareMatrix::route('/{record}/compare-matrix'),
'edit' => Pages\EditBaselineProfile::route('/{record}/edit'),
];
}
public static function compareMatrixUrl(BaselineProfile|int $profile): string
{
return static::getUrl('compare-matrix', ['record' => $profile], panel: 'admin');
}
/**
* @return array<string, string>
*/

View File

@ -44,6 +44,8 @@ protected function getHeaderActions(): array
->color('gray'),
$this->captureAction(),
$this->compareNowAction(),
$this->openCompareMatrixAction(),
$this->compareAssignedTenantsAction(),
EditAction::make()
->visible(fn (): bool => $this->hasManageCapability()),
];
@ -307,6 +309,80 @@ private function compareNowAction(): Action
});
}
private function openCompareMatrixAction(): Action
{
return Action::make('openCompareMatrix')
->label('Open compare matrix')
->icon('heroicon-o-squares-2x2')
->color('gray')
->url(fn (): string => BaselineProfileResource::compareMatrixUrl($this->getRecord()));
}
private function compareAssignedTenantsAction(): Action
{
$action = Action::make('compareAssignedTenants')
->label('Compare assigned tenants')
->icon('heroicon-o-play')
->requiresConfirmation()
->modalHeading('Compare assigned tenants')
->modalDescription('Simulation only. This starts the normal tenant-owned baseline compare path for the visible assigned set. No workspace umbrella run is created.')
->disabled(fn (): bool => $this->compareAssignedTenantsDisabledReason() !== null)
->tooltip(fn (): ?string => $this->compareAssignedTenantsDisabledReason())
->action(function (): void {
$user = auth()->user();
if (! $user instanceof User) {
abort(403);
}
/** @var BaselineProfile $profile */
$profile = $this->getRecord();
$result = app(BaselineCompareService::class)->startCompareForVisibleAssignments($profile, $user);
$summary = sprintf(
'%d queued, %d already queued, %d blocked across %d visible assigned tenant%s.',
(int) $result['queuedCount'],
(int) $result['alreadyQueuedCount'],
(int) $result['blockedCount'],
(int) $result['visibleAssignedTenantCount'],
(int) $result['visibleAssignedTenantCount'] === 1 ? '' : 's',
);
if ((int) $result['queuedCount'] > 0 || (int) $result['alreadyQueuedCount'] > 0) {
OpsUxBrowserEvents::dispatchRunEnqueued($this);
$toast = (int) $result['queuedCount'] > 0
? OperationUxPresenter::queuedToast('baseline_compare')
: OperationUxPresenter::alreadyQueuedToast('baseline_compare');
$toast
->body($summary.' Open Operations for progress and next steps.')
->actions([
Action::make('open_operations')
->label('Open operations')
->url(OperationRunLinks::index(allTenants: true)),
])
->send();
return;
}
Notification::make()
->title('No baseline compares were started')
->body($summary)
->warning()
->send();
});
return WorkspaceUiEnforcement::forAction(
$action,
fn (): ?Workspace => Workspace::query()->whereKey((int) $this->getRecord()->workspace_id)->first(),
)
->requireCapability(Capabilities::WORKSPACE_BASELINES_MANAGE)
->preserveDisabled()
->tooltip('You need workspace baseline manage access to compare the visible assigned set.')
->apply();
}
/**
* @return array<int, string>
*/
@ -407,4 +483,48 @@ private function profileHasConsumableSnapshot(): bool
return $profile->resolveCurrentConsumableSnapshot() !== null;
}
private function compareAssignedTenantsDisabledReason(): ?string
{
/** @var BaselineProfile $profile */
$profile = $this->getRecord();
if (! $this->profileHasConsumableSnapshot()) {
return 'Capture a complete baseline snapshot before comparing assigned tenants.';
}
if ($this->visibleAssignedTenantCount($profile) === 0) {
return 'No visible assigned tenants are available for compare.';
}
return null;
}
private function visibleAssignedTenantCount(BaselineProfile $profile): int
{
$user = auth()->user();
if (! $user instanceof User) {
return 0;
}
$tenantIds = BaselineTenantAssignment::query()
->where('workspace_id', (int) $profile->workspace_id)
->where('baseline_profile_id', (int) $profile->getKey())
->pluck('tenant_id')
->all();
if ($tenantIds === []) {
return 0;
}
$resolver = app(CapabilityResolver::class);
return Tenant::query()
->where('workspace_id', (int) $profile->workspace_id)
->whereIn('id', $tenantIds)
->get(['id'])
->filter(fn (Tenant $tenant): bool => $resolver->can($user, $tenant, Capabilities::TENANT_VIEW))
->count();
}
}

View File

@ -1257,6 +1257,16 @@ private static function primaryRelatedEntry(Finding $record, bool $fresh = false
private static function findingRunNavigationContext(Finding $record): CanonicalNavigationContext
{
$incomingContext = CanonicalNavigationContext::fromRequest(request());
if (
$incomingContext instanceof CanonicalNavigationContext
&& str_starts_with($incomingContext->sourceSurface, 'baseline_compare_matrix')
&& $incomingContext->backLinkUrl !== null
) {
return $incomingContext;
}
$tenant = $record->tenant;
return new CanonicalNavigationContext(

View File

@ -5,6 +5,7 @@
use App\Filament\Resources\FindingExceptionResource;
use App\Filament\Resources\FindingResource;
use App\Models\Finding;
use App\Support\Navigation\CanonicalNavigationContext;
use App\Support\Navigation\CrossResourceNavigationMatrix;
use App\Support\Navigation\RelatedNavigationResolver;
use Filament\Actions;
@ -23,7 +24,17 @@ protected function resolveRecord(int|string $key): Model
protected function getHeaderActions(): array
{
return [
$actions = [];
$navigationContext = $this->navigationContext();
if ($navigationContext?->backLinkLabel !== null && $navigationContext->backLinkUrl !== null) {
$actions[] = Actions\Action::make('back_to_origin')
->label($navigationContext->backLinkLabel)
->color('gray')
->url($navigationContext->backLinkUrl);
}
return array_merge($actions, [
Actions\Action::make('primary_related')
->label(fn (): string => app(RelatedNavigationResolver::class)
->primaryListAction(CrossResourceNavigationMatrix::SOURCE_FINDING, $this->getRecord())?->actionLabel ?? 'Open related record')
@ -53,11 +64,16 @@ protected function getHeaderActions(): array
->label('Actions')
->icon('heroicon-o-ellipsis-vertical')
->color('gray'),
];
]);
}
public function getSubheading(): string|Htmlable|null
{
return FindingResource::findingSubheading($this->getRecord());
}
private function navigationContext(): ?CanonicalNavigationContext
{
return CanonicalNavigationContext::fromRequest(request());
}
}

View File

@ -13,6 +13,7 @@
use App\Models\ProviderConnection;
use App\Models\Tenant;
use App\Models\TenantOnboardingSession;
use App\Models\TenantTriageReview;
use App\Models\User;
use App\Services\Audit\WorkspaceAuditLogger;
use App\Services\Auth\CapabilityResolver;
@ -25,6 +26,7 @@
use App\Services\OperationRunService;
use App\Services\Operations\BulkSelectionIdentity;
use App\Services\Providers\AdminConsentUrlFactory;
use App\Services\PortfolioTriage\TenantTriageReviewService;
use App\Services\Tenants\TenantActionPolicySurface;
use App\Services\Tenants\TenantOperabilityService;
use App\Services\Verification\StartVerification;
@ -42,6 +44,7 @@
use App\Support\OpsUx\OperationUxPresenter;
use App\Support\OpsUx\OpsUxBrowserEvents;
use App\Support\PortfolioTriage\PortfolioArrivalContextToken;
use App\Support\PortfolioTriage\TenantTriageReviewStateResolver;
use App\Support\Rbac\UiEnforcement;
use App\Support\RestoreSafety\RestoreSafetyResolver;
use App\Support\Tenants\TenantActionDescriptor;
@ -103,6 +106,8 @@ class TenantResource extends Resource
private const string POSTURE_SNAPSHOT_REQUEST_KEY = 'tenant_resource.posture_snapshot';
private const string TRIAGE_REVIEW_SNAPSHOT_REQUEST_KEY = 'tenant_resource.triage_review_snapshot';
/**
* @var array<string, true>
*/
@ -302,6 +307,21 @@ public static function table(Table $table): Table
->color(fn (Tenant $record): string => TenantRecoveryTriagePresentation::recoveryEvidenceTone(
static::recoveryEvidenceForTenant($record),
)),
Tables\Columns\TextColumn::make('review_state')
->label('Review state')
->badge()
->state(fn (Tenant $record, mixed $livewire): ?string => static::selectedTriageReviewRowForTenant(
$record,
static::currentPortfolioTriageState($livewire),
)['derived_state'] ?? null)
->formatStateUsing(BadgeRenderer::label(BadgeDomain::TenantTriageReviewState))
->color(BadgeRenderer::color(BadgeDomain::TenantTriageReviewState))
->icon(BadgeRenderer::icon(BadgeDomain::TenantTriageReviewState))
->iconColor(BadgeRenderer::iconColor(BadgeDomain::TenantTriageReviewState))
->description(fn (Tenant $record, mixed $livewire): ?string => static::triageReviewDescriptionForTenant(
$record,
static::currentPortfolioTriageState($livewire),
)),
Tables\Columns\TextColumn::make('tenant_id')
->label('Tenant ID')
->copyable()
@ -375,6 +395,17 @@ public static function table(Table $table): Table
static::sanitizeRecoveryEvidenceStates($data['values'] ?? []),
);
}),
Tables\Filters\SelectFilter::make('review_state')
->label('Review state')
->multiple()
->options(static::reviewStateOptions())
->query(function (Builder $query, array $data, mixed $livewire): Builder {
return static::applyReviewStateFilter(
$query,
static::sanitizeReviewStates($data['values'] ?? []),
static::currentPortfolioTriageState($livewire),
);
}),
Tables\Filters\SelectFilter::make('triage_sort')
->label('Sort order')
->options(TenantRecoveryTriagePresentation::triageSortOptions())
@ -387,26 +418,23 @@ public static function table(Table $table): Table
->icon(fn (Tenant $record): string => static::relatedOnboardingDraftAction($record, TenantActionSurface::TenantIndexRow)?->icon ?? 'heroicon-o-arrow-path')
->url(fn (Tenant $record): string => static::relatedOnboardingDraftUrl($record) ?? route('admin.onboarding'))
->visible(fn (Tenant $record): bool => static::tenantIndexPrimaryAction($record)?->key === 'related_onboarding'),
Actions\Action::make('openTenant')
->label('Open')
->icon('heroicon-o-arrow-right')
->color('primary')
->url(function (?Tenant $record = null, mixed $livewire = null): string {
Actions\Action::make('openTenant')
->label('Open')
->icon('heroicon-o-arrow-right')
->color('primary')
->url(function (?Tenant $record = null, mixed $livewire = null): string {
if (! $record instanceof Tenant) {
return '#';
}
$triageState = $livewire instanceof Pages\ListTenants
? static::portfolioReturnFilters(
static::backupPostureState($livewire),
static::recoveryEvidenceState($livewire),
static::triageSortState($livewire),
)
? static::currentPortfolioTriageState($livewire)
: [];
if (! static::hasActivePortfolioTriageState(
static::sanitizeBackupPostures($triageState['backup_posture'] ?? []),
static::sanitizeRecoveryEvidenceStates($triageState['recovery_evidence'] ?? []),
static::sanitizeReviewStates($triageState['review_state'] ?? []),
static::sanitizeTriageSort($triageState['triage_sort'] ?? null),
)) {
$triageState = static::portfolioReturnFiltersFromRequest(request()->query());
@ -684,6 +712,70 @@ public static function table(Table $table): Table
->preserveVisibility()
->requireCapability(Capabilities::PROVIDER_RUN)
->apply(),
Actions\Action::make('markReviewed')
->label('Mark reviewed')
->icon('heroicon-o-check-circle')
->color('success')
->requiresConfirmation()
->modalHeading('Mark reviewed')
->modalDescription(fn (Tenant $record, mixed $livewire): string => static::triageReviewActionModalDescription(
$record,
static::currentPortfolioTriageState($livewire),
TenantTriageReview::STATE_REVIEWED,
))
->visible(fn (Tenant $record, mixed $livewire): bool => static::selectedTriageReviewRowForTenant(
$record,
static::currentPortfolioTriageState($livewire),
) !== null && static::userCanSeeTriageReviewAction($record))
->disabled(fn (Tenant $record): bool => static::triageReviewActionIsDisabled($record))
->tooltip(fn (Tenant $record): ?string => static::triageReviewActionTooltip($record))
->before(function (Tenant $record): void {
static::authorizeTriageReviewAction($record);
})
->action(function (
Tenant $record,
mixed $livewire,
TenantTriageReviewService $service,
): void {
static::handleTriageReviewMutation(
tenant: $record,
triageState: static::currentPortfolioTriageState($livewire),
targetManualState: TenantTriageReview::STATE_REVIEWED,
service: $service,
);
}),
Actions\Action::make('markFollowUpNeeded')
->label('Mark follow-up needed')
->icon('heroicon-o-exclamation-triangle')
->color('warning')
->requiresConfirmation()
->modalHeading('Mark follow-up needed')
->modalDescription(fn (Tenant $record, mixed $livewire): string => static::triageReviewActionModalDescription(
$record,
static::currentPortfolioTriageState($livewire),
TenantTriageReview::STATE_FOLLOW_UP_NEEDED,
))
->visible(fn (Tenant $record, mixed $livewire): bool => static::selectedTriageReviewRowForTenant(
$record,
static::currentPortfolioTriageState($livewire),
) !== null && static::userCanSeeTriageReviewAction($record))
->disabled(fn (Tenant $record): bool => static::triageReviewActionIsDisabled($record))
->tooltip(fn (Tenant $record): ?string => static::triageReviewActionTooltip($record))
->before(function (Tenant $record): void {
static::authorizeTriageReviewAction($record);
})
->action(function (
Tenant $record,
mixed $livewire,
TenantTriageReviewService $service,
): void {
static::handleTriageReviewMutation(
tenant: $record,
triageState: static::currentPortfolioTriageState($livewire),
targetManualState: TenantTriageReview::STATE_FOLLOW_UP_NEEDED,
service: $service,
);
}),
UiEnforcement::forAction(
Actions\Action::make('restore')
->label(fn (Tenant $record): string => static::lifecycleActionDescriptor($record, TenantActionSurface::TenantIndexRow)?->label ?? 'Restore')
@ -929,6 +1021,17 @@ public static function sanitizeRecoveryEvidenceStates(mixed $value): array
);
}
/**
* @return list<string>
*/
public static function sanitizeReviewStates(mixed $value): array
{
return static::sanitizeRequestedValues(
$value,
TenantTriageReview::DERIVED_STATES,
);
}
public static function sanitizeTriageSort(mixed $value): ?string
{
if (! is_string($value) || ! isset(self::TRIAGE_SORT_VALUES[$value])) {
@ -942,6 +1045,7 @@ public static function sanitizeTriageSort(mixed $value): ?string
* @param array{
* backup_posture?: list<string>,
* recovery_evidence?: list<string>,
* review_state?: list<string>,
* triage_sort?: string|null
* } $triageState
*/
@ -966,6 +1070,7 @@ public static function tenantDashboardOpenUrl(Tenant $record, array $triageState
* @param array{
* backup_posture?: list<string>,
* recovery_evidence?: list<string>,
* review_state?: list<string>,
* triage_sort?: string|null
* } $triageState
* @return array<string, mixed>|null
@ -974,97 +1079,36 @@ private static function portfolioArrivalStateForTenant(Tenant $record, array $tr
{
$backupPostures = static::sanitizeBackupPostures($triageState['backup_posture'] ?? []);
$recoveryEvidenceFilters = static::sanitizeRecoveryEvidenceStates($triageState['recovery_evidence'] ?? []);
$reviewStates = static::sanitizeReviewStates($triageState['review_state'] ?? []);
$triageSort = static::sanitizeTriageSort($triageState['triage_sort'] ?? null);
if (! static::hasActivePortfolioTriageState($backupPostures, $recoveryEvidenceFilters, $triageSort)) {
if (! static::hasActivePortfolioTriageState($backupPostures, $recoveryEvidenceFilters, $reviewStates, $triageSort)) {
return null;
}
$backupAssessment = static::backupHealthAssessmentForTenant($record);
$backupPosture = $backupAssessment?->posture;
$recoveryEvidence = static::recoveryEvidenceForTenant($record);
$recoveryState = is_array($recoveryEvidence)
? TenantRecoveryTriagePresentation::recoveryEvidenceState($recoveryEvidence)
: null;
$matchedConcerns = [];
$selectedReviewRow = static::selectedTriageReviewRowForTenant(
$record,
static::portfolioReturnFilters(
$backupPostures,
$recoveryEvidenceFilters,
$reviewStates,
$triageSort,
),
);
if ($backupAssessment instanceof TenantBackupHealthAssessment
&& $backupPostures !== []
&& in_array($backupPosture, $backupPostures, true)
&& in_array($backupPosture, [
TenantBackupHealthAssessment::POSTURE_ABSENT,
TenantBackupHealthAssessment::POSTURE_STALE,
TenantBackupHealthAssessment::POSTURE_DEGRADED,
], true)) {
$matchedConcerns[] = [
'priority' => static::portfolioConcernPriority('backup_health', $backupPosture),
'family' => 'backup_health',
'state' => $backupPosture,
'reason' => $backupAssessment->primaryReason,
];
}
if (is_string($recoveryState)
&& $recoveryEvidenceFilters !== []
&& in_array($recoveryState, $recoveryEvidenceFilters, true)
&& in_array($recoveryState, [
TenantRecoveryTriagePresentation::RECOVERY_EVIDENCE_UNVALIDATED,
TenantRecoveryTriagePresentation::RECOVERY_EVIDENCE_WEAKENED,
], true)) {
$matchedConcerns[] = [
'priority' => static::portfolioConcernPriority('recovery_evidence', $recoveryState),
'family' => 'recovery_evidence',
'state' => $recoveryState,
'reason' => is_string($recoveryEvidence['reason'] ?? null) ? $recoveryEvidence['reason'] : null,
];
}
if ($matchedConcerns === [] && $triageSort === TenantRecoveryTriagePresentation::TRIAGE_SORT_WORST_FIRST) {
if ($backupAssessment instanceof TenantBackupHealthAssessment
&& in_array($backupPosture, [
TenantBackupHealthAssessment::POSTURE_ABSENT,
TenantBackupHealthAssessment::POSTURE_STALE,
TenantBackupHealthAssessment::POSTURE_DEGRADED,
], true)) {
$matchedConcerns[] = [
'priority' => static::portfolioConcernPriority('backup_health', $backupPosture),
'family' => 'backup_health',
'state' => $backupPosture,
'reason' => $backupAssessment->primaryReason,
];
}
if (is_string($recoveryState)
&& in_array($recoveryState, [
TenantRecoveryTriagePresentation::RECOVERY_EVIDENCE_UNVALIDATED,
TenantRecoveryTriagePresentation::RECOVERY_EVIDENCE_WEAKENED,
], true)) {
$matchedConcerns[] = [
'priority' => static::portfolioConcernPriority('recovery_evidence', $recoveryState),
'family' => 'recovery_evidence',
'state' => $recoveryState,
'reason' => is_string($recoveryEvidence['reason'] ?? null) ? $recoveryEvidence['reason'] : null,
];
}
}
if ($matchedConcerns === []) {
if ($selectedReviewRow === null) {
return null;
}
usort($matchedConcerns, static fn (array $left, array $right): int => $left['priority'] <=> $right['priority']);
$primaryConcern = $matchedConcerns[0];
$workspaceId = app(WorkspaceContext::class)->currentWorkspaceId(request());
return [
'sourceSurface' => PortfolioArrivalContextToken::SOURCE_TENANT_REGISTRY,
'tenantRouteKey' => filled($record->external_id) ? (string) $record->external_id : (string) $record->getKey(),
'workspaceId' => $workspaceId,
'concernFamily' => $primaryConcern['family'],
'concernState' => $primaryConcern['state'],
'concernReason' => $primaryConcern['reason'],
'returnFilters' => static::portfolioReturnFilters($backupPostures, $recoveryEvidenceFilters, $triageSort),
'concernFamily' => $selectedReviewRow['concern_family'],
'concernState' => $selectedReviewRow['current_state'],
'concernReason' => $selectedReviewRow['current_snapshot']['reasonCode'] ?? null,
'returnFilters' => static::portfolioReturnFilters($backupPostures, $recoveryEvidenceFilters, $reviewStates, $triageSort),
];
}
@ -1074,6 +1118,7 @@ private static function portfolioArrivalStateForTenant(Tenant $record, array $tr
public static function portfolioReturnFilters(
array $backupPostures,
array $recoveryEvidence,
array $reviewStates,
?string $triageSort,
): array {
$filters = [];
@ -1086,6 +1131,10 @@ public static function portfolioReturnFilters(
$filters['recovery_evidence'] = $recoveryEvidence;
}
if ($reviewStates !== []) {
$filters['review_state'] = $reviewStates;
}
if ($triageSort !== null) {
$filters['triage_sort'] = $triageSort;
}
@ -1102,6 +1151,7 @@ private static function portfolioReturnFiltersFromRequest(array $query): array
return static::portfolioReturnFilters(
static::sanitizeBackupPostures($query['backup_posture'] ?? []),
static::sanitizeRecoveryEvidenceStates($query['recovery_evidence'] ?? []),
static::sanitizeReviewStates($query['review_state'] ?? []),
static::sanitizeTriageSort($query['triage_sort'] ?? null),
);
}
@ -1109,14 +1159,16 @@ private static function portfolioReturnFiltersFromRequest(array $query): array
private static function hasActivePortfolioTriageState(
array $backupPostures,
array $recoveryEvidence,
array $reviewStates,
?string $triageSort,
): bool {
return $backupPostures !== []
|| $recoveryEvidence !== []
|| $reviewStates !== []
|| $triageSort === TenantRecoveryTriagePresentation::TRIAGE_SORT_WORST_FIRST;
}
private static function portfolioConcernPriority(string $family, string $state): int
public static function portfolioConcernPriority(string $family, string $state): int
{
return match (true) {
$family === 'backup_health' && $state === TenantBackupHealthAssessment::POSTURE_ABSENT => 1,
@ -1200,6 +1252,39 @@ private static function recoveryEvidenceState(mixed $livewire): array
return static::sanitizeRecoveryEvidenceStates($values);
}
/**
* @return list<string>
*/
private static function reviewStateState(mixed $livewire): array
{
if (! is_object($livewire) || ! method_exists($livewire, 'getTableFilterState')) {
return [];
}
$state = $livewire->getTableFilterState('review_state');
$values = is_array($state) ? ($state['values'] ?? []) : [];
return static::sanitizeReviewStates($values);
}
/**
* @return array{
* backup_posture: list<string>,
* recovery_evidence: list<string>,
* review_state: list<string>,
* triage_sort: string|null
* }
*/
public static function currentPortfolioTriageState(mixed $livewire): array
{
return [
'backup_posture' => static::backupPostureState($livewire),
'recovery_evidence' => static::recoveryEvidenceState($livewire),
'review_state' => static::reviewStateState($livewire),
'triage_sort' => static::triageSortState($livewire),
];
}
private static function triageSortIsWorstFirst(?string $value): bool
{
return $value === TenantRecoveryTriagePresentation::TRIAGE_SORT_WORST_FIRST;
@ -1218,6 +1303,414 @@ private static function recoveryEvidenceForTenant(Tenant $tenant): ?array
return static::postureSnapshot()['recovery_evidence'][(int) $tenant->getKey()] ?? null;
}
/**
* @return array<string, string>
*/
private static function reviewStateOptions(): array
{
return collect(TenantTriageReview::DERIVED_STATES)
->mapWithKeys(static fn (string $state): array => [
$state => BadgeRenderer::spec(BadgeDomain::TenantTriageReviewState, $state)->label,
])
->all();
}
/**
* @return array{
* rows: array<int, array{
* backup_health: array<string, mixed>,
* recovery_evidence: array<string, mixed>
* }>,
* summaries: array<string, array{
* concern_family: string,
* affected_total: int,
* reviewed_count: int,
* follow_up_needed_count: int,
* changed_since_review_count: int,
* not_reviewed_count: int
* }>
* }
*/
private static function triageReviewSnapshot(): array
{
$request = request();
$cached = $request->attributes->get(self::TRIAGE_REVIEW_SNAPSHOT_REQUEST_KEY);
if (is_array($cached)) {
return $cached;
}
$snapshot = static::postureSnapshot();
$workspaceId = app(WorkspaceContext::class)->currentWorkspaceId($request);
if (! is_int($workspaceId) || $workspaceId <= 0) {
$resolved = [
'rows' => [],
'summaries' => [],
];
$request->attributes->set(self::TRIAGE_REVIEW_SNAPSHOT_REQUEST_KEY, $resolved);
return $resolved;
}
$resolved = app(TenantTriageReviewStateResolver::class)->resolveMany(
workspaceId: $workspaceId,
tenantIds: $snapshot['tenant_ids'],
backupHealthByTenant: $snapshot['backup_health'],
recoveryEvidenceByTenant: $snapshot['recovery_evidence'],
);
$request->attributes->set(self::TRIAGE_REVIEW_SNAPSHOT_REQUEST_KEY, $resolved);
return $resolved;
}
/**
* @param array{
* backup_posture?: list<string>,
* recovery_evidence?: list<string>,
* review_state?: list<string>,
* triage_sort?: string|null
* } $triageState
* @return array<string, mixed>|null
*/
private static function selectedTriageReviewRowForTenant(Tenant $tenant, array $triageState = []): ?array
{
$rows = static::triageReviewSnapshot()['rows'][(int) $tenant->getKey()] ?? null;
if (! is_array($rows)) {
return null;
}
return static::selectedTriageReviewRow($rows, $triageState);
}
/**
* @param array{
* backup_health?: array<string, mixed>,
* recovery_evidence?: array<string, mixed>
* } $rows
* @param array{
* backup_posture?: list<string>,
* recovery_evidence?: list<string>,
* review_state?: list<string>,
* triage_sort?: string|null
* } $triageState
* @return array<string, mixed>|null
*/
private static function selectedTriageReviewRow(array $rows, array $triageState = []): ?array
{
$selectedFamily = static::selectedTriageReviewFamily($rows, $triageState);
if ($selectedFamily === null) {
return null;
}
$row = $rows[$selectedFamily] ?? null;
if (! is_array($row) || ($row['current_concern_present'] ?? false) !== true) {
return null;
}
$row['concern_family'] = $selectedFamily;
$row['concern_family_label'] = static::triageConcernFamilyLabel($selectedFamily);
return $row;
}
/**
* @param array{
* backup_health?: array<string, mixed>,
* recovery_evidence?: array<string, mixed>
* } $rows
* @param array{
* backup_posture?: list<string>,
* recovery_evidence?: list<string>,
* review_state?: list<string>,
* triage_sort?: string|null
* } $triageState
*/
private static function selectedTriageReviewFamily(array $rows, array $triageState = []): ?string
{
$backupPostures = static::sanitizeBackupPostures($triageState['backup_posture'] ?? []);
$recoveryEvidence = static::sanitizeRecoveryEvidenceStates($triageState['recovery_evidence'] ?? []);
if ($backupPostures !== [] && $recoveryEvidence === []) {
return (($rows[PortfolioArrivalContextToken::FAMILY_BACKUP_HEALTH]['current_concern_present'] ?? false) === true)
? PortfolioArrivalContextToken::FAMILY_BACKUP_HEALTH
: null;
}
if ($backupPostures === [] && $recoveryEvidence !== []) {
return (($rows[PortfolioArrivalContextToken::FAMILY_RECOVERY_EVIDENCE]['current_concern_present'] ?? false) === true)
? PortfolioArrivalContextToken::FAMILY_RECOVERY_EVIDENCE
: null;
}
$candidates = [];
foreach ([PortfolioArrivalContextToken::FAMILY_BACKUP_HEALTH, PortfolioArrivalContextToken::FAMILY_RECOVERY_EVIDENCE] as $family) {
$row = $rows[$family] ?? null;
if (! is_array($row) || ($row['current_concern_present'] ?? false) !== true) {
continue;
}
$currentState = $row['current_state'] ?? null;
if (! is_string($currentState) || $currentState === '') {
continue;
}
$candidates[] = [
'family' => $family,
'priority' => static::portfolioConcernPriority($family, $currentState),
];
}
if ($candidates === []) {
return null;
}
usort($candidates, static fn (array $left, array $right): int => $left['priority'] <=> $right['priority']);
return $candidates[0]['family'];
}
private static function triageConcernFamilyLabel(string $concernFamily): string
{
return match ($concernFamily) {
PortfolioArrivalContextToken::FAMILY_BACKUP_HEALTH => 'Backup health',
PortfolioArrivalContextToken::FAMILY_RECOVERY_EVIDENCE => 'Recovery evidence',
default => 'Portfolio concern',
};
}
/**
* @param array{
* backup_posture?: list<string>,
* recovery_evidence?: list<string>,
* review_state?: list<string>,
* triage_sort?: string|null
* } $triageState
*/
private static function triageReviewDescriptionForTenant(Tenant $tenant, array $triageState = []): ?string
{
$row = static::selectedTriageReviewRowForTenant($tenant, $triageState);
if ($row === null) {
return null;
}
$parts = [$row['concern_family_label']];
if (filled($row['reviewed_by_user_name'] ?? null)) {
$parts[] = 'by '.(string) $row['reviewed_by_user_name'];
}
if (($row['reviewed_at'] ?? null) instanceof \Illuminate\Support\Carbon) {
$parts[] = $row['reviewed_at']->diffForHumans();
}
return implode(' · ', $parts);
}
/**
* @param array{
* backup_posture?: list<string>,
* recovery_evidence?: list<string>,
* review_state?: list<string>,
* triage_sort?: string|null
* } $triageState
*/
private static function triageReviewActionModalDescription(
Tenant $tenant,
array $triageState,
string $targetManualState,
): string {
$row = static::selectedActionTriageReviewRowForTenant($tenant, $triageState);
if ($row === null) {
return 'This triage slice no longer points at a current visible concern.';
}
$currentLabel = BadgeRenderer::spec(BadgeDomain::TenantTriageReviewState, $row['derived_state'])->label;
$targetLabel = BadgeRenderer::spec(BadgeDomain::TenantTriageReviewState, $targetManualState)->label;
return implode("\n\n", [
'Concern family: '.$row['concern_family_label'],
'Current review state: '.$currentLabel,
'Target state: '.$targetLabel,
'Scope: TenantPilot only. This updates shared triage progress and does not change backup posture or recovery evidence.',
]);
}
private static function userCanSeeTriageReviewAction(Tenant $tenant): bool
{
$user = auth()->user();
if (! $user instanceof User) {
return false;
}
return app(CapabilityResolver::class)->isMember($user, $tenant);
}
private static function triageReviewActionIsDisabled(Tenant $tenant): bool
{
$user = auth()->user();
if (! $user instanceof User) {
return true;
}
$resolver = app(CapabilityResolver::class);
if (! $resolver->isMember($user, $tenant)) {
return true;
}
return ! $resolver->can($user, $tenant, Capabilities::TENANT_TRIAGE_REVIEW_MANAGE);
}
private static function triageReviewActionTooltip(Tenant $tenant): ?string
{
$user = auth()->user();
if (! $user instanceof User) {
return UiTooltips::insufficientPermission();
}
$resolver = app(CapabilityResolver::class);
if ($resolver->isMember($user, $tenant) && ! $resolver->can($user, $tenant, Capabilities::TENANT_TRIAGE_REVIEW_MANAGE)) {
return UiTooltips::insufficientPermission();
}
return null;
}
private static function authorizeTriageReviewAction(Tenant $tenant): void
{
$user = auth()->user();
if (! $user instanceof User) {
abort(403);
}
$resolver = app(CapabilityResolver::class);
if (! $resolver->isMember($user, $tenant)) {
abort(404);
}
if (! $resolver->can($user, $tenant, Capabilities::TENANT_TRIAGE_REVIEW_MANAGE)) {
abort(403);
}
}
/**
* @param array{
* backup_posture?: list<string>,
* recovery_evidence?: list<string>,
* review_state?: list<string>,
* triage_sort?: string|null
* } $triageState
*/
private static function handleTriageReviewMutation(
Tenant $tenant,
array $triageState,
string $targetManualState,
TenantTriageReviewService $service,
): void {
$row = static::selectedActionTriageReviewRowForTenant($tenant, $triageState);
if ($row === null) {
Notification::make()
->title('No current concern to update')
->body('This tenant no longer belongs to the current triage slice.')
->warning()
->send();
return;
}
$actor = auth()->user();
$backupHealth = static::backupHealthAssessmentForTenant($tenant);
$recoveryEvidence = static::recoveryEvidenceForTenant($tenant);
$review = match ($targetManualState) {
TenantTriageReview::STATE_REVIEWED => $service->markReviewed(
tenant: $tenant,
concernFamily: (string) $row['concern_family'],
backupHealth: $backupHealth,
recoveryEvidence: $recoveryEvidence,
actor: $actor instanceof User ? $actor : null,
),
TenantTriageReview::STATE_FOLLOW_UP_NEEDED => $service->markFollowUpNeeded(
tenant: $tenant,
concernFamily: (string) $row['concern_family'],
backupHealth: $backupHealth,
recoveryEvidence: $recoveryEvidence,
actor: $actor instanceof User ? $actor : null,
),
default => null,
};
if (! $review instanceof TenantTriageReview) {
return;
}
request()->attributes->remove(self::TRIAGE_REVIEW_SNAPSHOT_REQUEST_KEY);
Notification::make()
->title('Review state updated')
->body(sprintf(
'%s is now %s for %s.',
$tenant->name,
BadgeRenderer::spec(BadgeDomain::TenantTriageReviewState, $review->current_state)->label,
static::triageConcernFamilyLabel((string) $review->concern_family),
))
->success()
->send();
}
/**
* @param array{
* backup_posture?: list<string>,
* recovery_evidence?: list<string>,
* review_state?: list<string>,
* triage_sort?: string|null
* } $triageState
* @return array<string, mixed>|null
*/
private static function selectedActionTriageReviewRowForTenant(Tenant $tenant, array $triageState = []): ?array
{
$tenantId = (int) $tenant->getKey();
$workspaceId = (int) $tenant->workspace_id;
if ($tenantId <= 0 || $workspaceId <= 0) {
return null;
}
$backupHealth = app(TenantBackupHealthResolver::class)->assess($tenant);
$recoveryEvidence = app(RestoreSafetyResolver::class)->dashboardRecoveryEvidence($tenant);
$rows = app(TenantTriageReviewStateResolver::class)->resolveMany(
workspaceId: $workspaceId,
tenantIds: [$tenantId],
backupHealthByTenant: [$tenantId => $backupHealth],
recoveryEvidenceByTenant: [$tenantId => $recoveryEvidence],
)['rows'][$tenantId] ?? null;
if (! is_array($rows)) {
return null;
}
return static::selectedTriageReviewRow($rows, $triageState);
}
private static function applySnapshotTenantSubsetFilter(
Builder $query,
array $selectedValues,
@ -1266,6 +1759,48 @@ private static function applyRecoveryEvidenceFilter(Builder $query, array $selec
return $query->whereIn((new Tenant)->qualifyColumn('id'), $tenantIds);
}
/**
* @param list<string> $selectedValues
* @param array{
* backup_posture?: list<string>,
* recovery_evidence?: list<string>,
* review_state?: list<string>,
* triage_sort?: string|null
* } $triageState
*/
private static function applyReviewStateFilter(Builder $query, array $selectedValues, array $triageState): Builder
{
if ($selectedValues === []) {
return $query;
}
$tenantIds = collect(static::triageReviewSnapshot()['rows'])
->map(function (mixed $rows, mixed $tenantId) use ($selectedValues, $triageState): ?int {
if (! is_array($rows)) {
return null;
}
$row = static::selectedTriageReviewRow($rows, $triageState);
if ($row === null) {
return null;
}
return in_array((string) ($row['derived_state'] ?? ''), $selectedValues, true)
? (int) $tenantId
: null;
})
->filter(static fn (?int $tenantId): bool => is_int($tenantId))
->values()
->all();
if ($tenantIds === []) {
return $query->whereRaw('1 = 0');
}
return $query->whereIn((new Tenant)->qualifyColumn('id'), $tenantIds);
}
private static function applyWorstFirstTriageOrdering(Builder $query): Builder
{
$tiers = static::postureSnapshot()['triage_tiers'];

View File

@ -56,7 +56,7 @@ protected function getTableEmptyStateHeading(): ?string
protected function getTableEmptyStateDescription(): ?string
{
if ($this->hasActiveTriageEmptyState()) {
return 'Try a different backup posture or recovery evidence filter, or return to the default calm-browsing order.';
return 'Try a different backup posture, recovery evidence, or review-state filter, or return to the default calm-browsing order.';
}
return parent::getTableEmptyStateDescription();
@ -85,6 +85,7 @@ private function applyRequestedTriageIntent(): void
{
$hasIntent = request()->query->has('backup_posture')
|| request()->query->has('recovery_evidence')
|| request()->query->has('review_state')
|| request()->query->has('triage_sort');
if (! $hasIntent) {
@ -93,9 +94,10 @@ private function applyRequestedTriageIntent(): void
$backupPostures = TenantResource::sanitizeBackupPostures(request()->query('backup_posture'));
$recoveryEvidence = TenantResource::sanitizeRecoveryEvidenceStates(request()->query('recovery_evidence'));
$reviewStates = TenantResource::sanitizeReviewStates(request()->query('review_state'));
$triageSort = TenantResource::sanitizeTriageSort(request()->query('triage_sort'));
foreach (['backup_posture', 'recovery_evidence', 'triage_sort'] as $filterName) {
foreach (['backup_posture', 'recovery_evidence', 'review_state', 'triage_sort'] as $filterName) {
data_forget($this->tableFilters, $filterName);
data_forget($this->tableDeferredFilters, $filterName);
}
@ -110,6 +112,11 @@ private function applyRequestedTriageIntent(): void
$this->tableDeferredFilters['recovery_evidence']['values'] = $recoveryEvidence;
}
if ($reviewStates !== []) {
$this->tableFilters['review_state']['values'] = $reviewStates;
$this->tableDeferredFilters['review_state']['values'] = $reviewStates;
}
if ($triageSort !== null) {
$this->tableFilters['triage_sort']['value'] = $triageSort;
$this->tableDeferredFilters['triage_sort']['value'] = $triageSort;
@ -122,17 +129,19 @@ private function hasActiveTriageEmptyState(): bool
return $state['backup_posture'] !== []
|| $state['recovery_evidence'] !== []
|| $state['review_state'] !== []
|| $state['triage_sort'] === TenantRecoveryTriagePresentation::TRIAGE_SORT_WORST_FIRST;
}
/**
* @return array{backup_posture: list<string>, recovery_evidence: list<string>, triage_sort: string|null}
* @return array{backup_posture: list<string>, recovery_evidence: list<string>, review_state: list<string>, triage_sort: string|null}
*/
public function currentPortfolioTriageReturnState(): array
{
return [
'backup_posture' => TenantResource::sanitizeBackupPostures(data_get($this->tableFilters, 'backup_posture.values', [])),
'recovery_evidence' => TenantResource::sanitizeRecoveryEvidenceStates(data_get($this->tableFilters, 'recovery_evidence.values', [])),
'review_state' => TenantResource::sanitizeReviewStates(data_get($this->tableFilters, 'review_state.values', [])),
'triage_sort' => TenantResource::sanitizeTriageSort(data_get($this->tableFilters, 'triage_sort.value')),
];
}

View File

@ -5,18 +5,51 @@
namespace App\Filament\Widgets\Tenant;
use App\Models\Tenant;
use App\Models\TenantTriageReview;
use App\Models\User;
use App\Services\PortfolioTriage\TenantTriageReviewService;
use App\Support\Auth\Capabilities;
use App\Support\BackupHealth\TenantBackupHealthResolver;
use App\Support\Badges\BadgeDomain;
use App\Support\Badges\BadgeRenderer;
use App\Support\PortfolioTriage\PortfolioArrivalContext;
use App\Support\PortfolioTriage\PortfolioArrivalContextResolver;
use App\Support\PortfolioTriage\PortfolioArrivalContextToken;
use App\Support\PortfolioTriage\TenantTriageReviewStateResolver;
use App\Support\Rbac\UiEnforcement;
use App\Support\RestoreSafety\RestoreSafetyResolver;
use Filament\Actions\Action;
use Filament\Actions\Concerns\InteractsWithActions;
use Filament\Actions\Contracts\HasActions;
use Filament\Facades\Filament;
use Filament\Notifications\Notification;
use Filament\Schemas\Concerns\InteractsWithSchemas;
use Filament\Schemas\Contracts\HasSchemas;
use Filament\Widgets\Widget;
class TenantTriageArrivalContinuity extends Widget
class TenantTriageArrivalContinuity extends Widget implements HasActions, HasSchemas
{
use InteractsWithActions;
use InteractsWithSchemas;
/**
* @var array<string, mixed>|null
*/
public ?array $arrivalState = null;
protected static bool $isLazy = false;
protected int|string|array $columnSpan = 'full';
protected string $view = 'filament.widgets.tenant.triage-arrival-continuity';
public function mount(): void
{
$this->arrivalState = PortfolioArrivalContextToken::decode(
request()->query(PortfolioArrivalContextToken::QUERY_PARAMETER),
);
}
/**
* @return array<string, mixed>
*/
@ -25,11 +58,210 @@ protected function getViewData(): array
$tenant = Filament::getTenant();
if (! $tenant instanceof Tenant) {
return ['context' => null];
return ['context' => null, 'reviewState' => null];
}
$context = $this->resolveArrivalContext($tenant);
if ($context === null) {
return ['context' => null, 'reviewState' => null];
}
return [
'context' => app(PortfolioArrivalContextResolver::class)->resolve(request(), $tenant),
'context' => $context,
'reviewState' => $this->currentReviewStateFor($tenant, $context->concernFamily),
];
}
public function markReviewedAction(): Action
{
return UiEnforcement::forAction(
Action::make('markReviewed')
->label('Mark reviewed')
->icon('heroicon-o-check-circle')
->color('success')
->requiresConfirmation()
->modalHeading('Mark reviewed')
->modalDescription($this->reviewModalDescription(TenantTriageReview::STATE_REVIEWED))
->visible(fn (): bool => $this->canShowReviewActions())
->action(function (TenantTriageReviewService $service): void {
$this->handleReviewMutation(TenantTriageReview::STATE_REVIEWED, $service);
}),
)
->preserveVisibility()
->requireCapability(Capabilities::TENANT_TRIAGE_REVIEW_MANAGE)
->apply();
}
public function markFollowUpNeededAction(): Action
{
return UiEnforcement::forAction(
Action::make('markFollowUpNeeded')
->label('Mark follow-up needed')
->icon('heroicon-o-exclamation-triangle')
->color('warning')
->requiresConfirmation()
->modalHeading('Mark follow-up needed')
->modalDescription($this->reviewModalDescription(TenantTriageReview::STATE_FOLLOW_UP_NEEDED))
->visible(fn (): bool => $this->canShowReviewActions())
->action(function (TenantTriageReviewService $service): void {
$this->handleReviewMutation(TenantTriageReview::STATE_FOLLOW_UP_NEEDED, $service);
}),
)
->preserveVisibility()
->requireCapability(Capabilities::TENANT_TRIAGE_REVIEW_MANAGE)
->apply();
}
private function canShowReviewActions(): bool
{
$tenant = Filament::getTenant();
if (! $tenant instanceof Tenant) {
return false;
}
$context = $this->resolveArrivalContext($tenant);
if ($context === null) {
return false;
}
return ($this->currentReviewStateFor($tenant, $context->concernFamily)['current_concern_present'] ?? false) === true;
}
private function reviewModalDescription(string $targetManualState): \Closure
{
return function () use ($targetManualState): string {
$tenant = Filament::getTenant();
if (! $tenant instanceof Tenant) {
return 'This triage session is no longer available.';
}
$context = $this->resolveArrivalContext($tenant);
if ($context === null) {
return 'This triage session is no longer available.';
}
$reviewState = $this->currentReviewStateFor($tenant, $context->concernFamily);
if (($reviewState['current_concern_present'] ?? false) !== true) {
return 'This triage session no longer points at a current concern.';
}
$currentLabel = BadgeRenderer::spec(
BadgeDomain::TenantTriageReviewState,
(string) ($reviewState['derived_state'] ?? TenantTriageReview::DERIVED_STATE_NOT_REVIEWED),
)->label;
$targetLabel = BadgeRenderer::spec(BadgeDomain::TenantTriageReviewState, $targetManualState)->label;
return implode("\n\n", [
'Concern family: '.$this->concernFamilyLabel($context->concernFamily),
'Current review state: '.$currentLabel,
'Target state: '.$targetLabel,
'Scope: TenantPilot only. This updates shared triage progress and does not change backup posture or recovery evidence.',
]);
};
}
private function handleReviewMutation(string $targetManualState, TenantTriageReviewService $service): void
{
$tenant = Filament::getTenant();
if (! $tenant instanceof Tenant) {
return;
}
$context = $this->resolveArrivalContext($tenant);
if ($context === null) {
Notification::make()
->title('No triage session available')
->warning()
->send();
return;
}
$reviewState = $this->currentReviewStateFor($tenant, $context->concernFamily);
if (($reviewState['current_concern_present'] ?? false) !== true) {
Notification::make()
->title('No current concern to update')
->body('This arrival context no longer maps to an active concern.')
->warning()
->send();
return;
}
$backupHealth = app(TenantBackupHealthResolver::class)->assess($tenant);
$recoveryEvidence = app(RestoreSafetyResolver::class)->dashboardRecoveryEvidence($tenant);
$actor = auth()->user();
$review = match ($targetManualState) {
TenantTriageReview::STATE_REVIEWED => $service->markReviewed(
tenant: $tenant,
concernFamily: $context->concernFamily,
backupHealth: $backupHealth,
recoveryEvidence: $recoveryEvidence,
actor: $actor instanceof User ? $actor : null,
),
TenantTriageReview::STATE_FOLLOW_UP_NEEDED => $service->markFollowUpNeeded(
tenant: $tenant,
concernFamily: $context->concernFamily,
backupHealth: $backupHealth,
recoveryEvidence: $recoveryEvidence,
actor: $actor instanceof User ? $actor : null,
),
default => null,
};
if (! $review instanceof TenantTriageReview) {
return;
}
Notification::make()
->title('Review state updated')
->body(sprintf(
'%s is now %s for %s.',
$tenant->name,
BadgeRenderer::spec(BadgeDomain::TenantTriageReviewState, $review->current_state)->label,
$this->concernFamilyLabel($context->concernFamily),
))
->success()
->send();
}
/**
* @return array<string, mixed>|null
*/
private function currentReviewStateFor(Tenant $tenant, string $concernFamily): ?array
{
$backupHealth = app(TenantBackupHealthResolver::class)->assess($tenant);
$recoveryEvidence = app(RestoreSafetyResolver::class)->dashboardRecoveryEvidence($tenant);
return app(TenantTriageReviewStateResolver::class)->resolveMany(
workspaceId: (int) $tenant->workspace_id,
tenantIds: [(int) $tenant->getKey()],
backupHealthByTenant: [(int) $tenant->getKey() => $backupHealth],
recoveryEvidenceByTenant: [(int) $tenant->getKey() => $recoveryEvidence],
)['rows'][(int) $tenant->getKey()][$concernFamily] ?? null;
}
private function concernFamilyLabel(string $concernFamily): string
{
return match ($concernFamily) {
PortfolioArrivalContextToken::FAMILY_BACKUP_HEALTH => 'Backup health',
PortfolioArrivalContextToken::FAMILY_RECOVERY_EVIDENCE => 'Recovery evidence',
default => 'Portfolio concern',
};
}
private function resolveArrivalContext(Tenant $tenant): ?PortfolioArrivalContext
{
return app(PortfolioArrivalContextResolver::class)->resolveState($tenant, $this->arrivalState);
}
}

View File

@ -46,6 +46,11 @@ class WorkspaceNeedsAttention extends Widget
*/
public array $emptyState = [];
/**
* @var array<int, array<string, mixed>>
*/
public array $triageReviewProgress = [];
/**
* @param array<int, array{
* key: string,
@ -71,10 +76,12 @@ class WorkspaceNeedsAttention extends Widget
* action_label: string,
* action_url: string
* } $emptyState
* @param array<int, array<string, mixed>> $triageReviewProgress
*/
public function mount(array $items = [], array $emptyState = []): void
public function mount(array $items = [], array $emptyState = [], array $triageReviewProgress = []): void
{
$this->items = $items;
$this->emptyState = $emptyState;
$this->triageReviewProgress = $triageReviewProgress;
}
}

View File

@ -4,6 +4,7 @@
use App\Support\Concerns\DerivesWorkspaceIdFromTenant;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
@ -37,4 +38,30 @@ public function assignedByUser(): BelongsTo
{
return $this->belongsTo(User::class, 'assigned_by_user_id');
}
public function scopeForBaselineProfile(Builder $query, BaselineProfile|int $profile): Builder
{
$profileId = $profile instanceof BaselineProfile
? (int) $profile->getKey()
: (int) $profile;
return $query->where('baseline_profile_id', $profileId);
}
public function scopeInWorkspace(Builder $query, int $workspaceId): Builder
{
return $query->where('workspace_id', $workspaceId);
}
public static function assignedTenantIdsForProfile(BaselineProfile|int $profile, ?int $workspaceId = null): array
{
return static::query()
->when($workspaceId !== null, fn (Builder $query): Builder => $query->inWorkspace($workspaceId))
->forBaselineProfile($profile)
->pluck('tenant_id')
->map(static fn (mixed $tenantId): int => (int) $tenantId)
->filter(static fn (int $tenantId): bool => $tenantId > 0)
->values()
->all();
}
}

View File

@ -274,6 +274,18 @@ public function scopeOpenDrift(Builder $query): Builder
->openWorkflow();
}
public function scopeBaselineCompareForProfile(Builder $query, BaselineProfile|int $profile): Builder
{
$profileId = $profile instanceof BaselineProfile
? (int) $profile->getKey()
: (int) $profile;
return $query
->drift()
->where('source', 'baseline.compare')
->where('scope_key', 'baseline_profile:'.$profileId);
}
public function scopeOverdueOpen(Builder $query): Builder
{
return $query

View File

@ -6,6 +6,7 @@
use App\Support\OperationCatalog;
use App\Support\OperationRunOutcome;
use App\Support\OperationRunStatus;
use App\Support\OperationRunType;
use App\Support\Operations\OperationLifecyclePolicy;
use App\Support\Operations\OperationRunFreshnessState;
use Illuminate\Database\Eloquent\Builder;
@ -89,6 +90,17 @@ public function scopeTerminalFailure(Builder $query): Builder
->where('outcome', OperationRunOutcome::Failed->value);
}
public function scopeBaselineCompareForProfile(Builder $query, BaselineProfile|int $profile): Builder
{
$profileId = $profile instanceof BaselineProfile
? (int) $profile->getKey()
: (int) $profile;
return $query
->where('type', OperationRunType::BaselineCompare->value)
->where('context->baseline_profile_id', $profileId);
}
public function scopeLikelyStale(Builder $query, ?OperationLifecyclePolicy $policy = null): Builder
{
$policy ??= app(OperationLifecyclePolicy::class);
@ -284,6 +296,34 @@ public function isGovernanceArtifactOperation(): bool
return OperationCatalog::isGovernanceArtifactOperation((string) $this->type);
}
/**
* @param array<int, int> $tenantIds
* @return \Illuminate\Support\Collection<int, self>
*/
public static function latestBaselineCompareRunsForProfile(
BaselineProfile|int $profile,
array $tenantIds,
?int $workspaceId = null,
bool $completedOnly = false,
): \Illuminate\Support\Collection {
if ($tenantIds === []) {
return collect();
}
$runs = static::query()
->when($workspaceId !== null, fn (Builder $query): Builder => $query->where('workspace_id', $workspaceId))
->whereIn('tenant_id', $tenantIds)
->baselineCompareForProfile($profile)
->when($completedOnly, fn (Builder $query): Builder => $query->where('status', OperationRunStatus::Completed->value))
->orderByDesc('completed_at')
->orderByDesc('id')
->get();
return $runs
->unique(static fn (self $run): int => (int) $run->tenant_id)
->values();
}
public static function latestCompletedCoverageBearingInventorySyncForTenant(int $tenantId): ?self
{
if ($tenantId <= 0) {

View File

@ -0,0 +1,134 @@
<?php
declare(strict_types=1);
namespace App\Models;
use App\Support\PortfolioTriage\PortfolioArrivalContextToken;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class TenantTriageReview extends Model
{
use HasFactory;
public const STATE_REVIEWED = 'reviewed';
public const STATE_FOLLOW_UP_NEEDED = 'follow_up_needed';
public const DERIVED_STATE_NOT_REVIEWED = 'not_reviewed';
public const DERIVED_STATE_CHANGED_SINCE_REVIEW = 'changed_since_review';
/**
* @var list<string>
*/
public const ACTIVE_CONCERN_FAMILIES = [
PortfolioArrivalContextToken::FAMILY_BACKUP_HEALTH,
PortfolioArrivalContextToken::FAMILY_RECOVERY_EVIDENCE,
];
/**
* @var list<string>
*/
public const MANUAL_STATES = [
self::STATE_REVIEWED,
self::STATE_FOLLOW_UP_NEEDED,
];
/**
* @var list<string>
*/
public const DERIVED_STATES = [
self::DERIVED_STATE_NOT_REVIEWED,
self::STATE_REVIEWED,
self::STATE_FOLLOW_UP_NEEDED,
self::DERIVED_STATE_CHANGED_SINCE_REVIEW,
];
protected $guarded = [];
/**
* @return array<string, string>
*/
protected function casts(): array
{
return [
'review_snapshot' => 'array',
'reviewed_at' => 'datetime',
'last_seen_matching_at' => 'datetime',
'resolved_at' => 'datetime',
];
}
/**
* @return BelongsTo<Workspace, $this>
*/
public function workspace(): BelongsTo
{
return $this->belongsTo(Workspace::class);
}
/**
* @return BelongsTo<Tenant, $this>
*/
public function tenant(): BelongsTo
{
return $this->belongsTo(Tenant::class);
}
/**
* @return BelongsTo<User, $this>
*/
public function reviewer(): BelongsTo
{
return $this->belongsTo(User::class, 'reviewed_by_user_id');
}
/**
* @param Builder<self> $query
* @return Builder<self>
*/
public function scopeForWorkspace(Builder $query, int $workspaceId): Builder
{
return $query->where('workspace_id', $workspaceId);
}
/**
* @param Builder<self> $query
* @return Builder<self>
*/
public function scopeForTenant(Builder $query, int $tenantId): Builder
{
return $query->where('tenant_id', $tenantId);
}
/**
* @param Builder<self> $query
* @return Builder<self>
*/
public function scopeForConcernFamily(Builder $query, string $concernFamily): Builder
{
return $query->where('concern_family', $concernFamily);
}
/**
* @param Builder<self> $query
* @return Builder<self>
*/
public function scopeActive(Builder $query): Builder
{
return $query->whereNull('resolved_at');
}
/**
* @param Builder<self> $query
* @return Builder<self>
*/
public function scopeResolved(Builder $query): Builder
{
return $query->whereNotNull('resolved_at');
}
}

View File

@ -79,8 +79,8 @@ class AppServiceProvider extends ServiceProvider
*/
public function register(): void
{
$this->app->singleton(CapabilityResolver::class);
$this->app->singleton(WorkspaceCapabilityResolver::class);
$this->app->scoped(CapabilityResolver::class);
$this->app->scoped(WorkspaceCapabilityResolver::class);
$this->app->bind(FindingGeneratorContract::class, PermissionPostureFindingGenerator::class);

View File

@ -32,6 +32,7 @@
use App\Support\Workspaces\WorkspaceContext;
use Filament\Http\Middleware\Authenticate;
use Filament\Http\Middleware\AuthenticateSession;
use Filament\FontProviders\LocalFontProvider;
use Filament\Http\Middleware\DisableBladeIconComponents;
use Filament\Http\Middleware\DispatchServingFilamentEvent;
use Filament\Navigation\NavigationItem;
@ -62,6 +63,7 @@ public function panel(Panel $panel): Panel
->brandLogoHeight('2rem')
->homeUrl(fn (): string => route('admin.home'))
->favicon(asset('favicon.ico'))
->font(null, provider: LocalFontProvider::class, preload: [])
->authenticatedRoutes(function (Panel $panel): void {
ChooseWorkspace::registerRoutes($panel);
ChooseTenant::registerRoutes($panel);

View File

@ -7,6 +7,7 @@
use App\Http\Middleware\UseSystemSessionCookie;
use App\Support\Auth\PlatformCapabilities;
use App\Support\Filament\PanelThemeAsset;
use Filament\FontProviders\LocalFontProvider;
use Filament\Http\Middleware\Authenticate;
use Filament\Http\Middleware\AuthenticateSession;
use Filament\Http\Middleware\DisableBladeIconComponents;
@ -31,6 +32,7 @@ public function panel(Panel $panel): Panel
->path('system')
->authGuard('platform')
->login(Login::class)
->font(null, provider: LocalFontProvider::class, preload: [])
->colors([
'primary' => Color::Blue,
])

View File

@ -10,6 +10,7 @@
use App\Support\Middleware\DenyNonMemberTenantAccess;
use App\Support\OperationRunLinks;
use Filament\Facades\Filament;
use Filament\FontProviders\LocalFontProvider;
use Filament\Http\Middleware\Authenticate;
use Filament\Http\Middleware\AuthenticateSession;
use Filament\Http\Middleware\DisableBladeIconComponents;
@ -40,6 +41,7 @@ public function panel(Panel $panel): Panel
->brandLogo(fn () => view('filament.admin.logo'))
->brandLogoHeight('2rem')
->favicon(asset('favicon.ico'))
->font(null, provider: LocalFontProvider::class, preload: [])
->tenant(Tenant::class, slugAttribute: 'external_id')
->tenantRoutePrefix(null)
->tenantMenu(fn (): bool => filled(Filament::getTenant()))

View File

@ -54,6 +54,7 @@ class RoleCapabilityMap
Capabilities::REVIEW_PACK_MANAGE,
Capabilities::TENANT_REVIEW_VIEW,
Capabilities::TENANT_REVIEW_MANAGE,
Capabilities::TENANT_TRIAGE_REVIEW_MANAGE,
Capabilities::EVIDENCE_VIEW,
Capabilities::EVIDENCE_MANAGE,
],
@ -94,6 +95,7 @@ class RoleCapabilityMap
Capabilities::REVIEW_PACK_MANAGE,
Capabilities::TENANT_REVIEW_VIEW,
Capabilities::TENANT_REVIEW_MANAGE,
Capabilities::TENANT_TRIAGE_REVIEW_MANAGE,
Capabilities::EVIDENCE_VIEW,
Capabilities::EVIDENCE_MANAGE,
],
@ -121,6 +123,7 @@ class RoleCapabilityMap
Capabilities::REVIEW_PACK_VIEW,
Capabilities::TENANT_REVIEW_VIEW,
Capabilities::TENANT_TRIAGE_REVIEW_MANAGE,
Capabilities::EVIDENCE_VIEW,
],

View File

@ -11,6 +11,7 @@
use App\Models\OperationRun;
use App\Models\Tenant;
use App\Models\User;
use App\Services\Auth\CapabilityResolver;
use App\Services\OperationRunService;
use App\Support\Baselines\BaselineCaptureMode;
use App\Support\Baselines\BaselineFullContentRolloutGate;
@ -28,6 +29,7 @@ public function __construct(
private readonly BaselineFullContentRolloutGate $rolloutGate,
private readonly BaselineSnapshotTruthResolver $snapshotTruthResolver,
private readonly BaselineSupportCapabilityGuard $capabilityGuard,
private readonly CapabilityResolver $capabilityResolver,
) {}
/**
@ -47,12 +49,34 @@ public function startCompare(
return $this->failedStart(BaselineReasonCodes::COMPARE_NO_ASSIGNMENT);
}
$profile = BaselineProfile::query()->find($assignment->baseline_profile_id);
$profile = $assignment->baselineProfile;
if (! $profile instanceof BaselineProfile) {
return $this->failedStart(BaselineReasonCodes::COMPARE_PROFILE_NOT_ACTIVE);
}
return $this->startCompareForProfile($profile, $tenant, $initiator, $baselineSnapshotId);
}
/**
* @return array{ok: bool, run?: OperationRun, reason_code?: string, reason_translation?: array<string, mixed>}
*/
public function startCompareForProfile(
BaselineProfile $profile,
Tenant $tenant,
User $initiator,
?int $baselineSnapshotId = null,
): array {
$assignment = BaselineTenantAssignment::query()
->where('workspace_id', (int) $profile->workspace_id)
->where('tenant_id', (int) $tenant->getKey())
->where('baseline_profile_id', (int) $profile->getKey())
->first();
if (! $assignment instanceof BaselineTenantAssignment) {
return $this->failedStart(BaselineReasonCodes::COMPARE_NO_ASSIGNMENT);
}
$precondition = $this->validatePreconditions($profile);
if ($precondition !== null) {
@ -124,6 +148,103 @@ public function startCompare(
return ['ok' => true, 'run' => $run];
}
/**
* @return array{
* baselineProfileId: int,
* visibleAssignedTenantCount: int,
* queuedCount: int,
* alreadyQueuedCount: int,
* blockedCount: int,
* targets: list<array{tenantId: int, runId: ?int, launchState: string, reasonCode: ?string}>
* }
*/
public function startCompareForVisibleAssignments(BaselineProfile $profile, User $initiator): array
{
$assignments = BaselineTenantAssignment::query()
->where('workspace_id', (int) $profile->workspace_id)
->where('baseline_profile_id', (int) $profile->getKey())
->with('tenant')
->get();
$queuedCount = 0;
$alreadyQueuedCount = 0;
$blockedCount = 0;
$targets = [];
foreach ($assignments as $assignment) {
$tenant = $assignment->tenant;
if (! $tenant instanceof Tenant) {
continue;
}
if (! $this->capabilityResolver->isMember($initiator, $tenant)) {
continue;
}
if (! $this->capabilityResolver->can($initiator, $tenant, \App\Support\Auth\Capabilities::TENANT_VIEW)) {
continue;
}
if (! $this->capabilityResolver->can($initiator, $tenant, \App\Support\Auth\Capabilities::TENANT_SYNC)) {
$blockedCount++;
$targets[] = [
'tenantId' => (int) $tenant->getKey(),
'runId' => null,
'launchState' => 'blocked',
'reasonCode' => 'tenant_sync_required',
];
continue;
}
$result = $this->startCompareForProfile($profile, $tenant, $initiator);
$run = $result['run'] ?? null;
$reasonCode = is_string($result['reason_code'] ?? null) ? (string) $result['reason_code'] : null;
if (! ($result['ok'] ?? false) || ! $run instanceof OperationRun) {
$blockedCount++;
$targets[] = [
'tenantId' => (int) $tenant->getKey(),
'runId' => null,
'launchState' => 'blocked',
'reasonCode' => $reasonCode,
];
continue;
}
if ($run->wasRecentlyCreated) {
$queuedCount++;
$targets[] = [
'tenantId' => (int) $tenant->getKey(),
'runId' => (int) $run->getKey(),
'launchState' => 'queued',
'reasonCode' => null,
];
continue;
}
$alreadyQueuedCount++;
$targets[] = [
'tenantId' => (int) $tenant->getKey(),
'runId' => (int) $run->getKey(),
'launchState' => 'already_queued',
'reasonCode' => null,
];
}
return [
'baselineProfileId' => (int) $profile->getKey(),
'visibleAssignedTenantCount' => count($targets),
'queuedCount' => $queuedCount,
'alreadyQueuedCount' => $alreadyQueuedCount,
'blockedCount' => $blockedCount,
'targets' => $targets,
];
}
private function validatePreconditions(BaselineProfile $profile): ?string
{
if ($profile->status !== BaselineProfileStatus::Active) {

View File

@ -0,0 +1,165 @@
<?php
declare(strict_types=1);
namespace App\Services\PortfolioTriage;
use App\Models\Tenant;
use App\Models\TenantTriageReview;
use App\Models\User;
use App\Services\Audit\WorkspaceAuditLogger;
use App\Support\Audit\AuditActionId;
use App\Support\BackupHealth\TenantBackupHealthAssessment;
use App\Support\PortfolioTriage\TenantTriageReviewFingerprint;
use Illuminate\Support\Facades\DB;
use InvalidArgumentException;
final readonly class TenantTriageReviewService
{
public function __construct(
private TenantTriageReviewFingerprint $fingerprints,
private WorkspaceAuditLogger $auditLogger,
) {}
/**
* @param array<string, mixed>|null $recoveryEvidence
*/
public function markReviewed(
Tenant $tenant,
string $concernFamily,
?TenantBackupHealthAssessment $backupHealth = null,
?array $recoveryEvidence = null,
?User $actor = null,
): TenantTriageReview {
return $this->store(
tenant: $tenant,
concernFamily: $concernFamily,
manualState: TenantTriageReview::STATE_REVIEWED,
backupHealth: $backupHealth,
recoveryEvidence: $recoveryEvidence,
actor: $actor,
);
}
/**
* @param array<string, mixed>|null $recoveryEvidence
*/
public function markFollowUpNeeded(
Tenant $tenant,
string $concernFamily,
?TenantBackupHealthAssessment $backupHealth = null,
?array $recoveryEvidence = null,
?User $actor = null,
): TenantTriageReview {
return $this->store(
tenant: $tenant,
concernFamily: $concernFamily,
manualState: TenantTriageReview::STATE_FOLLOW_UP_NEEDED,
backupHealth: $backupHealth,
recoveryEvidence: $recoveryEvidence,
actor: $actor,
);
}
/**
* @param array<string, mixed>|null $recoveryEvidence
*/
private function store(
Tenant $tenant,
string $concernFamily,
string $manualState,
?TenantBackupHealthAssessment $backupHealth,
?array $recoveryEvidence,
?User $actor,
): TenantTriageReview {
if (! in_array($manualState, TenantTriageReview::MANUAL_STATES, true)) {
throw new InvalidArgumentException('Unsupported triage review state.');
}
if (! is_numeric($tenant->workspace_id) || (int) $tenant->workspace_id <= 0) {
throw new InvalidArgumentException('Tenant must belong to a workspace.');
}
$currentConcern = $this->fingerprints->forConcernFamily($concernFamily, $backupHealth, $recoveryEvidence);
if ($currentConcern === null) {
throw new InvalidArgumentException('No current triage concern is available for review.');
}
$workspaceId = (int) $tenant->workspace_id;
$now = now();
/** @var TenantTriageReview $review */
$review = DB::transaction(function () use (
$tenant,
$workspaceId,
$manualState,
$currentConcern,
$actor,
$now,
): TenantTriageReview {
TenantTriageReview::query()
->forWorkspace($workspaceId)
->forTenant((int) $tenant->getKey())
->where('concern_family', $currentConcern['concern_family'])
->active()
->update([
'resolved_at' => $now,
'updated_at' => $now,
]);
return TenantTriageReview::query()->create([
'workspace_id' => $workspaceId,
'tenant_id' => (int) $tenant->getKey(),
'concern_family' => $currentConcern['concern_family'],
'current_state' => $manualState,
'reviewed_at' => $now,
'reviewed_by_user_id' => $actor?->getKey(),
'review_fingerprint' => $currentConcern['fingerprint'],
'review_snapshot' => $currentConcern['snapshot'],
'last_seen_matching_at' => $now,
'resolved_at' => null,
]);
});
$review->loadMissing('reviewer');
$this->auditLogger->log(
workspace: $tenant->workspace,
action: $manualState === TenantTriageReview::STATE_REVIEWED
? AuditActionId::TenantTriageReviewMarkedReviewed
: AuditActionId::TenantTriageReviewMarkedFollowUpNeeded,
context: [
'metadata' => [
'concern_family' => $currentConcern['concern_family'],
'concern_state' => $currentConcern['concern_state'],
'reason_code' => $currentConcern['snapshot']['reasonCode'] ?? null,
'review_state' => $manualState,
],
],
actor: $actor,
resourceType: 'tenant_triage_review',
resourceId: (string) $review->getKey(),
targetLabel: $tenant->name,
tenant: $tenant,
summary: $this->summaryFor($currentConcern['concern_family'], $manualState),
);
return $review;
}
private function summaryFor(string $concernFamily, string $manualState): string
{
$family = match ($concernFamily) {
'backup_health' => 'Backup health',
'recovery_evidence' => 'Recovery evidence',
default => 'Portfolio concern',
};
$state = $manualState === TenantTriageReview::STATE_REVIEWED
? 'reviewed'
: 'follow-up needed';
return sprintf('%s marked %s', $family, $state);
}
}

View File

@ -95,6 +95,8 @@ enum AuditActionId: string
case TenantReviewArchived = 'tenant_review.archived';
case TenantReviewExported = 'tenant_review.exported';
case TenantReviewSuccessorCreated = 'tenant_review.successor_created';
case TenantTriageReviewMarkedReviewed = 'tenant_triage_review.marked_reviewed';
case TenantTriageReviewMarkedFollowUpNeeded = 'tenant_triage_review.marked_follow_up_needed';
// Workspace selection / switch events (Spec 107).
case WorkspaceAutoSelected = 'workspace.auto_selected';
@ -228,6 +230,8 @@ private static function labels(): array
self::TenantReviewArchived->value => 'Tenant review archived',
self::TenantReviewExported->value => 'Tenant review exported',
self::TenantReviewSuccessorCreated->value => 'Tenant review next cycle created',
self::TenantTriageReviewMarkedReviewed->value => 'Triage review marked reviewed',
self::TenantTriageReviewMarkedFollowUpNeeded->value => 'Triage review marked follow-up needed',
'baseline.capture.started' => 'Baseline capture started',
'baseline.capture.completed' => 'Baseline capture completed',
'baseline.capture.failed' => 'Baseline capture failed',

View File

@ -143,6 +143,9 @@ class Capabilities
public const TENANT_REVIEW_MANAGE = 'tenant_review.manage';
// Portfolio triage review progress
public const TENANT_TRIAGE_REVIEW_MANAGE = 'tenant_triage_review.manage';
// Evidence snapshots
public const EVIDENCE_VIEW = 'evidence.view';

View File

@ -62,9 +62,13 @@ final class BadgeCatalog
BadgeDomain::EvidenceCompleteness->value => Domains\EvidenceCompletenessBadge::class,
BadgeDomain::TenantReviewStatus->value => Domains\TenantReviewStatusBadge::class,
BadgeDomain::TenantReviewCompleteness->value => Domains\TenantReviewCompletenessStateBadge::class,
BadgeDomain::TenantTriageReviewState->value => Domains\TenantTriageReviewStateBadge::class,
BadgeDomain::SystemHealth->value => Domains\SystemHealthBadge::class,
BadgeDomain::ReferenceResolutionState->value => Domains\ReferenceResolutionStateBadge::class,
BadgeDomain::DiffRowStatus->value => Domains\DiffRowStatusBadge::class,
BadgeDomain::BaselineCompareMatrixState->value => Domains\BaselineCompareMatrixStateBadge::class,
BadgeDomain::BaselineCompareMatrixFreshness->value => Domains\BaselineCompareMatrixFreshnessBadge::class,
BadgeDomain::BaselineCompareMatrixTrust->value => Domains\BaselineCompareMatrixTrustBadge::class,
];
/**

View File

@ -53,7 +53,11 @@ enum BadgeDomain: string
case EvidenceCompleteness = 'evidence_completeness';
case TenantReviewStatus = 'tenant_review_status';
case TenantReviewCompleteness = 'tenant_review_completeness';
case TenantTriageReviewState = 'tenant_triage_review_state';
case SystemHealth = 'system_health';
case ReferenceResolutionState = 'reference_resolution_state';
case DiffRowStatus = 'diff_row_status';
case BaselineCompareMatrixState = 'baseline_compare_matrix_state';
case BaselineCompareMatrixFreshness = 'baseline_compare_matrix_freshness';
case BaselineCompareMatrixTrust = 'baseline_compare_matrix_trust';
}

View File

@ -0,0 +1,23 @@
<?php
declare(strict_types=1);
namespace App\Support\Badges\Domains;
use App\Support\Badges\BadgeCatalog;
use App\Support\Badges\BadgeMapper;
use App\Support\Badges\BadgeSpec;
final class BaselineCompareMatrixFreshnessBadge implements BadgeMapper
{
public function spec(mixed $value): BadgeSpec
{
return match (BadgeCatalog::normalizeState($value)) {
'fresh' => new BadgeSpec('Current result', 'success', 'heroicon-m-check-badge'),
'stale' => new BadgeSpec('Refresh recommended', 'warning', 'heroicon-m-arrow-path'),
'never_compared' => new BadgeSpec('Not compared yet', 'gray', 'heroicon-m-minus-circle'),
'unknown' => new BadgeSpec('Freshness unknown', 'info', 'heroicon-m-question-mark-circle'),
default => BadgeSpec::unknown(),
};
}
}

View File

@ -0,0 +1,25 @@
<?php
declare(strict_types=1);
namespace App\Support\Badges\Domains;
use App\Support\Badges\BadgeCatalog;
use App\Support\Badges\BadgeMapper;
use App\Support\Badges\BadgeSpec;
final class BaselineCompareMatrixStateBadge implements BadgeMapper
{
public function spec(mixed $value): BadgeSpec
{
return match (BadgeCatalog::normalizeState($value)) {
'match' => new BadgeSpec('Reference aligned', 'success', 'heroicon-m-check-circle'),
'differ' => new BadgeSpec('Drift detected', 'danger', 'heroicon-m-exclamation-triangle'),
'missing' => new BadgeSpec('Missing from tenant', 'warning', 'heroicon-m-minus-circle'),
'ambiguous' => new BadgeSpec('Identity ambiguous', 'info', 'heroicon-m-question-mark-circle'),
'not_compared' => new BadgeSpec('Not compared', 'gray', 'heroicon-m-clock'),
'stale_result' => new BadgeSpec('Result stale', 'warning', 'heroicon-m-arrow-path'),
default => BadgeSpec::unknown(),
};
}
}

View File

@ -0,0 +1,18 @@
<?php
declare(strict_types=1);
namespace App\Support\Badges\Domains;
use App\Support\Badges\BadgeCatalog;
use App\Support\Badges\BadgeDomain;
use App\Support\Badges\BadgeMapper;
use App\Support\Badges\BadgeSpec;
final class BaselineCompareMatrixTrustBadge implements BadgeMapper
{
public function spec(mixed $value): BadgeSpec
{
return BadgeCatalog::spec(BadgeDomain::OperatorExplanationTrustworthiness, $value);
}
}

View File

@ -0,0 +1,26 @@
<?php
declare(strict_types=1);
namespace App\Support\Badges\Domains;
use App\Models\TenantTriageReview;
use App\Support\Badges\BadgeCatalog;
use App\Support\Badges\BadgeMapper;
use App\Support\Badges\BadgeSpec;
final class TenantTriageReviewStateBadge implements BadgeMapper
{
public function spec(mixed $value): BadgeSpec
{
$state = BadgeCatalog::normalizeState($value);
return match ($state) {
TenantTriageReview::DERIVED_STATE_NOT_REVIEWED => new BadgeSpec('Not reviewed', 'gray', 'heroicon-m-eye-slash'),
TenantTriageReview::STATE_REVIEWED => new BadgeSpec('Reviewed', 'success', 'heroicon-m-check-circle'),
TenantTriageReview::STATE_FOLLOW_UP_NEEDED => new BadgeSpec('Follow-up needed', 'danger', 'heroicon-m-exclamation-triangle'),
TenantTriageReview::DERIVED_STATE_CHANGED_SINCE_REVIEW => new BadgeSpec('Changed since review', 'warning', 'heroicon-m-arrow-path'),
default => BadgeSpec::unknown(),
};
}
}

View File

@ -245,6 +245,57 @@ public static function topReasons(array $byReason, int $limit = 5): array
);
}
/**
* @return array<string, list<string>>
*/
public static function subjectReasonsFromOperationRun(?OperationRun $run): array
{
$details = self::fromOperationRun($run);
$buckets = is_array($details['buckets'] ?? null) ? $details['buckets'] : [];
$reasonMap = [];
foreach ($buckets as $bucket) {
if (! is_array($bucket)) {
continue;
}
$reasonCode = self::stringOrNull($bucket['reason_code'] ?? null);
if ($reasonCode === null) {
continue;
}
$rows = is_array($bucket['rows'] ?? null) ? $bucket['rows'] : [];
foreach ($rows as $row) {
if (! is_array($row)) {
continue;
}
$policyType = self::stringOrNull($row['policy_type'] ?? null);
$subjectKey = self::stringOrNull($row['subject_key'] ?? null);
if ($policyType === null || $subjectKey === null) {
continue;
}
$compositeKey = self::subjectCompositeKey($policyType, $subjectKey);
$reasonMap[$compositeKey] ??= [];
$reasonMap[$compositeKey][] = $reasonCode;
}
}
return array_map(
static fn (array $reasons): array => array_values(array_unique(array_filter($reasons, 'is_string'))),
$reasonMap,
);
}
public static function subjectCompositeKey(string $policyType, string $subjectKey): string
{
return trim(mb_strtolower($policyType)).'|'.trim(mb_strtolower($subjectKey));
}
/**
* @param list<array<string, mixed>> $buckets
* @return list<array<string, mixed>>

View File

@ -4,6 +4,7 @@
namespace App\Support\Baselines;
use App\Models\OperationRun;
use App\Support\ReasonTranslation\ReasonPresenter;
use App\Support\Ui\OperatorExplanation\CountDescriptor;
use App\Support\Ui\OperatorExplanation\ExplanationFamily;
@ -18,6 +19,36 @@ public function __construct(
private readonly ReasonPresenter $reasonPresenter,
) {}
public function trustLevelForRun(?OperationRun $run): string
{
if (! $run instanceof OperationRun) {
return TrustworthinessLevel::Unusable->value;
}
$context = is_array($run->context) ? $run->context : [];
$baselineCompare = is_array($context['baseline_compare'] ?? null) ? $context['baseline_compare'] : [];
$coverage = is_array($baselineCompare['coverage'] ?? null) ? $baselineCompare['coverage'] : [];
$evidenceGaps = is_array($baselineCompare['evidence_gaps'] ?? null) ? $baselineCompare['evidence_gaps'] : [];
$reasonCode = is_string($baselineCompare['reason_code'] ?? null) ? trim((string) $baselineCompare['reason_code']) : null;
$proof = is_bool($coverage['proof'] ?? null) ? (bool) $coverage['proof'] : null;
$uncoveredTypes = is_array($coverage['uncovered_types'] ?? null) ? $coverage['uncovered_types'] : [];
$evidenceGapCount = is_numeric($evidenceGaps['count'] ?? null) ? (int) $evidenceGaps['count'] : 0;
if ($run->status !== 'completed' || $run->outcome === 'failed') {
return TrustworthinessLevel::Unusable->value;
}
if ($proof === false || $reasonCode !== null) {
return TrustworthinessLevel::DiagnosticOnly->value;
}
if ($uncoveredTypes !== [] || $evidenceGapCount > 0) {
return TrustworthinessLevel::LimitedConfidence->value;
}
return TrustworthinessLevel::Trustworthy->value;
}
public function forStats(BaselineCompareStats $stats): OperatorExplanationPattern
{
$reason = $stats->reasonCode !== null

File diff suppressed because it is too large Load Diff

View File

@ -12,6 +12,28 @@ final class BaselineCompareSummaryAssessor
{
private const int STALE_AFTER_DAYS = 7;
public static function staleAfterDays(): int
{
return self::STALE_AFTER_DAYS;
}
public static function isStaleComparedAt(\DateTimeInterface|string|null $value): bool
{
if ($value === null) {
return false;
}
try {
$comparedAt = $value instanceof \DateTimeInterface
? CarbonImmutable::instance(\DateTimeImmutable::createFromInterface($value))
: CarbonImmutable::parse($value);
} catch (\Throwable) {
return false;
}
return $comparedAt->lt(now()->subDays(self::STALE_AFTER_DAYS));
}
public function assess(BaselineCompareStats $stats): BaselineCompareSummaryAssessment
{
$explanation = $stats->operatorExplanation();
@ -376,12 +398,6 @@ private function hasStaleResult(BaselineCompareStats $stats, string $evaluationR
return false;
}
try {
$lastComparedAt = CarbonImmutable::parse($stats->lastComparedIso);
} catch (\Throwable) {
return false;
}
return $lastComparedAt->lt(now()->subDays(self::STALE_AFTER_DAYS));
return self::isStaleComparedAt($stats->lastComparedIso);
}
}

View File

@ -4,6 +4,9 @@
namespace App\Support\Navigation;
use App\Filament\Pages\BaselineCompareMatrix;
use App\Models\BaselineProfile;
use App\Models\Tenant;
use Illuminate\Http\Request;
final readonly class CanonicalNavigationContext
@ -63,4 +66,31 @@ public function toQuery(): array
return $query;
}
/**
* @param array<string, mixed> $filters
*/
public static function forBaselineCompareMatrix(
BaselineProfile $profile,
array $filters = [],
?Tenant $tenant = null,
?string $subjectKey = null,
): self {
$parameters = array_filter([
'record' => $profile,
...$filters,
], static fn (mixed $value): bool => $value !== null && $value !== [] && $value !== '');
return new self(
sourceSurface: 'baseline_compare_matrix',
canonicalRouteName: BaselineCompareMatrix::getRouteName(),
tenantId: $tenant?->getKey(),
backLinkLabel: 'Back to compare matrix',
backLinkUrl: BaselineCompareMatrix::getUrl($parameters, panel: 'admin'),
filterPayload: array_filter([
'baseline_profile_id' => (int) $profile->getKey(),
'subject_key' => $subjectKey,
], static fn (mixed $value): bool => $value !== null && $value !== ''),
);
}
}

View File

@ -48,7 +48,7 @@ public function resolve(Request $request, Tenant $tenant): ?PortfolioArrivalCont
$request->query(PortfolioArrivalContextToken::QUERY_PARAMETER),
);
if ($state === null || ! $this->matchesScope($tenant, $request, $state)) {
if ($state === null || ! $this->matchesRequestScope($tenant, $request, $state)) {
$request->attributes->set(self::REQUEST_CACHE_KEY, null);
return null;
@ -61,6 +61,26 @@ public function resolve(Request $request, Tenant $tenant): ?PortfolioArrivalCont
return $context;
}
/**
* @param array{
* sourceSurface: string,
* tenantRouteKey: string|null,
* workspaceId: int|null,
* concernFamily: string,
* concernState: string,
* concernReason: string|null,
* returnFilters: array<string, mixed>|null
* }|null $state
*/
public function resolveState(Tenant $tenant, ?array $state): ?PortfolioArrivalContext
{
if ($state === null || ! $this->matchesTenantScope($tenant, $state)) {
return null;
}
return $this->buildContext($tenant, $state);
}
/**
* @param array{
* sourceSurface: string,
@ -72,7 +92,30 @@ public function resolve(Request $request, Tenant $tenant): ?PortfolioArrivalCont
* returnFilters: array<string, mixed>|null
* } $state
*/
private function matchesScope(Tenant $tenant, Request $request, array $state): bool
private function matchesRequestScope(Tenant $tenant, Request $request, array $state): bool
{
if (! $this->matchesTenantScope($tenant, $state)) {
return false;
}
$workspaceId = $state['workspaceId'];
return $workspaceId === null
|| $this->workspaceContext->currentWorkspaceId($request) === $workspaceId;
}
/**
* @param array{
* sourceSurface: string,
* tenantRouteKey: string|null,
* workspaceId: int|null,
* concernFamily: string,
* concernState: string,
* concernReason: string|null,
* returnFilters: array<string, mixed>|null
* } $state
*/
private function matchesTenantScope(Tenant $tenant, array $state): bool
{
$tenantRouteKey = $state['tenantRouteKey'];
@ -92,7 +135,7 @@ private function matchesScope(Tenant $tenant, Request $request, array $state): b
$workspaceId = $state['workspaceId'];
return $workspaceId === null
|| $this->workspaceContext->currentWorkspaceId($request) === $workspaceId;
|| (int) $tenant->workspace_id === $workspaceId;
}
/**

View File

@ -55,6 +55,7 @@ final class PortfolioArrivalContextToken
private const array RETURN_FILTER_ALLOWLIST = [
'backup_posture' => true,
'recovery_evidence' => true,
'review_state' => true,
'triage_sort' => true,
];

View File

@ -0,0 +1,157 @@
<?php
declare(strict_types=1);
namespace App\Support\PortfolioTriage;
use App\Support\BackupHealth\TenantBackupHealthAssessment;
use App\Support\Tenants\TenantRecoveryTriagePresentation;
use JsonException;
final class TenantTriageReviewFingerprint
{
/**
* @param array<string, mixed>|null $recoveryEvidence
* @return array{
* concern_family: string,
* concern_state: string,
* fingerprint: string,
* snapshot: array{
* concernFamily: string,
* concernState: string,
* reasonCode: ?string,
* severityKey: ?string,
* supportingKey: ?string
* }
* }|null
*/
public function forConcernFamily(
string $concernFamily,
?TenantBackupHealthAssessment $backupHealth,
?array $recoveryEvidence,
): ?array {
return match ($concernFamily) {
PortfolioArrivalContextToken::FAMILY_BACKUP_HEALTH => $this->forBackupHealth($backupHealth),
PortfolioArrivalContextToken::FAMILY_RECOVERY_EVIDENCE => $this->forRecoveryEvidence($recoveryEvidence),
default => null,
};
}
/**
* @return array{
* concern_family: string,
* concern_state: string,
* fingerprint: string,
* snapshot: array{
* concernFamily: string,
* concernState: string,
* reasonCode: ?string,
* severityKey: ?string,
* supportingKey: ?string
* }
* }|null
*/
public function forBackupHealth(?TenantBackupHealthAssessment $assessment): ?array
{
if (! $assessment instanceof TenantBackupHealthAssessment) {
return null;
}
if (! in_array($assessment->posture, [
TenantBackupHealthAssessment::POSTURE_ABSENT,
TenantBackupHealthAssessment::POSTURE_STALE,
TenantBackupHealthAssessment::POSTURE_DEGRADED,
], true)) {
return null;
}
$snapshot = [
'concernFamily' => PortfolioArrivalContextToken::FAMILY_BACKUP_HEALTH,
'concernState' => $assessment->posture,
'reasonCode' => $assessment->primaryReason,
'severityKey' => $assessment->primaryReason,
'supportingKey' => $assessment->primaryReason,
];
return [
'concern_family' => PortfolioArrivalContextToken::FAMILY_BACKUP_HEALTH,
'concern_state' => $assessment->posture,
'fingerprint' => $this->hash($snapshot),
'snapshot' => $snapshot,
];
}
/**
* @param array<string, mixed>|null $recoveryEvidence
* @return array{
* concern_family: string,
* concern_state: string,
* fingerprint: string,
* snapshot: array{
* concernFamily: string,
* concernState: string,
* reasonCode: ?string,
* severityKey: ?string,
* supportingKey: ?string
* }
* }|null
*/
public function forRecoveryEvidence(?array $recoveryEvidence): ?array
{
$state = is_array($recoveryEvidence)
? TenantRecoveryTriagePresentation::recoveryEvidenceState($recoveryEvidence)
: null;
if (! in_array($state, [
TenantRecoveryTriagePresentation::RECOVERY_EVIDENCE_WEAKENED,
TenantRecoveryTriagePresentation::RECOVERY_EVIDENCE_UNVALIDATED,
], true)) {
return null;
}
$reason = is_string($recoveryEvidence['reason'] ?? null)
? $recoveryEvidence['reason']
: null;
$supportingKey = is_string($recoveryEvidence['latest_relevant_attention_state'] ?? null)
? $recoveryEvidence['latest_relevant_attention_state']
: $reason;
$snapshot = [
'concernFamily' => PortfolioArrivalContextToken::FAMILY_RECOVERY_EVIDENCE,
'concernState' => $state,
'reasonCode' => $reason,
'severityKey' => $reason,
'supportingKey' => $supportingKey,
];
return [
'concern_family' => PortfolioArrivalContextToken::FAMILY_RECOVERY_EVIDENCE,
'concern_state' => $state,
'fingerprint' => $this->hash($snapshot),
'snapshot' => $snapshot,
];
}
/**
* @param array{
* concernFamily: string,
* concernState: string,
* reasonCode: ?string,
* severityKey: ?string,
* supportingKey: ?string
* } $snapshot
*/
private function hash(array $snapshot): string
{
try {
return hash('sha256', json_encode($snapshot, JSON_THROW_ON_ERROR));
} catch (JsonException) {
return hash('sha256', implode(':', array_map(
static fn (mixed $value): string => is_scalar($value) || $value === null
? (string) $value
: '',
$snapshot,
)));
}
}
}

View File

@ -0,0 +1,192 @@
<?php
declare(strict_types=1);
namespace App\Support\PortfolioTriage;
use App\Models\TenantTriageReview;
use App\Support\BackupHealth\TenantBackupHealthAssessment;
final readonly class TenantTriageReviewStateResolver
{
public function __construct(
private TenantTriageReviewFingerprint $fingerprints,
) {}
/**
* @param list<int> $tenantIds
* @param array<int, TenantBackupHealthAssessment> $backupHealthByTenant
* @param array<int, array<string, mixed>> $recoveryEvidenceByTenant
* @return array{
* rows: array<int, array{
* backup_health: array<string, mixed>,
* recovery_evidence: array<string, mixed>
* }>,
* summaries: array<string, array{
* concern_family: string,
* affected_total: int,
* reviewed_count: int,
* follow_up_needed_count: int,
* changed_since_review_count: int,
* not_reviewed_count: int
* }>
* }
*/
public function resolveMany(
int $workspaceId,
array $tenantIds,
array $backupHealthByTenant = [],
array $recoveryEvidenceByTenant = [],
): array {
$tenantIds = array_values(array_unique(array_map(static fn (int|string $tenantId): int => (int) $tenantId, $tenantIds)));
$emptySummary = [
PortfolioArrivalContextToken::FAMILY_BACKUP_HEALTH => $this->emptySummary(PortfolioArrivalContextToken::FAMILY_BACKUP_HEALTH),
PortfolioArrivalContextToken::FAMILY_RECOVERY_EVIDENCE => $this->emptySummary(PortfolioArrivalContextToken::FAMILY_RECOVERY_EVIDENCE),
];
if ($workspaceId <= 0 || $tenantIds === []) {
return [
'rows' => [],
'summaries' => $emptySummary,
];
}
$activeReviews = TenantTriageReview::query()
->with('reviewer:id,name,email')
->forWorkspace($workspaceId)
->whereIn('tenant_id', $tenantIds)
->active()
->orderByDesc('reviewed_at')
->orderByDesc('id')
->get()
->groupBy([
'tenant_id',
'concern_family',
]);
$rows = [];
$summaries = $emptySummary;
foreach ($tenantIds as $tenantId) {
$rows[$tenantId] = [
PortfolioArrivalContextToken::FAMILY_BACKUP_HEALTH => $this->resolveFamily(
tenantId: $tenantId,
concernFamily: PortfolioArrivalContextToken::FAMILY_BACKUP_HEALTH,
currentConcern: $this->fingerprints->forBackupHealth($backupHealthByTenant[$tenantId] ?? null),
activeReview: $activeReviews[$tenantId][PortfolioArrivalContextToken::FAMILY_BACKUP_HEALTH][0] ?? null,
),
PortfolioArrivalContextToken::FAMILY_RECOVERY_EVIDENCE => $this->resolveFamily(
tenantId: $tenantId,
concernFamily: PortfolioArrivalContextToken::FAMILY_RECOVERY_EVIDENCE,
currentConcern: $this->fingerprints->forRecoveryEvidence($recoveryEvidenceByTenant[$tenantId] ?? null),
activeReview: $activeReviews[$tenantId][PortfolioArrivalContextToken::FAMILY_RECOVERY_EVIDENCE][0] ?? null,
),
];
foreach ($rows[$tenantId] as $family => $row) {
if (($row['current_concern_present'] ?? false) !== true) {
continue;
}
$summaries[$family]['affected_total']++;
match ($row['derived_state']) {
TenantTriageReview::STATE_REVIEWED => $summaries[$family]['reviewed_count']++,
TenantTriageReview::STATE_FOLLOW_UP_NEEDED => $summaries[$family]['follow_up_needed_count']++,
TenantTriageReview::DERIVED_STATE_CHANGED_SINCE_REVIEW => $summaries[$family]['changed_since_review_count']++,
default => $summaries[$family]['not_reviewed_count']++,
};
}
}
return [
'rows' => $rows,
'summaries' => $summaries,
];
}
/**
* @param array{
* concern_family: string,
* concern_state: string,
* fingerprint: string,
* snapshot: array<string, mixed>
* }|null $currentConcern
* @return array<string, mixed>
*/
private function resolveFamily(
int $tenantId,
string $concernFamily,
?array $currentConcern,
?TenantTriageReview $activeReview,
): array {
$reviewerName = null;
if ($activeReview?->reviewer !== null) {
$reviewerName = filled($activeReview->reviewer->name)
? (string) $activeReview->reviewer->name
: (filled($activeReview->reviewer->email) ? (string) $activeReview->reviewer->email : null);
}
if ($currentConcern === null) {
return [
'tenant_id' => $tenantId,
'concern_family' => $concernFamily,
'current_concern_present' => false,
'current_state' => null,
'current_fingerprint' => null,
'review_fingerprint' => $activeReview?->review_fingerprint,
'derived_state' => null,
'reviewed_at' => $activeReview?->reviewed_at,
'reviewed_by_user_id' => $activeReview?->reviewed_by_user_id,
'reviewed_by_user_name' => $reviewerName,
'current_snapshot' => null,
'review_snapshot' => is_array($activeReview?->review_snapshot) ? $activeReview->review_snapshot : null,
];
}
$derivedState = match (true) {
! $activeReview instanceof TenantTriageReview => TenantTriageReview::DERIVED_STATE_NOT_REVIEWED,
$activeReview->review_fingerprint !== $currentConcern['fingerprint'] => TenantTriageReview::DERIVED_STATE_CHANGED_SINCE_REVIEW,
default => (string) $activeReview->current_state,
};
return [
'tenant_id' => $tenantId,
'concern_family' => $concernFamily,
'current_concern_present' => true,
'current_state' => $currentConcern['concern_state'],
'current_fingerprint' => $currentConcern['fingerprint'],
'review_fingerprint' => $activeReview?->review_fingerprint,
'derived_state' => $derivedState,
'reviewed_at' => $activeReview?->reviewed_at,
'reviewed_by_user_id' => $activeReview?->reviewed_by_user_id,
'reviewed_by_user_name' => $reviewerName,
'current_snapshot' => $currentConcern['snapshot'],
'review_snapshot' => is_array($activeReview?->review_snapshot) ? $activeReview->review_snapshot : $currentConcern['snapshot'],
];
}
/**
* @return array{
* concern_family: string,
* affected_total: int,
* reviewed_count: int,
* follow_up_needed_count: int,
* changed_since_review_count: int,
* not_reviewed_count: int
* }
*/
private function emptySummary(string $concernFamily): array
{
return [
'concern_family' => $concernFamily,
'affected_total' => 0,
'reviewed_count' => 0,
'follow_up_needed_count' => 0,
'changed_since_review_count' => 0,
'not_reviewed_count' => 0,
];
}
}

View File

@ -660,6 +660,22 @@ private function resolveTenantWithRecord(?Model $record = null): ?Tenant
}
}
if ($this->action instanceof Action) {
$actionRecord = $this->action->getRecord(withDefault: false);
if ($actionRecord instanceof Tenant) {
return $actionRecord;
}
if ($actionRecord instanceof Model && method_exists($actionRecord, 'relationLoaded') && $actionRecord->relationLoaded('tenant')) {
$relatedTenant = $actionRecord->getRelation('tenant');
if ($relatedTenant instanceof Tenant) {
return $relatedTenant;
}
}
}
// If a record is set from forTableAction, try to resolve it
if ($this->record !== null) {
$resolved = $this->record instanceof Closure

View File

@ -12,6 +12,7 @@
use Closure;
use Filament\Actions\Action;
use Illuminate\Database\Eloquent\Model;
use ReflectionObject;
use Throwable;
/**
@ -39,6 +40,10 @@ final class WorkspaceUiEnforcement
private Model|Closure|null $record = null;
private bool $preserveExistingVisibility = false;
private bool $preserveExistingDisabled = false;
private function __construct(Action $action)
{
$this->action = $action;
@ -58,6 +63,14 @@ public static function forTableAction(Action $action, Model|Closure $record): se
return $instance;
}
public static function forAction(Action $action, Model|Closure|null $record = null): self
{
$instance = new self($action);
$instance->record = $record;
return $instance;
}
public function requireMembership(bool $require = true): self
{
$this->requireMembership = $require;
@ -95,6 +108,20 @@ public function tooltip(string $message): self
return $this;
}
public function preserveVisibility(): self
{
$this->preserveExistingVisibility = true;
return $this;
}
public function preserveDisabled(): self
{
$this->preserveExistingDisabled = true;
return $this;
}
public function apply(): Action
{
$this->applyVisibility();
@ -111,10 +138,22 @@ private function applyVisibility(): void
return;
}
$this->action->visible(function (?Model $record = null): bool {
$existingVisibility = $this->preserveExistingVisibility
? $this->getExistingVisibilityCondition()
: null;
$this->action->visible(function (?Model $record = null) use ($existingVisibility): bool {
$context = $this->resolveContextWithRecord($record);
return $context->isMember;
if (! $context->isMember) {
return false;
}
if ($existingVisibility === null) {
return true;
}
return $this->evaluateVisibilityCondition($existingVisibility, $record);
});
}
@ -126,7 +165,15 @@ private function applyDisabledState(): void
$tooltip = $this->customTooltip ?? AuthUiTooltips::insufficientPermission();
$this->action->disabled(function (?Model $record = null): bool {
$existingDisabled = $this->preserveExistingDisabled
? $this->getExistingDisabledCondition()
: null;
$this->action->disabled(function (?Model $record = null) use ($existingDisabled): bool {
if ($existingDisabled !== null && $this->evaluateDisabledCondition($existingDisabled, $record)) {
return true;
}
$context = $this->resolveContextWithRecord($record);
if (! $context->isMember) {
@ -173,6 +220,96 @@ private function applyServerSideGuard(): void
});
}
private function getExistingVisibilityCondition(): bool|Closure|null
{
try {
$ref = new ReflectionObject($this->action);
if (! $ref->hasProperty('isVisible')) {
return null;
}
$property = $ref->getProperty('isVisible');
$property->setAccessible(true);
/** @var bool|Closure $value */
$value = $property->getValue($this->action);
return $value;
} catch (Throwable) {
return null;
}
}
private function evaluateVisibilityCondition(bool|Closure $condition, ?Model $record): bool
{
if (is_bool($condition)) {
return $condition;
}
try {
$reflection = new \ReflectionFunction($condition);
$parameters = $reflection->getParameters();
if ($parameters === []) {
return (bool) $condition();
}
if ($record === null) {
return false;
}
return (bool) $condition($record);
} catch (Throwable) {
return false;
}
}
private function getExistingDisabledCondition(): bool|Closure|null
{
try {
$ref = new ReflectionObject($this->action);
if (! $ref->hasProperty('isDisabled')) {
return null;
}
$property = $ref->getProperty('isDisabled');
$property->setAccessible(true);
/** @var bool|Closure $value */
$value = $property->getValue($this->action);
return $value;
} catch (Throwable) {
return null;
}
}
private function evaluateDisabledCondition(bool|Closure $condition, ?Model $record): bool
{
if (is_bool($condition)) {
return $condition;
}
try {
$reflection = new \ReflectionFunction($condition);
$parameters = $reflection->getParameters();
if ($parameters === []) {
return (bool) $condition();
}
if ($record === null) {
return true;
}
return (bool) $condition($record);
} catch (Throwable) {
return true;
}
}
private function resolveContextWithRecord(?Model $record = null): WorkspaceAccessContext
{
$user = auth()->user();

View File

@ -6,10 +6,12 @@
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceComponentType;
use App\Support\Ui\ActionSurface\Enums\ActionSurfacePanelScope;
use ReflectionClass;
use Filament\Tables\Contracts\HasTable;
use RecursiveDirectoryIterator;
use RecursiveIteratorIterator;
use SplFileInfo;
use Throwable;
final class ActionSurfaceDiscovery
{
@ -100,7 +102,10 @@ private function panelScopesFor(string $className, array $adminScopedClasses): a
{
$scopes = [ActionSurfacePanelScope::Tenant];
if (in_array($className, $adminScopedClasses, true)) {
if (
in_array($className, $adminScopedClasses, true)
|| $this->inheritsAdminScopeFromResource($className, $adminScopedClasses)
) {
$scopes[] = ActionSurfacePanelScope::Admin;
}
@ -228,6 +233,37 @@ private function isDeclaredSystemTablePage(string $className): bool
&& method_exists($className, 'actionSurfaceDeclaration');
}
/**
* Resource-owned Filament pages can live under app/Filament/Pages and be routed
* from the resource instead of being panel-registered directly. When that happens,
* inherit admin scope from the owning resource so discovery stays truthful.
*
* @param array<int, string> $adminScopedClasses
*/
private function inheritsAdminScopeFromResource(string $className, array $adminScopedClasses): bool
{
if (! class_exists($className)) {
return false;
}
try {
$reflection = new ReflectionClass($className);
if (! $reflection->hasProperty('resource')) {
return false;
}
$defaults = $reflection->getDefaultProperties();
$resourceClass = $defaults['resource'] ?? null;
return is_string($resourceClass)
&& $resourceClass !== ''
&& in_array($resourceClass, $adminScopedClasses, true);
} catch (Throwable) {
return false;
}
}
/**
* @return array<int, string>
*/

View File

@ -13,6 +13,7 @@
use App\Models\FindingException;
use App\Models\OperationRun;
use App\Models\Tenant;
use App\Models\TenantTriageReview;
use App\Models\User;
use App\Models\Workspace;
use App\Services\Auth\CapabilityResolver;
@ -31,6 +32,7 @@
use App\Support\OperationRunLinks;
use App\Support\OpsUx\OperationUxPresenter;
use App\Support\PortfolioTriage\PortfolioArrivalContextToken;
use App\Support\PortfolioTriage\TenantTriageReviewStateResolver;
use App\Support\Rbac\UiTooltips;
use App\Support\RestoreSafety\RestoreResultAttention;
use App\Support\RestoreSafety\RestoreSafetyCopy;
@ -47,6 +49,7 @@ public function __construct(
private TenantGovernanceAggregateResolver $tenantGovernanceAggregateResolver,
private TenantBackupHealthResolver $tenantBackupHealthResolver,
private RestoreSafetyResolver $restoreSafetyResolver,
private TenantTriageReviewStateResolver $tenantTriageReviewStateResolver,
) {}
/**
@ -66,6 +69,7 @@ public function build(Workspace $workspace, User $user): array
$canViewAlerts = $this->workspaceCapabilityResolver->can($user, $workspace, Capabilities::ALERTS_VIEW);
$navigationContext = $this->workspaceOverviewNavigationContext();
$tenantContexts = $this->tenantContexts($accessibleTenants, $workspaceId, $canViewAlerts);
$triageReviewProgress = $this->triageReviewProgress($workspaceId, $tenantContexts);
$attentionItems = $this->attentionItems($tenantContexts, $user, $canViewAlerts, $navigationContext);
$governanceAttentionTenantCount = count(array_filter(
@ -141,6 +145,7 @@ public function build(Workspace $workspace, User $user): array
totalAlertFailuresCount: $totalAlertFailuresCount,
canViewAlerts: $canViewAlerts,
tenantContexts: $tenantContexts,
triageReviewSummaries: $triageReviewProgress['summaries'],
user: $user,
navigationContext: $navigationContext,
);
@ -164,6 +169,7 @@ public function build(Workspace $workspace, User $user): array
'workspace_name' => (string) $workspace->name,
'accessible_tenant_count' => $accessibleTenants->count(),
'summary_metrics' => $summaryMetrics,
'triage_review_progress' => $triageReviewProgress['families'],
'attention_items' => $attentionItems,
'attention_empty_state' => $attentionEmptyState,
'recent_operations' => $recentOperations,
@ -828,6 +834,7 @@ private function summaryMetrics(
int $totalAlertFailuresCount,
bool $canViewAlerts,
array $tenantContexts,
array $triageReviewSummaries,
User $user,
CanonicalNavigationContext $navigationContext,
): array {
@ -861,7 +868,11 @@ private function summaryMetrics(
label: 'Backup attention',
value: $backupAttentionTenantCount,
category: 'backup_health',
description: 'Visible tenants with non-healthy backup posture.',
description: $this->reviewSummaryMetricDescription(
family: PortfolioArrivalContextToken::FAMILY_BACKUP_HEALTH,
baseDescription: 'Visible tenants with non-healthy backup posture.',
triageReviewSummaries: $triageReviewSummaries,
),
color: $backupAttentionTenantCount > 0 ? 'danger' : 'gray',
destination: $this->attentionMetricDestination(
tenantContexts: $tenantContexts,
@ -874,7 +885,11 @@ private function summaryMetrics(
label: 'Recovery attention',
value: $recoveryAttentionTenantCount,
category: 'recovery_evidence',
description: 'Visible tenants with weakened or unvalidated recovery evidence.',
description: $this->reviewSummaryMetricDescription(
family: PortfolioArrivalContextToken::FAMILY_RECOVERY_EVIDENCE,
baseDescription: 'Visible tenants with weakened or unvalidated recovery evidence.',
triageReviewSummaries: $triageReviewSummaries,
),
color: $recoveryAttentionTenantCount > 0 ? 'warning' : 'gray',
destination: $this->attentionMetricDestination(
tenantContexts: $tenantContexts,
@ -912,6 +927,83 @@ private function summaryMetrics(
return $metrics;
}
/**
* @param list<array<string, mixed>> $tenantContexts
* @return array{
* summaries: array<string, array{
* concern_family: string,
* affected_total: int,
* reviewed_count: int,
* follow_up_needed_count: int,
* changed_since_review_count: int,
* not_reviewed_count: int
* }>,
* families: list<array<string, mixed>>
* }
*/
private function triageReviewProgress(int $workspaceId, array $tenantContexts): array
{
$tenantIds = [];
$backupHealthByTenant = [];
$recoveryEvidenceByTenant = [];
foreach ($tenantContexts as $context) {
$tenant = $context['tenant'] ?? null;
if (! $tenant instanceof Tenant) {
continue;
}
$tenantId = (int) $tenant->getKey();
$tenantIds[] = $tenantId;
$backupHealthByTenant[$tenantId] = $context['backup_health_assessment'] ?? null;
$recoveryEvidenceByTenant[$tenantId] = $context['recovery_evidence'] ?? null;
}
$resolved = $this->tenantTriageReviewStateResolver->resolveMany(
workspaceId: $workspaceId,
tenantIds: $tenantIds,
backupHealthByTenant: $backupHealthByTenant,
recoveryEvidenceByTenant: $recoveryEvidenceByTenant,
);
return [
'summaries' => $resolved['summaries'],
'families' => $this->triageReviewProgressFamilies($resolved['summaries']),
];
}
/**
* @param array<string, array{
* concern_family: string,
* affected_total: int,
* reviewed_count: int,
* follow_up_needed_count: int,
* changed_since_review_count: int,
* not_reviewed_count: int
* }> $triageReviewSummaries
*/
private function reviewSummaryMetricDescription(
string $family,
string $baseDescription,
array $triageReviewSummaries,
): string {
$summary = $triageReviewSummaries[$family] ?? null;
if (! is_array($summary) || (int) ($summary['affected_total'] ?? 0) === 0) {
return $baseDescription;
}
return sprintf(
'%s Reviewed %d/%d. Follow-up needed %d. Changed since review %d.',
$baseDescription,
(int) ($summary['reviewed_count'] ?? 0),
(int) ($summary['affected_total'] ?? 0),
(int) ($summary['follow_up_needed_count'] ?? 0),
(int) ($summary['changed_since_review_count'] ?? 0),
);
}
/**
* @return array<string, mixed>
*/
@ -938,6 +1030,77 @@ private function makeSummaryMetric(
];
}
/**
* @param array<string, array{
* concern_family: string,
* affected_total: int,
* reviewed_count: int,
* follow_up_needed_count: int,
* changed_since_review_count: int,
* not_reviewed_count: int
* }> $triageReviewSummaries
* @return list<array<string, mixed>>
*/
private function triageReviewProgressFamilies(array $triageReviewSummaries): array
{
$families = [];
foreach ([PortfolioArrivalContextToken::FAMILY_BACKUP_HEALTH, PortfolioArrivalContextToken::FAMILY_RECOVERY_EVIDENCE] as $family) {
$summary = $triageReviewSummaries[$family] ?? null;
if (! is_array($summary) || (int) ($summary['affected_total'] ?? 0) === 0) {
continue;
}
$families[] = [
'concern_family' => $family,
'label' => $family === PortfolioArrivalContextToken::FAMILY_BACKUP_HEALTH ? 'Backup health' : 'Recovery evidence',
'affected_total' => (int) ($summary['affected_total'] ?? 0),
'reviewed_count' => (int) ($summary['reviewed_count'] ?? 0),
'follow_up_needed_count' => (int) ($summary['follow_up_needed_count'] ?? 0),
'changed_since_review_count' => (int) ($summary['changed_since_review_count'] ?? 0),
'not_reviewed_count' => (int) ($summary['not_reviewed_count'] ?? 0),
'reviewed_destination' => $this->triageReviewBucketDestination($family, TenantTriageReview::STATE_REVIEWED, 'Reviewed'),
'follow_up_needed_destination' => $this->triageReviewBucketDestination($family, TenantTriageReview::STATE_FOLLOW_UP_NEEDED, 'Follow-up needed'),
'changed_since_review_destination' => $this->triageReviewBucketDestination($family, TenantTriageReview::DERIVED_STATE_CHANGED_SINCE_REVIEW, 'Changed since review'),
'not_reviewed_destination' => $this->triageReviewBucketDestination($family, TenantTriageReview::DERIVED_STATE_NOT_REVIEWED, 'Not reviewed'),
];
}
return $families;
}
/**
* @return array<string, mixed>
*/
private function triageReviewBucketDestination(string $family, string $reviewState, string $label): array
{
$filters = match ($family) {
PortfolioArrivalContextToken::FAMILY_BACKUP_HEALTH => [
'backup_posture' => [
TenantBackupHealthAssessment::POSTURE_ABSENT,
TenantBackupHealthAssessment::POSTURE_STALE,
TenantBackupHealthAssessment::POSTURE_DEGRADED,
],
'review_state' => [$reviewState],
'triage_sort' => TenantRecoveryTriagePresentation::TRIAGE_SORT_WORST_FIRST,
],
PortfolioArrivalContextToken::FAMILY_RECOVERY_EVIDENCE => [
'recovery_evidence' => [
TenantRecoveryTriagePresentation::RECOVERY_EVIDENCE_WEAKENED,
TenantRecoveryTriagePresentation::RECOVERY_EVIDENCE_UNVALIDATED,
],
'review_state' => [$reviewState],
'triage_sort' => TenantRecoveryTriagePresentation::TRIAGE_SORT_WORST_FIRST,
],
default => [
'review_state' => [$reviewState],
],
};
return $this->filteredTenantRegistryTarget($filters, $label);
}
/**
* @param list<array<string, mixed>> $tenantContexts
*/

View File

@ -0,0 +1,142 @@
<?php
declare(strict_types=1);
namespace Database\Factories;
use App\Models\Tenant;
use App\Models\TenantTriageReview;
use App\Models\User;
use App\Models\Workspace;
use App\Support\PortfolioTriage\PortfolioArrivalContextToken;
use Illuminate\Database\Eloquent\Factories\Factory;
/**
* @extends Factory<TenantTriageReview>
*/
class TenantTriageReviewFactory extends Factory
{
protected $model = TenantTriageReview::class;
/**
* @return array<string, mixed>
*/
public function definition(): array
{
$snapshot = [
'concernFamily' => PortfolioArrivalContextToken::FAMILY_BACKUP_HEALTH,
'concernState' => 'stale',
'reasonCode' => 'latest_backup_stale',
'severityKey' => 'latest_backup_stale',
'supportingKey' => 'latest_backup_stale',
];
return [
'tenant_id' => Tenant::factory()->for(Workspace::factory()),
'workspace_id' => function (array $attributes): int {
$tenantId = $attributes['tenant_id'] ?? null;
if (! is_numeric($tenantId)) {
return (int) Workspace::factory()->create()->getKey();
}
$tenant = Tenant::query()->whereKey((int) $tenantId)->first();
if (! $tenant instanceof Tenant || ! is_numeric($tenant->workspace_id)) {
return (int) Workspace::factory()->create()->getKey();
}
return (int) $tenant->workspace_id;
},
'concern_family' => PortfolioArrivalContextToken::FAMILY_BACKUP_HEALTH,
'current_state' => TenantTriageReview::STATE_REVIEWED,
'reviewed_at' => now()->subMinutes(5),
'reviewed_by_user_id' => User::factory(),
'review_fingerprint' => $this->hashSnapshot($snapshot),
'review_snapshot' => $snapshot,
'last_seen_matching_at' => now()->subMinutes(5),
'resolved_at' => null,
];
}
public function reviewed(): static
{
return $this->state(fn (): array => [
'current_state' => TenantTriageReview::STATE_REVIEWED,
]);
}
public function followUpNeeded(): static
{
return $this->state(fn (): array => [
'current_state' => TenantTriageReview::STATE_FOLLOW_UP_NEEDED,
]);
}
public function backupHealth(): static
{
return $this->state(function (): array {
$snapshot = [
'concernFamily' => PortfolioArrivalContextToken::FAMILY_BACKUP_HEALTH,
'concernState' => 'stale',
'reasonCode' => 'latest_backup_stale',
'severityKey' => 'latest_backup_stale',
'supportingKey' => 'latest_backup_stale',
];
return [
'concern_family' => PortfolioArrivalContextToken::FAMILY_BACKUP_HEALTH,
'review_fingerprint' => $this->hashSnapshot($snapshot),
'review_snapshot' => $snapshot,
];
});
}
public function recoveryEvidence(): static
{
return $this->state(function (): array {
$snapshot = [
'concernFamily' => PortfolioArrivalContextToken::FAMILY_RECOVERY_EVIDENCE,
'concernState' => 'weakened',
'reasonCode' => 'failed',
'severityKey' => 'failed',
'supportingKey' => 'failed',
];
return [
'concern_family' => PortfolioArrivalContextToken::FAMILY_RECOVERY_EVIDENCE,
'review_fingerprint' => $this->hashSnapshot($snapshot),
'review_snapshot' => $snapshot,
];
});
}
public function resolved(): static
{
return $this->state(fn (): array => [
'resolved_at' => now()->subMinute(),
]);
}
public function active(): static
{
return $this->state(fn (): array => [
'resolved_at' => null,
]);
}
public function changedFingerprint(): static
{
return $this->state(fn (): array => [
'review_fingerprint' => hash('sha256', 'changed-fingerprint-'.fake()->uuid()),
]);
}
/**
* @param array<string, mixed> $snapshot
*/
private function hashSnapshot(array $snapshot): string
{
return hash('sha256', json_encode($snapshot, JSON_THROW_ON_ERROR));
}
}

View File

@ -0,0 +1,63 @@
<?php
declare(strict_types=1);
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('tenant_triage_reviews', function (Blueprint $table): void {
$table->id();
$table->foreignId('workspace_id')->constrained('workspaces')->cascadeOnDelete();
$table->foreignId('tenant_id')->constrained('tenants')->cascadeOnDelete();
$table->string('concern_family', 64);
$table->string('current_state', 64);
$table->timestampTz('reviewed_at');
$table->foreignId('reviewed_by_user_id')->nullable()->constrained('users')->nullOnDelete();
$table->string('review_fingerprint', 64);
$table->jsonb('review_snapshot')->default('{}');
$table->timestampTz('last_seen_matching_at')->nullable();
$table->timestampTz('resolved_at')->nullable();
$table->timestamps();
$table->index(
['workspace_id', 'concern_family', 'resolved_at', 'tenant_id'],
'tenant_triage_reviews_lookup_index',
);
$table->index(
['tenant_id', 'concern_family', 'resolved_at'],
'tenant_triage_reviews_tenant_family_index',
);
});
DB::statement("
CREATE UNIQUE INDEX tenant_triage_reviews_active_unique
ON tenant_triage_reviews (workspace_id, tenant_id, concern_family)
WHERE resolved_at IS NULL
");
if (DB::getDriverName() !== 'sqlite') {
DB::statement("
ALTER TABLE tenant_triage_reviews
ADD CONSTRAINT tenant_triage_reviews_current_state_check
CHECK (current_state IN ('reviewed', 'follow_up_needed'))
");
}
}
public function down(): void
{
DB::statement('DROP INDEX IF EXISTS tenant_triage_reviews_active_unique');
if (DB::getDriverName() !== 'sqlite') {
DB::statement('ALTER TABLE tenant_triage_reviews DROP CONSTRAINT IF EXISTS tenant_triage_reviews_current_state_check');
}
Schema::dropIfExists('tenant_triage_reviews');
}
};

View File

@ -19,6 +19,49 @@
}
};
const isTransportEnvelope = (value) => {
return value !== null
&& typeof value === 'object'
&& Object.prototype.hasOwnProperty.call(value, 'status')
&& Object.prototype.hasOwnProperty.call(value, 'body')
&& Object.prototype.hasOwnProperty.call(value, 'json')
&& Object.prototype.hasOwnProperty.call(value, 'errors');
};
const isCancellationReason = (reason) => {
if (!isTransportEnvelope(reason)) {
return false;
}
return reason.status === null
&& reason.body === null
&& reason.json === null
&& reason.errors === null;
};
const isPageHiddenOrInactive = () => {
if (document.visibilityState !== 'visible') {
return true;
}
return typeof document.hasFocus === 'function'
? document.hasFocus() === false
: false;
};
const isExpectedBackgroundTransportFailure = (reason) => {
if (isCancellationReason(reason)) {
return true;
}
if (!isTransportEnvelope(reason) || !isPageHiddenOrInactive()) {
return false;
}
return (reason.status === 419 && typeof reason.body === 'string' && reason.body.includes('Page Expired'))
|| (reason.status === 404 && typeof reason.body === 'string' && reason.body.includes('Not Found'));
};
const normalizeReason = (value, depth = 0) => {
if (depth > 3) {
return '[max-depth-reached]';
@ -95,23 +138,36 @@
};
window.addEventListener('unhandledrejection', (event) => {
const normalizedReason = normalizeReason(event.reason);
const payload = {
source: 'window.unhandledrejection',
href: window.location.href,
timestamp: new Date().toISOString(),
reason: normalizeReason(event.reason),
reason: normalizedReason,
};
if (isExpectedBackgroundTransportFailure(normalizedReason)) {
event.preventDefault();
return;
}
const dedupeKey = toStableJson({
source: payload.source,
href: payload.href,
reason: payload.reason,
});
const payloadJson = toStableJson(payload);
const nowMs = Date.now();
cleanupRecentKeys(nowMs);
if (recentKeys.has(payloadJson)) {
if (recentKeys.has(dedupeKey)) {
return;
}
recentKeys.set(payloadJson, nowMs);
recentKeys.set(dedupeKey, nowMs);
console.error(`TenantPilot unhandled promise rejection ${payloadJson}`);
});

View File

@ -9,6 +9,8 @@
$duplicateNameSubjectsCountValue = (int) ($duplicateNameSubjectsCount ?? 0);
$explanation = is_array($operatorExplanation ?? null) ? $operatorExplanation : null;
$summary = is_array($summaryAssessment ?? null) ? $summaryAssessment : null;
$matrixNavigationContext = is_array($navigationContext ?? null) ? $navigationContext : null;
$arrivedFromCompareMatrix = str_starts_with((string) ($matrixNavigationContext['source_surface'] ?? ''), 'baseline_compare_matrix');
$explanationCounts = collect(is_array($explanation['countDescriptors'] ?? null) ? $explanation['countDescriptors'] : []);
$evaluationSpec = is_string($explanation['evaluationResult'] ?? null)
? \App\Support\Badges\BadgeCatalog::spec(\App\Support\Badges\BadgeDomain::OperatorExplanationEvaluationResult, $explanation['evaluationResult'])
@ -26,6 +28,28 @@
};
@endphp
@if ($arrivedFromCompareMatrix)
<x-filament::section>
<div class="flex flex-wrap items-center gap-2">
<x-filament::badge color="info" icon="heroicon-m-squares-2x2" size="sm">
Arrived from compare matrix
</x-filament::badge>
@if ($matrixBaselineProfileId)
<x-filament::badge color="gray" size="sm">
Baseline profile #{{ (int) $matrixBaselineProfileId }}
</x-filament::badge>
@endif
@if (filled($matrixSubjectKey))
<x-filament::badge color="gray" size="sm">
Subject {{ $matrixSubjectKey }}
</x-filament::badge>
@endif
</div>
</x-filament::section>
@endif
@if ($duplicateNamePoliciesCountValue > 0)
<div role="alert" class="rounded-lg border border-warning-300 bg-warning-50 p-4 dark:border-warning-700 dark:bg-warning-950/40">
<div class="flex items-start gap-3">

View File

@ -0,0 +1,868 @@
<x-filament::page>
@php
$reference = is_array($reference ?? null) ? $reference : [];
$tenantSummaries = is_array($tenantSummaries ?? null) ? $tenantSummaries : [];
$denseRows = is_array($denseRows ?? null) ? $denseRows : [];
$compactResults = is_array($compactResults ?? null) ? $compactResults : [];
$policyTypeOptions = is_array($policyTypeOptions ?? null) ? $policyTypeOptions : [];
$tenantSortOptions = is_array($tenantSortOptions ?? null) ? $tenantSortOptions : [];
$subjectSortOptions = is_array($subjectSortOptions ?? null) ? $subjectSortOptions : [];
$stateLegend = is_array($stateLegend ?? null) ? $stateLegend : [];
$freshnessLegend = is_array($freshnessLegend ?? null) ? $freshnessLegend : [];
$trustLegend = is_array($trustLegend ?? null) ? $trustLegend : [];
$emptyState = is_array($emptyState ?? null) ? $emptyState : null;
$currentFilters = is_array($currentFilters ?? null) ? $currentFilters : [];
$draftFilters = is_array($draftFilters ?? null) ? $draftFilters : [];
$presentationState = is_array($presentationState ?? null) ? $presentationState : [];
$supportSurfaceState = is_array($supportSurfaceState ?? null) ? $supportSurfaceState : [];
$referenceReady = ($reference['referenceState'] ?? null) === 'ready';
$activeFilterCount = $this->activeFilterCount();
$activeFilterSummary = $this->activeFilterSummary();
$stagedFilterSummary = $this->stagedFilterSummary();
$hasStagedFilterChanges = (bool) ($presentationState['hasStagedFilterChanges'] ?? false);
$requestedMode = (string) ($presentationState['requestedMode'] ?? 'auto');
$resolvedMode = (string) ($presentationState['resolvedMode'] ?? 'compact');
$visibleTenantCount = (int) ($presentationState['visibleTenantCount'] ?? 0);
$autoRefreshActive = (bool) ($presentationState['autoRefreshActive'] ?? false);
$lastUpdatedAt = $presentationState['lastUpdatedAt'] ?? null;
$compactModeAvailable = (bool) ($presentationState['compactModeAvailable'] ?? false);
$hiddenAssignedTenantCount = max(0, (int) ($reference['assignedTenantCount'] ?? 0) - $visibleTenantCount);
$stateBadge = static fn (mixed $value) => \App\Support\Badges\BadgeCatalog::spec(\App\Support\Badges\BadgeDomain::BaselineCompareMatrixState, $value);
$freshnessBadge = static fn (mixed $value) => \App\Support\Badges\BadgeCatalog::spec(\App\Support\Badges\BadgeDomain::BaselineCompareMatrixFreshness, $value);
$trustBadge = static fn (mixed $value) => \App\Support\Badges\BadgeCatalog::spec(\App\Support\Badges\BadgeDomain::BaselineCompareMatrixTrust, $value);
$severityBadge = static fn (mixed $value) => \App\Support\Badges\BadgeCatalog::spec(\App\Support\Badges\BadgeDomain::FindingSeverity, $value);
$profileStatusBadge = static fn (mixed $value) => \App\Support\Badges\BadgeCatalog::spec(\App\Support\Badges\BadgeDomain::BaselineProfileStatus, $value);
$profileStatusSpec = $profileStatusBadge($reference['baselineStatus'] ?? null);
$modeBadgeColor = match ($resolvedMode) {
'dense' => 'info',
'compact' => 'success',
default => 'gray',
};
$modeLabel = $this->presentationModeLabel($resolvedMode);
@endphp
@if ($autoRefreshActive)
<div aria-hidden="true" wire:poll.5s="pollMatrix"></div>
@endif
<x-filament::section heading="Reference overview">
<x-slot name="description">
Compare assigned tenants remains simulation only. This operator view changes presentation density, not compare truth, visible-set scope, or the existing drilldown path.
</x-slot>
<div class="space-y-4">
<div class="flex flex-col gap-4 xl:flex-row xl:items-start xl:justify-between">
<div class="space-y-3">
<div class="flex flex-wrap items-center gap-2">
<x-filament::badge :color="$profileStatusSpec->color" :icon="$profileStatusSpec->icon" size="sm">
{{ $profileStatusSpec->label }}
</x-filament::badge>
<x-filament::badge :color="$referenceReady ? 'success' : 'warning'" :icon="$referenceReady ? 'heroicon-m-check-badge' : 'heroicon-m-exclamation-triangle'" size="sm">
{{ $referenceReady ? 'Reference snapshot ready' : 'Reference snapshot blocked' }}
</x-filament::badge>
<x-filament::badge :color="$modeBadgeColor" size="sm">
{{ $modeLabel }}
</x-filament::badge>
@if (filled($reference['referenceSnapshotId'] ?? null))
<x-filament::badge color="gray" size="sm">
Snapshot #{{ (int) $reference['referenceSnapshotId'] }}
</x-filament::badge>
@endif
@if ($hiddenAssignedTenantCount > 0)
<x-filament::badge color="info" icon="heroicon-m-eye-slash" size="sm">
{{ $hiddenAssignedTenantCount }} hidden by access scope
</x-filament::badge>
@endif
</div>
<div class="space-y-1">
<h2 class="text-xl font-semibold text-gray-950 dark:text-white" data-testid="baseline-compare-matrix-profile">
{{ $reference['baselineProfileName'] ?? ($profile->name ?? 'Baseline compare matrix') }}
</h2>
<p class="text-sm text-gray-600 dark:text-gray-300">
Assigned tenants: {{ (int) ($reference['assignedTenantCount'] ?? 0) }}.
Visible tenants: {{ $visibleTenantCount }}.
@if (filled($reference['referenceSnapshotCapturedAt'] ?? null))
Reference captured {{ \Illuminate\Support\Carbon::parse($reference['referenceSnapshotCapturedAt'])->diffForHumans() }}.
@endif
</p>
<p class="text-sm text-gray-600 dark:text-gray-300">
Auto mode resolves from the visible tenant set. Manual mode stays local to this route and never becomes stored preference truth.
</p>
@if (filled($reference['referenceReasonCode'] ?? null))
<p class="text-sm text-warning-700 dark:text-warning-300">
Reference reason: {{ \Illuminate\Support\Str::headline(str_replace('.', ' ', (string) $reference['referenceReasonCode'])) }}
</p>
@endif
</div>
</div>
<dl class="grid gap-3 sm:grid-cols-2 xl:w-[28rem]">
<div class="rounded-2xl border border-gray-200 bg-white px-4 py-3 shadow-sm dark:border-gray-800 dark:bg-gray-900/70">
<dt class="text-[11px] font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">Visible tenants</dt>
<dd class="mt-1 text-3xl font-semibold text-gray-950 dark:text-white">
{{ $visibleTenantCount }}
</dd>
</div>
<div class="rounded-2xl border border-gray-200 bg-white px-4 py-3 shadow-sm dark:border-gray-800 dark:bg-gray-900/70">
<dt class="text-[11px] font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">Rendered subjects</dt>
<dd class="mt-1 text-3xl font-semibold text-gray-950 dark:text-white">
{{ $resolvedMode === 'compact' ? count($compactResults) : count($denseRows) }}
</dd>
</div>
<div class="rounded-2xl border border-gray-200 bg-white px-4 py-3 shadow-sm dark:border-gray-800 dark:bg-gray-900/70">
<dt class="text-[11px] font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">Active filters</dt>
<dd class="mt-1 text-sm font-medium text-gray-900 dark:text-white">
@if ($activeFilterCount === 0)
All visible results
@else
{{ $activeFilterCount }} active {{ \Illuminate\Support\Str::plural('filter', $activeFilterCount) }}
@endif
</dd>
</div>
<div class="rounded-2xl border border-gray-200 bg-white px-4 py-3 shadow-sm dark:border-gray-800 dark:bg-gray-900/70">
<dt class="text-[11px] font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">Resolved mode</dt>
<dd class="mt-1 text-sm font-medium text-gray-900 dark:text-white">
{{ $modeLabel }}
</dd>
</div>
</dl>
</div>
<div class="grid gap-4 xl:grid-cols-[minmax(0,1fr)_minmax(19rem,23rem)]">
<div class="rounded-2xl border border-gray-200 bg-gray-50/80 p-4 shadow-sm dark:border-gray-800 dark:bg-gray-900/50" data-testid="baseline-compare-matrix-mode-switcher">
<div class="flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between">
<div class="space-y-1">
<div class="text-sm font-semibold text-gray-950 dark:text-white">Presentation mode</div>
<p class="text-sm text-gray-600 dark:text-gray-300">
Requested: {{ $this->presentationModeLabel($requestedMode) }}. Resolved: {{ $modeLabel }}.
</p>
</div>
<div class="flex flex-wrap gap-2">
<x-filament::button tag="a" :href="$this->modeUrl('auto')" :color="$requestedMode === 'auto' ? 'primary' : 'gray'" size="sm">
Auto
</x-filament::button>
<x-filament::button tag="a" :href="$this->modeUrl('dense')" :color="$requestedMode === 'dense' ? 'primary' : 'gray'" size="sm">
Dense
</x-filament::button>
@if ($compactModeAvailable)
<x-filament::button tag="a" :href="$this->modeUrl('compact')" :color="$requestedMode === 'compact' ? 'primary' : 'gray'" size="sm">
Compact
</x-filament::button>
@else
<x-filament::badge color="gray" size="sm">
Compact unlocks at one visible tenant
</x-filament::badge>
@endif
</div>
</div>
</div>
<div class="rounded-2xl border border-gray-200 bg-white/90 p-4 shadow-sm dark:border-gray-800 dark:bg-gray-900/70">
<div class="flex flex-col gap-3">
<div class="flex flex-wrap items-center gap-2" data-testid="baseline-compare-matrix-last-updated">
@if (($supportSurfaceState['showLastUpdated'] ?? true) && filled($lastUpdatedAt))
<x-filament::badge color="gray" icon="heroicon-m-clock" size="sm">
Last updated {{ \Illuminate\Support\Carbon::parse($lastUpdatedAt)->diffForHumans() }}
</x-filament::badge>
@endif
@if (($supportSurfaceState['showAutoRefreshHint'] ?? false) && $autoRefreshActive)
<x-filament::badge color="info" icon="heroicon-m-arrow-path" size="sm">
Passive auto-refresh every 5 seconds
</x-filament::badge>
@endif
<div wire:loading.flex wire:target="refreshMatrix,applyFilters,resetFilters" class="items-center">
<x-filament::badge color="warning" icon="heroicon-m-arrow-path" size="sm">
Refreshing now
</x-filament::badge>
</div>
</div>
<div class="flex flex-wrap items-center gap-3">
<x-filament::button type="button" wire:click="refreshMatrix" wire:loading.attr="disabled" wire:target="refreshMatrix,applyFilters,resetFilters" color="gray" size="sm">
Refresh matrix
</x-filament::button>
@if ($hiddenAssignedTenantCount > 0)
<span class="text-xs text-gray-500 dark:text-gray-400">
Visible-set only. Hidden tenants never contribute to summaries or drilldowns.
</span>
@endif
</div>
</div>
</div>
</div>
</div>
</x-filament::section>
<x-filament::section heading="Filters">
<x-slot name="description">
Heavy filters stage locally first. The matrix keeps rendering the applied scope until you explicitly apply or reset the draft.
</x-slot>
<div class="space-y-4" data-testid="baseline-compare-matrix-filters">
<div class="rounded-2xl border border-gray-200 bg-gray-50/80 px-4 py-3 shadow-sm dark:border-gray-800 dark:bg-gray-900/50" data-testid="matrix-active-filters">
<div class="flex flex-col gap-3 xl:flex-row xl:items-start xl:justify-between">
<div class="space-y-2 min-w-0">
<div class="text-sm font-semibold text-gray-950 dark:text-white">Applied matrix scope</div>
<p class="text-sm leading-6 text-gray-600 dark:text-gray-300">
@if ($activeFilterCount === 0)
No narrowing filters are active. Showing every visible subject and tenant in the current baseline scope.
@else
{{ $activeFilterCount }} active {{ \Illuminate\Support\Str::plural('filter', $activeFilterCount) }} are already shaping the rendered matrix.
@endif
</p>
@if ($activeFilterSummary !== [])
<div class="flex flex-wrap gap-2">
@foreach ($activeFilterSummary as $label => $value)
<x-filament::badge color="info" size="sm">
{{ $label }}: {{ $value }}
</x-filament::badge>
@endforeach
</div>
@endif
</div>
<div class="flex flex-wrap items-center gap-2 xl:justify-end">
<x-filament::badge :color="$activeFilterCount === 0 ? 'gray' : 'info'" icon="heroicon-m-funnel" size="sm">
@if ($activeFilterCount === 0)
All visible results
@else
{{ $activeFilterCount }} active {{ \Illuminate\Support\Str::plural('filter', $activeFilterCount) }}
@endif
</x-filament::badge>
<x-filament::badge color="gray" size="sm">
Tenant sort: {{ $tenantSortOptions[$currentFilters['tenant_sort'] ?? 'tenant_name'] ?? 'Tenant name' }}
</x-filament::badge>
<x-filament::badge color="gray" size="sm">
Subject sort: {{ $subjectSortOptions[$currentFilters['subject_sort'] ?? 'deviation_breadth'] ?? 'Deviation breadth' }}
</x-filament::badge>
</div>
</div>
@if ($hasStagedFilterChanges)
<div class="mt-3 rounded-xl border border-primary-200 bg-primary-50/70 px-3 py-3 dark:border-primary-900/60 dark:bg-primary-950/20" data-testid="baseline-compare-matrix-staged-filters">
<div class="flex flex-col gap-2 lg:flex-row lg:items-start lg:justify-between">
<div class="space-y-1">
<div class="text-sm font-semibold text-primary-900 dark:text-primary-100">Draft filters are staged</div>
<p class="text-sm text-primary-800/90 dark:text-primary-200/90">
The controls below differ from the current route state. Apply them when you are ready to redraw the matrix.
</p>
</div>
@if ($stagedFilterSummary !== [])
<div class="flex flex-wrap gap-2">
@foreach ($stagedFilterSummary as $label => $value)
<x-filament::badge color="primary" size="sm">
{{ $label }}: {{ is_string($value) ? \Illuminate\Support\Str::headline(str_replace('_', ' ', $value)) : $value }}
</x-filament::badge>
@endforeach
</div>
@endif
</div>
</div>
@endif
</div>
<form wire:submit.prevent="applyFilters" class="space-y-4">
{{ $this->form }}
<div class="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between">
<div class="flex-1 rounded-xl border border-dashed border-gray-300 bg-gray-50 px-4 py-3 dark:border-gray-700 dark:bg-gray-900/40">
<div class="flex flex-wrap items-center gap-2 text-sm">
<span class="font-semibold text-gray-950 dark:text-white">Focused subject</span>
@if (filled($currentFilters['subject_key'] ?? null))
<x-filament::badge color="info" icon="heroicon-m-funnel" size="sm">
{{ $currentFilters['subject_key'] }}
</x-filament::badge>
<x-filament::button tag="a" :href="$this->clearSubjectFocusUrl()" color="gray" size="sm">
Clear subject focus
</x-filament::button>
@else
<span class="text-gray-500 dark:text-gray-400">
None set yet. Use Focus subject from a row when you want a subject-first drilldown.
</span>
@endif
</div>
</div>
<div class="flex flex-wrap items-center gap-3 lg:shrink-0">
<x-filament::button type="submit" color="primary" size="sm" wire:loading.attr="disabled" wire:target="applyFilters,resetFilters,refreshMatrix">
Apply filters
</x-filament::button>
<x-filament::button type="button" wire:click="resetFilters" color="gray" size="sm" wire:loading.attr="disabled" wire:target="applyFilters,resetFilters,refreshMatrix">
Reset filters
</x-filament::button>
</div>
</div>
</form>
<x-filament-actions::modals />
</div>
</x-filament::section>
<x-filament::section heading="Support context">
<x-slot name="description">
Status, legends, and refresh cues stay compact so the matrix body remains the primary working surface.
</x-slot>
<div class="grid gap-4 xl:grid-cols-[minmax(0,1fr)_minmax(22rem,26rem)]">
<div class="grid gap-3 sm:grid-cols-2">
<div class="rounded-2xl border border-gray-200 bg-white p-4 shadow-sm dark:border-gray-800 dark:bg-gray-900/70">
<div class="text-sm font-semibold text-gray-950 dark:text-white">Current scope</div>
<p class="mt-1 text-sm text-gray-600 dark:text-gray-300">
{{ $visibleTenantCount }} visible {{ \Illuminate\Support\Str::plural('tenant', $visibleTenantCount) }}.
{{ $resolvedMode === 'dense' ? 'State-first dense scan stays active.' : 'Compact single-tenant review stays active.' }}
</p>
@if ($policyTypeOptions !== [])
<div class="mt-3 flex flex-wrap gap-2">
<x-filament::badge color="gray" size="sm">
{{ count($policyTypeOptions) }} searchable policy types
</x-filament::badge>
@if ($hiddenAssignedTenantCount > 0)
<x-filament::badge color="gray" size="sm">
Visible-set only
</x-filament::badge>
@endif
</div>
@endif
</div>
<div class="rounded-2xl border border-gray-200 bg-white p-4 shadow-sm dark:border-gray-800 dark:bg-gray-900/70">
<div class="text-sm font-semibold text-gray-950 dark:text-white">Refresh honesty</div>
<p class="mt-1 text-sm text-gray-600 dark:text-gray-300">
Manual refresh shows a blocking state only while you explicitly redraw. Background polling remains a passive hint.
</p>
@if ($autoRefreshActive)
<div class="mt-3">
<x-filament::badge color="info" icon="heroicon-m-arrow-path" size="sm">
Compare work is still queued or running
</x-filament::badge>
</div>
@endif
</div>
</div>
<details class="rounded-2xl border border-gray-200 bg-gray-50/80 p-4 shadow-sm open:bg-white dark:border-gray-800 dark:bg-gray-900/50 dark:open:bg-gray-900/70">
<summary class="cursor-pointer list-none">
<div class="flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between">
<div class="space-y-1">
<div class="text-sm font-semibold text-gray-950 dark:text-white">Grouped legend</div>
<p class="text-sm text-gray-600 dark:text-gray-300">
State, freshness, and trust stay available on demand without pushing the matrix down the page.
</p>
</div>
<div class="flex flex-wrap gap-2">
<x-filament::badge color="gray" size="sm">{{ count($stateLegend) }} states</x-filament::badge>
<x-filament::badge color="gray" size="sm">{{ count($freshnessLegend) }} freshness cues</x-filament::badge>
<x-filament::badge color="gray" size="sm">{{ count($trustLegend) }} trust cues</x-filament::badge>
</div>
</div>
</summary>
<div class="mt-4 grid gap-3">
<div class="rounded-xl border border-gray-200 bg-white px-3 py-3 dark:border-gray-800 dark:bg-gray-950/40">
<div class="mb-2 text-[11px] font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">State legend</div>
<div class="flex flex-wrap gap-2">
@foreach ($stateLegend as $item)
<x-filament::badge :color="$item['color']" :icon="$item['icon']" size="sm">
{{ $item['label'] }}
</x-filament::badge>
@endforeach
</div>
</div>
<div class="rounded-xl border border-gray-200 bg-white px-3 py-3 dark:border-gray-800 dark:bg-gray-950/40">
<div class="mb-2 text-[11px] font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">Freshness legend</div>
<div class="flex flex-wrap gap-2">
@foreach ($freshnessLegend as $item)
<x-filament::badge :color="$item['color']" :icon="$item['icon']" size="sm">
{{ $item['label'] }}
</x-filament::badge>
@endforeach
</div>
</div>
<div class="rounded-xl border border-gray-200 bg-white px-3 py-3 dark:border-gray-800 dark:bg-gray-950/40">
<div class="mb-2 text-[11px] font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">Trust legend</div>
<div class="flex flex-wrap gap-2">
@foreach ($trustLegend as $item)
<x-filament::badge :color="$item['color']" :icon="$item['icon']" size="sm">
{{ $item['label'] }}
</x-filament::badge>
@endforeach
</div>
</div>
</div>
</details>
</div>
</x-filament::section>
<div class="relative" data-testid="baseline-compare-matrix-results">
@if ($emptyState !== null)
<x-filament::section heading="Results">
<div class="rounded-2xl border border-dashed border-gray-300 bg-gray-50 px-6 py-8 dark:border-gray-700 dark:bg-gray-900/40">
<div class="space-y-3" data-testid="baseline-compare-matrix-empty-state">
<h3 class="text-lg font-semibold text-gray-950 dark:text-white">{{ $emptyState['title'] ?? 'Nothing to show' }}</h3>
<p class="text-sm text-gray-600 dark:text-gray-300">{{ $emptyState['body'] ?? 'Adjust the current inputs and try again.' }}</p>
@if ($activeFilterCount > 0)
<div class="pt-1">
<x-filament::button type="button" wire:click="resetFilters" color="primary" size="sm" wire:loading.attr="disabled" wire:target="applyFilters,resetFilters,refreshMatrix">
Reset filters
</x-filament::button>
</div>
@endif
</div>
</div>
</x-filament::section>
@elseif ($resolvedMode === 'compact')
@php
$compactTenant = $tenantSummaries[0] ?? null;
$compactTenantFreshnessSpec = $freshnessBadge($compactTenant['freshnessState'] ?? null);
$compactTenantTrustSpec = $trustBadge($compactTenant['trustLevel'] ?? null);
@endphp
<x-filament::section heading="Compact compare results">
<x-slot name="description">
One visible tenant remains in scope, so the matrix collapses into a shorter subject-result list instead of a pseudo-grid.
</x-slot>
@if ($compactTenant)
<div class="mb-4 rounded-2xl border border-gray-200 bg-white p-4 shadow-sm dark:border-gray-800 dark:bg-gray-900/70" data-testid="baseline-compare-matrix-compact-shell">
<div class="flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between">
<div class="space-y-1">
<div class="text-sm font-semibold text-gray-950 dark:text-white">{{ $compactTenant['tenantName'] }}</div>
<p class="text-sm text-gray-600 dark:text-gray-300">
Compact mode stays visible-set only. Subject drilldowns and run links still preserve the matrix context.
</p>
</div>
<div class="flex flex-wrap gap-2">
<x-filament::badge :color="$compactTenantFreshnessSpec->color" :icon="$compactTenantFreshnessSpec->icon" size="sm">
{{ $compactTenantFreshnessSpec->label }}
</x-filament::badge>
<x-filament::badge :color="$compactTenantTrustSpec->color" :icon="$compactTenantTrustSpec->icon" size="sm">
{{ $compactTenantTrustSpec->label }}
</x-filament::badge>
</div>
</div>
</div>
@endif
<div class="space-y-3">
@foreach ($compactResults as $result)
@php
$stateSpec = $stateBadge($result['state'] ?? null);
$freshnessSpec = $freshnessBadge($result['freshnessState'] ?? null);
$trustSpec = $trustBadge($result['trustLevel'] ?? null);
$severitySpec = filled($result['severity'] ?? null) ? $severityBadge($result['severity']) : null;
$tenantId = (int) ($result['tenantId'] ?? 0);
$subjectKey = $result['subjectKey'] ?? null;
$primaryUrl = filled($result['findingId'] ?? null)
? $this->findingUrl($tenantId, (int) $result['findingId'], $subjectKey)
: $this->tenantCompareUrl($tenantId, $subjectKey);
$runUrl = filled($result['compareRunId'] ?? null)
? $this->runUrl((int) $result['compareRunId'], $tenantId, $subjectKey)
: null;
$attentionClasses = match ((string) ($result['attentionLevel'] ?? 'review')) {
'needs_attention' => 'bg-danger-100 text-danger-700 dark:bg-danger-950/40 dark:text-danger-300',
'refresh_recommended' => 'bg-warning-100 text-warning-700 dark:bg-warning-950/40 dark:text-warning-300',
'aligned' => 'bg-success-100 text-success-700 dark:bg-success-950/40 dark:text-success-300',
default => 'bg-gray-100 text-gray-700 dark:bg-gray-800 dark:text-gray-300',
};
$attentionLabel = \Illuminate\Support\Str::headline(str_replace('_', ' ', (string) ($result['attentionLevel'] ?? 'review')));
@endphp
<div class="rounded-2xl border border-gray-200 bg-white p-4 shadow-sm dark:border-gray-800 dark:bg-gray-900/70">
<div class="flex flex-col gap-4 xl:flex-row xl:items-start xl:justify-between">
<div class="space-y-3">
<div class="space-y-1">
<div class="text-base font-semibold text-gray-950 dark:text-white">
{{ $result['displayName'] ?? $result['subjectKey'] ?? 'Subject' }}
</div>
<div class="text-sm text-gray-500 dark:text-gray-400">
{{ $result['policyType'] ?? 'Unknown policy type' }}
</div>
@if (filled($result['baselineExternalId'] ?? null))
<div class="text-xs text-gray-500 dark:text-gray-400">
Reference ID: {{ $result['baselineExternalId'] }}
</div>
@endif
</div>
<div class="flex flex-wrap gap-2">
<x-filament::badge :color="$stateSpec->color" :icon="$stateSpec->icon" size="sm">
{{ $stateSpec->label }}
</x-filament::badge>
<x-filament::badge :color="$freshnessSpec->color" :icon="$freshnessSpec->icon" size="sm">
{{ $freshnessSpec->label }}
</x-filament::badge>
<x-filament::badge :color="$trustSpec->color" :icon="$trustSpec->icon" size="sm">
{{ $trustSpec->label }}
</x-filament::badge>
@if ($severitySpec)
<x-filament::badge :color="$severitySpec->color" :icon="$severitySpec->icon" size="sm">
{{ $severitySpec->label }}
</x-filament::badge>
@endif
<span class="inline-flex items-center rounded-full px-2 py-1 text-[11px] font-semibold uppercase tracking-wide {{ $attentionClasses }}">
{{ $attentionLabel }}
</span>
</div>
@if (filled($result['reasonSummary'] ?? null) || filled($result['lastComparedAt'] ?? null))
<div class="space-y-1 text-sm text-gray-600 dark:text-gray-300">
@if (filled($result['reasonSummary'] ?? null))
<div>{{ $result['reasonSummary'] }}</div>
@endif
@if (filled($result['lastComparedAt'] ?? null))
<div>Compared {{ \Illuminate\Support\Carbon::parse($result['lastComparedAt'])->diffForHumans() }}</div>
@endif
</div>
@endif
</div>
<div class="flex flex-col gap-3 xl:items-end">
<div class="flex flex-wrap gap-2">
<x-filament::badge color="gray" size="sm">
Drift breadth {{ (int) ($result['deviationBreadth'] ?? 0) }}
</x-filament::badge>
<x-filament::badge color="gray" size="sm">
Missing {{ (int) ($result['missingBreadth'] ?? 0) }}
</x-filament::badge>
<x-filament::badge color="gray" size="sm">
Ambiguous {{ (int) ($result['ambiguousBreadth'] ?? 0) }}
</x-filament::badge>
</div>
<div class="flex flex-wrap gap-3 text-sm">
@if ($primaryUrl)
<x-filament::link :href="$primaryUrl" size="sm">
{{ filled($result['findingId'] ?? null) ? 'Open finding' : 'Open tenant compare' }}
</x-filament::link>
@endif
@if ($runUrl)
<x-filament::link :href="$runUrl" color="gray" size="sm">
Open run
</x-filament::link>
@endif
@if (filled($result['subjectKey'] ?? null))
<x-filament::link :href="$this->filterUrl(['subject_key' => $result['subjectKey']])" color="gray" size="sm">
Focus subject
</x-filament::link>
@endif
</div>
</div>
</div>
</div>
@endforeach
</div>
</x-filament::section>
@else
<x-filament::section heading="Dense multi-tenant scan">
<x-slot name="description">
The matrix body is state-first. Row click stays forbidden, the subject column stays pinned, and repeated follow-up actions move behind compact secondary reveals.
</x-slot>
<div class="mb-4 grid gap-3 md:grid-cols-2 2xl:grid-cols-3">
@foreach ($tenantSummaries as $tenantSummary)
@php
$freshnessSpec = $freshnessBadge($tenantSummary['freshnessState'] ?? null);
$trustSpec = $trustBadge($tenantSummary['trustLevel'] ?? null);
$tenantSeveritySpec = filled($tenantSummary['maxSeverity'] ?? null) ? $severityBadge($tenantSummary['maxSeverity']) : null;
$tenantCompareUrl = $this->tenantCompareUrl((int) $tenantSummary['tenantId']);
$tenantRunUrl = filled($tenantSummary['compareRunId'] ?? null)
? $this->runUrl((int) $tenantSummary['compareRunId'], (int) $tenantSummary['tenantId'])
: null;
@endphp
<div class="rounded-2xl border border-gray-200 bg-white p-4 shadow-sm dark:border-gray-800 dark:bg-gray-900/70">
<div class="flex flex-col gap-3">
<div class="space-y-1">
<div class="text-sm font-semibold text-gray-950 dark:text-white">{{ $tenantSummary['tenantName'] }}</div>
<div class="flex flex-wrap gap-2">
<x-filament::badge :color="$freshnessSpec->color" :icon="$freshnessSpec->icon" size="sm">
{{ $freshnessSpec->label }}
</x-filament::badge>
<x-filament::badge :color="$trustSpec->color" :icon="$trustSpec->icon" size="sm">
{{ $trustSpec->label }}
</x-filament::badge>
@if ($tenantSeveritySpec)
<x-filament::badge :color="$tenantSeveritySpec->color" :icon="$tenantSeveritySpec->icon" size="sm">
{{ $tenantSeveritySpec->label }}
</x-filament::badge>
@endif
</div>
</div>
<div class="grid grid-cols-2 gap-3 text-sm">
<div>
<div class="text-[11px] font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">Aligned</div>
<div class="mt-1 font-semibold text-gray-950 dark:text-white">{{ (int) ($tenantSummary['matchedCount'] ?? 0) }}</div>
</div>
<div>
<div class="text-[11px] font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">Drift</div>
<div class="mt-1 font-semibold text-gray-950 dark:text-white">{{ (int) ($tenantSummary['differingCount'] ?? 0) }}</div>
</div>
<div>
<div class="text-[11px] font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">Missing</div>
<div class="mt-1 font-semibold text-gray-950 dark:text-white">{{ (int) ($tenantSummary['missingCount'] ?? 0) }}</div>
</div>
<div>
<div class="text-[11px] font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">Ambiguous</div>
<div class="mt-1 font-semibold text-gray-950 dark:text-white">{{ (int) ($tenantSummary['ambiguousCount'] ?? 0) }}</div>
</div>
</div>
<div class="flex flex-wrap gap-3 text-sm">
@if ($tenantCompareUrl)
<x-filament::link :href="$tenantCompareUrl" size="sm">
Open tenant compare
</x-filament::link>
@endif
@if ($tenantRunUrl)
<x-filament::link :href="$tenantRunUrl" color="gray" size="sm">
Open latest run
</x-filament::link>
@endif
</div>
</div>
</div>
@endforeach
</div>
<div class="overflow-x-auto rounded-2xl" data-testid="baseline-compare-matrix-grid">
<div class="min-w-[82rem] overflow-hidden rounded-2xl border border-gray-200 dark:border-gray-800" data-testid="baseline-compare-matrix-dense-shell">
<table class="min-w-full border-separate border-spacing-0">
<thead class="bg-gray-50 dark:bg-gray-950/70">
<tr>
<th class="sticky left-0 z-20 w-[22rem] border-r border-gray-200 bg-gray-50 px-4 py-3 text-left text-xs font-semibold uppercase tracking-wide text-gray-500 dark:border-gray-800 dark:bg-gray-950/70 dark:text-gray-400">
Baseline subject
</th>
@foreach ($tenantSummaries as $tenantSummary)
@php
$freshnessSpec = $freshnessBadge($tenantSummary['freshnessState'] ?? null);
@endphp
<th class="min-w-[16rem] border-b border-gray-200 px-4 py-3 text-left align-top text-xs font-semibold uppercase tracking-wide text-gray-500 dark:border-gray-800 dark:text-gray-400">
<div class="space-y-2">
<div class="text-sm font-semibold normal-case text-gray-950 dark:text-white">{{ $tenantSummary['tenantName'] }}</div>
<div class="flex flex-wrap gap-2">
<x-filament::badge :color="$freshnessSpec->color" :icon="$freshnessSpec->icon" size="sm">
{{ $freshnessSpec->label }}
</x-filament::badge>
</div>
</div>
</th>
@endforeach
</tr>
</thead>
<tbody class="divide-y divide-gray-200 dark:divide-gray-800">
@foreach ($denseRows as $row)
@php
$subject = is_array($row['subject'] ?? null) ? $row['subject'] : [];
$cells = is_array($row['cells'] ?? null) ? $row['cells'] : [];
$subjectTrustSpec = $trustBadge($subject['trustLevel'] ?? null);
$subjectSeveritySpec = filled($subject['maxSeverity'] ?? null) ? $severityBadge($subject['maxSeverity']) : null;
$rowKey = (string) ($subject['subjectKey'] ?? 'subject-'.$loop->index);
$rowSurfaceClasses = $loop->even
? 'bg-gray-50/70 dark:bg-gray-950/20'
: 'bg-white dark:bg-gray-900/60';
$subjectAttentionClasses = match ((string) ($subject['attentionLevel'] ?? 'review')) {
'needs_attention' => 'bg-danger-100 text-danger-700 dark:bg-danger-950/40 dark:text-danger-300',
'refresh_recommended' => 'bg-warning-100 text-warning-700 dark:bg-warning-950/40 dark:text-warning-300',
'aligned' => 'bg-success-100 text-success-700 dark:bg-success-950/40 dark:text-success-300',
default => 'bg-gray-100 text-gray-700 dark:bg-gray-800 dark:text-gray-300',
};
@endphp
<tr wire:key="baseline-compare-matrix-row-{{ $rowKey }}" class="group transition-colors hover:bg-primary-50/30 dark:hover:bg-primary-950/10 {{ $rowSurfaceClasses }}" data-testid="baseline-compare-matrix-row">
<td class="sticky left-0 z-10 border-r border-gray-200 px-4 py-4 align-top dark:border-gray-800 {{ $rowSurfaceClasses }}">
<div class="space-y-3">
<div class="space-y-1">
<div class="text-sm font-semibold text-gray-950 dark:text-white">
{{ $subject['displayName'] ?? $subject['subjectKey'] ?? 'Subject' }}
</div>
<div class="text-xs text-gray-500 dark:text-gray-400">
{{ $subject['policyType'] ?? 'Unknown policy type' }}
</div>
@if (filled($subject['baselineExternalId'] ?? null))
<div class="text-xs text-gray-500 dark:text-gray-400">
Reference ID: {{ $subject['baselineExternalId'] }}
</div>
@endif
</div>
<div class="flex flex-wrap gap-2">
<x-filament::badge color="gray" size="sm">
Drift breadth {{ (int) ($subject['deviationBreadth'] ?? 0) }}
</x-filament::badge>
<x-filament::badge color="gray" size="sm">
Missing {{ (int) ($subject['missingBreadth'] ?? 0) }}
</x-filament::badge>
<x-filament::badge :color="$subjectTrustSpec->color" :icon="$subjectTrustSpec->icon" size="sm">
{{ $subjectTrustSpec->label }}
</x-filament::badge>
@if ($subjectSeveritySpec)
<x-filament::badge :color="$subjectSeveritySpec->color" :icon="$subjectSeveritySpec->icon" size="sm">
{{ $subjectSeveritySpec->label }}
</x-filament::badge>
@endif
<span class="inline-flex items-center rounded-full px-2 py-1 text-[11px] font-semibold uppercase tracking-wide {{ $subjectAttentionClasses }}">
{{ \Illuminate\Support\Str::headline(str_replace('_', ' ', (string) ($subject['attentionLevel'] ?? 'review'))) }}
</span>
</div>
@if (filled($subject['subjectKey'] ?? null))
<div class="flex flex-wrap gap-3 text-sm">
<x-filament::link :href="$this->filterUrl(['subject_key' => $subject['subjectKey']])" color="gray" size="sm" data-testid="matrix-focus-subject">
Focus subject
</x-filament::link>
</div>
@endif
</div>
</td>
@foreach ($cells as $cell)
@php
$cellStateSpec = $stateBadge($cell['state'] ?? null);
$cellFreshnessSpec = $freshnessBadge($cell['freshnessState'] ?? null);
$cellTrustSpec = $trustBadge($cell['trustLevel'] ?? null);
$cellSeveritySpec = filled($cell['severity'] ?? null) ? $severityBadge($cell['severity']) : null;
$tenantId = (int) ($cell['tenantId'] ?? 0);
$subjectKey = $subject['subjectKey'] ?? ($cell['subjectKey'] ?? null);
$primaryUrl = filled($cell['findingId'] ?? null)
? $this->findingUrl($tenantId, (int) $cell['findingId'], $subjectKey)
: $this->tenantCompareUrl($tenantId, $subjectKey);
$runUrl = filled($cell['compareRunId'] ?? null)
? $this->runUrl((int) $cell['compareRunId'], $tenantId, $subjectKey)
: null;
$attentionClasses = match ((string) ($cell['attentionLevel'] ?? 'review')) {
'needs_attention' => 'bg-danger-100 text-danger-700 dark:bg-danger-950/40 dark:text-danger-300',
'refresh_recommended' => 'bg-warning-100 text-warning-700 dark:bg-warning-950/40 dark:text-warning-300',
'aligned' => 'bg-success-100 text-success-700 dark:bg-success-950/40 dark:text-success-300',
default => 'bg-gray-100 text-gray-700 dark:bg-gray-800 dark:text-gray-300',
};
$cellSurfaceClasses = match ((string) ($cell['attentionLevel'] ?? 'review')) {
'needs_attention' => 'border-danger-300 bg-danger-50/80 ring-1 ring-danger-200/70 dark:border-danger-900/70 dark:bg-danger-950/15 dark:ring-danger-900/40',
'refresh_recommended' => 'border-warning-300 bg-warning-50/80 ring-1 ring-warning-200/70 dark:border-warning-900/70 dark:bg-warning-950/15 dark:ring-warning-900/40',
'aligned' => 'border-success-200 bg-success-50/70 dark:border-success-900/60 dark:bg-success-950/10',
default => 'border-gray-200 bg-gray-50 dark:border-gray-800 dark:bg-gray-950/40',
};
@endphp
<td wire:key="baseline-compare-matrix-cell-{{ $rowKey }}-{{ $tenantId > 0 ? $tenantId : $loop->index }}" class="px-4 py-4 align-top">
<div class="flex h-full flex-col gap-3 rounded-xl border p-3 text-xs transition-colors group-hover:border-primary-200 dark:group-hover:border-primary-900 {{ $cellSurfaceClasses }}">
<div class="flex flex-wrap items-start justify-between gap-2">
<div class="flex flex-wrap gap-2">
<x-filament::badge :color="$cellStateSpec->color" :icon="$cellStateSpec->icon" size="sm">
{{ $cellStateSpec->label }}
</x-filament::badge>
@if ($cellSeveritySpec)
<x-filament::badge :color="$cellSeveritySpec->color" :icon="$cellSeveritySpec->icon" size="sm">
{{ $cellSeveritySpec->label }}
</x-filament::badge>
@endif
</div>
<span class="inline-flex items-center rounded-full px-2 py-1 text-[11px] font-semibold uppercase tracking-wide {{ $attentionClasses }}">
{{ \Illuminate\Support\Str::headline(str_replace('_', ' ', (string) ($cell['attentionLevel'] ?? 'review'))) }}
</span>
</div>
<div class="flex flex-wrap gap-2">
<x-filament::badge :color="$cellFreshnessSpec->color" :icon="$cellFreshnessSpec->icon" size="sm">
{{ $cellFreshnessSpec->label }}
</x-filament::badge>
<x-filament::badge :color="$cellTrustSpec->color" :icon="$cellTrustSpec->icon" size="sm">
{{ $cellTrustSpec->label }}
</x-filament::badge>
</div>
@if (filled($cell['reasonSummary'] ?? null) || filled($cell['lastComparedAt'] ?? null))
<div class="space-y-1 text-xs leading-5 text-gray-600 dark:text-gray-300">
@if (filled($cell['reasonSummary'] ?? null))
<div>{{ $cell['reasonSummary'] }}</div>
@endif
@if (filled($cell['lastComparedAt'] ?? null))
<div>Compared {{ \Illuminate\Support\Carbon::parse($cell['lastComparedAt'])->diffForHumans() }}</div>
@endif
</div>
@endif
<div class="mt-auto space-y-2">
@if ($primaryUrl)
<div class="text-sm">
<x-filament::link :href="$primaryUrl" size="sm">
{{ filled($cell['findingId'] ?? null) ? 'Open finding' : 'Open tenant compare' }}
</x-filament::link>
</div>
@endif
@if ($runUrl || filled($subjectKey))
<details class="rounded-lg border border-gray-200 bg-white/70 px-2 py-1.5 dark:border-gray-800 dark:bg-gray-950/50">
<summary class="cursor-pointer list-none text-[11px] font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">
More follow-up
</summary>
<div class="mt-2 flex flex-wrap gap-3 text-sm">
@if ($runUrl)
<x-filament::link :href="$runUrl" color="gray" size="sm">
Open run
</x-filament::link>
@endif
@if (filled($subjectKey))
<x-filament::link :href="$this->filterUrl(['subject_key' => $subjectKey])" color="gray" size="sm">
Focus subject
</x-filament::link>
@endif
</div>
</details>
@endif
</div>
</div>
</td>
@endforeach
</tr>
@endforeach
</tbody>
</table>
</div>
</div>
</x-filament::section>
@endif
</div>
</x-filament::page>

View File

@ -105,6 +105,7 @@ class="rounded-xl border border-gray-200 bg-white px-4 py-3 text-left transition
@livewire(\App\Filament\Widgets\Workspace\WorkspaceNeedsAttention::class, [
'items' => $overview['attention_items'] ?? [],
'emptyState' => $overview['attention_empty_state'] ?? [],
'triageReviewProgress' => $overview['triage_review_progress'] ?? [],
], key('workspace-overview-attention-' . ($workspace['id'] ?? 'none')))
@livewire(\App\Filament\Widgets\Workspace\WorkspaceRecentOperations::class, [

View File

@ -1,5 +1,6 @@
@php
/** @var ?\App\Support\PortfolioTriage\PortfolioArrivalContext $context */
/** @var array<string, mixed>|null $reviewState */
@endphp
<div>
@ -11,6 +12,8 @@
'stale', 'degraded', 'weakened', 'unvalidated' => 'warning',
default => 'gray',
};
$reviewStateColor = \App\Support\Badges\BadgeRenderer::color(\App\Support\Badges\BadgeDomain::TenantTriageReviewState)($reviewState['derived_state'] ?? null);
$reviewStateLabel = \App\Support\Badges\BadgeRenderer::label(\App\Support\Badges\BadgeDomain::TenantTriageReviewState)($reviewState['derived_state'] ?? null);
@endphp
<x-filament::section>
@ -49,6 +52,24 @@ class="h-5 w-5 text-warning-500"
{{ $context->arrivalSummary }}
</div>
@if (is_array($reviewState) && ($reviewState['current_concern_present'] ?? false) === true)
<div class="mt-3 flex flex-wrap items-center gap-2">
<x-filament::badge :color="$reviewStateColor" size="sm">
{{ $reviewStateLabel }}
</x-filament::badge>
<span class="text-xs text-gray-500 dark:text-gray-400">
{{ $context->concernFamilyLabel() }} review state
@if (filled($reviewState['reviewed_by_user_name'] ?? null))
by {{ $reviewState['reviewed_by_user_name'] }}
@endif
@if (($reviewState['reviewed_at'] ?? null) instanceof \Illuminate\Support\Carbon)
· {{ $reviewState['reviewed_at']->diffForHumans() }}
@endif
</span>
</div>
@endif
@if (filled($context->currentTruthDelta))
<div class="mt-3 rounded-lg bg-gray-50 p-3 text-sm text-gray-700 dark:bg-white/5 dark:text-gray-200">
{{ $context->currentTruthDelta }}
@ -89,9 +110,24 @@ class="h-5 w-5 text-warning-500"
{{ $context->nextStep['helperText'] }}
</div>
@endif
@if (is_array($reviewState) && ($reviewState['current_concern_present'] ?? false) === true)
<div class="rounded-lg border border-gray-200 bg-white/80 p-3 text-xs text-gray-600 dark:border-white/10 dark:bg-white/5 dark:text-gray-300">
TenantPilot only. Review state tracks shared triage progress and never changes backup posture or recovery evidence.
</div>
@endif
</div>
</div>
@if (is_array($reviewState) && ($reviewState['current_concern_present'] ?? false) === true)
<div class="flex flex-wrap items-center gap-2 border-t border-gray-200 pt-4 dark:border-white/10">
{{ $this->markReviewedAction }}
{{ $this->markFollowUpNeededAction }}
</div>
@endif
</div>
</x-filament::section>
<x-filament-actions::modals />
@endif
</div>

View File

@ -1,4 +1,42 @@
<x-filament::section heading="Needs attention">
@if ($triageReviewProgress !== [])
<div class="mb-4 grid grid-cols-1 gap-3 lg:grid-cols-2">
@foreach ($triageReviewProgress as $progress)
<div class="rounded-xl border border-gray-200 bg-white p-4 dark:border-white/10 dark:bg-white/5">
<div class="flex items-start justify-between gap-3">
<div>
<div class="text-sm font-semibold text-gray-950 dark:text-white">
{{ $progress['label'] }}
</div>
<div class="mt-1 text-xs text-gray-500 dark:text-gray-400">
Reviewed {{ $progress['reviewed_count'] }}/{{ $progress['affected_total'] }} · Follow-up needed {{ $progress['follow_up_needed_count'] }} · Changed since review {{ $progress['changed_since_review_count'] }}
</div>
</div>
<span class="inline-flex items-center rounded-full border border-gray-200 bg-gray-50 px-2.5 py-0.5 text-xs font-medium text-gray-600 dark:border-white/10 dark:bg-white/10 dark:text-gray-300">
Current affected set
</span>
</div>
<div class="mt-3 flex flex-wrap gap-2 text-xs">
<x-filament::link :href="$progress['not_reviewed_destination']['url']" size="sm">
Not reviewed {{ $progress['not_reviewed_count'] }}
</x-filament::link>
<x-filament::link :href="$progress['follow_up_needed_destination']['url']" size="sm">
Follow-up needed {{ $progress['follow_up_needed_count'] }}
</x-filament::link>
<x-filament::link :href="$progress['changed_since_review_destination']['url']" size="sm">
Changed since review {{ $progress['changed_since_review_count'] }}
</x-filament::link>
<x-filament::link :href="$progress['reviewed_destination']['url']" size="sm">
Reviewed {{ $progress['reviewed_count'] }}
</x-filament::link>
</div>
</div>
@endforeach
</div>
@endif
@if ($items === [])
<div class="flex h-full flex-col justify-between gap-4">
<div class="space-y-2">

View File

@ -0,0 +1,149 @@
<?php
declare(strict_types=1);
use App\Filament\Resources\BaselineProfileResource;
use App\Models\Finding;
use App\Models\User;
use App\Models\WorkspaceMembership;
use App\Support\Workspaces\WorkspaceContext;
use Tests\Feature\Concerns\BuildsBaselineCompareMatrixFixtures;
uses(BuildsBaselineCompareMatrixFixtures::class);
pest()->browser()->timeout(15_000);
it('smokes dense multi-tenant scanning and finding drilldown continuity', function (): void {
$fixture = $this->makeBaselineCompareMatrixFixture();
$run = $this->makeBaselineCompareMatrixRun(
$fixture['visibleTenant'],
$fixture['profile'],
$fixture['snapshot'],
);
$this->makeBaselineCompareMatrixRun(
$fixture['visibleTenantTwo'],
$fixture['profile'],
$fixture['snapshot'],
);
$this->makeBaselineCompareMatrixFinding(
$fixture['visibleTenant'],
$fixture['profile'],
$run,
'wifi-corp-profile',
['severity' => Finding::SEVERITY_CRITICAL],
);
$this->actingAs($fixture['user'])->withSession([
WorkspaceContext::SESSION_KEY => (int) $fixture['workspace']->getKey(),
WorkspaceContext::LAST_TENANT_IDS_SESSION_KEY => [
(string) $fixture['workspace']->getKey() => (int) $fixture['visibleTenant']->getKey(),
],
]);
session()->put(WorkspaceContext::SESSION_KEY, (int) $fixture['workspace']->getKey());
$page = visit(BaselineProfileResource::compareMatrixUrl($fixture['profile']));
$page
->assertNoJavaScriptErrors()
->waitForText('Requested: Auto mode. Resolved: Dense mode.')
->assertSee('Dense multi-tenant scan')
->assertSee('Grouped legend')
->assertSee('Open finding')
->assertSee('More follow-up')
->click('Open finding')
->waitForText('Back to compare matrix')
->assertNoJavaScriptErrors()
->assertSee('Back to compare matrix');
});
it('smokes the compact single-tenant path when only one visible tenant remains', function (): void {
$fixture = $this->makeBaselineCompareMatrixFixture();
$run = $this->makeBaselineCompareMatrixRun(
$fixture['visibleTenant'],
$fixture['profile'],
$fixture['snapshot'],
);
$this->makeBaselineCompareMatrixRun(
$fixture['visibleTenantTwo'],
$fixture['profile'],
$fixture['snapshot'],
);
$this->makeBaselineCompareMatrixFinding(
$fixture['visibleTenant'],
$fixture['profile'],
$run,
'wifi-corp-profile',
['severity' => Finding::SEVERITY_HIGH],
);
$viewer = User::factory()->create();
WorkspaceMembership::factory()->create([
'workspace_id' => (int) $fixture['workspace']->getKey(),
'user_id' => (int) $viewer->getKey(),
'role' => 'owner',
]);
$viewer->tenants()->syncWithoutDetaching([
(int) $fixture['visibleTenant']->getKey() => ['role' => 'owner'],
]);
$this->actingAs($viewer)->withSession([
WorkspaceContext::SESSION_KEY => (int) $fixture['workspace']->getKey(),
WorkspaceContext::LAST_TENANT_IDS_SESSION_KEY => [
(string) $fixture['workspace']->getKey() => (int) $fixture['visibleTenant']->getKey(),
],
]);
session()->put(WorkspaceContext::SESSION_KEY, (int) $fixture['workspace']->getKey());
visit(BaselineProfileResource::compareMatrixUrl($fixture['profile']))
->assertNoJavaScriptErrors()
->waitForText('Requested: Auto mode. Resolved: Compact mode.')
->assertSee('Compact compare results')
->assertSee('Open finding');
});
it('smokes filtered zero-results reset flow and passive refresh cues without losing the matrix route', function (): void {
$fixture = $this->makeBaselineCompareMatrixFixture();
$this->makeBaselineCompareMatrixRun(
$fixture['visibleTenant'],
$fixture['profile'],
$fixture['snapshot'],
attributes: [
'status' => \App\Support\OperationRunStatus::Queued->value,
'outcome' => \App\Support\OperationRunOutcome::Pending->value,
'completed_at' => null,
'started_at' => now(),
],
);
$this->makeBaselineCompareMatrixRun(
$fixture['visibleTenantTwo'],
$fixture['profile'],
$fixture['snapshot'],
);
$this->actingAs($fixture['user'])->withSession([
WorkspaceContext::SESSION_KEY => (int) $fixture['workspace']->getKey(),
WorkspaceContext::LAST_TENANT_IDS_SESSION_KEY => [
(string) $fixture['workspace']->getKey() => (int) $fixture['visibleTenant']->getKey(),
],
]);
session()->put(WorkspaceContext::SESSION_KEY, (int) $fixture['workspace']->getKey());
visit(BaselineProfileResource::compareMatrixUrl($fixture['profile']).'?mode=dense&state[]=missing')
->assertNoJavaScriptErrors()
->waitForText('No rows match the current filters')
->assertSee('Passive auto-refresh every 5 seconds')
->click('Reset filters')
->waitForText('Dense multi-tenant scan')
->assertSee('Requested: Dense mode. Resolved: Dense mode.')
->assertNoJavaScriptErrors();
});

View File

@ -0,0 +1,317 @@
<?php
declare(strict_types=1);
use App\Models\Tenant;
use App\Models\User;
use App\Models\WorkspaceMembership;
use App\Support\Baselines\BaselineCompareMatrixBuilder;
use App\Support\OperationRunOutcome;
use App\Support\OperationRunStatus;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\Feature\Concerns\BuildsBaselineCompareMatrixFixtures;
uses(RefreshDatabase::class, BuildsBaselineCompareMatrixFixtures::class);
it('builds visible-set-only dense rows plus support metadata from assigned baseline truth', function (): void {
$fixture = $this->makeBaselineCompareMatrixFixture();
$visibleRun = $this->makeBaselineCompareMatrixRun(
$fixture['visibleTenant'],
$fixture['profile'],
$fixture['snapshot'],
);
$visibleRunTwo = $this->makeBaselineCompareMatrixRun(
$fixture['visibleTenantTwo'],
$fixture['profile'],
$fixture['snapshot'],
);
$hiddenRun = $this->makeBaselineCompareMatrixRun(
$fixture['hiddenTenant'],
$fixture['profile'],
$fixture['snapshot'],
);
$this->makeBaselineCompareMatrixFinding(
$fixture['visibleTenantTwo'],
$fixture['profile'],
$visibleRunTwo,
'wifi-corp-profile',
);
$this->makeBaselineCompareMatrixFinding(
$fixture['hiddenTenant'],
$fixture['profile'],
$hiddenRun,
'wifi-corp-profile',
['severity' => 'critical'],
);
$matrix = app(BaselineCompareMatrixBuilder::class)->build($fixture['profile'], $fixture['user']);
$wifiRow = collect($matrix['denseRows'])->first(
static fn (array $row): bool => ($row['subject']['subjectKey'] ?? null) === 'wifi-corp-profile',
);
expect($matrix['reference']['assignedTenantCount'])->toBe(3)
->and($matrix['reference']['visibleTenantCount'])->toBe(2)
->and(collect($matrix['tenantSummaries'])->pluck('tenantName')->all())->toEqualCanonicalizing([
(string) $fixture['visibleTenant']->name,
(string) $fixture['visibleTenantTwo']->name,
])
->and($wifiRow)->not->toBeNull()
->and($wifiRow['subject']['deviationBreadth'])->toBe(1)
->and($wifiRow['subject']['attentionLevel'])->toBe('needs_attention')
->and(count($wifiRow['cells']))->toBe(2)
->and($matrix['denseRows'])->toHaveCount(2)
->and($matrix['compactResults'])->toBeEmpty()
->and($matrix['supportSurfaceState']['legendMode'])->toBe('grouped')
->and($matrix['supportSurfaceState']['showAutoRefreshHint'])->toBeFalse()
->and($matrix['lastUpdatedAt'])->not->toBeNull();
});
it('derives matrix cell precedence, freshness, attention, and reason summaries from compare truth', function (): void {
$fixture = $this->makeBaselineCompareMatrixFixture();
$matchTenant = $fixture['visibleTenant'];
$differTenant = $fixture['visibleTenantTwo'];
$missingTenant = Tenant::factory()->create([
'workspace_id' => (int) $fixture['workspace']->getKey(),
'name' => 'Contoso Missing',
]);
$ambiguousTenant = Tenant::factory()->create([
'workspace_id' => (int) $fixture['workspace']->getKey(),
'name' => 'Contoso Ambiguous',
]);
$notComparedTenant = Tenant::factory()->create([
'workspace_id' => (int) $fixture['workspace']->getKey(),
'name' => 'Contoso Uncovered',
]);
$staleTenant = Tenant::factory()->create([
'workspace_id' => (int) $fixture['workspace']->getKey(),
'name' => 'Contoso Stale',
]);
$fixture['user']->tenants()->syncWithoutDetaching([
(int) $missingTenant->getKey() => ['role' => 'owner'],
(int) $ambiguousTenant->getKey() => ['role' => 'owner'],
(int) $notComparedTenant->getKey() => ['role' => 'owner'],
(int) $staleTenant->getKey() => ['role' => 'owner'],
]);
$this->assignTenantToBaselineProfile($fixture['profile'], $missingTenant);
$this->assignTenantToBaselineProfile($fixture['profile'], $ambiguousTenant);
$this->assignTenantToBaselineProfile($fixture['profile'], $notComparedTenant);
$this->assignTenantToBaselineProfile($fixture['profile'], $staleTenant);
$this->makeBaselineCompareMatrixRun($matchTenant, $fixture['profile'], $fixture['snapshot']);
$differRun = $this->makeBaselineCompareMatrixRun($differTenant, $fixture['profile'], $fixture['snapshot']);
$this->makeBaselineCompareMatrixFinding($differTenant, $fixture['profile'], $differRun, 'wifi-corp-profile', [
'evidence_jsonb' => [
'subject_key' => 'wifi-corp-profile',
'change_type' => 'different_version',
],
]);
$missingRun = $this->makeBaselineCompareMatrixRun($missingTenant, $fixture['profile'], $fixture['snapshot']);
$this->makeBaselineCompareMatrixFinding($missingTenant, $fixture['profile'], $missingRun, 'wifi-corp-profile', [
'evidence_jsonb' => [
'subject_key' => 'wifi-corp-profile',
'change_type' => 'missing_policy',
],
]);
$ambiguousRun = $this->makeBaselineCompareMatrixRun(
$ambiguousTenant,
$fixture['profile'],
$fixture['snapshot'],
[
'baseline_compare' => [
'evidence_gaps' => [
'count' => 1,
'by_reason' => ['ambiguous_match' => 1],
'subjects' => [
$this->baselineCompareMatrixGap('deviceConfiguration', 'wifi-corp-profile', [
'reason_code' => 'ambiguous_match',
'resolution_outcome' => 'ambiguous_match',
]),
],
],
],
],
);
$this->makeBaselineCompareMatrixFinding($ambiguousTenant, $fixture['profile'], $ambiguousRun, 'wifi-corp-profile', [
'evidence_jsonb' => [
'subject_key' => 'wifi-corp-profile',
'change_type' => 'missing_policy',
],
]);
$this->makeBaselineCompareMatrixRun(
$notComparedTenant,
$fixture['profile'],
$fixture['snapshot'],
[
'baseline_compare' => [
'coverage' => [
'proof' => true,
'effective_types' => ['deviceConfiguration'],
'covered_types' => [],
'uncovered_types' => ['deviceConfiguration'],
],
],
],
);
$this->makeBaselineCompareMatrixRun(
$staleTenant,
$fixture['profile'],
$fixture['snapshot'],
[],
[
'completed_at' => $fixture['snapshot']->captured_at->copy()->subDay(),
'context' => [
'baseline_profile_id' => (int) $fixture['profile']->getKey(),
'baseline_snapshot_id' => (int) $fixture['snapshot']->getKey() - 1,
'baseline_compare' => [
'coverage' => [
'proof' => true,
'effective_types' => ['deviceConfiguration'],
'covered_types' => ['deviceConfiguration'],
'uncovered_types' => [],
],
'evidence_gaps' => [
'count' => 0,
'by_reason' => [],
'subjects' => [],
],
],
],
],
);
$matrix = app(BaselineCompareMatrixBuilder::class)->build($fixture['profile'], $fixture['user']);
$wifiRow = collect($matrix['denseRows'])->first(
static fn (array $row): bool => ($row['subject']['subjectKey'] ?? null) === 'wifi-corp-profile',
);
$cellsByTenant = collect($wifiRow['cells'] ?? [])
->mapWithKeys(static fn (array $cell): array => [(int) $cell['tenantId'] => $cell])
->all();
expect($cellsByTenant[(int) $matchTenant->getKey()]['state'] ?? null)->toBe('match')
->and($cellsByTenant[(int) $matchTenant->getKey()]['attentionLevel'] ?? null)->toBe('aligned')
->and($cellsByTenant[(int) $differTenant->getKey()]['state'] ?? null)->toBe('differ')
->and($cellsByTenant[(int) $differTenant->getKey()]['attentionLevel'] ?? null)->toBe('needs_attention')
->and($cellsByTenant[(int) $differTenant->getKey()]['reasonSummary'] ?? null)->toBe('A baseline compare finding exists for this subject.')
->and($cellsByTenant[(int) $missingTenant->getKey()]['state'] ?? null)->toBe('missing')
->and($cellsByTenant[(int) $missingTenant->getKey()]['attentionLevel'] ?? null)->toBe('needs_attention')
->and($cellsByTenant[(int) $ambiguousTenant->getKey()]['state'] ?? null)->toBe('ambiguous')
->and($cellsByTenant[(int) $ambiguousTenant->getKey()]['reasonSummary'] ?? null)->toBe('Ambiguous Match')
->and($cellsByTenant[(int) $notComparedTenant->getKey()]['state'] ?? null)->toBe('not_compared')
->and($cellsByTenant[(int) $notComparedTenant->getKey()]['attentionLevel'] ?? null)->toBe('refresh_recommended')
->and($cellsByTenant[(int) $notComparedTenant->getKey()]['reasonSummary'] ?? null)->toContain('Policy type coverage was not proven')
->and($cellsByTenant[(int) $staleTenant->getKey()]['state'] ?? null)->toBe('stale_result')
->and($cellsByTenant[(int) $staleTenant->getKey()]['freshnessState'] ?? null)->toBe('stale')
->and($cellsByTenant[(int) $staleTenant->getKey()]['trustLevel'] ?? null)->toBe('limited_confidence')
->and($cellsByTenant[(int) $staleTenant->getKey()]['attentionLevel'] ?? null)->toBe('refresh_recommended');
});
it('applies policy, state, severity, and subject-focus filters honestly without changing compare truth', function (): void {
$fixture = $this->makeBaselineCompareMatrixFixture();
$visibleRun = $this->makeBaselineCompareMatrixRun(
$fixture['visibleTenant'],
$fixture['profile'],
$fixture['snapshot'],
);
$this->makeBaselineCompareMatrixRun(
$fixture['visibleTenantTwo'],
$fixture['profile'],
$fixture['snapshot'],
[],
[
'status' => OperationRunStatus::Completed->value,
'outcome' => OperationRunOutcome::Failed->value,
],
);
$this->makeBaselineCompareMatrixFinding(
$fixture['visibleTenant'],
$fixture['profile'],
$visibleRun,
'wifi-corp-profile',
['severity' => 'critical'],
);
$deviceOnly = app(BaselineCompareMatrixBuilder::class)->build($fixture['profile'], $fixture['user'], [
'policyTypes' => ['deviceConfiguration'],
]);
$driftOnly = app(BaselineCompareMatrixBuilder::class)->build($fixture['profile'], $fixture['user'], [
'states' => ['differ'],
'severities' => ['critical'],
]);
$subjectFocus = app(BaselineCompareMatrixBuilder::class)->build($fixture['profile'], $fixture['user'], [
'focusedSubjectKey' => 'wifi-corp-profile',
]);
expect(count($deviceOnly['denseRows']))->toBe(1)
->and($deviceOnly['denseRows'][0]['subject']['policyType'])->toBe('deviceConfiguration')
->and(count($driftOnly['denseRows']))->toBe(1)
->and($driftOnly['denseRows'][0]['subject']['subjectKey'])->toBe('wifi-corp-profile')
->and(count($subjectFocus['denseRows']))->toBe(1)
->and($subjectFocus['denseRows'][0]['subject']['subjectKey'])->toBe('wifi-corp-profile');
});
it('emits compact single-tenant results from the visible set only when one tenant remains in scope', function (): void {
$fixture = $this->makeBaselineCompareMatrixFixture();
$run = $this->makeBaselineCompareMatrixRun(
$fixture['visibleTenant'],
$fixture['profile'],
$fixture['snapshot'],
);
$this->makeBaselineCompareMatrixFinding(
$fixture['visibleTenant'],
$fixture['profile'],
$run,
'wifi-corp-profile',
['severity' => 'critical'],
);
$this->makeBaselineCompareMatrixRun(
$fixture['visibleTenantTwo'],
$fixture['profile'],
$fixture['snapshot'],
);
$viewer = User::factory()->create();
WorkspaceMembership::factory()->create([
'workspace_id' => (int) $fixture['workspace']->getKey(),
'user_id' => (int) $viewer->getKey(),
'role' => 'owner',
]);
$viewer->tenants()->syncWithoutDetaching([
(int) $fixture['visibleTenant']->getKey() => ['role' => 'owner'],
]);
$matrix = app(BaselineCompareMatrixBuilder::class)->build($fixture['profile'], $viewer);
expect($matrix['reference']['assignedTenantCount'])->toBe(3)
->and($matrix['reference']['visibleTenantCount'])->toBe(1)
->and($matrix['compactResults'])->toHaveCount(2)
->and(collect($matrix['compactResults'])->pluck('tenantId')->unique()->all())->toBe([(int) $fixture['visibleTenant']->getKey()])
->and(collect($matrix['compactResults'])->firstWhere('subjectKey', 'wifi-corp-profile')['state'] ?? null)->toBe('differ')
->and(collect($matrix['compactResults'])->firstWhere('subjectKey', 'wifi-corp-profile')['attentionLevel'] ?? null)->toBe('needs_attention');
});

View File

@ -0,0 +1,95 @@
<?php
declare(strict_types=1);
use App\Filament\Pages\BaselineCompareMatrix;
use App\Jobs\CompareBaselineToTenantJob;
use App\Models\OperationRun;
use App\Models\Tenant;
use App\Services\Baselines\BaselineCompareService;
use App\Support\OperationRunOutcome;
use App\Support\OperationRunStatus;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Queue;
use Livewire\Livewire;
use Tests\Feature\Concerns\BuildsBaselineCompareMatrixFixtures;
uses(RefreshDatabase::class, BuildsBaselineCompareMatrixFixtures::class);
it('fans out compare starts across the visible assigned set without creating a workspace umbrella run', function (): void {
Queue::fake();
$fixture = $this->makeBaselineCompareMatrixFixture();
$readonlyTenant = Tenant::factory()->create([
'workspace_id' => (int) $fixture['workspace']->getKey(),
'name' => 'Readonly Contoso',
]);
$fixture['user']->tenants()->syncWithoutDetaching([
(int) $readonlyTenant->getKey() => ['role' => 'readonly'],
]);
$this->assignTenantToBaselineProfile($fixture['profile'], $readonlyTenant);
$service = app(BaselineCompareService::class);
$existingRunResult = $service->startCompareForProfile(
$fixture['profile'],
$fixture['visibleTenantTwo'],
$fixture['user'],
);
expect($existingRunResult['ok'] ?? false)->toBeTrue();
$result = $service->startCompareForVisibleAssignments($fixture['profile'], $fixture['user']);
expect($result['visibleAssignedTenantCount'])->toBe(3)
->and($result['queuedCount'])->toBe(1)
->and($result['alreadyQueuedCount'])->toBe(1)
->and($result['blockedCount'])->toBe(1);
$launchStates = collect($result['targets'])
->mapWithKeys(static fn (array $target): array => [(int) $target['tenantId'] => (string) $target['launchState']])
->all();
expect($launchStates[(int) $fixture['visibleTenant']->getKey()] ?? null)->toBe('queued')
->and($launchStates[(int) $fixture['visibleTenantTwo']->getKey()] ?? null)->toBe('already_queued')
->and($launchStates[(int) $readonlyTenant->getKey()] ?? null)->toBe('blocked');
Queue::assertPushed(CompareBaselineToTenantJob::class);
$activeRuns = OperationRun::query()
->where('workspace_id', (int) $fixture['workspace']->getKey())
->where('type', 'baseline_compare')
->get();
expect($activeRuns)->toHaveCount(2)
->and($activeRuns->every(static fn (OperationRun $run): bool => $run->tenant_id !== null))->toBeTrue()
->and($activeRuns->every(static fn (OperationRun $run): bool => (string) $run->status === OperationRunStatus::Queued->value))->toBeTrue()
->and($activeRuns->every(static fn (OperationRun $run): bool => (string) $run->outcome === OperationRunOutcome::Pending->value))->toBeTrue()
->and(OperationRun::query()->whereNull('tenant_id')->where('type', 'baseline_compare')->count())->toBe(0);
});
it('runs compare assigned tenants from the matrix page and keeps feedback on tenant-owned runs', function (): void {
Queue::fake();
$fixture = $this->makeBaselineCompareMatrixFixture();
$this->setAdminWorkspaceContext($fixture['user'], $fixture['workspace']);
Livewire::actingAs($fixture['user'])
->test(BaselineCompareMatrix::class, ['record' => $fixture['profile']->getKey()])
->assertActionVisible('compareAssignedTenants')
->assertActionEnabled('compareAssignedTenants')
->callAction('compareAssignedTenants')
->assertStatus(200);
Queue::assertPushed(CompareBaselineToTenantJob::class, 2);
expect(OperationRun::query()
->where('workspace_id', (int) $fixture['workspace']->getKey())
->where('type', 'baseline_compare')
->whereNull('tenant_id')
->count())->toBe(0);
});

View File

@ -12,8 +12,13 @@
use App\Services\Drift\DriftHasher;
use App\Services\Intune\AuditLogger;
use App\Services\OperationRunService;
use App\Support\Baselines\BaselineCompareMatrixBuilder;
use App\Support\OperationRunOutcome;
use App\Support\OperationRunType;
use Illuminate\Support\Facades\DB;
use Tests\Feature\Concerns\BuildsBaselineCompareMatrixFixtures;
uses(BuildsBaselineCompareMatrixFixtures::class);
it('runs baseline compare without outbound HTTP and uses chunking', function (): void {
bindFailHardGraphClient();
@ -100,3 +105,28 @@
expect($code)->toBeString();
expect($code)->toContain('->chunk(');
});
it('keeps matrix aggregation query-bounded over the visible assigned set', function (): void {
$fixture = $this->makeBaselineCompareMatrixFixture();
foreach (range(1, 6) as $index) {
$tenant = \App\Models\Tenant::factory()->create([
'workspace_id' => (int) $fixture['workspace']->getKey(),
'name' => 'Matrix Tenant '.$index,
]);
$fixture['user']->tenants()->syncWithoutDetaching([
(int) $tenant->getKey() => ['role' => 'owner'],
]);
$this->assignTenantToBaselineProfile($fixture['profile'], $tenant);
$this->makeBaselineCompareMatrixRun($tenant, $fixture['profile'], $fixture['snapshot']);
}
DB::enableQueryLog();
DB::flushQueryLog();
app(BaselineCompareMatrixBuilder::class)->build($fixture['profile'], $fixture['user']);
expect(count(DB::getQueryLog()))->toBeLessThanOrEqual(20);
});

View File

@ -0,0 +1,272 @@
<?php
declare(strict_types=1);
namespace Tests\Feature\Concerns;
use App\Models\BaselineProfile;
use App\Models\BaselineSnapshot;
use App\Models\BaselineSnapshotItem;
use App\Models\BaselineTenantAssignment;
use App\Models\Finding;
use App\Models\OperationRun;
use App\Models\Tenant;
use App\Models\User;
use App\Models\Workspace;
use App\Models\WorkspaceMembership;
use App\Support\Baselines\BaselineCaptureMode;
use App\Support\Baselines\BaselineProfileStatus;
use App\Support\OperationRunOutcome;
use App\Support\OperationRunStatus;
use App\Support\OperationRunType;
use App\Support\Workspaces\WorkspaceContext;
use Filament\Facades\Filament;
use Tests\Feature\Baselines\Support\BaselineSubjectResolutionFixtures;
trait BuildsBaselineCompareMatrixFixtures
{
/**
* @return array{
* user: User,
* workspace: Workspace,
* profile: BaselineProfile,
* snapshot: BaselineSnapshot,
* visibleTenant: Tenant,
* visibleTenantTwo: Tenant,
* hiddenTenant: Tenant,
* subjects: array<string, BaselineSnapshotItem>
* }
*/
protected function makeBaselineCompareMatrixFixture(
string $viewerRole = 'owner',
?string $workspaceRole = null,
): array {
[$user, $visibleTenant] = createUserWithTenant(role: $viewerRole, workspaceRole: $workspaceRole ?? $viewerRole);
$workspace = Workspace::query()->findOrFail((int) $visibleTenant->workspace_id);
$profile = BaselineProfile::factory()->create([
'workspace_id' => (int) $workspace->getKey(),
'status' => BaselineProfileStatus::Active->value,
'capture_mode' => BaselineCaptureMode::Opportunistic->value,
'name' => 'Visible-set baseline',
'scope_jsonb' => [
'policy_types' => ['deviceConfiguration', 'compliancePolicy'],
'foundation_types' => [],
],
]);
$snapshot = BaselineSnapshot::factory()->complete()->create([
'workspace_id' => (int) $workspace->getKey(),
'baseline_profile_id' => (int) $profile->getKey(),
'captured_at' => now()->subHours(2),
'completed_at' => now()->subHours(2),
]);
$profile->forceFill([
'active_snapshot_id' => (int) $snapshot->getKey(),
])->save();
$visibleTenantTwo = Tenant::factory()->create([
'workspace_id' => (int) $workspace->getKey(),
'name' => 'Northwind',
]);
$hiddenTenant = Tenant::factory()->create([
'workspace_id' => (int) $workspace->getKey(),
'name' => 'Hidden Fabrikam',
]);
$user->tenants()->syncWithoutDetaching([
(int) $visibleTenantTwo->getKey() => ['role' => 'owner'],
]);
WorkspaceMembership::query()->updateOrCreate([
'workspace_id' => (int) $workspace->getKey(),
'user_id' => (int) $user->getKey(),
], [
'role' => $workspaceRole ?? $viewerRole,
]);
$this->assignTenantToBaselineProfile($profile, $visibleTenant);
$this->assignTenantToBaselineProfile($profile, $visibleTenantTwo);
$this->assignTenantToBaselineProfile($profile, $hiddenTenant);
$subjects = [
'wifi-corp-profile' => $this->makeBaselineCompareMatrixSubject(
$snapshot,
'deviceConfiguration',
'wifi-corp-profile',
'WiFi Corp Profile',
'dc:wifi-corp-profile',
),
'windows-compliance' => $this->makeBaselineCompareMatrixSubject(
$snapshot,
'compliancePolicy',
'windows-compliance',
'Windows Compliance',
'cp:windows-compliance',
),
];
return [
'user' => $user,
'workspace' => $workspace,
'profile' => $profile,
'snapshot' => $snapshot,
'visibleTenant' => $visibleTenant,
'visibleTenantTwo' => $visibleTenantTwo,
'hiddenTenant' => $hiddenTenant,
'subjects' => $subjects,
];
}
protected function makeBaselineCompareMatrixSubject(
BaselineSnapshot $snapshot,
string $policyType,
string $subjectKey,
string $displayName,
?string $subjectExternalId = null,
): BaselineSnapshotItem {
return BaselineSnapshotItem::factory()->create([
'baseline_snapshot_id' => (int) $snapshot->getKey(),
'policy_type' => $policyType,
'subject_key' => $subjectKey,
'subject_external_id' => $subjectExternalId ?? $policyType.':'.$subjectKey,
'meta_jsonb' => ['display_name' => $displayName],
]);
}
protected function assignTenantToBaselineProfile(BaselineProfile $profile, Tenant $tenant): BaselineTenantAssignment
{
return BaselineTenantAssignment::factory()->create([
'workspace_id' => (int) $profile->workspace_id,
'baseline_profile_id' => (int) $profile->getKey(),
'tenant_id' => (int) $tenant->getKey(),
]);
}
/**
* @param array<string, mixed> $contextOverrides
* @param array<string, mixed> $attributes
*/
protected function makeBaselineCompareMatrixRun(
Tenant $tenant,
BaselineProfile $profile,
BaselineSnapshot $snapshot,
array $contextOverrides = [],
array $attributes = [],
): OperationRun {
$defaults = [
'tenant_id' => (int) $tenant->getKey(),
'workspace_id' => (int) $tenant->workspace_id,
'type' => OperationRunType::BaselineCompare->value,
'status' => OperationRunStatus::Completed->value,
'outcome' => OperationRunOutcome::Succeeded->value,
'initiator_name' => 'Spec190 Matrix',
'summary_counts' => [
'matched_items' => 1,
'different_items' => 0,
'missing_items' => 0,
'unexpected_items' => 0,
],
'context' => array_replace_recursive([
'baseline_profile_id' => (int) $profile->getKey(),
'baseline_snapshot_id' => (int) $snapshot->getKey(),
'baseline_compare' => [
'reason_code' => null,
'subjects_total' => 2,
'fidelity' => 'content',
'coverage' => [
'proof' => true,
'effective_types' => ['deviceConfiguration', 'compliancePolicy'],
'covered_types' => ['deviceConfiguration', 'compliancePolicy'],
'uncovered_types' => [],
],
'evidence_gaps' => [
'count' => 0,
'by_reason' => [],
'subjects' => [],
],
],
], $contextOverrides),
'started_at' => now()->subMinutes(5),
'completed_at' => now()->subMinute(),
];
return OperationRun::factory()->create(array_replace_recursive($defaults, $attributes));
}
/**
* @param array<string, mixed> $overrides
*/
protected function makeBaselineCompareMatrixFinding(
Tenant $tenant,
BaselineProfile $profile,
OperationRun $run,
string $subjectKey,
array $overrides = [],
): Finding {
$defaults = [
'tenant_id' => (int) $tenant->getKey(),
'workspace_id' => (int) $tenant->workspace_id,
'finding_type' => Finding::FINDING_TYPE_DRIFT,
'source' => 'baseline.compare',
'scope_key' => 'baseline_profile:'.(int) $profile->getKey(),
'baseline_operation_run_id' => (int) $run->getKey(),
'current_operation_run_id' => (int) $run->getKey(),
'subject_type' => 'policy',
'subject_external_id' => 'subject:'.$subjectKey,
'severity' => Finding::SEVERITY_HIGH,
'status' => Finding::STATUS_NEW,
'evidence_jsonb' => [
'subject_key' => $subjectKey,
'change_type' => 'different_version',
],
];
return Finding::factory()->create(array_replace_recursive($defaults, $overrides));
}
/**
* @param array<string, mixed> $overrides
* @return array<string, mixed>
*/
protected function baselineCompareMatrixGap(string $policyType, string $subjectKey, array $overrides = []): array
{
return BaselineSubjectResolutionFixtures::structuredGap(array_replace([
'policy_type' => $policyType,
'subject_key' => $subjectKey,
], $overrides));
}
protected function setAdminWorkspaceContext(User $user, Workspace|int $workspace, ?Tenant $rememberedTenant = null): array
{
$workspaceId = $workspace instanceof Workspace ? (int) $workspace->getKey() : (int) $workspace;
$session = [
WorkspaceContext::SESSION_KEY => $workspaceId,
];
if ($rememberedTenant instanceof Tenant) {
$session[WorkspaceContext::LAST_TENANT_IDS_SESSION_KEY] = [
(string) $workspaceId => (int) $rememberedTenant->getKey(),
];
}
$this->actingAs($user)->withSession($session);
session()->put(WorkspaceContext::SESSION_KEY, $workspaceId);
if ($rememberedTenant instanceof Tenant) {
session()->put(WorkspaceContext::LAST_TENANT_IDS_SESSION_KEY, [
(string) $workspaceId => (int) $rememberedTenant->getKey(),
]);
}
Filament::setCurrentPanel('admin');
Filament::setTenant(null, true);
Filament::bootCurrentPanel();
return $session;
}
}

View File

@ -8,12 +8,17 @@
use App\Models\BackupSet;
use App\Models\RestoreRun;
use App\Models\Tenant;
use App\Models\TenantTriageReview;
use App\Models\User;
use App\Services\PortfolioTriage\TenantTriageReviewService;
use App\Support\BackupHealth\TenantBackupHealthAssessment;
use App\Support\BackupHealth\TenantBackupHealthResolver;
use App\Support\RestoreSafety\RestoreResultAttention;
use App\Support\RestoreSafety\RestoreSafetyResolver;
use App\Support\Tenants\TenantRecoveryTriagePresentation;
use App\Support\Workspaces\WorkspaceContext;
use Filament\Facades\Filament;
use InvalidArgumentException;
use Livewire\Livewire;
trait BuildsPortfolioTriageFixtures
@ -154,12 +159,58 @@ protected function portfolioTriageRegistryList(User $user, Tenant $workspaceTena
protected function portfolioReturnFilters(
array $backupPosture = [],
array $recoveryEvidence = [],
array $reviewState = [],
?string $triageSort = TenantRecoveryTriagePresentation::TRIAGE_SORT_WORST_FIRST,
): array {
return [
'backup_posture' => $backupPosture,
'recovery_evidence' => $recoveryEvidence,
'review_state' => $reviewState,
'triage_sort' => $triageSort,
];
}
protected function seedPortfolioTriageReview(
Tenant $tenant,
string $concernFamily,
string $manualState = TenantTriageReview::STATE_REVIEWED,
?User $actor = null,
bool $changedFingerprint = false,
): TenantTriageReview {
$backupHealth = app(TenantBackupHealthResolver::class)->assess($tenant);
$recoveryEvidence = app(RestoreSafetyResolver::class)->dashboardRecoveryEvidence($tenant);
$review = match ($manualState) {
TenantTriageReview::STATE_REVIEWED => app(TenantTriageReviewService::class)->markReviewed(
tenant: $tenant,
concernFamily: $concernFamily,
backupHealth: $backupHealth,
recoveryEvidence: $recoveryEvidence,
actor: $actor,
),
TenantTriageReview::STATE_FOLLOW_UP_NEEDED => app(TenantTriageReviewService::class)->markFollowUpNeeded(
tenant: $tenant,
concernFamily: $concernFamily,
backupHealth: $backupHealth,
recoveryEvidence: $recoveryEvidence,
actor: $actor,
),
default => throw new InvalidArgumentException('Unsupported triage review state.'),
};
if ($changedFingerprint) {
$review->forceFill([
'review_fingerprint' => hash('sha256', sprintf(
'%s:%s:%d',
$concernFamily,
$manualState,
(int) $review->getKey(),
)),
])->save();
}
request()->attributes->remove('tenant_resource.triage_review_snapshot');
return $review->fresh(['reviewer']);
}
}

View File

@ -0,0 +1,279 @@
<?php
declare(strict_types=1);
use App\Filament\Pages\BaselineCompareMatrix;
use App\Filament\Resources\BaselineProfileResource;
use App\Models\User;
use App\Models\WorkspaceMembership;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Livewire\Livewire;
use Tests\Feature\Concerns\BuildsBaselineCompareMatrixFixtures;
uses(RefreshDatabase::class, BuildsBaselineCompareMatrixFixtures::class);
it('renders dense auto mode with sticky subject behavior and compact support surfaces', function (): void {
$fixture = $this->makeBaselineCompareMatrixFixture();
$run = $this->makeBaselineCompareMatrixRun(
$fixture['visibleTenantTwo'],
$fixture['profile'],
$fixture['snapshot'],
);
$this->makeBaselineCompareMatrixRun(
$fixture['visibleTenant'],
$fixture['profile'],
$fixture['snapshot'],
);
$this->makeBaselineCompareMatrixFinding(
$fixture['visibleTenantTwo'],
$fixture['profile'],
$run,
'wifi-corp-profile',
);
$session = $this->setAdminWorkspaceContext($fixture['user'], $fixture['workspace'], $fixture['visibleTenant']);
$this->withSession($session)
->get(BaselineProfileResource::compareMatrixUrl($fixture['profile']))
->assertOk()
->assertSee('Visible-set baseline')
->assertSee('Requested: Auto mode. Resolved: Dense mode.')
->assertDontSee('fonts/filament/filament/inter/inter-latin-wght-normal', false)
->assertDontSee('Passive auto-refresh every 5 seconds')
->assertSee('Grouped legend')
->assertSee('Apply filters')
->assertSee('Compact unlocks at one visible tenant')
->assertSee('Dense multi-tenant scan')
->assertSee('Open finding')
->assertSee('More follow-up')
->assertSee('data-testid="baseline-compare-matrix-dense-shell"', false)
->assertSee('sticky left-0', false);
});
it('stages heavy filter changes until apply and preserves mode and subject continuity in drilldown urls', function (): void {
$fixture = $this->makeBaselineCompareMatrixFixture();
$run = $this->makeBaselineCompareMatrixRun(
$fixture['visibleTenant'],
$fixture['profile'],
$fixture['snapshot'],
);
$finding = $this->makeBaselineCompareMatrixFinding(
$fixture['visibleTenant'],
$fixture['profile'],
$run,
'wifi-corp-profile',
);
$this->makeBaselineCompareMatrixRun(
$fixture['visibleTenantTwo'],
$fixture['profile'],
$fixture['snapshot'],
);
$this->setAdminWorkspaceContext($fixture['user'], $fixture['workspace'], $fixture['visibleTenant']);
$component = Livewire::withQueryParams([
'mode' => 'dense',
'policy_type' => ['deviceConfiguration'],
'state' => ['differ'],
'severity' => ['high'],
'subject_key' => 'wifi-corp-profile',
])
->actingAs($fixture['user'])
->test(BaselineCompareMatrix::class, ['record' => $fixture['profile']->getKey()])
->assertSet('requestedMode', 'dense')
->assertSee('Requested: Dense mode. Resolved: Dense mode.')
->assertSee('Focused subject')
->assertSee('wifi-corp-profile');
expect($component->instance()->hasStagedFilterChanges())->toBeFalse();
$component
->set('draftSelectedPolicyTypes', ['compliancePolicy'])
->set('draftSelectedStates', ['match'])
->set('draftSelectedSeverities', [])
->set('draftTenantSort', 'freshness_urgency')
->set('draftSubjectSort', 'display_name')
->assertSee('Draft filters are staged');
expect($component->instance()->hasStagedFilterChanges())->toBeTrue();
$component->call('applyFilters')->assertRedirect(
BaselineProfileResource::compareMatrixUrl($fixture['profile']).'?mode=dense&policy_type%5B0%5D=compliancePolicy&state%5B0%5D=match&tenant_sort=freshness_urgency&subject_sort=display_name&subject_key=wifi-corp-profile'
);
$applied = Livewire::withQueryParams([
'mode' => 'dense',
'policy_type' => ['compliancePolicy'],
'state' => ['match'],
'tenant_sort' => 'freshness_urgency',
'subject_sort' => 'display_name',
'subject_key' => 'wifi-corp-profile',
])
->actingAs($fixture['user'])
->test(BaselineCompareMatrix::class, ['record' => $fixture['profile']->getKey()]);
$tenantCompareUrl = $applied->instance()->tenantCompareUrl((int) $fixture['visibleTenant']->getKey(), 'wifi-corp-profile');
$findingUrl = $applied->instance()->findingUrl((int) $fixture['visibleTenant']->getKey(), (int) $finding->getKey(), 'wifi-corp-profile');
expect(urldecode((string) $tenantCompareUrl))->toContain('mode=dense')
->and(urldecode((string) $tenantCompareUrl))->toContain('subject_key=wifi-corp-profile')
->and(urldecode((string) $findingUrl))->toContain('mode=dense')
->and($findingUrl)->toContain('nav%5Bsource_surface%5D=baseline_compare_matrix');
$applied->call('resetFilters')->assertRedirect(
BaselineProfileResource::compareMatrixUrl($fixture['profile']).'?mode=dense'
);
});
it('resolves auto to compact for the visible-set-only single-tenant edge case and still allows dense override', function (): void {
$fixture = $this->makeBaselineCompareMatrixFixture();
$run = $this->makeBaselineCompareMatrixRun(
$fixture['visibleTenant'],
$fixture['profile'],
$fixture['snapshot'],
);
$this->makeBaselineCompareMatrixRun(
$fixture['visibleTenantTwo'],
$fixture['profile'],
$fixture['snapshot'],
);
$this->makeBaselineCompareMatrixFinding(
$fixture['visibleTenant'],
$fixture['profile'],
$run,
'wifi-corp-profile',
['severity' => 'critical'],
);
$viewer = User::factory()->create();
WorkspaceMembership::factory()->create([
'workspace_id' => (int) $fixture['workspace']->getKey(),
'user_id' => (int) $viewer->getKey(),
'role' => 'owner',
]);
$viewer->tenants()->syncWithoutDetaching([
(int) $fixture['visibleTenant']->getKey() => ['role' => 'owner'],
]);
$session = $this->setAdminWorkspaceContext($viewer, $fixture['workspace'], $fixture['visibleTenant']);
$this->withSession($session)
->get(BaselineProfileResource::compareMatrixUrl($fixture['profile']))
->assertOk()
->assertSee('Requested: Auto mode. Resolved: Compact mode.')
->assertSee('Compact compare results')
->assertSee('data-testid="baseline-compare-matrix-compact-shell"', false)
->assertDontSee('data-testid="baseline-compare-matrix-dense-shell"', false);
$this->withSession($session)
->get(BaselineProfileResource::compareMatrixUrl($fixture['profile']).'?mode=dense')
->assertOk()
->assertSee('Requested: Dense mode. Resolved: Dense mode.')
->assertSee('data-testid="baseline-compare-matrix-dense-shell"', false);
});
it('renders a blocked state when the baseline profile has no usable reference snapshot', function (): void {
$fixture = $this->makeBaselineCompareMatrixFixture();
$fixture['snapshot']->markIncomplete();
$session = $this->setAdminWorkspaceContext($fixture['user'], $fixture['workspace']);
$this->withSession($session)
->get(BaselineProfileResource::compareMatrixUrl($fixture['profile']))
->assertOk()
->assertSee('No usable reference snapshot')
->assertSee('Capture a complete baseline snapshot before using the compare matrix.');
});
it('renders an empty state when the baseline profile has no assigned tenants', function (): void {
$fixture = $this->makeBaselineCompareMatrixFixture();
$fixture['profile']->tenantAssignments()->delete();
$session = $this->setAdminWorkspaceContext($fixture['user'], $fixture['workspace']);
$this->withSession($session)
->get(BaselineProfileResource::compareMatrixUrl($fixture['profile']))
->assertOk()
->assertSee('No assigned tenants');
});
it('renders an empty state when the assigned set is not visible to the current actor', function (): void {
$fixture = $this->makeBaselineCompareMatrixFixture();
$viewer = User::factory()->create();
WorkspaceMembership::factory()->create([
'workspace_id' => (int) $fixture['workspace']->getKey(),
'user_id' => (int) $viewer->getKey(),
'role' => 'owner',
]);
$session = $this->setAdminWorkspaceContext($viewer, $fixture['workspace']);
$this->withSession($session)
->get(BaselineProfileResource::compareMatrixUrl($fixture['profile']))
->assertOk()
->assertSee('No visible assigned tenants');
});
it('renders a passive auto-refresh cue instead of a perpetual blocking state while compare runs remain active', function (): void {
$fixture = $this->makeBaselineCompareMatrixFixture();
$this->makeBaselineCompareMatrixRun(
$fixture['visibleTenant'],
$fixture['profile'],
$fixture['snapshot'],
attributes: [
'status' => \App\Support\OperationRunStatus::Queued->value,
'outcome' => \App\Support\OperationRunOutcome::Pending->value,
'completed_at' => null,
'started_at' => now(),
],
);
$session = $this->setAdminWorkspaceContext($fixture['user'], $fixture['workspace']);
$this->withSession($session)
->get(BaselineProfileResource::compareMatrixUrl($fixture['profile']))
->assertOk()
->assertSee('Passive auto-refresh every 5 seconds')
->assertSee('wire:poll.5s="pollMatrix"', false)
->assertSee('Refresh matrix');
});
it('renders a filtered zero-result state that preserves mode and offers reset filters as the primary cta', function (): void {
$fixture = $this->makeBaselineCompareMatrixFixture();
$this->makeBaselineCompareMatrixRun(
$fixture['visibleTenant'],
$fixture['profile'],
$fixture['snapshot'],
);
$this->makeBaselineCompareMatrixRun(
$fixture['visibleTenantTwo'],
$fixture['profile'],
$fixture['snapshot'],
);
$session = $this->setAdminWorkspaceContext($fixture['user'], $fixture['workspace']);
$this->withSession($session)
->get(BaselineProfileResource::compareMatrixUrl($fixture['profile']).'?mode=dense&state[]=missing')
->assertOk()
->assertSee('Requested: Dense mode. Resolved: Dense mode.')
->assertSee('No rows match the current filters')
->assertSee('Reset filters');
});

View File

@ -1,5 +1,6 @@
<?php
use App\Filament\Resources\BaselineProfileResource;
use App\Filament\Resources\BaselineProfileResource\Pages\ViewBaselineProfile;
use App\Jobs\CompareBaselineToTenantJob;
use App\Models\BaselineProfile;
@ -8,6 +9,7 @@
use App\Models\OperationRun;
use App\Support\Baselines\BaselineCaptureMode;
use App\Support\Workspaces\WorkspaceContext;
use Filament\Actions\Action;
use Illuminate\Support\Facades\Queue;
use Livewire\Livewire;
@ -132,3 +134,64 @@
Queue::assertNotPushed(CompareBaselineToTenantJob::class);
expect(OperationRun::query()->where('type', 'baseline_compare')->count())->toBe(0);
});
it('shows open-compare-matrix and compare-assigned-tenants header actions with simulation-only copy', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'owner');
$this->actingAs($user);
$profile = BaselineProfile::factory()->active()->create([
'workspace_id' => (int) $tenant->workspace_id,
]);
$snapshot = BaselineSnapshot::factory()->create([
'workspace_id' => (int) $tenant->workspace_id,
'baseline_profile_id' => (int) $profile->getKey(),
]);
$profile->update(['active_snapshot_id' => (int) $snapshot->getKey()]);
BaselineTenantAssignment::factory()->create([
'workspace_id' => (int) $tenant->workspace_id,
'tenant_id' => (int) $tenant->getKey(),
'baseline_profile_id' => (int) $profile->getKey(),
]);
session()->put(WorkspaceContext::SESSION_KEY, (int) $tenant->workspace_id);
Livewire::actingAs($user)
->test(ViewBaselineProfile::class, ['record' => $profile->getKey()])
->assertActionExists('openCompareMatrix', fn (Action $action): bool => $action->getLabel() === 'Open compare matrix'
&& $action->getUrl() === BaselineProfileResource::compareMatrixUrl($profile))
->assertActionExists('compareAssignedTenants', fn (Action $action): bool => $action->getLabel() === 'Compare assigned tenants'
&& $action->isConfirmationRequired()
&& str_contains((string) $action->getModalDescription(), 'Simulation only.'));
});
it('keeps compare-assigned-tenants visible but disabled for readonly workspace members', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'readonly');
$profile = BaselineProfile::factory()->active()->create([
'workspace_id' => (int) $tenant->workspace_id,
]);
$snapshot = BaselineSnapshot::factory()->create([
'workspace_id' => (int) $tenant->workspace_id,
'baseline_profile_id' => (int) $profile->getKey(),
]);
$profile->update(['active_snapshot_id' => (int) $snapshot->getKey()]);
BaselineTenantAssignment::factory()->create([
'workspace_id' => (int) $tenant->workspace_id,
'tenant_id' => (int) $tenant->getKey(),
'baseline_profile_id' => (int) $profile->getKey(),
]);
session()->put(WorkspaceContext::SESSION_KEY, (int) $tenant->workspace_id);
Livewire::actingAs($user)
->test(ViewBaselineProfile::class, ['record' => $profile->getKey()])
->assertActionVisible('openCompareMatrix')
->assertActionVisible('compareAssignedTenants')
->assertActionDisabled('compareAssignedTenants');
});

View File

@ -6,13 +6,20 @@
use App\Filament\Resources\BackupSetResource;
use App\Filament\Resources\RestoreRunResource;
use App\Filament\Resources\TenantResource;
use App\Filament\Widgets\Tenant\TenantTriageArrivalContinuity;
use App\Models\AuditLog;
use App\Models\TenantTriageReview;
use Filament\Actions\Action;
use App\Services\Auth\CapabilityResolver;
use App\Support\Audit\AuditActionId;
use App\Support\Auth\Capabilities;
use App\Support\BackupHealth\TenantBackupHealthAssessment;
use App\Support\PortfolioTriage\PortfolioArrivalContextToken;
use App\Support\Rbac\UiTooltips;
use App\Support\RestoreSafety\RestoreResultAttention;
use App\Support\Tenants\TenantRecoveryTriagePresentation;
use App\Support\Workspaces\WorkspaceContext;
use Livewire\Livewire;
use Tests\Feature\Concerns\BuildsPortfolioTriageFixtures;
use function Pest\Laravel\mock;
@ -26,6 +33,18 @@ function tenantDashboardArrivalUrl(\App\Models\Tenant $tenant, array $state): st
], panel: 'tenant', tenant: $tenant);
}
function tenantDashboardArrivalWidget(\App\Models\User $user, \App\Models\Tenant $tenant, array $state): mixed
{
test()->actingAs($user);
session()->put(WorkspaceContext::SESSION_KEY, (int) $tenant->workspace_id);
setTenantPanelContext($tenant);
request()->attributes->remove('portfolio_triage.arrival_context');
return Livewire::withQueryParams([
PortfolioArrivalContextToken::QUERY_PARAMETER => PortfolioArrivalContextToken::encode($state),
])->actingAs($user)->test(TenantTriageArrivalContinuity::class);
}
it('renders source surface, concern, next step, and return flow for workspace backup arrivals', function (): void {
[$user, $tenant] = $this->makePortfolioTriageActor('Workspace Backup Tenant');
$this->actingAs($user);
@ -164,3 +183,71 @@ function tenantDashboardArrivalUrl(\App\Models\Tenant $tenant, array $state): st
'backup_health_reason' => TenantBackupHealthAssessment::REASON_NO_BACKUP_BASIS,
], panel: 'tenant', tenant: $tenant), false);
});
it('shows review-state context and requires preview confirmation before marking the current concern reviewed', function (): void {
[$user, $tenant] = $this->makePortfolioTriageActor('Dashboard Review Tenant');
$this->seedPortfolioBackupConcern($tenant, TenantBackupHealthAssessment::POSTURE_STALE);
$component = tenantDashboardArrivalWidget($user, $tenant, [
'sourceSurface' => PortfolioArrivalContextToken::SOURCE_TENANT_REGISTRY,
'tenantRouteKey' => (string) $tenant->external_id,
'workspaceId' => (int) $tenant->workspace_id,
'concernFamily' => PortfolioArrivalContextToken::FAMILY_BACKUP_HEALTH,
'concernState' => TenantBackupHealthAssessment::POSTURE_STALE,
'concernReason' => TenantBackupHealthAssessment::REASON_LATEST_BACKUP_STALE,
'returnFilters' => $this->portfolioReturnFilters(
[TenantBackupHealthAssessment::POSTURE_STALE],
),
])
->assertSee('Not reviewed')
->assertActionVisible('markReviewed')
->assertActionExists('markReviewed', fn (Action $action): bool => $action->isConfirmationRequired()
&& str_contains((string) $action->getModalDescription(), 'Concern family: Backup health')
&& str_contains((string) $action->getModalDescription(), 'Target state: Reviewed')
&& str_contains((string) $action->getModalDescription(), 'TenantPilot only'))
->mountAction('markReviewed');
expect(TenantTriageReview::query()->count())->toBe(0);
$component
->callMountedAction()
->assertSee('Reviewed');
expect(TenantTriageReview::query()
->where('tenant_id', (int) $tenant->getKey())
->where('concern_family', PortfolioArrivalContextToken::FAMILY_BACKUP_HEALTH)
->where('current_state', TenantTriageReview::STATE_REVIEWED)
->whereNull('resolved_at')
->exists())->toBeTrue()
->and(AuditLog::query()
->where('workspace_id', (int) $tenant->workspace_id)
->where('tenant_id', (int) $tenant->getKey())
->where('action', AuditActionId::TenantTriageReviewMarkedReviewed->value)
->exists())->toBeTrue();
});
it('renders changed-since-review when the current concern fingerprint no longer matches the stored review', function (): void {
[$user, $tenant] = $this->makePortfolioTriageActor('Dashboard Changed Tenant');
$this->seedPortfolioBackupConcern($tenant, TenantBackupHealthAssessment::POSTURE_STALE);
$this->seedPortfolioTriageReview(
$tenant,
PortfolioArrivalContextToken::FAMILY_BACKUP_HEALTH,
TenantTriageReview::STATE_REVIEWED,
$user,
changedFingerprint: true,
);
tenantDashboardArrivalWidget($user, $tenant, [
'sourceSurface' => PortfolioArrivalContextToken::SOURCE_TENANT_REGISTRY,
'tenantRouteKey' => (string) $tenant->external_id,
'workspaceId' => (int) $tenant->workspace_id,
'concernFamily' => PortfolioArrivalContextToken::FAMILY_BACKUP_HEALTH,
'concernState' => TenantBackupHealthAssessment::POSTURE_STALE,
'concernReason' => TenantBackupHealthAssessment::REASON_LATEST_BACKUP_STALE,
'returnFilters' => $this->portfolioReturnFilters(
[TenantBackupHealthAssessment::POSTURE_STALE],
),
])
->assertSee('Changed since review')
->assertSee($user->name);
});

View File

@ -0,0 +1,176 @@
<?php
declare(strict_types=1);
use App\Filament\Resources\TenantResource\Pages\ListTenants;
use App\Services\Auth\CapabilityResolver;
use App\Support\Auth\Capabilities;
use App\Models\Tenant;
use App\Models\TenantTriageReview;
use App\Support\BackupHealth\TenantBackupHealthAssessment;
use App\Support\PortfolioTriage\PortfolioArrivalContextToken;
use App\Support\Tenants\TenantRecoveryTriagePresentation;
use Carbon\CarbonImmutable;
use Filament\Actions\Action;
use Tests\Feature\Concerns\BuildsPortfolioTriageFixtures;
uses(BuildsPortfolioTriageFixtures::class);
afterEach(function (): void {
CarbonImmutable::setTestNow();
});
it('renders review-state badges and all four review-state filters for the current backup slice', function (): void {
CarbonImmutable::setTestNow(CarbonImmutable::create(2026, 4, 10, 8, 0, 0, 'UTC'));
[$user, $anchorTenant] = $this->makePortfolioTriageActor('Anchor Backup Tenant');
$reviewedTenant = $this->makePortfolioTriagePeer($user, $anchorTenant, 'Reviewed Backup Tenant');
$followUpTenant = $this->makePortfolioTriagePeer($user, $anchorTenant, 'Follow-up Backup Tenant');
$changedTenant = $this->makePortfolioTriagePeer($user, $anchorTenant, 'Changed Backup Tenant');
$notReviewedTenant = $this->makePortfolioTriagePeer($user, $anchorTenant, 'Not Reviewed Backup Tenant');
$calmTenant = $this->makePortfolioTriagePeer($user, $anchorTenant, 'Calm Backup Tenant');
foreach ([$reviewedTenant, $followUpTenant, $changedTenant, $notReviewedTenant] as $tenant) {
$this->seedPortfolioBackupConcern($tenant, TenantBackupHealthAssessment::POSTURE_STALE);
}
$this->seedPortfolioBackupConcern($calmTenant, TenantBackupHealthAssessment::POSTURE_HEALTHY);
$this->seedPortfolioTriageReview($reviewedTenant, PortfolioArrivalContextToken::FAMILY_BACKUP_HEALTH, TenantTriageReview::STATE_REVIEWED, $user);
$this->seedPortfolioTriageReview($followUpTenant, PortfolioArrivalContextToken::FAMILY_BACKUP_HEALTH, TenantTriageReview::STATE_FOLLOW_UP_NEEDED, $user);
$this->seedPortfolioTriageReview($changedTenant, PortfolioArrivalContextToken::FAMILY_BACKUP_HEALTH, TenantTriageReview::STATE_REVIEWED, $user, changedFingerprint: true);
$this->portfolioTriageRegistryList($user, $anchorTenant, [
'backup_posture' => [TenantBackupHealthAssessment::POSTURE_STALE],
])
->assertTableColumnExists('review_state')
->assertTableColumnFormattedStateSet('review_state', 'Reviewed', $reviewedTenant)
->assertTableColumnFormattedStateSet('review_state', 'Follow-up needed', $followUpTenant)
->assertTableColumnFormattedStateSet('review_state', 'Changed since review', $changedTenant)
->assertTableColumnFormattedStateSet('review_state', 'Not reviewed', $notReviewedTenant)
->assertDontSee('Calm Backup Tenant');
$reviewedNames = $this->portfolioTriageRegistryList($user, $anchorTenant, [
'backup_posture' => [TenantBackupHealthAssessment::POSTURE_STALE],
])
->filterTable('review_state', [TenantTriageReview::STATE_REVIEWED])
->instance()
->getFilteredTableQuery()
?->pluck('tenants.name')
->all();
$followUpNames = $this->portfolioTriageRegistryList($user, $anchorTenant, [
'backup_posture' => [TenantBackupHealthAssessment::POSTURE_STALE],
])
->filterTable('review_state', [TenantTriageReview::STATE_FOLLOW_UP_NEEDED])
->instance()
->getFilteredTableQuery()
?->pluck('tenants.name')
->all();
$changedNames = $this->portfolioTriageRegistryList($user, $anchorTenant, [
'backup_posture' => [TenantBackupHealthAssessment::POSTURE_STALE],
])
->filterTable('review_state', [TenantTriageReview::DERIVED_STATE_CHANGED_SINCE_REVIEW])
->instance()
->getFilteredTableQuery()
?->pluck('tenants.name')
->all();
$notReviewedNames = $this->portfolioTriageRegistryList($user, $anchorTenant, [
'backup_posture' => [TenantBackupHealthAssessment::POSTURE_STALE],
])
->filterTable('review_state', [TenantTriageReview::DERIVED_STATE_NOT_REVIEWED])
->instance()
->getFilteredTableQuery()
?->pluck('tenants.name')
->all();
expect($reviewedNames)->toBe(['Reviewed Backup Tenant'])
->and($followUpNames)->toBe(['Follow-up Backup Tenant'])
->and($changedNames)->toBe(['Changed Backup Tenant'])
->and($notReviewedNames)->toBe(['Not Reviewed Backup Tenant']);
});
it('uses the highest-priority current concern family when the registry slice is mixed', function (): void {
[$user, $anchorTenant] = $this->makePortfolioTriageActor('Anchor Mixed Tenant');
$mixedTenant = $this->makePortfolioTriagePeer($user, $anchorTenant, 'Mixed Concern Tenant');
$backupSet = $this->seedPortfolioBackupConcern($mixedTenant, TenantBackupHealthAssessment::POSTURE_STALE);
$this->seedPortfolioRecoveryConcern($mixedTenant, 'failed', $backupSet);
$this->seedPortfolioTriageReview(
$mixedTenant,
PortfolioArrivalContextToken::FAMILY_RECOVERY_EVIDENCE,
TenantTriageReview::STATE_FOLLOW_UP_NEEDED,
$user,
);
$this->portfolioTriageRegistryList($user, $anchorTenant, [
'triage_sort' => TenantRecoveryTriagePresentation::TRIAGE_SORT_WORST_FIRST,
])
->assertTableColumnFormattedStateSet('review_state', 'Follow-up needed', $mixedTenant)
->assertSee('Recovery evidence');
});
it('keeps review-state mutations in overflow with a preview-confirmed write path', function (): void {
[$user, $anchorTenant] = $this->makePortfolioTriageActor('Anchor Action Tenant');
$actionTenant = $this->makePortfolioTriagePeer($user, $anchorTenant, 'Action Backup Tenant');
$this->seedPortfolioBackupConcern($actionTenant, TenantBackupHealthAssessment::POSTURE_STALE);
$component = $this->portfolioTriageRegistryList($user, $anchorTenant, [
'backup_posture' => [TenantBackupHealthAssessment::POSTURE_STALE],
])
->assertTableActionVisible('openTenant', $actionTenant)
->assertTableActionEnabled('markReviewed', $actionTenant)
->assertTableActionExists('markReviewed', fn (Action $action): bool => $action->isConfirmationRequired()
&& str_contains((string) $action->getModalDescription(), 'Concern family: Backup health')
&& str_contains((string) $action->getModalDescription(), 'Target state: Reviewed')
&& str_contains((string) $action->getModalDescription(), 'TenantPilot only'), $actionTenant)
->assertTableActionExists('markFollowUpNeeded', fn (Action $action): bool => $action->isConfirmationRequired(), $actionTenant);
$action = $component->instance()->getAction([
[
'name' => 'markReviewed',
'context' => [
'table' => true,
'recordKey' => (string) $actionTenant->getKey(),
],
],
]);
expect(app(CapabilityResolver::class)->can($user, $actionTenant, Capabilities::TENANT_TRIAGE_REVIEW_MANAGE))->toBeTrue();
expect($action)->not->toBeNull()
->and($action?->getRecord())->toBeInstanceOf(Tenant::class)
->and((int) $action?->getRecord()?->getKey())->toBe((int) $actionTenant->getKey())
->and($action?->isDisabled())->toBeFalse()
->and($component->instance()->mountedActionShouldOpenModal($action))->toBeTrue();
$component->mountAction([
[
'name' => 'markReviewed',
'context' => [
'table' => true,
'recordKey' => (string) $actionTenant->getKey(),
],
],
]);
$mountedAction = $component->instance()->getMountedAction();
expect($mountedAction)->not->toBeNull()
->and($mountedAction?->getRecord())->toBeInstanceOf(Tenant::class)
->and((int) $mountedAction?->getRecord()?->getKey())->toBe((int) $actionTenant->getKey());
$component
->callMountedAction();
expect(TenantTriageReview::query()
->where('tenant_id', (int) $actionTenant->getKey())
->where('concern_family', PortfolioArrivalContextToken::FAMILY_BACKUP_HEALTH)
->where('current_state', TenantTriageReview::STATE_REVIEWED)
->whereNull('resolved_at')
->exists())->toBeTrue()
->and($component->instance())->toBeInstanceOf(ListTenants::class);
});

View File

@ -15,6 +15,18 @@
expect($js)
->toContain('__tenantpilotUnhandledRejectionLoggerApplied')
->toContain("window.addEventListener('unhandledrejection'")
->toContain('isExpectedBackgroundTransportFailure')
->toContain("document.visibilityState !== 'visible'")
->toContain('document.hasFocus')
->toContain('event.preventDefault()')
->toContain('status === 419')
->toContain('Page Expired')
->toContain('status === 404')
->toContain('Not Found')
->toContain('const dedupeKey = toStableJson({')
->toContain('reason: payload.reason')
->toContain('TenantPilot unhandled promise rejection')
->toContain('JSON.stringify');
->toContain('JSON.stringify')
->not->toContain('recentKeys.has(payloadJson)')
->not->toContain('recentKeys.set(payloadJson, nowMs)');
});

View File

@ -0,0 +1,69 @@
<?php
declare(strict_types=1);
use App\Models\Tenant;
use App\Models\TenantTriageReview;
use App\Support\BackupHealth\TenantBackupHealthAssessment;
use App\Support\PortfolioTriage\PortfolioArrivalContextToken;
use App\Support\Workspaces\WorkspaceOverviewBuilder;
use Carbon\CarbonImmutable;
use Tests\Feature\Concerns\BuildsPortfolioTriageFixtures;
uses(BuildsPortfolioTriageFixtures::class);
afterEach(function (): void {
CarbonImmutable::setTestNow();
});
it('counts triage review progress only for the current visible affected set', function (): void {
CarbonImmutable::setTestNow(CarbonImmutable::create(2026, 4, 10, 8, 0, 0, 'UTC'));
[$user, $anchorTenant] = $this->makePortfolioTriageActor('Overview Anchor Tenant');
$anchorBackup = $this->seedPortfolioBackupConcern($anchorTenant, TenantBackupHealthAssessment::POSTURE_HEALTHY);
workspaceOverviewSeedRestoreHistory($anchorTenant, $anchorBackup, 'completed');
$reviewedTenant = $this->makePortfolioTriagePeer($user, $anchorTenant, 'Overview Reviewed Tenant');
$followUpTenant = $this->makePortfolioTriagePeer($user, $anchorTenant, 'Overview Follow-up Tenant');
$changedTenant = $this->makePortfolioTriagePeer($user, $anchorTenant, 'Overview Changed Tenant');
$calmTenant = $this->makePortfolioTriagePeer($user, $anchorTenant, 'Overview Calm Tenant');
foreach ([$reviewedTenant, $followUpTenant, $changedTenant] as $tenant) {
$backupSet = $this->seedPortfolioBackupConcern($tenant, TenantBackupHealthAssessment::POSTURE_STALE);
workspaceOverviewSeedRestoreHistory($tenant, $backupSet, 'completed');
}
$calmBackup = $this->seedPortfolioBackupConcern($calmTenant, TenantBackupHealthAssessment::POSTURE_HEALTHY);
workspaceOverviewSeedRestoreHistory($calmTenant, $calmBackup, 'completed');
$this->seedPortfolioTriageReview($reviewedTenant, PortfolioArrivalContextToken::FAMILY_BACKUP_HEALTH, TenantTriageReview::STATE_REVIEWED, $user);
$this->seedPortfolioTriageReview($followUpTenant, PortfolioArrivalContextToken::FAMILY_BACKUP_HEALTH, TenantTriageReview::STATE_FOLLOW_UP_NEEDED, $user);
$this->seedPortfolioTriageReview($changedTenant, PortfolioArrivalContextToken::FAMILY_BACKUP_HEALTH, TenantTriageReview::STATE_REVIEWED, $user, changedFingerprint: true);
$workspace = $anchorTenant->workspace()->firstOrFail();
$overview = app(WorkspaceOverviewBuilder::class)->build($workspace, $user);
$backupProgress = collect($overview['triage_review_progress'])
->firstWhere('concern_family', PortfolioArrivalContextToken::FAMILY_BACKUP_HEALTH);
$backupMetric = collect($overview['summary_metrics'])->firstWhere('key', 'backup_attention_tenants');
expect($backupProgress['affected_total'])->toBe(3)
->and($backupProgress['reviewed_count'])->toBe(1)
->and($backupProgress['follow_up_needed_count'])->toBe(1)
->and($backupProgress['changed_since_review_count'])->toBe(1)
->and($backupProgress['not_reviewed_count'])->toBe(0)
->and($backupMetric['description'])->toContain('Reviewed 1/3.')
->and($backupProgress['not_reviewed_destination']['url'])->toContain('review_state%5B0%5D=not_reviewed');
});
it('omits triage review progress when the visible workspace slice is calm', function (): void {
[$user, $tenant] = $this->makePortfolioTriageActor('Calm Overview Tenant');
$backupSet = $this->seedPortfolioBackupConcern($tenant, TenantBackupHealthAssessment::POSTURE_HEALTHY);
workspaceOverviewSeedRestoreHistory($tenant, $backupSet, 'completed');
$workspace = $tenant->workspace()->firstOrFail();
$overview = app(WorkspaceOverviewBuilder::class)->build($workspace, $user);
expect($overview['triage_review_progress'])->toBe([]);
});

View File

@ -78,6 +78,8 @@
use App\Models\BackupSchedule;
use App\Models\BackupSet;
use App\Models\BaselineProfile;
use App\Models\BaselineSnapshot;
use App\Models\BaselineTenantAssignment;
use App\Models\EvidenceSnapshot;
use App\Models\Finding;
use App\Models\FindingException;
@ -207,6 +209,54 @@ function actionSurfaceSystemPanelContext(array $capabilities): PlatformUser
}
});
it('discovers the baseline compare matrix page as an admin-scoped drilldown-only surface', function (): void {
$components = collect(ActionSurfaceValidator::withBaselineExemptions()->discoveredComponents())
->keyBy('className');
$matrixPage = $components->get(\App\Filament\Pages\BaselineCompareMatrix::class);
expect($matrixPage)->not->toBeNull('BaselineCompareMatrix should be discovered by action surface discovery')
->and($matrixPage?->hasPanelScope(ActionSurfacePanelScope::Admin))->toBeTrue();
$declaration = \App\Filament\Pages\BaselineCompareMatrix::actionSurfaceDeclaration();
expect((string) ($declaration->slot(ActionSurfaceSlot::ListHeader)?->details ?? ''))
->toContain('compare fan-out')
->and((string) ($declaration->exemption(ActionSurfaceSlot::InspectAffordance)?->reason ?? ''))
->toContain('forbids row click');
});
it('keeps compare-assigned-tenants visibly disabled with helper text on the matrix for readonly members', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'readonly');
$profile = BaselineProfile::factory()->active()->create([
'workspace_id' => (int) $tenant->workspace_id,
]);
$snapshot = BaselineSnapshot::factory()->complete()->create([
'workspace_id' => (int) $tenant->workspace_id,
'baseline_profile_id' => (int) $profile->getKey(),
]);
$profile->update(['active_snapshot_id' => (int) $snapshot->getKey()]);
BaselineTenantAssignment::factory()->create([
'workspace_id' => (int) $tenant->workspace_id,
'tenant_id' => (int) $tenant->getKey(),
'baseline_profile_id' => (int) $profile->getKey(),
]);
session()->put(WorkspaceContext::SESSION_KEY, (int) $tenant->workspace_id);
Filament::setTenant(null, true);
Livewire::actingAs($user)
->test(\App\Filament\Pages\BaselineCompareMatrix::class, ['record' => $profile->getKey()])
->assertActionVisible('backToBaselineProfile')
->assertActionVisible('compareAssignedTenants')
->assertActionDisabled('compareAssignedTenants')
->assertActionExists('compareAssignedTenants', fn (Action $action): bool => $action->getTooltip() === 'You need workspace baseline manage access to compare the visible assigned set.');
});
it('keeps BaselineProfile archive under the More menu and declares it in the action surface slots', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'owner');

View File

@ -1,5 +1,7 @@
<?php
use App\Support\Badges\BadgeCatalog;
use App\Support\Badges\BadgeDomain;
use Illuminate\Support\Collection;
it('does not contain ad-hoc status-like badge semantics', function () {
@ -113,3 +115,9 @@
expect($hits)->toBeEmpty("Ad-hoc status-like badge semantics found:\n".implode("\n", $hits));
});
it('keeps baseline compare matrix state and freshness surfaces on centralized badge domains', function (): void {
expect(BadgeCatalog::spec(BadgeDomain::BaselineCompareMatrixState, 'differ')->label)->toBe('Drift detected')
->and(BadgeCatalog::spec(BadgeDomain::BaselineCompareMatrixState, 'stale_result')->label)->toBe('Result stale')
->and(BadgeCatalog::spec(BadgeDomain::BaselineCompareMatrixFreshness, 'stale')->label)->toBe('Refresh recommended');
});

View File

@ -2,6 +2,8 @@
declare(strict_types=1);
use App\Support\Badges\BadgeCatalog;
use App\Support\Badges\BadgeDomain;
use App\Support\Badges\OperatorOutcomeTaxonomy;
use App\Support\Badges\OperatorStateClassification;
@ -41,3 +43,15 @@
expect($violations)->toBeEmpty("Overloaded bare operator labels remain in the first-slice taxonomy:\n".implode("\n", $violations));
});
it('keeps baseline compare matrix trust aligned with the adopted trust taxonomy and qualified labels', function (): void {
$matrixTrust = BadgeCatalog::spec(BadgeDomain::BaselineCompareMatrixTrust, 'diagnostic_only');
$operatorTrust = BadgeCatalog::spec(BadgeDomain::OperatorExplanationTrustworthiness, 'diagnostic_only');
$matrixMissing = BadgeCatalog::spec(BadgeDomain::BaselineCompareMatrixState, 'missing');
$matrixStale = BadgeCatalog::spec(BadgeDomain::BaselineCompareMatrixState, 'stale_result');
expect($matrixTrust->label)->toBe($operatorTrust->label)
->and($matrixTrust->color)->toBe($operatorTrust->color)
->and($matrixMissing->label)->toBe('Missing from tenant')
->and($matrixStale->label)->toBe('Result stale');
});

View File

@ -0,0 +1,124 @@
<?php
declare(strict_types=1);
use App\Filament\Pages\BaselineCompareLanding;
use App\Filament\Resources\BaselineProfileResource;
use App\Filament\Resources\FindingResource;
use App\Models\User;
use App\Services\Auth\CapabilityResolver;
use App\Services\Auth\WorkspaceCapabilityResolver;
use App\Support\Auth\Capabilities;
use App\Support\Navigation\CanonicalNavigationContext;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Gate;
use Tests\Feature\Concerns\BuildsBaselineCompareMatrixFixtures;
uses(RefreshDatabase::class, BuildsBaselineCompareMatrixFixtures::class);
it('returns 404 for non-members on the workspace baseline compare matrix', function (): void {
$fixture = $this->makeBaselineCompareMatrixFixture();
$nonMember = User::factory()->create();
$this->actingAs($nonMember)->withSession([
\App\Support\Workspaces\WorkspaceContext::SESSION_KEY => (int) $fixture['workspace']->getKey(),
]);
$this->get(BaselineProfileResource::compareMatrixUrl($fixture['profile']))
->assertNotFound();
});
it('returns 403 for workspace members missing baseline view capability on the matrix route', function (): void {
$fixture = $this->makeBaselineCompareMatrixFixture();
$viewer = User::factory()->create();
\App\Models\WorkspaceMembership::factory()->create([
'workspace_id' => (int) $fixture['workspace']->getKey(),
'user_id' => (int) $viewer->getKey(),
'role' => 'readonly',
]);
$resolver = \Mockery::mock(WorkspaceCapabilityResolver::class);
$resolver->shouldReceive('isMember')->andReturnTrue();
$resolver->shouldReceive('can')->andReturnFalse();
app()->instance(WorkspaceCapabilityResolver::class, $resolver);
$this->actingAs($viewer)->withSession([
\App\Support\Workspaces\WorkspaceContext::SESSION_KEY => (int) $fixture['workspace']->getKey(),
]);
$this->get(BaselineProfileResource::compareMatrixUrl($fixture['profile']))
->assertForbidden();
});
it('returns 404 for matrix tenant drilldowns when the actor is not a tenant member', function (): void {
$fixture = $this->makeBaselineCompareMatrixFixture();
$nonMember = User::factory()->create();
$this->actingAs($nonMember)->withSession([
\App\Support\Workspaces\WorkspaceContext::SESSION_KEY => (int) $fixture['workspace']->getKey(),
]);
$query = CanonicalNavigationContext::forBaselineCompareMatrix(
$fixture['profile'],
subjectKey: 'wifi-corp-profile',
)->toQuery();
$this->get(BaselineCompareLanding::getUrl(parameters: $query, panel: 'tenant', tenant: $fixture['visibleTenant']))
->assertNotFound();
});
it('returns 403 for matrix tenant drilldowns when tenant view capability is missing', function (): void {
$fixture = $this->makeBaselineCompareMatrixFixture();
$resolver = \Mockery::mock(CapabilityResolver::class);
$resolver->shouldReceive('isMember')->andReturnTrue();
$resolver->shouldReceive('can')->andReturnFalse();
app()->instance(CapabilityResolver::class, $resolver);
$this->actingAs($fixture['user']);
$fixture['visibleTenant']->makeCurrent();
$query = CanonicalNavigationContext::forBaselineCompareMatrix(
$fixture['profile'],
subjectKey: 'wifi-corp-profile',
tenant: $fixture['visibleTenant'],
)->toQuery();
$this->get(BaselineCompareLanding::getUrl(parameters: $query, panel: 'tenant', tenant: $fixture['visibleTenant']))
->assertForbidden();
});
it('returns 403 for matrix finding drilldowns when findings view capability is missing', function (): void {
$fixture = $this->makeBaselineCompareMatrixFixture();
$run = $this->makeBaselineCompareMatrixRun(
$fixture['visibleTenant'],
$fixture['profile'],
$fixture['snapshot'],
);
$finding = $this->makeBaselineCompareMatrixFinding(
$fixture['visibleTenant'],
$fixture['profile'],
$run,
'wifi-corp-profile',
);
$this->actingAs($fixture['user']);
$fixture['visibleTenant']->makeCurrent();
Gate::define(Capabilities::TENANT_FINDINGS_VIEW, fn (): bool => false);
$query = CanonicalNavigationContext::forBaselineCompareMatrix(
$fixture['profile'],
subjectKey: 'wifi-corp-profile',
tenant: $fixture['visibleTenant'],
)->toQuery();
$this->get(FindingResource::getUrl('view', [
'record' => $finding,
...$query,
], tenant: $fixture['visibleTenant']))
->assertForbidden();
});

View File

@ -0,0 +1,140 @@
<?php
declare(strict_types=1);
use App\Filament\Pages\TenantDashboard;
use App\Filament\Widgets\Tenant\TenantTriageArrivalContinuity;
use App\Models\AuditLog;
use App\Models\Tenant;
use App\Models\TenantTriageReview;
use App\Models\User;
use App\Support\Audit\AuditActionId;
use App\Support\BackupHealth\TenantBackupHealthAssessment;
use App\Support\PortfolioTriage\PortfolioArrivalContextToken;
use App\Support\Workspaces\WorkspaceContext;
use Filament\Facades\Filament;
use Filament\Actions\Action;
use Livewire\Livewire;
use Symfony\Component\HttpKernel\Exception\HttpException;
use Tests\Feature\Concerns\BuildsPortfolioTriageFixtures;
uses(BuildsPortfolioTriageFixtures::class);
function triageReviewArrivalState(Tenant $tenant): array
{
return [
'sourceSurface' => PortfolioArrivalContextToken::SOURCE_TENANT_REGISTRY,
'tenantRouteKey' => (string) $tenant->external_id,
'workspaceId' => (int) $tenant->workspace_id,
'concernFamily' => PortfolioArrivalContextToken::FAMILY_BACKUP_HEALTH,
'concernState' => TenantBackupHealthAssessment::POSTURE_STALE,
'concernReason' => TenantBackupHealthAssessment::REASON_LATEST_BACKUP_STALE,
'returnFilters' => [
'backup_posture' => [TenantBackupHealthAssessment::POSTURE_STALE],
],
];
}
function triageReviewDashboardWidget(User $user, Tenant $tenant, array $state): mixed
{
test()->actingAs($user);
session()->put(WorkspaceContext::SESSION_KEY, (int) $tenant->workspace_id);
setTenantPanelContext($tenant);
request()->attributes->remove('portfolio_triage.arrival_context');
return Livewire::withQueryParams([
PortfolioArrivalContextToken::QUERY_PARAMETER => PortfolioArrivalContextToken::encode($state),
])->actingAs($user)->test(TenantTriageArrivalContinuity::class);
}
it('returns 404 for non-members on the tenant dashboard triage route', function (): void {
$tenant = Tenant::factory()->create(['status' => 'active']);
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner');
$this->seedPortfolioBackupConcern($tenant, TenantBackupHealthAssessment::POSTURE_STALE);
$foreignTenant = Tenant::factory()->create([
'status' => 'active',
'workspace_id' => (int) $tenant->workspace_id,
]);
$this->seedPortfolioBackupConcern($foreignTenant, TenantBackupHealthAssessment::POSTURE_STALE);
$this->actingAs($user)
->withSession([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id])
->get(TenantDashboard::getUrl([
PortfolioArrivalContextToken::QUERY_PARAMETER => PortfolioArrivalContextToken::encode(triageReviewArrivalState($foreignTenant)),
], panel: 'tenant', tenant: $foreignTenant))
->assertNotFound();
});
it('shows review actions as disabled for readonly members and still rejects a bypassed mutation with 403', function (): void {
$tenant = Tenant::factory()->create(['status' => 'active']);
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'readonly');
$this->seedPortfolioBackupConcern($tenant, TenantBackupHealthAssessment::POSTURE_STALE);
$component = triageReviewDashboardWidget($user, $tenant, triageReviewArrivalState($tenant))
->assertActionVisible('markReviewed')
->assertActionDisabled('markReviewed');
$instance = $component->instance();
$componentReflection = new ReflectionObject($instance);
$cachedActionsProperty = $componentReflection->getProperty('cachedActions');
$cachedActionsProperty->setAccessible(true);
$cachedActions = $cachedActionsProperty->getValue($instance);
$action = $cachedActions['markReviewed'] ?? null;
expect($action)->not->toBeNull();
$actionReflection = new ReflectionObject($action);
$disabledProperty = $actionReflection->getProperty('isDisabled');
$disabledProperty->setAccessible(true);
$disabledProperty->setValue($action, false);
$cachedActionsProperty->setValue($instance, $cachedActions);
$instance->mountAction('markReviewed');
$mountedAction = $instance->getMountedAction();
expect($mountedAction)->not->toBeNull();
$mountedReflection = new ReflectionObject($mountedAction);
$mountedDisabledProperty = $mountedReflection->getProperty('isDisabled');
$mountedDisabledProperty->setAccessible(true);
$mountedDisabledProperty->setValue($mountedAction, false);
try {
$instance->callMountedAction();
$this->fail('Expected a 403 when bypassing the disabled action.');
} catch (HttpException $exception) {
expect($exception->getStatusCode())->toBe(403);
}
expect(TenantTriageReview::query()->count())->toBe(0);
});
it('writes review progress and audit state only after the preview-confirmed action executes', function (): void {
[$user, $tenant] = $this->makePortfolioTriageActor('Authorization Success Tenant');
$this->seedPortfolioBackupConcern($tenant, TenantBackupHealthAssessment::POSTURE_STALE);
$component = triageReviewDashboardWidget($user, $tenant, triageReviewArrivalState($tenant))
->assertActionExists('markFollowUpNeeded', fn (Action $action): bool => $action->isConfirmationRequired()
&& str_contains((string) $action->getModalDescription(), 'Target state: Follow-up needed')
&& str_contains((string) $action->getModalDescription(), 'TenantPilot only'))
->mountAction('markFollowUpNeeded');
expect(TenantTriageReview::query()->count())->toBe(0);
$component
->callMountedAction();
expect(TenantTriageReview::query()
->where('tenant_id', (int) $tenant->getKey())
->where('current_state', TenantTriageReview::STATE_FOLLOW_UP_NEEDED)
->whereNull('resolved_at')
->exists())->toBeTrue()
->and(AuditLog::query()
->where('workspace_id', (int) $tenant->workspace_id)
->where('tenant_id', (int) $tenant->getKey())
->where('action', AuditActionId::TenantTriageReviewMarkedFollowUpNeeded->value)
->exists())->toBeTrue();
Filament::setTenant(null, true);
});

View File

@ -20,6 +20,8 @@
use App\Models\WorkspaceMembership;
use App\Services\Evidence\EvidenceSnapshotService;
use App\Services\Graph\GraphClientInterface;
use App\Services\Auth\CapabilityResolver;
use App\Services\Auth\WorkspaceCapabilityResolver;
use App\Services\TenantReviews\TenantReviewService;
use App\Services\Tenants\TenantActionPolicySurface;
use App\Support\Evidence\EvidenceCompletenessState;
@ -384,6 +386,9 @@ function createUserWithTenant(
$tenant->getKey() => ['role' => $role],
]);
app(CapabilityResolver::class)->clearCache();
app(WorkspaceCapabilityResolver::class)->clearCache();
if ($ensureDefaultMicrosoftProviderConnection) {
ensureDefaultProviderConnection($tenant, 'microsoft', $defaultProviderConnectionType);
}

View File

@ -0,0 +1,33 @@
<?php
declare(strict_types=1);
use App\Support\Badges\BadgeCatalog;
use App\Support\Badges\BadgeDomain;
it('maps baseline compare matrix state badges through centralized semantics', function (): void {
expect(BadgeCatalog::spec(BadgeDomain::BaselineCompareMatrixState, 'match')->label)->toBe('Reference aligned')
->and(BadgeCatalog::spec(BadgeDomain::BaselineCompareMatrixState, 'differ')->label)->toBe('Drift detected')
->and(BadgeCatalog::spec(BadgeDomain::BaselineCompareMatrixState, 'missing')->label)->toBe('Missing from tenant')
->and(BadgeCatalog::spec(BadgeDomain::BaselineCompareMatrixState, 'ambiguous')->label)->toBe('Identity ambiguous')
->and(BadgeCatalog::spec(BadgeDomain::BaselineCompareMatrixState, 'not_compared')->label)->toBe('Not compared')
->and(BadgeCatalog::spec(BadgeDomain::BaselineCompareMatrixState, 'stale_result')->label)->toBe('Result stale');
});
it('maps baseline compare matrix freshness badges through centralized semantics', function (): void {
expect(BadgeCatalog::spec(BadgeDomain::BaselineCompareMatrixFreshness, 'fresh')->label)->toBe('Current result')
->and(BadgeCatalog::spec(BadgeDomain::BaselineCompareMatrixFreshness, 'stale')->label)->toBe('Refresh recommended')
->and(BadgeCatalog::spec(BadgeDomain::BaselineCompareMatrixFreshness, 'never_compared')->label)->toBe('Not compared yet')
->and(BadgeCatalog::spec(BadgeDomain::BaselineCompareMatrixFreshness, 'unknown')->label)->toBe('Freshness unknown');
});
it('reuses operator trustworthiness semantics for matrix trust badges', function (): void {
foreach (['trustworthy', 'limited_confidence', 'diagnostic_only', 'unusable'] as $state) {
$matrixSpec = BadgeCatalog::spec(BadgeDomain::BaselineCompareMatrixTrust, $state);
$operatorSpec = BadgeCatalog::spec(BadgeDomain::OperatorExplanationTrustworthiness, $state);
expect($matrixSpec->label)->toBe($operatorSpec->label)
->and($matrixSpec->color)->toBe($operatorSpec->color)
->and($matrixSpec->icon)->toBe($operatorSpec->icon);
}
});

View File

@ -0,0 +1,30 @@
<?php
declare(strict_types=1);
use App\Models\TenantTriageReview;
use App\Support\Badges\BadgeCatalog;
use App\Support\Badges\BadgeDomain;
it('maps triage review states to centralized badge semantics', function (): void {
$notReviewed = BadgeCatalog::spec(BadgeDomain::TenantTriageReviewState, TenantTriageReview::DERIVED_STATE_NOT_REVIEWED);
$reviewed = BadgeCatalog::spec(BadgeDomain::TenantTriageReviewState, TenantTriageReview::STATE_REVIEWED);
$followUpNeeded = BadgeCatalog::spec(BadgeDomain::TenantTriageReviewState, TenantTriageReview::STATE_FOLLOW_UP_NEEDED);
$changedSinceReview = BadgeCatalog::spec(BadgeDomain::TenantTriageReviewState, TenantTriageReview::DERIVED_STATE_CHANGED_SINCE_REVIEW);
expect($notReviewed->label)->toBe('Not reviewed')
->and($notReviewed->color)->toBe('gray')
->and($reviewed->label)->toBe('Reviewed')
->and($reviewed->color)->toBe('success')
->and($followUpNeeded->label)->toBe('Follow-up needed')
->and($followUpNeeded->color)->toBe('danger')
->and($changedSinceReview->label)->toBe('Changed since review')
->and($changedSinceReview->color)->toBe('warning');
});
it('falls back to the unknown badge semantics for invalid review states', function (): void {
$unknown = BadgeCatalog::spec(BadgeDomain::TenantTriageReviewState, 'invalid_state');
expect($unknown->label)->toBe('Unknown')
->and($unknown->color)->toBe('gray');
});

View File

@ -0,0 +1,141 @@
<?php
declare(strict_types=1);
use App\Support\BackupHealth\BackupFreshnessEvaluation;
use App\Support\BackupHealth\BackupScheduleFollowUpEvaluation;
use App\Support\BackupHealth\TenantBackupHealthAssessment;
use App\Support\PortfolioTriage\PortfolioArrivalContextToken;
use App\Support\PortfolioTriage\TenantTriageReviewFingerprint;
use Carbon\CarbonImmutable;
afterEach(function (): void {
CarbonImmutable::setTestNow();
});
function triageFingerprintBackupAssessment(
int $tenantId,
string $posture,
?string $reason = null,
string $headline = 'Headline',
string $supportingMessage = 'Supporting message',
): TenantBackupHealthAssessment {
return new TenantBackupHealthAssessment(
tenantId: $tenantId,
posture: $posture,
primaryReason: $reason,
headline: $headline,
supportingMessage: $supportingMessage,
latestRelevantBackupSetId: null,
latestRelevantCompletedAt: now()->subMinutes(15),
qualitySummary: null,
freshnessEvaluation: new BackupFreshnessEvaluation(
latestCompletedAt: now()->subMinutes(15),
cutoffAt: now()->subHour(),
isFresh: $posture === TenantBackupHealthAssessment::POSTURE_HEALTHY,
),
scheduleFollowUp: new BackupScheduleFollowUpEvaluation(
hasEnabledSchedules: false,
enabledScheduleCount: 0,
overdueScheduleCount: 0,
failedRecentRunCount: 0,
neverSuccessfulCount: 0,
needsFollowUp: false,
primaryScheduleId: null,
summaryMessage: null,
),
healthyClaimAllowed: $posture === TenantBackupHealthAssessment::POSTURE_HEALTHY,
primaryActionTarget: null,
positiveClaimBoundary: 'Boundary',
);
}
it('keeps backup fingerprints deterministic across volatile copy changes', function (): void {
CarbonImmutable::setTestNow(CarbonImmutable::create(2026, 4, 10, 8, 0, 0, 'UTC'));
$fingerprints = app(TenantTriageReviewFingerprint::class);
$first = $fingerprints->forBackupHealth(triageFingerprintBackupAssessment(
tenantId: 1,
posture: TenantBackupHealthAssessment::POSTURE_STALE,
reason: TenantBackupHealthAssessment::REASON_LATEST_BACKUP_STALE,
headline: 'Original stale backup',
supportingMessage: 'Original wording',
));
CarbonImmutable::setTestNow(CarbonImmutable::create(2026, 4, 10, 9, 0, 0, 'UTC'));
$second = $fingerprints->forBackupHealth(triageFingerprintBackupAssessment(
tenantId: 1,
posture: TenantBackupHealthAssessment::POSTURE_STALE,
reason: TenantBackupHealthAssessment::REASON_LATEST_BACKUP_STALE,
headline: 'Updated stale backup wording',
supportingMessage: 'Updated wording',
));
expect($first)->not->toBeNull()
->and($second)->not->toBeNull()
->and($second['fingerprint'])->toBe($first['fingerprint'])
->and($second['snapshot'])->toBe($first['snapshot']);
});
it('separates concern families and changes fingerprints when the material concern changes', function (): void {
$fingerprints = app(TenantTriageReviewFingerprint::class);
$backup = $fingerprints->forConcernFamily(
PortfolioArrivalContextToken::FAMILY_BACKUP_HEALTH,
triageFingerprintBackupAssessment(
tenantId: 1,
posture: TenantBackupHealthAssessment::POSTURE_DEGRADED,
reason: TenantBackupHealthAssessment::REASON_LATEST_BACKUP_DEGRADED,
),
null,
);
$recovery = $fingerprints->forConcernFamily(
PortfolioArrivalContextToken::FAMILY_RECOVERY_EVIDENCE,
null,
[
'overview_state' => 'weakened',
'reason' => 'failed',
'latest_relevant_attention_state' => 'failed',
'summary' => 'Summary A',
'claim_boundary' => 'Boundary A',
],
);
$changedRecovery = $fingerprints->forConcernFamily(
PortfolioArrivalContextToken::FAMILY_RECOVERY_EVIDENCE,
null,
[
'overview_state' => 'weakened',
'reason' => 'partial',
'latest_relevant_attention_state' => 'partial',
'summary' => 'Summary B',
'claim_boundary' => 'Boundary B',
],
);
expect($backup)->not->toBeNull()
->and($recovery)->not->toBeNull()
->and($changedRecovery)->not->toBeNull()
->and($backup['concern_family'])->toBe(PortfolioArrivalContextToken::FAMILY_BACKUP_HEALTH)
->and($recovery['concern_family'])->toBe(PortfolioArrivalContextToken::FAMILY_RECOVERY_EVIDENCE)
->and($backup['fingerprint'])->not->toBe($recovery['fingerprint'])
->and($recovery['fingerprint'])->not->toBe($changedRecovery['fingerprint']);
});
it('only emits fingerprints for current affected-set concerns', function (): void {
$fingerprints = app(TenantTriageReviewFingerprint::class);
expect($fingerprints->forBackupHealth(
triageFingerprintBackupAssessment(
tenantId: 1,
posture: TenantBackupHealthAssessment::POSTURE_HEALTHY,
),
))->toBeNull()
->and($fingerprints->forRecoveryEvidence([
'overview_state' => 'no_recent_issues_visible',
'reason' => 'no_recent_issues_visible',
]))->toBeNull();
});

View File

@ -0,0 +1,179 @@
<?php
declare(strict_types=1);
use App\Models\Tenant;
use App\Models\TenantTriageReview;
use App\Models\User;
use App\Support\BackupHealth\BackupFreshnessEvaluation;
use App\Support\BackupHealth\BackupScheduleFollowUpEvaluation;
use App\Support\BackupHealth\TenantBackupHealthAssessment;
use App\Support\PortfolioTriage\PortfolioArrivalContextToken;
use App\Support\PortfolioTriage\TenantTriageReviewStateResolver;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
function triageResolverBackupAssessment(int $tenantId, string $posture, ?string $reason = null): TenantBackupHealthAssessment
{
return new TenantBackupHealthAssessment(
tenantId: $tenantId,
posture: $posture,
primaryReason: $reason,
headline: 'Headline',
supportingMessage: 'Supporting message',
latestRelevantBackupSetId: null,
latestRelevantCompletedAt: now()->subMinutes(10),
qualitySummary: null,
freshnessEvaluation: new BackupFreshnessEvaluation(
latestCompletedAt: now()->subMinutes(10),
cutoffAt: now()->subHour(),
isFresh: $posture === TenantBackupHealthAssessment::POSTURE_HEALTHY,
),
scheduleFollowUp: new BackupScheduleFollowUpEvaluation(
hasEnabledSchedules: false,
enabledScheduleCount: 0,
overdueScheduleCount: 0,
failedRecentRunCount: 0,
neverSuccessfulCount: 0,
needsFollowUp: false,
primaryScheduleId: null,
summaryMessage: null,
),
healthyClaimAllowed: $posture === TenantBackupHealthAssessment::POSTURE_HEALTHY,
primaryActionTarget: null,
positiveClaimBoundary: 'Boundary',
);
}
it('resolves active review rows into current-set summaries', function (): void {
$firstTenant = Tenant::factory()->create(['status' => 'active']);
[$reviewer, $firstTenant] = createUserWithTenant(tenant: $firstTenant, role: 'owner');
$secondTenant = Tenant::factory()->create([
'status' => 'active',
'workspace_id' => (int) $firstTenant->workspace_id,
]);
createUserWithTenant(tenant: $secondTenant, user: $reviewer, role: 'owner');
$thirdTenant = Tenant::factory()->create([
'status' => 'active',
'workspace_id' => (int) $firstTenant->workspace_id,
]);
createUserWithTenant(tenant: $thirdTenant, user: $reviewer, role: 'owner');
TenantTriageReview::factory()
->for($firstTenant)
->for($reviewer, 'reviewer')
->reviewed()
->backupHealth()
->create([
'workspace_id' => (int) $firstTenant->workspace_id,
]);
TenantTriageReview::factory()
->for($secondTenant)
->for($reviewer, 'reviewer')
->followUpNeeded()
->backupHealth()
->create([
'workspace_id' => (int) $firstTenant->workspace_id,
]);
$resolved = app(TenantTriageReviewStateResolver::class)->resolveMany(
workspaceId: (int) $firstTenant->workspace_id,
tenantIds: [(int) $firstTenant->getKey(), (int) $secondTenant->getKey(), (int) $thirdTenant->getKey()],
backupHealthByTenant: [
(int) $firstTenant->getKey() => triageResolverBackupAssessment(
tenantId: (int) $firstTenant->getKey(),
posture: TenantBackupHealthAssessment::POSTURE_STALE,
reason: TenantBackupHealthAssessment::REASON_LATEST_BACKUP_STALE,
),
(int) $secondTenant->getKey() => triageResolverBackupAssessment(
tenantId: (int) $secondTenant->getKey(),
posture: TenantBackupHealthAssessment::POSTURE_STALE,
reason: TenantBackupHealthAssessment::REASON_LATEST_BACKUP_STALE,
),
(int) $thirdTenant->getKey() => triageResolverBackupAssessment(
tenantId: (int) $thirdTenant->getKey(),
posture: TenantBackupHealthAssessment::POSTURE_STALE,
reason: TenantBackupHealthAssessment::REASON_LATEST_BACKUP_STALE,
),
],
);
$summary = $resolved['summaries'][PortfolioArrivalContextToken::FAMILY_BACKUP_HEALTH];
expect($resolved['rows'][(int) $firstTenant->getKey()][PortfolioArrivalContextToken::FAMILY_BACKUP_HEALTH]['derived_state'])
->toBe(TenantTriageReview::STATE_REVIEWED)
->and($resolved['rows'][(int) $secondTenant->getKey()][PortfolioArrivalContextToken::FAMILY_BACKUP_HEALTH]['derived_state'])
->toBe(TenantTriageReview::STATE_FOLLOW_UP_NEEDED)
->and($resolved['rows'][(int) $thirdTenant->getKey()][PortfolioArrivalContextToken::FAMILY_BACKUP_HEALTH]['derived_state'])
->toBe(TenantTriageReview::DERIVED_STATE_NOT_REVIEWED)
->and($summary['affected_total'])->toBe(3)
->and($summary['reviewed_count'])->toBe(1)
->and($summary['follow_up_needed_count'])->toBe(1)
->and($summary['changed_since_review_count'])->toBe(0)
->and($summary['not_reviewed_count'])->toBe(1);
});
it('prefers changed-since-review when the current fingerprint no longer matches', function (): void {
$tenant = Tenant::factory()->create(['status' => 'active']);
[$reviewer, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner');
TenantTriageReview::factory()
->for($tenant)
->for($reviewer, 'reviewer')
->reviewed()
->backupHealth()
->changedFingerprint()
->create([
'workspace_id' => (int) $tenant->workspace_id,
]);
$resolved = app(TenantTriageReviewStateResolver::class)->resolveMany(
workspaceId: (int) $tenant->workspace_id,
tenantIds: [(int) $tenant->getKey()],
backupHealthByTenant: [
(int) $tenant->getKey() => triageResolverBackupAssessment(
tenantId: (int) $tenant->getKey(),
posture: TenantBackupHealthAssessment::POSTURE_STALE,
reason: TenantBackupHealthAssessment::REASON_LATEST_BACKUP_STALE,
),
],
);
$row = $resolved['rows'][(int) $tenant->getKey()][PortfolioArrivalContextToken::FAMILY_BACKUP_HEALTH];
expect($row['derived_state'])->toBe(TenantTriageReview::DERIVED_STATE_CHANGED_SINCE_REVIEW)
->and($resolved['summaries'][PortfolioArrivalContextToken::FAMILY_BACKUP_HEALTH]['changed_since_review_count'])->toBe(1);
});
it('excludes inactive concern families from the current affected set', function (): void {
$tenant = Tenant::factory()->create(['status' => 'active']);
[$reviewer, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner');
TenantTriageReview::factory()
->for($tenant)
->for($reviewer, 'reviewer')
->reviewed()
->backupHealth()
->create([
'workspace_id' => (int) $tenant->workspace_id,
]);
$resolved = app(TenantTriageReviewStateResolver::class)->resolveMany(
workspaceId: (int) $tenant->workspace_id,
tenantIds: [(int) $tenant->getKey()],
backupHealthByTenant: [
(int) $tenant->getKey() => triageResolverBackupAssessment(
tenantId: (int) $tenant->getKey(),
posture: TenantBackupHealthAssessment::POSTURE_HEALTHY,
),
],
);
expect($resolved['rows'][(int) $tenant->getKey()][PortfolioArrivalContextToken::FAMILY_BACKUP_HEALTH]['current_concern_present'])
->toBeFalse()
->and($resolved['summaries'][PortfolioArrivalContextToken::FAMILY_BACKUP_HEALTH]['affected_total'])->toBe(0);
});

View File

@ -0,0 +1,36 @@
# Specification Quality Checklist: Portfolio Triage Review State and Operator Progress
**Purpose**: Validate specification completeness and quality before proceeding to planning
**Created**: 2026-04-10
**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
## Notes
- Validation pass completed on 2026-04-10.
- Route identifiers, stored-state semantics, and operator surface names are included because the project template and constitution require explicit scope, action-surface, and persistence-truth definitions for operator-facing changes.
- The spec intentionally adds one lightweight persisted triage-review truth while rejecting formal review packs, notes, assignments, SLAs, and broader workflow-engine behavior.

View File

@ -0,0 +1,436 @@
openapi: 3.1.0
info:
title: Portfolio Triage Review State Internal Surface Contract
version: 0.1.0
summary: Internal logical contract for persisted triage-review state, current-set progress, and operator mutations
description: |
This contract is an internal planning artifact for Spec 189. The affected routes still
render HTML through Filament and Livewire. The schemas below describe the bounded read
models and mutation payloads that must be derivable or writable before portfolio-triage
surfaces render review-state badges, progress summaries, or mutation actions.
servers:
- url: /internal
x-triage-review-consumers:
- surface: workspace.overview.progress
sourceFiles:
- apps/platform/app/Support/Workspaces/WorkspaceOverviewBuilder.php
- apps/platform/app/Filament/Widgets/Workspace/WorkspaceSummaryStats.php
- apps/platform/app/Filament/Widgets/Workspace/WorkspaceNeedsAttention.php
mustRender:
- concern_family
- affected_total
- reviewed_count
- follow_up_needed_count
- changed_since_review_count
- surface: tenant.registry.triage
sourceFiles:
- apps/platform/app/Filament/Resources/TenantResource.php
- apps/platform/app/Filament/Resources/TenantResource/Pages/ListTenants.php
mustRender:
- concern_family
- derived_review_state
- reviewed_at
- reviewed_by_user_id
mustAccept:
- review_state
- backup_posture
- recovery_evidence
- triage_sort
- surface: tenant.dashboard.arrival
sourceFiles:
- apps/platform/app/Filament/Pages/TenantDashboard.php
- apps/platform/app/Filament/Widgets/Tenant/TenantTriageArrivalContinuity.php
mustRender:
- concern_family
- derived_review_state
- mark_reviewed_action
- mark_follow_up_needed_action
paths:
/admin:
get:
summary: Workspace overview exposes current-set triage progress summaries
operationId: viewWorkspaceOverviewWithTriageReviewProgress
responses:
'200':
description: Rendered workspace overview with additive progress summary semantics
content:
text/html:
schema:
type: string
application/vnd.tenantpilot.workspace-triage-progress+json:
schema:
$ref: '#/components/schemas/WorkspaceTriageProgressBundle'
'404':
description: Workspace scope is not available to the actor
/admin/tenants:
get:
summary: Tenant registry triage renders review-state badges and accepts review-state filters
operationId: viewTenantRegistryWithTriageReviewState
parameters:
- name: backup_posture
in: query
required: false
schema:
type: array
items:
$ref: '#/components/schemas/BackupConcernState'
- name: recovery_evidence
in: query
required: false
schema:
type: array
items:
$ref: '#/components/schemas/RecoveryConcernState'
- name: review_state
in: query
required: false
schema:
type: array
items:
$ref: '#/components/schemas/DerivedReviewState'
- name: triage_sort
in: query
required: false
schema:
type: string
enum:
- worst_first
responses:
'200':
description: Rendered tenant registry with concern truth plus review-state context
content:
text/html:
schema:
type: string
application/vnd.tenantpilot.tenant-registry-triage-review+json:
schema:
$ref: '#/components/schemas/TenantRegistryTriageReviewBundle'
'404':
description: Workspace scope is not available to the actor
/admin/t/{tenant}:
get:
summary: Tenant dashboard arrival continuity shows the current review state for the focused concern family
operationId: viewTenantDashboardWithTriageReviewState
parameters:
- name: tenant
in: path
required: true
schema:
type: string
- name: arrival
in: query
required: false
schema:
type: string
description: Existing portfolio-arrival token carrying concern-family focus.
responses:
'200':
description: Rendered tenant dashboard with optional continuity review-state controls
content:
text/html:
schema:
type: string
application/vnd.tenantpilot.tenant-dashboard-triage-review+json:
schema:
$ref: '#/components/schemas/TenantDashboardTriageReviewBundle'
'404':
description: Tenant is outside workspace or tenant entitlement scope
/internal/workspaces/{workspace}/tenants/{tenant}/triage-review-state:
put:
summary: Upsert the active triage-review record for one workspace, tenant, and concern family
operationId: upsertTenantTriageReviewState
parameters:
- name: workspace
in: path
required: true
schema:
type: integer
- name: tenant
in: path
required: true
schema:
type: integer
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/TriageReviewMutationRequest'
responses:
'200':
description: Active review record updated and current derived state returned
content:
application/vnd.tenantpilot.triage-review-state+json:
schema:
$ref: '#/components/schemas/TriageReviewMutationResult'
'403':
description: Actor is in scope but lacks the capability to mutate triage review state
'404':
description: Workspace or tenant is outside the actor's entitlement scope
components:
schemas:
ConcernFamily:
type: string
enum:
- backup_health
- recovery_evidence
BackupConcernState:
type: string
enum:
- absent
- stale
- degraded
RecoveryConcernState:
type: string
enum:
- unvalidated
- weakened
ConcernState:
type: string
enum:
- absent
- stale
- degraded
- unvalidated
- weakened
ManualReviewState:
type: string
enum:
- reviewed
- follow_up_needed
DerivedReviewState:
type: string
enum:
- not_reviewed
- reviewed
- follow_up_needed
- changed_since_review
TriageReviewSnapshot:
type: object
additionalProperties: false
required:
- concernFamily
- concernState
properties:
concernFamily:
$ref: '#/components/schemas/ConcernFamily'
concernState:
$ref: '#/components/schemas/ConcernState'
reasonCode:
type:
- string
- 'null'
severityKey:
type:
- string
- 'null'
supportingKey:
type:
- string
- 'null'
ActiveTriageReviewRecord:
type: object
additionalProperties: false
required:
- workspaceId
- tenantId
- concernFamily
- currentState
- reviewedAt
- reviewFingerprint
properties:
workspaceId:
type: integer
tenantId:
type: integer
concernFamily:
$ref: '#/components/schemas/ConcernFamily'
currentState:
$ref: '#/components/schemas/ManualReviewState'
reviewedAt:
type: string
format: date-time
reviewedByUserId:
type:
- integer
- 'null'
reviewFingerprint:
type: string
reviewSnapshot:
$ref: '#/components/schemas/TriageReviewSnapshot'
lastSeenMatchingAt:
type:
- string
- 'null'
format: date-time
resolvedAt:
type:
- string
- 'null'
format: date-time
ResolvedTriageReviewState:
type: object
additionalProperties: false
required:
- concernFamily
- derivedState
- currentConcernPresent
properties:
concernFamily:
$ref: '#/components/schemas/ConcernFamily'
derivedState:
$ref: '#/components/schemas/DerivedReviewState'
currentConcernPresent:
type: boolean
currentFingerprint:
type:
- string
- 'null'
reviewedAt:
type:
- string
- 'null'
format: date-time
reviewedByUserId:
type:
- integer
- 'null'
snapshot:
anyOf:
- $ref: '#/components/schemas/TriageReviewSnapshot'
- type: 'null'
TriageProgressSummary:
type: object
additionalProperties: false
required:
- concernFamily
- affectedTotal
- reviewedCount
- followUpNeededCount
- changedSinceReviewCount
- notReviewedCount
properties:
concernFamily:
$ref: '#/components/schemas/ConcernFamily'
affectedTotal:
type: integer
reviewedCount:
type: integer
followUpNeededCount:
type: integer
changedSinceReviewCount:
type: integer
notReviewedCount:
type: integer
TriageReviewMutationRequest:
type: object
additionalProperties: false
required:
- concernFamily
- state
- currentFingerprint
- snapshot
properties:
concernFamily:
$ref: '#/components/schemas/ConcernFamily'
state:
$ref: '#/components/schemas/ManualReviewState'
currentFingerprint:
type: string
snapshot:
$ref: '#/components/schemas/TriageReviewSnapshot'
sourceSurface:
type:
- string
- 'null'
enum:
- tenant_registry
- tenant_dashboard_arrival
- null
TriageReviewMutationResult:
type: object
additionalProperties: false
required:
- activeRecord
- resolvedState
properties:
activeRecord:
$ref: '#/components/schemas/ActiveTriageReviewRecord'
resolvedState:
$ref: '#/components/schemas/ResolvedTriageReviewState'
WorkspaceTriageProgressBundle:
type: object
additionalProperties: false
required:
- summaries
properties:
summaries:
type: array
items:
$ref: '#/components/schemas/TriageProgressSummary'
TenantRegistryRowReviewState:
type: object
additionalProperties: false
required:
- tenantId
- concernFamily
- derivedState
properties:
tenantId:
type: integer
concernFamily:
$ref: '#/components/schemas/ConcernFamily'
derivedState:
$ref: '#/components/schemas/DerivedReviewState'
reviewedAt:
type:
- string
- 'null'
format: date-time
reviewedByUserId:
type:
- integer
- 'null'
TenantRegistryTriageReviewBundle:
type: object
additionalProperties: false
required:
- rows
properties:
concernFamilyFocus:
type:
- string
- 'null'
enum:
- backup_health
- recovery_evidence
- null
rows:
type: array
items:
$ref: '#/components/schemas/TenantRegistryRowReviewState'
activeFilters:
type:
- object
- 'null'
additionalProperties: true
TenantDashboardTriageReviewBundle:
type: object
additionalProperties: false
required:
- canRenderReviewControls
properties:
concernFamilyFocus:
anyOf:
- $ref: '#/components/schemas/ConcernFamily'
- type: 'null'
reviewState:
anyOf:
- $ref: '#/components/schemas/ResolvedTriageReviewState'
- type: 'null'
canRenderReviewControls:
type: boolean
markReviewedAllowed:
type: boolean
markFollowUpNeededAllowed:
type: boolean

View File

@ -0,0 +1,161 @@
# Data Model: Portfolio Triage Review State and Operator Progress
## Overview
This feature adds one persisted operator-progress entity and a small set of derived read models. Current backup-health and recovery-evidence posture remain authoritative and are not duplicated in storage.
## Existing Source Truths
### Current concern truth
**Type**: Existing derived posture state
**Sources**: `TenantBackupHealthResolver`, `TenantBackupHealthAssessment`, `RestoreSafetyResolver`, `WorkspaceOverviewBuilder`, `TenantResource`, `PortfolioArrivalContextResolver`
| Concern Family | Stable Inputs | Notes |
|---------------|---------------|-------|
| `backup_health` | posture state, stable reason code, schedule-follow-up family, freshness family | Current source of truth for backup triage remains existing tenant backup-health assessment |
| `recovery_evidence` | posture state, stable reason code, restore-evidence concern family | Current source of truth for recovery triage remains existing restore-safety and recovery-evidence assessment |
### Existing arrival context
**Type**: Existing request-scoped continuity contract
**Source**: `PortfolioArrivalContext`, `PortfolioArrivalContextToken`, `PortfolioArrivalContextResolver`
This feature consumes the existing concern-family focus on `/admin/t/{tenant}` to know which triage-review state should be rendered or mutated from the tenant dashboard.
## New Persisted Entity
### TenantTriageReview
**Table**: `tenant_triage_reviews`
**Type**: Workspace-shared persisted operator-progress record
**Lifecycle**: Active while it is the current manual review record for one workspace, tenant, and concern family; becomes inactive when superseded or explicitly resolved
| Field | Type | Validation / Notes |
|------|------|--------------------|
| `id` | bigint | Primary key |
| `workspace_id` | foreign key | Required; must reference the active workspace scope |
| `tenant_id` | foreign key | Required; must reference the tenant inside the same workspace |
| `concern_family` | string | Required; allowlisted to `backup_health` or `recovery_evidence` in V1 |
| `current_state` | string | Required; persisted values limited to `reviewed` or `follow_up_needed` |
| `reviewed_at` | timestamp | Required for active rows; when the manual state was recorded |
| `reviewed_by_user_id` | foreign key nullable | Optional actor reference for workspace-shared progress visibility |
| `review_fingerprint` | string | Required; deterministic fingerprint of the current material concern situation at review time |
| `review_snapshot` | jsonb | Required; bounded diagnostic snapshot with stable concern state, reason, and small supporting keys |
| `last_seen_matching_at` | timestamp nullable | Optional lightweight diagnostic field; initialized on write and may be refreshed opportunistically by future maintenance, but render-time correctness must not depend on it |
| `resolved_at` | timestamp nullable | Null for the current active row; set when a row is superseded or explicitly marked inactive |
| `created_at` | timestamp | Laravel default |
| `updated_at` | timestamp | Laravel default |
### Constraints and Indexes
| Constraint | Purpose |
|-----------|---------|
| Foreign keys on `workspace_id`, `tenant_id`, `reviewed_by_user_id` | Preserve workspace and tenant ownership plus reviewer linkage |
| Partial unique index on (`workspace_id`, `tenant_id`, `concern_family`) where `resolved_at IS NULL` | Ensures at most one active review record per workspace, tenant, and concern family |
| Lookup index on (`workspace_id`, `concern_family`, `resolved_at`, `tenant_id`) | Supports batch loading for workspace and registry current-set resolution |
| Check constraint or enum cast for `current_state` | Limits persisted manual states to `reviewed` or `follow_up_needed` |
## New Derived Read Models
### Derived review state
**Type**: Request-scoped resolved state
**Source**: `TenantTriageReviewStateResolver`
| Derived State | Rule | Stored? |
|--------------|------|---------|
| `not_reviewed` | Current concern exists and no active review row exists for the same workspace, tenant, and concern family | No |
| `reviewed` | Current concern exists, active review row exists, `current_state = reviewed`, and fingerprint matches | No |
| `follow_up_needed` | Current concern exists, active review row exists, `current_state = follow_up_needed`, and fingerprint matches | No |
| `changed_since_review` | Current concern exists, active review row exists, and current fingerprint differs from the stored fingerprint | No |
| `inactive` / excluded | Current concern does not exist in the current affected set | No |
### Current-set progress summary
**Type**: Request-scoped aggregate summary
**Source**: `TenantTriageReviewStateResolver` batch output, consumed by workspace overview and registry surfaces
| Field | Type | Notes |
|------|------|-------|
| `concern_family` | string | `backup_health` or `recovery_evidence` |
| `affected_total` | integer | Count of currently visible affected tenants in the family-specific set |
| `reviewed_count` | integer | Count of current affected rows resolved to `reviewed` |
| `follow_up_needed_count` | integer | Count of current affected rows resolved to `follow_up_needed` |
| `changed_since_review_count` | integer | Count of current affected rows resolved to `changed_since_review` |
| `not_reviewed_count` | integer | Count of current affected rows resolved to `not_reviewed` |
### Resolved row payload
**Type**: Request-scoped row-level bundle
**Source**: `TenantTriageReviewStateResolver`
| Field | Type | Notes |
|------|------|-------|
| `tenant_id` | integer | Tenant identifier for the current row |
| `concern_family` | string | Family the resolved review state refers to |
| `current_concern_present` | boolean | False rows are excluded from current-set progress |
| `current_fingerprint` | string | Deterministic fingerprint of current concern truth |
| `derived_state` | string | `not_reviewed`, `reviewed`, `follow_up_needed`, or `changed_since_review` |
| `reviewed_at` | timestamp or null | From active review row when present |
| `reviewed_by_user_id` | integer or null | From active review row when present |
| `review_snapshot` | array or null | Bounded snapshot for optional secondary display |
## Validation Rules
### Concern-family rules
| Concern Family | Allowed Current States | Fingerprint Inputs |
|---------------|------------------------|--------------------|
| `backup_health` | `reviewed`, `follow_up_needed` | Stable backup posture, stable reason code, schedule-follow-up family, freshness family |
| `recovery_evidence` | `reviewed`, `follow_up_needed` | Stable recovery posture, stable reason code, restore-evidence concern family |
### Snapshot rules
- `review_snapshot` must remain lightweight and bounded.
- Allowed snapshot keys may include `concern_family`, `concern_state`, `reason_code`, `severity_key`, `supporting_key`, and small label-safe metadata.
- Snapshot data must not contain comments, evidence payloads, free-text notes, rendered HTML, or volatile timestamps that would destabilize equality.
### Fingerprint rules
- Fingerprints must be deterministic across repeated reads of the same material concern situation.
- Fingerprints must ignore translated copy, badge labels, rendered descriptions, and volatile time values.
- Fingerprints must change when material concern family, stable state, or stable reason keys change.
## Lifecycle and State Transitions
### Manual mutation transitions
| Event | Existing Active Row | Result |
|------|---------------------|--------|
| `Mark reviewed` | none | Insert new active row with `current_state = reviewed` |
| `Mark reviewed` | active row exists | Set prior row `resolved_at`, then insert new active `reviewed` row |
| `Mark follow_up_needed` | none | Insert new active row with `current_state = follow_up_needed` |
| `Mark follow_up_needed` | active row exists | Set prior row `resolved_at`, then insert new active `follow_up_needed` row |
### Derived-state precedence
1. If the current concern is absent, the row is excluded from current-set state.
2. If the current concern exists and no active row exists, the derived state is `not_reviewed`.
3. If an active row exists and the current fingerprint does not match, the derived state is `changed_since_review`.
4. If an active row exists and fingerprints match, the derived state follows the stored manual state.
### Inactivity handling
- Superseded writes always resolve the previous active row.
- UI correctness does not depend on immediately writing `resolved_at` when a concern naturally disappears from the current affected set; current-set exclusion is derived from current concern truth.
- If later cleanup or maintenance chooses to mark concern-gone rows as resolved, that is an implementation detail and not required for V1 correctness.
## Relationships
- One workspace has many `TenantTriageReview` rows.
- One tenant has many `TenantTriageReview` rows across concern families and historical supersessions.
- One user may review many rows through `reviewed_by_user_id`.
- One current concern family on one tenant resolves to zero or one active row.
## Rendering Rules
- Posture truth remains primary and is displayed independently of review state.
- Registry and overview counts include only current affected rows, never calm or resolved rows.
- Mixed-family registry views must label which concern family the displayed review state refers to.
- Tenant dashboard review-state actions render only when portfolio-arrival context provides a valid concern-family focus.

View File

@ -0,0 +1,282 @@
# Implementation Plan: Portfolio Triage Review State and Operator Progress
**Branch**: `189-portfolio-triage-review-state` | **Date**: 2026-04-10 | **Spec**: `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/specs/189-portfolio-triage-review-state/spec.md`
**Input**: Feature specification from `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/specs/189-portfolio-triage-review-state/spec.md`
**Note**: This template is filled in by the `/speckit.plan` command. See `.specify/scripts/` for helper scripts.
## Summary
Add one lightweight persisted triage-review record per workspace, tenant, and concern family so existing workspace overview, tenant registry triage, and tenant dashboard arrival surfaces can show operator progress without changing posture truth. The implementation will reuse existing backup-health and recovery-evidence resolvers, existing portfolio-arrival context, existing BadgeCatalog and UiEnforcement patterns, and one batch-loaded review-state resolver so the feature stays narrow, query-bounded, RBAC-safe, and clearly separate from formal TenantReview and ReviewPack workflows.
## Technical Context
**Language/Version**: PHP 8.4.15
**Primary Dependencies**: Laravel 12, Filament v5, Livewire v4, Pest v4, existing `WorkspaceOverviewBuilder`, `TenantResource`, `TenantDashboard`, `PortfolioArrivalContext`, `TenantBackupHealthResolver`, `RestoreSafetyResolver`, `BadgeCatalog`, `UiEnforcement`, and `AuditRecorder` patterns
**Storage**: PostgreSQL via Laravel Eloquent with one new table `tenant_triage_reviews` and no new external caches or background stores
**Testing**: Pest 4 unit and feature tests, Filament or Livewire surface tests, focused RBAC regressions, run through Laravel Sail
**Target Platform**: Laravel monolith web application in Sail locally and containerized Linux deployment in staging and production
**Project Type**: web application
**Performance Goals**: Keep review-state mutations DB-only and under the no-`OperationRun` threshold, batch-load review state for tenant-registry and workspace-overview affected sets, reuse existing posture batch resolvers, and avoid new N+1 patterns on `/admin`, `/admin/tenants`, or `/admin/t/{tenant}`
**Constraints**: Preserve posture truth as primary; keep review state workspace-shared and concern-family-specific; no Graph calls; no `OperationRun`; no new formal review or workflow engine; no notes, assignments, or SLA logic; RBAC must keep non-members at `404` and members without capability at `403`; mutations must communicate `TenantPilot only` scope, show a bounded pre-execution preview plus explicit confirmation, and remain reversible
**Scale/Scope**: One new persisted model and migration, one small fingerprint helper, one batch resolver, one narrow mutation service, one new badge domain, and additive changes across three existing operator surfaces and two current concern families (`backup_health`, `recovery_evidence`)
## Constitution Check
*GATE: Passed before Phase 0 research. Re-checked after Phase 1 design and still passing.*
| Principle | Pre-Research | Post-Design | Notes |
|-----------|--------------|-------------|-------|
| Inventory-first / snapshots-second | PASS | PASS | Backup-health and recovery-evidence remain the source of current concern truth; the new table stores only operator progress truth. |
| Read/write separation | PASS | PASS | The feature adds only TenantPilot-internal progress writes. Each mutation stays DB-only and sub-2-second, but still uses a bounded pre-execution preview plus explicit confirmation, server-side authorization, mutation-scope copy, audit logging, and focused tests. |
| Graph contract path | N/A | N/A | No Microsoft Graph calls or contract-registry changes are required. |
| Deterministic capabilities | PASS | PASS | A new capability constant can be added to the canonical registry and enforced through existing capability resolvers and UI helpers. |
| Workspace + tenant isolation | PASS | PASS | Review-state reads and writes are always bound to the active workspace and tenant scope. |
| RBAC-UX authorization semantics | PASS | PASS | Non-members remain `404`; in-scope members without mutation capability receive `403`; server-side authorization remains authoritative. |
| Run observability / Ops-UX | PASS | PASS | Review-state mutations are DB-only and below the `OperationRun` threshold; no remote or queued work is introduced. |
| Data minimization | PASS | PASS | The persisted snapshot stays bounded to stable concern state, reason, and small diagnostic keys; no notes or rich evidence are stored. |
| Proportionality / no premature abstraction | PASS WITH JUSTIFIED ABSTRACTION | PASS WITH JUSTIFIED ABSTRACTION | One new persisted model plus one fingerprint and state resolver are justified because request-scoped posture truth cannot persist operator progress across time. No broader workflow framework is added. |
| Persisted truth / behavioral state | PASS | PASS | The new table represents independent product truth with its own lifecycle. Only behaviorally meaningful manual states are persisted; `not_reviewed` and `changed_since_review` remain derived. |
| UI semantics / few layers | PASS | PASS | The design adds one narrow review-state semantic family and reuses existing domain truth and central badge mappings rather than introducing a presentation framework. |
| Filament v5 / Livewire v4 compliance | PASS | PASS | The plan stays inside existing Filament v5 and Livewire v4 conventions. |
| Provider registration location | PASS | PASS | No panel or provider changes are required. Provider registration remains in `bootstrap/providers.php`. |
| Global search hard rule | PASS | PASS | No new globally searchable resource is introduced. Existing impacted resources already satisfy the rule: `TenantResource` has a view page and tenant drilldown pages already exist. |
| Destructive action safety | PASS | PASS | No destructive action is added. `Mark reviewed` and `Mark follow-up needed` are reversible TenantPilot-only progress writes, not destructive mutations. |
| Asset strategy | PASS | PASS | No new assets are introduced. Existing `filament:assets` deployment behavior remains unchanged. |
| Filament-native UI / Action Surface Contract | PASS | PASS | The tenant registry keeps one primary inspect model with review-state mutations in overflow, and the tenant dashboard continuity block remains additive. |
| Filament UX-001 | PASS | PASS | No create or edit layout changes are introduced; list filters, badges, and continuity sections remain within existing page structures. |
| Testing truth (TEST-TRUTH-001) | PASS | PASS | The plan adds focused tests for business consequences: persistence, derivation, stale detection, counts, and authorization, not thin presentation layers alone. |
## Phase 0 Research
Research outcomes are captured in `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/specs/189-portfolio-triage-review-state/research.md`.
Key decisions:
- Persist triage-review state in a dedicated `tenant_triage_reviews` table instead of reusing `TenantReview` or `ReviewPack` governance artifacts.
- Store only the manual states `reviewed` and `follow_up_needed`; derive `not_reviewed` and `changed_since_review` at read time.
- Use one active row per workspace, tenant, and concern family, resolving prior rows on superseding writes instead of overwriting them in place.
- Compute stable concern fingerprints from existing backup-health and recovery-evidence resolver outputs rather than inventing a second concern model.
- Batch-load active review records alongside existing posture batch resolvers so `/admin` and `/admin/tenants` stay query-bounded.
- Reuse `TenantResource::portfolioConcernPriority()` as the canonical highest-priority selector whenever a mixed registry slice needs one review-state family to render or mutate.
- Use a bounded pre-execution preview and explicit confirmation on dashboard and registry mutation actions instead of introducing a separate remote dry-run pipeline for this local DB write.
- Reuse existing BadgeCatalog, UiEnforcement, PortfolioArrivalContext, and tenant triage fixtures so the UI stays consistent with recent portfolio-triage specs.
- Record lightweight audit entries for progress mutations without introducing an operator-facing audit timeline.
## Phase 1 Design
Design artifacts are created under `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/specs/189-portfolio-triage-review-state/`:
- `data-model.md`: persisted triage-review entity, derived state precedence, fingerprint rules, and progress-summary read model
- `contracts/portfolio-triage-review-state.logical.openapi.yaml`: internal logical contract for current-set summaries, registry review-state rendering, and triage-review mutations
- `quickstart.md`: implementation and verification workflow
Design decisions:
- The new table stores only independent operator-progress truth. Current concern posture remains sourced from `TenantBackupHealthResolver` and `RestoreSafetyResolver`.
- The mutation path uses one narrow service that resolves any prior active record, inserts the new active record, writes one bounded audit event, and is invoked only after a surface-level preview plus explicit confirmation.
- Review-state resolution is batch-based and concern-family-aware. The resolver never merges backup-health and recovery-evidence into a single global tenant review state, and mixed registry slices reuse the existing `TenantResource::portfolioConcernPriority()` rules when one family must be chosen.
- Workspace progress summaries consume the same batch resolver output as tenant-registry rows so counts and badges are derived from one source.
- The tenant dashboard arrival continuity block remains the only inline mutation surface; registry mutations stay in overflow to preserve the existing action-surface contract, and generic tenant browsing sessions suppress queue-like triage-review mutation language entirely.
## Project Structure
### Documentation (this feature)
```text
specs/189-portfolio-triage-review-state/
├── spec.md
├── plan.md
├── research.md
├── data-model.md
├── quickstart.md
├── contracts/
│ └── portfolio-triage-review-state.logical.openapi.yaml
├── checklists/
│ └── requirements.md
└── tasks.md
```
### Source Code (repository root)
```text
apps/platform/
├── app/
│ ├── Filament/
│ │ ├── Pages/
│ │ │ ├── TenantDashboard.php
│ │ │ └── WorkspaceOverview.php
│ │ ├── Resources/
│ │ │ ├── TenantResource.php
│ │ │ └── TenantResource/
│ │ │ └── Pages/ListTenants.php
│ │ └── Widgets/
│ │ ├── Workspace/
│ │ │ ├── WorkspaceNeedsAttention.php
│ │ │ └── WorkspaceSummaryStats.php
│ │ └── Tenant/
│ │ └── TenantTriageArrivalContinuity.php
│ ├── Models/
│ │ └── TenantTriageReview.php
│ ├── Services/
│ │ ├── Audit/AuditRecorder.php
│ │ └── PortfolioTriage/TenantTriageReviewService.php
│ ├── Support/
│ │ ├── Audit/AuditActionId.php
│ │ ├── Auth/Capabilities.php
│ │ ├── Badges/
│ │ │ ├── BadgeDomain.php
│ │ │ └── Domains/TenantTriageReviewStateBadge.php
│ │ ├── PortfolioTriage/
│ │ │ ├── PortfolioArrivalContext.php
│ │ │ ├── PortfolioArrivalContextResolver.php
│ │ │ ├── TenantTriageReviewFingerprint.php
│ │ │ └── TenantTriageReviewStateResolver.php
│ │ ├── Rbac/UiEnforcement.php
│ │ └── Workspaces/WorkspaceOverviewBuilder.php
│ └── database/
│ ├── factories/TenantTriageReviewFactory.php
│ └── migrations/*_create_tenant_triage_reviews_table.php
└── tests/
├── Feature/
│ ├── Concerns/BuildsPortfolioTriageFixtures.php
│ ├── Filament/
│ │ ├── TenantDashboardArrivalContextTest.php
│ │ ├── TenantRegistryRecoveryTriageTest.php
│ │ ├── TenantRegistryTriageReviewStateTest.php
│ │ └── WorkspaceOverviewTriageReviewProgressTest.php
│ └── Rbac/
│ └── TriageReviewStateAuthorizationTest.php
└── Unit/
└── Support/PortfolioTriage/
├── TenantTriageReviewFingerprintTest.php
└── TenantTriageReviewStateResolverTest.php
```
**Structure Decision**: Keep the existing Laravel monolith layout under `apps/platform`. Add one narrow persisted model and migration, one small service plus resolver and fingerprint helper in the existing `PortfolioTriage` namespace, one badge domain, and additive tests beside the already-established portfolio-triage suites instead of creating a broader workflow subsystem or new base directories.
## Implementation Strategy
### Phase A — Persist Lightweight Triage-Review Truth
**Goal**: Introduce the minimal table and model needed to persist workspace-shared operator progress.
| Step | File | Change |
|------|------|--------|
| A.1 | `apps/platform/database/migrations/*_create_tenant_triage_reviews_table.php` | Add the `tenant_triage_reviews` table with `workspace_id`, `tenant_id`, `concern_family`, `current_state`, `reviewed_at`, `reviewed_by_user_id`, `review_fingerprint`, `review_snapshot`, `last_seen_matching_at`, `resolved_at`, timestamps, foreign keys, and a partial unique index for active rows in PostgreSQL |
| A.2 | `apps/platform/app/Models/TenantTriageReview.php` | Add the Eloquent model, casts, relationships to workspace, tenant, and reviewing user, plus active or resolved query scopes |
| A.3 | `apps/platform/database/factories/TenantTriageReviewFactory.php` | Add factory states for `reviewed`, `follow_up_needed`, active, resolved, and changed-fingerprint scenarios used by feature and unit tests |
### Phase B — Resolve Stable Fingerprints And Derived Review State
**Goal**: Combine current concern truth with active review records without creating per-row query fanout or a second concern model.
| Step | File | Change |
|------|------|--------|
| B.1 | `apps/platform/app/Support/PortfolioTriage/TenantTriageReviewFingerprint.php` | Add deterministic fingerprint generation for `backup_health` and `recovery_evidence` from stable resolver outputs and concern-family-safe reason keys |
| B.2 | `apps/platform/app/Support/PortfolioTriage/TenantTriageReviewStateResolver.php` | Add batch loading for active review rows keyed by workspace, tenant, and concern family; derive `not_reviewed`, `reviewed`, `follow_up_needed`, and `changed_since_review`; expose current-set progress counts |
| B.3 | Existing batch posture resolvers and portfolio helpers | Reuse `TenantBackupHealthResolver`, `RestoreSafetyResolver`, and `PortfolioArrivalContextResolver` outputs as the sole inputs to fingerprinting and family focus rather than duplicating concern logic |
### Phase C — Add A Narrow Mutation Service With Audit And RBAC
**Goal**: Provide one canonical write path for `Mark reviewed` and `Mark follow-up needed`.
| Step | File | Change |
|------|------|--------|
| C.1 | `apps/platform/app/Services/PortfolioTriage/TenantTriageReviewService.php` | Add service methods to resolve any prior active row, insert a new active row, bound the snapshot payload, and return the new derived state |
| C.2 | `apps/platform/app/Support/Auth/Capabilities.php` | Add one canonical capability for triage-review mutation, scoped to tenant or workspace triage usage according to existing registry patterns |
| C.3 | `apps/platform/app/Support/Audit/AuditActionId.php` and `apps/platform/app/Services/Audit/AuditRecorder.php` usage | Add lightweight audit action IDs and record bounded audit entries for the two mutation verbs without adding a new operator-facing audit timeline |
| C.4 | `apps/platform/app/Support/Rbac/UiEnforcement.php` integration points | Reuse visible-but-disabled RBAC gating for members without capability and preserve server-side 404 or 403 semantics on action execution |
| C.5 | `apps/platform/app/Filament/Resources/TenantResource.php`, `apps/platform/app/Filament/Widgets/Tenant/TenantTriageArrivalContinuity.php`, and `apps/platform/resources/views/filament/widgets/tenant/triage-arrival-continuity.blade.php` | Add a bounded pre-execution preview and explicit confirmation showing concern family, current review state, target manual state, and `TenantPilot only` scope before invoking the mutation service |
### Phase D — Bind Review State Into Existing Operator Surfaces
**Goal**: Show current review state and progress exactly where portfolio triage already lives.
| Step | File | Change |
|------|------|--------|
| D.1 | `apps/platform/app/Support/Workspaces/WorkspaceOverviewBuilder.php` | Extend current affected-set summary building so overview widgets receive review-state progress counts derived from the same visible tenant population |
| D.2 | `apps/platform/app/Filament/Widgets/Workspace/WorkspaceSummaryStats.php` and `apps/platform/app/Filament/Widgets/Workspace/WorkspaceNeedsAttention.php` | Render additive current-set progress chips or summary text without changing existing posture truth or drilldown semantics |
| D.3 | `apps/platform/app/Filament/Resources/TenantResource.php` and `apps/platform/app/Filament/Resources/TenantResource/Pages/ListTenants.php` | Add a review-state column, all four review-state filters, and overflow actions for `Mark reviewed` and `Mark follow-up needed`, keeping full-row click and the existing `openTenant` shortcut intact and reusing `TenantResource::portfolioConcernPriority()` for mixed-slice family selection |
| D.4 | `apps/platform/app/Filament/Widgets/Tenant/TenantTriageArrivalContinuity.php` and `apps/platform/app/Filament/Pages/TenantDashboard.php` | Show the resolved review state for the current arrival concern family, expose the same two preview-confirmed mutation actions inside the existing continuity block only when valid triage context exists, and suppress queue-like review-state mutation language in generic tenant browsing sessions |
| D.5 | `apps/platform/app/Support/Badges/BadgeDomain.php` and `apps/platform/app/Support/Badges/Domains/TenantTriageReviewStateBadge.php` | Register centralized badge semantics for `Not reviewed`, `Reviewed`, `Follow-up needed`, and `Changed since review` so the same labels render consistently across overview, registry, and dashboard |
### Phase E — Regression Protection And Verification
**Goal**: Prove persistence, derivation, and authorization without regressing portfolio-triage semantics.
| Step | File | Change |
|------|------|--------|
| E.1 | `apps/platform/tests/Feature/Concerns/BuildsPortfolioTriageFixtures.php` | Extend existing fixtures with review-state records and changed-fingerprint scenarios so new tests reuse the current portfolio-triage setup |
| E.2 | `apps/platform/tests/Unit/Support/PortfolioTriage/TenantTriageReviewFingerprintTest.php` | Cover stable fingerprint generation, ignored volatile fields, and concern-family separation |
| E.3 | `apps/platform/tests/Unit/Support/PortfolioTriage/TenantTriageReviewStateResolverTest.php` | Cover state precedence, current-set exclusion, batch derivation, and changed-since-review behavior |
| E.4 | `apps/platform/tests/Feature/Filament/TenantRegistryTriageReviewStateTest.php` and `apps/platform/tests/Feature/Filament/TenantRegistryRecoveryTriageTest.php` | Cover registry review-state badges, filters, overflow actions, and no-conflation with posture truth |
| E.5 | `apps/platform/tests/Feature/Filament/TenantDashboardArrivalContextTest.php` and `apps/platform/tests/Feature/Filament/WorkspaceOverviewTriageReviewProgressTest.php` | Cover dashboard continuity actions, overview progress counts, current-set-only semantics, and changed-since-review visibility |
| E.6 | `apps/platform/tests/Feature/Rbac/TriageReviewStateAuthorizationTest.php` | Cover non-member `404`, member-without-capability `403`, visible-but-disabled UI, and allowed mutation success |
| E.7 | Focused Sail and Pint runs | Run the minimal verification pack for unit, feature, and RBAC suites plus `pint --dirty --format agent` |
## Key Design Decisions
### D-001 — Persist only manual review intent, derive everything else
The table stores only `reviewed` and `follow_up_needed`. `not_reviewed` and `changed_since_review` remain derived from the presence of an active row plus fingerprint comparison. This keeps the persisted state family narrow and behaviorally meaningful.
### D-002 — Reuse current concern truth; do not create a second concern model
`TenantBackupHealthResolver`, `RestoreSafetyResolver`, and existing portfolio-arrival context already know which family and state currently matter. Fingerprints must be built from those stable outputs, not from new parallel concern DTOs.
### D-003 — Keep one active row per workspace, tenant, and concern family
Superseding writes resolve the previous active row and insert a new one. This preserves lightweight continuity without requiring a formal review timeline or a collaboration engine.
### D-004 — Use existing portfolio-triage surfaces instead of adding a queue page
Workspace overview, tenant registry, and the tenant dashboard continuity block already form the operator flow. Adding a separate triage-review page would add IA weight without solving a new problem.
### D-005 — Use central badge and RBAC helpers, not local status language or ad hoc authorization
The review-state labels must be rendered through the same badge infrastructure used elsewhere, and mutation actions must be gated through the canonical capability registry plus `UiEnforcement` so the feature does not scatter raw strings or local auth logic.
### D-006 — Keep the feature lightweight but still auditable
No `OperationRun`, no external writes, and no audit timeline are needed, but the two progress mutations still use bounded preview-and-confirmation UI and emit bounded `AuditLog` entries so the write path remains traceable under the constitution's write-safety rules.
## Risk Assessment
| Risk | Impact | Likelihood | Mitigation |
|------|--------|------------|------------|
| Fingerprint rules are too sensitive and overproduce `Changed since review` | High | Medium | Restrict fingerprint inputs to stable state, reason-code, and posture keys; cover false-positive cases in unit tests. |
| Fingerprint rules are too weak and leave stale `Reviewed` badges visible | High | Medium | Define family-specific stable keys from existing resolvers and cover state-transition cases in resolver tests. |
| Review-state loading introduces N+1 behavior on `/admin` or `/admin/tenants` | High | Medium | Batch-load active rows keyed by tenant and concern family, reuse current posture batch resolvers, and add query-shape regressions. |
| Backup and recovery review state become conflated on mixed rows | High | Medium | Keep explicit concern-family focus in resolver output and registry rendering; add family-separation tests. |
| Lightweight writes are mistaken for formal review completion | Medium | Medium | Keep vocabulary narrow (`Review state`, `Mark reviewed`, `Changed since review`), render posture truth separately, and avoid formal review nouns. |
| Audit logging grows into a new workflow surface | Low | Low | Limit audit to backend `AuditLog` recording only and explicitly keep timeline UI and governance artifacts out of scope. |
## Test Strategy
- Add focused unit tests for fingerprint generation, ignored volatile inputs, active-row precedence, current-set exclusion, and concern-family isolation.
- Extend portfolio-triage fixture builders so review-state scenarios can be exercised alongside existing backup and recovery posture scenarios.
- Add Filament feature coverage for tenant-registry column rendering, all four review-state filters, mixed-family highest-priority selection, preview-confirmed overflow actions, and no-conflation with backup or recovery truth.
- Extend tenant-dashboard arrival-context tests so the continuity block shows review state, executes the two allowed mutations only when triage context exists, and stays suppressed for generic tenant browsing sessions.
- Add workspace-overview progress-summary tests so counts are derived only from the current visible affected set and remain separate per concern family.
- Add RBAC tests for non-member `404`, member-without-capability `403`, and successful mutation for an authorized operator.
- Add audit assertions for bounded `AuditLog` entries on both mutation verbs without requiring an operator-facing timeline.
- No new relation managers or destructive flows are introduced. Covered Filament surfaces are `WorkspaceOverview`, `WorkspaceSummaryStats`, `WorkspaceNeedsAttention`, `TenantResource`, `ListTenants`, `TenantDashboard`, and `TenantTriageArrivalContinuity`.
- Run the minimum focused Sail pack before implementation sign-off: migration-aware unit tests, new and extended Filament feature tests, RBAC tests, and `cd apps/platform && ./vendor/bin/sail bin pint --dirty --format agent`.
## Complexity Tracking
| Violation | Why Needed | Simpler Alternative Rejected Because |
|-----------|------------|-------------------------------------|
| New persisted `tenant_triage_reviews` table | Request-scoped posture and arrival context cannot preserve operator progress across sessions or team handoff | Recomputing from current posture alone cannot answer whether someone already reviewed the current concern |
| New fingerprint helper and batch resolver | The feature must compare current concern truth to the last reviewed concern without N+1 behavior and without duplicating posture logic | Ad hoc per-surface comparisons would repeat concern logic, drift across overview or registry surfaces, and increase query fanout |
| Bounded preview and confirmation for DB-only review-state writes | The constitution still requires write safety even though this mutation is a reversible TenantPilot-only local write | A blind click would violate the constitution, while a fuller remote dry-run pipeline would overfit a sub-2-second DB-only action |
## Proportionality Review
- **Current operator problem**: Operators can already identify affected tenants and open them with context, but they still cannot track what has been checked, what still needs follow-up, or what became relevant again after a prior review.
- **Existing structure is insufficient because**: Existing workspace overview, tenant registry, and arrival context are all read-first and request-scoped. None of them persists lightweight review progress across time.
- **Narrowest correct implementation**: Add one lightweight persisted triage-review table, one stable fingerprint helper, one batch resolver, and additive bindings into existing overview, registry, and dashboard surfaces.
- **Ownership cost created**: One migration, one new model and factory, one small service plus resolver, one badge domain, small action bindings, and focused regression coverage for persistence, derivation, authorization, and progress counts.
- **Alternative intentionally rejected**: Reusing `TenantReview` or `ReviewPack`, adding notes or assignments, or introducing a full workflow queue were rejected because they create governance and collaboration weight beyond the current operational-triage need.
- **Release truth**: Current-release truth. The feature closes an active workflow gap in the shipped portfolio-triage experience rather than preparing a future governance layer.

View File

@ -0,0 +1,84 @@
# Quickstart: Portfolio Triage Review State and Operator Progress
## Goal
Implement one lightweight, workspace-shared triage-review state so operators can mark current concerns as reviewed or follow-up needed, see changed-since-review detection, and track current affected-set progress without changing posture truth or reusing formal review artifacts.
## Implementation Sequence
1. Add the persisted triage-review core.
- Create the `tenant_triage_reviews` migration.
- Add `TenantTriageReview` and `TenantTriageReviewFactory`.
- Add the minimal stored-state enum or cast for `reviewed` and `follow_up_needed` only.
2. Add deterministic fingerprinting and batch state resolution.
- Create `TenantTriageReviewFingerprint` under `apps/platform/app/Support/PortfolioTriage/`.
- Create `TenantTriageReviewStateResolver` that batch-loads active rows for a visible tenant set and combines them with existing backup-health and recovery-evidence truth.
- Keep `not_reviewed` and `changed_since_review` derived only.
3. Add one canonical mutation path.
- Create `TenantTriageReviewService` for `markReviewed()` and `markFollowUpNeeded()`.
- Add one capability constant to `Capabilities` and enforce it through `UiEnforcement` plus server-side authorization.
- Add bounded `AuditActionId` values and record lightweight audit entries through `AuditRecorder`.
- Require a bounded pre-execution preview plus explicit confirmation on dashboard and registry review-state actions before the write executes.
4. Bind the new state into existing operator surfaces.
- Extend `WorkspaceOverviewBuilder`, `WorkspaceSummaryStats`, and `WorkspaceNeedsAttention` to show current-set progress counts.
- Extend `TenantResource` and `ListTenants` with a review-state column, all four review-state filters, mixed-family selection driven by the existing worst-first concern priority rules, and overflow actions.
- Extend `TenantTriageArrivalContinuity` and `TenantDashboard` so triage-arrival sessions can mark reviewed or follow-up needed inline after preview-and-confirmation, while generic tenant browsing suppresses queue-like review-state actions.
- Add one new badge domain or mapper for centralized review-state labels.
5. Add regression coverage.
- Add fingerprint and resolver unit tests.
- Add registry rendering, filtering, and action tests.
- Add tenant-dashboard arrival-action tests.
- Add workspace-overview progress-count tests.
- Add RBAC view-versus-mutate tests.
## Suggested Test Files
- `apps/platform/tests/Unit/Support/PortfolioTriage/TenantTriageReviewFingerprintTest.php`
- `apps/platform/tests/Unit/Support/PortfolioTriage/TenantTriageReviewStateResolverTest.php`
- `apps/platform/tests/Feature/Filament/TenantRegistryTriageReviewStateTest.php`
- `apps/platform/tests/Feature/Filament/TenantDashboardArrivalContextTest.php`
- `apps/platform/tests/Feature/Filament/WorkspaceOverviewTriageReviewProgressTest.php`
- `apps/platform/tests/Feature/Rbac/TriageReviewStateAuthorizationTest.php`
- `apps/platform/tests/Feature/Guards/ActionSurfaceContractTest.php`
## Existing Suites To Extend Or Keep Green
- `apps/platform/tests/Feature/Concerns/BuildsPortfolioTriageFixtures.php`
- `apps/platform/tests/Feature/Filament/TenantRegistryRecoveryTriageTest.php`
- `apps/platform/tests/Feature/Filament/TenantDashboardArrivalContextTest.php`
## Minimum Verification Commands
Run all commands through Sail from `apps/platform`.
```bash
cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Unit/Support/PortfolioTriage/TenantTriageReviewFingerprintTest.php
cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Unit/Support/PortfolioTriage/TenantTriageReviewStateResolverTest.php
cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Filament/TenantRegistryTriageReviewStateTest.php
cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Filament/TenantDashboardArrivalContextTest.php
cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Filament/WorkspaceOverviewTriageReviewProgressTest.php
cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Rbac/TriageReviewStateAuthorizationTest.php
cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Filament/TenantRegistryRecoveryTriageTest.php
cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Guards/ActionSurfaceContractTest.php
cd apps/platform && ./vendor/bin/sail bin pint --dirty --format agent
```
## Manual Acceptance Checklist
1. Open a tenant from a backup-health triage slice, trigger `Mark reviewed`, and confirm the preview shows concern family, current review state, target state, and `TenantPilot only` scope before the registry shows `Reviewed` while backup posture remains unchanged.
2. Open a tenant from a recovery-evidence triage slice, trigger `Mark follow-up needed`, confirm the preview, and verify the workspace progress summary increments the correct bucket.
3. Change the underlying concern truth for a previously reviewed tenant and confirm the UI shows `Changed since review` instead of the prior manual state.
4. Open the registry in a mixed-family slice and confirm the review-state badge names the selected concern family and follows the existing worst-first concern priority rules.
5. Exercise all four registry review-state filters (`not_reviewed`, `reviewed`, `follow_up_needed`, `changed_since_review`) and confirm each filter only returns the current visible affected tenants in that bucket.
6. Open a tenant directly without portfolio-triage context and confirm no triage-review actions or queue-like review-state progress copy appears.
7. Use a viewer without mutation capability and confirm review-state truth stays visible while mutation actions are disabled or fail with `403`.
## Deployment Notes
- One migration is required for `tenant_triage_reviews`.
- No new assets are expected.
- No `OperationRun` orchestration or `filament:assets` changes are required beyond the repo's normal deployment process.

View File

@ -0,0 +1,81 @@
# Research: Portfolio Triage Review State and Operator Progress
## Decision: Persist lightweight triage progress in `tenant_triage_reviews` instead of reusing formal review artifacts
### Rationale
The codebase already has formal governance artifacts such as `TenantReview`, `TenantReviewSection`, `ReviewPack`, and `EvidenceSnapshot`. Those models carry longer lifecycle, export, publication, and evidence semantics. Spec 189 needs only workspace-shared operator progress for current portfolio triage, so a dedicated lightweight table is the narrowest persisted truth that survives navigation and time without dragging formal review machinery into the operator flow.
### Alternatives considered
- Reuse `TenantReview`: rejected because it is governance-oriented, lifecycle-heavy, and semantically stronger than "someone checked the current triage concern".
- Reuse `ReviewPack`: rejected because it is an export or evidence artifact, not an operational progress record.
- Keep progress request-scoped only: rejected because current posture and arrival context do not survive registry revisits, handoff, or later sessions.
## Decision: Persist only manual states and derive `not_reviewed` plus `changed_since_review`
### Rationale
Only two states require durable operator intent: `reviewed` and `follow_up_needed`. `not_reviewed` can be derived from the absence of a matching active record, and `changed_since_review` can be derived from a current-fingerprint mismatch. Persisting all four would add state surface without adding new behavior.
### Alternatives considered
- Persist all visible UI states: rejected because `not_reviewed` and `changed_since_review` are derivative display outcomes, not independent product truth.
- Persist a generic workflow status family such as `open`, `in_progress`, `blocked`, `done`: rejected because the spec explicitly keeps workflow orchestration out of scope.
## Decision: Use one active row per workspace, tenant, and concern family, and resolve prior rows on superseding writes
### Rationale
The feature needs one coherent current review record for each workspace, tenant, and concern family. Resolving prior active rows and inserting a new active row on each superseding mutation preserves lightweight historical continuity without requiring a formal review timeline or collaboration engine.
### Alternatives considered
- Overwrite one row in place forever: rejected because it erases minimal continuity and makes later stale-detection harder to reason about.
- Create unbounded episode history every time the current concern disappears: rejected because the spec explicitly avoids complex review-epoch history and background cleanup machinery.
## Decision: Build fingerprints only from stable existing concern truth
### Rationale
`TenantBackupHealthResolver`, `TenantBackupHealthAssessment`, `RestoreSafetyResolver`, and the current portfolio-triage concern-family outputs already provide the stable state and reason inputs needed to detect meaningful change. Fingerprints should be built from those bounded values and supporting stable keys, not from translated copy, timestamps, or free-text.
### Alternatives considered
- Fingerprint raw rendered copy: rejected because wording and translation changes would create false `changed_since_review` results.
- Fingerprint every available diagnostic field: rejected because volatile or cosmetic values would cause churn and make review state noisy.
## Decision: Batch-load review state alongside existing posture batch resolvers
### Rationale
`WorkspaceOverviewBuilder` already batches backup-health and recovery-evidence truth for visible tenants, and `TenantResource` already reuses that posture truth at list level. The new review-state resolver should follow the same shape: load all active review rows for the current workspace and tenant set in one query, key them by tenant and concern family, and combine them with already-batched posture truth.
### Alternatives considered
- Resolve review state per row inside table columns or widget views: rejected because it would create N+1 behavior on `/admin` and `/admin/tenants`.
- Add a new materialized portfolio summary table: rejected because it would persist convenience projections rather than independent truth.
## Decision: Reuse BadgeCatalog, `UiEnforcement`, and existing portfolio-arrival surfaces
### Rationale
The repo already centralizes badge semantics through `BadgeDomain` plus domain badge mappers and centralizes Filament RBAC behavior through `UiEnforcement`. Recent portfolio-triage work already added `PortfolioArrivalContext` and `TenantTriageArrivalContinuity`. Reusing those paths keeps review state visually and behaviorally consistent with the rest of the operator product.
### Alternatives considered
- Add page-local badge colors or hand-built status pills: rejected because BADGE-001 forbids ad hoc status mappings.
- Add raw capability checks or role-string checks inline on actions: rejected because RBAC-UX requires canonical capability registry use and central helpers.
- Add a separate queue page for review-state actions: rejected because the operator flow already lives in overview, registry, and tenant arrival continuity.
## Decision: Keep review-state mutations lightweight, preview-confirmed, DB-only, and auditable without `OperationRun`
### Rationale
The new actions mutate only TenantPilot-internal portfolio progress and complete well below the long-running threshold. They should not start an `OperationRun` or call Microsoft Graph. To satisfy the constitution's write-safety expectations without creating a separate workflow engine, the UI can require a bounded pre-execution preview plus explicit confirmation before the service writes the new review state. The service can then emit bounded `AuditLog` entries via existing audit infrastructure while still keeping audit timeline UI out of scope.
### Alternatives considered
- Add an `OperationRun` for each review-state mutation: rejected because the actions are fast, local, and not operationally meaningful enough for Monitoring.
- Skip auditing entirely: rejected because the codebase already has lightweight audit infrastructure for internal writes and the constitution expects traceability for write paths.
- Add a dedicated review-state timeline page: rejected because the product decision is to stay lightweight and non-governance-oriented.

View File

@ -0,0 +1,276 @@
# Feature Specification: Portfolio Triage Review State and Operator Progress
**Feature Branch**: `[189-portfolio-triage-review-state]`
**Created**: 2026-04-10
**Status**: Draft
**Input**: User description: "Spec 189 - Portfolio Triage Review State / Operator Progress Tracking"
## Spec Scope Fields *(mandatory)*
- **Scope**: workspace
- **Primary Routes**:
- `/admin` as the workspace overview where backup-attention and recovery-attention surfaces gain current-set progress semantics
- `/admin/tenants` as the canonical portfolio triage list that shows review state, supports review-state filtering, and preserves concern-family focus
- `/admin/tenants/{tenant}` as the existing tenant-registry inspect route that remains the canonical registry detail path
- `/admin/t/{tenant}` as the canonical tenant dashboard arrival surface where the triage continuity block shows current review state and exposes lightweight progress actions
- `/admin/t/{tenant}/backup-sets` and `/admin/t/{tenant}/restore-runs` as deeper existing backup and recovery follow-up surfaces that remain authoritative for domain truth after the triage action is recorded
- **Data Ownership**:
- Existing backup-health and recovery-evidence posture remain derived tenant truth built from current backup, restore, and workspace overview aggregation layers
- A new persisted triage-review record becomes the source of truth for whether one workspace has already reviewed one tenant for one concern family, because that operational truth must survive navigation and time
- The new record is workspace-shared but tenant-owned in storage terms because it is attached to one tenant and one concern family inside one workspace; it is not a formal review artifact, evidence artifact, or policy-governance record
- Workspace progress summaries and registry badges remain derived views over the combination of current concern truth and the persisted triage-review record; this feature does not create a second posture model or a formal review workflow layer
- **RBAC**:
- Workspace membership remains required to render `/admin` and `/admin/tenants`, and tenant membership remains required for `/admin/t/{tenant}` and deeper tenant surfaces
- Review-state visibility follows the same visible-tenant and visible-concern boundaries as portfolio triage; out-of-scope tenants and concern hints remain hidden under deny-as-not-found rules
- Members with view access may see review-state badges and progress, but only members with the canonical server-side capability to update portfolio triage review state, or an equivalent existing operator-level capability mapped through the capability registry, may mark reviewed or follow-up needed
- Non-members remain `404`; in-scope members lacking the mutation capability receive `403` on review-state mutations
## UI/UX Surface Classification *(mandatory when operator-facing surfaces are changed)*
| Surface | Surface Type | Primary Inspect/Open Model | Row Click | Secondary Actions Placement | Destructive Actions Placement | Canonical Collection Route | Canonical Detail Route | Scope Signals | Canonical Noun | Critical Truth Visible by Default | Exception Type |
|---|---|---|---|---|---|---|---|---|---|---|---|
| Workspace overview current-set progress summary | Embedded status summary / drill-in surface | Explicit stat or summary CTA to a filtered tenant-registry slice | forbidden | Inline summary links only | none | `/admin` | `/admin/tenants` | Active workspace, concern family, visible affected-set scope | Triage progress / Review state | Reviewed `X/Y`, follow-up-needed count, and changed-since-review count for the current affected set | additive summary strip |
| Tenant registry triage list | CRUD / list-first resource | Full-row click remains the canonical inspect path for registry detail, while the existing tenant-open shortcut remains the fast triage continuation | required | One inline safe shortcut plus More, with review-state mutations inside More to preserve the action-surface contract | none | `/admin/tenants` | `/admin/tenants/{tenant}` | Active workspace, current concern-family focus, backup posture, recovery evidence, review state | Tenants / Tenant | Current concern truth and current review state stay visible together without merging | working-surface augmentation |
| Tenant dashboard arrival continuity block | Embedded arrival and progress control surface | Explicit inline actions inside the continuity block for review-state mutation and return-to-triage | forbidden | Inline actions and helper copy inside the block | none | `/admin/t/{tenant}` | Existing next-step surfaces remain `/admin/t/{tenant}/backup-sets` or `/admin/t/{tenant}/restore-runs` | Workspace context, tenant context, arrival source, concern family, review state | Triage review / Review state | Why the operator is here, whether it was already reviewed, and whether it changed since review | additive continuity control |
## Operator Surface Contract *(mandatory when operator-facing surfaces are changed)*
| Surface | Primary Persona | Surface Type | Primary Operator Question | Default-visible Information | Diagnostics-only Information | Status Dimensions Used | Mutation Scope | Primary Actions | Dangerous Actions |
|---|---|---|---|---|---|---|---|---|---|
| Workspace overview current-set progress summary | Workspace operator | Embedded portfolio summary | How far through the current affected set are we, and which bucket still needs attention? | Concern-family label, reviewed count, affected-set total, follow-up-needed count, changed-since-review count | Raw fingerprints, low-level resolver inputs, inactive historical rows | concern family, current affected-set size, review state, portfolio urgency | none; read-only summary surface | Open filtered registry slices for not reviewed, follow-up needed, or changed since review | none |
| Tenant registry triage list | Workspace operator | List-first working surface | Which visible affected tenants are untouched, already checked, still needing follow-up, or changed since review? | Tenant identity, backup posture, recovery evidence, review state, current slice focus, bounded reviewer or timestamp context when available | Raw snapshot JSON, fingerprint ingredients, inactive state history | backup posture, recovery evidence, review state, triage priority | TenantPilot only for review-state mutations; read-only for posture truth | Open tenant, keep triage filters, mark reviewed, mark follow-up needed | none |
| Tenant dashboard arrival continuity block | Workspace operator arriving from triage | Embedded continuity and mutation surface | Why was this tenant opened, has this concern already been checked, and can I close or park it now? | Arrival reason, triggering concern family, current review state, last reviewed actor or time when available, return target | Fingerprint delta detail, low-level concern payloads, inactive prior records | arrival reason, concern family, review state, current-vs-reviewed drift | TenantPilot only for review-state mutation; deeper next-step links stay read-only navigation | Mark reviewed, Mark follow-up needed, Return to triage, Open next-step follow-up surface | none |
## Proportionality Review *(mandatory when structural complexity is introduced)*
- **New source of truth?**: yes
- **New persisted entity/table/artifact?**: yes
- **New abstraction?**: yes
- **New enum/state/reason family?**: yes
- **New cross-domain UI framework/taxonomy?**: no
- **Current operator problem**: The product can already identify affected tenants and explain why one tenant was opened, but it still cannot tell the operator which affected tenants were already checked, which ones still need follow-up, or which ones became relevant again after a prior review.
- **Existing structure is insufficient because**: Current posture truth and arrival context are intentionally read-first and request-scoped. They can explain concern state, but they do not persist lightweight operator progress across sessions, registry revisits, or team handoff.
- **Narrowest correct implementation**: Introduce one lightweight persisted triage-review record per workspace, tenant, and concern family, derive `not reviewed` and `changed since review` around that record, and reuse existing overview, registry, and arrival surfaces rather than creating notes, assignments, review packs, or a workflow engine.
- **Ownership cost**: One small persisted review-state model, a stable fingerprint rule, bounded preloading and derivation logic, additive UI badges and actions on existing surfaces, and focused regression coverage for state derivation, progress counts, and RBAC.
- **Alternative intentionally rejected**: Reusing formal `TenantReview` or `ReviewPack` artifacts, introducing user-specific queues, or adding a broader case-management workflow were rejected because they overshoot the immediate portfolio-triage need and would create a heavier governance layer than this release requires.
- **Release truth**: current-release portfolio-triage workflow hardening
## User Scenarios & Testing *(mandatory)*
### User Story 1 - Record That A Concern Was Checked (Priority: P1)
As a workspace operator, I want to mark the current tenant concern as reviewed or follow-up needed from the tenant context so that the portfolio queue reflects work I already did.
**Why this priority**: Without a persisted action at the point of review, the operator still has to track progress outside the product.
**Independent Test**: Can be fully tested by opening a tenant from portfolio triage, marking the current concern reviewed or follow-up needed, and verifying that the registry and overview immediately reflect the new state for the same concern family.
**Acceptance Scenarios**:
1. **Given** a tenant is currently affected for backup health and has no active review record, **When** an authorized operator opens the `Mark reviewed` preview, confirms the `TenantPilot only` scope, and submits it from the arrival continuity block, **Then** the tenant shows `Reviewed` for backup health and stores the current fingerprint and review timestamp.
2. **Given** a tenant is currently affected for recovery evidence and still needs additional work, **When** an authorized operator opens the `Mark follow-up needed` preview, confirms the `TenantPilot only` scope, and submits it, **Then** the tenant shows `Follow-up needed` for that recovery concern while the underlying posture remains unchanged.
---
### User Story 2 - Work The Remaining Set From The Registry (Priority: P1)
As a workspace operator, I want the tenant registry to show and filter review state so that I can quickly focus on untouched or reappeared tenants.
**Why this priority**: Portfolio triage becomes operationally useful only if the registry can separate completed work from the remaining work.
**Independent Test**: Can be fully tested by seeding affected tenants with mixed review states, opening `/admin/tenants`, and verifying the review-state column, state filters, and concern-family focus behavior.
**Acceptance Scenarios**:
1. **Given** the current tenant-registry slice is focused on backup health, **When** the operator filters for `Not reviewed`, **Then** only backup-affected tenants without an active matching review record remain.
2. **Given** the current tenant-registry slice is mixed across backup and recovery concerns, **When** the review-state column renders, **Then** each row identifies which concern family its displayed review state refers to instead of showing an ambiguous shared state.
---
### User Story 3 - Re-review Tenants When The Concern Changes (Priority: P1)
As a workspace operator, I want a previously reviewed tenant to become visibly changed since review when the relevant concern changes so that stale review state does not hide renewed risk.
**Why this priority**: Review progress is unsafe if an old reviewed badge can survive a materially different current concern.
**Independent Test**: Can be fully tested by recording a review, changing the current concern fingerprint while the tenant remains affected, and verifying that the registry and dashboard show `Changed since review` until a new review is recorded.
**Acceptance Scenarios**:
1. **Given** a tenant was reviewed when backup health was `stale`, **When** the current backup concern changes to a different stable reason or state while the tenant remains affected, **Then** the system shows `Changed since review` instead of `Reviewed`.
2. **Given** a tenant was marked `Follow-up needed` for recovery evidence, **When** the current recovery fingerprint changes, **Then** the changed-since-review state overrides the prior manual state until the operator reviews the new situation.
---
### User Story 4 - See Honest Progress For The Current Affected Set (Priority: P2)
As a workspace operator, I want the workspace overview to show progress only for the tenants that are currently affected so that the product tells me what is left right now rather than what was reviewed historically.
**Why this priority**: Progress is misleading if calm or resolved tenants keep inflating reviewed counts or if reviewed badges are mistaken for fixed posture.
**Independent Test**: Can be fully tested by mixing currently affected tenants, resolved tenants, and stale-review tenants, then verifying that the overview summary counts only current affected tenants and keeps posture separate from review state.
**Acceptance Scenarios**:
1. **Given** a workspace has nine currently affected tenants, **When** the overview progress summary renders, **Then** the reviewed total and bucket counts are derived only from those nine tenants and exclude calm or resolved tenants.
2. **Given** a tenant remains weak but is marked `Reviewed`, **When** the registry or overview renders, **Then** the product still shows the weak posture separately and does not imply that the issue is fixed.
### Edge Cases
- A tenant can be affected in both `backup_health` and `recovery_evidence` at the same time; the product must keep one review state per concern family and must not let one family's review mark the other family reviewed.
- The registry can be opened without a single concern-family focus; in that mixed slice the displayed review state must name the concern family it refers to instead of pretending there is one global tenant review state, and it must choose that family by reusing the existing worst-first portfolio concern priority rules already used for mixed registry triage.
- A concern can disappear after review and later reappear; prior resolved or inactive review records must not make the tenant count as reviewed for the new current affected set unless the current fingerprint matches an active record.
- Two operators may update the same workspace-shared review state close together; V1 uses one coherent active state with last-write-wins semantics and does not introduce per-change collaboration workflows.
- Cosmetic text or volatile timestamps may change while the material concern remains the same; fingerprinting must not mark the tenant changed since review unless stable concern inputs changed.
- A user may be allowed to view the registry and dashboard but not mutate review state; review-state truth remains visible while mutation actions degrade safely or fail with `403`.
## Requirements *(mandatory)*
**Constitution alignment (required):** This feature adds lightweight TenantPilot-only DB mutations and one new persisted review-state truth, but it introduces no Microsoft Graph calls, no remote writes, and no long-running work. Review-state updates are internal triage-progress writes only, so they do not require an `OperationRun`. Each mutation still uses a bounded pre-execution preview plus explicit confirmation that shows the concern family, current review state, target manual state, and `TenantPilot only` scope. V1 also does not introduce a dedicated per-change audit timeline for these updates because the product decision is to stay lightweight and non-governance-oriented.
**Constitution alignment (PROP-001 / ABSTR-001 / PERSIST-001 / STATE-001 / BLOAT-001):** New persistence is justified because current operator workflow now requires progress that survives navigation and time, while request-scoped posture truth and arrival context do not. The persisted addition remains narrow: one active triage-review record per workspace, tenant, and concern family plus derived `not reviewed` and `changed since review` states. No review-pack layer, no generic workflow engine, and no formal evidence model are introduced.
**Constitution alignment (OPS-UX):** No `OperationRun`, progress widget for execution, or terminal operation notification is introduced. These are quick internal DB writes whose effect is immediately reflected in existing portfolio surfaces.
**Constitution alignment (Read/Write Separation):** Because these writes are local, reversible, and sub-2-second, the required preview or dry-run is implemented as a bounded pre-execution summary plus explicit confirmation on the existing Filament actions rather than as a separate remote dry-run pipeline.
**Constitution alignment (RBAC-UX):** Authorization spans the workspace plane at `/admin` and `/admin/tenants` plus the tenant plane at `/admin/t/{tenant}`. Non-members or actors outside workspace or tenant scope remain `404`. Established members without the review-state mutation capability receive `403` on `Mark reviewed` and `Mark follow-up needed`. Server-side authorization remains the source of truth for every mutation. Existing tenant resource global search behavior remains tenant-safe and unchanged; review state is not added as a new cross-tenant search hint.
**Constitution alignment (OPS-EX-AUTH-001):** Not applicable. No auth-handshake behavior changes.
**Constitution alignment (BADGE-001):** The new status-like labels `Not reviewed`, `Reviewed`, `Follow-up needed`, and `Changed since review` must use centralized badge and label semantics rather than page-local color decisions. Tests must cover any new centralized mappings so review-state meaning stays consistent across overview, registry, and arrival surfaces.
**Constitution alignment (UI-FIL-001):** The feature reuses existing Filament tables, filters, actions, stats, sections, and badge-like shared primitives. It must avoid local replacement markup for review-state badges or action blocks. Semantic emphasis comes from existing Filament primitives and central label mappings rather than page-specific border or color language.
**Constitution alignment (UI-NAMING-001):** Operator-facing vocabulary stays explicit and narrow: `Review state`, `Mark reviewed`, `Mark follow-up needed`, and `Changed since review`. The wording must not borrow formal-governance language such as `Tenant review complete`, `approved`, `resolved`, or `evidence accepted`.
**Constitution alignment (UI-CONST-001 / UI-SURF-001 / UI-HARD-001 / UI-EX-001 / UI-REVIEW-001):** The tenant registry remains the canonical collection route and keeps full-row click as its one primary inspect model. Review-state mutations on the registry live in overflow, not as competing primary opens. The tenant dashboard arrival continuity block is an additive mutation surface with explicit inline actions. No redundant view affordance, no destructive control, and no new cross-domain review page are introduced.
**Constitution alignment (OPSURF-001):** Default-visible content on `/admin` and `/admin/tenants` remains operator-first: current concern truth, current review state, and remaining work. Diagnostics such as fingerprints and snapshots stay secondary. Every review-state mutation must communicate that it changes TenantPilot-only portfolio progress, not Microsoft tenant posture or configuration.
**Constitution alignment (UI-SEM-001 / LAYER-001 / TEST-TRUTH-001):** Direct mapping from current posture alone to operator progress is insufficient because posture truth answers what is wrong, not whether someone already checked it. The new triage-review layer is therefore allowed, but it must stay narrow, concern-family-bound, and clearly secondary to posture truth. Tests must focus on business consequences such as wrong counts, stale reviewed badges, family conflation, and false fixed semantics.
**Constitution alignment (Filament Action Surfaces):** The Action Surface Contract remains satisfied. `TenantResource` keeps exactly one primary inspect model, retains its existing safe tenant-open shortcut, and places the new review-state mutations in overflow. `TenantDashboard` gets additive inline actions inside the existing continuity block, not redundant header actions. No empty action groups or destructive placements are introduced. UI-FIL-001 remains satisfied with no exception required.
**Constitution alignment (UX-001 - Layout & Information Architecture):** No new create or edit forms are introduced. The tenant registry continues to provide search, sort, filters, and meaningful empty states for core dimensions. The tenant dashboard uses existing cards or sections for the continuity block rather than introducing a new standalone screen. View or detail surfaces remain operator-first and do not turn review state into a full workflow board.
### Functional Requirements
- **FR-189-001**: The system MUST persist one active triage-review record per workspace, tenant, and concern family and MUST allow prior records to become inactive instead of overwriting historical continuity in place.
- **FR-189-002**: Persisted manual review states MUST be limited to `reviewed` and `follow_up_needed`; `not_reviewed` and `changed_since_review` MUST remain derived states.
- **FR-189-003**: When a currently affected tenant has no active triage-review record for the current concern family, the system MUST derive `not_reviewed`.
- **FR-189-004**: Authorized operators MUST be able to mark a currently affected tenant concern as `reviewed` from portfolio triage context.
- **FR-189-005**: Authorized operators MUST be able to mark a currently affected tenant concern as `follow_up_needed` from portfolio triage context.
- **FR-189-006**: Setting either manual state MUST store the current concern family, the current stable concern fingerprint, the review timestamp, the reviewing actor when available, and a lightweight bounded snapshot sufficient to explain what was reviewed without becoming a formal evidence artifact.
- **FR-189-007**: The stored fingerprint MUST be based only on stable concern inputs such as concern family, concern state, stable reason code, and stable supporting keys; it MUST NOT depend on cosmetic labels, free-text notes, or volatile timestamps.
- **FR-189-008**: When the current concern still exists but the current fingerprint differs from the stored review fingerprint, the system MUST derive `changed_since_review` (`stale_review`) for that workspace, tenant, and concern family.
- **FR-189-009**: `changed_since_review` MUST override displayed `reviewed` or `follow_up_needed` until an authorized operator records a new review against the current fingerprint.
- **FR-189-010**: Review state MUST never overwrite, hide, or downgrade the separately displayed backup posture or recovery evidence truth; a tenant may remain weak and reviewed at the same time.
- **FR-189-011**: Backup-health and recovery-evidence review states MUST be stored and resolved independently. Reviewing one concern family MUST NOT mark the other family reviewed.
- **FR-189-012**: The tenant registry MUST display the resolved review state for each currently affected row and MUST identify the concern family that state refers to whenever the current view is not already family-specific.
- **FR-189-013**: When `/admin/tenants` is in a family-specific triage slice, whether from workspace drilldown or explicit filters, the review-state column and review-state mutations MUST operate on that same concern family.
- **FR-189-014**: When `/admin/tenants` is in a mixed slice with more than one active concern family, the review-state presentation MUST follow the row's highest-priority active concern family according to the existing worst-first portfolio concern priority rules and MUST label that family explicitly.
- **FR-189-015**: The tenant registry MUST support filtering the current visible affected set by `not_reviewed`, `reviewed`, `follow_up_needed`, and `changed_since_review`.
- **FR-189-016**: Workspace overview surfaces MUST show minimal progress summary for each current concern family or active triage slice, including at least `Reviewed X/Y`, `Follow-up needed`, and `Changed since review`, and those counts MUST be derived only from the current visible affected set.
- **FR-189-017**: Tenants whose relevant concern no longer exists MUST NOT remain counted inside current-set progress or current review-state buckets for that concern family.
- **FR-189-018**: The tenant dashboard arrival continuity block MUST show the current review state for the triggering concern family and MUST offer `Mark reviewed` and `Mark follow-up needed` actions when the current session arrived from portfolio triage with valid concern context.
- **FR-189-019**: Before any review-state mutation executes, the product MUST show a bounded pre-execution preview and explicit confirmation describing the concern family, current review state, target manual state, and `TenantPilot only` mutation scope.
- **FR-189-020**: Generic tenant browsing sessions without portfolio-triage context MUST NOT show triage-review actions, mutation affordances, or progress language that implies a queue.
- **FR-189-021**: When an active triage-review record exists, the product MUST expose the last reviewed time and, when known, the last reviewing actor in a bounded way that supports workspace-shared progress without turning into a comment thread or audit timeline.
- **FR-189-022**: Review-state visibility MUST remain available to entitled viewers even when they lack mutation capability; mutation attempts from non-members MUST resolve as `404`, and mutation attempts from in-scope members without capability MUST resolve as `403`.
- **FR-189-023**: Review-state loading for the registry and workspace progress summaries MUST remain query-bounded and MUST avoid uncontrolled per-row resolver fanout or per-row fingerprint recomputation that would cause list-level N+1 behavior.
- **FR-189-024**: Review-state mutation MUST be a TenantPilot-only workflow mutation that does not start an `OperationRun`, does not call Microsoft Graph, and does not create a formal `TenantReview`, `ReviewPack`, comment thread, assignment, or workflow ticket.
- **FR-189-025**: The feature MUST ship without person-specific queues, collaboration conflict resolution, notes, due dates, SLAs, or per-episode review history beyond the minimal active and inactive triage-review record needed to keep current state honest.
- **FR-189-026**: Regression coverage MUST prove persistence semantics, fingerprint stability, changed-since-review derivation, family separation, registry badging, registry filtering, workspace progress counts, arrival-block actions, generic-session suppression, preview-and-confirmation semantics, honest posture-plus-review coexistence, and RBAC-safe view-versus-mutate behavior.
## Review-State Semantics
- **Active triage-review record**: The current workspace-shared progress record for one tenant and one concern family. It stores the operator's last manual state plus the fingerprint that was current when the review was recorded.
- **Inactive triage-review record**: A prior record whose concern no longer matches the current affected set or was intentionally superseded. It exists only to keep continuity lightweight and does not create an operator-visible review timeline.
- **Current affected set**: The visible tenants whose current backup-health or recovery-evidence posture still matches the concern-family attention rules used by workspace overview and tenant registry triage.
- **Derived state precedence**: `changed_since_review` overrides `follow_up_needed`, which overrides `reviewed`, which overrides `not_reviewed` for currently affected tenants.
- **Current-set progress**: The overview summary over the current visible affected set for one concern family or active triage slice; calm or resolved tenants are excluded.
## Fingerprint Boundaries
- The fingerprint must stay stable across repeated reads of the same material concern situation.
- The fingerprint must change when the current concern family, material concern state, stable reason code, or stable supporting posture keys change.
- The fingerprint must ignore cosmetic wording, translated copy, timestamps, and other volatile display-only values.
- Backup-health fingerprints may include stable posture values such as `absent`, `stale`, `degraded`, `healthy`, schedule-follow-up family, or freshness family.
- Recovery-evidence fingerprints may include stable posture values such as `weakened`, `unvalidated`, `no_recent_issues_visible`, primary concern reason, or restore-evidence concern family.
## 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 |
|---|---|---|---|---|---|---|---|---|---|---|
| Workspace overview progress summary | `app/Filament/Pages/WorkspaceOverview.php`, `app/Filament/Widgets/Workspace/WorkspaceSummaryStats.php`, `app/Filament/Widgets/Workspace/WorkspaceNeedsAttention.php` | none added | Explicit stat or summary CTA to a filtered registry slice | none | none | Existing calm-state CTA behavior remains unchanged | n/a | n/a | no dedicated triage-review audit requirement | Read-only summary only. No destructive action or new action-group exception is introduced. |
| Tenant registry triage list | `app/Filament/Resources/TenantResource.php`, `app/Filament/Resources/TenantResource/Pages/ListTenants.php` | Existing header actions remain unchanged | Full-row click remains the canonical inspect affordance | Existing `openTenant` safe shortcut remains visible; `Mark reviewed` and `Mark follow-up needed` live in More or equivalent overflow | No new bulk mutation required in V1 | Existing empty-state CTA remains unchanged | Existing tenant detail header actions remain unchanged | Existing create or edit flows remain unchanged | no dedicated triage-review audit requirement | Action Surface Contract remains satisfied because review-state mutations do not compete with the primary open model, are not destructive, and open a bounded preview-and-confirmation step before the write executes. |
| Tenant dashboard arrival continuity block | `app/Filament/Pages/TenantDashboard.php` plus the existing arrival continuity surface introduced by Spec 187 | none added | Explicit inline `Return to triage` and next-step links only | n/a | n/a | none | n/a | n/a | no dedicated triage-review audit requirement | `Mark reviewed` and `Mark follow-up needed` live inside the continuity block as additive inline actions, open a bounded preview-and-confirmation step before write, and stay suppressed for generic browsing sessions without valid triage context. |
## Key Entities *(include if feature involves data)*
- **Triage-review record**: The persisted workspace-shared progress record for one tenant and one concern family, including manual state, review fingerprint, bounded snapshot, and active or inactive lifecycle.
- **Concern fingerprint**: The stable representation of the current material concern situation used to decide whether a prior review still applies.
- **Concern-family focus**: The backup-health or recovery-evidence slice that tells the registry and tenant arrival block which review state they are resolving or mutating.
- **Current affected set**: The visible tenant population currently matching one concern family's triage rules.
- **Progress summary**: The derived count view over the current affected set showing how many items are reviewed, need follow-up, or changed since review.
## Assumptions
- Existing workspace overview, tenant registry triage, and tenant dashboard arrival-context slices already expose enough stable concern-family and reason context to support bounded fingerprint generation without inventing a second posture model.
- V1 review-state scope is limited to the current portfolio-triage concern families `backup_health` and `recovery_evidence`. Extending the concept to other domains requires a follow-up spec.
- Workspace-shared review state uses one coherent active record with last-write-wins behavior rather than a multi-operator merge or conflict-resolution layer.
- A lightweight bounded snapshot is sufficient for V1; rich notes, comments, evidence attachments, and long-form review rationale remain intentionally out of scope.
## Dependencies
- Existing workspace overview backup-attention and recovery-attention semantics from the current portfolio-triage work
- Existing tenant registry triage filters, worst-first ordering, and tenant-open continuity
- Existing tenant dashboard arrival continuity block introduced by Spec 187
- Existing backup-health and recovery-evidence truth used by workspace overview and tenant registry triage
- Existing RBAC helpers, capability registry, and tenant-safe route resolution patterns
## Out of Scope and Follow-up
- No formal `TenantReview`, `ReviewPack`, or evidence-artifact workflow
- No notes, comments, or rich-text rationale on review-state records
- No assignments, owner queues, or team delegation workflow
- No due dates, reminders, SLAs, or escalation mechanics
- No automatic ticket creation or external task synchronization
- No multi-operator conflict-resolution layer or per-episode review history beyond active and inactive continuity records
- No broader workflow engine with `in progress`, `blocked`, `done`, or other cross-domain states
## Risks
- If review state is rendered more prominently than posture truth, operators may misread `Reviewed` as `Fixed`.
- If fingerprint rules are too sensitive, tenants will churn into `Changed since review` because of cosmetic differences instead of meaningful posture change.
- If fingerprint rules are too weak, materially different concern situations will keep stale `Reviewed` or `Follow-up needed` badges.
- If workspace-shared state lacks reviewer and timestamp context, team handoff can become confusing even in a lightweight model.
- If registry loading resolves review state per row without batching, portfolio triage performance can regress under moderate affected-set sizes.
## Definition of Done
This feature is complete when:
- a persisted portfolio triage review state exists for workspace, tenant, and concern-family combinations,
- the tenant registry shows review state beside posture truth without conflating the two,
- the tenant registry can filter by review state for the current visible affected set,
- authorized operators can mark `Reviewed` and `Follow-up needed` from tenant triage context,
- fingerprint-based `Changed since review` derivation works and overrides stale prior manual states,
- workspace overview surfaces show lightweight progress for the current affected set,
- backup-health and recovery-evidence review state remain separate,
- formal review, notes, assignments, and workflow-engine behavior remain absent,
- targeted regression coverage is green, and
- the shipped UI remains honest that `Reviewed` does not mean `Fixed`.
## Success Criteria *(mandatory)*
### Measurable Outcomes
- **SC-189-001**: In acceptance review, a workspace operator can determine within 10 seconds from `/admin/tenants` which currently affected tenants are `Not reviewed`, `Reviewed`, `Follow-up needed`, or `Changed since review`.
- **SC-189-002**: In 100% of covered mutation scenarios, marking `Reviewed` or `Follow-up needed` from tenant triage context updates the matching registry row and current-set progress summary without requiring an external note-taking step.
- **SC-189-003**: In 100% of covered fingerprint-change scenarios, a previously reviewed or follow-up-needed tenant shows `Changed since review` until a new review is recorded against the current fingerprint.
- **SC-189-004**: In 100% of covered overview and registry scenarios, current-set progress counts include only currently affected visible tenants and exclude calm or resolved tenants.
- **SC-189-005**: In 100% of covered truthfulness scenarios, posture truth and review state appear together without any label or layout implying that review state means the tenant is fixed, approved, or recovery-proven.
- **SC-189-006**: In RBAC regression coverage, entitled viewers can see review-state truth while non-members receive `404` and in-scope members without mutation capability receive `403` for review-state mutations.
- **SC-189-007**: Targeted query-bounded regression coverage shows that representative affected-set registry rendering does not degrade into uncontrolled per-row review-state query fanout.

View File

@ -0,0 +1,267 @@
# Tasks: Portfolio Triage Review State and Operator Progress
**Input**: Design documents from `/specs/189-portfolio-triage-review-state/`
**Prerequisites**: `plan.md`, `spec.md`, `research.md`, `data-model.md`, `contracts/portfolio-triage-review-state.logical.openapi.yaml`, `quickstart.md`
**Tests**: Tests are REQUIRED for this feature. Use Pest unit coverage for fingerprinting, state resolution, and badge semantics; Filament feature coverage for tenant-dashboard continuity, tenant-registry review-state rendering, and workspace-overview progress; and RBAC coverage for `404` versus `403` mutation semantics.
**Operations**: This feature introduces no `OperationRun`, queue, scheduler, or remote call. Review-state writes stay TenantPilot-only and DB-only, but they must record bounded `AuditLog` entries through the existing audit infrastructure and execute only after a bounded pre-execution preview plus explicit confirmation.
**RBAC**: Existing workspace membership and tenant visibility remain authoritative. Tasks must preserve deny-as-not-found `404` behavior for non-members, `403` behavior for in-scope members lacking the mutation capability, canonical capability-registry usage, and visible-but-disabled UI where the surface contract requires it.
**Operator Surfaces**: The affected operator surfaces are workspace overview progress summaries, the tenant registry triage list, and the tenant dashboard arrival continuity block.
**Filament UI Action Surfaces**: Full-row click remains the registry inspect model, the existing tenant-open shortcut remains the only inline safe shortcut, registry review-state mutations live in overflow, and tenant-dashboard review-state mutations live only inside the continuity block.
**Filament UI UX-001**: No create, edit, or view-form layouts change. This slice is limited to additive status rendering, filters, overflow actions, and dashboard continuity controls.
**Badges**: Review-state semantics must use `BadgeDomain` plus a centralized domain mapper. No page-local status-pill styling or ad hoc label mapping is allowed.
**Organization**: Tasks are grouped by user story so each story can be implemented and verified independently once the shared triage-review foundation is in place.
## Phase 1: Setup (Shared Triage-Review Harness)
**Purpose**: Prepare reusable fixtures and acceptance scaffolds for mixed-family triage-review scenarios shared across all stories.
- [X] T001 [P] Extend mixed-family and superseded-review fixture helpers in `apps/platform/tests/Feature/Concerns/BuildsPortfolioTriageFixtures.php`
- [X] T002 [P] Stage focused review-state acceptance scaffolds in `apps/platform/tests/Feature/Filament/TenantDashboardArrivalContextTest.php`, `apps/platform/tests/Feature/Filament/TenantRegistryTriageReviewStateTest.php`, and `apps/platform/tests/Feature/Filament/WorkspaceOverviewTriageReviewProgressTest.php`
**Checkpoint**: Shared fixture and acceptance seams exist for dashboard, registry, and overview review-state work.
---
## Phase 2: Foundational (Blocking Triage-Review Core)
**Purpose**: Establish the persisted review-state model, deterministic fingerprinting, batch state resolution, and centralized badge mapping that every story depends on.
**⚠️ CRITICAL**: No user story work should begin until this phase is complete.
- [X] T003 [P] Add deterministic fingerprint and concern-family separation coverage in `apps/platform/tests/Unit/Support/PortfolioTriage/TenantTriageReviewFingerprintTest.php`
- [X] T004 [P] Add active-row precedence and current-set summary coverage in `apps/platform/tests/Unit/Support/PortfolioTriage/TenantTriageReviewStateResolverTest.php`
- [X] T005 [P] Add centralized review-state badge mapping coverage in `apps/platform/tests/Unit/Badges/TenantTriageReviewStateBadgesTest.php`
- [X] T006 Create the `tenant_triage_reviews` schema in `apps/platform/database/migrations/*_create_tenant_triage_reviews_table.php`
- [X] T007 [P] Create the persisted review-state model and factory in `apps/platform/app/Models/TenantTriageReview.php` and `apps/platform/database/factories/TenantTriageReviewFactory.php`
- [X] T008 [P] Implement deterministic concern fingerprint generation in `apps/platform/app/Support/PortfolioTriage/TenantTriageReviewFingerprint.php`
- [X] T009 Implement batch review-state resolution and current-set progress aggregation in `apps/platform/app/Support/PortfolioTriage/TenantTriageReviewStateResolver.php`
- [X] T010 Register centralized review-state badge semantics in `apps/platform/app/Support/Badges/BadgeDomain.php` and `apps/platform/app/Support/Badges/Domains/TenantTriageReviewStateBadge.php`
**Checkpoint**: One authoritative persisted review-state core exists and can be reused by dashboard, registry, and overview surfaces without N+1 fanout.
---
## Phase 3: User Story 1 - Record That A Concern Was Checked (Priority: P1) 🎯 MVP
**Goal**: Let authorized operators mark the current dashboard concern as reviewed or follow-up needed so progress survives navigation and time.
**Independent Test**: Open a tenant from portfolio triage, trigger the continuity-block preview for `Mark reviewed` or `Mark follow-up needed`, confirm the `TenantPilot only` scope, and verify the active triage-review record, bounded audit entry, and dashboard state update all reflect the new manual state.
### Tests for User Story 1
- [X] T011 [P] [US1] Extend tenant-dashboard continuity coverage for preview-and-confirmation semantics, valid triage-context gating, generic-session suppression, `Mark reviewed`, `Mark follow-up needed`, and bounded reviewer or timestamp context in `apps/platform/tests/Feature/Filament/TenantDashboardArrivalContextTest.php`
- [X] T012 [P] [US1] Add mutation authorization and audit coverage for non-member `404`, member-without-capability `403`, preview-confirmed successful writes, and no action execution without confirmation in `apps/platform/tests/Feature/Rbac/TriageReviewStateAuthorizationTest.php`
### Implementation for User Story 1
- [X] T013 [US1] Add the canonical triage-review mutation capability and audit action IDs in `apps/platform/app/Support/Auth/Capabilities.php` and `apps/platform/app/Support/Audit/AuditActionId.php`
- [X] T014 [US1] Implement `markReviewed()` and `markFollowUpNeeded()` with superseded-row resolution and `AuditRecorder` writes in `apps/platform/app/Services/PortfolioTriage/TenantTriageReviewService.php`
- [X] T015 [US1] Bind resolved review state, bounded preview-and-confirmation actions, `TenantPilot only` scope copy, and generic-session suppression into the dashboard continuity surface in `apps/platform/app/Filament/Widgets/Tenant/TenantTriageArrivalContinuity.php`, `apps/platform/resources/views/filament/widgets/tenant/triage-arrival-continuity.blade.php`, and `apps/platform/app/Filament/Pages/TenantDashboard.php`
- [X] T016 [US1] Enforce visible-but-disabled UI plus server-side mutation gating through `apps/platform/app/Support/Rbac/UiEnforcement.php` and `apps/platform/app/Filament/Widgets/Tenant/TenantTriageArrivalContinuity.php`
- [X] T017 [US1] Run focused US1 verification from `specs/189-portfolio-triage-review-state/quickstart.md` against `apps/platform/tests/Feature/Filament/TenantDashboardArrivalContextTest.php` and `apps/platform/tests/Feature/Rbac/TriageReviewStateAuthorizationTest.php`
**Checkpoint**: Operators can record review intent from the tenant dashboard, and writes are RBAC-safe, auditable, and workspace-shared.
---
## Phase 4: User Story 2 - Work The Remaining Set From The Registry (Priority: P1)
**Goal**: Make the tenant registry show and filter review state so operators can keep working the remaining affected set without losing posture truth.
**Independent Test**: Seed mixed review states, open `/admin/tenants`, and verify the review-state column, highest-priority concern-family labeling, preview-confirmed overflow actions, and all four `review_state` filters (`not_reviewed`, `reviewed`, `follow_up_needed`, `changed_since_review`) all behave correctly while backup posture and recovery evidence remain separately visible.
### Tests for User Story 2
- [X] T018 [P] [US2] Add registry column, highest-priority concern-family labeling, and all four `review_state` filters (`not_reviewed`, `reviewed`, `follow_up_needed`, `changed_since_review`) coverage in `apps/platform/tests/Feature/Filament/TenantRegistryTriageReviewStateTest.php`
- [X] T019 [P] [US2] Extend posture-truth coexistence, overflow action-surface placement, and preview-and-confirmation action coverage in `apps/platform/tests/Feature/Filament/TenantRegistryRecoveryTriageTest.php` and `apps/platform/tests/Feature/Guards/ActionSurfaceContractTest.php`
### Implementation for User Story 2
- [X] T020 [US2] Add review-state row rendering, mixed-family labeling, reuse of `TenantResource::portfolioConcernPriority()` for highest-priority concern selection, and overflow mutation actions with preview-and-confirmation in `apps/platform/app/Filament/Resources/TenantResource.php`
- [X] T021 [US2] Add all four `review_state` filters, current-slice concern-family handling, and empty-state context in `apps/platform/app/Filament/Resources/TenantResource/Pages/ListTenants.php`
- [X] T022 [US2] Reuse `TenantTriageReviewStateResolver` inside `apps/platform/app/Filament/Resources/TenantResource.php` to keep registry rendering query-bounded and posture truth separate from review state
- [X] T023 [US2] Run focused US2 verification from `specs/189-portfolio-triage-review-state/quickstart.md` against `apps/platform/tests/Feature/Filament/TenantRegistryTriageReviewStateTest.php` and `apps/platform/tests/Feature/Filament/TenantRegistryRecoveryTriageTest.php`
**Checkpoint**: The registry becomes a working triage surface for remaining review work without implying that `Reviewed` means `Fixed`.
---
## Phase 5: User Story 3 - Re-review Tenants When The Concern Changes (Priority: P1)
**Goal**: Turn stale manual review records into `Changed since review` when the current concern changes so old progress does not hide renewed risk.
**Independent Test**: Record a review, change the stable concern fingerprint while the tenant remains affected, and verify the dashboard and registry both show `Changed since review` until a new review is recorded.
### Tests for User Story 3
- [X] T024 [P] [US3] Extend stale-review precedence and fingerprint-mismatch coverage in `apps/platform/tests/Unit/Support/PortfolioTriage/TenantTriageReviewStateResolverTest.php`
- [X] T025 [P] [US3] Extend dashboard and registry stale-review rendering coverage in `apps/platform/tests/Feature/Filament/TenantDashboardArrivalContextTest.php` and `apps/platform/tests/Feature/Filament/TenantRegistryTriageReviewStateTest.php`
### Implementation for User Story 3
- [X] T026 [US3] Add `changed_since_review` precedence and mismatch handling in `apps/platform/app/Support/PortfolioTriage/TenantTriageReviewStateResolver.php`
- [X] T027 [US3] Refine stable fingerprint inputs for backup and recovery concern changes in `apps/platform/app/Support/PortfolioTriage/TenantTriageReviewFingerprint.php`
- [X] T028 [US3] Surface `Changed since review` labels and re-review affordances in `apps/platform/app/Filament/Resources/TenantResource.php`, `apps/platform/app/Filament/Widgets/Tenant/TenantTriageArrivalContinuity.php`, and `apps/platform/resources/views/filament/widgets/tenant/triage-arrival-continuity.blade.php`
- [X] T029 [US3] Run focused US3 verification from `specs/189-portfolio-triage-review-state/quickstart.md` against `apps/platform/tests/Unit/Support/PortfolioTriage/TenantTriageReviewFingerprintTest.php`, `apps/platform/tests/Unit/Support/PortfolioTriage/TenantTriageReviewStateResolverTest.php`, `apps/platform/tests/Feature/Filament/TenantDashboardArrivalContextTest.php`, and `apps/platform/tests/Feature/Filament/TenantRegistryTriageReviewStateTest.php`
**Checkpoint**: Previously reviewed tenants become visibly stale when the material concern changes, and re-review becomes the obvious next step.
---
## Phase 6: User Story 4 - See Honest Progress For The Current Affected Set (Priority: P2)
**Goal**: Show workspace-level progress only for the currently affected visible set so review counts stay honest and clearly separate from posture truth.
**Independent Test**: Seed currently affected, resolved, and stale-review tenants, open `/admin`, and verify the overview summaries count only the current visible affected set while still showing weak posture independently from review state.
### Tests for User Story 4
- [X] T030 [P] [US4] Add current-set-only progress and calm-tenant exclusion coverage in `apps/platform/tests/Feature/Filament/WorkspaceOverviewTriageReviewProgressTest.php`
- [X] T031 [P] [US4] Extend overview drilldown honesty and visible-tenant scoping coverage in `apps/platform/tests/Feature/Filament/WorkspaceOverviewSummaryMetricsTest.php`
### Implementation for User Story 4
- [X] T032 [US4] Extend current-set progress derivation with `TenantTriageReviewStateResolver` in `apps/platform/app/Support/Workspaces/WorkspaceOverviewBuilder.php`
- [X] T033 [US4] Render additive reviewed, follow-up-needed, and changed-since-review summaries in `apps/platform/app/Filament/Widgets/Workspace/WorkspaceSummaryStats.php` and `apps/platform/app/Filament/Widgets/Workspace/WorkspaceNeedsAttention.php`
- [X] T034 [US4] Keep overview review-state summaries operator-first and posture-truth-safe in `apps/platform/app/Support/Workspaces/WorkspaceOverviewBuilder.php` and `apps/platform/app/Filament/Pages/WorkspaceOverview.php`
- [X] T035 [US4] Run focused US4 verification from `specs/189-portfolio-triage-review-state/quickstart.md` against `apps/platform/tests/Feature/Filament/WorkspaceOverviewTriageReviewProgressTest.php` and `apps/platform/tests/Feature/Filament/WorkspaceOverviewSummaryMetricsTest.php`
**Checkpoint**: Workspace overview progress stays bounded to the current affected set and never overclaims that reviewed posture is fixed posture.
---
## Phase 7: Polish & Cross-Cutting Concerns
**Purpose**: Finalize guard coverage, performance protection, copy review, formatting, and the focused verification pack across all stories.
- [X] T036 [P] Add no-ad-hoc-badge and no diagnostic-warning guard coverage for the new review-state surfaces in `apps/platform/tests/Feature/Guards/NoAdHocStatusBadgesTest.php` and `apps/platform/tests/Feature/Guards/NoDiagnosticWarningBadgesTest.php`
- [X] T037 [P] Add query-shape regression coverage for registry and overview batch loading in `apps/platform/tests/Feature/Filament/TenantRegistryTriageReviewStateTest.php` and `apps/platform/tests/Feature/Filament/WorkspaceOverviewTriageReviewProgressTest.php`
- [X] T038 [P] Review `Verb + Object` copy and TenantPilot-only mutation-scope helper text in `apps/platform/app/Filament/Resources/TenantResource.php`, `apps/platform/app/Filament/Widgets/Tenant/TenantTriageArrivalContinuity.php`, and `apps/platform/resources/views/filament/widgets/tenant/triage-arrival-continuity.blade.php`
- [X] T039 [P] Run formatting with `cd apps/platform && ./vendor/bin/sail bin pint --dirty --format agent` using `specs/189-portfolio-triage-review-state/quickstart.md`
- [X] T040 Run the focused verification pack from `specs/189-portfolio-triage-review-state/quickstart.md` against `apps/platform/tests/Unit/Support/PortfolioTriage/TenantTriageReviewFingerprintTest.php`, `apps/platform/tests/Unit/Support/PortfolioTriage/TenantTriageReviewStateResolverTest.php`, `apps/platform/tests/Unit/Badges/TenantTriageReviewStateBadgesTest.php`, `apps/platform/tests/Feature/Filament/TenantDashboardArrivalContextTest.php`, `apps/platform/tests/Feature/Filament/TenantRegistryTriageReviewStateTest.php`, `apps/platform/tests/Feature/Filament/TenantRegistryRecoveryTriageTest.php`, `apps/platform/tests/Feature/Filament/WorkspaceOverviewTriageReviewProgressTest.php`, `apps/platform/tests/Feature/Filament/WorkspaceOverviewSummaryMetricsTest.php`, `apps/platform/tests/Feature/Rbac/TriageReviewStateAuthorizationTest.php`, `apps/platform/tests/Feature/Guards/ActionSurfaceContractTest.php`, `apps/platform/tests/Feature/Guards/NoAdHocStatusBadgesTest.php`, and `apps/platform/tests/Feature/Guards/NoDiagnosticWarningBadgesTest.php`
---
## Dependencies & Execution Order
### Phase Dependencies
- **Setup (Phase 1)**: Starts immediately and prepares shared review-state fixtures plus acceptance scaffolds.
- **Foundational (Phase 2)**: Depends on Setup and blocks all user-story work until the persisted model, fingerprinting, resolver, and badge mapping exist.
- **User Story 1 (Phase 3)**: Starts after Foundational and is the recommended MVP slice because it introduces the first operator write path.
- **User Story 2 (Phase 4)**: Starts after User Story 1 because registry overflow actions reuse the mutation service and capability gating established on the dashboard path.
- **User Story 3 (Phase 5)**: Starts after User Story 1 and User Story 2 because stale-review semantics must reuse the write path and then render truthfully on both dashboard and registry surfaces.
- **User Story 4 (Phase 6)**: Starts after User Story 2 and User Story 3 because workspace progress needs the final registry-facing state family and stale-review semantics in place.
- **Polish (Phase 7)**: Starts after all desired user stories are complete.
### User Story Dependencies
- **US1**: Depends only on the shared triage-review foundation.
- **US2**: Depends on US1 for the canonical mutation service, capability, and audit seam used by registry overflow actions.
- **US3**: Depends on US1 for persisted review records and on US2 for final registry rendering.
- **US4**: Depends on US2 and US3 because honest current-set progress must aggregate the full review-state family across visible tenants.
### Within Each User Story
- Write or extend the story tests first and confirm they fail before implementation is considered complete.
- Land service or resolver changes before UI copy and action wiring in the same story.
- Keep each story shippable on its own before moving to the next priority.
### Parallel Opportunities
- `T001` and `T002` can run in parallel during Setup.
- `T003`, `T004`, and `T005` can run in parallel before the persisted core lands.
- `T007` and `T008` can run in parallel after `T006` defines the storage contract.
- Within US1, `T011` and `T012` can run in parallel, then `T015` and `T016` can be split after `T013` and `T014` establish the write seam.
- Within US2, `T018` and `T019` can run in parallel, then `T020` and `T021` can be split across contributors.
- Within US3, `T024` and `T025` can run in parallel, then `T026` and `T027` can be split before `T028` updates the shared UI surfaces.
- Within US4, `T030` and `T031` can run in parallel, then `T032`, `T033`, and `T034` can be sequenced across builder and widget work.
- Within Phase 7, `T036`, `T037`, and `T038` can run in parallel before formatting and final verification.
---
## Parallel Example: User Story 1
```bash
# User Story 1 tests in parallel
T011 apps/platform/tests/Feature/Filament/TenantDashboardArrivalContextTest.php
T012 apps/platform/tests/Feature/Rbac/TriageReviewStateAuthorizationTest.php
# User Story 1 implementation split after the write seam exists
T015 apps/platform/app/Filament/Widgets/Tenant/TenantTriageArrivalContinuity.php
T016 apps/platform/app/Support/Rbac/UiEnforcement.php
```
## Parallel Example: User Story 2
```bash
# User Story 2 tests in parallel
T018 apps/platform/tests/Feature/Filament/TenantRegistryTriageReviewStateTest.php
T019 apps/platform/tests/Feature/Filament/TenantRegistryRecoveryTriageTest.php
# User Story 2 implementation split
T020 apps/platform/app/Filament/Resources/TenantResource.php
T021 apps/platform/app/Filament/Resources/TenantResource/Pages/ListTenants.php
```
## Parallel Example: User Story 3
```bash
# User Story 3 tests in parallel
T024 apps/platform/tests/Unit/Support/PortfolioTriage/TenantTriageReviewStateResolverTest.php
T025 apps/platform/tests/Feature/Filament/TenantDashboardArrivalContextTest.php
# User Story 3 implementation split
T026 apps/platform/app/Support/PortfolioTriage/TenantTriageReviewStateResolver.php
T027 apps/platform/app/Support/PortfolioTriage/TenantTriageReviewFingerprint.php
```
## Parallel Example: User Story 4
```bash
# User Story 4 tests in parallel
T030 apps/platform/tests/Feature/Filament/WorkspaceOverviewTriageReviewProgressTest.php
T031 apps/platform/tests/Feature/Filament/WorkspaceOverviewSummaryMetricsTest.php
# User Story 4 implementation split
T032 apps/platform/app/Support/Workspaces/WorkspaceOverviewBuilder.php
T033 apps/platform/app/Filament/Widgets/Workspace/WorkspaceSummaryStats.php
```
---
## Implementation Strategy
### MVP First
1. Complete Phase 1: Setup.
2. Complete Phase 2: Foundational.
3. Complete Phase 3: User Story 1.
4. **STOP and VALIDATE**: Confirm dashboard-originated review-state writes persist correctly, stay RBAC-safe, and do not imply posture is fixed.
### Incremental Delivery
1. Deliver Setup plus Foundational to lock the persisted truth, fingerprinting, resolver, and badge semantics.
2. Deliver User Story 1 so operators can record review intent from the tenant dashboard.
3. Deliver User Story 2 so the tenant registry becomes a working queue for remaining review work.
4. Deliver User Story 3 so stale reviews become visible and re-review becomes explicit.
5. Deliver User Story 4 so workspace overview progress stays honest for the current visible affected set.
6. Finish with guard coverage, formatting, and the focused verification pack.
### Parallel Team Strategy
1. One contributor can take persisted-core tests and storage while another prepares the acceptance scaffolds.
2. After Foundational work lands, one contributor can own dashboard mutation wiring while another prepares the registry rendering tests.
3. Once User Story 2 is in place, one contributor can refine stale-review derivation while another prepares overview progress coverage.
4. Rejoin for Polish so guard suites, copy review, formatting, and the verification pack land together.
---
## Notes
- `[P]` tasks target different files or safe concurrent work after prerequisite seams are in place.
- `[US1]`, `[US2]`, `[US3]`, and `[US4]` labels map directly to the user stories in `spec.md`.
- The suggested MVP scope is Phase 1 through Phase 3 only.
- This task plan stays compliant with Filament v5 on Livewire v4, makes no panel-provider changes in `bootstrap/providers.php`, introduces no new globally searchable resource, adds no destructive action, and requires no asset-strategy change beyond the existing deployment process.

View File

@ -0,0 +1,36 @@
# Specification Quality Checklist: Workspace Baseline Compare Matrix V1
**Purpose**: Validate specification completeness and quality before proceeding to planning
**Created**: 2026-04-11
**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
## Notes
- Validation pass completed on 2026-04-11.
- Route names, capability names, and action-surface identifiers remain in the spec because the repository template and constitution require explicit operator-surface, RBAC, and action-surface contracts.
- The spec intentionally keeps the matrix derived from existing baseline, compare, finding, and run truth and rejects new persisted cross-tenant compare artifacts in V1.

View File

@ -0,0 +1,468 @@
openapi: 3.1.0
info:
title: Workspace Baseline Compare Matrix Internal Surface Contract
version: 0.1.0
summary: Internal logical contract for workspace baseline compare matrix reads, drilldown context, and compare-all launch behavior
description: |
This contract is an internal planning artifact for Spec 190. The affected routes still
render HTML through Filament and Livewire. The schemas below define the bounded read
models and action payloads that must be derivable from existing baseline, compare,
finding, and run truth before the matrix surface can render or trigger compare-all.
servers:
- url: /internal
x-baseline-compare-matrix-consumers:
- surface: baseline.profile.detail
sourceFiles:
- apps/platform/app/Filament/Resources/BaselineProfileResource/Pages/ViewBaselineProfile.php
mustRender:
- baseline_profile_id
- reference_snapshot_id
- visible_tenant_count
- open_compare_matrix_action
- compare_assigned_tenants_action
- surface: baseline.compare.matrix
sourceFiles:
- apps/platform/app/Filament/Pages/BaselineCompareMatrix.php
- apps/platform/resources/views/filament/pages/baseline-compare-matrix.blade.php
mustRender:
- reference
- filters
- tenant_summaries
- subject_summaries
- matrix_rows
- freshness_legend
- trust_legend
mustAccept:
- policy_type
- state
- severity
- tenant_sort
- subject_sort
- subject_key
- surface: tenant.compare.drilldown
sourceFiles:
- apps/platform/app/Filament/Pages/BaselineCompareLanding.php
- apps/platform/app/Support/Navigation/CanonicalNavigationContext.php
mustRender:
- matrix_source_context
- baseline_profile_id
- subject_key
- back_link
paths:
/admin/baseline-profiles/{profile}/compare-matrix:
get:
summary: Render the workspace baseline compare matrix for one selected baseline profile
operationId: viewBaselineCompareMatrix
parameters:
- name: profile
in: path
required: true
schema:
type: integer
- name: policy_type
in: query
required: false
schema:
type: array
items:
type: string
- name: state
in: query
required: false
schema:
type: array
items:
$ref: '#/components/schemas/MatrixCellState'
- name: severity
in: query
required: false
schema:
type: array
items:
$ref: '#/components/schemas/FindingSeverity'
- name: tenant_sort
in: query
required: false
schema:
$ref: '#/components/schemas/TenantSort'
- name: subject_sort
in: query
required: false
schema:
$ref: '#/components/schemas/SubjectSort'
- name: subject_key
in: query
required: false
schema:
type: string
description: Optional focused subject row selector for subject-first drilldown within the same matrix route.
responses:
'200':
description: Rendered workspace matrix plus a fully derived matrix bundle
content:
text/html:
schema:
type: string
application/vnd.tenantpilot.baseline-compare-matrix+json:
schema:
$ref: '#/components/schemas/BaselineCompareMatrixBundle'
'403':
description: Actor is in scope but lacks workspace baseline view capability
'404':
description: Workspace or baseline profile is outside actor scope
/internal/workspaces/{workspace}/baseline-profiles/{profile}/compare-assigned-tenants:
post:
summary: Launch compare for all visible assigned tenants under the selected baseline profile
operationId: compareAssignedTenants
parameters:
- name: workspace
in: path
required: true
schema:
type: integer
- name: profile
in: path
required: true
schema:
type: integer
responses:
'200':
description: Compare-all launch summary derived from underlying tenant compare starts
content:
application/vnd.tenantpilot.compare-assigned-tenants+json:
schema:
$ref: '#/components/schemas/CompareAssignedTenantsLaunchResult'
'403':
description: Actor is in scope but lacks workspace baseline manage capability
'404':
description: Workspace or baseline profile is outside actor scope
components:
schemas:
MatrixCellState:
type: string
enum:
- match
- differ
- missing
- ambiguous
- not_compared
- stale_result
FindingSeverity:
type: string
enum:
- low
- medium
- high
- critical
FreshnessState:
type: string
enum:
- fresh
- stale
- never_compared
- unknown
TrustLevel:
type: string
enum:
- trustworthy
- limited_confidence
- diagnostic_only
- unusable
TenantSort:
type: string
enum:
- tenant_name
- deviation_count
- freshness_urgency
SubjectSort:
type: string
enum:
- deviation_breadth
- policy_type
- display_name
MatrixReference:
type: object
additionalProperties: false
required:
- workspaceId
- baselineProfileId
- baselineProfileName
- baselineStatus
- referenceState
- assignedTenantCount
- visibleTenantCount
properties:
workspaceId:
type: integer
baselineProfileId:
type: integer
baselineProfileName:
type: string
baselineStatus:
type: string
referenceSnapshotId:
type:
- integer
- 'null'
referenceSnapshotCapturedAt:
type:
- string
- 'null'
format: date-time
referenceState:
type: string
referenceReasonCode:
type:
- string
- 'null'
assignedTenantCount:
type: integer
visibleTenantCount:
type: integer
MatrixTenantSummary:
type: object
additionalProperties: false
required:
- tenantId
- tenantName
- freshnessState
- matchedCount
- differingCount
- missingCount
- ambiguousCount
- notComparedCount
- trustLevel
properties:
tenantId:
type: integer
tenantName:
type: string
compareRunId:
type:
- integer
- 'null'
compareRunStatus:
type:
- string
- 'null'
compareRunOutcome:
type:
- string
- 'null'
freshnessState:
$ref: '#/components/schemas/FreshnessState'
lastComparedAt:
type:
- string
- 'null'
format: date-time
matchedCount:
type: integer
differingCount:
type: integer
missingCount:
type: integer
ambiguousCount:
type: integer
notComparedCount:
type: integer
maxSeverity:
anyOf:
- $ref: '#/components/schemas/FindingSeverity'
- type: 'null'
trustLevel:
$ref: '#/components/schemas/TrustLevel'
MatrixSubjectSummary:
type: object
additionalProperties: false
required:
- subjectKey
- policyType
- deviationBreadth
- missingBreadth
- ambiguousBreadth
- notComparedBreadth
- trustLevel
properties:
subjectKey:
type: string
policyType:
type: string
displayName:
type:
- string
- 'null'
baselineExternalId:
type:
- string
- 'null'
deviationBreadth:
type: integer
missingBreadth:
type: integer
ambiguousBreadth:
type: integer
notComparedBreadth:
type: integer
maxSeverity:
anyOf:
- $ref: '#/components/schemas/FindingSeverity'
- type: 'null'
trustLevel:
$ref: '#/components/schemas/TrustLevel'
MatrixCell:
type: object
additionalProperties: false
required:
- tenantId
- subjectKey
- state
- trustLevel
- policyTypeCovered
properties:
tenantId:
type: integer
subjectKey:
type: string
state:
$ref: '#/components/schemas/MatrixCellState'
severity:
anyOf:
- $ref: '#/components/schemas/FindingSeverity'
- type: 'null'
trustLevel:
$ref: '#/components/schemas/TrustLevel'
reasonCode:
type:
- string
- 'null'
compareRunId:
type:
- integer
- 'null'
findingId:
type:
- integer
- 'null'
findingWorkflowState:
type:
- string
- 'null'
lastComparedAt:
type:
- string
- 'null'
format: date-time
policyTypeCovered:
type: boolean
MatrixRow:
type: object
additionalProperties: false
required:
- subject
- cells
properties:
subject:
$ref: '#/components/schemas/MatrixSubjectSummary'
cells:
type: array
items:
$ref: '#/components/schemas/MatrixCell'
MatrixFilterState:
type: object
additionalProperties: false
properties:
policyTypes:
type: array
items:
type: string
states:
type: array
items:
$ref: '#/components/schemas/MatrixCellState'
severities:
type: array
items:
$ref: '#/components/schemas/FindingSeverity'
tenantSort:
$ref: '#/components/schemas/TenantSort'
subjectSort:
$ref: '#/components/schemas/SubjectSort'
focusedSubjectKey:
type:
- string
- 'null'
BaselineCompareMatrixBundle:
type: object
additionalProperties: false
required:
- reference
- filters
- tenantSummaries
- subjectSummaries
- rows
properties:
reference:
$ref: '#/components/schemas/MatrixReference'
filters:
$ref: '#/components/schemas/MatrixFilterState'
tenantSummaries:
type: array
items:
$ref: '#/components/schemas/MatrixTenantSummary'
subjectSummaries:
type: array
items:
$ref: '#/components/schemas/MatrixSubjectSummary'
rows:
type: array
items:
$ref: '#/components/schemas/MatrixRow'
CompareAssignedTenantTarget:
type: object
additionalProperties: false
required:
- tenantId
- launchState
properties:
tenantId:
type: integer
runId:
type:
- integer
- 'null'
launchState:
type: string
enum:
- queued
- already_queued
- blocked
reasonCode:
type:
- string
- 'null'
CompareAssignedTenantsLaunchResult:
type: object
additionalProperties: false
required:
- baselineProfileId
- visibleAssignedTenantCount
- queuedCount
- alreadyQueuedCount
- blockedCount
- targets
properties:
baselineProfileId:
type: integer
visibleAssignedTenantCount:
type: integer
queuedCount:
type: integer
alreadyQueuedCount:
type: integer
blockedCount:
type: integer
targets:
type: array
items:
$ref: '#/components/schemas/CompareAssignedTenantTarget'

View File

@ -0,0 +1,192 @@
# Data Model: Workspace Baseline Compare Matrix V1
## Overview
This feature introduces no new persisted entity. The matrix is a derived workspace-scoped read model over existing baseline reference truth, tenant compare execution truth, and compare-created finding truth.
## Existing Source Truths
### Baseline reference truth
**Types**: Existing workspace-owned source of truth
**Sources**: `BaselineProfile`, `BaselineSnapshot`, `BaselineSnapshotTruthResolver`, `BaselineSnapshotItem`
| Source | Purpose | Key Fields |
|--------|---------|------------|
| `BaselineProfile` | Selects the reference standard and active snapshot pointer | `id`, `workspace_id`, `name`, `status`, `active_snapshot_id`, `scope_jsonb`, `capture_mode` |
| `BaselineSnapshot` | Represents the effective baseline reference state | `id`, `baseline_profile_id`, `workspace_id`, `state`, `completed_at`, `operation_run_id` |
| `BaselineSnapshotItem` | Defines the subject row axis for the matrix | `baseline_snapshot_id`, `policy_type`, `subject_key`, `external_id`, `display_name` |
### Assignment truth
**Type**: Existing workspace-to-tenant mapping truth
**Source**: `BaselineTenantAssignment`
| Source | Purpose | Key Fields |
|--------|---------|------------|
| `BaselineTenantAssignment` | Defines which tenants belong in the matrix target set | `workspace_id`, `tenant_id`, `baseline_profile_id`, `override_scope_jsonb` |
### Tenant compare truth
**Type**: Existing tenant-owned execution and diagnostic truth
**Sources**: `OperationRun` with `type = baseline_compare`, `BaselineCompareStats`, `baseline_compare` run context
| Source | Purpose | Key Fields |
|--------|---------|------------|
| `OperationRun` | Stores compare lifecycle, timestamps, summary counts, and context | `tenant_id`, `workspace_id`, `type`, `status`, `outcome`, `completed_at`, `summary_counts`, `context` |
| `OperationRun.context.baseline_compare` | Stores compare diagnostics needed for trust and no-result interpretation | `coverage`, `reason_code`, `reason_translation`, `evidence_gaps`, `subjects_total`, `fidelity`, `inventory_sync_run_id` |
| `BaselineCompareStats` | Existing single-tenant projection for freshness, coverage, findings count, and operator explanation | `state`, `reasonCode`, `reasonMessage`, `lastComparedIso`, `coverageStatus`, `evidenceGapDetails`, `operatorExplanation` |
### Drift finding truth
**Type**: Existing tenant-owned technical drift and workflow truth
**Source**: `Finding` with `finding_type = drift` and `source = baseline.compare`
| Source | Purpose | Key Fields |
|--------|---------|------------|
| `Finding` | Stores subject-level technical differences plus workflow metadata | `tenant_id`, `workspace_id`, `scope_key`, `subject_external_id`, `subject_type`, `severity`, `status`, `baseline_operation_run_id`, `current_operation_run_id`, `evidence_jsonb` |
## New Derived Read Models
### BaselineCompareMatrixReference
**Type**: Request-scoped reference bundle
**Source**: `BaselineProfile` + resolved effective `BaselineSnapshot`
| Field | Type | Notes |
|------|------|-------|
| `workspaceId` | integer | Active workspace scope |
| `baselineProfileId` | integer | Selected baseline profile |
| `baselineProfileName` | string | Operator-facing reference label |
| `baselineStatus` | string | Existing profile lifecycle/status |
| `referenceSnapshotId` | integer or null | Null when compare is blocked |
| `referenceSnapshotCapturedAt` | datetime or null | For freshness context |
| `referenceState` | string | `ready` or a blocked state such as `no_snapshot` |
| `referenceReasonCode` | string or null | Existing compare-snapshot truth reason |
| `assignedTenantCount` | integer | Total assigned tenant count in workspace |
| `visibleTenantCount` | integer | Count after visibility filtering |
### MatrixTenantSummary
**Type**: Request-scoped visible tenant summary
**Source**: visible tenant set + latest relevant compare run + derived cell states
| Field | Type | Notes |
|------|------|-------|
| `tenantId` | integer | Visible tenant identifier |
| `tenantName` | string | Operator-facing column label |
| `compareRunId` | integer or null | Latest relevant compare run for this baseline |
| `compareRunStatus` | string or null | `queued`, `running`, `completed`, or null |
| `compareRunOutcome` | string or null | Existing `OperationRun` outcome |
| `freshnessState` | string | `fresh`, `stale`, `never_compared`, or `unknown` |
| `lastComparedAt` | datetime or null | Latest completed compare time |
| `matchedCount` | integer | Derived from cell states |
| `differingCount` | integer | Derived from cell states |
| `missingCount` | integer | Derived from cell states |
| `ambiguousCount` | integer | Derived from cell states |
| `notComparedCount` | integer | Derived from cell states |
| `maxSeverity` | string or null | Highest visible severity among differing cells |
| `trustLevel` | string | Existing trustworthiness semantics reused at tenant level |
### MatrixSubjectSummary
**Type**: Request-scoped baseline subject summary
**Source**: `BaselineSnapshotItem` + visible tenant cell states
| Field | Type | Notes |
|------|------|-------|
| `subjectKey` | string | Reused existing subject identity |
| `policyType` | string | For filter and grouping |
| `displayName` | string or null | Operator-facing row label |
| `baselineExternalId` | string or null | Secondary drilldown metadata |
| `deviationBreadth` | integer | Count of visible tenants in `differ` or `missing` |
| `missingBreadth` | integer | Count of visible tenants in `missing` |
| `ambiguousBreadth` | integer | Count of visible tenants in `ambiguous` |
| `notComparedBreadth` | integer | Count of visible tenants in `not_compared` |
| `maxSeverity` | string or null | Highest visible severity across differing cells |
| `trustLevel` | string | Highest-risk trust signal across visible cells |
### MatrixCell
**Type**: Request-scoped cell read model
**Source**: `BaselineSnapshotItem` + latest relevant compare run context + compare-created findings
| Field | Type | Notes |
|------|------|-------|
| `tenantId` | integer | Visible tenant column key |
| `subjectKey` | string | Subject row key |
| `state` | string | `match`, `differ`, `missing`, `ambiguous`, `not_compared`, or `stale_result` |
| `severity` | string or null | From current technical drift finding when present |
| `trustLevel` | string | Reused trustworthiness semantics or cell-level downgraded trust |
| `reasonCode` | string or null | Existing reason/evidence-gap code when the state is not a plain match |
| `compareRunId` | integer or null | Latest relevant compare run |
| `findingId` | integer or null | Existing finding drilldown target when a related finding exists |
| `findingWorkflowState` | string or null | Secondary workflow context; never overrides technical state |
| `lastComparedAt` | datetime or null | Latest compare timestamp for this tenant |
| `policyTypeCovered` | boolean | False when the run never covered this subject's policy type |
### CompareAssignedTenantsLaunchResult
**Type**: Request-scoped action result bundle
**Source**: repeated calls to existing `BaselineCompareService::startCompare()`
| Field | Type | Notes |
|------|------|-------|
| `baselineProfileId` | integer | Selected baseline profile |
| `visibleAssignedTenantCount` | integer | Size of eligible visible set considered by the action |
| `queuedCount` | integer | New compare runs started |
| `alreadyQueuedCount` | integer | Existing active runs reused |
| `blockedCount` | integer | Compare starts refused by normal service preconditions |
| `targets` | array | Per-tenant action outcome bundle with tenant id, run id or reason code |
## Validation Rules
### Visibility rules
- Only tenants visible to the current actor may produce columns, counts, or drilldown targets.
- Summary counts are always computed from the visible tenant set only.
- Hidden tenants are never represented as anonymous remainder counts.
### Reference rules
- The matrix may render technical compare truth only when the selected baseline profile resolves to a usable effective snapshot.
- If no usable snapshot exists, the page remains in a blocked state and no cell state may imply compare health.
### Cell derivation precedence
1. If no usable reference snapshot exists, the page is blocked and no cells are materialized.
2. If the tenant has no completed compare against this baseline reference or the subject's policy type was not covered, the cell state is `not_compared`.
3. If the latest completed compare result predates the current effective snapshot or is stale under existing stale-result policy, the cell state is `stale_result` unless a stronger blocked or absent condition applies.
4. If the latest relevant compare run records an evidence-gap or reason code indicating ambiguous or low-confidence identity matching for the subject, the cell state is `ambiguous`.
5. If the latest relevant compare run records the subject as missing from tenant truth, the cell state is `missing`.
6. If the latest relevant compare output created or updated a drift finding for the subject under the current compare run, the cell state is `differ` regardless of finding workflow status.
7. Otherwise the cell state is `match`.
### Freshness rules
- `fresh` means the tenant has a completed compare result against the current effective snapshot and it does not exceed the existing stale-result threshold.
- `stale` means the compare result predates the current effective snapshot or breaches the existing stale-result threshold.
- `never_compared` means no relevant compare run exists for the tenant and selected baseline.
- `unknown` is reserved for unexpected cases where timestamps or run truth are missing but a cell still exists.
### Trust rules
- Trust levels reuse existing compare explanation semantics rather than a new matrix-only taxonomy.
- `match` may only be shown when the subject was covered and no ambiguity or missing-basis signal exists.
- `not_compared` and uncovered policy types are treated as low-trust or unusable for operator interpretation.
## Relationships
- One `BaselineProfile` resolves to zero or one effective `BaselineSnapshot` for compare.
- One `BaselineSnapshot` has many `BaselineSnapshotItem` rows.
- One `BaselineProfile` has many `BaselineTenantAssignment` rows.
- One visible tenant may have many `baseline_compare` `OperationRun` rows over time, but the matrix resolves one latest relevant run per tenant for the selected baseline.
- One latest relevant compare run may have many related drift `Finding` rows.
- One matrix cell may link to zero or one preferred finding drilldown and always belongs to exactly one visible tenant and one subject row.
## Rendering Rules
- Technical deviation state is primary; finding workflow state is secondary.
- Tenant running or queued compare state is shown at tenant-summary level and does not replace the last known completed technical truth unless the latest completed truth is absent.
- Subject-focused views reuse the same row and cell models and simply reduce the visible subject set to one selected subject.
- Empty and degraded states remain page-level states rather than synthetic match rows.

View File

@ -0,0 +1,271 @@
# Implementation Plan: Workspace Baseline Compare Matrix V1
**Branch**: `190-baseline-compare-matrix` | **Date**: 2026-04-11 | **Spec**: `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/specs/190-baseline-compare-matrix/spec.md`
**Input**: Feature specification from `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/specs/190-baseline-compare-matrix/spec.md`
**Note**: This template is filled in by the `/speckit.plan` command. See `.specify/scripts/` for helper scripts.
## Summary
Add one workspace-scoped Filament page anchored on `BaselineProfile` that renders a tenant-by-subject compare matrix from the current effective baseline snapshot, visible assigned tenants, the latest relevant tenant `baseline_compare` runs, and compare-created findings. Reuse the existing tenant compare start path for `Compare assigned tenants`, reuse `CanonicalNavigationContext` for drilldown continuity, and keep V1 derived and read-only so no new persisted cross-tenant compare artifact or workspace umbrella run is introduced.
## Technical Context
**Language/Version**: PHP 8.4.15
**Primary Dependencies**: Laravel 12, Filament v5, Livewire v4, Pest v4, existing `BaselineCompareService`, `BaselineSnapshotTruthResolver`, `BaselineCompareStats`, `RelatedNavigationResolver`, `CanonicalNavigationContext`, `BadgeCatalog`, and `UiEnforcement` patterns
**Storage**: PostgreSQL via existing `baseline_profiles`, `baseline_snapshots`, `baseline_snapshot_items`, `baseline_tenant_assignments`, `operation_runs`, and `findings` tables; no new persistence planned
**Testing**: Pest 4 feature and browser tests, Filament or Livewire page coverage, focused RBAC regressions, run through Laravel Sail
**Target Platform**: Laravel monolith web application in Sail locally and containerized Linux deployment in staging and production
**Project Type**: web application
**Performance Goals**: Keep matrix reads query-bounded over the visible tenant set and reference snapshot, avoid per-cell N+1 queries, keep render-time surfaces DB-only, and keep `Compare assigned tenants` enqueue-only by reusing tenant compare run starts
**Constraints**: No new persisted cross-tenant compare artifact; no new workspace umbrella `OperationRun`; hidden tenants must not leak by name or count; `Compare assigned tenants` must remain confirmation-gated and `simulation only`; matrix uses a narrow custom grid exception because a one-axis table cannot represent subject-by-tenant truth cleanly; no remote calls at render time
**Scale/Scope**: One workspace, one baseline profile at a time, all visible assigned tenants for that profile, all in-scope baseline subjects for the effective snapshot, one new page and view, one narrow derived matrix builder, additive baseline-detail header actions, and focused feature plus smoke-test coverage
## Constitution Check
*GATE: Passed before Phase 0 research. Re-checked after Phase 1 design and still passing.*
| Principle | Pre-Research | Post-Design | Notes |
|-----------|--------------|-------------|-------|
| Inventory-first / snapshots-second | PASS | PASS | The matrix reads current tenant evidence through existing compare runs and baseline snapshots; it does not redefine last-observed truth. |
| Read/write separation | PASS | PASS | The feature is read-only apart from compare start. `Compare assigned tenants` remains confirmation-gated and simulation-only, with existing run and audit semantics. |
| Graph contract path | N/A | N/A | No new Graph calls or contract-registry changes are required. |
| Deterministic capabilities | PASS | PASS | Existing capability constants `WORKSPACE_BASELINES_VIEW`, `WORKSPACE_BASELINES_MANAGE`, `TENANT_VIEW`, and `TENANT_FINDINGS_VIEW` remain canonical. |
| Workspace + tenant isolation | PASS | PASS | The matrix is workspace-scoped, its columns are limited to visible assigned tenants, and all drilldowns re-check tenant entitlement. |
| RBAC-UX authorization semantics | PASS | PASS | Non-members remain `404`; in-scope members missing required capability remain `403`; server-side authorization remains authoritative. |
| Run observability / Ops-UX | PASS | PASS | `Compare assigned tenants` fans out to existing tenant `baseline_compare` runs only. No new shadow run model or inline remote work is introduced. |
| Data minimization | PASS | PASS | The matrix reads existing compare and finding metadata only; no new payload copies or exported artifacts are stored. |
| Proportionality / no premature abstraction | PASS | PASS | The design adds one narrow matrix builder and one new page instead of a generalized compare framework or persisted portfolio layer. |
| Persisted truth / behavioral state | PASS | PASS | No new persistence or state family is added. Matrix states remain derived from existing truth. |
| UI semantics / few layers | PASS | PASS | Trust, stale, and ambiguity reuse existing compare semantics and do not create a new UI taxonomy. |
| Filament v5 / Livewire v4 compliance | PASS | PASS | The feature stays inside current Filament v5 and Livewire v4 patterns. |
| Provider registration location | PASS | PASS | No panel or provider changes are required. Provider registration remains in `bootstrap/providers.php`. |
| Global search hard rule | PASS | PASS | No new globally searchable resource is added. Existing baseline resources already satisfy current search constraints. |
| Destructive action safety | PASS | PASS | No destructive action is added. Existing destructive baseline actions remain unchanged and confirmation-gated. |
| Asset strategy | PASS | PASS | No new assets are planned. Existing deployment behavior for `filament:assets` remains unchanged. |
| Filament-native UI / Action Surface Contract | PASS WITH NARROW EXCEPTION | PASS WITH NARROW EXCEPTION | The matrix uses native Filament page structure and actions, but the core body is a custom two-dimensional grid because a one-axis table is insufficient. |
| Filament UX-001 | PASS WITH NARROW EXCEPTION | PASS WITH NARROW EXCEPTION | Sections, filters, legends, and empty states remain standard. The matrix body itself is the documented UX-001 exception. |
| Testing truth (TEST-TRUTH-001) | PASS | PASS | The test plan focuses on aggregation correctness, hidden-tenant safety, stale or ambiguous truth, compare-all fan-out, and drilldown continuity. |
## Phase 0 Research
Research outcomes are captured in `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/specs/190-baseline-compare-matrix/research.md`.
Key decisions:
- Use `/admin/baseline-profiles/{record}/compare-matrix` as the canonical route and anchor the workflow under the existing baseline profile resource.
- Derive matrix cells and summaries from `BaselineSnapshotItem` plus the latest relevant `baseline_compare` run plus compare-created findings; findings alone are insufficient.
- Treat technical deviation state as primary and finding workflow state as secondary metadata so closed or risk-accepted findings do not hide real current drift.
- Reuse the existing stale-result threshold and current-reference comparison rather than inventing matrix-specific freshness rules.
- Reuse existing evidence-gap and trustworthiness semantics for ambiguity and low-confidence signals.
- Reuse `CanonicalNavigationContext` for matrix drilldown continuity instead of extending `PortfolioArrivalContext`.
- Implement `Compare assigned tenants` as visible-tenant fan-out over existing tenant compare starts without a workspace umbrella run.
- Validate primarily with focused feature tests plus one browser smoke test.
## Phase 1 Design
Design artifacts are created under `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/specs/190-baseline-compare-matrix/`:
- `data-model.md`: existing source truths, derived matrix read models, and cell-state precedence
- `contracts/baseline-compare-matrix.logical.openapi.yaml`: internal logical contract for matrix reads and compare-all launch behavior
- `quickstart.md`: implementation sequence and focused verification workflow
Design decisions:
- The matrix stays fully derived and introduces no new table, artifact, or workspace-level run type.
- The new page is baseline-profile-scoped and reuses existing baseline detail, compare landing, finding detail, and operation-run viewer destinations.
- The core builder lives alongside existing baseline support code and returns plain derived arrays or value-oriented payloads rather than a new presenter framework.
- `Compare assigned tenants` iterates the visible assigned tenant set and reuses `BaselineCompareService::startCompare()` per tenant, reporting queued, already queued, and blocked results honestly.
- Cell state precedence is fixed so `not_compared`, `stale_result`, `ambiguous`, `missing`, and `differ` remain visibly distinct from `match`.
- Matrix drilldowns preserve a canonical return path by reusing `CanonicalNavigationContext` query propagation rather than inventing a new navigation token system.
## Project Structure
### Documentation (this feature)
```text
specs/190-baseline-compare-matrix/
├── spec.md
├── plan.md
├── research.md
├── data-model.md
├── quickstart.md
├── contracts/
│ └── baseline-compare-matrix.logical.openapi.yaml
├── checklists/
│ └── requirements.md
└── tasks.md
```
### Source Code (repository root)
```text
apps/platform/
├── app/
│ ├── Filament/
│ │ ├── Pages/
│ │ │ ├── BaselineCompareLanding.php
│ │ │ └── BaselineCompareMatrix.php
│ │ └── Resources/
│ │ ├── BaselineProfileResource.php
│ │ ├── BaselineProfileResource/
│ │ │ └── Pages/ViewBaselineProfile.php
│ │ ├── FindingResource.php
│ │ └── OperationRunResource.php
│ ├── Models/
│ │ ├── BaselineProfile.php
│ │ ├── BaselineSnapshot.php
│ │ ├── BaselineSnapshotItem.php
│ │ ├── BaselineTenantAssignment.php
│ │ ├── Finding.php
│ │ └── OperationRun.php
│ ├── Services/
│ │ └── Baselines/BaselineCompareService.php
│ ├── Support/
│ │ ├── Baselines/
│ │ │ ├── BaselineCompareEvidenceGapDetails.php
│ │ │ ├── BaselineCompareMatrixBuilder.php
│ │ │ ├── BaselineCompareStats.php
│ │ │ ├── BaselineCompareSummaryAssessor.php
│ │ │ └── BaselineSnapshotTruthResolver.php
│ │ ├── Navigation/
│ │ │ ├── CanonicalNavigationContext.php
│ │ │ └── RelatedNavigationResolver.php
│ │ └── Rbac/
│ │ └── UiEnforcement.php
│ └── resources/views/filament/pages/
│ └── baseline-compare-matrix.blade.php
└── tests/
├── Browser/
│ └── Spec190BaselineCompareMatrixSmokeTest.php
├── Feature/
│ ├── Baselines/
│ │ ├── BaselineCompareMatrixBuilderTest.php
│ │ └── BaselineCompareMatrixCompareAllActionTest.php
│ ├── Filament/
│ │ └── BaselineCompareMatrixPageTest.php
│ └── Rbac/
│ └── BaselineCompareMatrixAuthorizationTest.php
└── Feature/Guards/
└── ActionSurfaceContractTest.php
```
**Structure Decision**: Keep the work inside the existing Laravel monolith under `apps/platform`. Add one new workspace page, one new view, one narrow baseline-support matrix builder, and additive changes to existing baseline detail and drilldown surfaces instead of creating a new domain package, new persistence layer, or new top-level workspace app.
## Implementation Strategy
### Phase A — Add The Workspace Entry Surface
**Goal**: Introduce the canonical matrix route and entry points without changing baseline ownership or navigation semantics.
| Step | File | Change |
|------|------|--------|
| A.1 | `apps/platform/app/Filament/Pages/BaselineCompareMatrix.php` | Add the new workspace page class, scope it to one `BaselineProfile`, expose filter state, and declare the page contract plus action-surface intent |
| A.2 | `apps/platform/resources/views/filament/pages/baseline-compare-matrix.blade.php` | Add the page view using Filament sections, summaries, legends, and one narrow custom grid body |
| A.3 | `apps/platform/app/Filament/Resources/BaselineProfileResource/Pages/ViewBaselineProfile.php` | Add `Open compare matrix` and `Compare assigned tenants` header actions with confirmation and capability gating |
### Phase B — Build The Live Matrix Aggregation
**Goal**: Derive visible rows, columns, cells, summaries, freshness, and trust from existing truth only.
| Step | File | Change |
|------|------|--------|
| B.1 | `apps/platform/app/Support/Baselines/BaselineCompareMatrixBuilder.php` | Add the narrow read-model builder that resolves reference snapshot, visible assigned tenants, subject rows, matrix cells, and summaries |
| B.2 | Existing baseline support classes such as `BaselineCompareStats.php`, `BaselineCompareEvidenceGapDetails.php`, and `BaselineCompareSummaryAssessor.php` | Reuse or extract helper logic for freshness, trust, evidence-gap interpretation, and no-result handling instead of duplicating semantics |
| B.3 | Existing models `BaselineTenantAssignment`, `Finding`, and `OperationRun` | Add or reuse scoped queries for visible tenant assignments, latest relevant compare runs per tenant, and compare-created findings keyed to the selected baseline profile |
### Phase C — Add Compare-All Fan-Out Over Existing Tenant Runs
**Goal**: Start compare for the visible assigned set without creating a workspace umbrella run.
| Step | File | Change |
|------|------|--------|
| C.1 | `apps/platform/app/Services/Baselines/BaselineCompareService.php` | Add or extend a batch-start path that iterates visible assigned tenants and reuses `startCompare()` for each target |
| C.2 | `apps/platform/app/Filament/Pages/BaselineCompareMatrix.php` | Surface `Compare assigned tenants`, call the batch-start path, render queued, already queued, and blocked outcomes honestly, and preserve visible-disabled helper text for in-scope users missing manage capability |
| C.3 | Existing operation feedback helpers such as `OperationUxPresenter` and `OpsUxBrowserEvents` | Reuse current queued feedback and run-link behavior; do not invent matrix-specific run notifications |
### Phase D — Bind Filters, Drilldowns, And Degraded States
**Goal**: Make the matrix operator-scanable, trustworthy, and continuous with existing follow-up surfaces.
| Step | File | Change |
|------|------|--------|
| D.1 | `apps/platform/app/Filament/Pages/BaselineCompareMatrix.php` | Add policy-type, state, severity, tenant-sort, subject-sort, and focused-subject state |
| D.2 | `apps/platform/app/Support/Navigation/CanonicalNavigationContext.php` and related call sites | Reuse canonical navigation context so cell, tenant, finding, and run drilldowns preserve a return path to the matrix |
| D.3 | Existing drilldown destinations such as `BaselineCompareLanding.php`, `FindingResource.php`, and `OperationRunResource.php` | Accept bounded matrix source context and render a meaningful back link or source hint without adding a new navigation system |
| D.4 | Matrix view and builder | Render explicit empty, blocked, partial, stale, ambiguous, and no-result states without collapsing them into matches |
### Phase E — Regression Protection And Hardening
**Goal**: Prove business truth, hidden-tenant safety, and action-surface compliance.
| Step | File | Change |
|------|------|--------|
| E.1 | `apps/platform/tests/Feature/Baselines/BaselineCompareMatrixBuilderTest.php` | Cover row and cell derivation, stale or no-result logic, ambiguity, and visible-set-only summaries |
| E.2 | `apps/platform/tests/Feature/Baselines/BaselineCompareMatrixCompareAllActionTest.php` | Cover compare-all fan-out, per-tenant queued or blocked outcomes, and no workspace umbrella run creation |
| E.3 | `apps/platform/tests/Feature/Filament/BaselineCompareMatrixPageTest.php` | Cover page mount, filters, subject focus, degraded states, and drilldown link presence |
| E.4 | `apps/platform/tests/Feature/Rbac/BaselineCompareMatrixAuthorizationTest.php` | Cover non-member `404`, member-without-capability `403`, visible-set filtering, and allowed access |
| E.5 | `apps/platform/tests/Browser/Spec190BaselineCompareMatrixSmokeTest.php` | Add one smoke test for the rendered matrix, one core filter interaction, and one drilldown or compare-all affordance |
| E.6 | `apps/platform/tests/Feature/Guards/ActionSurfaceContractTest.php` | Keep the new page and baseline detail actions aligned with action-surface rules |
## Key Design Decisions
### D-001 — Keep the matrix fully derived
The page must read from existing baseline, compare, finding, and run truth only. No `CrossTenantCompare`, no stored matrix snapshot, and no new reporting artifact are introduced in V1.
### D-002 — Keep baseline profile as the visible standard
The matrix route lives under the selected baseline profile because the operator question is not generic cross-tenant compare; it is reference-to-many compare against one workspace-owned standard.
### D-003 — Reuse tenant compare runs only
`Compare assigned tenants` fans out across the visible assigned tenant set through existing tenant compare starts. Aggregate progress on the matrix page derives from those tenant runs instead of a workspace batch run.
### D-004 — Technical drift comes before workflow posture
Cells and counts reflect technical compare truth first. Finding workflow state, risk acceptance, or acknowledgement remain secondary metadata and do not suppress a current `differ` or `missing` state.
### D-005 — Reuse canonical navigation context
Drilldown continuity uses existing canonical navigation patterns rather than extending portfolio-triage tokens or adding a new navigation framework.
### D-006 — Accept one narrow matrix-grid UI exception
The matrix body is the only major UI exception because a standard table cannot represent subject-by-tenant truth. Everything around the grid remains Filament-native: actions, sections, legends, empty states, and badges.
## Risk Assessment
| Risk | Impact | Likelihood | Mitigation |
|------|--------|------------|------------|
| Hidden-tenant leakage through summaries or totals | High | Medium | Build every column and summary from the visible tenant set only; add explicit negative RBAC tests. |
| Findings-only aggregation understates current drift | High | Medium | Derive technical state from latest compare run plus findings; treat finding workflow as secondary metadata. |
| Stale or no-result truth looks calm | High | Medium | Reuse stale-result semantics, explicit `not_compared`, and blocked-state rendering; test all degraded cases. |
| Matrix render becomes query-heavy | High | Medium | Build one narrow aggregation layer that batches tenant runs, findings, and snapshot items instead of resolving per cell. |
| Compare-all introduces a shadow batch model | Medium | Medium | Reuse `BaselineCompareService::startCompare()` per tenant and return a simple launch summary only. |
| The page grows into a generalized standardization platform | Medium | Medium | Keep route, copy, and design anchored on one baseline profile, one reference snapshot, and read-only workflow scope. |
## Test Strategy
- Add focused feature coverage for matrix aggregation, cell precedence, tenant and subject summaries, stale or no-result truth, and visible-set-only counts.
- Add action coverage for compare-all fan-out, per-tenant queued or blocked outcomes, reuse of the existing tenant compare run path, service-owned run transitions, canonical summary-count invariants, and notification-surface limits.
- Add RBAC coverage for workspace membership, capability denial, partial tenant visibility, visible-disabled helper-text states for in-scope users missing manage capability, and drilldown authorization semantics.
- Add query-shape regression coverage so wide tenant/subject matrices stay batched and avoid per-cell N+1 resolution.
- Add one browser smoke test for matrix rendering plus a core filter or drilldown interaction.
- Keep action-surface and existing baseline compare suites green so the new page does not regress current baseline workflows.
- Run the minimum focused Sail pack plus `pint --dirty --format agent` before implementation sign-off.
## Complexity Tracking
| Violation | Why Needed | Simpler Alternative Rejected Because |
|-----------|------------|-------------------------------------|
| Two-dimensional matrix grid on a Filament page | The operator must scan subjects across multiple visible tenant columns in one surface | A standard Filament table or tenant-by-tenant list cannot represent both axes without destroying the core workflow question |
## Proportionality Review
- **Current operator problem**: Operators can already define baselines and compare one tenant at a time, but they still cannot answer cross-tenant baseline deviation questions without manual aggregation.
- **Existing structure is insufficient because**: Existing tenant compare and finding surfaces are tenant-first. They do not present one visible-set baseline view across all assigned tenants.
- **Narrowest correct implementation**: Add one baseline-scoped matrix page, one narrow derived matrix builder, one centralized badge adapter that reuses canonical compare truth, and one compare-all fan-out path over existing tenant compare starts.
- **Ownership cost created**: One new page, one view, one derived aggregation helper, one centralized badge adapter, additive baseline-detail actions, and focused feature plus browser regression coverage.
- **Alternative intentionally rejected**: A persisted cross-tenant compare artifact, a generalized compare engine, or a workspace umbrella run were rejected because they add durable complexity beyond V1's read-only portfolio visibility goal.
- **Release truth**: Current-release truth. The feature closes a present operator workflow gap using already-shipped baseline, finding, and run foundations.

View File

@ -0,0 +1,83 @@
# Quickstart: Workspace Baseline Compare Matrix V1
## Goal
Implement one workspace-scoped baseline compare matrix that lets an operator inspect visible assigned tenants against one baseline reference, trigger compare execution across the visible assigned set, and drill into existing tenant compare or finding surfaces without introducing a new persisted cross-tenant compare artifact.
## Implementation Sequence
1. Add the new workspace matrix page and baseline entry action.
- Add a new workspace Filament page for `/admin/baseline-profiles/{record}/compare-matrix`.
- Add `Open compare matrix` to the existing baseline profile detail header.
- Keep the page scoped to one selected baseline profile and one explicit reference snapshot.
2. Build the live aggregation layer over existing truth.
- Create a narrow matrix builder under the existing baseline-support namespace.
- Use `BaselineSnapshotTruthResolver` and `BaselineSnapshotItem` for the reference axis.
- Use latest relevant `baseline_compare` runs plus their `context['baseline_compare']` payload for freshness, coverage, and trust.
- Use compare-created findings for technical difference severity and drilldown targets.
- Keep the matrix derived only; do not add persistence.
3. Add compare-all fan-out without a workspace umbrella run.
- Extend the baseline compare start path so the matrix and baseline detail can iterate visible assigned tenants and call the existing tenant compare start logic.
- Keep confirmation, queued toast behavior, and run observability aligned with existing `OperationRun` semantics.
- Report partial success, already queued, and blocked starts honestly from the underlying per-tenant results.
4. Bind filtering, subject focus, and drilldown continuity.
- Add policy-type, state, and severity filters.
- Add tenant and subject sorting.
- Reuse `CanonicalNavigationContext`, `RelatedNavigationResolver`, and existing destination routes for tenant, finding, and run drilldowns.
- Preserve a clear return path to the matrix.
5. Add regression coverage.
- Cover live aggregation, compare-all, stale/no-result/ambiguous truth, visible-set RBAC filtering, and drilldown continuity.
- Add one browser smoke test to prove the interactive matrix surface renders and performs the core operator flow.
## Suggested Test Files
- `apps/platform/tests/Feature/Baselines/BaselineCompareMatrixBuilderTest.php`
- `apps/platform/tests/Feature/Baselines/BaselineCompareMatrixCompareAllActionTest.php`
- `apps/platform/tests/Feature/Filament/BaselineCompareMatrixPageTest.php`
- `apps/platform/tests/Feature/Rbac/BaselineCompareMatrixAuthorizationTest.php`
- `apps/platform/tests/Browser/Spec190BaselineCompareMatrixSmokeTest.php`
- `apps/platform/tests/Feature/Guards/ActionSurfaceContractTest.php`
## Existing Suites To Extend Or Keep Green
- `apps/platform/tests/Feature/Baselines/BaselineCompareStatsTest.php`
- `apps/platform/tests/Feature/Baselines/BaselineCompareFindingsTest.php`
- `apps/platform/tests/Feature/Baselines/BaselineProfileAuthorizationTest.php`
- `apps/platform/tests/Feature/Filament/WorkspaceOverview*` suites that currently consume baseline attention summaries
- `apps/platform/tests/Feature/Guards/ActionSurfaceContractTest.php`
## Minimum Verification Commands
Run all commands through Sail from `apps/platform`.
```bash
cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Baselines/BaselineCompareMatrixBuilderTest.php
cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Baselines/BaselineCompareMatrixCompareAllActionTest.php
cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Filament/BaselineCompareMatrixPageTest.php
cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Rbac/BaselineCompareMatrixAuthorizationTest.php
cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Baselines/BaselineCompareStatsTest.php
cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Baselines/BaselineCompareFindingsTest.php
cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Guards/ActionSurfaceContractTest.php
cd apps/platform && ./vendor/bin/sail bin pint --dirty --format agent
```
## Manual Acceptance Checklist
1. Open a baseline profile with a usable reference snapshot and verify `Open compare matrix` lands on the new workspace matrix page.
2. Confirm the page shows the selected baseline profile, reference snapshot, visible assigned tenant count, and truthful per-tenant and per-subject summaries.
3. Filter the matrix by policy type and by state and confirm the visible rows, columns, and counts update without implying hidden tenants.
4. Open a differing, missing, or ambiguous cell and confirm the drilldown reaches an existing tenant compare or finding surface with a clear return path.
5. Trigger `Compare assigned tenants` and confirm the modal describes `simulation only`, the launch fans out to visible assigned tenants only, and partial success is visible.
6. Verify a tenant with no prior compare reads as `Not compared`, not healthy.
7. Verify a tenant with stale compare truth reads as stale and does not look current.
8. Verify a user with partial tenant visibility sees only allowed tenants and no hidden-tenant aggregate counts.
## Deployment Notes
- No new database migration is expected.
- No new assets are planned; normal Filament asset publishing behavior remains unchanged.
- `Compare assigned tenants` reuses existing tenant compare runs only, so no new queue worker topology or deployment artifact is required.

View File

@ -0,0 +1,90 @@
# Research: Workspace Baseline Compare Matrix V1
## Decision: Build the matrix as a baseline-scoped workspace page under the existing baseline profile resource
### Rationale
The existing baseline workflow is already anchored on `BaselineProfileResource` and `ViewBaselineProfile`. A matrix is meaningful only in the context of one explicit baseline reference, so the narrowest route is a child workspace page such as `/admin/baseline-profiles/{record}/compare-matrix`. This keeps the baseline profile as the visible standard, preserves existing navigation expectations, and avoids introducing a new top-level workspace workbench.
### Alternatives considered
- Add a separate top-level workspace navigation item: rejected because it weakens the explicit baseline reference and creates a second entry pattern for the same workflow.
- Extend the existing tenant `BaselineCompareLanding` page to become cross-tenant: rejected because that page is tenant-scoped by design and would blur tenant and workspace concerns.
## Decision: Derive matrix truth from `BaselineSnapshotItem` plus the latest relevant tenant compare run plus compare-created findings
### Rationale
No single existing artifact is sufficient by itself. `BaselineSnapshotItem` defines the immutable subject row axis and reference truth. The latest `baseline_compare` `OperationRun` provides freshness, coverage, evidence-gap, and trust metadata. Findings created with `source = baseline.compare` provide durable per-subject technical drift detail and severity. Combining those existing sources yields matrix rows, columns, summaries, and drilldowns without adding a second persisted compare model.
### Alternatives considered
- Use findings alone: rejected because findings do not represent matches, do not fully encode no-result or stale truth, and can be filtered by workflow status in ways that would understate current technical deviation.
- Use run context alone: rejected because findings remain the durable subject-level drilldown truth and severity carrier across compare executions.
- Persist a new cross-tenant compare report: rejected because V1 can be derived live from existing truth.
## Decision: Treat technical deviation state as primary and finding workflow state as secondary metadata
### Rationale
The matrix must answer whether a tenant currently differs from the baseline, not whether someone already triaged the resulting finding. A current compare may still indicate drift even if a related finding is acknowledged, risk accepted, or closed. Therefore cell and summary counts must derive from the latest compare truth for the selected baseline and visible tenant, while any finding workflow state remains optional secondary metadata for drilldown or badges.
### Alternatives considered
- Count only open findings: rejected because terminal workflow states can hide still-present technical drift.
- Ignore findings entirely: rejected because the operator still needs severity and existing finding drilldown continuity.
## Decision: Reuse existing stale-result semantics and reference-snapshot freshness rules
### Rationale
The repo already defines baseline compare staleness in `BaselineCompareSummaryAssessor`, including a seven-day age threshold and `stale_result` evidence semantics. The matrix should reuse that rule and additionally mark results stale when the latest completed compare predates the current effective baseline snapshot. This keeps tenant and workspace compare freshness language aligned.
### Alternatives considered
- Invent a matrix-specific freshness threshold: rejected because it would create a parallel freshness language.
- Use only elapsed time: rejected because a result can become stale immediately when the baseline reference snapshot changes.
## Decision: Reuse evidence-gap and operator-explanation metadata for trust and ambiguity signals
### Rationale
`baseline_compare` run context already records evidence gaps, uncovered policy types, and related diagnostics. `BaselineCompareExplanationRegistry` and the existing trustworthiness semantics already distinguish trustworthy, limited-confidence, diagnostic-only, and unusable result conditions. The matrix should reuse those signals at tenant and subject level, while mapping per-cell ambiguity from evidence-gap reason codes such as ambiguous or low-confidence match situations.
### Alternatives considered
- Add a new trust or certainty taxonomy for the matrix: rejected because the compare domain already has operator-trust semantics.
- Hide uncertainty behind summary prose only: rejected because the spec explicitly forbids hidden ambiguity.
## Decision: Use existing `CanonicalNavigationContext` patterns for matrix drilldown continuity
### Rationale
The codebase already uses `CanonicalNavigationContext`, `RelatedNavigationResolver`, and `OperationRunLinks` to preserve source context and return paths across related surfaces. The matrix only needs source baseline, source subject, and return path continuity. Reusing canonical navigation context is narrower than extending `PortfolioArrivalContext`, which is currently specialized for backup-health and recovery-evidence triage families.
### Alternatives considered
- Reuse `PortfolioArrivalContext`: rejected because the matrix is not a backup or recovery concern-family workflow and would pollute that token's allowlist and semantics.
- Introduce a brand-new context token type: rejected because `CanonicalNavigationContext` already solves the generic return-path problem.
## Decision: Implement `Compare assigned tenants` as visible-tenant fan-out over existing tenant compare starts, without a workspace umbrella run
### Rationale
`BaselineCompareService::startCompare()` and tenant-scoped `OperationRun` creation already implement canonical baseline compare start semantics, deduplication, queued feedback, and run observability. V1 compare-all should iterate the visible assigned tenant set and reuse those existing starts. Aggregate progress on the matrix page can then be derived from the underlying tenant runs. This avoids inventing a second run identity or a workspace-level shadow batch truth.
### Alternatives considered
- Create one workspace umbrella `OperationRun`: rejected because the spec explicitly avoids a new parallel run truth and the repo already treats tenant compare as tenant-owned execution.
- Start compare directly in the page without reusing the service: rejected because it would duplicate preconditions, snapshot truth resolution, and dedupe behavior.
## Decision: Cover the feature primarily with focused feature tests plus one browser smoke test
### Rationale
The matrix combines query-heavy aggregation, RBAC filtering, and one new interactive page surface. Focused feature tests should verify aggregation correctness, compare-all launch behavior, and authorization semantics. One browser smoke test is sufficient to prove the two-dimensional page and its core drilldown or action affordances render correctly under Livewire and Filament, without turning the spec into a browser-heavy suite.
### Alternatives considered
- Browser-test the full feature exhaustively: rejected because most business truth can be validated faster and more deterministically through feature tests.
- Limit tests to one page smoke test: rejected because the feature's core risks are incorrect aggregation, stale or ambiguous truth, and hidden-tenant leakage.

View File

@ -0,0 +1,288 @@
# Feature Specification: Workspace Baseline Compare Matrix V1
**Feature Branch**: `190-baseline-compare-matrix`
**Created**: 2026-04-11
**Status**: Draft
**Input**: User description: "Spec 190 - Workspace Baseline Compare Matrix V1"
## Spec Candidate Check *(mandatory — SPEC-GATE-001)*
- **Problem**: Workspace operators can define baselines, assign tenants, and review tenant-level compare truth, but they still cannot see one workspace-scoped picture of how all assigned visible tenants deviate from the same baseline standard.
- **Today's failure**: Portfolio governance remains tenant-by-tenant. Operators must open tenants individually, cannot rank the worst deviators quickly, cannot see which subjects drift across many tenants, and can misread stale, absent, or ambiguous compare truth as calm.
- **User-visible improvement**: One workspace matrix shows the reference baseline, the visible assigned tenants, the breadth of deviation by tenant and subject, freshness, and trust, with direct drilldown into existing compare and finding surfaces.
- **Smallest enterprise-capable version**: A read-only workspace-scoped matrix for one `BaselineProfile` at a time, powered by existing snapshot, compare, finding, and `OperationRun` truth, plus one `Compare assigned tenants` action.
- **Explicit non-goals**: Promotion, rollout orchestration, approval, cross-workspace compare, persisted portfolio compare reports, manual match overrides, remediation actions, compliance mapping, exports, and a generalized standardization framework.
- **Permanent complexity imported**: One new workspace page, one derived aggregation shape for rows, columns, cells, and summaries, additional filter and sort semantics, compare-all orchestration over existing runs, drilldown continuity, and focused feature/browser regression coverage. No new persisted domain artifact is imported.
- **Why now**: The baseline, finding, `OperationRun`, and workspace portfolio foundations already exist. Without a workspace-level compare projection, a high-value MSP/operator workflow stays fragmented even though the underlying truth is already in the product.
- **Why not local**: Tenant-local compare and finding pages cannot answer cross-tenant questions such as "which visible tenant deviates most from this baseline right now?" or "which subject is recurring across the portfolio?" without repeated context switching and manual aggregation by the operator.
- **Approval class**: Core Enterprise
- **Red flags triggered**: `New Achsen` because a matrix can accidentally become a second truth layer, and `New Meta-Infrastructure` because cross-tenant aggregation can tempt a generalized compare platform. Defense: V1 is baseline-referenced, read-only, live-aggregated from existing truth, and explicitly forbids new persisted cross-tenant compare artifacts.
- **Score**: Nutzen: 2 | Dringlichkeit: 2 | Scope: 2 | Komplexitaet: 1 | Produktnaehe: 2 | Wiederverwendung: 2 | **Gesamt: 11/12**
- **Decision**: approve
## Spec Scope Fields *(mandatory)*
- **Scope**: workspace
- **Primary Routes**:
- `/admin/baseline-profiles` as the existing workspace list and selection entry for baseline standards
- `/admin/baseline-profiles/{record}` as the existing baseline profile detail that gains the canonical matrix entry action
- `/admin/baseline-profiles/{record}/compare-matrix` as the new canonical workspace route for one baseline compare matrix
- `/admin/t/{tenant}/baseline-compare` as the existing tenant drilldown landing
- `/admin/findings` plus finding detail as existing finding drilldown surfaces
- Monitoring -> Operation Run Detail for compare-all follow-up and tenant compare run truth
- **Data Ownership**:
- Workspace-owned reference truth remains `BaselineProfile`, `BaselineSnapshot`, `BaselineSnapshotItem`, and baseline-to-tenant assignment records.
- Tenant-owned compare truth remains the existing tenant compare outputs, drift findings, and `OperationRun` rows created for compare execution.
- The matrix itself is a read-only workspace projection over visible assigned tenants and does not persist a new cross-tenant compare result, report, or standardization artifact.
- **RBAC**:
- Workspace membership plus `WORKSPACE_BASELINES_VIEW` is required to open the matrix and inspect workspace baseline reference truth.
- `WORKSPACE_BASELINES_MANAGE` is required to start `Compare assigned tenants`.
- In-scope members who can view the baseline but lack `WORKSPACE_BASELINES_MANAGE` still see `Compare assigned tenants` on the baseline detail and matrix surfaces in a disabled state with helper text; forced execution remains `403`.
- Matrix columns are limited to tenants the actor may already see under existing workspace and tenant visibility rules; drilldowns continue to enforce their destination capabilities such as `TENANT_VIEW` and `TENANT_FINDINGS_VIEW`.
- Non-members of the workspace or a tenant scope remain `404`; in-scope members missing the required capability remain `403`.
## UI/UX Surface Classification *(mandatory when operator-facing surfaces are changed)*
| Surface | Surface Type | Primary Inspect/Open Model | Row Click | Secondary Actions Placement | Destructive Actions Placement | Canonical Collection Route | Canonical Detail Route | Scope Signals | Canonical Noun | Critical Truth Visible by Default | Exception Type |
|---|---|---|---|---|---|---|---|---|---|---|---|
| Baseline profile detail | Detail / action-launch surface | Explicit `Open compare matrix` header action | forbidden | Detail header actions | Existing archive remains in detail header | `/admin/baseline-profiles` | `/admin/baseline-profiles/{record}` | Active workspace, baseline status, assignment count, reference snapshot truth | Baseline profile | Whether this baseline is compare-ready and how many tenants are assigned | none |
| Workspace baseline compare matrix | Workspace matrix / report surface | Explicit tenant, subject, and cell drilldowns | forbidden | Header toolbar, summary strips, and focused in-matrix controls | none | `/admin/baseline-profiles/{record}/compare-matrix` | Same route with focused tenant or subject state, plus existing drilldowns to tenant compare or finding surfaces | Active workspace, selected baseline profile, reference snapshot, visible tenant count, filter scope, freshness legend | Baseline compare matrix | Tenant deviation breadth, subject breadth, freshness, ambiguity, and not-compared truth | matrix-grid surface |
| Tenant compare and finding drilldowns from the matrix | Existing detail/list drilldown surfaces | Explicit matrix drilldown links only | forbidden | Existing local actions remain in their current placements | Existing destructive or lifecycle actions remain where already defined | `/admin/t/{tenant}/baseline-compare` and `/admin/findings` | Existing tenant compare and finding detail routes | Tenant context, source baseline profile, source subject focus, arrival source | Baseline compare / Finding | Why the operator drilled from the matrix and what subject or deviation is being followed up | canonical-navigation extension |
## Operator Surface Contract *(mandatory when operator-facing surfaces are changed)*
| Surface | Primary Persona | Surface Type | Primary Operator Question | Default-visible Information | Diagnostics-only Information | Status Dimensions Used | Mutation Scope | Primary Actions | Dangerous Actions |
|---|---|---|---|---|---|---|---|---|---|
| Baseline profile detail | Workspace manager | Detail / launch surface | Is this the right reference baseline, and should I open or refresh its matrix now? | Profile name and status, effective snapshot, assignment count, matrix entry, last compare coverage summary | Snapshot capture history, run diagnostics, low-level compare details | baseline lifecycle, snapshot completeness, visible assignment coverage | `TenantPilot only` for profile edits, `simulation only` for compare starts | Open compare matrix, Compare assigned tenants, Capture baseline | Archive baseline profile |
| Workspace baseline compare matrix | Workspace operator | Workspace matrix / drilldown hub | Which visible assigned tenants diverge from this baseline, how fresh and trustworthy is that truth, and where should I drill next? | Reference baseline, reference snapshot, visible-vs-assigned counts, per-tenant summaries, per-subject summaries, cell states, freshness legend, trust legend | Matching method detail, evidence-gap reasons, run identifiers, raw subject keys | compare result state, freshness, trust or ambiguity, visible severity breadth | `simulation only` for compare starts; otherwise read-only | Compare assigned tenants, filter matrix, focus subject, open tenant compare, open finding follow-up | none |
| Tenant compare and finding drilldowns from the matrix | Workspace operator continuing investigation | Existing follow-up surface | What exactly is wrong for this tenant and subject, and what existing workflow should I use next? | Tenant context, baseline context, subject context, current compare or finding truth, return path | Raw evidence payloads, underlying run payload, low-level diagnostic detail | tenant compare readiness, finding workflow state, drift severity, evidence completeness | Existing destination-specific mutation scope only | View compare context, view finding context, return to matrix | Existing destination-specific dangerous actions only |
## Proportionality Review *(mandatory when structural complexity is introduced)*
- **New source of truth?**: no
- **New persisted entity/table/artifact?**: no
- **New abstraction?**: yes, one narrow request-scoped matrix builder plus one centralized badge adapter for matrix state semantics
- **New enum/state/reason family?**: no
- **New cross-domain UI framework/taxonomy?**: no
- **Current operator problem**: The product has strong tenant-level baseline compare truth but still lacks a portfolio answer to "who deviates most from this baseline right now?"
- **Existing structure is insufficient because**: Existing tenant compare and finding surfaces are tenant-first or item-first. They cannot show visible-set deviation breadth, freshness, and trust across all assigned tenants without forcing the operator to aggregate manually.
- **Narrowest correct implementation**: One baseline-scoped matrix page, one narrow request-scoped matrix builder, one centralized badge adapter that reuses canonical compare truth, plus one batch compare trigger that reuses current snapshots, compare outputs, findings, run truth, and drilldown surfaces.
- **Ownership cost**: One new page, one narrow derived builder, one centralized badge adapter, derived aggregation queries, one narrow matrix-grid presentation exception, additional filter/sort coverage, and focused feature/browser regression tests.
- **Alternative intentionally rejected**: A new `CrossTenantCompare`, stored report model, generalized tenant-vs-tenant compare engine, promotion workflow, or broader standardization platform.
- **Release truth**: current-release portfolio governance visibility
## User Scenarios & Testing *(mandatory)*
### User Story 1 - Scan visible drift across assigned tenants (Priority: P1)
As a workspace operator, I want one matrix for one baseline profile so I can see which visible assigned tenants and subjects deviate most without opening tenants one by one.
**Why this priority**: This is the core product outcome. If the matrix cannot answer the portfolio question directly, the feature misses its reason to exist.
**Independent Test**: Open the matrix for a baseline profile with mixed compare outcomes and verify that the reference baseline, visible tenant set, per-tenant summaries, per-subject summaries, and cell states are all understandable from one page.
**Acceptance Scenarios**:
1. **Given** a usable baseline reference and multiple visible assigned tenants with existing compare outcomes, **When** the operator opens the compare matrix, **Then** the page shows one column per visible assigned tenant, one row per baseline subject, and truthful cell states for match, differ, missing, ambiguous, stale, or not compared.
2. **Given** the actor can see only a subset of assigned tenants, **When** the matrix opens, **Then** only visible tenants appear and every count and summary stays scoped to that visible set.
---
### User Story 2 - Refresh compare truth for the visible assigned set (Priority: P1)
As a workspace operator, I want to compare all visible assigned tenants in one action so I can refresh portfolio truth without visiting each tenant.
**Why this priority**: The matrix becomes operationally useful only if an authorized operator can refresh stale portfolio truth from the same workspace surface.
**Independent Test**: Start `Compare assigned tenants` from the baseline detail or matrix page and verify that the product reuses normal compare execution semantics, shows honest run progress, and reflects partial completion accurately.
**Acceptance Scenarios**:
1. **Given** an authorized operator and visible assigned tenants eligible for compare, **When** the operator confirms `Compare assigned tenants`, **Then** the system starts or reuses normal tenant compare execution for each eligible visible tenant and returns the operator to honest queued or running matrix truth.
2. **Given** some visible assigned tenants succeed and others fail or remain running, **When** the matrix refreshes, **Then** the page distinguishes completed, running, failed, stale, and never-compared tenants without collapsing them into a single calm state.
---
### User Story 3 - Drill from the matrix into existing follow-up surfaces (Priority: P2)
As a workspace operator, I want cell, tenant, and subject drilldowns so I can move from the portfolio view into existing compare or finding workflows without reconstructing context.
**Why this priority**: The matrix must be a decision surface, not a dead-end report.
**Independent Test**: Open a differing, missing, or ambiguous cell and confirm the drilldown lands in an existing tenant compare or finding surface with the tenant, subject, and baseline context still understandable.
**Acceptance Scenarios**:
1. **Given** a differing or missing matrix cell, **When** the operator opens its drilldown, **Then** the product lands in an existing tenant compare or finding context for that tenant and subject with the baseline reference still clear.
2. **Given** a subject row that deviates across several visible tenants, **When** the operator focuses that subject, **Then** the product shows the subject-first picture across the visible tenant set without inventing a new persisted subject report.
---
### User Story 4 - Stay honest in degraded or low-trust conditions (Priority: P2)
As a workspace operator, I want empty, partial, stale, and ambiguous states to stay explicit so the matrix never reads as healthier than the underlying truth.
**Why this priority**: False calmness would be worse than having no matrix at all.
**Independent Test**: Open the matrix for a baseline profile with no usable snapshot, no prior compare runs, stale results, and ambiguous matches, and verify that each degraded condition is visibly distinct from a normal match state.
**Acceptance Scenarios**:
1. **Given** the baseline profile has no usable reference snapshot, **When** the operator opens the matrix, **Then** the page does not imply compare truth exists and instead shows one clear next-step call to action.
2. **Given** compare truth is ambiguous, stale, or absent, **When** the matrix renders, **Then** those cells and summaries remain visibly distinct from matches and point the operator toward the right next compare or drilldown action.
### Edge Cases
- A baseline profile has assigned tenants but no usable reference snapshot; the matrix must block compare interpretation instead of rendering empty matches.
- A baseline profile has assigned tenants but the current actor can see none of them; the page must explain visible-set scoping without leaking hidden tenant counts or names.
- A visible assigned tenant has never been compared against this baseline; the cell and tenant summary must read as `Not compared` rather than healthy.
- A visible tenant has compare results against an older baseline reference than the current one; the result must read as stale rather than current.
- Subject identity is ambiguous for one or more tenants; the matrix must show ambiguity instead of forcing a match or differ conclusion.
- `Compare assigned tenants` starts successfully for some visible tenants but not others; the surface must stay honest about partial start and partial completion.
- One policy type has high trust while another has low trust; filtering by policy type must preserve the correct trust explanation for the visible slice.
## Requirements *(mandatory)*
**Constitution alignment (required):** This feature adds one workspace-scoped operator surface and reuses existing compare execution. It introduces no new Microsoft Graph contract path, no new write workflow beyond compare start, and no new source of baseline or finding truth. `Compare assigned tenants` remains a simulation-only governance action that must respect confirmation, tenant isolation, auditability through existing run truth, and focused regression coverage.
**Constitution alignment (PROP-001 / ABSTR-001 / PERSIST-001 / STATE-001 / BLOAT-001):** The feature stays within the default bias of deriving before persisting. The matrix is a thin derived projection over existing baseline snapshots, compare outputs, findings, and run context. V1 must not create new persistence, a generalized compare framework, or a new persisted state family just to support the matrix.
**Constitution alignment (OPS-UX):** `Compare assigned tenants` reuses existing `baseline_compare` run semantics only. Toast feedback remains intent-only, progress remains on active-ops and run-detail surfaces, and terminal notification behavior remains initiator-aware. `OperationRun.status` and `OperationRun.outcome` remain service-owned via `OperationRunService`. Any summary counts written to runs remain numeric-only and must use canonical summary keys. Scheduled or system-run semantics stay unchanged: no initiator means no terminal DB notification, and Monitoring remains the audit surface. Regression coverage must prove that compare-all does not invent shadow statuses or a second batch truth.
**Constitution alignment (RBAC-UX):** This feature affects the workspace-admin plane on `/admin/baseline-profiles` and the tenant follow-up plane on `/admin/t/{tenant}/baseline-compare`, plus existing finding drilldowns. Cross-plane access remains deny-as-not-found. Non-members of the workspace or tenant scope receive `404`. In-scope members missing `WORKSPACE_BASELINES_VIEW`, `WORKSPACE_BASELINES_MANAGE`, `TENANT_VIEW`, or `TENANT_FINDINGS_VIEW` as required by the destination surface receive `403`. Server-side enforcement remains mandatory for matrix view, compare start, and every drilldown. Global search behavior stays unchanged; the matrix itself is not introduced as a separate global-search result.
**Constitution alignment (OPS-EX-AUTH-001):** Not applicable beyond reaffirming that this feature does not introduce auth-handshake HTTP behavior.
**Constitution alignment (BADGE-001):** Any new matrix legends, badges, or label mappings for match, differ, missing, ambiguous, not compared, stale result, freshness, or trust must stay centralized and derived from canonical compare truth. No page-local color language may redefine those meanings. Tests must cover any new or changed centralized mappings.
**Constitution alignment (UI-FIL-001):** The feature should use a Filament page, Filament header actions, sections, stats, filters, and shared badge or alert primitives wherever possible. The tenant-by-subject grid itself may require custom Blade markup because standard one-axis tables do not represent two-dimensional matrix inspection well enough; this is the only approved exception. Even with that exception, buttons, alerts, legends, empty states, and state badges must still use shared primitives and central semantics rather than page-local styling rules.
**Constitution alignment (UI-NAMING-001):** Operator-facing vocabulary must stay consistent across the matrix, baseline detail, run titles, and notifications: `Baseline compare matrix`, `Compare assigned tenants`, `Reference snapshot`, `Visible tenants`, `Match`, `Differs`, `Missing`, `Ambiguous match`, `Not compared`, and `Stale result`. Internal implementation terms such as `cross-tenant compare engine`, `matrix resolver`, or `portfolio deviation artifact` must stay out of primary labels.
**Constitution alignment (UI-CONST-001 / UI-SURF-001 / UI-HARD-001 / UI-EX-001 / UI-REVIEW-001):** The new surface is classified as a workspace matrix/report because the operator task is cross-tenant inspection, not CRUD. It has one primary inspect model: explicit tenant, subject, and cell drilldowns. Row click is forbidden because matrix rows and columns each represent different drilldown intents. Secondary actions live in the page header and matrix summaries. The matrix has no destructive actions. Its canonical collection route and canonical detail route are the same matrix route with focused state, while deeper investigation moves into existing tenant compare or finding routes. Scope signals must show active workspace, selected baseline profile, reference snapshot, visible-vs-assigned counts, and filter state. The critical truth visible by default is deviation breadth plus freshness and trust for the visible tenant set.
**Constitution alignment (OPSURF-001):** Default-visible matrix content must remain operator-first: baseline reference, visible tenant set, per-tenant deviation summary, per-subject breadth, freshness, and low-trust signals. Diagnostics such as raw subject keys, matching mechanics, and run identifiers must stay secondary. Status dimensions must remain separate: compare result state, freshness, trust or ambiguity, and visible severity breadth. `Compare assigned tenants` must communicate `simulation only` before execution. Workspace and tenant context must remain explicit in the matrix, the drilldowns, and the return path.
**Constitution alignment (UI-SEM-001 / LAYER-001 / TEST-TRUTH-001):** Direct exposure of tenant compare truth alone is insufficient because the operator needs a visible-set portfolio decision surface. The solution must stay a thin derived aggregation layer rather than a second truth system. Tests must focus on business consequences such as wrong visible-set counts, stale or absent truth being mistaken for calm, ambiguous matches being hidden, and drilldowns losing context.
**Constitution alignment (Filament Action Surfaces):** The Action Surface Contract remains satisfied. `BaselineProfileResource` keeps its existing primary inspect model and adds `Open compare matrix` plus `Compare assigned tenants` in the detail header. The new matrix page has no row-click primary open, no redundant `View` action, no empty action groups, and no destructive actions. UI-FIL-001 remains satisfied with the narrow matrix-grid exception described above.
**Constitution alignment (UX-001 — Layout & Information Architecture):** The new matrix page must use sections or cards for reference truth, summaries, filters, and the matrix body. Because the core surface is two-dimensional, the matrix body is a narrow UX-001 exemption from standard Filament table layout. That exemption does not remove the need for search/filter/sort over core dimensions, a specific empty-state title plus explanation plus exactly one call to action, or BADGE-001-compliant state markers. No new create or edit form is introduced.
### Functional Requirements
- **FR-190-001 Workspace entry surface**: The system MUST provide a new workspace-scoped compare matrix page for one baseline profile at a time, reachable from existing workspace baseline context and directly from the selected baseline profile detail.
- **FR-190-002 Reference truth visibility**: The matrix MUST identify the selected baseline profile, the profile status, the reference snapshot or effective baseline state in use, the total assigned tenants, and the currently visible tenant count.
- **FR-190-003 Explicit compare source truth**: The matrix MUST derive from one explicit baseline reference state. If no usable reference state exists, the page MUST not imply compare truth and MUST explain the blocked state.
- **FR-190-004 Visible target set**: Matrix columns MUST be limited to tenants assigned to the selected baseline profile in the same workspace that the current actor is allowed to see. Hidden tenants MUST NOT leak by name, count, or indirect summary.
- **FR-190-005 Subject identity reuse**: Matrix rows MUST reuse the existing baseline subject identity and matching strategy. V1 MUST NOT introduce a competing subject identity system.
- **FR-190-006 Cell truth states**: Each subject-by-tenant cell MUST resolve to a truthful visible outcome representing at least `Match`, `Differs`, `Missing`, `Ambiguous match`, `Not compared`, or `Stale result`, even if the final UI wording is slightly adjusted.
- **FR-190-007 No false calmness**: `Ambiguous match`, `Not compared`, `Stale result`, and insufficient-basis conditions MUST remain visibly distinct from `Match` and MUST NOT contribute to healthy counts as if they were matches.
- **FR-190-008 Per-tenant summary**: The page MUST show a summary for each visible tenant including compared subject count, differing count, missing count, ambiguous count, highest visible severity or attention level, and compare freshness.
- **FR-190-009 Per-subject summary**: The page MUST show a summary for each subject including deviation breadth across visible tenants, missing breadth, ambiguous breadth, and highest visible severity or attention level.
- **FR-190-010 Filtering**: The matrix MUST support filtering by policy type, state group at minimum (`All`, `Deviations only`, `Missing only`, `Ambiguous only`, and a stale/no-result view), and visible severity band.
- **FR-190-011 Sorting**: The matrix MUST support sorting by tenant name, tenant deviation count, tenant freshness urgency, and subject deviation breadth.
- **FR-190-012 Compare-all availability**: Authorized users MUST be able to start `Compare assigned tenants` from the matrix page and from the selected baseline profile detail when the baseline has a usable reference state.
- **FR-190-013 Compare-all execution model**: `Compare assigned tenants` MUST start or reuse normal tenant compare execution only for eligible visible assigned tenants and MUST NOT invent a second persisted batch truth or shadow status model.
- **FR-190-014 Compare-all honesty**: The matrix MUST surface queued, running, completed, failed, partial, stale, and never-compared truth from the underlying compare runs and findings without collapsing mixed outcomes into one success state.
- **FR-190-015 Freshness visibility**: The matrix MUST show when each visible tenant was last compared against the selected baseline reference, whether that result predates the current reference state, and whether the tenant has never been compared.
- **FR-190-016 Trust visibility**: The matrix MUST show the applicable matching or identity trust signal for low-confidence cells or subjects, including ambiguous matches and missing compare basis, without forcing raw technical diagnostics into the primary scan path.
- **FR-190-017 Cell drilldown**: A matrix cell with meaningful follow-up MUST open an existing tenant compare or finding surface that keeps the tenant, subject, and baseline context understandable on arrival.
- **FR-190-018 Tenant drilldown**: A tenant summary or header interaction MUST open the existing tenant baseline compare landing or equivalent tenant follow-up surface for that tenant under the selected baseline context.
- **FR-190-019 Subject focus**: A subject interaction MUST open or switch to a subject-focused view across the current visible tenant set without creating a new persisted subject report artifact.
- **FR-190-020 Existing-truth-first aggregation**: V1 MUST derive matrix cells, summaries, freshness, and trust from existing baseline snapshots, compare outputs, findings, inventory subject identity, and `OperationRun` context before considering any new stored projection.
- **FR-190-021 No new portfolio persistence**: V1 MUST NOT introduce a first-class `CrossTenantCompare`, `CrossTenantCompareResult`, `PortfolioDeviationReport`, or equivalent persisted artifact unless a later spec proves live aggregation insufficient.
- **FR-190-022 RBAC-safe degradation**: Whenever the actor can see only part of the assigned tenant set, every column, summary, filter total, and subject breadth count MUST be computed from the visible set only or explicitly labeled as visible-set-only.
- **FR-190-023 Empty and degraded states**: The page MUST provide explicit operator-readable states for no usable snapshot, no assigned tenants, no visible tenants, no compare results, partially complete compare coverage, and all-low-trust results.
- **FR-190-024 Read-only boundary**: V1 MUST remain read-only apart from starting compare execution. It MUST NOT add remediation, restore, promotion, exception, approval, or manual match-override actions to matrix cells or summaries.
- **FR-190-025 Centralized semantics**: Any new matrix-specific legends, labels, or badges MUST stay centrally mapped from existing canonical truth and MUST NOT create a new persisted status family.
- **FR-190-026 Automated coverage**: Automated coverage MUST verify the core matrix flow, compare-all, visible-set RBAC filtering, ambiguous matching, stale/no-result honesty, policy-type filtering, subject focus, drilldown continuity, degraded-state behavior, and query-bounded read shapes for wide visible tenant and subject sets.
## Non-Goals
- No policy promotion or push between tenants
- No rollout waves, rings, or fleet orchestration
- No approval workflow or accepted-deviation flow
- No cross-workspace compare
- No ad hoc tenant-A-vs-tenant-B compare
- No manual match override workflow
- No deep field-by-field compare editor
- No compare export or stored report artifact in V1
- No portfolio-level finding persistence beyond existing truth
- No automatic remediation
## Assumptions
- Existing `BaselineProfile`, `BaselineSnapshot`, tenant compare outputs, findings, and `OperationRun` truth remain the canonical building blocks for V1.
- One baseline profile is the reference frame for one matrix at a time.
- Existing baseline subject identity and matching strategy are already authoritative enough to support portfolio aggregation, as long as ambiguity stays explicit.
- `Compare assigned tenants` can fan out to existing tenant compare execution without requiring a second persisted batch artifact.
- Existing tenant compare and finding surfaces can accept bounded canonical navigation context so the operator can understand why they drilled from the matrix.
## Dependencies
- Existing workspace baseline resources and snapshot truth
- Existing tenant baseline compare landing and compare execution
- Existing finding generation, finding detail, and finding triage surfaces
- Existing `baseline_compare` `OperationRun` lifecycle and Monitoring surfaces
- Existing `CanonicalNavigationContext`, `RelatedNavigationResolver`, and return-path helpers for bounded matrix drilldown continuity
## Risks
- The matrix could expand into a generalized standardization platform unless V1 stays baseline-referenced and read-only.
- Low-trust subject matching could be over-read as certainty unless ambiguity stays highly visible.
- Workspace aggregation could leak hidden tenants unless every summary is computed from the visible set only.
- Very wide tenant or subject sets could reduce scanability unless filtering and subject focus stay first-class.
- Old compare results could be misread as current truth unless freshness stays explicit and prominent.
## Review Questions
- Does the matrix clearly show which baseline reference state it is using?
- Does V1 aggregate existing compare and finding truth instead of inventing a second compare system?
- Are stale, absent, and ambiguous states visibly distinct from matches?
- Are visible-set RBAC boundaries fully preserved in columns, summaries, and drilldowns?
- Does `Compare assigned tenants` stay consistent with existing `OperationRun` semantics?
- Does the matrix stay operator-first rather than becoming a technical dashboard?
- Does drilldown land in existing follow-up workflows with enough context preserved?
- Has the spec stayed read-only apart from compare start and avoided new persistence?
## Definition of Done
This feature is complete when:
- a workspace-scoped compare matrix exists for one selected baseline profile,
- the matrix uses existing baseline, compare, finding, and run truth rather than a new persisted portfolio artifact,
- visible tenants are strictly filtered by RBAC and hidden tenants do not leak through summaries,
- `Compare assigned tenants` is available to authorized operators and reuses normal compare execution,
- per-tenant deviation and freshness summary is visible,
- per-subject deviation breadth is visible,
- ambiguous, stale, and not-compared states are rendered honestly,
- drilldown to existing tenant compare or finding surfaces works with understandable context,
- no promotion, approval, remediation, or manual match-override logic has been introduced,
- feature and, where appropriate, browser tests cover the core operator flows.
## 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 resource | `app/Filament/Resources/BaselineProfileResource.php` and `app/Filament/Resources/BaselineProfileResource/Pages/ViewBaselineProfile.php` | Existing list-header actions remain | Existing explicit `View` inspect affordance remains | Existing `View`; `Edit` and `Archive baseline profile` remain under `More` where already used | None | Existing create CTA remains | `Open compare matrix`, `Compare assigned tenants`, existing `Capture baseline`, `Edit`, and `Archive baseline profile` | Existing save/cancel unchanged | Yes | `Open compare matrix` requires `WORKSPACE_BASELINES_VIEW`. `Compare assigned tenants` requires `WORKSPACE_BASELINES_MANAGE`, is confirmation-gated, is `simulation only`, and remains visible-disabled with helper text for in-scope members who can view but cannot manage. `Archive baseline profile` remains the only destructive action and stays confirmation-gated. |
| Workspace baseline compare matrix page | New workspace page at `app/Filament/Pages/BaselineCompareMatrix.php` or equivalent | `Compare assigned tenants`, `Back to baseline profile` | Explicit tenant, subject, and cell drilldowns only; no row click | Inline `Focus subject` at most; all other follow-up opens use explicit drilldowns | None | One context-specific CTA such as `Capture baseline` or `Compare assigned tenants` depending on the blocked or empty state | Same as header actions | Not applicable | Yes through underlying compare runs | Action Surface Contract remains satisfied. The matrix is a narrow grid-surface exemption because a two-dimensional tenant-by-subject view is not a normal one-axis table. `Compare assigned tenants` remains visible-disabled with helper text for in-scope users missing manage capability, and forced execution still fails with `403`. No destructive actions are added. |
| Tenant compare and finding drilldowns from matrix context | `app/Filament/Pages/BaselineCompareLanding.php` and existing finding resource/detail surfaces | Existing destination actions remain in place | Existing destination inspect models remain in place | Existing destination row actions remain unchanged | Existing destination bulk actions remain unchanged | Existing destination empty-state behavior remains, but matrix arrival context must preserve return meaning | Existing destination view-header actions remain | Not applicable | Existing destination audit behavior remains | This spec reuses destination surfaces and adds bounded matrix-arrival context only. It does not add new destructive actions or competing inspect models there. |
### Key Entities *(include if feature involves data)*
- **Baseline compare matrix view**: The derived workspace projection for one selected baseline profile across visible assigned tenants and baseline subjects.
- **Compare target tenant**: A visible tenant assigned to the selected baseline profile whose existing compare truth can be refreshed or inspected.
- **Compare subject row**: The reusable baseline subject identity shown across the visible tenant set.
- **Matrix cell state**: The derived outcome for one subject and one visible tenant, including compare outcome, freshness, and trust.
- **Tenant freshness summary**: The per-tenant view of last compare time, deviation counts, and urgency against the selected baseline reference.
## Success Criteria *(mandatory)*
### Measurable Outcomes
- **SC-190-001**: In validation scenarios with a selected baseline profile and multiple visible assigned tenants, an operator can identify the most divergent visible tenant and the broadest recurring subject from the matrix without opening tenant detail pages first.
- **SC-190-002**: In acceptance coverage, 100% of visible tenants or cells with stale, absent, or ambiguous compare truth remain visually distinct from matched truth.
- **SC-190-003**: An authorized operator can start compare execution for the full visible assigned set in one action and the product shows honest queued, running, completed, or partial progress without inventing a separate batch status language.
- **SC-190-004**: In negative visibility coverage, matrix summaries and counts disclose no hidden tenant identity or hidden-tenant aggregate when the actor can see only part of the assigned set.
- **SC-190-005**: From a differing, missing, or ambiguous cell, the operator can reach an existing tenant-level follow-up surface in one drilldown step with enough preserved context to understand why they arrived there.

View File

@ -0,0 +1,269 @@
# Tasks: Workspace Baseline Compare Matrix V1
**Input**: Design documents from `/specs/190-baseline-compare-matrix/`
**Prerequisites**: `plan.md`, `spec.md`, `research.md`, `data-model.md`, `contracts/baseline-compare-matrix.logical.openapi.yaml`, `quickstart.md`
**Tests**: Tests are REQUIRED for this feature. Use Pest unit coverage for centralized matrix badge semantics, Pest feature coverage for matrix aggregation, page rendering, compare-all fan-out, and RBAC semantics, plus one browser smoke test for the rendered matrix surface and one core interaction.
**Operations**: This feature must reuse existing tenant-owned `baseline_compare` `OperationRun` semantics only. Tasks must preserve the Ops-UX 3-surface feedback contract, avoid any workspace umbrella run or shadow batch truth, keep `OperationRun.status` and `OperationRun.outcome` service-owned, keep reused `summary_counts` canonical via `OperationSummaryKeys` and numeric-only, prevent ad hoc queued, running, or completion database notifications, and keep compare-all feedback limited to canonical queued feedback plus existing Monitoring drilldowns.
**RBAC**: Existing workspace membership and tenant visibility remain authoritative. Tasks must preserve deny-as-not-found `404` behavior for non-members, `403` behavior for in-scope members missing `WORKSPACE_BASELINES_VIEW`, `WORKSPACE_BASELINES_MANAGE`, `TENANT_VIEW`, or `TENANT_FINDINGS_VIEW`, visible-disabled compare actions plus helper text for in-scope members missing capability where the surface contract requires it, and visible-set-only aggregation so hidden tenants never leak through counts, summaries, or drilldowns.
**Operator Surfaces**: The affected operator surfaces are baseline profile detail, the new workspace baseline compare matrix page, tenant compare landing, finding follow-up surfaces, and Monitoring run drilldowns.
**Filament UI Action Surfaces**: Baseline profile detail keeps its existing inspect model and gains `Open compare matrix` plus confirmation-gated `Compare assigned tenants` header actions. The matrix page exposes explicit tenant, subject, cell, and run drilldowns only, forbids row click, adds no destructive actions, and uses one narrow matrix-grid exception for the two-dimensional body.
**Filament UI UX-001**: The new page must keep Filament-native sections, summaries, legends, filters, and empty states. The grid body is the only approved UX-001 exception because a one-axis table cannot represent subject-by-tenant truth.
**Badges**: Matrix state, freshness, and trust surfaces must use centralized badge semantics through `BadgeDomain`, `BadgeCatalog`, and `BadgeRenderer`. No page-local status color or ad hoc legend mapping is allowed.
**Organization**: Tasks are grouped by user story so each story can be implemented and verified independently once the shared matrix foundation is in place.
## Phase 1: Setup (Shared Matrix Harness)
**Purpose**: Prepare reusable fixtures and acceptance scaffolds for multi-tenant baseline matrix scenarios shared across all stories.
- [X] T001 [P] Add reusable visible-set baseline matrix fixture builders in `apps/platform/tests/Feature/Concerns/BuildsBaselineCompareMatrixFixtures.php`
- [X] T002 [P] Stage matrix acceptance scaffolds in `apps/platform/tests/Feature/Baselines/BaselineCompareMatrixBuilderTest.php`, `apps/platform/tests/Feature/Filament/BaselineCompareMatrixPageTest.php`, `apps/platform/tests/Feature/Baselines/BaselineCompareMatrixCompareAllActionTest.php`, `apps/platform/tests/Feature/Rbac/BaselineCompareMatrixAuthorizationTest.php`, and `apps/platform/tests/Browser/Spec190BaselineCompareMatrixSmokeTest.php`
**Checkpoint**: Shared matrix fixtures and empty acceptance seams exist for builder, page, compare-all, RBAC, and browser smoke coverage.
---
## Phase 2: Foundational (Blocking Matrix Core)
**Purpose**: Establish centralized badge semantics, action-surface guards, and reusable query seams that every user story depends on.
**⚠️ CRITICAL**: No user story work should begin until this phase is complete.
- [X] T003 [P] Add centralized matrix state and trust badge coverage in `apps/platform/tests/Unit/Badges/BaselineCompareMatrixBadgesTest.php`
- [X] T004 [P] Add matrix header-action, visible-disabled compare-action helper-text, and grid-surface guard coverage in `apps/platform/tests/Feature/Guards/ActionSurfaceContractTest.php`
- [X] T005 [P] Register matrix state and trust badge semantics in `apps/platform/app/Support/Badges/BadgeDomain.php` and `apps/platform/app/Support/Badges/Domains/BaselineCompareMatrixStateBadge.php`
- [X] T006 [P] Add visible-assignment and latest-compare query helpers for query-bounded matrix loading in `apps/platform/app/Models/BaselineTenantAssignment.php`, `apps/platform/app/Models/OperationRun.php`, and `apps/platform/app/Models/Finding.php`
- [X] T007 Reuse reference snapshot, freshness, and explanation seams for matrix aggregation in `apps/platform/app/Support/Baselines/BaselineSnapshotTruthResolver.php`, `apps/platform/app/Support/Baselines/BaselineCompareSummaryAssessor.php`, `apps/platform/app/Support/Baselines/BaselineCompareEvidenceGapDetails.php`, and `apps/platform/app/Support/Baselines/BaselineCompareExplanationRegistry.php`
**Checkpoint**: Central badge semantics and data-access seams are ready, so page, compare-all, drilldown, and degraded-state work can build on one authoritative matrix foundation.
---
## Phase 3: User Story 1 - Scan Visible Drift Across Assigned Tenants (Priority: P1) 🎯 MVP
**Goal**: Let workspace operators open one baseline-scoped matrix and understand visible tenant drift, subject breadth, freshness, and trust from a single page.
**Independent Test**: Open the matrix for a baseline profile with mixed compare outcomes and verify that reference truth, visible tenant columns, subject rows, summaries, filters, and cell states are truthful without opening tenant pages first.
### Tests for User Story 1
- [X] T008 [P] [US1] Add matrix aggregation coverage for visible-set-only counts, subject-axis derivation, and cell precedence in `apps/platform/tests/Feature/Baselines/BaselineCompareMatrixBuilderTest.php`
- [X] T009 [P] [US1] Add matrix page coverage for reference truth, summaries, filters, and grid rendering in `apps/platform/tests/Feature/Filament/BaselineCompareMatrixPageTest.php`
### Implementation for User Story 1
- [X] T010 [US1] Implement derived reference, tenant, subject, and cell bundle assembly in `apps/platform/app/Support/Baselines/BaselineCompareMatrixBuilder.php`
- [X] T011 [US1] Add the workspace matrix page class, route state, and filter schema in `apps/platform/app/Filament/Pages/BaselineCompareMatrix.php`
- [X] T012 [US1] Render reference sections, visible-set summaries, legends, and the subject-by-tenant grid in `apps/platform/resources/views/filament/pages/baseline-compare-matrix.blade.php`
- [X] T013 [US1] Add `Open compare matrix` header navigation on baseline detail in `apps/platform/app/Filament/Resources/BaselineProfileResource/Pages/ViewBaselineProfile.php`
- [X] T014 [US1] Register the matrix page entry seam on the baseline resource in `apps/platform/app/Filament/Resources/BaselineProfileResource.php` and `apps/platform/app/Filament/Resources/BaselineProfileResource/Pages/ViewBaselineProfile.php`
- [X] T015 [US1] Run focused US1 verification from `specs/190-baseline-compare-matrix/quickstart.md` against `apps/platform/tests/Unit/Badges/BaselineCompareMatrixBadgesTest.php`, `apps/platform/tests/Feature/Baselines/BaselineCompareMatrixBuilderTest.php`, and `apps/platform/tests/Feature/Filament/BaselineCompareMatrixPageTest.php`
**Checkpoint**: Operators can scan one baseline-scoped visible-set matrix and identify the most divergent tenants and subjects without leaving the workspace surface.
---
## Phase 4: User Story 2 - Refresh Compare Truth For The Visible Assigned Set (Priority: P1)
**Goal**: Let authorized operators trigger compare across all visible assigned tenants from the baseline detail or matrix surface without inventing a workspace umbrella run.
**Independent Test**: Start `Compare assigned tenants` from the baseline detail or matrix page and verify that eligible visible tenants reuse normal compare execution, blocked tenants stay explicit, and no second batch truth is created.
### Tests for User Story 2
- [X] T016 [P] [US2] Add compare-all fan-out and Ops-UX regression coverage for queued, already-queued, blocked, no-umbrella-run, service-owned run transitions, canonical `summary_counts`, and no ad hoc non-terminal database notifications in `apps/platform/tests/Feature/Baselines/BaselineCompareMatrixCompareAllActionTest.php`
- [X] T017 [P] [US2] Extend compare-start surface, confirmation, and visible-disabled helper-text coverage for `Compare assigned tenants` in `apps/platform/tests/Feature/Filament/BaselineProfileCompareStartSurfaceTest.php` and `apps/platform/tests/Feature/Guards/ActionSurfaceContractTest.php`
### Implementation for User Story 2
- [X] T018 [US2] Implement visible-tenant batch compare start reuse in `apps/platform/app/Services/Baselines/BaselineCompareService.php`
- [X] T019 [US2] Add confirmation-gated `Compare assigned tenants` execution, honest launch summaries, and visible-disabled helper text on the matrix page in `apps/platform/app/Filament/Pages/BaselineCompareMatrix.php` and `apps/platform/app/Support/Rbac/UiEnforcement.php`
- [X] T020 [US2] Add confirmation-gated `Compare assigned tenants` execution with capability-gated disabled state on baseline detail in `apps/platform/app/Filament/Resources/BaselineProfileResource/Pages/ViewBaselineProfile.php` and `apps/platform/app/Support/Rbac/UiEnforcement.php`
- [ ] T021 [US2] Reuse canonical queued feedback, initiator-only run links, service-owned run transitions, canonical `summary_counts`, and no ad hoc notification or status writes for compare-all outcomes in `apps/platform/app/Support/OpsUx/OperationUxPresenter.php`, `apps/platform/app/Support/OpsUx/OpsUxBrowserEvents.php`, `apps/platform/app/Support/OperationRunLinks.php`, and `apps/platform/app/Services/OperationRunService.php`
- [X] T022 [US2] Run focused US2 verification from `specs/190-baseline-compare-matrix/quickstart.md` against `apps/platform/tests/Feature/Baselines/BaselineCompareMatrixCompareAllActionTest.php` and `apps/platform/tests/Feature/Filament/BaselineProfileCompareStartSurfaceTest.php`
**Checkpoint**: Authorized operators can refresh compare truth for the visible assigned set from one action while Monitoring and tenant compare runs remain the only execution truth.
---
## Phase 5: User Story 3 - Drill From The Matrix Into Existing Follow-Up Surfaces (Priority: P2)
**Goal**: Let operators move from matrix cells, tenant summaries, and subject focus into existing compare, finding, and run detail surfaces without reconstructing context.
**Independent Test**: Open a differing, missing, or ambiguous matrix cell and confirm the product lands on the existing tenant compare or finding path with a bounded return path back to the matrix.
### Tests for User Story 3
- [X] T023 [P] [US3] Add subject-focus and drilldown continuity coverage in `apps/platform/tests/Feature/Filament/BaselineCompareMatrixPageTest.php`
- [X] T024 [P] [US3] Add matrix-to-tenant and matrix-to-finding authorization coverage for `404` versus `403` semantics in `apps/platform/tests/Feature/Rbac/BaselineCompareMatrixAuthorizationTest.php`
### Implementation for User Story 3
- [X] T025 [US3] Add tenant, subject, finding, and run drilldown state handling in `apps/platform/app/Filament/Pages/BaselineCompareMatrix.php`
- [X] T026 [US3] Reuse canonical matrix return-path context in `apps/platform/app/Support/Navigation/CanonicalNavigationContext.php` and `apps/platform/app/Support/Navigation/RelatedNavigationResolver.php`
- [X] T027 [US3] Accept matrix source context on tenant compare and finding follow-up surfaces in `apps/platform/app/Filament/Pages/BaselineCompareLanding.php` and `apps/platform/app/Filament/Resources/FindingResource.php`
- [ ] T028 [US3] Expose bounded back-link and source-hint rendering for matrix arrivals in `apps/platform/app/Filament/Pages/BaselineCompareLanding.php` and `apps/platform/app/Filament/Resources/OperationRunResource.php`
- [X] T029 [US3] Run focused US3 verification from `specs/190-baseline-compare-matrix/quickstart.md` against `apps/platform/tests/Feature/Filament/BaselineCompareMatrixPageTest.php` and `apps/platform/tests/Feature/Rbac/BaselineCompareMatrixAuthorizationTest.php`
**Checkpoint**: The matrix becomes a decision surface instead of a dead-end report because operators can drill directly into existing follow-up workflows and return cleanly.
---
## Phase 6: User Story 4 - Stay Honest In Degraded Or Low-Trust Conditions (Priority: P2)
**Goal**: Keep blocked, stale, ambiguous, not-compared, and low-trust states visibly distinct so the matrix never reads calmer than the underlying truth.
**Independent Test**: Open the matrix for a baseline profile with no usable snapshot, no visible tenants, stale results, ambiguous matches, and uncovered policy types, and verify that none of those states render as healthy matches.
### Tests for User Story 4
- [X] T030 [P] [US4] Add degraded-state page coverage for no usable snapshot, no assigned tenants, no visible tenants, and no compare results in `apps/platform/tests/Feature/Filament/BaselineCompareMatrixPageTest.php`
- [X] T031 [P] [US4] Add ambiguity, stale-result, uncovered-policy-type, policy-type filter honesty, and query-shape guard coverage in `apps/platform/tests/Feature/Baselines/BaselineCompareMatrixBuilderTest.php` and `apps/platform/tests/Feature/Baselines/BaselineComparePerformanceGuardTest.php`
### Implementation for User Story 4
- [X] T032 [US4] Reuse stale-result and evidence-gap semantics inside `apps/platform/app/Support/Baselines/BaselineCompareMatrixBuilder.php`, `apps/platform/app/Support/Baselines/BaselineCompareSummaryAssessor.php`, and `apps/platform/app/Support/Baselines/BaselineCompareEvidenceGapDetails.php`
- [X] T033 [US4] Render explicit blocked, empty, stale, ambiguous, and low-trust states in `apps/platform/resources/views/filament/pages/baseline-compare-matrix.blade.php`
- [X] T034 [US4] Add policy-type, state-group, severity, tenant-sort, subject-sort, and subject-focus state handling in `apps/platform/app/Filament/Pages/BaselineCompareMatrix.php`
- [X] T035 [US4] Run focused US4 verification from `specs/190-baseline-compare-matrix/quickstart.md` against `apps/platform/tests/Feature/Baselines/BaselineCompareMatrixBuilderTest.php` and `apps/platform/tests/Feature/Filament/BaselineCompareMatrixPageTest.php`
**Checkpoint**: The matrix stays honest when data is stale, missing, ambiguous, or invisible, so operators do not mistake degraded truth for clean posture.
---
## Phase 7: Polish & Cross-Cutting Concerns
**Purpose**: Finalize guard coverage, browser confidence, copy review, formatting, and the focused verification pack across all stories.
- [X] T036 [P] Add no-ad-hoc-badge and no-diagnostic-warning guard coverage for matrix state surfaces in `apps/platform/tests/Feature/Guards/NoAdHocStatusBadgesTest.php` and `apps/platform/tests/Feature/Guards/NoDiagnosticWarningBadgesTest.php`
- [X] T037 [P] Add browser smoke coverage for matrix render, one filter interaction, and one drilldown or compare affordance in `apps/platform/tests/Browser/Spec190BaselineCompareMatrixSmokeTest.php`
- [X] T038 [P] Review `Verb + Object`, `visible-set only`, and `simulation only` copy in `apps/platform/app/Filament/Pages/BaselineCompareMatrix.php`, `apps/platform/app/Filament/Resources/BaselineProfileResource/Pages/ViewBaselineProfile.php`, and `apps/platform/resources/views/filament/pages/baseline-compare-matrix.blade.php`
- [X] T041 [P] Tighten matrix scanability with active-filter scope summaries, visible-set scope disclosure, non-blocking refresh feedback, sticky subject-column treatment, and focused UI regression coverage in `apps/platform/app/Filament/Pages/BaselineCompareMatrix.php`, `apps/platform/resources/views/filament/pages/baseline-compare-matrix.blade.php`, `apps/platform/tests/Feature/Filament/BaselineCompareMatrixPageTest.php`, and `apps/platform/tests/Browser/Spec190BaselineCompareMatrixSmokeTest.php`
- [X] T039 [P] Run formatting with `cd apps/platform && ./vendor/bin/sail bin pint --dirty --format agent` using `specs/190-baseline-compare-matrix/quickstart.md`
- [ ] T040 Run the focused verification pack from `specs/190-baseline-compare-matrix/quickstart.md` against `apps/platform/tests/Unit/Badges/BaselineCompareMatrixBadgesTest.php`, `apps/platform/tests/Feature/Baselines/BaselineCompareMatrixBuilderTest.php`, `apps/platform/tests/Feature/Baselines/BaselineCompareMatrixCompareAllActionTest.php`, `apps/platform/tests/Feature/Baselines/BaselineCompareStatsTest.php`, `apps/platform/tests/Feature/Baselines/BaselineCompareFindingsTest.php`, `apps/platform/tests/Feature/Baselines/BaselineComparePerformanceGuardTest.php`, `apps/platform/tests/Feature/Filament/BaselineCompareMatrixPageTest.php`, `apps/platform/tests/Feature/Filament/BaselineProfileCompareStartSurfaceTest.php`, `apps/platform/tests/Feature/Rbac/BaselineCompareMatrixAuthorizationTest.php`, `apps/platform/tests/Feature/Guards/ActionSurfaceContractTest.php`, `apps/platform/tests/Feature/Guards/NoAdHocStatusBadgesTest.php`, `apps/platform/tests/Feature/Guards/NoDiagnosticWarningBadgesTest.php`, and `apps/platform/tests/Browser/Spec190BaselineCompareMatrixSmokeTest.php`
---
## Dependencies & Execution Order
### Phase Dependencies
- **Setup (Phase 1)**: Starts immediately and prepares shared fixtures plus empty acceptance seams.
- **Foundational (Phase 2)**: Depends on Setup and blocks all user-story work until centralized badge semantics, action-surface guards, and query seams exist.
- **User Story 1 (Phase 3)**: Starts after Foundational and is the recommended engineering MVP because it delivers the first truthful read-only matrix.
- **User Story 2 (Phase 4)**: Starts after User Story 1 because compare-all actions reuse the matrix page and baseline-detail seams established in the MVP.
- **User Story 3 (Phase 5)**: Starts after User Story 1 because drilldown continuity depends on matrix page state and cell metadata.
- **User Story 4 (Phase 6)**: Starts after User Story 1 and can proceed in parallel with User Story 2 or User Story 3 once the core matrix bundle and page exist.
- **Polish (Phase 7)**: Starts after all desired user stories are complete.
### User Story Dependencies
- **US1**: Depends only on the shared matrix foundation.
- **US2**: Depends on US1 for the matrix page, baseline-detail entry seams, and derived visible-set truth.
- **US3**: Depends on US1 for matrix page state and cell metadata used by drilldowns.
- **US4**: Depends on US1 for the matrix builder and page, then hardens degraded-state and filter honesty across the same surface.
### Within Each User Story
- Write or extend the story tests first and confirm they fail before implementation is considered complete.
- Land shared service, builder, or navigation changes before view and copy wiring in the same story.
- Keep each story shippable on its own before moving to the next priority.
### Parallel Opportunities
- `T001` and `T002` can run in parallel during Setup.
- `T003` and `T004` can run in parallel before the shared badge and action-surface seams land.
- `T005` and `T006` can run in parallel once the corresponding tests exist.
- Within US1, `T008` and `T009` can run in parallel, then `T011` and `T012` can split after `T010` defines the matrix bundle.
- Within US2, `T016` and `T017` can run in parallel, then `T019` and `T020` can split after `T018` lands the batch compare seam.
- Within US3, `T023` and `T024` can run in parallel, then `T026`, `T027`, and `T028` can split after `T025` defines the page drilldown state.
- Within US4, `T030` and `T031` can run in parallel, then `T033` and `T034` can split after `T032` lands the degraded-state semantics.
- Within Phase 7, `T036`, `T037`, and `T038` can run in parallel before formatting and the final verification pack.
---
## Parallel Example: User Story 1
```bash
# User Story 1 tests in parallel
T008 apps/platform/tests/Feature/Baselines/BaselineCompareMatrixBuilderTest.php
T009 apps/platform/tests/Feature/Filament/BaselineCompareMatrixPageTest.php
# User Story 1 implementation split after the matrix bundle exists
T011 apps/platform/app/Filament/Pages/BaselineCompareMatrix.php
T012 apps/platform/resources/views/filament/pages/baseline-compare-matrix.blade.php
```
## Parallel Example: User Story 2
```bash
# User Story 2 tests in parallel
T016 apps/platform/tests/Feature/Baselines/BaselineCompareMatrixCompareAllActionTest.php
T017 apps/platform/tests/Feature/Filament/BaselineProfileCompareStartSurfaceTest.php
# User Story 2 implementation split after the batch start seam lands
T019 apps/platform/app/Filament/Pages/BaselineCompareMatrix.php
T020 apps/platform/app/Filament/Resources/BaselineProfileResource/Pages/ViewBaselineProfile.php
```
## Parallel Example: User Story 3
```bash
# User Story 3 tests in parallel
T023 apps/platform/tests/Feature/Filament/BaselineCompareMatrixPageTest.php
T024 apps/platform/tests/Feature/Rbac/BaselineCompareMatrixAuthorizationTest.php
# User Story 3 implementation split after drilldown state exists
T026 apps/platform/app/Support/Navigation/CanonicalNavigationContext.php
T027 apps/platform/app/Filament/Pages/BaselineCompareLanding.php
```
## Parallel Example: User Story 4
```bash
# User Story 4 tests in parallel
T030 apps/platform/tests/Feature/Filament/BaselineCompareMatrixPageTest.php
T031 apps/platform/tests/Feature/Baselines/BaselineCompareMatrixBuilderTest.php
# User Story 4 implementation split after degraded-state semantics land
T033 apps/platform/resources/views/filament/pages/baseline-compare-matrix.blade.php
T034 apps/platform/app/Filament/Pages/BaselineCompareMatrix.php
```
---
## Implementation Strategy
### MVP First
1. Complete Phase 1: Setup.
2. Complete Phase 2: Foundational.
3. Complete Phase 3: User Story 1.
4. **STOP and VALIDATE**: Confirm the read-only matrix answers the visible-set portfolio question honestly before adding refresh or drilldown behavior.
### Incremental Delivery
1. Deliver Setup plus Foundational to lock shared matrix fixtures, centralized badge semantics, action-surface guards, and query seams.
2. Deliver User Story 1 so operators can scan one truthful cross-tenant baseline matrix.
3. Deliver User Story 2 so operators can refresh visible-set compare truth from the same workflow.
4. Deliver User Story 3 so the matrix becomes a decision surface with bounded follow-up continuity.
5. Deliver User Story 4 so degraded and low-trust conditions remain explicit under the final filter set.
6. Finish with browser confidence, copy review, formatting, and the focused verification pack.
### Parallel Team Strategy
1. One contributor can prepare fixture scaffolds and browser seams while another adds badge and action-surface guards.
2. After the foundation lands, one contributor can own the matrix builder while another wires the page shell and Blade view.
3. Once the matrix page exists, one contributor can take compare-all fan-out while another handles drilldown continuity.
4. Degraded-state hardening can proceed in parallel with drilldown work once the base matrix bundle and page filters are stable.
5. Rejoin for Polish so guard suites, browser smoke coverage, copy review, formatting, and the final verification pack land together.
---
## Notes
- `[P]` tasks target different files or safe concurrent work after prerequisite seams are in place.
- `[US1]`, `[US2]`, `[US3]`, and `[US4]` map directly to the user stories in `spec.md`.
- The recommended engineering MVP scope is Phase 1 through Phase 3. The product-complete P1 scope is Phase 1 through Phase 4.
- This task plan stays compliant with Filament v5 on Livewire v4, makes no panel-provider changes in `bootstrap/providers.php`, introduces no new globally searchable resource, adds no destructive action, and requires no asset-strategy change beyond the existing deployment process.

View File

@ -0,0 +1,13 @@
# Requirements Checklist: Baseline Compare Matrix: High-Density Operator Mode
- [x] Spec candidate check is complete and scores the candidate before approval.
- [x] The spec is explicitly scoped as a follow-up to the existing workspace matrix rather than a new domain truth.
- [x] Multi-tenant dense mode is defined as the primary operator-density gain.
- [x] Single-tenant compact mode is defined as a separate adaptive presentation path.
- [x] Filters, legends, actions, and refresh surfaces are explicitly compressed as supporting context.
- [x] Visible-set-only semantics and existing RBAC rules are preserved.
- [x] No new persisted artifact, state family, or generalized UI framework is introduced.
- [x] Manual presentation override is local to the route and not stored as domain truth.
- [x] Functional requirements include mode selection, action calming, filter workflow, and last-updated visibility.
- [x] Definition of done is testable and aligned with operator scanability rather than generic visual polish.
- [x] Tasks are grouped by user story and include focused verification work.

View File

@ -0,0 +1,518 @@
openapi: 3.1.0
info:
title: Baseline Compare Matrix Operator Mode Internal Surface Contract
version: 0.2.0
summary: Internal logical contract for adaptive operator-density rendering on the existing baseline compare matrix route
description: |
This contract is an internal planning artifact for Spec 191. The affected surface
still renders HTML through Filament and Livewire. The schemas below define the
bounded request-scoped presentation models and staged filter interactions that must
be derivable from existing Spec 190 matrix truth before the operator-density
refactor can render safely.
servers:
- url: /internal
x-baseline-compare-operator-mode-consumers:
- surface: baseline.compare.matrix
sourceFiles:
- apps/platform/app/Filament/Pages/BaselineCompareMatrix.php
- apps/platform/resources/views/filament/pages/baseline-compare-matrix.blade.php
mustRender:
- reference
- requested_vs_resolved_mode
- presentation_state
- support_surface_state
- applied_filters
- draft_filters
- staged_filter_changes
- tenant_summaries
- dense_rows_or_compact_results
- last_updated_at
- auto_refresh_state
mustAccept:
- mode
- policy_type
- state
- severity
- tenant_sort
- subject_sort
- subject_key
mustStage:
- selectedPolicyTypes
- selectedStates
- selectedSeverities
- tenantSort
- subjectSort
paths:
/admin/baseline-profiles/{profile}/compare-matrix:
get:
summary: Render the existing baseline compare matrix using adaptive operator-density presentation
operationId: viewBaselineCompareOperatorMode
parameters:
- name: profile
in: path
required: true
schema:
type: integer
- name: mode
in: query
required: false
schema:
$ref: '#/components/schemas/PresentationMode'
- name: policy_type
in: query
required: false
schema:
type: array
items:
type: string
- name: state
in: query
required: false
schema:
type: array
items:
$ref: '#/components/schemas/MatrixCellState'
- name: severity
in: query
required: false
schema:
type: array
items:
$ref: '#/components/schemas/FindingSeverity'
- name: tenant_sort
in: query
required: false
schema:
type: string
- name: subject_sort
in: query
required: false
schema:
type: string
- name: subject_key
in: query
required: false
schema:
type: string
responses:
'200':
description: Rendered matrix plus adaptive operator-density read models
content:
text/html:
schema:
type: string
application/vnd.tenantpilot.baseline-compare-operator-mode+json:
schema:
$ref: '#/components/schemas/BaselineCompareOperatorModeBundle'
'403':
description: Actor is in scope but lacks workspace baseline view capability
'404':
description: Workspace or baseline profile is outside actor scope
/internal/workspaces/{workspace}/baseline-profiles/{profile}/compare-matrix/apply-filters:
post:
summary: Apply staged heavy filters to the operator-density matrix route
operationId: applyBaselineCompareOperatorFilters
parameters:
- name: workspace
in: path
required: true
schema:
type: integer
- name: profile
in: path
required: true
schema:
type: integer
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/MatrixFilterDraft'
responses:
'200':
description: Updated operator-density bundle using the applied filter state
content:
application/vnd.tenantpilot.baseline-compare-operator-mode+json:
schema:
$ref: '#/components/schemas/BaselineCompareOperatorModeBundle'
'403':
description: Actor is in scope but lacks workspace baseline view capability
'404':
description: Workspace or baseline profile is outside actor scope
/internal/workspaces/{workspace}/baseline-profiles/{profile}/compare-matrix/reset-filters:
post:
summary: Reset staged and applied heavy filters for the operator-density matrix route
operationId: resetBaselineCompareOperatorFilters
parameters:
- name: workspace
in: path
required: true
schema:
type: integer
- name: profile
in: path
required: true
schema:
type: integer
responses:
'200':
description: Updated operator-density bundle with default filter state restored
content:
application/vnd.tenantpilot.baseline-compare-operator-mode+json:
schema:
$ref: '#/components/schemas/BaselineCompareOperatorModeBundle'
'403':
description: Actor is in scope but lacks workspace baseline view capability
'404':
description: Workspace or baseline profile is outside actor scope
components:
schemas:
PresentationMode:
type: string
enum:
- auto
- dense
- compact
MatrixCellState:
type: string
enum:
- match
- differ
- missing
- ambiguous
- not_compared
- stale_result
FindingSeverity:
type: string
enum:
- low
- medium
- high
- critical
FreshnessState:
type: string
enum:
- fresh
- stale
- never_compared
- unknown
TrustLevel:
type: string
enum:
- trustworthy
- limited_confidence
- diagnostic_only
- unusable
AttentionLevel:
type: string
enum:
- aligned
- review
- refresh_recommended
- needs_attention
MatrixReference:
type: object
additionalProperties: false
required:
- baselineProfileId
- baselineProfileName
- referenceState
- assignedTenantCount
- visibleTenantCount
properties:
baselineProfileId:
type: integer
baselineProfileName:
type: string
referenceSnapshotId:
type:
- integer
- 'null'
referenceState:
type: string
assignedTenantCount:
type: integer
visibleTenantCount:
type: integer
MatrixFilterDraft:
type: object
additionalProperties: false
required:
- selectedPolicyTypes
- selectedStates
- selectedSeverities
- tenantSort
- subjectSort
properties:
selectedPolicyTypes:
type: array
items:
type: string
selectedStates:
type: array
items:
$ref: '#/components/schemas/MatrixCellState'
selectedSeverities:
type: array
items:
$ref: '#/components/schemas/FindingSeverity'
tenantSort:
type: string
subjectSort:
type: string
focusedSubjectKey:
type:
- string
- 'null'
MatrixPresentationState:
type: object
additionalProperties: false
required:
- requestedMode
- resolvedMode
- visibleTenantCount
- activeFilterCount
- hasStagedFilterChanges
- autoRefreshActive
- lastUpdatedAt
- canOverrideMode
- compactModeAvailable
properties:
requestedMode:
$ref: '#/components/schemas/PresentationMode'
resolvedMode:
type: string
enum:
- dense
- compact
description: |
Final render mode after evaluating the requested route mode against the
visible tenant count. A requested `compact` mode may still resolve to
`dense` when more than one visible tenant remains in scope.
visibleTenantCount:
type: integer
activeFilterCount:
type: integer
hasStagedFilterChanges:
type: boolean
autoRefreshActive:
type: boolean
lastUpdatedAt:
type:
- string
- 'null'
format: date-time
canOverrideMode:
type: boolean
compactModeAvailable:
type: boolean
MatrixTenantSummary:
type: object
additionalProperties: false
required:
- tenantId
- tenantName
- freshnessState
- differingCount
- missingCount
- ambiguousCount
- trustLevel
properties:
tenantId:
type: integer
tenantName:
type: string
freshnessState:
$ref: '#/components/schemas/FreshnessState'
lastComparedAt:
type:
- string
- 'null'
format: date-time
differingCount:
type: integer
missingCount:
type: integer
ambiguousCount:
type: integer
trustLevel:
$ref: '#/components/schemas/TrustLevel'
maxSeverity:
type:
- string
- 'null'
DenseCellView:
type: object
additionalProperties: false
required:
- tenantId
- subjectKey
- state
- freshnessState
- trustLevel
- attentionLevel
properties:
tenantId:
type: integer
subjectKey:
type: string
state:
$ref: '#/components/schemas/MatrixCellState'
freshnessState:
$ref: '#/components/schemas/FreshnessState'
trustLevel:
$ref: '#/components/schemas/TrustLevel'
severity:
type:
- string
- 'null'
attentionLevel:
$ref: '#/components/schemas/AttentionLevel'
reasonSummary:
type:
- string
- 'null'
primaryDrilldownUrl:
type:
- string
- 'null'
secondaryDrilldownUrls:
type: object
additionalProperties:
type: string
DenseSubjectRowView:
type: object
additionalProperties: false
required:
- subjectKey
- displayName
- policyType
- deviationBreadth
- missingBreadth
- ambiguousBreadth
- trustLevel
- cells
properties:
subjectKey:
type: string
displayName:
type: string
policyType:
type: string
baselineExternalId:
type:
- string
- 'null'
deviationBreadth:
type: integer
missingBreadth:
type: integer
ambiguousBreadth:
type: integer
maxSeverity:
type:
- string
- 'null'
trustLevel:
$ref: '#/components/schemas/TrustLevel'
cells:
type: array
items:
$ref: '#/components/schemas/DenseCellView'
CompactSubjectResultView:
type: object
additionalProperties: false
required:
- tenantId
- subjectKey
- displayName
- policyType
- state
- freshnessState
- trustLevel
properties:
tenantId:
type: integer
subjectKey:
type: string
displayName:
type: string
policyType:
type: string
state:
$ref: '#/components/schemas/MatrixCellState'
freshnessState:
$ref: '#/components/schemas/FreshnessState'
trustLevel:
$ref: '#/components/schemas/TrustLevel'
severity:
type:
- string
- 'null'
reasonSummary:
type:
- string
- 'null'
primaryDrilldownUrl:
type:
- string
- 'null'
runUrl:
type:
- string
- 'null'
MatrixSupportSurfaceState:
type: object
additionalProperties: false
required:
- legendMode
- showActiveFilterSummary
- showLastUpdated
- showAutoRefreshHint
- showBlockingRefreshState
properties:
legendMode:
type: string
showActiveFilterSummary:
type: boolean
showLastUpdated:
type: boolean
showAutoRefreshHint:
type: boolean
showBlockingRefreshState:
type: boolean
BaselineCompareOperatorModeBundle:
type: object
additionalProperties: false
required:
- reference
- presentation
- supportSurface
- appliedFilters
- draftFilters
- tenantSummaries
properties:
reference:
$ref: '#/components/schemas/MatrixReference'
presentation:
$ref: '#/components/schemas/MatrixPresentationState'
supportSurface:
$ref: '#/components/schemas/MatrixSupportSurfaceState'
appliedFilters:
$ref: '#/components/schemas/MatrixFilterDraft'
draftFilters:
$ref: '#/components/schemas/MatrixFilterDraft'
tenantSummaries:
type: array
items:
$ref: '#/components/schemas/MatrixTenantSummary'
denseRows:
type: array
items:
$ref: '#/components/schemas/DenseSubjectRowView'
compactResults:
type: array
items:
$ref: '#/components/schemas/CompactSubjectResultView'
$ref: '#/components/schemas/CompactSubjectResultView'

View File

@ -0,0 +1,166 @@
# Data Model: Baseline Compare Matrix: High-Density Operator Mode
## Overview
This follow-up introduces no new persisted entity. It reuses the existing Spec 190 matrix truth and adds derived presentation models for operator density, staged filtering, and non-blocking status cues.
## Existing Source Truths Reused Without Change
### Baseline compare truth from Spec 190
The following derived or canonical inputs remain authoritative and are not redefined by this spec:
- workspace-scoped baseline reference truth
- visible tenant summaries
- subject summaries
- subject-by-tenant matrix cells
- compare-start availability and existing drilldown destinations
This spec changes how those inputs are rendered and interacted with, not how they are computed.
## New Derived Presentation Models
### MatrixPresentationState
**Type**: request-scoped page presentation contract
**Source**: route/query state + visible tenant count + existing run state
| Field | Type | Notes |
|------|------|-------|
| `requestedMode` | string | `auto`, `dense`, or `compact` from route/query state |
| `resolvedMode` | string | Final mode used for rendering: `dense` or `compact` |
| `visibleTenantCount` | integer | Existing visible-set count from the matrix bundle |
| `activeFilterCount` | integer | Count of currently applied filters |
| `hasStagedFilterChanges` | boolean | Whether filter draft state differs from applied state |
| `autoRefreshActive` | boolean | True when background polling is active because compare work is queued or running |
| `lastUpdatedAt` | datetime or null | Timestamp for the currently rendered matrix data |
| `canOverrideMode` | boolean | Whether the operator may locally switch away from `auto` |
### MatrixFilterDraft
**Type**: request-scoped staged filter model
**Source**: page form state only
| Field | Type | Notes |
|------|------|-------|
| `selectedPolicyTypes` | array<string> | Draft policy-type filter selection |
| `selectedStates` | array<string> | Draft state-group selection |
| `selectedSeverities` | array<string> | Draft severity selection |
| `tenantSort` | string | Current tenant sort choice |
| `subjectSort` | string | Current subject sort choice |
| `focusedSubjectKey` | string or null | Optional current subject focus |
### DenseSubjectRowView
**Type**: request-scoped dense-mode row view
**Source**: existing subject summary + existing matrix cells
| Field | Type | Notes |
|------|------|-------|
| `subjectKey` | string | Stable row key |
| `displayName` | string | Primary row label |
| `policyType` | string | Compact secondary label |
| `baselineExternalId` | string or null | Optional secondary context |
| `deviationBreadth` | integer | Existing subject summary metric |
| `missingBreadth` | integer | Existing subject summary metric |
| `ambiguousBreadth` | integer | Existing subject summary metric |
| `maxSeverity` | string or null | Existing subject summary severity |
| `trustLevel` | string | Existing subject summary trust |
| `cells` | array<DenseCellView> | One condensed cell per visible tenant |
### DenseCellView
**Type**: request-scoped dense-mode cell view
**Source**: existing matrix cell + existing tenant summary freshness
| Field | Type | Notes |
|------|------|-------|
| `tenantId` | integer | Visible tenant identifier |
| `subjectKey` | string | Subject row key |
| `state` | string | Existing Spec 190 state |
| `freshnessState` | string | Freshness signal shown in compact form |
| `trustLevel` | string | Trust signal shown in compact form |
| `severity` | string or null | Optional attention signal |
| `attentionLevel` | string | Derived presentation label such as `aligned`, `refresh_recommended`, or `needs_attention` |
| `reasonSummary` | string or null | Short secondary explanation for compact reveal surfaces |
| `primaryDrilldownUrl` | string or null | Preferred next follow-up action |
| `secondaryDrilldownUrls` | array<string, string> | Additional compact follow-up links when available |
### CompactSubjectResultView
**Type**: request-scoped single-tenant row view
**Source**: one visible tenant summary + existing matrix cell + existing subject summary
| Field | Type | Notes |
|------|------|-------|
| `tenantId` | integer | The single visible tenant in compact mode |
| `subjectKey` | string | Stable subject key |
| `displayName` | string | Primary subject label |
| `policyType` | string | Secondary grouping/context |
| `state` | string | Existing Spec 190 state |
| `freshnessState` | string | Compact freshness label |
| `trustLevel` | string | Compact trust label |
| `severity` | string or null | Optional attention indicator |
| `reasonSummary` | string or null | Short explanation line |
| `primaryDrilldownUrl` | string or null | Main follow-up action |
| `runUrl` | string or null | Secondary run-level follow-up |
### MatrixSupportSurfaceState
**Type**: request-scoped supporting-context contract
**Source**: page state + existing legends + refresh metadata
| Field | Type | Notes |
|------|------|-------|
| `legendMode` | string | `grouped`, `collapsed`, or equivalent compact support behavior |
| `showActiveFilterSummary` | boolean | Whether applied filters are summarized inline |
| `showLastUpdated` | boolean | Whether the page displays last-updated metadata |
| `showAutoRefreshHint` | boolean | Whether passive auto-refresh copy is visible |
| `showBlockingRefreshState` | boolean | Reserved for deliberate user-triggered reloads only |
## Rendering and Resolution Rules
### Mode resolution rules
1. If `requestedMode = auto` and `visibleTenantCount > 1`, resolve to `dense`.
2. If `requestedMode = auto` and `visibleTenantCount = 1`, resolve to `compact`.
3. If a manual override is present, use it unless it would produce an invalid empty layout.
4. Manual override remains route-local and must never be persisted as product truth.
### Dense-mode rules
- The subject column remains sticky during horizontal scroll.
- The primary visible content per cell is state, trust, freshness, and attention.
- Long explanatory text and repeated action links do not render as the dominant cell body.
### Compact single-tenant rules
- The tenant header does not repeat as a pseudo-column structure.
- Each subject entry shows one primary status line and a reduced set of secondary metadata.
- Existing subject focus and drilldown continuity remain available.
### Filter workflow rules
- Heavy multi-select filters use staged state first and apply only when the operator confirms.
- Applied filter count and scope summary reflect the applied state, not merely the draft state.
- Reset may clear both draft and applied state in one explicit action.
### Status signal rules
- `blocking refresh` is reserved for deliberate user-triggered reload or recalculation moments.
- `auto-refresh active` indicates passive polling while compare work is still queued or running.
- `lastUpdatedAt` reflects the timestamp of the rendered matrix payload, not merely the latest compare run in the system.
### Safety rules
- No rendering path may widen tenant visibility beyond the existing visible set.
- No presentation-state change may change the underlying compare state, trust, or freshness semantics.
- No grouped legend or compact cell may invent new status vocabulary outside existing centralized badge semantics.
## Relationships
- One `MatrixPresentationState` governs one rendered matrix page.
- One `MatrixFilterDraft` belongs to one `MatrixPresentationState`.
- In dense mode, one `DenseSubjectRowView` maps to many `DenseCellView` entries.
- In compact mode, one visible tenant yields many `CompactSubjectResultView` entries.
- One `MatrixSupportSurfaceState` coordinates legends, refresh hints, and active-filter summaries for the same page render.

View File

@ -0,0 +1,200 @@
# Implementation Plan: Baseline Compare Matrix: High-Density Operator Mode
**Branch**: `191-baseline-compare-operator-mode` | **Date**: 2026-04-11 | **Spec**: `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/specs/191-baseline-compare-operator-mode/spec.md`
**Input**: Feature specification from `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/specs/191-baseline-compare-operator-mode/spec.md`
**Note**: This plan formalizes the existing 191 spec slice and keeps the work strictly inside the already-shipped Spec 190 matrix surface.
## Summary
Refactor the existing workspace baseline compare matrix into an adaptive operator-density surface. The route, baseline reference, visible-set-only truth, compare-start behavior, and drilldowns stay unchanged, but the page gains local presentation-mode state, dense multi-tenant scanning, compact single-tenant rendering, staged heavy-filter application, grouped legends, and clearer separation between blocking refresh, passive auto-refresh, and last-updated status.
## Technical Context
**Language/Version**: PHP 8.4.15
**Primary Dependencies**: Laravel 12, Filament v5, Livewire v4, Pest v4, Tailwind CSS v4, existing `BaselineCompareMatrixBuilder`, `BadgeCatalog`, `CanonicalNavigationContext`, and `UiEnforcement` patterns
**Storage**: PostgreSQL via existing baseline, assignment, compare-run, and finding tables; no new persistence planned
**Testing**: Pest feature tests and browser smoke coverage run through Laravel Sail
**Target Platform**: Laravel monolith web application under `apps/platform`
**Project Type**: web application
**Performance Goals**: Improve scan throughput without increasing query shape beyond Spec 190, keep heavy filter changes non-chatty, and preserve DB-only render-time matrix surfaces
**Constraints**: No compare-logic change, no new persistence, no hidden-tenant leakage, no generalized density framework, no provider or panel changes, and no new asset pipeline
**Scale/Scope**: One existing matrix page, one existing Blade view, one existing builder, one logical contract file, and focused feature plus browser regressions
## Constitution Check
*GATE: Passed before Phase 0 research. Re-checked after Phase 1 design and still passing.*
| Principle | Pre-Research | Post-Design | Notes |
|-----------|--------------|-------------|-------|
| Inventory-first / snapshots-second | PASS | PASS | The spec changes presentation only and keeps Spec 190 truth sources intact. |
| Read/write separation | PASS | PASS | `Compare assigned tenants` remains the only mutation and is unchanged. |
| Graph contract path | N/A | N/A | No new Graph behavior or contract-registry work is introduced. |
| Deterministic capabilities | PASS | PASS | Existing capabilities remain canonical and unchanged. |
| Workspace + tenant isolation | PASS | PASS | Visible-set-only aggregation and drilldown scope remain unchanged. |
| RBAC-UX authorization semantics | PASS | PASS | Existing `404` vs `403` semantics and server-side enforcement remain unchanged. |
| Run observability / Ops-UX | PASS | PASS | Compare-run truth is reused exactly as in Spec 190; this spec only clarifies the visual cues around it. |
| Data minimization | PASS | PASS | No new data copies, exports, or persisted UI artifacts are introduced. |
| Proportionality / anti-bloat | PASS | PASS | The work stays local to one page and does not add a new abstraction or stored artifact. |
| Persisted truth / behavioral state | PASS | PASS | Presentation mode and staged filter state remain request-scoped only. |
| UI semantics / few layers | PASS | PASS | Existing state, trust, freshness, and severity semantics are reused rather than redefined. |
| Filament v5 / Livewire v4 compliance | PASS | PASS | The work remains inside the existing Filament page and Livewire-backed route. |
| Provider registration location | PASS | PASS | No provider changes are required; Laravel 11+ registration remains in `bootstrap/providers.php`. |
| Global search hard rule | PASS | PASS | No new searchable resource or page is introduced. |
| Destructive action safety | PASS | PASS | No destructive action is added. Existing confirmation behavior for compare-start remains unchanged. |
| Asset strategy | PASS | PASS | No new assets are required. Existing deployment behavior for `cd apps/platform && php artisan filament:assets` remains unchanged. |
## Filament-Specific Compliance Notes
- **Livewire v4.0+ compliance**: This plan remains on Filament v5 + Livewire v4 and does not introduce legacy APIs.
- **Provider registration location**: No panel or provider changes are needed; Laravel 11+ provider registration remains in `bootstrap/providers.php`.
- **Global search**: The feature does not add a new globally searchable resource. Existing baseline-resource search behavior is unchanged.
- **Destructive actions**: No new destructive action is introduced. Existing compare-start actions remain confirmation-gated where already defined.
- **Asset strategy**: No new global or on-demand asset registration is planned. Deployment handling of `cd apps/platform && php artisan filament:assets` remains unchanged.
- **Testing plan**: Extend the existing matrix feature, builder, guard, and browser suites to cover presentation mode, staged filter application, and non-blocking status surfaces.
## Phase 0 Research
Research outcomes are captured in `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/specs/191-baseline-compare-operator-mode/research.md`.
Key decisions:
- Keep the existing matrix route and truth model and change presentation only.
- Resolve `auto`, `dense`, and `compact` mode from visible tenant count, with a route-local override only.
- Make dense mode state-first rather than action-first.
- Render single-tenant review as a compact compare list rather than a one-column matrix.
- Convert heavy filters to staged apply/reset semantics.
- Replace the long policy-type checkbox stack with a more compact operator-first selector.
- Group legends into compact support context and separate blocking refresh from passive auto-refresh and last-updated cues.
- Reuse existing drilldown and visible-set semantics unchanged.
## Phase 1 Design
Design artifacts are created under `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/specs/191-baseline-compare-operator-mode/`:
- `research.md`: decisions and rejected alternatives for local operator-density work
- `data-model.md`: request-scoped presentation models for mode state, staged filters, dense rows, compact results, and support-surface state
- `contracts/baseline-compare-operator-mode.logical.openapi.yaml`: internal logical contract for adaptive rendering and staged filter application
- `quickstart.md`: implementation and verification sequence for the follow-up spec
Design decisions:
- `auto` remains the default requested mode and resolves to `dense` for multiple visible tenants and `compact` for exactly one visible tenant.
- Manual mode override remains route-local and must never become stored product truth.
- Dense mode reuses existing compare truth but condenses cell content to state, trust, freshness, and attention.
- Compact mode reuses the same truth but removes pseudo-matrix structure once only one visible tenant remains.
- Heavy filter inputs stage locally and apply explicitly; lightweight route-state changes may remain immediate.
- Grouped legends, passive auto-refresh, and last-updated signals become support context rather than competing top-level content.
## Project Structure
### Documentation (this feature)
```text
specs/191-baseline-compare-operator-mode/
├── plan.md
├── research.md
├── data-model.md
├── quickstart.md
├── spec.md
├── tasks.md
├── contracts/
│ └── baseline-compare-operator-mode.logical.openapi.yaml
└── checklists/
└── requirements.md
```
### Source Code (repository root)
```text
apps/platform/
├── app/
│ ├── Filament/
│ │ └── Pages/
│ │ └── BaselineCompareMatrix.php
│ └── Support/
│ └── Baselines/
│ └── BaselineCompareMatrixBuilder.php
├── resources/views/filament/pages/
│ └── baseline-compare-matrix.blade.php
└── tests/
├── Browser/
│ └── Spec190BaselineCompareMatrixSmokeTest.php
├── Feature/
│ ├── Baselines/
│ │ └── BaselineCompareMatrixBuilderTest.php
│ ├── Filament/
│ │ └── BaselineCompareMatrixPageTest.php
│ └── Guards/
│ └── ActionSurfaceContractTest.php
└── Unit/
└── Badges/
```
**Structure Decision**: Keep the work inside the existing Spec 190 matrix implementation surface. This is a presentation refactor of one existing page and its supporting builder/view behavior, not a new domain slice or a new application area.
## Complexity Tracking
| Violation | Why Needed | Simpler Alternative Rejected Because |
|-----------|------------|-------------------------------------|
| None | N/A | The follow-up stays within the existing page, builder, and test surfaces and introduces no new structural violation. |
## Proportionality Review
- **Current operator problem**: The matrix already answers the right governance question, but not with enough density or calmness for repeated operator scanning.
- **Existing structure is insufficient because**: The current single rendering shape tries to serve both multi-tenant and single-tenant workflows, so supporting context, action repetition, and cell chrome are too heavy in both cases.
- **Narrowest correct implementation**: Keep the same route, truth sources, drilldowns, and compare semantics while adding route-local presentation state, denser rendering, and staged filter application.
- **Ownership cost created**: Additional view-state logic, a logical contract file, and focused regression coverage for mode resolution, filter workflow, and status visibility.
- **Alternative intentionally rejected**: A generalized density framework, a separate dense-report route, or a stored matrix artifact were rejected because the problem is local to the existing matrix surface.
- **Release truth**: current-release operator workflow compression
## Implementation Strategy
### Phase A — Presentation Mode Contract
- Add route-local `auto`, `dense`, and `compact` mode state.
- Resolve the active mode from visible tenant count unless manually overridden.
- Expose `lastUpdatedAt`, `hasStagedFilterChanges`, and passive auto-refresh state to the page.
### Phase B — Dense Multi-Tenant Surface
- Keep the subject column sticky during horizontal scroll.
- Condense dense cells to state, trust, freshness, and attention signals.
- Move repeated actions into compact secondary affordances without breaking drilldown continuity.
### Phase C — Compact Single-Tenant Surface
- Replace pseudo-matrix rendering with a compact subject-result list when only one visible tenant remains.
- Remove repeated tenant headers and duplicated secondary metadata.
- Preserve subject focus and the existing compare/finding/run destinations.
### Phase D — Supporting Context Compression
- Convert heavy matrix filters to staged apply/reset behavior.
- Replace the current long policy-type control with a more compact selector.
- Group or collapse legends.
- Separate blocking refresh from passive auto-refresh and last-updated status.
### Phase E — Verification
- Extend focused feature coverage for mode resolution, staged filter behavior, and support-surface state.
- Extend browser smoke coverage for one dense-mode path and one compact-mode path.
- Keep existing Spec 190 matrix truth, drilldown continuity, and RBAC guarantees green.
## Risk Assessment
| Risk | Impact | Likelihood | Mitigation |
|------|--------|------------|------------|
| Dense mode becomes another framework | Medium | Low | Keep presentation logic local to the matrix page and avoid generalized shared abstractions. |
| Compact mode hides too much follow-up value | Medium | Medium | Preserve one clear primary drilldown per subject and keep existing follow-up destinations intact. |
| Staged filtering feels slow or unclear | Medium | Medium | Show explicit staged/applied state and keep reset obvious. |
| Manual override confuses operators | Low | Medium | Keep `auto` as the default and surface the resolved mode clearly. |
| Last-updated and auto-refresh cues drift out of sync | Medium | Low | Derive both cues from the same rendered matrix payload and active-run state. |
## Test Strategy
- Extend `BaselineCompareMatrixPageTest` for requested vs resolved mode, active filter application, compact vs dense rendering, and non-blocking refresh cues.
- Extend `BaselineCompareMatrixBuilderTest` for any new derived presentation metadata required by the page.
- Keep `ActionSurfaceContractTest` green so calmer actions do not regress the surface contract.
- Extend `Spec190BaselineCompareMatrixSmokeTest` to prove one dense-mode and one compact-mode operator path on the Livewire page.
- Run the focused Sail verification pack from `quickstart.md` and re-run `update-agent-context.sh copilot` after the plan is finalized.

View File

@ -0,0 +1,70 @@
# Quickstart: Baseline Compare Matrix: High-Density Operator Mode
## Goal
Turn the existing baseline compare matrix into a denser operator surface without changing its underlying compare truth. Multi-tenant use should favor high-density cross-tenant scanning, while single-tenant use should collapse into a calmer compact comparison view.
## Implementation Sequence
1. Add page-level presentation state.
- Add `auto`, `dense`, and `compact` route-local mode state.
- Resolve the active mode from visible tenant count unless the operator explicitly overrides it.
- Expose `lastUpdatedAt`, staged-filter state, and passive auto-refresh state on the page.
2. Build the dense multi-tenant rendering contract.
- Keep the subject column sticky.
- Reduce dense-cell chrome to state, trust, freshness, and attention.
- Move repeated follow-up links into compact secondary affordances.
3. Build the compact single-tenant rendering contract.
- Replace the pseudo-matrix layout with a compact subject-result list.
- Remove repeated tenant headers and repeated metadata blocks.
- Preserve subject focus and existing drilldowns.
4. Compress supporting context.
- Convert heavy filters to staged apply/reset semantics.
- Replace the current long policy-type list with a more compact operator-first control.
- Group or collapse legends so they remain available without dominating the page.
- Separate blocking refresh from passive auto-refresh and last-updated status.
5. Extend regression coverage.
- Cover mode resolution, dense multi-tenant layout, compact single-tenant layout, staged filters, and non-blocking refresh cues.
- Keep existing Spec 190 matrix truth, drilldown continuity, and RBAC guarantees green.
## Suggested Test Files
- `apps/platform/tests/Feature/Filament/BaselineCompareMatrixPageTest.php`
- `apps/platform/tests/Feature/Baselines/BaselineCompareMatrixBuilderTest.php`
- `apps/platform/tests/Feature/Guards/ActionSurfaceContractTest.php`
- `apps/platform/tests/Browser/Spec190BaselineCompareMatrixSmokeTest.php`
## Minimum Verification Commands
Run all commands through Sail from `apps/platform`.
```bash
cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Filament/BaselineCompareMatrixPageTest.php
cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Baselines/BaselineCompareMatrixBuilderTest.php
cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Guards/ActionSurfaceContractTest.php
cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Browser/Spec190BaselineCompareMatrixSmokeTest.php
cd apps/platform && ./vendor/bin/sail bin pint --dirty --format agent
```
## Manual Acceptance Checklist
1. Open a baseline profile whose matrix has multiple visible tenants and confirm `auto` resolves to dense mode.
2. Verify the first subject column remains visible while horizontally scrolling dense mode.
3. Confirm dense cells foreground compare state, trust, freshness, and attention before links or long prose.
4. Open a matrix that resolves to one visible tenant and confirm `auto` resolves to compact mode instead of a one-column matrix.
5. Change heavy filters and confirm the page stages those changes until the operator applies them.
6. Confirm active filter count and filter summary reflect the applied state clearly.
7. Confirm legends are still understandable but no longer dominate the top of the page.
8. Trigger or observe queued/running compare work and confirm passive auto-refresh does not look like a permanent blocking load.
9. Confirm the page shows when the current matrix payload was last updated.
10. Verify tenant compare, finding, and run drilldowns still preserve the existing matrix context.
## Deployment Notes
- No migration is expected.
- No new asset registration is expected.
- No queue topology change is expected because compare execution semantics stay unchanged.

View File

@ -0,0 +1,111 @@
# Research: Baseline Compare Matrix: High-Density Operator Mode
## Decision: Keep the existing matrix route and truth model, and change presentation only
### Rationale
Spec 190 already established the correct workspace route, the correct baseline reference model, and the correct visible-set-only compare truth. The operator-density follow-up should stay on `/admin/baseline-profiles/{record}/compare-matrix` and must not introduce a second route, a second report artifact, or a second source of matrix truth.
### Alternatives considered
- Add a separate `dense report` page: rejected because it would duplicate the same baseline-scoped workflow on a second route.
- Add a stored matrix snapshot: rejected because the operator problem is scan efficiency, not missing persistence.
## Decision: Resolve presentation mode from visible tenant count, with a local override only
### Rationale
The core operator split is real: one visible tenant is a compact review problem, while several visible tenants create a cross-tenant scan problem. The narrowest implementation is one requested mode (`auto`, `dense`, or `compact`) and one resolved mode at render time. `auto` should remain the default, while manual override stays local to the matrix route and must not become stored user preference or domain truth.
### Alternatives considered
- Separate feature flags or separate navigation entries for each mode: rejected because the matrix should remain one operator surface.
- Persist mode preference per user: rejected because the current need is local workflow control, not profile-level personalization.
## Decision: Dense mode must be state-first, not action-first
### Rationale
In multi-tenant reading, the primary questions are where drift exists, how severe it is, whether the signal is trustworthy, and what deserves follow-up next. Dense cells should therefore foreground compare state, trust, freshness, and attention, while detailed reasons and repeated links move into compact secondary affordances.
### Alternatives considered
- Keep the current repeated open-link pattern in every cell: rejected because repeated actions visually outrank the state being scanned.
- Remove cell-level follow-up completely: rejected because the matrix must remain a decision surface, not a dead-end report.
## Decision: Single-tenant mode should be a compact compare list, not a one-column matrix
### Rationale
Once only one visible tenant remains, the value of cross-tenant columns disappears. The surface should switch to a shorter subject-result list that reuses the same truth but removes repeated tenant headers, empty width, and oversized cell chrome.
### Alternatives considered
- Reuse dense mode even for one tenant: rejected because it preserves the wrong reading model.
- Route single-tenant viewing away to the tenant compare page: rejected because the operator still started from the workspace baseline matrix context and should not lose that context automatically.
## Decision: Heavy filters should use staged apply/reset semantics
### Rationale
The current matrix is dense enough that chatty recomputation on every multi-select click works against operator flow. Policy types and other heavy matrix filters should stage changes locally, show that staged state clearly, and apply them deliberately. This improves calmness and makes the surface feel less like a form page.
### Alternatives considered
- Keep all filters live: rejected because heavy multi-select controls create noisy redraw behavior.
- Convert every filter to manual apply: rejected because lightweight interactions such as mode switching or focused-subject clearing should remain immediate.
## Decision: Replace the long policy-type checkbox stack with a more compact operator-first selector
### Rationale
The policy-type filter is the most visually expensive control on the page. The follow-up spec should use a denser selection pattern such as searchable multi-select, type-to-find, or another compact control that exposes the same filter truth without the current long vertical list.
### Alternatives considered
- Keep the long checkbox list and only restyle it: rejected because vertical space is the actual product problem.
- Hide policy type filtering behind a modal by default: rejected because the filter remains core enough to deserve immediate access.
## Decision: Legends should become grouped support context, optionally collapsible
### Rationale
State, freshness, and trust legends remain semantically valuable, especially for onboarding or occasional operators, but they should no longer compete with the matrix for top-of-screen attention. Grouped, compact legend blocks are the narrowest way to preserve semantics while reducing dominance.
### Alternatives considered
- Remove legends entirely: rejected because trust and freshness semantics still need an on-page reference.
- Leave three separate full-width legend sections: rejected because they displace the primary working surface.
## Decision: Separate loading, auto-refresh, and last-updated cues
### Rationale
Spec 190 already exposed the risk of background polling reading like permanent blocking load. This follow-up should make three states explicit: active loading for user-triggered refresh, passive auto-refresh while queued or running compare work exists, and last-updated time for the currently rendered matrix.
### Alternatives considered
- Reuse one generic refresh chip for all states: rejected because operators cannot tell whether the page is blocked or simply polling.
- Hide refresh state entirely: rejected because operator trust depends on understanding when the matrix is current.
## Decision: Reuse the existing drilldown and visible-set semantics without change
### Rationale
This spec is a presentation refactor, not a navigation or authorization redesign. The existing tenant compare, finding, run-detail, and canonical-navigation context from Spec 190 remain correct and should carry forward unchanged.
### Alternatives considered
- Introduce a dense-mode-specific drilldown model: rejected because it would create new behavior where existing follow-up paths are already sufficient.
- Add aggregated hidden-tenant remainder summaries: rejected because visible-set-only semantics explicitly avoid hidden-tenant leakage.
## Decision: Validate primarily with focused page, builder, guard, and browser coverage
### Rationale
The highest-risk changes are mode resolution, dense-cell hierarchy, compact single-tenant rendering, filter apply behavior, and non-blocking refresh cues. These are best covered with focused feature tests plus one browser smoke path for the interactive Livewire surface.
### Alternatives considered
- Browser-test every combination exhaustively: rejected because most of the behavior is deterministic and cheaper to validate through feature tests.
- Limit validation to visual inspection: rejected because mode resolution and filter workflow are important enough to guard in CI.

View File

@ -0,0 +1,231 @@
# Feature Specification: Baseline Compare Matrix: High-Density Operator Mode
**Feature Branch**: `191-baseline-compare-operator-mode`
**Created**: 2026-04-11
**Status**: Approved
**Input**: User description: "Spec Candidate 190b — Baseline Compare Matrix: High-Density Operator Mode"
## Spec Candidate Check *(mandatory — SPEC-GATE-001)*
- **Problem**: The current baseline compare matrix is semantically strong but still too visually heavy for repeat operator use, especially when several visible tenants must be scanned quickly.
- **Today's failure**: Operators reach the right truth, but the page spends too much space on supporting context, repeated actions, and vertically expensive cells. Multi-tenant comparison is slower than it should be, and single-tenant viewing still feels like a stretched matrix instead of a compact operator surface.
- **User-visible improvement**: The same matrix route becomes faster to scan, calmer to use, and more obviously centered on drift detection. Multi-tenant work gets a true dense scan mode, while single-tenant work gets a compact compare list.
- **Smallest enterprise-capable version**: Rework the existing matrix route with adaptive presentation only: `auto` mode picks dense multi-tenant view for more than one visible tenant and compact single-tenant view for one visible tenant, while filters, legends, actions, and refresh feedback are compressed without changing compare logic.
- **Explicit non-goals**: No change to compare truth, no new finding semantics, no new persisted matrix artifact, no generalized table engine, no mobile-first redesign, no broader design-system rewrite.
- **Permanent complexity imported**: One page-level presentation-mode contract, denser cell-layout rules, compact control behavior, route/query persistence for local mode override, and focused regression coverage for the new operator surface behavior.
- **Why now**: Spec 190 established the truthful workspace compare surface. The next real bottleneck is not domain correctness but operator throughput and scan efficiency on the page that now exists.
- **Why not local**: Small CSS-only tweaks will not solve the actual product problem because the core issue is presentation mode, action hierarchy, and default information density rather than isolated spacing bugs.
- **Approval class**: Workflow Compression
- **Red flags triggered**: `New Meta-Infrastructure` risk if presentation-mode work grows into a reusable UI framework. Defense: this spec keeps all mode logic page-local to the existing baseline compare matrix and forbids a generalized density framework.
- **Score**: Nutzen: 2 | Dringlichkeit: 1 | Scope: 2 | Komplexität: 2 | Produktnähe: 2 | Wiederverwendung: 1 | **Gesamt: 10/12**
- **Decision**: approve
## Spec Scope Fields *(mandatory)*
- **Scope**: workspace
- **Primary Routes**:
- `/admin/baseline-profiles/{record}/compare-matrix` as the existing workspace matrix route that gains dense and compact operator modes
- `/admin/baseline-profiles/{record}` as the existing baseline profile detail that remains the canonical entry point into the matrix
- `/admin/t/{tenant}/baseline-compare` as the existing tenant drilldown destination
- `/admin/findings` and finding detail as the existing follow-up destinations
- Monitoring run-detail routes as existing compare-run drilldowns
- **Data Ownership**:
- Workspace-owned baseline profile, snapshot, and assignment truth remain unchanged.
- Tenant-owned compare runs and findings remain unchanged.
- Presentation mode, filter compaction, and dense cell rendering remain derived UI behavior only and introduce no new persisted truth.
- **RBAC**:
- Matrix access remains gated by workspace membership plus `WORKSPACE_BASELINES_VIEW`.
- `Compare assigned tenants` remains gated by `WORKSPACE_BASELINES_MANAGE`.
- Tenant and finding drilldowns continue to enforce their existing tenant-scope capabilities such as `TENANT_VIEW` and `TENANT_FINDINGS_VIEW`.
- Presentation-mode changes MUST NOT widen visibility, leak hidden tenants, or relax `404` vs `403` semantics already established in Spec 190.
## UI/UX Surface Classification *(mandatory when operator-facing surfaces are changed)*
| Surface | Surface Type | Primary Inspect/Open Model | Row Click | Secondary Actions Placement | Destructive Actions Placement | Canonical Collection Route | Canonical Detail Route | Scope Signals | Canonical Noun | Critical Truth Visible by Default | Exception Type |
|---|---|---|---|---|---|---|---|---|---|---|---|
| Workspace baseline compare matrix | Workspace matrix / operator surface | Explicit subject, cell, and tenant drilldown controls | forbidden | Header controls, compact cell action slot, focused subject utilities | none | `/admin/baseline-profiles/{record}/compare-matrix` | same route with filter and presentation state | Active workspace, baseline profile, visible tenant count, active filter count, presentation mode, last updated | Baseline compare matrix | Drift hotspots, trust, freshness, and next follow-up path | dense-grid + compact-single-tenant exception |
## Operator Surface Contract *(mandatory when operator-facing surfaces are changed)*
| Surface | Primary Persona | Surface Type | Primary Operator Question | Default-visible Information | Diagnostics-only Information | Status Dimensions Used | Mutation Scope | Primary Actions | Dangerous Actions |
|---|---|---|---|---|---|---|---|---|---|
| Workspace baseline compare matrix | Workspace operator | Matrix / triage surface | Where is the meaningful drift across the visible tenant set, how trustworthy is it, and where should I go next? | Subject-by-tenant state, trust, freshness, severity or attention signal, visible-set filter scope, mode, last updated | Raw reason codes, run identifiers, detailed evidence gaps, low-level compare metadata | compare state, freshness, trust, severity/attention | `simulation only` for compare start; otherwise read-only | Compare assigned tenants, apply or reset filters, switch presentation mode, focus subject, drill into compare/finding/run | none |
## 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 |
|---|---|---|---|---|---|---|---|---|---|---|
| Workspace baseline compare matrix | `apps/platform/app/Filament/Pages/BaselineCompareMatrix.php`, `apps/platform/resources/views/filament/pages/baseline-compare-matrix.blade.php` | `Compare assigned tenants` remains the sole primary header action; presentation mode, refresh status, and filter state stay in contextual support surfaces rather than the header | Explicit subject, cell, and tenant drilldown controls only; row click remains forbidden | none; follow-up links remain inside compact cell or compact-result affordances only | none | `Reset filters` becomes the single primary CTA when filters reduce the visible row set to zero; otherwise the surface keeps the existing compare-start guidance and no duplicate empty-state CTA | No separate detail header exists; the matrix route remains the canonical working surface | n/a | Existing compare-start run and audit semantics remain unchanged; no new audit event is introduced by presentation changes | Dense-grid and compact-single-tenant rendering are approved custom surface exceptions, but HDR-001 still applies: no pure-navigation header actions and only one primary visible header action |
## Proportionality Review *(mandatory when structural complexity is introduced)*
- **New source of truth?**: no
- **New persisted entity/table/artifact?**: no
- **New abstraction?**: no
- **New enum/state/reason family?**: no
- **New cross-domain UI framework/taxonomy?**: no
- **Current operator problem**: The matrix already answers the right governance question, but not with enough density or calmness for repeated operator scanning.
- **Existing structure is insufficient because**: The current single rendering shape tries to serve both multi-tenant and single-tenant use cases, so supporting context, cell chrome, and repeated actions stay too heavy for both.
- **Narrowest correct implementation**: Keep the same route, same truth sources, same drilldowns, and same compare semantics while adding one adaptive presentation contract and denser default rendering.
- **Ownership cost**: More page-view branching, additional view-state tests, and stricter UI regression coverage for density, action noise, and status visibility.
- **Alternative intentionally rejected**: A generalized dense-table framework or a second persisted reporting artifact was rejected because this need is local to the baseline compare matrix.
- **Release truth**: current-release operator workflow compression
## User Scenarios & Testing *(mandatory)*
### User Story 1 - Scan multi-tenant drift in dense mode (Priority: P1)
As a workspace operator, I want the matrix to switch into a true high-density view when multiple visible tenants are in scope so I can read drift patterns quickly without losing the subject axis.
**Why this priority**: Multi-tenant scanning is the core operator value of the matrix. If this remains visually slow, the page does not earn its workspace-level role.
**Independent Test**: Open the matrix for a baseline profile with multiple visible tenants and verify that one subject row and one tenant column remain readable in a dense layout with a sticky subject column and compact cell states.
**Acceptance Scenarios**:
1. **Given** a baseline profile with more than one visible assigned tenant, **When** the operator opens the matrix route in auto mode, **Then** the page renders the dense multi-tenant mode with one subject row per baseline subject and one tenant column per visible tenant.
2. **Given** the operator scrolls horizontally in dense mode, **When** the matrix remains wider than the viewport, **Then** the first subject column stays visible and anchored for cross-tenant reading.
3. **Given** a dense-mode cell represents a visible tenant and subject, **When** the page renders it, **Then** the primary visible signal is the technical state plus condensed trust and freshness rather than a block of repeated links or prose.
---
### User Story 2 - Work a single visible tenant in compact mode (Priority: P2)
As a workspace operator, I want the matrix to stop pretending to be multi-tenant when only one visible tenant remains so the screen becomes shorter and calmer.
**Why this priority**: A single-tenant compare surface should not spend horizontal and vertical space simulating columns that do not exist.
**Independent Test**: Open the matrix for a baseline profile where only one tenant is visible and verify that the page uses a compact compare-list mode instead of the dense cross-tenant layout.
**Acceptance Scenarios**:
1. **Given** exactly one visible assigned tenant after RBAC scoping, **When** the operator opens the matrix in auto mode, **Then** the page renders compact single-tenant mode instead of dense mode.
2. **Given** more than one tenant is assigned to the baseline profile but RBAC scoping leaves only one tenant visible to the current actor, **When** the operator opens the matrix in auto mode, **Then** the page still resolves to compact mode and all counts and drilldowns remain visible-set-only.
3. **Given** compact mode is active, **When** the operator scans a subject entry, **Then** repeated labels, repeated badges, and repeated action chrome are reduced compared with the current matrix surface.
---
### User Story 3 - Use filters, legends, and status surfaces without losing the matrix (Priority: P2)
As a workspace operator, I want supporting controls to stay available but compact so the matrix remains the primary working surface above the fold.
**Why this priority**: Filtering, legends, and refresh status are necessary, but they should support the matrix rather than compete with it.
**Independent Test**: Open the matrix, apply policy-type or state filters, and verify that active filter count, filter application, legend compaction, and refresh signals remain visible without dominating the page.
**Acceptance Scenarios**:
1. **Given** the operator changes multi-select filters, **When** those changes are staged, **Then** the page uses an explicit apply or reset pattern for heavy filter changes instead of re-rendering noisily on every click.
2. **Given** active compare runs or polling are present, **When** the matrix refreshes in the background, **Then** the operator sees a non-blocking update signal and a page-level freshness hint rather than a permanent loading impression.
3. **Given** the operator already understands the legends, **When** the page loads in daily-use mode, **Then** legends are grouped and visually compact, with deeper explanation still available on demand.
4. **Given** staged or applied filters reduce the visible subject set to zero, **When** the page renders the filtered result, **Then** it preserves the active presentation mode, shows a clear zero-results empty state, and offers `Reset filters` as the single primary CTA.
### Edge Cases
- If total assigned tenants are greater than one but only one tenant is visible to the current actor, auto mode MUST choose compact mode, not dense mode.
- If the operator manually overrides `auto` to `dense` or `compact`, the override MUST stay local to the matrix route and MUST NOT create a persisted user preference or domain artifact.
- If filters reduce the visible row set to zero, the page MUST preserve the active mode and still show a clear empty state.
- If compare runs are queued or running while the page is open, the refresh signal MUST remain distinct from a blocking loading state.
- If dense mode cannot fit all compact cell details legibly, secondary detail MUST move behind tooltip, popover, expand, or a deliberate drilldown instead of widening every cell again.
## Requirements *(mandatory)*
**Constitution alignment (required):** This follow-up spec changes only the operator presentation of the existing matrix surface. It introduces no new Microsoft Graph path, no new baseline or finding truth, and no new mutation beyond the already-existing compare-start behavior.
**Constitution alignment (PROP-001 / ABSTR-001 / PERSIST-001 / STATE-001 / BLOAT-001):** The feature must remain a local presentation refactor on top of Spec 190. It MUST NOT introduce a new persisted report, a new compare artifact, a new domain state family, or a reusable density framework.
**Constitution alignment (OPS-UX):** Any compare-start controls remain bound to the existing `baseline_compare` run semantics from Spec 190. This spec only changes the presentation around those controls and their feedback, not the run model.
**Constitution alignment (RBAC-UX):** All existing `404` versus `403` semantics, visible-set-only counts, drilldown authorization, and capability checks remain unchanged. Dense or compact mode MUST never reveal more tenant truth than the current actor can already see.
**Constitution alignment (BADGE-001):** Dense and compact mode MUST reuse centralized state, freshness, trust, and severity badge semantics. This spec MUST NOT create page-local status colors or a second status vocabulary.
**Constitution alignment (UI-FIL-001):** The matrix page should continue to use Filament-native sections, actions, and shared primitives. The dense matrix body and compact single-tenant layout may use custom Blade composition where Filament's one-axis primitives are insufficient, but the page MUST avoid inventing a local semantic component framework.
**Constitution alignment (UI-NAMING-001):** Operator-facing labels must stay aligned with the vocabulary established in Spec 190, including `Baseline compare matrix`, `Compare assigned tenants`, `Reference snapshot`, `Visible tenants`, and the existing compare-state labels.
**Constitution alignment (UI-CONST-001 / UI-SURF-001 / UI-HARD-001 / UI-REVIEW-001):** The matrix remains a workspace operator surface with explicit inspect controls and forbidden row click. The primary working surface is the matrix body, while filters, legends, and status strips become supporting context.
### Functional Requirements
- **FR-191-001 Primary working surface**: On desktop operator viewports (`>= 1280px`), the initial render MUST show the first dense matrix row or first compact result without scrolling past expanded legends or a long filter stack. Reference context, filter summary, and legend summary MAY remain above the working surface, but detailed legend or helper text MUST stay collapsed or secondary by default.
- **FR-191-002 Auto presentation mode**: The page MUST support an `auto` presentation mode that chooses dense multi-tenant mode when more than one visible tenant is in scope and compact single-tenant mode when exactly one visible tenant is in scope.
- **FR-191-003 Manual override**: The page MUST allow a local manual override between `auto`, `dense`, and `compact` presentation without persisting that choice as domain truth or a stored user preference.
- **FR-191-004 Dense multi-tenant layout**: Dense mode MUST render one subject row and one visible-tenant column with a sticky first subject column.
- **FR-191-005 Dense cell contract**: Dense mode cells MUST default to compact state, trust, and freshness signals. Detailed reasons, long helper text, and multiple secondary links MUST NOT dominate the default cell chrome.
- **FR-191-006 Single-tenant compact layout**: Compact mode MUST render a shorter subject-result list optimized for one visible tenant instead of a pseudo-matrix with repeated tenant headers and oversized cells.
- **FR-191-007 Action calming**: Repeated follow-up actions such as tenant compare, finding, or run links MUST become visually secondary. The default focus in dense or compact mode MUST remain the compare state, not the link chrome.
- **FR-191-008 Filter density**: The page MUST show active filter count and active filter scope clearly while keeping the filter zone visually compact.
- **FR-191-009 Heavy-filter workflow**: Policy type and other heavy multi-select filters MUST use an explicit apply/reset interaction instead of forcing a full matrix recompute on every click.
- **FR-191-010 Policy type usability**: Policy type filtering MUST replace the long checkbox stack with a searchable multi-select or an equivalent compact selector that supports type-to-find behavior and stays one compact control when closed.
- **FR-191-011 Legend compression**: State, freshness, and trust legends MUST default to one grouped support block with summary labels visible and detailed explanatory text hidden behind an explicit reveal so they do not displace the matrix in daily use.
- **FR-191-012 Honest status transitions**: The page MUST distinguish between active loading, background auto-refresh, and last-updated freshness so operators can tell whether the matrix is recalculating or simply polling for updates.
- **FR-191-013 Last updated visibility**: The page MUST show a page-level or matrix-level freshness hint indicating when the currently rendered matrix data was last refreshed.
- **FR-191-014 Visible-set truth preserved**: Dense and compact mode MUST preserve the visible-set-only semantics already defined in Spec 190 for all counts, subject breadth, and drilldowns.
- **FR-191-015 Drilldown continuity preserved**: Switching presentation mode MUST NOT break subject focus, tenant drilldowns, finding drilldowns, or return-path continuity already established on the matrix route.
- **FR-191-016 No compare-logic changes**: This spec MUST NOT change how drift, trust, freshness, severity, or evidence gaps are calculated.
- **FR-191-017 No new persistence**: This spec MUST NOT introduce a new matrix snapshot, portfolio report, stored view preference, or any other new persisted artifact.
- **FR-191-018 Automated regression coverage**: Automated coverage MUST prove mode selection, sticky dense layout, compact single-tenant layout, filter apply/reset behavior, legend compression, non-blocking refresh state, and preservation of existing drilldowns and RBAC semantics.
## Measurable Acceptance Thresholds
- Dense auto mode is accepted only when a multi-tenant matrix render shows the first sticky subject row without scrolling past expanded legends or a long filter stack on a desktop operator viewport.
- Compact auto mode is accepted only when the RBAC-visible single-tenant edge case renders the compact result list instead of the dense grid while preserving visible-set-only counts and drilldown continuity.
- Staged filtering is accepted only when draft multi-select or sort changes do not redraw the matrix until the operator explicitly applies or resets them, and the active filter summary continues to describe the applied route state.
- Support-surface compression is accepted only when legends stay grouped behind an explicit reveal, passive auto-refresh remains visibly distinct from deliberate refresh, and last-updated context stays visible on the page.
## Non-Goals
- No change to baseline compare logic or evidence resolution
- No new matrix export or stored report artifact
- No new generic dense-table framework for other pages
- No new finding workflow or remediation workflow
- No mobile-first redesign of the matrix surface
- No cross-workspace or tenant-vs-tenant compare feature
## Assumptions
- Spec 190 remains the canonical domain-truth foundation for the matrix.
- Existing builder outputs can be extended or re-rendered without introducing new persistence.
- Existing drilldown URLs and canonical navigation context can carry any local presentation override that must survive navigation.
- Existing badge semantics already cover the status information needed for denser rendering.
## Dependencies
- Spec 190 baseline compare matrix route and builder
- Existing matrix page and view
- Existing badge semantics for state, freshness, trust, and severity
- Existing tenant compare, finding, and run-detail destinations
## Risks
- Dense mode could drift into a local mini-framework if rendering rules become over-generalized.
- Compacting actions too aggressively could hide next steps instead of calming them.
- Apply/reset filtering could feel slower if the staged-filter state is not clearly signaled.
- Manual mode override could create confusion if `auto` behavior and override state are not explicit.
## Review Questions
- Does the page now clearly separate supporting context from the primary working surface?
- Is dense mode truly optimized for cross-tenant scanning rather than just a tighter version of the old layout?
- Is single-tenant mode clearly calmer and shorter than the current matrix?
- Are repeated actions secondary without becoming hard to discover?
- Are filter count, legend compression, and last-updated feedback visible without dominating the page?
- Does the spec stay local to the matrix surface and avoid importing a reusable UI framework?
## Definition of Done
This feature is complete when:
- the existing matrix route supports `auto`, `dense`, and `compact` presentation behavior,
- multi-tenant auto mode renders a clearly denser matrix with a sticky subject column,
- the RBAC-scoped case where more than one tenant is assigned but only one tenant is visible resolves to compact mode while preserving visible-set-only counts and drilldowns,
- single-tenant auto mode renders a compact compare-list presentation instead of the current matrix-heavy layout,
- supporting context is visibly lighter than the matrix body,
- repeated per-cell or per-row actions no longer dominate the reading flow,
- active filters are counted and heavy filters use an explicit apply/reset pattern,
- zero-result filtered states preserve the active mode and offer `Reset filters` as the single primary CTA,
- legends remain available but are grouped and visually compressed,
- page-level refresh and last-updated signals are honest and non-blocking,
- no compare logic, trust logic, freshness logic, or RBAC semantics have changed,
- and focused feature plus browser coverage proves the new operator-density behavior.

View File

@ -0,0 +1,180 @@
# Tasks: Baseline Compare Matrix: High-Density Operator Mode
**Input**: Design documents from `/specs/191-baseline-compare-operator-mode/`
**Prerequisites**: `plan.md`, `spec.md`, `research.md`, `data-model.md`, `quickstart.md`, `contracts/baseline-compare-operator-mode.logical.openapi.yaml`
**Tests**: Tests are REQUIRED. Extend Pest feature coverage and browser smoke coverage around the existing matrix route.
**Operations**: This feature reuses existing `baseline_compare` run truth only. No new `OperationRun` type, run-summary contract, or notification channel should be introduced.
**RBAC**: Existing workspace and tenant visibility rules from Spec 190 remain authoritative. Tasks must preserve visible-set-only aggregation and existing `404` vs `403` behavior.
**Operator Surfaces**: The affected operator surface is the existing workspace baseline compare matrix route, with additive presentation changes only.
**Filament UI Action Surfaces**: The matrix page keeps explicit drilldown controls and forbidden row click. No destructive action is added.
**Badges**: Dense and compact rendering must continue to use centralized matrix state, trust, freshness, and severity semantics.
**Organization**: Tasks are grouped by user story so each operator-density improvement can be implemented and verified independently.
## Phase 1: Setup (Spec and Acceptance Seams)
**Purpose**: Lock the implementation contract and acceptance seams before page behavior changes.
- [X] T001 Finalize the UI Action Matrix, operator-surface assumptions, and measurable acceptance thresholds in `specs/191-baseline-compare-operator-mode/spec.md`
- [X] T002 [P] Reconcile the staged filter and presentation-mode interaction contract in `specs/191-baseline-compare-operator-mode/contracts/baseline-compare-operator-mode.logical.openapi.yaml`
- [X] T003 [P] Add acceptance scaffolds for multi-tenant, single-tenant, and staged-filter scenarios in `apps/platform/tests/Feature/Filament/BaselineCompareMatrixPageTest.php` and `apps/platform/tests/Feature/Baselines/BaselineCompareMatrixBuilderTest.php`
- [X] T004 [P] Extend browser and action-surface guard seams in `apps/platform/tests/Browser/Spec190BaselineCompareMatrixSmokeTest.php` and `apps/platform/tests/Feature/Guards/ActionSurfaceContractTest.php`
**Checkpoint**: The spec contract and test seams are ready for implementation work.
---
## Phase 2: Foundational (Blocking Presentation Contract)
**Purpose**: Establish page-level presentation state and derived read models before reshaping dense and compact layouts.
**⚠️ CRITICAL**: No user story work should begin until this phase is complete.
- [X] T005 Add requested, resolved, and manual presentation-mode query handling plus staged filter state as request-scoped-only route state in `apps/platform/app/Filament/Pages/BaselineCompareMatrix.php`
- [X] T006 [P] Extend matrix bundle outputs for dense rows, compact results, support-surface state, and last-updated metadata in `apps/platform/app/Support/Baselines/BaselineCompareMatrixBuilder.php`
- [X] T007 [P] Add foundational builder coverage for requested or resolved mode, filter metadata, support-surface state, and unchanged compare state, trust, freshness, and severity outputs in `apps/platform/tests/Feature/Baselines/BaselineCompareMatrixBuilderTest.php`
- [X] T008 [P] Add foundational page coverage for mode resolution, route-state persistence, and derived-only non-persistence guarantees in `apps/platform/tests/Feature/Filament/BaselineCompareMatrixPageTest.php`
**Checkpoint**: The page can resolve `auto`, `dense`, and `compact` mode and expose all derived state needed by the UI.
---
## Phase 3: User Story 1 - Scan multi-tenant drift in dense mode (Priority: P1) 🎯 MVP
**Goal**: Make multi-tenant reading materially denser and faster without changing compare truth.
**Independent Test**: Open the matrix with multiple visible tenants and verify dense mode, sticky subject behavior, and state-first cells.
### Tests for User Story 1
- [X] T009 [P] [US1] Add dense-mode assertions for auto resolution, sticky subject behavior, and compact cell hierarchy in `apps/platform/tests/Feature/Filament/BaselineCompareMatrixPageTest.php`
- [X] T010 [P] [US1] Extend browser smoke coverage for dense-mode scanning and dense-mode drilldowns in `apps/platform/tests/Browser/Spec190BaselineCompareMatrixSmokeTest.php`
### Implementation for User Story 1
- [X] T011 [US1] Render the dense multi-tenant matrix shell with sticky subject-column behavior in `apps/platform/resources/views/filament/pages/baseline-compare-matrix.blade.php`
- [X] T012 [US1] Surface condensed dense-cell state, trust, freshness, and attention summaries in `apps/platform/resources/views/filament/pages/baseline-compare-matrix.blade.php` and `apps/platform/app/Filament/Pages/BaselineCompareMatrix.php`
- [X] T013 [US1] Calm repeated cell and tenant actions into compact secondary affordances in `apps/platform/resources/views/filament/pages/baseline-compare-matrix.blade.php` and `apps/platform/app/Filament/Pages/BaselineCompareMatrix.php`
- [X] T014 [US1] Preserve focused-subject and visible-set drilldown continuity for dense mode in `apps/platform/app/Filament/Pages/BaselineCompareMatrix.php`
- [X] T015 [US1] Run focused dense-mode verification in `apps/platform/tests/Feature/Filament/BaselineCompareMatrixPageTest.php` and `apps/platform/tests/Browser/Spec190BaselineCompareMatrixSmokeTest.php`
**Checkpoint**: Multi-tenant scanning is visibly denser and the matrix body reads as the primary working surface.
---
## Phase 4: User Story 2 - Work a single visible tenant in compact mode (Priority: P2)
**Goal**: Replace pseudo-matrix rendering with a compact comparison surface when only one visible tenant remains.
**Independent Test**: Open the matrix with one visible tenant and verify compact mode in auto state plus drilldown continuity.
### Tests for User Story 2
- [X] T016 [P] [US2] Add compact single-tenant page assertions for auto-to-compact resolution, including the assigned-greater-than-visible RBAC edge case, in `apps/platform/tests/Feature/Filament/BaselineCompareMatrixPageTest.php`
- [X] T017 [P] [US2] Add compact single-tenant builder assertions for visible-set-only compact resolution and unchanged compare semantics in `apps/platform/tests/Feature/Baselines/BaselineCompareMatrixBuilderTest.php`
### Implementation for User Story 2
- [X] T018 [US2] Emit compact single-tenant result entries, compact drilldown metadata, and visible-set-only compact resolution when assigned tenants exceed visible tenants in `apps/platform/app/Support/Baselines/BaselineCompareMatrixBuilder.php`
- [X] T019 [US2] Render the compact single-tenant compare list and reduced metadata shell in `apps/platform/resources/views/filament/pages/baseline-compare-matrix.blade.php`
- [X] T020 [US2] Preserve manual override, subject focus, and drilldown continuity for compact mode in `apps/platform/app/Filament/Pages/BaselineCompareMatrix.php`
- [X] T021 [US2] Run focused compact-mode verification in `apps/platform/tests/Feature/Filament/BaselineCompareMatrixPageTest.php` and `apps/platform/tests/Feature/Baselines/BaselineCompareMatrixBuilderTest.php`
**Checkpoint**: One-tenant viewing is materially shorter and calmer than the current matrix surface.
---
## Phase 5: User Story 3 - Use filters, legends, and status surfaces without losing the matrix (Priority: P2)
**Goal**: Compress supporting context so it stays useful without pushing the matrix down or increasing visual noise.
**Independent Test**: Apply filters, inspect legends, and observe background refresh behavior without losing scanability.
### Tests for User Story 3
- [X] T022 [P] [US3] Add staged-filter, legend-compaction, refresh-cue, and zero-result empty-state assertions in `apps/platform/tests/Feature/Filament/BaselineCompareMatrixPageTest.php`
- [X] T023 [P] [US3] Add browser smoke coverage for apply/reset filters, passive auto-refresh cues, and filtered zero-result empty states in `apps/platform/tests/Browser/Spec190BaselineCompareMatrixSmokeTest.php`
### Implementation for User Story 3
- [X] T024 [US3] Implement staged heavy-filter draft, apply, reset, and zero-result empty-state behavior in `apps/platform/app/Filament/Pages/BaselineCompareMatrix.php`
- [X] T025 [US3] Replace the long policy-type control with a searchable compact selector in `apps/platform/app/Filament/Pages/BaselineCompareMatrix.php`
- [X] T026 [US3] Render applied-versus-draft filter summaries, one grouped collapsed legend block, and compressed support context in `apps/platform/resources/views/filament/pages/baseline-compare-matrix.blade.php`
- [X] T027 [US3] Render honest manual-refresh, passive polling, and last-updated cues in `apps/platform/app/Filament/Pages/BaselineCompareMatrix.php` and `apps/platform/resources/views/filament/pages/baseline-compare-matrix.blade.php`
- [X] T028 [US3] Keep calmer actions and forbidden row-click behavior enforced in `apps/platform/tests/Feature/Guards/ActionSurfaceContractTest.php`
- [X] T029 [US3] Run focused support-surface verification, including zero-result empty-state behavior, in `apps/platform/tests/Feature/Filament/BaselineCompareMatrixPageTest.php`, `apps/platform/tests/Feature/Guards/ActionSurfaceContractTest.php`, and `apps/platform/tests/Browser/Spec190BaselineCompareMatrixSmokeTest.php`
**Checkpoint**: Filters, legends, and status surfaces support the operator without visually competing with the matrix.
---
## Phase 6: Polish & Cross-Cutting Concerns
**Purpose**: Finalize copy, formatting, and the focused verification pack.
- [X] T030 [P] Review `auto`, `dense`, `compact`, `last updated`, and action-copy vocabulary in `apps/platform/app/Filament/Pages/BaselineCompareMatrix.php` and `apps/platform/resources/views/filament/pages/baseline-compare-matrix.blade.php`
- [X] T031 [P] Verify shared badge semantics remain centralized in `apps/platform/app/Filament/Pages/BaselineCompareMatrix.php`, `apps/platform/resources/views/filament/pages/baseline-compare-matrix.blade.php`, and `apps/platform/tests/Feature/Filament/BaselineCompareMatrixPageTest.php`
- [X] T032 [P] Run formatting for changed implementation files in `apps/platform/app/Filament/Pages/BaselineCompareMatrix.php` and `apps/platform/app/Support/Baselines/BaselineCompareMatrixBuilder.php`
- [X] T033 Run the focused verification pack and confirm no compare-truth or persistence regressions in `apps/platform/tests/Feature/Filament/BaselineCompareMatrixPageTest.php`, `apps/platform/tests/Feature/Baselines/BaselineCompareMatrixBuilderTest.php`, `apps/platform/tests/Feature/Guards/ActionSurfaceContractTest.php`, and `apps/platform/tests/Browser/Spec190BaselineCompareMatrixSmokeTest.php`
---
## Dependencies & Execution Order
### Phase Dependencies
- **Setup (Phase 1)**: No dependencies. Start immediately.
- **Foundational (Phase 2)**: Depends on Phase 1. Blocks all user-story implementation.
- **User Story 1 (Phase 3)**: Depends on Phase 2. This is the MVP slice.
- **User Story 2 (Phase 4)**: Depends on Phase 2. Can proceed after the shared presentation contract is stable.
- **User Story 3 (Phase 5)**: Depends on Phase 2. Should land after the dense and compact layout branches exist.
- **Polish (Phase 6)**: Depends on all desired user stories being complete.
### User Story Dependencies
- **US1**: Independent after Phase 2 and should be delivered first.
- **US2**: Independent after Phase 2, but it reuses the shared presentation contract from US1-era foundational work.
- **US3**: Independent after Phase 2, but it should align with the final dense and compact layout structure.
### Within Each User Story
- Tests for that story should be written and made to fail before implementation.
- Builder and page state updates should land before Blade branching that depends on them.
- Each story must remain independently testable when finished.
## Parallel Execution Examples
### User Story 1
- Run `T009` and `T010` in parallel because they touch separate test files.
- After `T011` lands, `T012` can proceed while `T014` is prepared if the route-state contract is already stable.
### User Story 2
- Run `T016` and `T017` in parallel because they cover separate test layers.
- `T018` should land before `T019` because the compact Blade path depends on compact result entries.
### User Story 3
- Run `T022` and `T023` in parallel because they touch separate test files.
- `T024` and `T025` can be split between staged filter flow and selector compaction if coordinated on `apps/platform/app/Filament/Pages/BaselineCompareMatrix.php`.
## Implementation Strategy
### MVP First
1. Finish Setup and Foundational work.
2. Deliver US1 dense multi-tenant mode as the MVP operator gain.
3. Verify US1 independently before moving on.
### Incremental Delivery
1. Add US2 compact single-tenant mode on top of the shared presentation contract.
2. Add US3 filter, legend, and refresh-surface compression once both layout branches are stable.
3. Finish with copy review, formatting, and the focused verification pack.
### Validation Rule
1. Do not mark a story complete until its focused verification task passes.
2. Keep the existing Spec 190 truth, RBAC semantics, and drilldown continuity intact while implementing each story.