From 2f45ff5a84aae90d7458682720b6c8c2085c9176 Mon Sep 17 00:00:00 2001 From: ahmido Date: Fri, 10 Apr 2026 21:35:17 +0000 Subject: [PATCH] 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 Reviewed-on: https://git.cloudarix.de/ahmido/TenantAtlas/pulls/220 --- .github/agents/copilot-instructions.md | 4 +- .specify/memory/constitution.md | 9 + .specify/memory/spec-approval-rubric.md | 236 ++++++ .specify/templates/spec-template.md | 18 + .../app/Filament/Resources/TenantResource.php | 711 +++++++++++++++--- .../TenantResource/Pages/ListTenants.php | 15 +- .../Tenant/TenantTriageArrivalContinuity.php | 238 +++++- .../Workspace/WorkspaceNeedsAttention.php | 9 +- .../app/Models/TenantTriageReview.php | 134 ++++ .../app/Providers/AppServiceProvider.php | 4 +- .../app/Services/Auth/RoleCapabilityMap.php | 3 + .../TenantTriageReviewService.php | 165 ++++ .../app/Support/Audit/AuditActionId.php | 4 + .../app/Support/Auth/Capabilities.php | 3 + .../app/Support/Badges/BadgeCatalog.php | 1 + .../app/Support/Badges/BadgeDomain.php | 1 + .../Domains/TenantTriageReviewStateBadge.php | 26 + .../PortfolioArrivalContextResolver.php | 49 +- .../PortfolioArrivalContextToken.php | 1 + .../TenantTriageReviewFingerprint.php | 157 ++++ .../TenantTriageReviewStateResolver.php | 192 +++++ .../app/Support/Rbac/UiEnforcement.php | 16 + .../Workspaces/WorkspaceOverviewBuilder.php | 167 +++- .../factories/TenantTriageReviewFactory.php | 142 ++++ ...003_create_tenant_triage_reviews_table.php | 63 ++ .../tenantpilot/unhandled-rejection-logger.js | 62 +- .../pages/workspace-overview.blade.php | 1 + .../triage-arrival-continuity.blade.php | 36 + .../workspace-needs-attention.blade.php | 38 + .../BuildsPortfolioTriageFixtures.php | 51 ++ .../TenantDashboardArrivalContextTest.php | 87 +++ .../TenantRegistryTriageReviewStateTest.php | 176 +++++ .../UnhandledRejectionLoggerAssetTest.php | 14 +- ...kspaceOverviewTriageReviewProgressTest.php | 69 ++ .../TriageReviewStateAuthorizationTest.php | 140 ++++ apps/platform/tests/Pest.php | 5 + .../TenantTriageReviewStateBadgesTest.php | 30 + .../TenantTriageReviewFingerprintTest.php | 141 ++++ .../TenantTriageReviewStateResolverTest.php | 179 +++++ .../checklists/requirements.md | 36 + ...o-triage-review-state.logical.openapi.yaml | 436 +++++++++++ .../data-model.md | 161 ++++ .../189-portfolio-triage-review-state/plan.md | 282 +++++++ .../quickstart.md | 84 +++ .../research.md | 81 ++ .../189-portfolio-triage-review-state/spec.md | 276 +++++++ .../tasks.md | 267 +++++++ 47 files changed, 4913 insertions(+), 107 deletions(-) create mode 100644 .specify/memory/spec-approval-rubric.md create mode 100644 apps/platform/app/Models/TenantTriageReview.php create mode 100644 apps/platform/app/Services/PortfolioTriage/TenantTriageReviewService.php create mode 100644 apps/platform/app/Support/Badges/Domains/TenantTriageReviewStateBadge.php create mode 100644 apps/platform/app/Support/PortfolioTriage/TenantTriageReviewFingerprint.php create mode 100644 apps/platform/app/Support/PortfolioTriage/TenantTriageReviewStateResolver.php create mode 100644 apps/platform/database/factories/TenantTriageReviewFactory.php create mode 100644 apps/platform/database/migrations/2026_04_10_000003_create_tenant_triage_reviews_table.php create mode 100644 apps/platform/tests/Feature/Filament/TenantRegistryTriageReviewStateTest.php create mode 100644 apps/platform/tests/Feature/Filament/WorkspaceOverviewTriageReviewProgressTest.php create mode 100644 apps/platform/tests/Feature/Rbac/TriageReviewStateAuthorizationTest.php create mode 100644 apps/platform/tests/Unit/Badges/TenantTriageReviewStateBadgesTest.php create mode 100644 apps/platform/tests/Unit/Support/PortfolioTriage/TenantTriageReviewFingerprintTest.php create mode 100644 apps/platform/tests/Unit/Support/PortfolioTriage/TenantTriageReviewStateResolverTest.php create mode 100644 specs/189-portfolio-triage-review-state/checklists/requirements.md create mode 100644 specs/189-portfolio-triage-review-state/contracts/portfolio-triage-review-state.logical.openapi.yaml create mode 100644 specs/189-portfolio-triage-review-state/data-model.md create mode 100644 specs/189-portfolio-triage-review-state/plan.md create mode 100644 specs/189-portfolio-triage-review-state/quickstart.md create mode 100644 specs/189-portfolio-triage-review-state/research.md create mode 100644 specs/189-portfolio-triage-review-state/spec.md create mode 100644 specs/189-portfolio-triage-review-state/tasks.md diff --git a/.github/agents/copilot-instructions.md b/.github/agents/copilot-instructions.md index 9ec7dd74..f0c4f35d 100644 --- a/.github/agents/copilot-instructions.md +++ b/.github/agents/copilot-instructions.md @@ -163,6 +163,8 @@ ## 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 (feat/005-bulk-operations) @@ -197,8 +199,8 @@ ## Code Style PHP 8.4.15: Follow standard conventions ## Recent Changes +- 189-portfolio-triage-review-state: Added PHP 8.4.15 + Laravel 12, Filament v5, Livewire v4, Pest v4, existing `WorkspaceOverviewBuilder`, `TenantResource`, `TenantDashboard`, `PortfolioArrivalContext`, `TenantBackupHealthResolver`, `RestoreSafetyResolver`, `BadgeCatalog`, `UiEnforcement`, and `AuditRecorder` patterns - 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 diff --git a/.specify/memory/constitution.md b/.specify/memory/constitution.md index 2276872a..bb19be46 100644 --- a/.specify/memory/constitution.md +++ b/.specify/memory/constitution.md @@ -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, 0–2 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 0–3 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. diff --git a/.specify/memory/spec-approval-rubric.md b/.specify/memory/spec-approval-rubric.md new file mode 100644 index 00000000..0590ee5d --- /dev/null +++ b/.specify/memory/spec-approval-rubric.md @@ -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 (0–2 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 | +|---|---| +| **10–12** | Freigabefähig | +| **7–9** | Nur freigeben wenn Scope enger gezogen wird | +| **4–6** | Verschieben oder zu Cleanup/Micro-Follow-up downgraden | +| **0–3** | 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 | diff --git a/.specify/templates/spec-template.md b/.specify/templates/spec-template.md index ff9b1a9d..8d18a681 100644 --- a/.specify/templates/spec-template.md +++ b/.specify/templates/spec-template.md @@ -5,6 +5,24 @@ # Feature Specification: [FEATURE NAME] **Status**: Draft **Input**: User description: "$ARGUMENTS" +## Spec Candidate Check *(mandatory — SPEC-GATE-001)* + + + +- **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] diff --git a/apps/platform/app/Filament/Resources/TenantResource.php b/apps/platform/app/Filament/Resources/TenantResource.php index 1227c95b..4d36ddd8 100644 --- a/apps/platform/app/Filament/Resources/TenantResource.php +++ b/apps/platform/app/Filament/Resources/TenantResource.php @@ -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 */ @@ -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 + */ + 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, * recovery_evidence?: list, + * review_state?: list, * triage_sort?: string|null * } $triageState */ @@ -966,6 +1070,7 @@ public static function tenantDashboardOpenUrl(Tenant $record, array $triageState * @param array{ * backup_posture?: list, * recovery_evidence?: list, + * review_state?: list, * triage_sort?: string|null * } $triageState * @return array|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 + */ + 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, + * recovery_evidence: list, + * review_state: list, + * 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 + */ + 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, + * recovery_evidence: array + * }>, + * summaries: array + * } + */ + 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, + * recovery_evidence?: list, + * review_state?: list, + * triage_sort?: string|null + * } $triageState + * @return array|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, + * recovery_evidence?: array + * } $rows + * @param array{ + * backup_posture?: list, + * recovery_evidence?: list, + * review_state?: list, + * triage_sort?: string|null + * } $triageState + * @return array|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, + * recovery_evidence?: array + * } $rows + * @param array{ + * backup_posture?: list, + * recovery_evidence?: list, + * review_state?: list, + * 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, + * recovery_evidence?: list, + * review_state?: list, + * 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, + * recovery_evidence?: list, + * review_state?: list, + * 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, + * recovery_evidence?: list, + * review_state?: list, + * 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, + * recovery_evidence?: list, + * review_state?: list, + * triage_sort?: string|null + * } $triageState + * @return array|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 $selectedValues + * @param array{ + * backup_posture?: list, + * recovery_evidence?: list, + * review_state?: list, + * 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']; diff --git a/apps/platform/app/Filament/Resources/TenantResource/Pages/ListTenants.php b/apps/platform/app/Filament/Resources/TenantResource/Pages/ListTenants.php index 75f360f7..162f8fe5 100644 --- a/apps/platform/app/Filament/Resources/TenantResource/Pages/ListTenants.php +++ b/apps/platform/app/Filament/Resources/TenantResource/Pages/ListTenants.php @@ -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, recovery_evidence: list, triage_sort: string|null} + * @return array{backup_posture: list, recovery_evidence: list, review_state: list, 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')), ]; } diff --git a/apps/platform/app/Filament/Widgets/Tenant/TenantTriageArrivalContinuity.php b/apps/platform/app/Filament/Widgets/Tenant/TenantTriageArrivalContinuity.php index a9c97585..06befe01 100644 --- a/apps/platform/app/Filament/Widgets/Tenant/TenantTriageArrivalContinuity.php +++ b/apps/platform/app/Filament/Widgets/Tenant/TenantTriageArrivalContinuity.php @@ -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|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 */ @@ -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|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); + } } diff --git a/apps/platform/app/Filament/Widgets/Workspace/WorkspaceNeedsAttention.php b/apps/platform/app/Filament/Widgets/Workspace/WorkspaceNeedsAttention.php index 59d89eb6..4780d269 100644 --- a/apps/platform/app/Filament/Widgets/Workspace/WorkspaceNeedsAttention.php +++ b/apps/platform/app/Filament/Widgets/Workspace/WorkspaceNeedsAttention.php @@ -46,6 +46,11 @@ class WorkspaceNeedsAttention extends Widget */ public array $emptyState = []; + /** + * @var array> + */ + public array $triageReviewProgress = []; + /** * @param array> $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; } } diff --git a/apps/platform/app/Models/TenantTriageReview.php b/apps/platform/app/Models/TenantTriageReview.php new file mode 100644 index 00000000..fa8961c6 --- /dev/null +++ b/apps/platform/app/Models/TenantTriageReview.php @@ -0,0 +1,134 @@ + + */ + public const ACTIVE_CONCERN_FAMILIES = [ + PortfolioArrivalContextToken::FAMILY_BACKUP_HEALTH, + PortfolioArrivalContextToken::FAMILY_RECOVERY_EVIDENCE, + ]; + + /** + * @var list + */ + public const MANUAL_STATES = [ + self::STATE_REVIEWED, + self::STATE_FOLLOW_UP_NEEDED, + ]; + + /** + * @var list + */ + 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 + */ + protected function casts(): array + { + return [ + 'review_snapshot' => 'array', + 'reviewed_at' => 'datetime', + 'last_seen_matching_at' => 'datetime', + 'resolved_at' => 'datetime', + ]; + } + + /** + * @return BelongsTo + */ + public function workspace(): BelongsTo + { + return $this->belongsTo(Workspace::class); + } + + /** + * @return BelongsTo + */ + public function tenant(): BelongsTo + { + return $this->belongsTo(Tenant::class); + } + + /** + * @return BelongsTo + */ + public function reviewer(): BelongsTo + { + return $this->belongsTo(User::class, 'reviewed_by_user_id'); + } + + /** + * @param Builder $query + * @return Builder + */ + public function scopeForWorkspace(Builder $query, int $workspaceId): Builder + { + return $query->where('workspace_id', $workspaceId); + } + + /** + * @param Builder $query + * @return Builder + */ + public function scopeForTenant(Builder $query, int $tenantId): Builder + { + return $query->where('tenant_id', $tenantId); + } + + /** + * @param Builder $query + * @return Builder + */ + public function scopeForConcernFamily(Builder $query, string $concernFamily): Builder + { + return $query->where('concern_family', $concernFamily); + } + + /** + * @param Builder $query + * @return Builder + */ + public function scopeActive(Builder $query): Builder + { + return $query->whereNull('resolved_at'); + } + + /** + * @param Builder $query + * @return Builder + */ + public function scopeResolved(Builder $query): Builder + { + return $query->whereNotNull('resolved_at'); + } +} diff --git a/apps/platform/app/Providers/AppServiceProvider.php b/apps/platform/app/Providers/AppServiceProvider.php index d5f02f77..e1c7f965 100644 --- a/apps/platform/app/Providers/AppServiceProvider.php +++ b/apps/platform/app/Providers/AppServiceProvider.php @@ -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); diff --git a/apps/platform/app/Services/Auth/RoleCapabilityMap.php b/apps/platform/app/Services/Auth/RoleCapabilityMap.php index ddae5205..c81993b4 100644 --- a/apps/platform/app/Services/Auth/RoleCapabilityMap.php +++ b/apps/platform/app/Services/Auth/RoleCapabilityMap.php @@ -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, ], diff --git a/apps/platform/app/Services/PortfolioTriage/TenantTriageReviewService.php b/apps/platform/app/Services/PortfolioTriage/TenantTriageReviewService.php new file mode 100644 index 00000000..e37f3ad7 --- /dev/null +++ b/apps/platform/app/Services/PortfolioTriage/TenantTriageReviewService.php @@ -0,0 +1,165 @@ +|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|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|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); + } +} diff --git a/apps/platform/app/Support/Audit/AuditActionId.php b/apps/platform/app/Support/Audit/AuditActionId.php index 7053e0ee..d3ef692c 100644 --- a/apps/platform/app/Support/Audit/AuditActionId.php +++ b/apps/platform/app/Support/Audit/AuditActionId.php @@ -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', diff --git a/apps/platform/app/Support/Auth/Capabilities.php b/apps/platform/app/Support/Auth/Capabilities.php index 94ae85b5..a5f32a7d 100644 --- a/apps/platform/app/Support/Auth/Capabilities.php +++ b/apps/platform/app/Support/Auth/Capabilities.php @@ -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'; diff --git a/apps/platform/app/Support/Badges/BadgeCatalog.php b/apps/platform/app/Support/Badges/BadgeCatalog.php index 399b929d..bbe1a4d0 100644 --- a/apps/platform/app/Support/Badges/BadgeCatalog.php +++ b/apps/platform/app/Support/Badges/BadgeCatalog.php @@ -62,6 +62,7 @@ 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, diff --git a/apps/platform/app/Support/Badges/BadgeDomain.php b/apps/platform/app/Support/Badges/BadgeDomain.php index f65b9444..523d1bcb 100644 --- a/apps/platform/app/Support/Badges/BadgeDomain.php +++ b/apps/platform/app/Support/Badges/BadgeDomain.php @@ -53,6 +53,7 @@ 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'; diff --git a/apps/platform/app/Support/Badges/Domains/TenantTriageReviewStateBadge.php b/apps/platform/app/Support/Badges/Domains/TenantTriageReviewStateBadge.php new file mode 100644 index 00000000..44074753 --- /dev/null +++ b/apps/platform/app/Support/Badges/Domains/TenantTriageReviewStateBadge.php @@ -0,0 +1,26 @@ + 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(), + }; + } +} diff --git a/apps/platform/app/Support/PortfolioTriage/PortfolioArrivalContextResolver.php b/apps/platform/app/Support/PortfolioTriage/PortfolioArrivalContextResolver.php index b7d7f6b1..7cadf987 100644 --- a/apps/platform/app/Support/PortfolioTriage/PortfolioArrivalContextResolver.php +++ b/apps/platform/app/Support/PortfolioTriage/PortfolioArrivalContextResolver.php @@ -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|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|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|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; } /** diff --git a/apps/platform/app/Support/PortfolioTriage/PortfolioArrivalContextToken.php b/apps/platform/app/Support/PortfolioTriage/PortfolioArrivalContextToken.php index 780f2689..7e670958 100644 --- a/apps/platform/app/Support/PortfolioTriage/PortfolioArrivalContextToken.php +++ b/apps/platform/app/Support/PortfolioTriage/PortfolioArrivalContextToken.php @@ -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, ]; diff --git a/apps/platform/app/Support/PortfolioTriage/TenantTriageReviewFingerprint.php b/apps/platform/app/Support/PortfolioTriage/TenantTriageReviewFingerprint.php new file mode 100644 index 00000000..c59b535d --- /dev/null +++ b/apps/platform/app/Support/PortfolioTriage/TenantTriageReviewFingerprint.php @@ -0,0 +1,157 @@ +|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|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, + ))); + } + } +} diff --git a/apps/platform/app/Support/PortfolioTriage/TenantTriageReviewStateResolver.php b/apps/platform/app/Support/PortfolioTriage/TenantTriageReviewStateResolver.php new file mode 100644 index 00000000..99a52281 --- /dev/null +++ b/apps/platform/app/Support/PortfolioTriage/TenantTriageReviewStateResolver.php @@ -0,0 +1,192 @@ + $tenantIds + * @param array $backupHealthByTenant + * @param array> $recoveryEvidenceByTenant + * @return array{ + * rows: array, + * recovery_evidence: array + * }>, + * summaries: array + * } + */ + 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 + * }|null $currentConcern + * @return array + */ + 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, + ]; + } +} diff --git a/apps/platform/app/Support/Rbac/UiEnforcement.php b/apps/platform/app/Support/Rbac/UiEnforcement.php index 0b4dda15..96a97c54 100644 --- a/apps/platform/app/Support/Rbac/UiEnforcement.php +++ b/apps/platform/app/Support/Rbac/UiEnforcement.php @@ -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 diff --git a/apps/platform/app/Support/Workspaces/WorkspaceOverviewBuilder.php b/apps/platform/app/Support/Workspaces/WorkspaceOverviewBuilder.php index b63e1445..44d28e59 100644 --- a/apps/platform/app/Support/Workspaces/WorkspaceOverviewBuilder.php +++ b/apps/platform/app/Support/Workspaces/WorkspaceOverviewBuilder.php @@ -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> $tenantContexts + * @return array{ + * summaries: array, + * families: list> + * } + */ + 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 $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 */ @@ -938,6 +1030,77 @@ private function makeSummaryMetric( ]; } + /** + * @param array $triageReviewSummaries + * @return list> + */ + 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 + */ + 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> $tenantContexts */ diff --git a/apps/platform/database/factories/TenantTriageReviewFactory.php b/apps/platform/database/factories/TenantTriageReviewFactory.php new file mode 100644 index 00000000..52580288 --- /dev/null +++ b/apps/platform/database/factories/TenantTriageReviewFactory.php @@ -0,0 +1,142 @@ + + */ +class TenantTriageReviewFactory extends Factory +{ + protected $model = TenantTriageReview::class; + + /** + * @return array + */ + 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 $snapshot + */ + private function hashSnapshot(array $snapshot): string + { + return hash('sha256', json_encode($snapshot, JSON_THROW_ON_ERROR)); + } +} diff --git a/apps/platform/database/migrations/2026_04_10_000003_create_tenant_triage_reviews_table.php b/apps/platform/database/migrations/2026_04_10_000003_create_tenant_triage_reviews_table.php new file mode 100644 index 00000000..3eec949a --- /dev/null +++ b/apps/platform/database/migrations/2026_04_10_000003_create_tenant_triage_reviews_table.php @@ -0,0 +1,63 @@ +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'); + } +}; diff --git a/apps/platform/public/js/tenantpilot/unhandled-rejection-logger.js b/apps/platform/public/js/tenantpilot/unhandled-rejection-logger.js index fda0ef7a..a64e95d1 100644 --- a/apps/platform/public/js/tenantpilot/unhandled-rejection-logger.js +++ b/apps/platform/public/js/tenantpilot/unhandled-rejection-logger.js @@ -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}`); }); diff --git a/apps/platform/resources/views/filament/pages/workspace-overview.blade.php b/apps/platform/resources/views/filament/pages/workspace-overview.blade.php index 30ee79e1..1d99d520 100644 --- a/apps/platform/resources/views/filament/pages/workspace-overview.blade.php +++ b/apps/platform/resources/views/filament/pages/workspace-overview.blade.php @@ -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, [ diff --git a/apps/platform/resources/views/filament/widgets/tenant/triage-arrival-continuity.blade.php b/apps/platform/resources/views/filament/widgets/tenant/triage-arrival-continuity.blade.php index 09f3e1e9..a95b55c3 100644 --- a/apps/platform/resources/views/filament/widgets/tenant/triage-arrival-continuity.blade.php +++ b/apps/platform/resources/views/filament/widgets/tenant/triage-arrival-continuity.blade.php @@ -1,5 +1,6 @@ @php /** @var ?\App\Support\PortfolioTriage\PortfolioArrivalContext $context */ + /** @var array|null $reviewState */ @endphp
@@ -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 @@ -49,6 +52,24 @@ class="h-5 w-5 text-warning-500" {{ $context->arrivalSummary }}
+ @if (is_array($reviewState) && ($reviewState['current_concern_present'] ?? false) === true) +
+ + {{ $reviewStateLabel }} + + + + {{ $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 + +
+ @endif + @if (filled($context->currentTruthDelta))
{{ $context->currentTruthDelta }} @@ -89,9 +110,24 @@ class="h-5 w-5 text-warning-500" {{ $context->nextStep['helperText'] }}
@endif + + @if (is_array($reviewState) && ($reviewState['current_concern_present'] ?? false) === true) +
+ TenantPilot only. Review state tracks shared triage progress and never changes backup posture or recovery evidence. +
+ @endif + + @if (is_array($reviewState) && ($reviewState['current_concern_present'] ?? false) === true) +
+ {{ $this->markReviewedAction }} + {{ $this->markFollowUpNeededAction }} +
+ @endif + + @endif diff --git a/apps/platform/resources/views/filament/widgets/workspace/workspace-needs-attention.blade.php b/apps/platform/resources/views/filament/widgets/workspace/workspace-needs-attention.blade.php index 7dee676b..1836e35d 100644 --- a/apps/platform/resources/views/filament/widgets/workspace/workspace-needs-attention.blade.php +++ b/apps/platform/resources/views/filament/widgets/workspace/workspace-needs-attention.blade.php @@ -1,4 +1,42 @@ + @if ($triageReviewProgress !== []) +
+ @foreach ($triageReviewProgress as $progress) +
+
+
+
+ {{ $progress['label'] }} +
+
+ Reviewed {{ $progress['reviewed_count'] }}/{{ $progress['affected_total'] }} · Follow-up needed {{ $progress['follow_up_needed_count'] }} · Changed since review {{ $progress['changed_since_review_count'] }} +
+
+ + + Current affected set + +
+ +
+ + Not reviewed {{ $progress['not_reviewed_count'] }} + + + Follow-up needed {{ $progress['follow_up_needed_count'] }} + + + Changed since review {{ $progress['changed_since_review_count'] }} + + + Reviewed {{ $progress['reviewed_count'] }} + +
+
+ @endforeach +
+ @endif + @if ($items === [])
diff --git a/apps/platform/tests/Feature/Concerns/BuildsPortfolioTriageFixtures.php b/apps/platform/tests/Feature/Concerns/BuildsPortfolioTriageFixtures.php index 00bce56c..ec1b6f01 100644 --- a/apps/platform/tests/Feature/Concerns/BuildsPortfolioTriageFixtures.php +++ b/apps/platform/tests/Feature/Concerns/BuildsPortfolioTriageFixtures.php @@ -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']); + } } diff --git a/apps/platform/tests/Feature/Filament/TenantDashboardArrivalContextTest.php b/apps/platform/tests/Feature/Filament/TenantDashboardArrivalContextTest.php index f9ca9147..dc1a915e 100644 --- a/apps/platform/tests/Feature/Filament/TenantDashboardArrivalContextTest.php +++ b/apps/platform/tests/Feature/Filament/TenantDashboardArrivalContextTest.php @@ -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); +}); diff --git a/apps/platform/tests/Feature/Filament/TenantRegistryTriageReviewStateTest.php b/apps/platform/tests/Feature/Filament/TenantRegistryTriageReviewStateTest.php new file mode 100644 index 00000000..08a6ef71 --- /dev/null +++ b/apps/platform/tests/Feature/Filament/TenantRegistryTriageReviewStateTest.php @@ -0,0 +1,176 @@ +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); +}); diff --git a/apps/platform/tests/Feature/Filament/UnhandledRejectionLoggerAssetTest.php b/apps/platform/tests/Feature/Filament/UnhandledRejectionLoggerAssetTest.php index 0d4126e7..67aa70c5 100644 --- a/apps/platform/tests/Feature/Filament/UnhandledRejectionLoggerAssetTest.php +++ b/apps/platform/tests/Feature/Filament/UnhandledRejectionLoggerAssetTest.php @@ -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)'); }); diff --git a/apps/platform/tests/Feature/Filament/WorkspaceOverviewTriageReviewProgressTest.php b/apps/platform/tests/Feature/Filament/WorkspaceOverviewTriageReviewProgressTest.php new file mode 100644 index 00000000..9120e9f2 --- /dev/null +++ b/apps/platform/tests/Feature/Filament/WorkspaceOverviewTriageReviewProgressTest.php @@ -0,0 +1,69 @@ +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([]); +}); diff --git a/apps/platform/tests/Feature/Rbac/TriageReviewStateAuthorizationTest.php b/apps/platform/tests/Feature/Rbac/TriageReviewStateAuthorizationTest.php new file mode 100644 index 00000000..5a0a3dc0 --- /dev/null +++ b/apps/platform/tests/Feature/Rbac/TriageReviewStateAuthorizationTest.php @@ -0,0 +1,140 @@ + 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); +}); diff --git a/apps/platform/tests/Pest.php b/apps/platform/tests/Pest.php index bd4198a4..63930934 100644 --- a/apps/platform/tests/Pest.php +++ b/apps/platform/tests/Pest.php @@ -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); } diff --git a/apps/platform/tests/Unit/Badges/TenantTriageReviewStateBadgesTest.php b/apps/platform/tests/Unit/Badges/TenantTriageReviewStateBadgesTest.php new file mode 100644 index 00000000..96577fab --- /dev/null +++ b/apps/platform/tests/Unit/Badges/TenantTriageReviewStateBadgesTest.php @@ -0,0 +1,30 @@ +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'); +}); diff --git a/apps/platform/tests/Unit/Support/PortfolioTriage/TenantTriageReviewFingerprintTest.php b/apps/platform/tests/Unit/Support/PortfolioTriage/TenantTriageReviewFingerprintTest.php new file mode 100644 index 00000000..ca93b60f --- /dev/null +++ b/apps/platform/tests/Unit/Support/PortfolioTriage/TenantTriageReviewFingerprintTest.php @@ -0,0 +1,141 @@ +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(); +}); diff --git a/apps/platform/tests/Unit/Support/PortfolioTriage/TenantTriageReviewStateResolverTest.php b/apps/platform/tests/Unit/Support/PortfolioTriage/TenantTriageReviewStateResolverTest.php new file mode 100644 index 00000000..6a7706eb --- /dev/null +++ b/apps/platform/tests/Unit/Support/PortfolioTriage/TenantTriageReviewStateResolverTest.php @@ -0,0 +1,179 @@ +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); +}); diff --git a/specs/189-portfolio-triage-review-state/checklists/requirements.md b/specs/189-portfolio-triage-review-state/checklists/requirements.md new file mode 100644 index 00000000..b56e27bf --- /dev/null +++ b/specs/189-portfolio-triage-review-state/checklists/requirements.md @@ -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. \ No newline at end of file diff --git a/specs/189-portfolio-triage-review-state/contracts/portfolio-triage-review-state.logical.openapi.yaml b/specs/189-portfolio-triage-review-state/contracts/portfolio-triage-review-state.logical.openapi.yaml new file mode 100644 index 00000000..c64bc816 --- /dev/null +++ b/specs/189-portfolio-triage-review-state/contracts/portfolio-triage-review-state.logical.openapi.yaml @@ -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 \ No newline at end of file diff --git a/specs/189-portfolio-triage-review-state/data-model.md b/specs/189-portfolio-triage-review-state/data-model.md new file mode 100644 index 00000000..709e282b --- /dev/null +++ b/specs/189-portfolio-triage-review-state/data-model.md @@ -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. \ No newline at end of file diff --git a/specs/189-portfolio-triage-review-state/plan.md b/specs/189-portfolio-triage-review-state/plan.md new file mode 100644 index 00000000..8bc93637 --- /dev/null +++ b/specs/189-portfolio-triage-review-state/plan.md @@ -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. diff --git a/specs/189-portfolio-triage-review-state/quickstart.md b/specs/189-portfolio-triage-review-state/quickstart.md new file mode 100644 index 00000000..67e74fde --- /dev/null +++ b/specs/189-portfolio-triage-review-state/quickstart.md @@ -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. \ No newline at end of file diff --git a/specs/189-portfolio-triage-review-state/research.md b/specs/189-portfolio-triage-review-state/research.md new file mode 100644 index 00000000..f9d5ca2d --- /dev/null +++ b/specs/189-portfolio-triage-review-state/research.md @@ -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. \ No newline at end of file diff --git a/specs/189-portfolio-triage-review-state/spec.md b/specs/189-portfolio-triage-review-state/spec.md new file mode 100644 index 00000000..bbd936ae --- /dev/null +++ b/specs/189-portfolio-triage-review-state/spec.md @@ -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. diff --git a/specs/189-portfolio-triage-review-state/tasks.md b/specs/189-portfolio-triage-review-state/tasks.md new file mode 100644 index 00000000..36a5cdba --- /dev/null +++ b/specs/189-portfolio-triage-review-state/tasks.md @@ -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.