Compare commits

...

1 Commits

Author SHA1 Message Date
Ahmed Darrazi
7fd84a16b8 feat: add portfolio triage review state tracking 2026-04-10 23:34:02 +02:00
47 changed files with 4913 additions and 107 deletions

View File

@ -163,6 +163,8 @@ ## Active Technologies
- PostgreSQL unchanged; no new tables, caches, or durable workflow artifacts (187-portfolio-triage-arrival-context) - 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) - 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) - 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) - PHP 8.4.15 (feat/005-bulk-operations)
@ -197,8 +199,8 @@ ## Code Style
PHP 8.4.15: Follow standard conventions PHP 8.4.15: Follow standard conventions
## Recent Changes ## 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 - 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 - 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
<!-- MANUAL ADDITIONS START --> <!-- MANUAL ADDITIONS START -->
<!-- MANUAL ADDITIONS END --> <!-- MANUAL ADDITIONS END -->

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -95,6 +95,8 @@ enum AuditActionId: string
case TenantReviewArchived = 'tenant_review.archived'; case TenantReviewArchived = 'tenant_review.archived';
case TenantReviewExported = 'tenant_review.exported'; case TenantReviewExported = 'tenant_review.exported';
case TenantReviewSuccessorCreated = 'tenant_review.successor_created'; 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). // Workspace selection / switch events (Spec 107).
case WorkspaceAutoSelected = 'workspace.auto_selected'; case WorkspaceAutoSelected = 'workspace.auto_selected';
@ -228,6 +230,8 @@ private static function labels(): array
self::TenantReviewArchived->value => 'Tenant review archived', self::TenantReviewArchived->value => 'Tenant review archived',
self::TenantReviewExported->value => 'Tenant review exported', self::TenantReviewExported->value => 'Tenant review exported',
self::TenantReviewSuccessorCreated->value => 'Tenant review next cycle created', 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.started' => 'Baseline capture started',
'baseline.capture.completed' => 'Baseline capture completed', 'baseline.capture.completed' => 'Baseline capture completed',
'baseline.capture.failed' => 'Baseline capture failed', 'baseline.capture.failed' => 'Baseline capture failed',

View File

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

View File

@ -62,6 +62,7 @@ final class BadgeCatalog
BadgeDomain::EvidenceCompleteness->value => Domains\EvidenceCompletenessBadge::class, BadgeDomain::EvidenceCompleteness->value => Domains\EvidenceCompletenessBadge::class,
BadgeDomain::TenantReviewStatus->value => Domains\TenantReviewStatusBadge::class, BadgeDomain::TenantReviewStatus->value => Domains\TenantReviewStatusBadge::class,
BadgeDomain::TenantReviewCompleteness->value => Domains\TenantReviewCompletenessStateBadge::class, BadgeDomain::TenantReviewCompleteness->value => Domains\TenantReviewCompletenessStateBadge::class,
BadgeDomain::TenantTriageReviewState->value => Domains\TenantTriageReviewStateBadge::class,
BadgeDomain::SystemHealth->value => Domains\SystemHealthBadge::class, BadgeDomain::SystemHealth->value => Domains\SystemHealthBadge::class,
BadgeDomain::ReferenceResolutionState->value => Domains\ReferenceResolutionStateBadge::class, BadgeDomain::ReferenceResolutionState->value => Domains\ReferenceResolutionStateBadge::class,
BadgeDomain::DiffRowStatus->value => Domains\DiffRowStatusBadge::class, BadgeDomain::DiffRowStatus->value => Domains\DiffRowStatusBadge::class,

View File

@ -53,6 +53,7 @@ enum BadgeDomain: string
case EvidenceCompleteness = 'evidence_completeness'; case EvidenceCompleteness = 'evidence_completeness';
case TenantReviewStatus = 'tenant_review_status'; case TenantReviewStatus = 'tenant_review_status';
case TenantReviewCompleteness = 'tenant_review_completeness'; case TenantReviewCompleteness = 'tenant_review_completeness';
case TenantTriageReviewState = 'tenant_triage_review_state';
case SystemHealth = 'system_health'; case SystemHealth = 'system_health';
case ReferenceResolutionState = 'reference_resolution_state'; case ReferenceResolutionState = 'reference_resolution_state';
case DiffRowStatus = 'diff_row_status'; case DiffRowStatus = 'diff_row_status';

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -15,6 +15,18 @@
expect($js) expect($js)
->toContain('__tenantpilotUnhandledRejectionLoggerApplied') ->toContain('__tenantpilotUnhandledRejectionLoggerApplied')
->toContain("window.addEventListener('unhandledrejection'") ->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('TenantPilot unhandled promise rejection')
->toContain('JSON.stringify'); ->toContain('JSON.stringify')
->not->toContain('recentKeys.has(payloadJson)')
->not->toContain('recentKeys.set(payloadJson, nowMs)');
}); });

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,36 @@
# Specification Quality Checklist: Portfolio Triage Review State and Operator Progress
**Purpose**: Validate specification completeness and quality before proceeding to planning
**Created**: 2026-04-10
**Feature**: [spec.md](../spec.md)
## Content Quality
- [x] No implementation details (languages, frameworks, APIs)
- [x] Focused on user value and business needs
- [x] Written for non-technical stakeholders
- [x] All mandatory sections completed
## Requirement Completeness
- [x] No [NEEDS CLARIFICATION] markers remain
- [x] Requirements are testable and unambiguous
- [x] Success criteria are measurable
- [x] Success criteria are technology-agnostic (no implementation details)
- [x] All acceptance scenarios are defined
- [x] Edge cases are identified
- [x] Scope is clearly bounded
- [x] Dependencies and assumptions identified
## Feature Readiness
- [x] All functional requirements have clear acceptance criteria
- [x] User scenarios cover primary flows
- [x] Feature meets measurable outcomes defined in Success Criteria
- [x] No implementation details leak into specification
## Notes
- Validation pass completed on 2026-04-10.
- Route identifiers, stored-state semantics, and operator surface names are included because the project template and constitution require explicit scope, action-surface, and persistence-truth definitions for operator-facing changes.
- The spec intentionally adds one lightweight persisted triage-review truth while rejecting formal review packs, notes, assignments, SLAs, and broader workflow-engine behavior.

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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