Compare commits

...

3 Commits

Author SHA1 Message Date
6f8eb28ca2 feat: add tenant backup health signals (#212)
## Summary
- add the Spec 180 tenant backup-health resolver and value objects to derive absent, stale, degraded, healthy, and schedule-follow-up posture from existing backup and schedule truth
- surface backup posture and reason-driven drillthroughs in the tenant dashboard and preserve continuity on backup-set and backup-schedule destinations
- add deterministic local/testing browser-fixture seeding plus a local fixture-login helper for the blocked drillthrough `403` scenario, along with the related spec artifacts and focused regression coverage

## Testing
- `vendor/bin/sail artisan test --compact tests/Feature/Auth/BackupHealthBrowserFixtureLoginTest.php tests/Feature/Console/TenantpilotSeedBackupHealthBrowserFixtureCommandTest.php`
- `vendor/bin/sail artisan test --compact tests/Unit/Support/BackupHealth/TenantBackupHealthResolverTest.php tests/Feature/Filament/DashboardKpisWidgetTest.php tests/Feature/Filament/NeedsAttentionWidgetTest.php tests/Feature/Filament/TenantDashboardTruthAlignmentTest.php tests/Feature/Filament/TenantDashboardTenantScopeTest.php tests/Feature/Filament/TenantDashboardDbOnlyTest.php tests/Feature/Filament/BackupSetListContinuityTest.php tests/Feature/Filament/BackupSetEnterpriseDetailPageTest.php tests/Feature/BackupScheduling/BackupScheduleLifecycleTest.php tests/Feature/Auth/BackupHealthBrowserFixtureLoginTest.php tests/Feature/Console/TenantpilotSeedBackupHealthBrowserFixtureCommandTest.php`

## Notes
- Filament v5 / Livewire v4 compliant; no panel-provider change was needed, so `bootstrap/providers.php` remains unchanged
- no new globally searchable resource was introduced, so global-search behavior is unchanged
- no new destructive action was added; existing destructive actions and confirmation behavior remain unchanged
- no new asset registration was added; the existing deploy-time `php artisan filament:assets` step remains sufficient
- the local fixture login helper route is limited to `local` and `testing` environments
- the focused and broader Spec 180 packs are green; the full suite was not rerun after these changes

Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de>
Reviewed-on: #212
2026-04-07 21:35:58 +00:00
e840007127 feat: add backup quality truth surfaces (#211)
## Summary
- add a shared backup-quality resolver and summary model for backup sets, backup items, policy versions, and restore selection
- surface backup-quality truth across Filament backup-set, policy-version, and restore-wizard entry points
- add focused Pest coverage and the full Spec Kit artifact set for spec 176

## Testing
- focused backup-quality verification and integrated-browser smoke coverage were completed during implementation
- degraded browser smoke path was validated with temporary seeded records and then cleaned up again
- the workspace already has a prior `vendor/bin/sail artisan test --compact` run exiting non-zero; that full-suite failure was not reworked as part of this PR

Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de>
Reviewed-on: #211
2026-04-07 11:39:40 +00:00
a107e7e41b feat: restore safety integrity and queue slide-over (#210)
## Summary
- add the Spec 181 restore-safety layer with scope fingerprinting, preview/check integrity states, execution safety snapshots, result attention, and operator-facing copy across the wizard, restore detail, and canonical operation detail
- add focused unit and feature coverage for restore-safety assessment, result attention, and restore-linked operation detail
- switch the finding exceptions queue `Inspect exception` action to a native Filament slide-over while preserving query-param-backed inline summary behavior

## Testing
- `vendor/bin/sail artisan test --compact tests/Feature/Monitoring/FindingExceptionsQueueTest.php tests/Feature/Filament/RestoreSafetyIntegrityWizardTest.php tests/Feature/Filament/RestoreResultAttentionSurfaceTest.php tests/Feature/Operations/RestoreLinkedOperationDetailTest.php tests/Unit/Support/RestoreSafety`

## Notes
- Spec 181 checklist is complete (`specs/181-restore-safety-integrity/checklists/requirements.md`)
- the branch still has unchecked follow-up tasks in `specs/181-restore-safety-integrity/tasks.md`: `T012`, `T018`, `T019`, `T023`, `T025`, `T029`, `T032`, `T033`, `T041`, `T042`, `T043`, `T044`
- Filament v5 / Livewire v4 compliance is preserved, no panel provider registration changes were made, no global-search behavior was added, destructive actions remain confirmation-gated, and no new Filament assets were introduced

Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de>
Reviewed-on: #210
2026-04-06 23:37:14 +00:00
117 changed files with 12703 additions and 384 deletions

View File

@ -135,6 +135,12 @@ ## Active Technologies
- PostgreSQL; existing `inventory_items` rows and `operation_runs.context` / `operation_runs.summary_counts` JSONB are reused with no schema change (177-inventory-coverage-truth) - PostgreSQL; existing `inventory_items` rows and `operation_runs.context` / `operation_runs.summary_counts` JSONB are reused with no schema change (177-inventory-coverage-truth)
- PHP 8.4.15 + Laravel 12, Filament v5, Livewire v4, Pest v4, existing `OperationRun`, `OperationLifecyclePolicy`, `OperationRunFreshnessState`, `OperationUxPresenter`, `OperationRunLinks`, `ActiveRuns`, `StuckRunClassifier`, `WorkspaceOverviewBuilder`, dashboard widgets, workspace widgets, and system ops pages (178-ops-truth-alignment) - PHP 8.4.15 + Laravel 12, Filament v5, Livewire v4, Pest v4, existing `OperationRun`, `OperationLifecyclePolicy`, `OperationRunFreshnessState`, `OperationUxPresenter`, `OperationRunLinks`, `ActiveRuns`, `StuckRunClassifier`, `WorkspaceOverviewBuilder`, dashboard widgets, workspace widgets, and system ops pages (178-ops-truth-alignment)
- PostgreSQL unchanged; existing `operation_runs` JSONB-backed `context`, `summary_counts`, and `failure_summary`; no schema change (178-ops-truth-alignment) - PostgreSQL unchanged; existing `operation_runs` JSONB-backed `context`, `summary_counts`, and `failure_summary`; no schema change (178-ops-truth-alignment)
- PHP 8.4, Laravel 12, Blade, Filament v5, Livewire v4 + Filament v5, Livewire v4, Pest v4, Laravel Sail, existing `RestoreRunResource`, `RestoreService`, `RestoreRiskChecker`, `RestoreDiffGenerator`, `OperationRunResource`, `TenantlessOperationRunViewer`, shared badge infrastructure, and existing RBAC or write-gate helpers (181-restore-safety-integrity)
- PostgreSQL with existing `restore_runs` and `operation_runs` records plus JSON or array-backed `metadata`, `preview`, `results`, and `context`; no schema change planned (181-restore-safety-integrity)
- PHP 8.4, Laravel 12, Blade, Filament v5, Livewire v4 + Filament v5, Livewire v4, Pest v4, Laravel Sail, existing `BackupSetResource`, `BackupItemsRelationManager`, `PolicyVersionResource`, `RestoreRunResource`, `CreateRestoreRun`, `AssignmentBackupService`, `VersionService`, `PolicySnapshotService`, `RestoreRiskChecker`, `BadgeRenderer`, `PolicySnapshotModeBadge`, `EnterpriseDetailBuilder`, and existing RBAC helpers (176-backup-quality-truth)
- PostgreSQL with existing tenant-owned `backup_sets`, `backup_items`, `policy_versions`, and restore wizard input state; JSON-backed `metadata`, `snapshot`, `assignments`, and `scope_tags`; no schema change planned (176-backup-quality-truth)
- PHP 8.4, Laravel 12, Blade, Filament v5, Livewire v4 + Filament v5, Livewire v4, Pest v4, Laravel Sail, existing `DashboardKpis`, `NeedsAttention`, `BackupSetResource`, `BackupScheduleResource`, `BackupQualityResolver`, `BackupQualitySummary`, `ScheduleTimeService`, shared badge infrastructure, and existing RBAC helpers (180-tenant-backup-health)
- PostgreSQL with existing tenant-owned `backup_sets`, `backup_items`, and `backup_schedules` records plus existing JSON-backed backup metadata; no schema change planned (180-tenant-backup-health)
- PHP 8.4.15 (feat/005-bulk-operations) - PHP 8.4.15 (feat/005-bulk-operations)
@ -154,8 +160,8 @@ ## Code Style
PHP 8.4.15: Follow standard conventions PHP 8.4.15: Follow standard conventions
## Recent Changes ## Recent Changes
- 178-ops-truth-alignment: Added PHP 8.4.15 + Laravel 12, Filament v5, Livewire v4, Pest v4, existing `OperationRun`, `OperationLifecyclePolicy`, `OperationRunFreshnessState`, `OperationUxPresenter`, `OperationRunLinks`, `ActiveRuns`, `StuckRunClassifier`, `WorkspaceOverviewBuilder`, dashboard widgets, workspace widgets, and system ops pages - 180-tenant-backup-health: Added PHP 8.4, Laravel 12, Blade, Filament v5, Livewire v4 + Filament v5, Livewire v4, Pest v4, Laravel Sail, existing `DashboardKpis`, `NeedsAttention`, `BackupSetResource`, `BackupScheduleResource`, `BackupQualityResolver`, `BackupQualitySummary`, `ScheduleTimeService`, shared badge infrastructure, and existing RBAC helpers
- 177-inventory-coverage-truth: Added PHP 8.4.15 + Laravel 12, Filament v5, Livewire v4, Pest v4, existing `InventoryItem`, `OperationRun`, `InventoryCoverage`, `InventoryPolicyTypeMeta`, `CoverageCapabilitiesResolver`, `InventoryKpiHeader`, `InventoryCoverage` page, and `OperationRunResource` enterprise-detail stack - 176-backup-quality-truth: Added PHP 8.4, Laravel 12, Blade, Filament v5, Livewire v4 + Filament v5, Livewire v4, Pest v4, Laravel Sail, existing `BackupSetResource`, `BackupItemsRelationManager`, `PolicyVersionResource`, `RestoreRunResource`, `CreateRestoreRun`, `AssignmentBackupService`, `VersionService`, `PolicySnapshotService`, `RestoreRiskChecker`, `BadgeRenderer`, `PolicySnapshotModeBadge`, `EnterpriseDetailBuilder`, and existing RBAC helpers
- 179-provider-truth-cleanup: Added PHP 8.4.15 + Laravel 12, Filament v5, Livewire v4, Pest v4, existing `TenantResource`, `ProviderConnectionResource`, `TenantVerificationReport`, `BadgeCatalog`, `BadgeRenderer`, `TenantOperabilityService`, `ProviderConsentStatus`, `ProviderVerificationStatus`, and shared provider-state Blade partials - 181-restore-safety-integrity: Added PHP 8.4, Laravel 12, Blade, Filament v5, Livewire v4 + Filament v5, Livewire v4, Pest v4, Laravel Sail, existing `RestoreRunResource`, `RestoreService`, `RestoreRiskChecker`, `RestoreDiffGenerator`, `OperationRunResource`, `TenantlessOperationRunViewer`, shared badge infrastructure, and existing RBAC or write-gate helpers
<!-- MANUAL ADDITIONS START --> <!-- MANUAL ADDITIONS START -->
<!-- MANUAL ADDITIONS END --> <!-- MANUAL ADDITIONS END -->

View File

@ -1,32 +1,20 @@
<!-- <!--
Sync Impact Report Sync Impact Report
- Version change: 1.14.0 -> 2.0.0 - Version change: 2.0.0 -> 2.1.0
- Modified principles: - Modified principles:
- Filament UI - Action Surface Contract -> Operator-Facing UI/UX Constitution v1 / Filament UI - Action Surface Contract - UX-001 (Layout & IA): header action line strengthened from SHOULD to MUST
- Filament UI - Layout & Information Architecture Standards (UX-001) -> Operator-Facing UI/UX Constitution v1 / Filament UI - Layout & Information Architecture Standards (UX-001) with cross-reference to new HDR-001
- Operator-facing UI Naming Standards (UI-NAMING-001) -> Operator-Facing UI/UX Constitution v1 / Operator-facing UI Naming Standards (UI-NAMING-001)
- Operator Surface Principles (OPSURF-001) -> Operator-Facing UI/UX Constitution v1 / Operator Surface Principles (OPSURF-001)
- Spec Scope Fields (SCOPE-002) -> Operator-Facing UI/UX Constitution v1 / Spec Scope Fields (SCOPE-002)
- Added sections: - Added sections:
- Operator-Facing UI/UX Constitution v1 (UI-CONST-001) - Header Action Discipline & Contextual Navigation (HDR-001)
- Surface Taxonomy (UI-SURF-001)
- Hard Rules (UI-HARD-001)
- Exception Model (UI-EX-001)
- Enforcement Model (UI-REVIEW-001)
- Immediate Retrofit Priorities
- Appendix A - One-page Condensed Constitution
- Appendix B - Feature Review Checklist
- Appendix C - Red Flags for Future PRs
- Removed sections: None - Removed sections: None
- Templates requiring updates: - Templates requiring updates:
- ✅ .specify/memory/constitution.md - ✅ .specify/memory/constitution.md
- ✅ .specify/templates/spec-template.md - ✅ .specify/templates/plan-template.md (Constitution Check: HDR-001 added)
- ✅ .specify/templates/plan-template.md - ✅ .specify/templates/tasks-template.md (Filament UI section: HDR-001 added)
- ✅ .specify/templates/tasks-template.md - ⚠ .specify/templates/spec-template.md (no changes needed; existing
- ✅ docs/product/principles.md UI/UX Surface Classification and Operator Surface Contract tables already
- ✅ docs/product/standards/README.md cover header action placement implicitly)
- ✅ docs/HANDOVER.md
- Commands checked: - Commands checked:
- N/A `.specify/templates/commands/*.md` directory is not present in this repo - N/A `.specify/templates/commands/*.md` directory is not present in this repo
- Follow-up TODOs: - Follow-up TODOs:
@ -535,7 +523,7 @@ #### Filament UI — Layout & Information Architecture Standards (UX-001)
- When records exist, that primary CTA moves to the header and MUST NOT be duplicated in the empty state shell. - When records exist, that primary CTA moves to the header and MUST NOT be duplicated in the empty state shell.
Actions and flows Actions and flows
- Pages SHOULD expose at most one primary header action and one secondary header action; all others belong in groups. - Pages MUST expose at most one primary header action and one secondary header action; all others belong in groups (see HDR-001 for the full header discipline rule).
- Multi-step or high-risk flows MUST use a wizard or an equivalent staged flow with preview and confirmation. - Multi-step or high-risk flows MUST use a wizard or an equivalent staged flow with preview and confirmation.
- Destructive actions remain non-primary and confirmed. - Destructive actions remain non-primary and confirmed.
@ -548,6 +536,121 @@ #### Filament UI — Layout & Information Architecture Standards (UX-001)
- Shared layout builders such as `MainAsideForm`, `MainAsideInfolist`, and `StandardTableDefaults` SHOULD be reused where available. - Shared layout builders such as `MainAsideForm`, `MainAsideInfolist`, and `StandardTableDefaults` SHOULD be reused where available.
- A change is not Done unless UX-001 is satisfied or an approved exception documents why not. - A change is not Done unless UX-001 is satisfied or an approved exception documents why not.
#### Header Action Discipline & Contextual Navigation (HDR-001)
Goal: record and detail pages MUST be comprehensible within seconds.
Header actions are reserved for the primary workflow of the current page
and MUST NOT become a dumping ground for every available action or
navigation jump.
##### Core rule
Header actions MUST contain only workflow-critical actions of the
currently displayed record. Pure navigation, relational jumps, and
contextual references do not belong in the header; they belong directly
at the affected field, status indicator, or relation.
##### Maximum one primary visible header action
- Each record/detail page MUST expose at most one clearly prioritized
primary visible header action.
- That action MUST represent the most obvious next operator step on
exactly this page.
##### Navigation does not belong in headers
- Actions such as "Open finding", "Open queue", "View related run",
"Open tenant", or similar jumps are navigation actions, not primary
object actions.
- They MUST be placed as contextual navigation at fields, badges,
relation entries, or status displays — never in the header.
##### Destructive or governance-changing actions require friction
- Actions with operational, security-relevant, or governance-changing
effect MUST NOT stand at the same visual level as the primary action.
- They MUST either:
- be rendered as a clearly separated danger action, or
- be placed in an Action Group / More Actions.
- They MUST always require explicit confirmation
(`->requiresConfirmation()`).
- If an action changes governance truth, compliance status, risk
acceptance, exception validity, or equivalent system truths,
additional friction is mandatory (e.g., typed confirmation, reason
field, or staged flow).
##### Rare secondary actions belong in an Action Group
- Actions that are not part of the expected core workflow of the page
or are only occasionally needed MUST NOT appear as equally weighted
visible header buttons.
- They MUST be placed in an Action Group.
##### Header clarity over implementation convenience
- The fact that a framework makes header actions easy to add is not a
reason to place actions there.
- Information architecture, scanability, and operator clarity take
precedence over implementation convenience.
##### 5-second scan rule
Every record/detail page MUST pass the 5-second scan rule:
1. The operator instantly recognizes where they are.
2. The operator instantly sees the status of the object.
3. The operator instantly identifies the one central next action.
4. The operator immediately understands where secondary or dangerous
actions live.
If multiple equally weighted header buttons degrade this readability,
it is a constitution violation.
##### Placement rules
Allowed in the header:
- One primary workflow action.
- Optionally one clearly justified secondary action.
- Rare or administrative actions only when grouped.
- Critical/destructive actions only when separated and with friction.
Forbidden in the header:
- Pure navigation to related objects.
- Relational jumps without immediate workflow relevance.
- Collections of technically available standard actions.
- Multiple equally weighted buttons without clear prioritization.
##### Preferred pattern
| Slot | Placement |
|---|---|
| Primary visible | Exactly 1 |
| Danger | Separated or grouped, never casual beside Primary |
| Navigation | Inline at context (field, badge, relation) |
| Rare actions | More / Action Group |
##### Binding decision — Exception / Approval surfaces
For exception detail pages specifically:
- **Renew exception** MAY appear as the primary visible header action.
- **Revoke exception** is a governance-changing danger action and MUST
require friction (separated + confirmation).
- **Open finding** MUST be placed as a link at the Finding field, not
in the header.
- **Open approval queue** MUST be placed as a contextual link at
approval / status context, not in the header.
##### Reviewer heuristics
A page violates HDR-001 if any of the following are true:
- Multiple equally weighted header actions without clear workflow
priority.
- Pure navigation buttons in the header.
- Danger actions beside normal actions without clear separation.
- Rarely used administrative actions as visible standard buttons.
- The header resembles an action stockpile instead of a focused
workflow entry point.
#### Operator-facing UI Naming Standards (UI-NAMING-001) #### Operator-facing UI Naming Standards (UI-NAMING-001)
Goal: operator-facing actions, runs, notifications, audit prose, and navigation MUST use one clear domain vocabulary. Goal: operator-facing actions, runs, notifications, audit prose, and navigation MUST use one clear domain vocabulary.
@ -672,6 +775,7 @@ #### Appendix A - One-page Condensed Constitution
- Standard lists stay scanable. - Standard lists stay scanable.
- Exceptions are catalogued, justified, and tested. - Exceptions are catalogued, justified, and tested.
- Features with ambiguous interaction semantics do not ship. - Features with ambiguous interaction semantics do not ship.
- Header actions on record/detail pages expose at most one primary action; navigation belongs at context, not in the header.
#### Appendix B - Feature Review Checklist #### Appendix B - Feature Review Checklist
@ -690,6 +794,9 @@ #### Appendix B - Feature Review Checklist
- Critical truth is visible. - Critical truth is visible.
- Scanability is preserved. - Scanability is preserved.
- Exceptions are documented and tested. - Exceptions are documented and tested.
- Header passes the 5-second scan rule (HDR-001).
- No pure navigation in the header.
- Governance-changing actions have extra friction.
#### Appendix C - Red Flags for Future PRs #### Appendix C - Red Flags for Future PRs
@ -704,6 +811,9 @@ #### Appendix C - Red Flags for Future PRs
- Queue surfaces throw the operator out of context through row click. - Queue surfaces throw the operator out of context through row click.
- Critical health or operability truth is hidden by default. - Critical health or operability truth is hidden by default.
- A contract claims conformance while the rendered UI behaves differently. - A contract claims conformance while the rendered UI behaves differently.
- Header has multiple equally weighted buttons without clear prioritization.
- "Open X" navigation links placed in the header instead of at the related field.
- Governance-changing actions sit casually beside the primary action without friction.
### Data Minimization & Safe Logging ### Data Minimization & Safe Logging
- Inventory MUST store only metadata + whitelisted `meta_jsonb`. - Inventory MUST store only metadata + whitelisted `meta_jsonb`.
@ -787,4 +897,4 @@ ### Versioning Policy (SemVer)
- **MINOR**: new principle/section or materially expanded guidance. - **MINOR**: new principle/section or materially expanded guidance.
- **MAJOR**: removing/redefining principles in a backward-incompatible way. - **MAJOR**: removing/redefining principles in a backward-incompatible way.
**Version**: 2.0.0 | **Ratified**: 2026-01-03 | **Last Amended**: 2026-03-28 **Version**: 2.1.0 | **Ratified**: 2026-01-03 | **Last Amended**: 2026-04-07

View File

@ -70,7 +70,8 @@ ## Constitution Check
- Operator surfaces (OPSURF-001): workspace and tenant context remain explicit in navigation, actions, and page semantics; tenant surfaces do not silently expose workspace-wide actions - Operator surfaces (OPSURF-001): workspace and tenant context remain explicit in navigation, actions, and page semantics; tenant surfaces do not silently expose workspace-wide actions
- Operator surfaces (OPSURF-001): each new or materially refactored operator-facing page defines a page contract covering persona, surface type, operator question, default-visible info, diagnostics-only info, status dimensions, mutation scope, primary actions, and dangerous actions - Operator surfaces (OPSURF-001): each new or materially refactored operator-facing page defines a page contract covering persona, surface type, operator question, default-visible info, diagnostics-only info, status dimensions, mutation scope, primary actions, and dangerous actions
- Filament UI Action Surface Contract: for any new/modified Filament Resource/RelationManager/Page, define Header/Row/Bulk/Empty-State actions, ensure every List/Table has a surface-appropriate inspect affordance, remove redundant View when row click or identifier click already opens the same destination, keep standard CRUD/Registry rows to inspect plus at most one inline safe shortcut, group or relocate the rest to “More” or detail header, forbid empty bulk/overflow groups, require confirmations for destructive actions, write audit logs for mutations, enforce RBAC via central helpers (non-member 404, member missing capability 403), and ensure CI blocks merges if the contract is violated or not explicitly exempted - Filament UI Action Surface Contract: for any new/modified Filament Resource/RelationManager/Page, define Header/Row/Bulk/Empty-State actions, ensure every List/Table has a surface-appropriate inspect affordance, remove redundant View when row click or identifier click already opens the same destination, keep standard CRUD/Registry rows to inspect plus at most one inline safe shortcut, group or relocate the rest to “More” or detail header, forbid empty bulk/overflow groups, require confirmations for destructive actions, write audit logs for mutations, enforce RBAC via central helpers (non-member 404, member missing capability 403), and ensure CI blocks merges if the contract is violated or not explicitly exempted
- Filament UI UX-001 (Layout & IA): Create/Edit uses Main/Aside (3-col grid, Main=columnSpan(2), Aside=columnSpan(1)); all fields inside Sections/Cards (no naked inputs); View uses Infolists (not disabled edit forms); status badges use BADGE-001; empty states have specific title + explanation + 1 CTA; max 1 primary + 1 secondary header action; tables provide search/sort/filters for core dimensions; shared layout builders preferred for consistency - Filament UI UX-001 (Layout & IA): Create/Edit uses Main/Aside (3-col grid, Main=columnSpan(2), Aside=columnSpan(1)); all fields inside Sections/Cards (no naked inputs); View uses Infolists (not disabled edit forms); status badges use BADGE-001; empty states have specific title + explanation + 1 CTA; max 1 primary + 1 secondary header action (see HDR-001); tables provide search/sort/filters for core dimensions; shared layout builders preferred for consistency
- Header action discipline (HDR-001): record/detail pages expose at most 1 primary visible header action; pure navigation (Open finding, Open tenant, View related run, etc.) is placed at the relevant field/badge/relation, NOT in the header; destructive or governance-changing actions are separated and require friction; rare actions live in Action Groups; every record/detail page passes the 5-second scan rule
## Project Structure ## Project Structure
### Documentation (this feature) ### Documentation (this feature)

View File

@ -72,6 +72,7 @@ # Tasks: [FEATURE NAME]
- ensuring View pages use Infolists (not disabled edit forms); status badges use BADGE-001, - ensuring View pages use Infolists (not disabled edit forms); status badges use BADGE-001,
- ensuring empty states show a specific title + explanation + exactly 1 CTA; non-empty tables move CTA to header, - ensuring empty states show a specific title + explanation + exactly 1 CTA; non-empty tables move CTA to header,
- capping header actions to max 1 primary + 1 secondary (rest grouped), - capping header actions to max 1 primary + 1 secondary (rest grouped),
- enforcing HDR-001 header action discipline: at most 1 primary visible action per record/detail page; pure navigation (Open finding, Open tenant, View related run, etc.) placed at the relevant field/badge/relation, NOT in the header; destructive or governance-changing actions separated and requiring friction; rare actions in Action Groups; every record/detail page passing the 5-second scan rule,
- using shared layout builders (e.g., `MainAsideForm`, `MainAsideInfolist`, `StandardTableDefaults`) where available, - using shared layout builders (e.g., `MainAsideForm`, `MainAsideInfolist`, `StandardTableDefaults`) where available,
- OR documenting an explicit exemption with rationale if UX-001 is not fully satisfied. - OR documenting an explicit exemption with rationale if UX-001 is not fully satisfied.
**Badges**: If this feature changes status-like badge semantics, tasks MUST use `BadgeCatalog` / `BadgeRenderer` (BADGE-001), **Badges**: If this feature changes status-like badge semantics, tasks MUST use `BadgeCatalog` / `BadgeRenderer` (BADGE-001),

View File

@ -0,0 +1,192 @@
<?php
declare(strict_types=1);
namespace App\Console\Commands;
use App\Models\BackupItem;
use App\Models\BackupSet;
use App\Models\Policy;
use App\Models\Tenant;
use App\Models\TenantMembership;
use App\Models\User;
use App\Models\UserTenantPreference;
use App\Models\Workspace;
use App\Models\WorkspaceMembership;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Schema;
class SeedBackupHealthBrowserFixture extends Command
{
protected $signature = 'tenantpilot:backup-health:seed-browser-fixture {--force-refresh : Rebuild the fixture backup basis even if it already exists}';
protected $description = 'Seed a local/testing browser fixture for the Spec 180 blocked backup drill-through scenario.';
public function handle(): int
{
if (! app()->environment(['local', 'testing'])) {
$this->error('This fixture command is limited to local and testing environments.');
return self::FAILURE;
}
$fixture = config('tenantpilot.backup_health.browser_smoke_fixture');
if (! is_array($fixture)) {
$this->error('The backup-health browser smoke fixture is not configured.');
return self::FAILURE;
}
$workspaceConfig = is_array($fixture['workspace'] ?? null) ? $fixture['workspace'] : [];
$userConfig = is_array($fixture['user'] ?? null) ? $fixture['user'] : [];
$scenarioConfig = is_array($fixture['blocked_drillthrough'] ?? null) ? $fixture['blocked_drillthrough'] : [];
$tenantRouteKey = (string) ($scenarioConfig['tenant_id'] ?? $scenarioConfig['tenant_external_id'] ?? '18000000-0000-4000-8000-000000000180');
$workspace = Workspace::query()->updateOrCreate(
['slug' => (string) ($workspaceConfig['slug'] ?? 'spec-180-backup-health-smoke')],
['name' => (string) ($workspaceConfig['name'] ?? 'Spec 180 Backup Health Smoke')],
);
$password = (string) ($userConfig['password'] ?? 'password');
$user = User::query()->updateOrCreate(
['email' => (string) ($userConfig['email'] ?? 'smoke-requester+180@tenantpilot.local')],
[
'name' => (string) ($userConfig['name'] ?? 'Spec 180 Requester'),
'password' => Hash::make($password),
'email_verified_at' => now(),
],
);
$tenant = Tenant::query()->updateOrCreate(
['external_id' => $tenantRouteKey],
[
'workspace_id' => (int) $workspace->getKey(),
'name' => (string) ($scenarioConfig['tenant_name'] ?? 'Spec 180 Blocked Backup Tenant'),
'tenant_id' => $tenantRouteKey,
'app_client_id' => (string) ($scenarioConfig['app_client_id'] ?? '18000000-0000-4000-8000-000000000182'),
'app_client_secret' => null,
'app_certificate_thumbprint' => null,
'app_status' => 'ok',
'app_notes' => null,
'status' => Tenant::STATUS_ACTIVE,
'environment' => 'dev',
'is_current' => false,
'metadata' => ['fixture' => 'spec-180-browser-smoke'],
'rbac_status' => 'ok',
'rbac_last_checked_at' => now(),
],
);
WorkspaceMembership::query()->updateOrCreate(
['workspace_id' => (int) $workspace->getKey(), 'user_id' => (int) $user->getKey()],
['role' => 'owner'],
);
TenantMembership::query()->updateOrCreate(
['tenant_id' => (int) $tenant->getKey(), 'user_id' => (int) $user->getKey()],
['role' => 'owner', 'source' => 'manual', 'source_ref' => 'spec-180-browser-smoke'],
);
if (Schema::hasColumn('users', 'last_workspace_id')) {
$user->forceFill(['last_workspace_id' => (int) $workspace->getKey()])->save();
}
if (Schema::hasTable('user_tenant_preferences')) {
UserTenantPreference::query()->updateOrCreate(
['user_id' => (int) $user->getKey(), 'tenant_id' => (int) $tenant->getKey()],
['last_used_at' => now()],
);
}
$policy = Policy::query()->updateOrCreate(
[
'tenant_id' => (int) $tenant->getKey(),
'external_id' => (string) ($scenarioConfig['policy_external_id'] ?? 'spec-180-rbac-stale-policy'),
'policy_type' => (string) ($scenarioConfig['policy_type'] ?? 'settingsCatalogPolicy'),
],
[
'display_name' => (string) ($scenarioConfig['policy_name'] ?? 'Spec 180 RBAC Smoke Policy'),
'platform' => 'windows',
'last_synced_at' => now(),
'metadata' => ['fixture' => 'spec-180-browser-smoke'],
],
);
$backupSet = BackupSet::withTrashed()->firstOrNew([
'tenant_id' => (int) $tenant->getKey(),
'name' => (string) ($scenarioConfig['backup_set_name'] ?? 'Spec 180 Blocked Stale Backup'),
]);
$backupSet->forceFill([
'created_by' => (string) $user->email,
'status' => 'completed',
'item_count' => 1,
'completed_at' => now()->subHours(max(25, (int) ($scenarioConfig['stale_age_hours'] ?? 48))),
'metadata' => ['fixture' => 'spec-180-browser-smoke'],
'deleted_at' => null,
])->save();
if (method_exists($backupSet, 'trashed') && $backupSet->trashed()) {
$backupSet->restore();
}
$backupItem = BackupItem::withTrashed()->firstOrNew([
'backup_set_id' => (int) $backupSet->getKey(),
'policy_identifier' => (string) ($scenarioConfig['policy_external_id'] ?? 'spec-180-rbac-stale-policy'),
'policy_type' => (string) ($scenarioConfig['policy_type'] ?? 'settingsCatalogPolicy'),
]);
$backupItem->forceFill([
'tenant_id' => (int) $tenant->getKey(),
'policy_id' => (int) $policy->getKey(),
'platform' => 'windows',
'captured_at' => $backupSet->completed_at,
'payload' => [
'id' => (string) ($scenarioConfig['policy_external_id'] ?? 'spec-180-rbac-stale-policy'),
'name' => (string) ($scenarioConfig['policy_name'] ?? 'Spec 180 RBAC Smoke Policy'),
],
'metadata' => [
'policy_name' => (string) ($scenarioConfig['policy_name'] ?? 'Spec 180 RBAC Smoke Policy'),
'fixture' => 'spec-180-browser-smoke',
],
'assignments' => [],
'deleted_at' => null,
])->save();
if (method_exists($backupItem, 'trashed') && $backupItem->trashed()) {
$backupItem->restore();
}
if ((bool) $this->option('force-refresh')) {
$backupSet->forceFill([
'completed_at' => now()->subHours(max(25, (int) ($scenarioConfig['stale_age_hours'] ?? 48))),
])->save();
$backupItem->forceFill([
'captured_at' => $backupSet->completed_at,
])->save();
}
$this->table(
['Fixture', 'Value'],
[
['Workspace', (string) $workspace->name],
['User email', (string) $user->email],
['User password', $password],
['Tenant', (string) $tenant->name],
['Tenant external id', (string) $tenant->external_id],
['Dashboard URL', "/admin/t/{$tenant->external_id}"],
['Fixture login URL', route('admin.local.backup-health-browser-fixture-login', absolute: false)],
['Blocked route', "/admin/t/{$tenant->external_id}/backup-sets"],
['Locally denied capability', 'tenant.view'],
],
);
$this->info('The dashboard remains visible for this fixture user, while backup drill-through routes stay forbidden via a local/testing-only capability deny seam.');
return self::SUCCESS;
}
}

View File

@ -38,6 +38,7 @@
use Filament\Tables\Contracts\HasTable; use Filament\Tables\Contracts\HasTable;
use Filament\Tables\Filters\SelectFilter; use Filament\Tables\Filters\SelectFilter;
use Filament\Tables\Table; use Filament\Tables\Table;
use Illuminate\Contracts\View\View;
use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Collection; use Illuminate\Support\Collection;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException; use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
@ -49,6 +50,8 @@ class FindingExceptionsQueue extends Page implements HasTable
public ?int $selectedFindingExceptionId = null; public ?int $selectedFindingExceptionId = null;
public bool $showSelectedExceptionSummary = false;
protected static bool $isDiscovered = false; protected static bool $isDiscovered = false;
protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-shield-exclamation'; protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-shield-exclamation';
@ -116,11 +119,12 @@ public static function canAccess(): bool
public function mount(): void public function mount(): void
{ {
$this->selectedFindingExceptionId = is_numeric(request()->query('exception')) ? (int) request()->query('exception') : null; $this->selectedFindingExceptionId = is_numeric(request()->query('exception')) ? (int) request()->query('exception') : null;
$this->showSelectedExceptionSummary = $this->selectedFindingExceptionId !== null;
$this->mountInteractsWithTable(); $this->mountInteractsWithTable();
$this->applyRequestedTenantPrefilter(); $this->applyRequestedTenantPrefilter();
if ($this->selectedFindingExceptionId !== null) { if ($this->selectedFindingExceptionId !== null) {
$this->selectedFindingException(); $this->resolveSelectedFindingException($this->selectedFindingExceptionId);
} }
} }
@ -141,6 +145,7 @@ protected function getHeaderActions(): array
$this->removeTableFilter('status'); $this->removeTableFilter('status');
$this->removeTableFilter('current_validity_state'); $this->removeTableFilter('current_validity_state');
$this->selectedFindingExceptionId = null; $this->selectedFindingExceptionId = null;
$this->showSelectedExceptionSummary = false;
$this->resetTable(); $this->resetTable();
}); });
@ -165,6 +170,7 @@ protected function getHeaderActions(): array
->visible(fn (): bool => $this->selectedFindingExceptionId !== null) ->visible(fn (): bool => $this->selectedFindingExceptionId !== null)
->action(function (): void { ->action(function (): void {
$this->selectedFindingExceptionId = null; $this->selectedFindingExceptionId = null;
$this->showSelectedExceptionSummary = false;
}); });
$actions[] = Action::make('open_selected_exception') $actions[] = Action::make('open_selected_exception')
@ -325,8 +331,31 @@ public function table(Table $table): Table
->label('Inspect exception') ->label('Inspect exception')
->icon('heroicon-o-eye') ->icon('heroicon-o-eye')
->color('gray') ->color('gray')
->action(function (FindingException $record): void { ->before(function (FindingException $record): void {
$this->selectedFindingExceptionId = (int) $record->getKey(); $this->selectedFindingExceptionId = (int) $record->getKey();
})
->slideOver()
->stickyModalHeader()
->modalSubmitAction(false)
->modalCancelAction(fn (Action $action): Action => $action->label('Close details'))
->modalHeading(function (): string {
$record = $this->inspectedFindingException();
return $record instanceof FindingException
? 'Finding exception #'.$record->getKey()
: 'Finding exception';
})
->modalDescription(fn (): ?string => $this->inspectedFindingException()?->requested_at?->toDayDateTimeString())
->modalContent(function (): View {
$record = $this->inspectedFindingException();
if (! $record instanceof FindingException) {
return view('filament.pages.monitoring.partials.finding-exception-queue-unavailable');
}
return view('filament.pages.monitoring.partials.finding-exception-queue-sidebar', [
'selectedException' => $record,
]);
}), }),
]) ])
->bulkActions([]) ->bulkActions([])
@ -343,6 +372,7 @@ public function table(Table $table): Table
$this->removeTableFilter('status'); $this->removeTableFilter('status');
$this->removeTableFilter('current_validity_state'); $this->removeTableFilter('current_validity_state');
$this->selectedFindingExceptionId = null; $this->selectedFindingExceptionId = null;
$this->showSelectedExceptionSummary = false;
$this->resetTable(); $this->resetTable();
}), }),
]); ]);
@ -354,15 +384,7 @@ public function selectedFindingException(): ?FindingException
return null; return null;
} }
$record = $this->queueBaseQuery() return $this->resolveSelectedFindingException($this->selectedFindingExceptionId);
->whereKey($this->selectedFindingExceptionId)
->first();
if (! $record instanceof FindingException) {
throw new NotFoundHttpException;
}
return $record;
} }
public function selectedExceptionUrl(): ?string public function selectedExceptionUrl(): ?string
@ -508,6 +530,30 @@ private function hasActiveQueueFilters(): bool
|| is_string(data_get($this->tableFilters, 'current_validity_state.value')); || is_string(data_get($this->tableFilters, 'current_validity_state.value'));
} }
private function resolveSelectedFindingException(int $findingExceptionId): FindingException
{
$record = $this->queueBaseQuery()
->whereKey($findingExceptionId)
->first();
if (! $record instanceof FindingException) {
throw new NotFoundHttpException;
}
return $record;
}
private function inspectedFindingException(): ?FindingException
{
$mountedRecord = $this->getMountedTableActionRecord();
if ($mountedRecord instanceof FindingException) {
return $mountedRecord;
}
return $this->selectedFindingException();
}
private function governanceWarning(FindingException $record): ?string private function governanceWarning(FindingException $record): ?string
{ {
$finding = $record->relationLoaded('finding') $finding = $record->relationLoaded('finding')

View File

@ -22,6 +22,7 @@
use App\Support\OpsUx\RunDetailPolling; use App\Support\OpsUx\RunDetailPolling;
use App\Support\ReasonTranslation\ReasonPresenter; use App\Support\ReasonTranslation\ReasonPresenter;
use App\Support\RedactionIntegrity; use App\Support\RedactionIntegrity;
use App\Support\RestoreSafety\RestoreSafetyCopy;
use App\Support\Tenants\ReferencedTenantLifecyclePresentation; use App\Support\Tenants\ReferencedTenantLifecyclePresentation;
use App\Support\Tenants\TenantInteractionLane; use App\Support\Tenants\TenantInteractionLane;
use App\Support\Tenants\TenantOperabilityQuestion; use App\Support\Tenants\TenantOperabilityQuestion;
@ -244,6 +245,42 @@ public function lifecycleBanner(): ?array
}; };
} }
/**
* @return array{tone: string, title: string, body: string, url: ?string, link_label: ?string}|null
*/
public function restoreContinuationBanner(): ?array
{
if (! isset($this->run)) {
return null;
}
$continuation = OperationRunResource::restoreContinuation($this->run);
if (! is_array($continuation)) {
return null;
}
$tone = ($continuation['follow_up_required'] ?? false) ? 'amber' : 'sky';
$body = $continuation['summary'] ?? 'Restore continuation detail is unavailable.';
$boundary = $continuation['recovery_claim_boundary'] ?? null;
if (is_string($boundary) && $boundary !== '') {
$body .= ' '.RestoreSafetyCopy::recoveryBoundary($boundary);
}
if (! ($continuation['link_available'] ?? false)) {
$body .= ' Restore detail is not available from this session.';
}
return [
'tone' => $tone,
'title' => 'Restore continuation',
'body' => $body,
'url' => is_string($continuation['link_url'] ?? null) ? $continuation['link_url'] : null,
'link_label' => ($continuation['link_available'] ?? false) ? 'Open restore run' : null,
];
}
/** /**
* @return array{tone: string, title: string, body: string}|null * @return array{tone: string, title: string, body: string}|null
*/ */

View File

@ -395,6 +395,7 @@ public static function table(Table $table): Table
return $nextRun->format('M j, Y H:i:s'); return $nextRun->format('M j, Y H:i:s');
} }
}) })
->description(fn (BackupSchedule $record): ?string => static::scheduleFollowUpDescription($record))
->sortable(), ->sortable(),
]) ])
->filters([ ->filters([
@ -1149,4 +1150,31 @@ protected static function dayOfWeekOptions(): array
7 => 'Sunday', 7 => 'Sunday',
]; ];
} }
protected static function scheduleFollowUpDescription(BackupSchedule $record): ?string
{
if (! $record->is_enabled || $record->trashed()) {
return null;
}
$graceCutoff = now('UTC')->subMinutes(max(1, (int) config('tenantpilot.backup_health.schedule_overdue_grace_minutes', 30)));
$lastRunStatus = strtolower(trim((string) $record->last_run_status));
$isOverdue = $record->next_run_at?->lessThan($graceCutoff) ?? false;
$neverSuccessful = $record->last_run_at === null
&& ($isOverdue || ($record->created_at?->lessThan($graceCutoff) ?? false));
if ($neverSuccessful) {
return 'No successful run has been recorded yet.';
}
if ($isOverdue) {
return 'This schedule looks overdue.';
}
if (in_array($lastRunStatus, ['failed', 'partial', 'skipped', 'canceled'], true)) {
return 'The last run needs follow-up.';
}
return null;
}
} }

View File

@ -3,7 +3,11 @@
namespace App\Filament\Resources\BackupScheduleResource\Pages; namespace App\Filament\Resources\BackupScheduleResource\Pages;
use App\Filament\Resources\BackupScheduleResource; use App\Filament\Resources\BackupScheduleResource;
use App\Models\Tenant;
use App\Support\BackupHealth\TenantBackupHealthAssessment;
use App\Support\BackupHealth\TenantBackupHealthResolver;
use App\Support\Filament\CanonicalAdminTenantFilterState; use App\Support\Filament\CanonicalAdminTenantFilterState;
use Filament\Facades\Filament;
use Filament\Resources\Pages\ListRecords; use Filament\Resources\Pages\ListRecords;
use Illuminate\Database\Eloquent\ModelNotFoundException; use Illuminate\Database\Eloquent\ModelNotFoundException;
@ -64,4 +68,23 @@ private function syncCanonicalAdminTenantFilterState(): void
tenantFilterName: null, tenantFilterName: null,
); );
} }
public function getSubheading(): ?string
{
if (request()->string('backup_health_reason')->toString() !== TenantBackupHealthAssessment::REASON_SCHEDULE_FOLLOW_UP) {
return null;
}
$tenant = Filament::getTenant();
if (! $tenant instanceof Tenant) {
return 'One or more enabled schedules need follow-up.';
}
/** @var TenantBackupHealthResolver $resolver */
$resolver = app(TenantBackupHealthResolver::class);
$summary = $resolver->assess($tenant)->scheduleFollowUp->summaryMessage;
return $summary ?? 'One or more enabled schedules need follow-up.';
}
} }

View File

@ -18,6 +18,9 @@
use App\Services\OperationRunService; use App\Services\OperationRunService;
use App\Services\Operations\BulkSelectionIdentity; use App\Services\Operations\BulkSelectionIdentity;
use App\Support\Auth\Capabilities; use App\Support\Auth\Capabilities;
use App\Support\BackupHealth\TenantBackupHealthAssessment;
use App\Support\BackupHealth\TenantBackupHealthResolver;
use App\Support\BackupQuality\BackupQualityResolver;
use App\Support\Badges\BadgeDomain; use App\Support\Badges\BadgeDomain;
use App\Support\Badges\BadgeRenderer; use App\Support\Badges\BadgeRenderer;
use App\Support\Filament\TablePaginationProfiles; use App\Support\Filament\TablePaginationProfiles;
@ -161,6 +164,15 @@ public static function table(Table $table): Table
->persistFiltersInSession() ->persistFiltersInSession()
->persistSearchInSession() ->persistSearchInSession()
->persistSortInSession() ->persistSortInSession()
->modifyQueryUsing(fn (Builder $query): Builder => $query->with([
'items' => fn ($itemQuery) => $itemQuery->select([
'id',
'backup_set_id',
'payload',
'metadata',
'assignments',
]),
]))
->columns([ ->columns([
Tables\Columns\TextColumn::make('name') Tables\Columns\TextColumn::make('name')
->searchable() ->searchable()
@ -172,6 +184,11 @@ public static function table(Table $table): Table
->icon(BadgeRenderer::icon(BadgeDomain::BackupSetStatus)) ->icon(BadgeRenderer::icon(BadgeDomain::BackupSetStatus))
->iconColor(BadgeRenderer::iconColor(BadgeDomain::BackupSetStatus)), ->iconColor(BadgeRenderer::iconColor(BadgeDomain::BackupSetStatus)),
Tables\Columns\TextColumn::make('item_count')->label('Items')->numeric()->sortable(), Tables\Columns\TextColumn::make('item_count')->label('Items')->numeric()->sortable(),
Tables\Columns\TextColumn::make('backup_quality')
->label('Backup quality')
->state(fn (BackupSet $record): string => static::backupQualitySummary($record)->compactSummary)
->description(fn (BackupSet $record): string => static::backupQualitySummary($record)->nextAction)
->wrap(),
Tables\Columns\TextColumn::make('created_by')->label('Created by')->toggleable(isToggledHiddenByDefault: true), Tables\Columns\TextColumn::make('created_by')->label('Created by')->toggleable(isToggledHiddenByDefault: true),
Tables\Columns\TextColumn::make('completed_at')->label('Completed')->dateTime()->since()->sortable(), Tables\Columns\TextColumn::make('completed_at')->label('Completed')->dateTime()->since()->sortable(),
Tables\Columns\TextColumn::make('created_at')->label('Captured')->dateTime()->since()->sortable()->toggleable(isToggledHiddenByDefault: true), Tables\Columns\TextColumn::make('created_at')->label('Captured')->dateTime()->since()->sortable()->toggleable(isToggledHiddenByDefault: true),
@ -659,6 +676,23 @@ private static function enterpriseDetailPage(BackupSet $record): EnterpriseDetai
$metadataKeyCount = count($metadata); $metadataKeyCount = count($metadata);
$relatedContext = static::relatedContextEntries($record); $relatedContext = static::relatedContextEntries($record);
$isArchived = $record->trashed(); $isArchived = $record->trashed();
$qualitySummary = static::backupQualitySummary($record);
$backupHealthAssessment = static::backupHealthContinuityAssessment($record);
$qualityBadge = match (true) {
$qualitySummary->totalItems === 0 => $factory->statusBadge('No items', 'gray'),
$qualitySummary->hasDegradations() => $factory->statusBadge('Degraded input', 'warning', 'heroicon-m-exclamation-triangle'),
default => $factory->statusBadge('No degradations', 'success', 'heroicon-m-check-circle'),
};
$backupHealthBadge = $backupHealthAssessment instanceof TenantBackupHealthAssessment
? $factory->statusBadge(
static::backupHealthContinuityLabel($backupHealthAssessment),
$backupHealthAssessment->tone(),
'heroicon-m-exclamation-triangle',
)
: null;
$descriptionHint = $backupHealthAssessment instanceof TenantBackupHealthAssessment
? trim($backupHealthAssessment->headline.' '.($backupHealthAssessment->supportingMessage ?? ''))
: 'Backup quality, lifecycle status, and related operations stay ahead of raw backup metadata.';
return EnterpriseDetailBuilder::make('backup_set', 'tenant') return EnterpriseDetailBuilder::make('backup_set', 'tenant')
->header(new SummaryHeaderData( ->header(new SummaryHeaderData(
@ -667,14 +701,46 @@ private static function enterpriseDetailPage(BackupSet $record): EnterpriseDetai
statusBadges: [ statusBadges: [
$factory->statusBadge($statusSpec->label, $statusSpec->color, $statusSpec->icon, $statusSpec->iconColor), $factory->statusBadge($statusSpec->label, $statusSpec->color, $statusSpec->icon, $statusSpec->iconColor),
$factory->statusBadge($isArchived ? 'Archived' : 'Active', $isArchived ? 'warning' : 'success'), $factory->statusBadge($isArchived ? 'Archived' : 'Active', $isArchived ? 'warning' : 'success'),
...array_filter([$backupHealthBadge]),
$qualityBadge,
], ],
keyFacts: [ keyFacts: [
$factory->keyFact('Items', $record->item_count), $factory->keyFact('Items', $record->item_count),
...array_filter([
$backupHealthAssessment instanceof TenantBackupHealthAssessment
? $factory->keyFact('Backup posture', static::backupHealthContinuityLabel($backupHealthAssessment), badge: $backupHealthBadge)
: null,
]),
$factory->keyFact('Backup quality', $qualitySummary->compactSummary),
$factory->keyFact('Created by', $record->created_by), $factory->keyFact('Created by', $record->created_by),
$factory->keyFact('Completed', static::formatDetailTimestamp($record->completed_at)), $factory->keyFact('Completed', static::formatDetailTimestamp($record->completed_at)),
$factory->keyFact('Captured', static::formatDetailTimestamp($record->created_at)), $factory->keyFact('Captured', static::formatDetailTimestamp($record->created_at)),
], ],
descriptionHint: 'Lifecycle status, recovery readiness, and related operations stay ahead of raw backup metadata.', descriptionHint: $descriptionHint,
))
->decisionZone($factory->decisionZone(
facts: array_values(array_filter([
$backupHealthAssessment instanceof TenantBackupHealthAssessment
? $factory->keyFact('Backup posture', static::backupHealthContinuityLabel($backupHealthAssessment), badge: $backupHealthBadge)
: null,
$factory->keyFact('Backup quality', $qualitySummary->compactSummary, badge: $qualityBadge),
$factory->keyFact('Degraded items', $qualitySummary->degradedItemCount),
$factory->keyFact('Metadata only', $qualitySummary->metadataOnlyCount),
$factory->keyFact('Assignment issues', $qualitySummary->assignmentIssueCount),
$factory->keyFact('Orphaned assignments', $qualitySummary->orphanedAssignmentCount),
$factory->keyFact('Integrity warnings', $qualitySummary->integrityWarningCount),
$qualitySummary->unknownQualityCount > 0
? $factory->keyFact('Unknown quality', $qualitySummary->unknownQualityCount)
: null,
])),
primaryNextStep: $factory->primaryNextStep(
$qualitySummary->nextAction,
'Backup quality',
),
description: 'Start here to judge whether this backup set looks strong or weak as restore input before reading diagnostics or raw metadata.',
compactCounts: $factory->countPresentation(summaryLine: $qualitySummary->summaryMessage),
attentionNote: $backupHealthAssessment?->positiveClaimBoundary ?? $qualitySummary->positiveClaimBoundary,
title: 'Backup quality',
)) ))
->addSection( ->addSection(
$factory->factsSection( $factory->factsSection(
@ -700,11 +766,12 @@ private static function enterpriseDetailPage(BackupSet $record): EnterpriseDetai
->addSupportingCard( ->addSupportingCard(
$factory->supportingFactsCard( $factory->supportingFactsCard(
kind: 'status', kind: 'status',
title: 'Recovery readiness', title: 'Backup quality counts',
items: [ items: [
$factory->keyFact('Backup state', $statusSpec->label, badge: $factory->statusBadge($statusSpec->label, $statusSpec->color, $statusSpec->icon, $statusSpec->iconColor)), $factory->keyFact('Degraded items', $qualitySummary->degradedItemCount),
$factory->keyFact('Archived', $isArchived), $factory->keyFact('Metadata only', $qualitySummary->metadataOnlyCount),
$factory->keyFact('Metadata keys', $metadataKeyCount), $factory->keyFact('Assignment issues', $qualitySummary->assignmentIssueCount),
$factory->keyFact('Orphaned assignments', $qualitySummary->orphanedAssignmentCount),
], ],
), ),
$factory->supportingFactsCard( $factory->supportingFactsCard(
@ -740,4 +807,64 @@ private static function formatDetailTimestamp(mixed $value): string
return $value->toDayDateTimeString(); return $value->toDayDateTimeString();
} }
private static function backupQualitySummary(BackupSet $record): \App\Support\BackupQuality\BackupQualitySummary
{
if ($record->trashed()) {
$record->setRelation('items', $record->items()->withTrashed()->select([
'id',
'backup_set_id',
'payload',
'metadata',
'assignments',
])->get());
} elseif (! $record->relationLoaded('items')) {
$record->loadMissing([
'items' => fn ($query) => $query->select([
'id',
'backup_set_id',
'payload',
'metadata',
'assignments',
]),
]);
}
return app(BackupQualityResolver::class)->summarizeBackupSet($record);
}
private static function backupHealthContinuityAssessment(BackupSet $record): ?TenantBackupHealthAssessment
{
$requestedReason = request()->string('backup_health_reason')->toString();
if (! in_array($requestedReason, [
TenantBackupHealthAssessment::REASON_LATEST_BACKUP_STALE,
TenantBackupHealthAssessment::REASON_LATEST_BACKUP_DEGRADED,
], true)) {
return null;
}
/** @var TenantBackupHealthResolver $resolver */
$resolver = app(TenantBackupHealthResolver::class);
$assessment = $resolver->assess((int) $record->tenant_id);
if ($assessment->latestRelevantBackupSetId !== (int) $record->getKey()) {
return null;
}
if ($assessment->primaryReason !== $requestedReason) {
return null;
}
return $assessment;
}
private static function backupHealthContinuityLabel(TenantBackupHealthAssessment $assessment): string
{
return match ($assessment->primaryReason) {
TenantBackupHealthAssessment::REASON_LATEST_BACKUP_STALE => 'Latest backup is stale',
TenantBackupHealthAssessment::REASON_LATEST_BACKUP_DEGRADED => 'Latest backup is degraded',
default => ucfirst($assessment->posture),
};
}
} }

View File

@ -3,6 +3,7 @@
namespace App\Filament\Resources\BackupSetResource\Pages; namespace App\Filament\Resources\BackupSetResource\Pages;
use App\Filament\Resources\BackupSetResource; use App\Filament\Resources\BackupSetResource;
use App\Support\BackupHealth\TenantBackupHealthAssessment;
use App\Support\Filament\CanonicalAdminTenantFilterState; use App\Support\Filament\CanonicalAdminTenantFilterState;
use Filament\Resources\Pages\ListRecords; use Filament\Resources\Pages\ListRecords;
@ -40,4 +41,14 @@ protected function getTableEmptyStateActions(): array
BackupSetResource::makeCreateAction(), BackupSetResource::makeCreateAction(),
]; ];
} }
public function getSubheading(): ?string
{
return match (request()->string('backup_health_reason')->toString()) {
TenantBackupHealthAssessment::REASON_NO_BACKUP_BASIS => 'No usable completed backup basis is currently available for this tenant.',
TenantBackupHealthAssessment::REASON_LATEST_BACKUP_STALE => 'The latest backup detail is no longer available, so this view stays on the backup-set list.',
TenantBackupHealthAssessment::REASON_LATEST_BACKUP_DEGRADED => 'The latest backup detail is no longer available, so this view stays on the backup-set list.',
default => null,
};
}
} }

View File

@ -11,6 +11,7 @@
use App\Models\User; use App\Models\User;
use App\Services\OperationRunService; use App\Services\OperationRunService;
use App\Support\Auth\Capabilities; use App\Support\Auth\Capabilities;
use App\Support\BackupQuality\BackupQualityResolver;
use App\Support\Badges\BadgeDomain; use App\Support\Badges\BadgeDomain;
use App\Support\Badges\BadgeRenderer; use App\Support\Badges\BadgeRenderer;
use App\Support\Badges\TagBadgeDomain; use App\Support\Badges\TagBadgeDomain;
@ -279,11 +280,32 @@ public function table(Table $table): Table
->sortable() ->sortable()
->searchable() ->searchable()
->getStateUsing(fn (BackupItem $record) => $record->resolvedDisplayName()), ->getStateUsing(fn (BackupItem $record) => $record->resolvedDisplayName()),
Tables\Columns\TextColumn::make('snapshot_mode')
->label('Snapshot')
->badge()
->state(fn (BackupItem $record): string => $this->backupItemQualitySummary($record)->snapshotMode)
->formatStateUsing(BadgeRenderer::label(BadgeDomain::PolicySnapshotMode))
->color(BadgeRenderer::color(BadgeDomain::PolicySnapshotMode))
->icon(BadgeRenderer::icon(BadgeDomain::PolicySnapshotMode))
->iconColor(BadgeRenderer::iconColor(BadgeDomain::PolicySnapshotMode)),
Tables\Columns\TextColumn::make('policyVersion.version_number') Tables\Columns\TextColumn::make('policyVersion.version_number')
->label('Version') ->label('Version')
->badge() ->badge()
->default('—') ->default('—')
->getStateUsing(fn (BackupItem $record): ?int => $record->policyVersion?->version_number), ->getStateUsing(fn (BackupItem $record): ?int => $record->policyVersion?->version_number),
Tables\Columns\TextColumn::make('backup_quality')
->label('Backup quality')
->state(fn (BackupItem $record): string => $this->backupItemQualitySummary($record)->compactSummary)
->description(function (BackupItem $record): string {
$summary = $this->backupItemQualitySummary($record);
if ($summary->assignmentCaptureReason === 'separate_role_assignments') {
return 'Assignments are captured separately for this item type.';
}
return $summary->nextAction;
})
->wrap(),
Tables\Columns\TextColumn::make('policy_type') Tables\Columns\TextColumn::make('policy_type')
->label('Type') ->label('Type')
->badge() ->badge()
@ -480,6 +502,11 @@ private function backupItemInspectUrl(BackupItem $record): ?string
return PolicyResource::getUrl('view', ['record' => $resolvedRecord->policy_id], tenant: $tenant); return PolicyResource::getUrl('view', ['record' => $resolvedRecord->policy_id], tenant: $tenant);
} }
private function backupItemQualitySummary(BackupItem $record): \App\Support\BackupQuality\BackupQualitySummary
{
return app(BackupQualityResolver::class)->forBackupItem($record);
}
private function resolveOwnerScopedBackupItemId(BackupSet $backupSet, mixed $record): int private function resolveOwnerScopedBackupItemId(BackupSet $backupSet, mixed $record): int
{ {
$recordId = $this->normalizeBackupItemKey($record); $recordId = $this->normalizeBackupItemKey($record);

View File

@ -5,6 +5,7 @@
use App\Filament\Support\VerificationReportChangeIndicator; use App\Filament\Support\VerificationReportChangeIndicator;
use App\Filament\Support\VerificationReportViewer; use App\Filament\Support\VerificationReportViewer;
use App\Models\OperationRun; use App\Models\OperationRun;
use App\Models\RestoreRun;
use App\Models\Tenant; use App\Models\Tenant;
use App\Models\User; use App\Models\User;
use App\Models\VerificationCheckAcknowledgement; use App\Models\VerificationCheckAcknowledgement;
@ -30,6 +31,7 @@
use App\Support\OpsUx\RunDurationInsights; use App\Support\OpsUx\RunDurationInsights;
use App\Support\OpsUx\SummaryCountsNormalizer; use App\Support\OpsUx\SummaryCountsNormalizer;
use App\Support\ReasonTranslation\ReasonPresenter; use App\Support\ReasonTranslation\ReasonPresenter;
use App\Support\RestoreSafety\RestoreSafetyResolver;
use App\Support\Tenants\ReferencedTenantLifecyclePresentation; use App\Support\Tenants\ReferencedTenantLifecyclePresentation;
use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration; use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration;
use App\Support\Ui\ActionSurface\ActionSurfaceDefaults; use App\Support\Ui\ActionSurface\ActionSurfaceDefaults;
@ -267,6 +269,7 @@ private static function enterpriseDetailPage(OperationRun $record): \App\Support
$artifactTruth = static::artifactTruthEnvelope($record); $artifactTruth = static::artifactTruthEnvelope($record);
$operatorExplanation = $artifactTruth?->operatorExplanation; $operatorExplanation = $artifactTruth?->operatorExplanation;
$primaryNextStep = static::resolvePrimaryNextStep($record, $artifactTruth, $operatorExplanation); $primaryNextStep = static::resolvePrimaryNextStep($record, $artifactTruth, $operatorExplanation);
$restoreContinuation = static::restoreContinuation($record);
$supportingGroups = static::supportingGroups( $supportingGroups = static::supportingGroups(
record: $record, record: $record,
factory: $factory, factory: $factory,
@ -319,6 +322,13 @@ private static function enterpriseDetailPage(OperationRun $record): \App\Support
), ),
) )
: null, : null,
is_array($restoreContinuation)
? $factory->keyFact(
'Restore continuation',
(string) ($restoreContinuation['badge_label'] ?? 'Restore detail'),
(string) ($restoreContinuation['summary'] ?? 'Restore continuation detail is unavailable.'),
)
: null,
])), ])),
primaryNextStep: $factory->primaryNextStep( primaryNextStep: $factory->primaryNextStep(
$primaryNextStep['text'], $primaryNextStep['text'],
@ -1328,6 +1338,58 @@ private static function surfaceGuidance(OperationRun $record, bool $fresh = fals
: OperationUxPresenter::surfaceGuidance($record); : OperationUxPresenter::surfaceGuidance($record);
} }
/**
* @return array{
* restore_run_id: int,
* state: string,
* summary: string,
* primary_next_action: string,
* recovery_claim_boundary: string,
* follow_up_required: bool,
* badge_label: string,
* link_url: ?string,
* link_available: bool
* }|null
*/
public static function restoreContinuation(OperationRun $record): ?array
{
if ($record->type !== 'restore.execute') {
return null;
}
$context = is_array($record->context) ? $record->context : [];
$restoreRunId = is_numeric($context['restore_run_id'] ?? null) ? (int) $context['restore_run_id'] : null;
$restoreRun = $restoreRunId !== null
? RestoreRun::query()->find($restoreRunId)
: RestoreRun::query()->where('operation_run_id', (int) $record->getKey())->latest('id')->first();
if (! $restoreRun instanceof RestoreRun) {
return null;
}
$attention = app(RestoreSafetyResolver::class)->resultAttentionForRun($restoreRun);
$tenant = $record->tenant;
$user = auth()->user();
$canOpenRestore = $tenant instanceof Tenant
&& $user instanceof User
&& app(\App\Services\Auth\CapabilityResolver::class)->isMember($user, $tenant);
return [
'restore_run_id' => (int) $restoreRun->getKey(),
'state' => $attention->state,
'summary' => $attention->summary,
'primary_next_action' => $attention->primaryNextAction,
'recovery_claim_boundary' => $attention->recoveryClaimBoundary,
'follow_up_required' => $attention->followUpRequired,
'badge_label' => \App\Support\Badges\BadgeRenderer::spec(\App\Support\Badges\BadgeDomain::RestoreResultStatus, $attention->state)->label,
'link_url' => $canOpenRestore
? RestoreRunResource::getUrl('view', ['record' => $restoreRun], panel: 'tenant', tenant: $tenant)
: null,
'link_available' => $canOpenRestore,
];
}
/** /**
* @return list<array{ * @return list<array{
* key: string, * key: string,

View File

@ -21,6 +21,9 @@
use App\Services\OperationRunService; use App\Services\OperationRunService;
use App\Services\Operations\BulkSelectionIdentity; use App\Services\Operations\BulkSelectionIdentity;
use App\Support\Auth\Capabilities; use App\Support\Auth\Capabilities;
use App\Support\BackupQuality\BackupQualityResolver;
use App\Support\Badges\BadgeDomain;
use App\Support\Badges\BadgeRenderer;
use App\Support\Badges\TagBadgeDomain; use App\Support\Badges\TagBadgeDomain;
use App\Support\Badges\TagBadgeRenderer; use App\Support\Badges\TagBadgeRenderer;
use App\Support\Baselines\PolicyVersionCapturePurpose; use App\Support\Baselines\PolicyVersionCapturePurpose;
@ -107,7 +110,7 @@ public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
->satisfy(ActionSurfaceSlot::InspectAffordance, ActionSurfaceInspectAffordance::ClickableRow->value) ->satisfy(ActionSurfaceSlot::InspectAffordance, ActionSurfaceInspectAffordance::ClickableRow->value)
->satisfy(ActionSurfaceSlot::ListRowMoreMenu, 'Secondary row actions are grouped under "More".') ->satisfy(ActionSurfaceSlot::ListRowMoreMenu, 'Secondary row actions are grouped under "More".')
->satisfy(ActionSurfaceSlot::ListBulkMoreGroup, 'Bulk actions are grouped under "More".') ->satisfy(ActionSurfaceSlot::ListBulkMoreGroup, 'Bulk actions are grouped under "More".')
->exempt(ActionSurfaceSlot::ListEmptyState, 'Empty-state CTA is intentionally omitted; versions appear after policy sync/capture workflows.') ->satisfy(ActionSurfaceSlot::ListEmptyState, 'Empty-state CTA routes operators to backup sets when no versions are available yet.')
->exempt(ActionSurfaceSlot::DetailHeader, 'View page header actions are intentionally minimal for now.'); ->exempt(ActionSurfaceSlot::DetailHeader, 'View page header actions are intentionally minimal for now.');
} }
@ -129,6 +132,37 @@ public static function infolist(Schema $schema): Schema
->color(TagBadgeRenderer::color(TagBadgeDomain::Platform)), ->color(TagBadgeRenderer::color(TagBadgeDomain::Platform)),
Infolists\Components\TextEntry::make('created_by')->label('Actor'), Infolists\Components\TextEntry::make('created_by')->label('Actor'),
Infolists\Components\TextEntry::make('captured_at')->dateTime(), Infolists\Components\TextEntry::make('captured_at')->dateTime(),
Section::make('Backup quality')
->schema([
Infolists\Components\TextEntry::make('quality_snapshot_mode')
->label('Snapshot')
->badge()
->state(fn (PolicyVersion $record): string => static::policyVersionQualitySummary($record)->snapshotMode)
->formatStateUsing(BadgeRenderer::label(BadgeDomain::PolicySnapshotMode))
->color(BadgeRenderer::color(BadgeDomain::PolicySnapshotMode))
->icon(BadgeRenderer::icon(BadgeDomain::PolicySnapshotMode))
->iconColor(BadgeRenderer::iconColor(BadgeDomain::PolicySnapshotMode)),
Infolists\Components\TextEntry::make('quality_summary')
->label('Backup quality')
->state(fn (PolicyVersion $record): string => static::policyVersionQualitySummary($record)->compactSummary),
Infolists\Components\TextEntry::make('quality_assignment_signal')
->label('Assignment quality')
->state(fn (PolicyVersion $record): string => static::policyVersionAssignmentQualityLabel($record)),
Infolists\Components\TextEntry::make('quality_next_action')
->label('Next action')
->state(fn (PolicyVersion $record): string => static::policyVersionQualitySummary($record)->nextAction),
Infolists\Components\TextEntry::make('quality_integrity_warning')
->label('Integrity note')
->state(fn (PolicyVersion $record): ?string => static::policyVersionQualitySummary($record)->integrityWarning)
->visible(fn (PolicyVersion $record): bool => static::policyVersionQualitySummary($record)->hasIntegrityWarning())
->columnSpanFull(),
Infolists\Components\TextEntry::make('quality_boundary')
->label('Boundary')
->state(fn (PolicyVersion $record): string => static::policyVersionQualitySummary($record)->positiveClaimBoundary)
->columnSpanFull(),
])
->columns(2)
->columnSpanFull(),
Section::make('Related context') Section::make('Related context')
->schema([ ->schema([
Infolists\Components\ViewEntry::make('related_context') Infolists\Components\ViewEntry::make('related_context')
@ -528,6 +562,19 @@ public static function table(Table $table): Table
->searchable() ->searchable()
->getStateUsing(fn (PolicyVersion $record): string => static::resolvedDisplayName($record)), ->getStateUsing(fn (PolicyVersion $record): string => static::resolvedDisplayName($record)),
Tables\Columns\TextColumn::make('version_number')->sortable(), Tables\Columns\TextColumn::make('version_number')->sortable(),
Tables\Columns\TextColumn::make('snapshot_mode')
->label('Snapshot')
->badge()
->state(fn (PolicyVersion $record): string => static::policyVersionQualitySummary($record)->snapshotMode)
->formatStateUsing(BadgeRenderer::label(BadgeDomain::PolicySnapshotMode))
->color(BadgeRenderer::color(BadgeDomain::PolicySnapshotMode))
->icon(BadgeRenderer::icon(BadgeDomain::PolicySnapshotMode))
->iconColor(BadgeRenderer::iconColor(BadgeDomain::PolicySnapshotMode)),
Tables\Columns\TextColumn::make('backup_quality')
->label('Backup quality')
->state(fn (PolicyVersion $record): string => static::policyVersionQualitySummary($record)->compactSummary)
->description(fn (PolicyVersion $record): string => static::policyVersionQualitySummary($record)->nextAction)
->wrap(),
Tables\Columns\TextColumn::make('policy_type') Tables\Columns\TextColumn::make('policy_type')
->badge() ->badge()
->formatStateUsing(TagBadgeRenderer::label(TagBadgeDomain::PolicyType)) ->formatStateUsing(TagBadgeRenderer::label(TagBadgeDomain::PolicyType))
@ -536,7 +583,7 @@ public static function table(Table $table): Table
->badge() ->badge()
->formatStateUsing(TagBadgeRenderer::label(TagBadgeDomain::Platform)) ->formatStateUsing(TagBadgeRenderer::label(TagBadgeDomain::Platform))
->color(TagBadgeRenderer::color(TagBadgeDomain::Platform)), ->color(TagBadgeRenderer::color(TagBadgeDomain::Platform)),
Tables\Columns\TextColumn::make('created_by')->label('Actor'), Tables\Columns\TextColumn::make('created_by')->label('Actor')->toggleable(isToggledHiddenByDefault: true),
Tables\Columns\TextColumn::make('captured_at')->dateTime()->sortable(), Tables\Columns\TextColumn::make('captured_at')->dateTime()->sortable(),
]) ])
->filters([ ->filters([
@ -578,7 +625,7 @@ public static function table(Table $table): Table
return $resolver->isMember($user, $tenant); return $resolver->isMember($user, $tenant);
}) })
->disabled(function (PolicyVersion $record): bool { ->disabled(function (PolicyVersion $record): bool {
if (($record->metadata['source'] ?? null) === 'metadata_only') { if (static::policyVersionQualitySummary($record)->snapshotMode === 'metadata_only') {
return true; return true;
} }
@ -617,7 +664,7 @@ public static function table(Table $table): Table
return 'You do not have permission to create restore runs.'; return 'You do not have permission to create restore runs.';
} }
if (($record->metadata['source'] ?? null) === 'metadata_only') { if (static::policyVersionQualitySummary($record)->snapshotMode === 'metadata_only') {
return 'Disabled for metadata-only snapshots (Graph did not provide policy settings).'; return 'Disabled for metadata-only snapshots (Graph did not provide policy settings).';
} }
@ -642,7 +689,7 @@ public static function table(Table $table): Table
abort(403); abort(403);
} }
if (($record->metadata['source'] ?? null) === 'metadata_only') { if (static::policyVersionQualitySummary($record)->snapshotMode === 'metadata_only') {
Notification::make() Notification::make()
->title('Restore disabled for metadata-only snapshot') ->title('Restore disabled for metadata-only snapshot')
->body('This snapshot only contains metadata; Graph did not provide policy settings to restore.') ->body('This snapshot only contains metadata; Graph did not provide policy settings to restore.')
@ -699,11 +746,15 @@ public static function table(Table $table): Table
$backupItemMetadata = [ $backupItemMetadata = [
'source' => 'policy_version', 'source' => 'policy_version',
'snapshot_source' => $record->snapshotSource(),
'display_name' => $policy->display_name, 'display_name' => $policy->display_name,
'policy_version_id' => $record->id, 'policy_version_id' => $record->id,
'policy_version_number' => $record->version_number, 'policy_version_number' => $record->version_number,
'version_captured_at' => $record->captured_at?->toIso8601String(), 'version_captured_at' => $record->captured_at?->toIso8601String(),
'redaction_version' => $record->redaction_version, 'redaction_version' => $record->redaction_version,
'warnings' => $record->warningMessages(),
'assignments_fetch_failed' => $record->assignmentsFetchFailed(),
'has_orphaned_assignments' => $record->hasOrphanedAssignments(),
]; ];
$integrityWarning = RedactionIntegrity::noteForPolicyVersion($record); $integrityWarning = RedactionIntegrity::noteForPolicyVersion($record);
@ -891,7 +942,13 @@ public static function table(Table $table): Table
]) ])
->emptyStateHeading('No policy versions') ->emptyStateHeading('No policy versions')
->emptyStateDescription('Capture or sync policy snapshots to build a version history.') ->emptyStateDescription('Capture or sync policy snapshots to build a version history.')
->emptyStateIcon('heroicon-o-clock'); ->emptyStateIcon('heroicon-o-clock')
->emptyStateActions([
Actions\Action::make('open_backup_sets')
->label('Open backup sets')
->url(fn (): string => BackupSetResource::getUrl('index', tenant: static::resolveTenantContextForCurrentPanel()))
->color('gray'),
]);
} }
public static function getEloquentQuery(): Builder public static function getEloquentQuery(): Builder
@ -980,6 +1037,23 @@ private static function primaryRelatedAction(): Actions\Action
->color('gray'); ->color('gray');
} }
private static function policyVersionQualitySummary(PolicyVersion $record): \App\Support\BackupQuality\BackupQualitySummary
{
return app(BackupQualityResolver::class)->forPolicyVersion($record);
}
private static function policyVersionAssignmentQualityLabel(PolicyVersion $record): string
{
$summary = static::policyVersionQualitySummary($record);
return match (true) {
$summary->hasAssignmentIssues && $summary->hasOrphanedAssignments => 'Assignment fetch failed and orphaned targets were detected.',
$summary->hasAssignmentIssues => 'Assignment fetch failed during capture.',
$summary->hasOrphanedAssignments => 'Orphaned assignment targets were detected.',
default => 'No assignment issues were detected from captured metadata.',
};
}
private static function primaryRelatedEntry(PolicyVersion $record): ?RelatedContextEntry private static function primaryRelatedEntry(PolicyVersion $record): ?RelatedContextEntry
{ {
return app(RelatedNavigationResolver::class) return app(RelatedNavigationResolver::class)

View File

@ -27,6 +27,7 @@
use App\Services\OperationRunService; use App\Services\OperationRunService;
use App\Services\Operations\BulkSelectionIdentity; use App\Services\Operations\BulkSelectionIdentity;
use App\Support\Auth\Capabilities; use App\Support\Auth\Capabilities;
use App\Support\BackupQuality\BackupQualityResolver;
use App\Support\Badges\BadgeDomain; use App\Support\Badges\BadgeDomain;
use App\Support\Badges\BadgeRenderer; use App\Support\Badges\BadgeRenderer;
use App\Support\Filament\FilterOptionCatalog; use App\Support\Filament\FilterOptionCatalog;
@ -37,6 +38,10 @@
use App\Support\Rbac\UiEnforcement; use App\Support\Rbac\UiEnforcement;
use App\Support\RestoreRunIdempotency; use App\Support\RestoreRunIdempotency;
use App\Support\RestoreRunStatus; use App\Support\RestoreRunStatus;
use App\Support\RestoreSafety\ChecksIntegrityState;
use App\Support\RestoreSafety\PreviewIntegrityState;
use App\Support\RestoreSafety\RestoreSafetyCopy;
use App\Support\RestoreSafety\RestoreSafetyResolver;
use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration; use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration;
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceInspectAffordance; use App\Support\Ui\ActionSurface\Enums\ActionSurfaceInspectAffordance;
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile; use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile;
@ -124,24 +129,8 @@ public static function form(Schema $schema): Schema
->schema([ ->schema([
Forms\Components\Select::make('backup_set_id') Forms\Components\Select::make('backup_set_id')
->label('Backup set') ->label('Backup set')
->options(function () { ->options(fn () => static::restoreBackupSetOptions())
$tenantId = static::resolveTenantContextForCurrentPanelOrFail()->getKey(); ->helperText(fn (Get $get): string => static::restoreBackupSetHelperText($get('backup_set_id')))
return BackupSet::query()
->when($tenantId, fn ($query) => $query->where('tenant_id', $tenantId))
->orderByDesc('created_at')
->get()
->mapWithKeys(function (BackupSet $set) {
$label = sprintf(
'%s • %s items • %s',
$set->name,
$set->item_count ?? 0,
optional($set->created_at)->format('Y-m-d H:i')
);
return [$set->id => $label];
});
})
->reactive() ->reactive()
->afterStateUpdated(function (Set $set): void { ->afterStateUpdated(function (Set $set): void {
$set('scope_mode', 'all'); $set('scope_mode', 'all');
@ -159,7 +148,7 @@ public static function form(Schema $schema): Schema
->bulkToggleable() ->bulkToggleable()
->reactive() ->reactive()
->afterStateUpdated(fn (Set $set) => $set('group_mapping', [])) ->afterStateUpdated(fn (Set $set) => $set('group_mapping', []))
->helperText('Search by name, type, or ID. Preview-only types stay in dry-run; leave empty to include all items. Include foundations (scope tags, assignment filters) with policies to re-map IDs.'), ->helperText(fn (): string => static::restoreItemQualityHelperText()),
Section::make('Group mapping') Section::make('Group mapping')
->description('Some source groups do not exist in the target tenant. Map them or choose Skip.') ->description('Some source groups do not exist in the target tenant. Map them or choose Skip.')
->schema(function (Get $get): array { ->schema(function (Get $get): array {
@ -187,7 +176,7 @@ public static function form(Schema $schema): Schema
$cacheNotice = match (true) { $cacheNotice = match (true) {
! $hasCachedGroups => 'No cached groups found. Run "Sync Groups" first.', ! $hasCachedGroups => 'No cached groups found. Run "Sync Groups" first.',
$isStale => "Cached groups may be stale (>${stalenessDays} days). Consider running \"Sync Groups\".", $isStale => "Cached groups may be stale (>{$stalenessDays} days). Consider running \"Sync Groups\".",
default => null, default => null,
}; };
@ -306,52 +295,43 @@ public static function getWizardSteps(): array
{ {
return [ return [
Step::make('Select Backup Set') Step::make('Select Backup Set')
->description('What are we restoring from?') ->description('What are we restoring from? Backup quality is visible here before safety checks run.')
->schema([ ->schema([
Forms\Components\Select::make('backup_set_id') Forms\Components\Select::make('backup_set_id')
->label('Backup set') ->label('Backup set')
->options(function () { ->options(fn () => static::restoreBackupSetOptions())
$tenantId = static::resolveTenantContextForCurrentPanelOrFail()->getKey(); ->helperText(fn (Get $get): string => static::restoreBackupSetHelperText($get('backup_set_id')))
return BackupSet::query()
->when($tenantId, fn ($query) => $query->where('tenant_id', $tenantId))
->orderByDesc('created_at')
->get()
->mapWithKeys(function (BackupSet $set) {
$label = sprintf(
'%s • %s items • %s',
$set->name,
$set->item_count ?? 0,
optional($set->created_at)->format('Y-m-d H:i')
);
return [$set->id => $label];
});
})
->reactive() ->reactive()
->afterStateUpdated(function (Set $set, Get $get): void { ->afterStateUpdated(function (Set $set, Get $get): void {
$set('scope_mode', 'all'); $groupMapping = static::groupMappingPlaceholders(
$set('backup_item_ids', null);
$set('group_mapping', static::groupMappingPlaceholders(
backupSetId: $get('backup_set_id'), backupSetId: $get('backup_set_id'),
scopeMode: 'all', scopeMode: 'all',
selectedItemIds: null, selectedItemIds: null,
tenant: static::resolveTenantContextForCurrentPanel(), tenant: static::resolveTenantContextForCurrentPanel(),
)); );
$set('scope_mode', 'all');
$set('backup_item_ids', null);
$set('group_mapping', $groupMapping);
$set('is_dry_run', true); $set('is_dry_run', true);
$set('acknowledged_impact', false); $set('acknowledged_impact', false);
$set('tenant_confirm', null); $set('tenant_confirm', null);
$set('check_summary', null);
$set('check_results', []); $draft = static::synchronizeRestoreSafetyDraft([
$set('checks_ran_at', null); ...static::draftDataSnapshot($get),
$set('preview_summary', null); 'scope_mode' => 'all',
$set('preview_diffs', []); 'backup_item_ids' => [],
$set('preview_ran_at', null); 'group_mapping' => $groupMapping,
]);
$set('scope_basis', $draft['scope_basis']);
$set('check_invalidation_reasons', $draft['check_invalidation_reasons']);
$set('preview_invalidation_reasons', $draft['preview_invalidation_reasons']);
}) })
->required(), ->required(),
]), ]),
Step::make('Define Restore Scope') Step::make('Define Restore Scope')
->description('What exactly should be restored?') ->description('What exactly should be restored? Item quality hints appear here before restore risk checks.')
->schema([ ->schema([
Forms\Components\Radio::make('scope_mode') Forms\Components\Radio::make('scope_mode')
->label('Scope') ->label('Scope')
@ -367,27 +347,45 @@ public static function getWizardSteps(): array
$set('is_dry_run', true); $set('is_dry_run', true);
$set('acknowledged_impact', false); $set('acknowledged_impact', false);
$set('tenant_confirm', null); $set('tenant_confirm', null);
$set('check_summary', null);
$set('check_results', []);
$set('checks_ran_at', null);
$set('preview_summary', null);
$set('preview_diffs', []);
$set('preview_ran_at', null);
if ($state === 'all') { if ($state === 'all') {
$set('backup_item_ids', null); $groupMapping = static::groupMappingPlaceholders(
$set('group_mapping', static::groupMappingPlaceholders(
backupSetId: $backupSetId, backupSetId: $backupSetId,
scopeMode: 'all', scopeMode: 'all',
selectedItemIds: null, selectedItemIds: null,
tenant: $tenant, tenant: $tenant,
)); );
$set('backup_item_ids', null);
$set('group_mapping', $groupMapping);
$draft = static::synchronizeRestoreSafetyDraft([
...static::draftDataSnapshot($get),
'scope_mode' => 'all',
'backup_item_ids' => [],
'group_mapping' => $groupMapping,
]);
$set('scope_basis', $draft['scope_basis']);
$set('check_invalidation_reasons', $draft['check_invalidation_reasons']);
$set('preview_invalidation_reasons', $draft['preview_invalidation_reasons']);
return; return;
} }
$set('group_mapping', []); $set('group_mapping', []);
$set('backup_item_ids', []); $set('backup_item_ids', []);
$draft = static::synchronizeRestoreSafetyDraft([
...static::draftDataSnapshot($get),
'scope_mode' => 'selected',
'backup_item_ids' => [],
'group_mapping' => [],
]);
$set('scope_basis', $draft['scope_basis']);
$set('check_invalidation_reasons', $draft['check_invalidation_reasons']);
$set('preview_invalidation_reasons', $draft['preview_invalidation_reasons']);
}) })
->required(), ->required(),
Forms\Components\Select::make('backup_item_ids') Forms\Components\Select::make('backup_item_ids')
@ -414,12 +412,21 @@ public static function getWizardSteps(): array
$set('is_dry_run', true); $set('is_dry_run', true);
$set('acknowledged_impact', false); $set('acknowledged_impact', false);
$set('tenant_confirm', null); $set('tenant_confirm', null);
$set('check_summary', null);
$set('check_results', []); $draft = static::synchronizeRestoreSafetyDraft([
$set('checks_ran_at', null); ...static::draftDataSnapshot($get),
$set('preview_summary', null); 'backup_item_ids' => $selectedItemIds ?? [],
$set('preview_diffs', []); 'group_mapping' => static::groupMappingPlaceholders(
$set('preview_ran_at', null); backupSetId: $backupSetId,
scopeMode: 'selected',
selectedItemIds: $selectedItemIds,
tenant: $tenant,
),
]);
$set('scope_basis', $draft['scope_basis']);
$set('check_invalidation_reasons', $draft['check_invalidation_reasons']);
$set('preview_invalidation_reasons', $draft['preview_invalidation_reasons']);
}) })
->visible(fn (Get $get): bool => $get('scope_mode') === 'selected') ->visible(fn (Get $get): bool => $get('scope_mode') === 'selected')
->required(fn (Get $get): bool => $get('scope_mode') === 'selected') ->required(fn (Get $get): bool => $get('scope_mode') === 'selected')
@ -447,7 +454,7 @@ public static function getWizardSteps(): array
->visible(fn (Get $get): bool => $get('scope_mode') === 'selected') ->visible(fn (Get $get): bool => $get('scope_mode') === 'selected')
->action(fn (Set $set) => $set('backup_item_ids', [], shouldCallUpdatedHooks: true)), ->action(fn (Set $set) => $set('backup_item_ids', [], shouldCallUpdatedHooks: true)),
]) ])
->helperText('Search by name or ID. Include foundations (scope tags, assignment filters) with policies to re-map IDs. Options are grouped by category, type, and platform.'), ->helperText(fn (): string => static::restoreItemQualityHelperText()),
Section::make('Group mapping') Section::make('Group mapping')
->description('Some source groups do not exist in the target tenant. Map them or choose Skip.') ->description('Some source groups do not exist in the target tenant. Map them or choose Skip.')
->schema(function (Get $get): array { ->schema(function (Get $get): array {
@ -482,7 +489,7 @@ public static function getWizardSteps(): array
$cacheNotice = match (true) { $cacheNotice = match (true) {
! $hasCachedGroups => 'No cached groups found. Run "Sync Groups" first.', ! $hasCachedGroups => 'No cached groups found. Run "Sync Groups" first.',
$isStale => "Cached groups may be stale (>${stalenessDays} days). Consider running \"Sync Groups\".", $isStale => "Cached groups may be stale (>{$stalenessDays} days). Consider running \"Sync Groups\".",
default => null, default => null,
}; };
@ -495,13 +502,16 @@ public static function getWizardSteps(): array
->placeholder('SKIP or target group Object ID (GUID)') ->placeholder('SKIP or target group Object ID (GUID)')
->rules([new SkipOrUuidRule]) ->rules([new SkipOrUuidRule])
->reactive() ->reactive()
->afterStateUpdated(function (Set $set): void { ->afterStateUpdated(function (Set $set, Get $get): void {
$set('check_summary', null); $set('is_dry_run', true);
$set('check_results', []); $set('acknowledged_impact', false);
$set('checks_ran_at', null); $set('tenant_confirm', null);
$set('preview_summary', null);
$set('preview_diffs', []); $draft = static::synchronizeRestoreSafetyDraft(static::draftDataSnapshot($get));
$set('preview_ran_at', null);
$set('scope_basis', $draft['scope_basis']);
$set('check_invalidation_reasons', $draft['check_invalidation_reasons']);
$set('preview_invalidation_reasons', $draft['preview_invalidation_reasons']);
}) })
->required() ->required()
->suffixAction( ->suffixAction(
@ -554,10 +564,16 @@ public static function getWizardSteps(): array
Step::make('Safety & Conflict Checks') Step::make('Safety & Conflict Checks')
->description('Is this dangerous?') ->description('Is this dangerous?')
->schema([ ->schema([
Forms\Components\Hidden::make('scope_basis')
->default(null),
Forms\Components\Hidden::make('check_summary') Forms\Components\Hidden::make('check_summary')
->default(null), ->default(null),
Forms\Components\Hidden::make('checks_ran_at') Forms\Components\Hidden::make('checks_ran_at')
->default(null), ->default(null),
Forms\Components\Hidden::make('check_basis')
->default(null),
Forms\Components\Hidden::make('check_invalidation_reasons')
->default([]),
Forms\Components\ViewField::make('check_results') Forms\Components\ViewField::make('check_results')
->label('Checks') ->label('Checks')
->default([]) ->default([])
@ -565,6 +581,7 @@ public static function getWizardSteps(): array
->viewData(fn (Get $get): array => [ ->viewData(fn (Get $get): array => [
'summary' => $get('check_summary'), 'summary' => $get('check_summary'),
'ranAt' => $get('checks_ran_at'), 'ranAt' => $get('checks_ran_at'),
...static::wizardSafetyState(static::draftDataSnapshot($get)),
]) ])
->hintActions([ ->hintActions([
Actions\Action::make('run_restore_checks') Actions\Action::make('run_restore_checks')
@ -614,9 +631,23 @@ public static function getWizardSteps(): array
groupMapping: $groupMapping, groupMapping: $groupMapping,
); );
$set('check_summary', $outcome['summary'] ?? [], shouldCallUpdatedHooks: true); $ranAt = now('UTC')->toIso8601String();
$set('check_results', $outcome['results'] ?? [], shouldCallUpdatedHooks: true); $draft = [
$set('checks_ran_at', now()->toIso8601String(), shouldCallUpdatedHooks: true); ...static::draftDataSnapshot($get),
'check_summary' => $outcome['summary'] ?? [],
'check_results' => $outcome['results'] ?? [],
'checks_ran_at' => $ranAt,
];
$draft['check_basis'] = static::restoreSafetyResolver()->checksBasisFromData($draft);
$draft['check_invalidation_reasons'] = [];
$draft = static::synchronizeRestoreSafetyDraft($draft);
$set('check_summary', $draft['check_summary'], shouldCallUpdatedHooks: true);
$set('check_results', $draft['check_results'], shouldCallUpdatedHooks: true);
$set('checks_ran_at', $ranAt, shouldCallUpdatedHooks: true);
$set('check_basis', $draft['check_basis'], shouldCallUpdatedHooks: true);
$set('check_invalidation_reasons', [], shouldCallUpdatedHooks: true);
$set('scope_basis', $draft['scope_basis'], shouldCallUpdatedHooks: true);
$summary = $outcome['summary'] ?? []; $summary = $outcome['summary'] ?? [];
$blockers = (int) ($summary['blocking'] ?? 0); $blockers = (int) ($summary['blocking'] ?? 0);
@ -644,6 +675,8 @@ public static function getWizardSteps(): array
$set('check_summary', null, shouldCallUpdatedHooks: true); $set('check_summary', null, shouldCallUpdatedHooks: true);
$set('check_results', [], shouldCallUpdatedHooks: true); $set('check_results', [], shouldCallUpdatedHooks: true);
$set('checks_ran_at', null, shouldCallUpdatedHooks: true); $set('checks_ran_at', null, shouldCallUpdatedHooks: true);
$set('check_basis', null, shouldCallUpdatedHooks: true);
$set('check_invalidation_reasons', [], shouldCallUpdatedHooks: true);
}), }),
]) ])
->helperText('Run checks after defining scope and mapping missing groups.'), ->helperText('Run checks after defining scope and mapping missing groups.'),
@ -656,6 +689,10 @@ public static function getWizardSteps(): array
Forms\Components\Hidden::make('preview_ran_at') Forms\Components\Hidden::make('preview_ran_at')
->default(null) ->default(null)
->required(), ->required(),
Forms\Components\Hidden::make('preview_basis')
->default(null),
Forms\Components\Hidden::make('preview_invalidation_reasons')
->default([]),
Forms\Components\ViewField::make('preview_diffs') Forms\Components\ViewField::make('preview_diffs')
->label('Preview') ->label('Preview')
->default([]) ->default([])
@ -663,6 +700,7 @@ public static function getWizardSteps(): array
->viewData(fn (Get $get): array => [ ->viewData(fn (Get $get): array => [
'summary' => $get('preview_summary'), 'summary' => $get('preview_summary'),
'ranAt' => $get('preview_ran_at'), 'ranAt' => $get('preview_ran_at'),
...static::wizardSafetyState(static::draftDataSnapshot($get)),
]) ])
->hintActions([ ->hintActions([
Actions\Action::make('run_restore_preview') Actions\Action::make('run_restore_preview')
@ -711,10 +749,23 @@ public static function getWizardSteps(): array
$summary = $outcome['summary'] ?? []; $summary = $outcome['summary'] ?? [];
$diffs = $outcome['diffs'] ?? []; $diffs = $outcome['diffs'] ?? [];
$ranAt = (string) ($summary['generated_at'] ?? now('UTC')->toIso8601String());
$draft = [
...static::draftDataSnapshot($get),
'preview_summary' => $summary,
'preview_diffs' => $diffs,
'preview_ran_at' => $ranAt,
];
$draft['preview_basis'] = static::restoreSafetyResolver()->previewBasisFromData($draft);
$draft['preview_invalidation_reasons'] = [];
$draft = static::synchronizeRestoreSafetyDraft($draft);
$set('preview_summary', $summary, shouldCallUpdatedHooks: true); $set('preview_summary', $summary, shouldCallUpdatedHooks: true);
$set('preview_diffs', $diffs, shouldCallUpdatedHooks: true); $set('preview_diffs', $diffs, shouldCallUpdatedHooks: true);
$set('preview_ran_at', $summary['generated_at'] ?? now()->toIso8601String(), shouldCallUpdatedHooks: true); $set('preview_ran_at', $ranAt, shouldCallUpdatedHooks: true);
$set('preview_basis', $draft['preview_basis'], shouldCallUpdatedHooks: true);
$set('preview_invalidation_reasons', [], shouldCallUpdatedHooks: true);
$set('scope_basis', $draft['scope_basis'], shouldCallUpdatedHooks: true);
$policiesChanged = (int) ($summary['policies_changed'] ?? 0); $policiesChanged = (int) ($summary['policies_changed'] ?? 0);
$policiesTotal = (int) ($summary['policies_total'] ?? 0); $policiesTotal = (int) ($summary['policies_total'] ?? 0);
@ -737,6 +788,8 @@ public static function getWizardSteps(): array
$set('preview_summary', null, shouldCallUpdatedHooks: true); $set('preview_summary', null, shouldCallUpdatedHooks: true);
$set('preview_diffs', [], shouldCallUpdatedHooks: true); $set('preview_diffs', [], shouldCallUpdatedHooks: true);
$set('preview_ran_at', null, shouldCallUpdatedHooks: true); $set('preview_ran_at', null, shouldCallUpdatedHooks: true);
$set('preview_basis', null, shouldCallUpdatedHooks: true);
$set('preview_invalidation_reasons', [], shouldCallUpdatedHooks: true);
}), }),
]) ])
->helperText('Generate a normalized diff preview before creating the dry-run restore.'), ->helperText('Generate a normalized diff preview before creating the dry-run restore.'),
@ -760,24 +813,66 @@ public static function getWizardSteps(): array
return (string) ($tenant->name ?? $tenantIdentifier ?? $tenant->getKey()); return (string) ($tenant->name ?? $tenantIdentifier ?? $tenant->getKey());
}), }),
Forms\Components\Placeholder::make('confirm_execution_readiness')
->label('Technical startability')
->content(function (Get $get): string {
$state = static::wizardSafetyState(static::draftDataSnapshot($get));
$readiness = $state['executionReadiness'];
if (! is_array($readiness)) {
return 'Execution readiness is unavailable.';
}
return (string) ($readiness['display_summary'] ?? 'Execution readiness is unavailable.');
}),
Forms\Components\Placeholder::make('confirm_safety_readiness')
->label('Safety readiness')
->content(function (Get $get): string {
$state = static::wizardSafetyState(static::draftDataSnapshot($get));
$assessment = $state['safetyAssessment'];
if (! is_array($assessment)) {
return 'Safety readiness is unavailable.';
}
return (string) ($assessment['summary'] ?? 'Safety readiness is unavailable.');
}),
Forms\Components\Placeholder::make('confirm_primary_next_step')
->label('Primary next step')
->content(function (Get $get): string {
$state = static::wizardSafetyState(static::draftDataSnapshot($get));
$assessment = $state['safetyAssessment'];
if (! is_array($assessment)) {
return 'Review the current scope and safety evidence.';
}
return RestoreSafetyCopy::primaryNextAction(
is_string($assessment['primary_next_action'] ?? null)
? $assessment['primary_next_action']
: 'review_scope'
);
}),
Forms\Components\Toggle::make('is_dry_run') Forms\Components\Toggle::make('is_dry_run')
->label('Preview only (dry-run)') ->label('Preview only (dry-run)')
->default(true) ->default(true)
->reactive() ->reactive()
->disabled(function (Get $get): bool { ->disabled(function (Get $get): bool {
if (! filled($get('checks_ran_at'))) { $state = static::wizardSafetyState(static::draftDataSnapshot($get));
return true; $readiness = $state['executionReadiness'];
}
$summary = $get('check_summary'); return ! is_array($readiness) || ! (bool) ($readiness['allowed'] ?? false);
if (! is_array($summary)) {
return false;
}
return (int) ($summary['blocking'] ?? 0) > 0;
}) })
->helperText('Turn OFF to queue a real execution. Execution requires checks + preview + confirmations.'), ->helperText(function (Get $get): string {
$state = static::wizardSafetyState(static::draftDataSnapshot($get));
$assessment = $state['safetyAssessment'];
if (! is_array($assessment)) {
return 'Turn OFF to queue a real execution. Execution requires checks, preview, and confirmations.';
}
return (string) ($assessment['summary'] ?? 'Turn OFF to queue a real execution. Execution requires checks, preview, and confirmations.');
}),
Forms\Components\Checkbox::make('acknowledged_impact') Forms\Components\Checkbox::make('acknowledged_impact')
->label('I reviewed the impact (checks + preview)') ->label('I reviewed the impact (checks + preview)')
->accepted() ->accepted()
@ -1290,11 +1385,11 @@ public static function infolist(Schema $schema): Schema
Infolists\Components\ViewEntry::make('preview') Infolists\Components\ViewEntry::make('preview')
->label('Preview') ->label('Preview')
->view('filament.infolists.entries.restore-preview') ->view('filament.infolists.entries.restore-preview')
->state(fn ($record) => $record->preview ?? []), ->state(fn (RestoreRun $record): array => static::detailPreviewState($record)),
Infolists\Components\ViewEntry::make('results') Infolists\Components\ViewEntry::make('results')
->label('Results') ->label('Results')
->view('filament.infolists.entries.restore-results') ->view('filament.infolists.entries.restore-results')
->state(fn ($record) => $record->results ?? []), ->state(fn (RestoreRun $record): array => static::detailResultsState($record)),
]); ]);
} }
@ -1366,6 +1461,7 @@ private static function restoreItemOptionData(?int $backupSetId): array
foreach ($items as $item) { foreach ($items as $item) {
$meta = static::typeMeta($item->policy_type); $meta = static::typeMeta($item->policy_type);
$qualitySummary = static::backupItemQualitySummary($item);
$typeLabel = $meta['label'] ?? $item->policy_type; $typeLabel = $meta['label'] ?? $item->policy_type;
$category = $meta['category'] ?? 'Policies'; $category = $meta['category'] ?? 'Policies';
$restore = $meta['restore'] ?? 'enabled'; $restore = $meta['restore'] ?? 'enabled';
@ -1380,6 +1476,7 @@ private static function restoreItemOptionData(?int $backupSetId): array
$category, $category,
$typeLabel, $typeLabel,
$platform, $platform,
'quality: '.$qualitySummary->compactSummary,
"restore: {$restore}", "restore: {$restore}",
$versionNumber ? "version: {$versionNumber}" : null, $versionNumber ? "version: {$versionNumber}" : null,
$item->hasAssignments() ? "assignments: {$item->assignment_count}" : null, $item->hasAssignments() ? "assignments: {$item->assignment_count}" : null,
@ -1445,12 +1542,111 @@ private static function restoreItemGroupedOptions(?int $backupSetId): array
])); ]));
$groups[$groupLabel] ??= []; $groups[$groupLabel] ??= [];
$groups[$groupLabel][$item->id] = $item->resolvedDisplayName(); $groups[$groupLabel][$item->id] = static::restoreItemSelectionLabel($item);
} }
return $groups; return $groups;
} }
/**
* @return array<int, string>
*/
private static function restoreBackupSetOptions(): array
{
$tenantId = static::resolveTenantContextForCurrentPanelOrFail()->getKey();
return BackupSet::query()
->where('tenant_id', $tenantId)
->with([
'items' => fn ($query) => $query->select([
'id',
'backup_set_id',
'payload',
'metadata',
'assignments',
]),
])
->orderByDesc('created_at')
->get()
->mapWithKeys(fn (BackupSet $set): array => [
(int) $set->getKey() => static::restoreBackupSetSelectionLabel($set),
])
->all();
}
private static function restoreBackupSetSelectionLabel(BackupSet $set): string
{
$qualitySummary = static::backupSetQualitySummary($set);
return implode(' • ', array_filter([
$set->name,
sprintf('%d items', (int) ($set->item_count ?? 0)),
optional($set->created_at)->format('Y-m-d H:i'),
$qualitySummary->compactSummary,
]));
}
private static function restoreBackupSetHelperText(mixed $backupSetId): string
{
$default = 'Backup quality hints describe input strength only. They do not approve restore execution or prove recoverability.';
if (! is_numeric($backupSetId)) {
return $default;
}
$tenant = static::resolveTenantContextForCurrentPanel();
if (! $tenant instanceof Tenant) {
return $default;
}
$backupSet = BackupSet::query()
->where('tenant_id', (int) $tenant->getKey())
->with([
'items' => fn ($query) => $query->select([
'id',
'backup_set_id',
'payload',
'metadata',
'assignments',
]),
])
->find((int) $backupSetId);
if (! $backupSet instanceof BackupSet) {
return $default;
}
$summary = static::backupSetQualitySummary($backupSet);
return $summary->compactSummary.'. '.$summary->positiveClaimBoundary;
}
private static function restoreItemSelectionLabel(BackupItem $item): string
{
$summary = static::backupItemQualitySummary($item);
return implode(' • ', array_filter([
$item->resolvedDisplayName(),
$summary->compactSummary,
]));
}
private static function restoreItemQualityHelperText(): string
{
return 'Quality hints describe input strength before risk checks. Include foundations with policies when you need ID re-mapping context.';
}
private static function backupSetQualitySummary(BackupSet $backupSet): \App\Support\BackupQuality\BackupQualitySummary
{
return app(BackupQualityResolver::class)->summarizeBackupSet($backupSet);
}
private static function backupItemQualitySummary(BackupItem $backupItem): \App\Support\BackupQuality\BackupQualitySummary
{
return app(BackupQualityResolver::class)->forBackupItem($backupItem);
}
public static function createRestoreRun(array $data): RestoreRun public static function createRestoreRun(array $data): RestoreRun
{ {
$tenant = static::resolveTenantContextForCurrentPanel(); $tenant = static::resolveTenantContextForCurrentPanel();
@ -1471,37 +1667,6 @@ public static function createRestoreRun(array $data): RestoreRun
abort(403); abort(403);
} }
try {
app(WriteGateInterface::class)->evaluate($tenant, 'restore.execute');
} catch (ProviderAccessHardeningRequired $e) {
app(\App\Services\Intune\AuditLogger::class)->log(
tenant: $tenant,
action: 'intune_rbac.write_blocked',
status: 'blocked',
actorId: (int) $user->getKey(),
actorEmail: $user->email,
actorName: $user->name,
resourceType: 'restore_run',
context: [
'metadata' => [
'operation_type' => 'restore.execute',
'reason_code' => $e->reasonCode,
'backup_set_id' => $data['backup_set_id'] ?? null,
],
],
);
Notification::make()
->title('Write operation blocked')
->body($e->reasonMessage)
->danger()
->send();
throw ValidationException::withMessages([
'backup_set_id' => $e->reasonMessage,
]);
}
/** @var BackupSet $backupSet */ /** @var BackupSet $backupSet */
$backupSet = BackupSet::findOrFail($data['backup_set_id']); $backupSet = BackupSet::findOrFail($data['backup_set_id']);
@ -1520,6 +1685,26 @@ public static function createRestoreRun(array $data): RestoreRun
$actorName = auth()->user()?->name; $actorName = auth()->user()?->name;
$isDryRun = (bool) ($data['is_dry_run'] ?? true); $isDryRun = (bool) ($data['is_dry_run'] ?? true);
$groupMapping = static::normalizeGroupMapping($data['group_mapping'] ?? null); $groupMapping = static::normalizeGroupMapping($data['group_mapping'] ?? null);
$data = static::synchronizeRestoreSafetyDraft([
...$data,
'group_mapping' => $groupMapping,
]);
$restoreSafetyResolver = static::restoreSafetyResolver();
$scopeBasis = is_array($data['scope_basis'] ?? null)
? $data['scope_basis']
: $restoreSafetyResolver->scopeBasisFromData($data);
$checkBasis = is_array($data['check_basis'] ?? null)
? $data['check_basis']
: $restoreSafetyResolver->checksBasisFromData($data);
$previewBasis = is_array($data['preview_basis'] ?? null)
? $data['preview_basis']
: $restoreSafetyResolver->previewBasisFromData($data);
$data = [
...$data,
'scope_basis' => $scopeBasis,
'check_basis' => $checkBasis,
'preview_basis' => $previewBasis,
];
$checkSummary = $data['check_summary'] ?? null; $checkSummary = $data['check_summary'] ?? null;
$checkResults = $data['check_results'] ?? null; $checkResults = $data['check_results'] ?? null;
@ -1532,27 +1717,71 @@ public static function createRestoreRun(array $data): RestoreRun
$highlanderLabel = (string) ($tenant->name ?? $tenantIdentifier ?? $tenant->getKey()); $highlanderLabel = (string) ($tenant->name ?? $tenantIdentifier ?? $tenant->getKey());
if (! $isDryRun) { if (! $isDryRun) {
if (! is_array($checkSummary) || ! filled($checksRanAt)) { try {
app(WriteGateInterface::class)->evaluate($tenant, 'restore.execute');
} catch (ProviderAccessHardeningRequired $e) {
app(\App\Services\Intune\AuditLogger::class)->log(
tenant: $tenant,
action: 'intune_rbac.write_blocked',
status: 'blocked',
actorId: (int) $user->getKey(),
actorEmail: $user->email,
actorName: $user->name,
resourceType: 'restore_run',
context: [
'metadata' => [
'operation_type' => 'restore.execute',
'reason_code' => $e->reasonCode,
'backup_set_id' => $data['backup_set_id'] ?? null,
],
],
);
Notification::make()
->title('Write operation blocked')
->body($e->reasonMessage)
->danger()
->send();
throw ValidationException::withMessages([
'backup_set_id' => $e->reasonMessage,
]);
}
$previewIntegrity = $restoreSafetyResolver->previewIntegrityFromData($data);
$checksIntegrity = $restoreSafetyResolver->checksIntegrityFromData($data);
$assessment = $restoreSafetyResolver->safetyAssessment($tenant, $user, $data);
if ($checksIntegrity->state === ChecksIntegrityState::STATE_NOT_RUN) {
throw ValidationException::withMessages([ throw ValidationException::withMessages([
'check_summary' => 'Run safety checks before executing.', 'check_summary' => 'Run safety checks before executing.',
]); ]);
} }
$blocking = (int) ($checkSummary['blocking'] ?? 0); if ($checksIntegrity->state !== ChecksIntegrityState::STATE_CURRENT) {
$hasBlockers = (bool) ($checkSummary['has_blockers'] ?? ($blocking > 0)); throw ValidationException::withMessages([
'check_summary' => 'Run safety checks again for the current scope before executing.',
]);
}
if ($blocking > 0 || $hasBlockers) { if ($checksIntegrity->blockingCount > 0 || $assessment->state === 'blocked') {
throw ValidationException::withMessages([ throw ValidationException::withMessages([
'check_summary' => 'Blocking checks must be resolved before executing.', 'check_summary' => 'Blocking checks must be resolved before executing.',
]); ]);
} }
if (! filled($previewRanAt)) { if ($previewIntegrity->state === PreviewIntegrityState::STATE_NOT_GENERATED) {
throw ValidationException::withMessages([ throw ValidationException::withMessages([
'preview_ran_at' => 'Generate preview before executing.', 'preview_ran_at' => 'Generate preview before executing.',
]); ]);
} }
if ($previewIntegrity->state !== PreviewIntegrityState::STATE_CURRENT) {
throw ValidationException::withMessages([
'preview_ran_at' => 'Generate preview again for the current scope before executing.',
]);
}
if (! (bool) ($data['acknowledged_impact'] ?? false)) { if (! (bool) ($data['acknowledged_impact'] ?? false)) {
throw ValidationException::withMessages([ throw ValidationException::withMessages([
'acknowledged_impact' => 'Please acknowledge that you reviewed the impact.', 'acknowledged_impact' => 'Please acknowledge that you reviewed the impact.',
@ -1605,6 +1834,16 @@ public static function createRestoreRun(array $data): RestoreRun
$metadata['preview_ran_at'] = $previewRanAt; $metadata['preview_ran_at'] = $previewRanAt;
} }
$metadata['scope_basis'] = $scopeBasis;
if (is_array($checkBasis)) {
$metadata['check_basis'] = $checkBasis;
}
if (is_array($previewBasis)) {
$metadata['preview_basis'] = $previewBasis;
}
$restoreRun->update(['metadata' => $metadata]); $restoreRun->update(['metadata' => $metadata]);
return $restoreRun->refresh(); return $restoreRun->refresh();
@ -1619,6 +1858,7 @@ public static function createRestoreRun(array $data): RestoreRun
'confirmed_at' => now()->toIso8601String(), 'confirmed_at' => now()->toIso8601String(),
'confirmed_by' => $actorEmail, 'confirmed_by' => $actorEmail,
'confirmed_by_name' => $actorName, 'confirmed_by_name' => $actorName,
'scope_basis' => $scopeBasis,
]; ];
if (is_array($checkSummary)) { if (is_array($checkSummary)) {
@ -1645,6 +1885,18 @@ public static function createRestoreRun(array $data): RestoreRun
$metadata['preview_ran_at'] = $previewRanAt; $metadata['preview_ran_at'] = $previewRanAt;
} }
if (is_array($checkBasis)) {
$metadata['check_basis'] = $checkBasis;
}
if (is_array($previewBasis)) {
$metadata['preview_basis'] = $previewBasis;
}
$metadata['execution_safety_snapshot'] = $restoreSafetyResolver
->executionSafetySnapshot($tenant, $user, $data)
->toArray();
$idempotencyKey = RestoreRunIdempotency::restoreExecuteKey( $idempotencyKey = RestoreRunIdempotency::restoreExecuteKey(
tenantId: (int) $tenant->getKey(), tenantId: (int) $tenant->getKey(),
backupSetId: (int) $backupSet->getKey(), backupSetId: (int) $backupSet->getKey(),
@ -1768,6 +2020,145 @@ public static function createRestoreRun(array $data): RestoreRun
return $restoreRun->refresh(); return $restoreRun->refresh();
} }
/**
* @param array<string, mixed> $data
* @return array<string, mixed>
*/
public static function synchronizeRestoreSafetyDraft(array $data): array
{
$resolver = static::restoreSafetyResolver();
$scope = $resolver->scopeFingerprintFromData($data);
$data['scope_basis'] = $resolver->scopeBasisFromData($data);
$data['check_invalidation_reasons'] = $resolver->invalidationReasonsForBasis(
currentScope: $scope,
basis: is_array($data['check_basis'] ?? null) ? $data['check_basis'] : null,
explicitReasons: $data['check_invalidation_reasons'] ?? null,
);
$data['preview_invalidation_reasons'] = $resolver->invalidationReasonsForBasis(
currentScope: $scope,
basis: is_array($data['preview_basis'] ?? null) ? $data['preview_basis'] : null,
explicitReasons: $data['preview_invalidation_reasons'] ?? null,
);
return $data;
}
/**
* @param array<string, mixed> $data
* @return array<string, mixed>
*/
private static function wizardSafetyState(array $data): array
{
$data = static::synchronizeRestoreSafetyDraft($data);
$resolver = static::restoreSafetyResolver();
$tenant = static::resolveTenantContextForCurrentPanel();
$user = auth()->user();
$scope = $resolver->scopeFingerprintFromData($data)->toArray();
$previewIntegrity = $resolver->previewIntegrityFromData($data);
$checksIntegrity = $resolver->checksIntegrityFromData($data);
if (! $tenant instanceof Tenant || ! $user instanceof User) {
return [
'currentScope' => $scope,
'previewIntegrity' => $previewIntegrity->toArray(),
'checksIntegrity' => $checksIntegrity->toArray(),
'executionReadiness' => null,
'safetyAssessment' => null,
];
}
$assessment = $resolver->safetyAssessment($tenant, $user, $data);
return [
'currentScope' => $scope,
'previewIntegrity' => $previewIntegrity->toArray(),
'checksIntegrity' => $checksIntegrity->toArray(),
'executionReadiness' => $assessment->executionReadiness->toArray(),
'safetyAssessment' => $assessment->toArray(),
];
}
/**
* @return array<string, mixed>
*/
private static function draftDataSnapshot(Get $get): array
{
return [
'backup_set_id' => $get('backup_set_id'),
'scope_mode' => $get('scope_mode'),
'backup_item_ids' => $get('backup_item_ids'),
'group_mapping' => static::normalizeGroupMapping($get('group_mapping')),
'check_summary' => $get('check_summary'),
'check_results' => $get('check_results'),
'checks_ran_at' => $get('checks_ran_at'),
'check_basis' => $get('check_basis'),
'check_invalidation_reasons' => $get('check_invalidation_reasons'),
'preview_summary' => $get('preview_summary'),
'preview_diffs' => $get('preview_diffs'),
'preview_ran_at' => $get('preview_ran_at'),
'preview_basis' => $get('preview_basis'),
'preview_invalidation_reasons' => $get('preview_invalidation_reasons'),
'scope_basis' => $get('scope_basis'),
'is_dry_run' => $get('is_dry_run'),
];
}
/**
* @return array{
* preview: array<int|string, mixed>,
* previewIntegrity: array<string, mixed>,
* checksIntegrity: array<string, mixed>,
* executionSafetySnapshot: array<string, mixed>,
* scopeBasis: array<string, mixed>
* }
*/
private static function detailPreviewState(RestoreRun $record): array
{
$resolver = static::restoreSafetyResolver();
$data = [
'backup_set_id' => $record->backup_set_id,
'scope_mode' => (string) (($record->scopeBasis()['scope_mode'] ?? null) ?: ((is_array($record->requested_items) && $record->requested_items !== []) ? 'selected' : 'all')),
'backup_item_ids' => is_array($record->requested_items) ? $record->requested_items : [],
'group_mapping' => is_array($record->group_mapping) ? $record->group_mapping : [],
'preview_basis' => $record->previewBasis(),
'check_basis' => $record->checkBasis(),
'check_summary' => is_array(($record->metadata ?? [])['check_summary'] ?? null) ? $record->metadata['check_summary'] : [],
'checks_ran_at' => $record->checkBasis()['ran_at'] ?? (($record->metadata ?? [])['checks_ran_at'] ?? null),
'preview_summary' => is_array(($record->metadata ?? [])['preview_summary'] ?? null) ? $record->metadata['preview_summary'] : [],
'preview_ran_at' => $record->previewBasis()['generated_at'] ?? (($record->metadata ?? [])['preview_ran_at'] ?? null),
];
return [
'preview' => is_array($record->preview) ? $record->preview : [],
'previewIntegrity' => $resolver->previewIntegrityFromData($data)->toArray(),
'checksIntegrity' => $resolver->checksIntegrityFromData($data)->toArray(),
'executionSafetySnapshot' => $record->executionSafetySnapshot(),
'scopeBasis' => $record->scopeBasis(),
];
}
/**
* @return array{
* results: array<string, mixed>|array<int|string, mixed>,
* resultAttention: array<string, mixed>,
* executionSafetySnapshot: array<string, mixed>
* }
*/
private static function detailResultsState(RestoreRun $record): array
{
return [
'results' => is_array($record->results) ? $record->results : [],
'resultAttention' => static::restoreSafetyResolver()->resultAttentionForRun($record)->toArray(),
'executionSafetySnapshot' => $record->executionSafetySnapshot(),
];
}
private static function restoreSafetyResolver(): RestoreSafetyResolver
{
return app(RestoreSafetyResolver::class);
}
/** /**
* @param array<int>|null $selectedItemIds * @param array<int>|null $selectedItemIds
* @return array<int, array{id:string,label:string}> * @return array<int, array{id:string,label:string}>

View File

@ -96,6 +96,9 @@ protected function afterFill(): void
$this->form->callAfterStateUpdated('data.backup_item_ids'); $this->form->callAfterStateUpdated('data.backup_item_ids');
} }
$this->data = RestoreRunResource::synchronizeRestoreSafetyDraft($this->data);
$this->form->fill($this->data);
} }
/** /**
@ -151,13 +154,10 @@ protected function handleRecordCreation(array $data): Model
public function applyEntraGroupCachePick(string $sourceGroupId, string $entraId): void public function applyEntraGroupCachePick(string $sourceGroupId, string $entraId): void
{ {
data_set($this->data, "group_mapping.{$sourceGroupId}", $entraId); data_set($this->data, "group_mapping.{$sourceGroupId}", $entraId);
$this->data['is_dry_run'] = true;
$this->data['check_summary'] = null; $this->data['acknowledged_impact'] = false;
$this->data['check_results'] = []; $this->data['tenant_confirm'] = null;
$this->data['checks_ran_at'] = null; $this->data = RestoreRunResource::synchronizeRestoreSafetyDraft($this->data);
$this->data['preview_summary'] = null;
$this->data['preview_diffs'] = [];
$this->data['preview_ran_at'] = null;
$this->form->fill($this->data); $this->form->fill($this->data);

View File

@ -4,18 +4,25 @@
namespace App\Filament\Widgets\Dashboard; namespace App\Filament\Widgets\Dashboard;
use App\Filament\Resources\BackupScheduleResource;
use App\Filament\Resources\BackupSetResource;
use App\Filament\Resources\FindingResource; use App\Filament\Resources\FindingResource;
use App\Models\Finding; use App\Models\Finding;
use App\Models\OperationRun; use App\Models\OperationRun;
use App\Models\Tenant; use App\Models\Tenant;
use App\Models\User; use App\Models\User;
use App\Support\Auth\Capabilities; use App\Support\Auth\Capabilities;
use App\Support\BackupHealth\BackupHealthActionTarget;
use App\Support\BackupHealth\TenantBackupHealthAssessment;
use App\Support\BackupHealth\TenantBackupHealthResolver;
use App\Support\OperationRunLinks; use App\Support\OperationRunLinks;
use App\Support\OpsUx\ActiveRuns; use App\Support\OpsUx\ActiveRuns;
use App\Support\Rbac\UiTooltips; use App\Support\Rbac\UiTooltips;
use Filament\Facades\Filament; use Filament\Facades\Filament;
use Filament\Widgets\StatsOverviewWidget; use Filament\Widgets\StatsOverviewWidget;
use Filament\Widgets\StatsOverviewWidget\Stat; use Filament\Widgets\StatsOverviewWidget\Stat;
use Illuminate\Database\Eloquent\ModelNotFoundException;
use Illuminate\Support\Str;
class DashboardKpis extends StatsOverviewWidget class DashboardKpis extends StatsOverviewWidget
{ {
@ -38,6 +45,8 @@ protected function getStats(): array
} }
$tenantId = (int) $tenant->getKey(); $tenantId = (int) $tenant->getKey();
$backupHealth = $this->backupHealthAssessment($tenant);
$backupHealthAction = $this->resolveBackupHealthAction($tenant, $backupHealth->primaryActionTarget);
$openDriftFindings = (int) Finding::query() $openDriftFindings = (int) Finding::query()
->where('tenant_id', $tenantId) ->where('tenant_id', $tenantId)
@ -79,6 +88,10 @@ protected function getStats(): array
$findingsHelperText = $this->findingsHelperText($tenant); $findingsHelperText = $this->findingsHelperText($tenant);
return [ return [
Stat::make('Backup posture', Str::headline($backupHealth->posture))
->description($this->backupHealthDescription($backupHealth, $backupHealthAction['helperText']))
->color($backupHealth->tone())
->url($backupHealthAction['actionUrl']),
Stat::make('Open drift findings', $openDriftFindings) Stat::make('Open drift findings', $openDriftFindings)
->description($openDriftUrl === null && $openDriftFindings > 0 ->description($openDriftUrl === null && $openDriftFindings > 0
? $findingsHelperText ? $findingsHelperText
@ -124,6 +137,7 @@ protected function getStats(): array
private function emptyStats(): array private function emptyStats(): array
{ {
return [ return [
Stat::make('Backup posture', '—'),
Stat::make('Open drift findings', 0), Stat::make('Open drift findings', 0),
Stat::make('High severity active findings', 0), Stat::make('High severity active findings', 0),
Stat::make('Active operations', 0), Stat::make('Active operations', 0),
@ -159,4 +173,106 @@ private function canOpenFindings(Tenant $tenant): bool
&& $user->canAccessTenant($tenant) && $user->canAccessTenant($tenant)
&& $user->can(Capabilities::TENANT_FINDINGS_VIEW, $tenant); && $user->can(Capabilities::TENANT_FINDINGS_VIEW, $tenant);
} }
private function backupHealthAssessment(Tenant $tenant): TenantBackupHealthAssessment
{
/** @var TenantBackupHealthResolver $resolver */
$resolver = app(TenantBackupHealthResolver::class);
return $resolver->assess($tenant);
}
/**
* @return array{actionUrl: string|null, helperText: string|null}
*/
private function resolveBackupHealthAction(Tenant $tenant, ?BackupHealthActionTarget $target): array
{
if (! $target instanceof BackupHealthActionTarget) {
return [
'actionUrl' => null,
'helperText' => null,
];
}
if (! $this->canOpenBackupSurfaces($tenant)) {
return [
'actionUrl' => null,
'helperText' => UiTooltips::INSUFFICIENT_PERMISSION,
];
}
return match ($target->surface) {
BackupHealthActionTarget::SURFACE_BACKUP_SETS_INDEX => [
'actionUrl' => BackupSetResource::getUrl('index', [
'backup_health_reason' => $target->reason,
], panel: 'tenant', tenant: $tenant),
'helperText' => null,
],
BackupHealthActionTarget::SURFACE_BACKUP_SCHEDULES_INDEX => [
'actionUrl' => BackupScheduleResource::getUrl('index', [
'backup_health_reason' => $target->reason,
], panel: 'tenant', tenant: $tenant),
'helperText' => null,
],
BackupHealthActionTarget::SURFACE_BACKUP_SET_VIEW => $this->resolveBackupSetAction($tenant, $target),
default => [
'actionUrl' => null,
'helperText' => null,
],
};
}
/**
* @return array{actionUrl: string|null, helperText: string|null}
*/
private function resolveBackupSetAction(Tenant $tenant, BackupHealthActionTarget $target): array
{
if (! is_numeric($target->recordId)) {
return [
'actionUrl' => BackupSetResource::getUrl('index', [
'backup_health_reason' => $target->reason,
], panel: 'tenant', tenant: $tenant),
'helperText' => 'The latest backup detail is no longer available.',
];
}
try {
BackupSetResource::resolveScopedRecordOrFail($target->recordId);
return [
'actionUrl' => BackupSetResource::getUrl('view', [
'record' => $target->recordId,
'backup_health_reason' => $target->reason,
], panel: 'tenant', tenant: $tenant),
'helperText' => null,
];
} catch (ModelNotFoundException) {
return [
'actionUrl' => BackupSetResource::getUrl('index', [
'backup_health_reason' => $target->reason,
], panel: 'tenant', tenant: $tenant),
'helperText' => 'The latest backup detail is no longer available.',
];
}
}
private function backupHealthDescription(TenantBackupHealthAssessment $assessment, ?string $helperText): string
{
$description = $assessment->supportingMessage ?? $assessment->headline;
if ($helperText === null) {
return $description;
}
return trim($description.' '.$helperText);
}
private function canOpenBackupSurfaces(Tenant $tenant): bool
{
$user = auth()->user();
return $user instanceof User
&& $user->canAccessTenant($tenant)
&& $user->can(Capabilities::TENANT_VIEW, $tenant);
}
} }

View File

@ -5,12 +5,18 @@
namespace App\Filament\Widgets\Dashboard; namespace App\Filament\Widgets\Dashboard;
use App\Filament\Pages\BaselineCompareLanding; use App\Filament\Pages\BaselineCompareLanding;
use App\Filament\Resources\BackupScheduleResource;
use App\Filament\Resources\BackupSetResource;
use App\Filament\Resources\FindingResource; use App\Filament\Resources\FindingResource;
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\User; use App\Models\User;
use App\Support\Auth\Capabilities; use App\Support\Auth\Capabilities;
use App\Support\BackupHealth\BackupHealthActionTarget;
use App\Support\BackupHealth\BackupHealthDashboardSignal;
use App\Support\BackupHealth\TenantBackupHealthAssessment;
use App\Support\BackupHealth\TenantBackupHealthResolver;
use App\Support\Baselines\TenantGovernanceAggregate; use App\Support\Baselines\TenantGovernanceAggregate;
use App\Support\Baselines\TenantGovernanceAggregateResolver; use App\Support\Baselines\TenantGovernanceAggregateResolver;
use App\Support\OperationRunLinks; use App\Support\OperationRunLinks;
@ -18,6 +24,7 @@
use App\Support\Rbac\UiTooltips; use App\Support\Rbac\UiTooltips;
use Filament\Facades\Filament; use Filament\Facades\Filament;
use Filament\Widgets\Widget; use Filament\Widgets\Widget;
use Illuminate\Database\Eloquent\ModelNotFoundException;
class NeedsAttention extends Widget class NeedsAttention extends Widget
{ {
@ -41,9 +48,17 @@ protected function getViewData(): array
$tenantId = (int) $tenant->getKey(); $tenantId = (int) $tenant->getKey();
$aggregate = $this->governanceAggregate($tenant); $aggregate = $this->governanceAggregate($tenant);
$compareAssessment = $aggregate->summaryAssessment; $compareAssessment = $aggregate->summaryAssessment;
$backupHealth = $this->backupHealthAssessment($tenant);
$items = []; $items = [];
if (($backupHealthItem = $this->backupHealthAttentionItem($tenant, $backupHealth)) instanceof BackupHealthDashboardSignal) {
$items[] = array_merge(
$backupHealthItem->toArray(),
$this->backupHealthActionPayload($tenant, $backupHealthItem->actionTarget, $backupHealthItem->actionLabel)
);
}
$overdueOpenCount = $aggregate->overdueOpenFindingsCount; $overdueOpenCount = $aggregate->overdueOpenFindingsCount;
$lapsedGovernanceCount = $aggregate->lapsedGovernanceCount; $lapsedGovernanceCount = $aggregate->lapsedGovernanceCount;
$expiringGovernanceCount = $aggregate->expiringGovernanceCount; $expiringGovernanceCount = $aggregate->expiringGovernanceCount;
@ -179,6 +194,7 @@ protected function getViewData(): array
if ($items === []) { if ($items === []) {
$healthyChecks = [ $healthyChecks = [
...array_filter([$this->backupHealthHealthyCheck($backupHealth)]),
[ [
'title' => 'Baseline compare looks trustworthy', 'title' => 'Baseline compare looks trustworthy',
'body' => $aggregate->headline, 'body' => $aggregate->headline,
@ -251,4 +267,154 @@ private function governanceAggregate(Tenant $tenant): TenantGovernanceAggregate
return $aggregate; return $aggregate;
} }
private function backupHealthAssessment(Tenant $tenant): TenantBackupHealthAssessment
{
/** @var TenantBackupHealthResolver $resolver */
$resolver = app(TenantBackupHealthResolver::class);
return $resolver->assess($tenant);
}
private function backupHealthAttentionItem(Tenant $tenant, TenantBackupHealthAssessment $assessment): ?BackupHealthDashboardSignal
{
if (! $assessment->hasActiveReason()) {
return null;
}
return new BackupHealthDashboardSignal(
title: $this->backupHealthAttentionTitle($assessment),
body: $assessment->supportingMessage ?? $assessment->headline,
tone: $assessment->tone(),
badge: 'Backups',
badgeColor: $assessment->tone(),
actionTarget: $assessment->primaryActionTarget,
actionLabel: $assessment->primaryActionTarget?->label,
);
}
/**
* @return array{title: string, body: string}|null
*/
private function backupHealthHealthyCheck(TenantBackupHealthAssessment $assessment): ?array
{
if (! $assessment->healthyClaimAllowed) {
return null;
}
return [
'title' => 'Backups are recent and healthy',
'body' => $assessment->supportingMessage ?? 'The latest completed backup is recent and shows no material degradation.',
];
}
/**
* @return array{actionLabel: string|null, actionUrl: string|null, actionDisabled: bool, helperText: string|null}
*/
private function backupHealthActionPayload(Tenant $tenant, ?BackupHealthActionTarget $target, ?string $label): array
{
if (! $target instanceof BackupHealthActionTarget) {
return [
'actionLabel' => $label,
'actionUrl' => null,
'actionDisabled' => false,
'helperText' => null,
];
}
if (! $this->canOpenBackupSurfaces($tenant)) {
return [
'actionLabel' => $label ?? $target->label,
'actionUrl' => null,
'actionDisabled' => true,
'helperText' => UiTooltips::INSUFFICIENT_PERMISSION,
];
}
return match ($target->surface) {
BackupHealthActionTarget::SURFACE_BACKUP_SETS_INDEX => [
'actionLabel' => $label ?? $target->label,
'actionUrl' => BackupSetResource::getUrl('index', [
'backup_health_reason' => $target->reason,
], panel: 'tenant', tenant: $tenant),
'actionDisabled' => false,
'helperText' => null,
],
BackupHealthActionTarget::SURFACE_BACKUP_SCHEDULES_INDEX => [
'actionLabel' => $label ?? $target->label,
'actionUrl' => BackupScheduleResource::getUrl('index', [
'backup_health_reason' => $target->reason,
], panel: 'tenant', tenant: $tenant),
'actionDisabled' => false,
'helperText' => null,
],
BackupHealthActionTarget::SURFACE_BACKUP_SET_VIEW => $this->backupHealthBackupSetActionPayload($tenant, $target, $label),
default => [
'actionLabel' => $label,
'actionUrl' => null,
'actionDisabled' => false,
'helperText' => null,
],
};
}
/**
* @return array{actionLabel: string|null, actionUrl: string|null, actionDisabled: bool, helperText: string|null}
*/
private function backupHealthBackupSetActionPayload(Tenant $tenant, BackupHealthActionTarget $target, ?string $label): array
{
if (! is_numeric($target->recordId)) {
return [
'actionLabel' => 'Open backup sets',
'actionUrl' => BackupSetResource::getUrl('index', [
'backup_health_reason' => $target->reason,
], panel: 'tenant', tenant: $tenant),
'actionDisabled' => false,
'helperText' => 'The latest backup detail is no longer available.',
];
}
try {
BackupSetResource::resolveScopedRecordOrFail($target->recordId);
return [
'actionLabel' => $label ?? $target->label,
'actionUrl' => BackupSetResource::getUrl('view', [
'record' => $target->recordId,
'backup_health_reason' => $target->reason,
], panel: 'tenant', tenant: $tenant),
'actionDisabled' => false,
'helperText' => null,
];
} catch (ModelNotFoundException) {
return [
'actionLabel' => 'Open backup sets',
'actionUrl' => BackupSetResource::getUrl('index', [
'backup_health_reason' => $target->reason,
], panel: 'tenant', tenant: $tenant),
'actionDisabled' => false,
'helperText' => 'The latest backup detail is no longer available.',
];
}
}
private function backupHealthAttentionTitle(TenantBackupHealthAssessment $assessment): string
{
return match ($assessment->primaryReason) {
TenantBackupHealthAssessment::REASON_NO_BACKUP_BASIS => 'No usable backup basis',
TenantBackupHealthAssessment::REASON_LATEST_BACKUP_STALE => 'Latest backup is stale',
TenantBackupHealthAssessment::REASON_LATEST_BACKUP_DEGRADED => 'Latest backup is degraded',
TenantBackupHealthAssessment::REASON_SCHEDULE_FOLLOW_UP => 'Backup schedules need follow-up',
default => $assessment->headline,
};
}
private function canOpenBackupSurfaces(Tenant $tenant): bool
{
$user = auth()->user();
return $user instanceof User
&& $user->canAccessTenant($tenant)
&& $user->can(Capabilities::TENANT_VIEW, $tenant);
}
} }

View File

@ -86,6 +86,63 @@ public function assignmentsFetchFailed(): bool
return $this->metadata['assignments_fetch_failed'] ?? false; return $this->metadata['assignments_fetch_failed'] ?? false;
} }
public function assignmentCaptureReason(): ?string
{
$reason = $this->metadata['assignment_capture_reason'] ?? null;
return is_string($reason) && trim($reason) !== ''
? trim($reason)
: null;
}
public function snapshotSource(): ?string
{
$source = $this->metadata['snapshot_source']
?? $this->metadata['source']
?? null;
return is_string($source) && trim($source) !== ''
? trim($source)
: null;
}
/**
* @return list<string>
*/
public function warningMessages(): array
{
$warnings = $this->metadata['warnings'] ?? [];
if (! is_array($warnings)) {
return [];
}
return collect($warnings)
->filter(fn (mixed $warning): bool => is_string($warning) && trim($warning) !== '')
->map(fn (string $warning): string => trim($warning))
->values()
->all();
}
public function integrityWarning(): ?string
{
$warning = $this->metadata['integrity_warning'] ?? null;
return is_string($warning) && trim($warning) !== ''
? trim($warning)
: null;
}
public function protectedPathsCount(): int
{
return max(0, (int) ($this->metadata['protected_paths_count'] ?? 0));
}
public function hasCapturedPayload(): bool
{
return is_array($this->payload) && $this->payload !== [];
}
public function isFoundation(): bool public function isFoundation(): bool
{ {
$types = array_column(config('tenantpilot.foundation_types', []), 'type'); $types = array_column(config('tenantpilot.foundation_types', []), 'type');

View File

@ -4,6 +4,7 @@
use App\Support\Baselines\PolicyVersionCapturePurpose; use App\Support\Baselines\PolicyVersionCapturePurpose;
use App\Support\Concerns\DerivesWorkspaceIdFromTenant; use App\Support\Concerns\DerivesWorkspaceIdFromTenant;
use App\Support\RedactionIntegrity;
use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\BelongsTo;
@ -59,6 +60,55 @@ public function baselineProfile(): BelongsTo
return $this->belongsTo(BaselineProfile::class); return $this->belongsTo(BaselineProfile::class);
} }
public function snapshotSource(): ?string
{
$source = $this->metadata['source']
?? $this->metadata['snapshot_source']
?? null;
return is_string($source) && trim($source) !== ''
? trim($source)
: null;
}
/**
* @return list<string>
*/
public function warningMessages(): array
{
$warnings = $this->metadata['warnings'] ?? [];
if (! is_array($warnings)) {
return [];
}
return collect($warnings)
->filter(fn (mixed $warning): bool => is_string($warning) && trim($warning) !== '')
->map(fn (string $warning): string => trim($warning))
->values()
->all();
}
public function assignmentsFetchFailed(): bool
{
return (bool) ($this->metadata['assignments_fetch_failed'] ?? false);
}
public function hasOrphanedAssignments(): bool
{
return (bool) ($this->metadata['has_orphaned_assignments'] ?? false);
}
public function integrityWarning(): ?string
{
return RedactionIntegrity::noteForPolicyVersion($this);
}
public function hasCapturedPayload(): bool
{
return is_array($this->snapshot) && $this->snapshot !== [];
}
public function scopePruneEligible($query, int $days = 90) public function scopePruneEligible($query, int $days = 90)
{ {
return $query return $query

View File

@ -156,4 +156,46 @@ public function getSkippedAssignmentsCount(): int
static fn (mixed $outcome): bool => is_array($outcome) && ($outcome['status'] ?? null) === 'skipped' static fn (mixed $outcome): bool => is_array($outcome) && ($outcome['status'] ?? null) === 'skipped'
)); ));
} }
/**
* @return array<string, mixed>
*/
public function scopeBasis(): array
{
$metadata = is_array($this->metadata) ? $this->metadata : [];
return is_array($metadata['scope_basis'] ?? null) ? $metadata['scope_basis'] : [];
}
/**
* @return array<string, mixed>
*/
public function checkBasis(): array
{
$metadata = is_array($this->metadata) ? $this->metadata : [];
return is_array($metadata['check_basis'] ?? null) ? $metadata['check_basis'] : [];
}
/**
* @return array<string, mixed>
*/
public function previewBasis(): array
{
$metadata = is_array($this->metadata) ? $this->metadata : [];
return is_array($metadata['preview_basis'] ?? null) ? $metadata['preview_basis'] : [];
}
/**
* @return array<string, mixed>
*/
public function executionSafetySnapshot(): array
{
$metadata = is_array($this->metadata) ? $this->metadata : [];
return is_array($metadata['execution_safety_snapshot'] ?? null)
? $metadata['execution_safety_snapshot']
: [];
}
} }

View File

@ -52,6 +52,12 @@ public function can(User $user, Tenant $tenant, string $capability): bool
return false; return false;
} }
if ($this->isLocallyDeniedByBackupHealthBrowserFixture($user, $tenant, $capability)) {
$this->logDenial($user, $tenant, $capability);
return false;
}
$allowed = RoleCapabilityMap::hasCapability($role, $capability); $allowed = RoleCapabilityMap::hasCapability($role, $capability);
if (! $allowed) { if (! $allowed) {
@ -61,6 +67,39 @@ public function can(User $user, Tenant $tenant, string $capability): bool
return $allowed; return $allowed;
} }
private function isLocallyDeniedByBackupHealthBrowserFixture(User $user, Tenant $tenant, string $capability): bool
{
if (! app()->environment(['local', 'testing'])) {
return false;
}
$fixture = config('tenantpilot.backup_health.browser_smoke_fixture.blocked_drillthrough');
if (! is_array($fixture)) {
return false;
}
$fixtureUserEmail = config('tenantpilot.backup_health.browser_smoke_fixture.user.email');
if (! is_string($fixtureUserEmail) || $fixtureUserEmail === '' || $user->email !== $fixtureUserEmail) {
return false;
}
$fixtureTenantExternalId = $fixture['tenant_external_id'] ?? null;
if (! is_string($fixtureTenantExternalId) || $fixtureTenantExternalId === '' || $tenant->external_id !== $fixtureTenantExternalId) {
return false;
}
$deniedCapabilities = $fixture['capability_denials'] ?? [];
if (! is_array($deniedCapabilities)) {
return false;
}
return in_array($capability, $deniedCapabilities, true);
}
private function logDenial(User $user, Tenant $tenant, string $capability): void private function logDenial(User $user, Tenant $tenant, string $capability): void
{ {
$key = implode(':', [(string) $user->getKey(), (string) $tenant->getKey(), $capability]); $key = implode(':', [(string) $user->getKey(), (string) $tenant->getKey(), $capability]);

View File

@ -0,0 +1,17 @@
<?php
declare(strict_types=1);
namespace App\Support\BackupHealth;
use Carbon\CarbonInterface;
final readonly class BackupFreshnessEvaluation
{
public function __construct(
public ?CarbonInterface $latestCompletedAt,
public CarbonInterface $cutoffAt,
public bool $isFresh,
public string $policySource = 'configured_window',
) {}
}

View File

@ -0,0 +1,51 @@
<?php
declare(strict_types=1);
namespace App\Support\BackupHealth;
final readonly class BackupHealthActionTarget
{
public const SURFACE_BACKUP_SETS_INDEX = 'backup_sets_index';
public const SURFACE_BACKUP_SET_VIEW = 'backup_set_view';
public const SURFACE_BACKUP_SCHEDULES_INDEX = 'backup_schedules_index';
public function __construct(
public string $surface,
public ?int $recordId,
public string $label,
public string $reason,
) {}
public static function backupSetsIndex(string $reason, string $label = 'Open backup sets'): self
{
return new self(
surface: self::SURFACE_BACKUP_SETS_INDEX,
recordId: null,
label: $label,
reason: $reason,
);
}
public static function backupSetView(int $recordId, string $reason, string $label = 'Open latest backup'): self
{
return new self(
surface: self::SURFACE_BACKUP_SET_VIEW,
recordId: $recordId,
label: $label,
reason: $reason,
);
}
public static function backupSchedulesIndex(string $reason, string $label = 'Open backup schedules'): self
{
return new self(
surface: self::SURFACE_BACKUP_SCHEDULES_INDEX,
recordId: null,
label: $label,
reason: $reason,
);
}
}

View File

@ -0,0 +1,47 @@
<?php
declare(strict_types=1);
namespace App\Support\BackupHealth;
final readonly class BackupHealthDashboardSignal
{
public function __construct(
public string $title,
public string $body,
public string $tone,
public ?string $badge = null,
public ?string $badgeColor = null,
public ?BackupHealthActionTarget $actionTarget = null,
public bool $actionDisabled = false,
public ?string $helperText = null,
public ?string $actionLabel = null,
public ?string $supportingMessage = null,
) {}
/**
* @return array{
* title: string,
* body: string,
* badge: string|null,
* badgeColor: string|null,
* actionLabel: string|null,
* actionDisabled: bool,
* helperText: string|null,
* supportingMessage: string|null
* }
*/
public function toArray(): array
{
return [
'title' => $this->title,
'body' => $this->body,
'badge' => $this->badge,
'badgeColor' => $this->badgeColor,
'actionLabel' => $this->actionLabel,
'actionDisabled' => $this->actionDisabled,
'helperText' => $this->helperText,
'supportingMessage' => $this->supportingMessage,
];
}
}

View File

@ -0,0 +1,19 @@
<?php
declare(strict_types=1);
namespace App\Support\BackupHealth;
final readonly class BackupScheduleFollowUpEvaluation
{
public function __construct(
public bool $hasEnabledSchedules,
public int $enabledScheduleCount,
public int $overdueScheduleCount,
public int $failedRecentRunCount,
public int $neverSuccessfulCount,
public bool $needsFollowUp,
public ?int $primaryScheduleId,
public ?string $summaryMessage,
) {}
}

View File

@ -0,0 +1,60 @@
<?php
declare(strict_types=1);
namespace App\Support\BackupHealth;
use App\Support\BackupQuality\BackupQualitySummary;
use Carbon\CarbonInterface;
final readonly class TenantBackupHealthAssessment
{
public const POSTURE_ABSENT = 'absent';
public const POSTURE_STALE = 'stale';
public const POSTURE_DEGRADED = 'degraded';
public const POSTURE_HEALTHY = 'healthy';
public const REASON_NO_BACKUP_BASIS = 'no_backup_basis';
public const REASON_LATEST_BACKUP_STALE = 'latest_backup_stale';
public const REASON_LATEST_BACKUP_DEGRADED = 'latest_backup_degraded';
public const REASON_SCHEDULE_FOLLOW_UP = 'schedule_follow_up';
public function __construct(
public int $tenantId,
public string $posture,
public ?string $primaryReason,
public string $headline,
public ?string $supportingMessage,
public ?int $latestRelevantBackupSetId,
public ?CarbonInterface $latestRelevantCompletedAt,
public ?BackupQualitySummary $qualitySummary,
public BackupFreshnessEvaluation $freshnessEvaluation,
public BackupScheduleFollowUpEvaluation $scheduleFollowUp,
public bool $healthyClaimAllowed,
public ?BackupHealthActionTarget $primaryActionTarget,
public string $positiveClaimBoundary,
) {}
public function hasActiveReason(): bool
{
return $this->primaryReason !== null;
}
public function tone(): string
{
return match ($this->primaryReason ?? $this->posture) {
self::REASON_NO_BACKUP_BASIS => 'danger',
self::REASON_LATEST_BACKUP_STALE => 'warning',
self::REASON_LATEST_BACKUP_DEGRADED => 'warning',
self::REASON_SCHEDULE_FOLLOW_UP => 'warning',
self::POSTURE_HEALTHY => 'success',
default => 'gray',
};
}
}

View File

@ -0,0 +1,321 @@
<?php
declare(strict_types=1);
namespace App\Support\BackupHealth;
use App\Models\BackupSchedule;
use App\Models\BackupSet;
use App\Models\Tenant;
use App\Support\BackupQuality\BackupQualityResolver;
use Carbon\CarbonImmutable;
use Carbon\CarbonInterface;
use Illuminate\Support\Arr;
final class TenantBackupHealthResolver
{
private const POSITIVE_CLAIM_BOUNDARY = 'Backup health reflects backup inputs only and does not prove restore success.';
public function __construct(
private readonly BackupQualityResolver $backupQualityResolver,
) {}
public function assess(Tenant|int $tenant): TenantBackupHealthAssessment
{
$tenantId = $tenant instanceof Tenant
? (int) $tenant->getKey()
: (int) $tenant;
$now = CarbonImmutable::now('UTC');
$latestBackupSet = $this->latestRelevantBackupSet($tenantId);
$qualitySummary = $latestBackupSet instanceof BackupSet
? $this->backupQualityResolver->summarizeBackupSet($latestBackupSet)
: null;
$freshnessEvaluation = $this->freshnessEvaluation($latestBackupSet?->completed_at, $now);
$scheduleFollowUp = $this->scheduleFollowUpEvaluation($tenantId, $now);
if (! $latestBackupSet instanceof BackupSet) {
return new TenantBackupHealthAssessment(
tenantId: $tenantId,
posture: TenantBackupHealthAssessment::POSTURE_ABSENT,
primaryReason: TenantBackupHealthAssessment::REASON_NO_BACKUP_BASIS,
headline: 'No usable backup basis is available.',
supportingMessage: $this->combineMessages([
'Create or finish a backup set before relying on restore input.',
$scheduleFollowUp->summaryMessage,
]),
latestRelevantBackupSetId: null,
latestRelevantCompletedAt: null,
qualitySummary: null,
freshnessEvaluation: $freshnessEvaluation,
scheduleFollowUp: $scheduleFollowUp,
healthyClaimAllowed: false,
primaryActionTarget: BackupHealthActionTarget::backupSetsIndex(TenantBackupHealthAssessment::REASON_NO_BACKUP_BASIS),
positiveClaimBoundary: self::POSITIVE_CLAIM_BOUNDARY,
);
}
if (! $freshnessEvaluation->isFresh) {
return new TenantBackupHealthAssessment(
tenantId: $tenantId,
posture: TenantBackupHealthAssessment::POSTURE_STALE,
primaryReason: TenantBackupHealthAssessment::REASON_LATEST_BACKUP_STALE,
headline: 'Latest backup is stale.',
supportingMessage: $this->combineMessages([
$this->latestBackupAgeMessage($latestBackupSet->completed_at, $now),
$qualitySummary?->hasDegradations() === true ? 'The latest completed backup is also degraded.' : null,
$scheduleFollowUp->summaryMessage,
]),
latestRelevantBackupSetId: (int) $latestBackupSet->getKey(),
latestRelevantCompletedAt: $latestBackupSet->completed_at,
qualitySummary: $qualitySummary,
freshnessEvaluation: $freshnessEvaluation,
scheduleFollowUp: $scheduleFollowUp,
healthyClaimAllowed: false,
primaryActionTarget: BackupHealthActionTarget::backupSetView(
recordId: (int) $latestBackupSet->getKey(),
reason: TenantBackupHealthAssessment::REASON_LATEST_BACKUP_STALE,
),
positiveClaimBoundary: self::POSITIVE_CLAIM_BOUNDARY,
);
}
if ($qualitySummary?->hasDegradations() === true) {
return new TenantBackupHealthAssessment(
tenantId: $tenantId,
posture: TenantBackupHealthAssessment::POSTURE_DEGRADED,
primaryReason: TenantBackupHealthAssessment::REASON_LATEST_BACKUP_DEGRADED,
headline: 'Latest backup is degraded.',
supportingMessage: $this->combineMessages([
$qualitySummary->summaryMessage,
$this->latestBackupAgeMessage($latestBackupSet->completed_at, $now),
$scheduleFollowUp->summaryMessage,
]),
latestRelevantBackupSetId: (int) $latestBackupSet->getKey(),
latestRelevantCompletedAt: $latestBackupSet->completed_at,
qualitySummary: $qualitySummary,
freshnessEvaluation: $freshnessEvaluation,
scheduleFollowUp: $scheduleFollowUp,
healthyClaimAllowed: false,
primaryActionTarget: BackupHealthActionTarget::backupSetView(
recordId: (int) $latestBackupSet->getKey(),
reason: TenantBackupHealthAssessment::REASON_LATEST_BACKUP_DEGRADED,
),
positiveClaimBoundary: self::POSITIVE_CLAIM_BOUNDARY,
);
}
$scheduleNeedsFollowUp = $scheduleFollowUp->needsFollowUp;
return new TenantBackupHealthAssessment(
tenantId: $tenantId,
posture: TenantBackupHealthAssessment::POSTURE_HEALTHY,
primaryReason: $scheduleNeedsFollowUp ? TenantBackupHealthAssessment::REASON_SCHEDULE_FOLLOW_UP : null,
headline: $scheduleNeedsFollowUp
? 'Backup schedules need follow-up.'
: 'Backups are recent and healthy.',
supportingMessage: $scheduleNeedsFollowUp
? $this->combineMessages([
'The latest completed backup is recent and shows no material degradation.',
$scheduleFollowUp->summaryMessage,
])
: $this->latestBackupAgeMessage($latestBackupSet->completed_at, $now),
latestRelevantBackupSetId: (int) $latestBackupSet->getKey(),
latestRelevantCompletedAt: $latestBackupSet->completed_at,
qualitySummary: $qualitySummary,
freshnessEvaluation: $freshnessEvaluation,
scheduleFollowUp: $scheduleFollowUp,
healthyClaimAllowed: ! $scheduleNeedsFollowUp,
primaryActionTarget: $scheduleNeedsFollowUp
? BackupHealthActionTarget::backupSchedulesIndex(TenantBackupHealthAssessment::REASON_SCHEDULE_FOLLOW_UP)
: null,
positiveClaimBoundary: self::POSITIVE_CLAIM_BOUNDARY,
);
}
private function latestRelevantBackupSet(int $tenantId): ?BackupSet
{
/** @var BackupSet|null $backupSet */
$backupSet = BackupSet::query()
->withTrashed()
->where('tenant_id', $tenantId)
->whereNotNull('completed_at')
->orderByDesc('completed_at')
->orderByDesc('id')
->with([
'items' => fn ($query) => $query->select([
'id',
'tenant_id',
'backup_set_id',
'payload',
'metadata',
'assignments',
]),
])
->first([
'id',
'tenant_id',
'workspace_id',
'name',
'status',
'item_count',
'created_by',
'completed_at',
'created_at',
'metadata',
'deleted_at',
]);
return $backupSet;
}
private function freshnessEvaluation(?CarbonInterface $latestCompletedAt, CarbonImmutable $now): BackupFreshnessEvaluation
{
$cutoffAt = $now->subHours($this->freshnessHours());
return new BackupFreshnessEvaluation(
latestCompletedAt: $latestCompletedAt,
cutoffAt: $cutoffAt,
isFresh: $latestCompletedAt?->greaterThanOrEqualTo($cutoffAt) ?? false,
);
}
private function scheduleFollowUpEvaluation(int $tenantId, CarbonImmutable $now): BackupScheduleFollowUpEvaluation
{
$graceCutoff = $now->subMinutes($this->scheduleOverdueGraceMinutes());
$schedules = BackupSchedule::query()
->where('tenant_id', $tenantId)
->where('is_enabled', true)
->orderBy('next_run_at')
->orderBy('id')
->get([
'id',
'tenant_id',
'last_run_status',
'last_run_at',
'next_run_at',
'created_at',
]);
$enabledScheduleCount = $schedules->count();
$overdueScheduleCount = 0;
$failedRecentRunCount = 0;
$neverSuccessfulCount = 0;
$primaryScheduleId = null;
foreach ($schedules as $schedule) {
$isOverdue = $schedule->next_run_at?->lessThan($graceCutoff) ?? false;
$lastRunStatus = strtolower(trim((string) $schedule->last_run_status));
$needsFollowUpAfterRun = in_array($lastRunStatus, ['failed', 'partial', 'skipped', 'canceled'], true);
$neverSuccessful = $schedule->last_run_at === null
&& ($isOverdue || ($schedule->created_at?->lessThan($graceCutoff) ?? false));
if ($isOverdue) {
$overdueScheduleCount++;
}
if ($needsFollowUpAfterRun) {
$failedRecentRunCount++;
}
if ($neverSuccessful) {
$neverSuccessfulCount++;
}
if ($primaryScheduleId === null && ($neverSuccessful || $isOverdue || $needsFollowUpAfterRun)) {
$primaryScheduleId = (int) $schedule->getKey();
}
}
return new BackupScheduleFollowUpEvaluation(
hasEnabledSchedules: $enabledScheduleCount > 0,
enabledScheduleCount: $enabledScheduleCount,
overdueScheduleCount: $overdueScheduleCount,
failedRecentRunCount: $failedRecentRunCount,
neverSuccessfulCount: $neverSuccessfulCount,
needsFollowUp: $overdueScheduleCount > 0 || $failedRecentRunCount > 0 || $neverSuccessfulCount > 0,
primaryScheduleId: $primaryScheduleId,
summaryMessage: $this->scheduleSummaryMessage(
enabledScheduleCount: $enabledScheduleCount,
overdueScheduleCount: $overdueScheduleCount,
failedRecentRunCount: $failedRecentRunCount,
neverSuccessfulCount: $neverSuccessfulCount,
),
);
}
private function latestBackupAgeMessage(?CarbonInterface $completedAt, CarbonImmutable $now): ?string
{
if (! $completedAt instanceof CarbonInterface) {
return null;
}
return sprintf(
'The latest completed backup was %s.',
$completedAt->diffForHumans($now, [
'parts' => 2,
'join' => true,
'short' => false,
'syntax' => CarbonInterface::DIFF_RELATIVE_TO_NOW,
]),
);
}
private function freshnessHours(): int
{
return max(1, (int) config('tenantpilot.backup_health.freshness_hours', 24));
}
private function scheduleOverdueGraceMinutes(): int
{
return max(1, (int) config('tenantpilot.backup_health.schedule_overdue_grace_minutes', 30));
}
private function scheduleSummaryMessage(
int $enabledScheduleCount,
int $overdueScheduleCount,
int $failedRecentRunCount,
int $neverSuccessfulCount,
): ?string {
if ($enabledScheduleCount === 0) {
return null;
}
if ($neverSuccessfulCount > 0) {
return $neverSuccessfulCount === 1
? 'One enabled schedule has not produced a successful run yet.'
: sprintf('%d enabled schedules have not produced a successful run yet.', $neverSuccessfulCount);
}
if ($overdueScheduleCount > 0) {
return $overdueScheduleCount === 1
? 'One enabled schedule looks overdue.'
: sprintf('%d enabled schedules look overdue.', $overdueScheduleCount);
}
if ($failedRecentRunCount > 0) {
return $failedRecentRunCount === 1
? 'One enabled schedule needs follow-up after the last run.'
: sprintf('%d enabled schedules need follow-up after the last run.', $failedRecentRunCount);
}
return null;
}
/**
* @param array<int, string|null> $messages
*/
private function combineMessages(array $messages): ?string
{
$parts = array_values(array_filter(
Arr::flatten($messages),
static fn (mixed $message): bool => is_string($message) && $message !== ''
));
if ($parts === []) {
return null;
}
return implode(' ', $parts);
}
}

View File

@ -0,0 +1,474 @@
<?php
declare(strict_types=1);
namespace App\Support\BackupQuality;
use App\Models\BackupItem;
use App\Models\BackupSet;
use App\Models\PolicyVersion;
use Illuminate\Support\Collection;
use Illuminate\Support\Str;
final class BackupQualityResolver
{
public function summarizeBackupSet(BackupSet $backupSet): BackupQualitySummary
{
$items = $backupSet->relationLoaded('items')
? $backupSet->items
: $backupSet->items()->get();
return $this->summarizeBackupItems(
$items,
max((int) ($backupSet->item_count ?? 0), $items->count()),
);
}
/**
* @param iterable<BackupItem> $items
*/
public function summarizeBackupItems(iterable $items, ?int $totalItems = null): BackupQualitySummary
{
$itemSummaries = Collection::make($items)
->map(fn (BackupItem $item): BackupQualitySummary => $this->forBackupItem($item))
->values();
$resolvedTotalItems = max($itemSummaries->count(), (int) ($totalItems ?? 0));
$metadataOnlyCount = $itemSummaries->where('metadataOnlyCount', '>', 0)->count();
$assignmentIssueCount = $itemSummaries->where('assignmentIssueCount', '>', 0)->count();
$orphanedAssignmentCount = $itemSummaries->where('orphanedAssignmentCount', '>', 0)->count();
$integrityWarningCount = $itemSummaries->where('integrityWarningCount', '>', 0)->count();
$unknownQualityCount = $itemSummaries->where('unknownQualityCount', '>', 0)->count();
$degradedItemCount = $itemSummaries->filter(
fn (BackupQualitySummary $summary): bool => $summary->hasDegradations()
)->count();
$degradationFamilies = $this->orderedFamilies(
$itemSummaries
->flatMap(fn (BackupQualitySummary $summary): array => $summary->degradationFamilies)
->all(),
);
$qualityHighlights = $this->setHighlights(
totalItems: $resolvedTotalItems,
degradedItemCount: $degradedItemCount,
metadataOnlyCount: $metadataOnlyCount,
assignmentIssueCount: $assignmentIssueCount,
orphanedAssignmentCount: $orphanedAssignmentCount,
integrityWarningCount: $integrityWarningCount,
unknownQualityCount: $unknownQualityCount,
);
$compactSummary = $qualityHighlights === []
? $this->defaultSetCompactSummary($resolvedTotalItems)
: implode(' • ', $qualityHighlights);
$summaryMessage = match (true) {
$resolvedTotalItems === 0 => 'No backup items were captured in this set.',
$degradedItemCount === 0 => sprintf(
'No degradations were detected across %d captured item%s.',
$resolvedTotalItems,
$resolvedTotalItems === 1 ? '' : 's',
),
default => sprintf(
'%d of %d captured item%s show degraded input quality.',
$degradedItemCount,
$resolvedTotalItems,
$resolvedTotalItems === 1 ? '' : 's',
),
};
$nextAction = match (true) {
$resolvedTotalItems === 0 => 'Create or refresh a backup set before starting a restore review.',
$degradedItemCount > 0 => 'Open the backup set detail and inspect degraded items before continuing into restore.',
default => 'Open the backup set detail to verify item-level context before relying on it for restore work.',
};
return new BackupQualitySummary(
kind: 'backup_set',
snapshotMode: $this->aggregateSnapshotMode($resolvedTotalItems, $metadataOnlyCount, $unknownQualityCount),
totalItems: $resolvedTotalItems,
degradedItemCount: $degradedItemCount,
metadataOnlyCount: $metadataOnlyCount,
assignmentIssueCount: $assignmentIssueCount,
orphanedAssignmentCount: $orphanedAssignmentCount,
integrityWarningCount: $integrityWarningCount,
unknownQualityCount: $unknownQualityCount,
hasAssignmentIssues: $assignmentIssueCount > 0,
hasOrphanedAssignments: $orphanedAssignmentCount > 0,
assignmentCaptureReason: null,
integrityWarning: null,
degradationFamilies: $degradationFamilies,
qualityHighlights: $qualityHighlights,
compactSummary: $compactSummary,
summaryMessage: $summaryMessage,
nextAction: $nextAction,
positiveClaimBoundary: $this->positiveClaimBoundary(),
);
}
public function forBackupItem(BackupItem $backupItem): BackupQualitySummary
{
$snapshotMode = $this->resolveSnapshotMode(
source: $backupItem->snapshotSource(),
warnings: $backupItem->warningMessages(),
hasCapturedPayload: $backupItem->hasCapturedPayload(),
);
$assignmentCaptureReason = $backupItem->assignmentCaptureReason();
$integrityWarning = $backupItem->integrityWarning();
$hasAssignmentIssues = $backupItem->assignmentsFetchFailed();
$hasOrphanedAssignments = $backupItem->hasOrphanedAssignments();
$degradationFamilies = $this->singleRecordFamilies(
snapshotMode: $snapshotMode,
hasAssignmentIssues: $hasAssignmentIssues,
hasOrphanedAssignments: $hasOrphanedAssignments,
integrityWarning: $integrityWarning,
);
$qualityHighlights = $this->singleRecordHighlights(
snapshotMode: $snapshotMode,
hasAssignmentIssues: $hasAssignmentIssues,
hasOrphanedAssignments: $hasOrphanedAssignments,
integrityWarning: $integrityWarning,
assignmentCaptureReason: $assignmentCaptureReason,
);
return new BackupQualitySummary(
kind: 'backup_item',
snapshotMode: $snapshotMode,
totalItems: 1,
degradedItemCount: $degradationFamilies === [] ? 0 : 1,
metadataOnlyCount: $snapshotMode === 'metadata_only' ? 1 : 0,
assignmentIssueCount: $hasAssignmentIssues ? 1 : 0,
orphanedAssignmentCount: $hasOrphanedAssignments ? 1 : 0,
integrityWarningCount: $integrityWarning !== null ? 1 : 0,
unknownQualityCount: $degradationFamilies === ['unknown_quality'] ? 1 : 0,
hasAssignmentIssues: $hasAssignmentIssues,
hasOrphanedAssignments: $hasOrphanedAssignments,
assignmentCaptureReason: $assignmentCaptureReason,
integrityWarning: $integrityWarning,
degradationFamilies: $degradationFamilies,
qualityHighlights: $qualityHighlights,
compactSummary: $this->compactSummaryFromHighlights($qualityHighlights, $snapshotMode),
summaryMessage: $this->singleRecordSummaryMessage($qualityHighlights, $snapshotMode),
nextAction: $degradationFamilies === []
? 'Open the linked detail if you need deeper restore context.'
: 'Inspect the linked detail before relying on this backup item for restore.',
positiveClaimBoundary: $this->positiveClaimBoundary(),
);
}
public function forPolicyVersion(PolicyVersion $policyVersion): BackupQualitySummary
{
$snapshotMode = $this->resolveSnapshotMode(
source: $policyVersion->snapshotSource(),
warnings: $policyVersion->warningMessages(),
hasCapturedPayload: $policyVersion->hasCapturedPayload(),
);
$integrityWarning = $policyVersion->integrityWarning();
$hasAssignmentIssues = $policyVersion->assignmentsFetchFailed();
$hasOrphanedAssignments = $policyVersion->hasOrphanedAssignments();
$degradationFamilies = $this->singleRecordFamilies(
snapshotMode: $snapshotMode,
hasAssignmentIssues: $hasAssignmentIssues,
hasOrphanedAssignments: $hasOrphanedAssignments,
integrityWarning: $integrityWarning,
);
$qualityHighlights = $this->singleRecordHighlights(
snapshotMode: $snapshotMode,
hasAssignmentIssues: $hasAssignmentIssues,
hasOrphanedAssignments: $hasOrphanedAssignments,
integrityWarning: $integrityWarning,
);
return new BackupQualitySummary(
kind: 'policy_version',
snapshotMode: $snapshotMode,
totalItems: 1,
degradedItemCount: $degradationFamilies === [] ? 0 : 1,
metadataOnlyCount: $snapshotMode === 'metadata_only' ? 1 : 0,
assignmentIssueCount: $hasAssignmentIssues ? 1 : 0,
orphanedAssignmentCount: $hasOrphanedAssignments ? 1 : 0,
integrityWarningCount: $integrityWarning !== null ? 1 : 0,
unknownQualityCount: $degradationFamilies === ['unknown_quality'] ? 1 : 0,
hasAssignmentIssues: $hasAssignmentIssues,
hasOrphanedAssignments: $hasOrphanedAssignments,
assignmentCaptureReason: null,
integrityWarning: $integrityWarning,
degradationFamilies: $degradationFamilies,
qualityHighlights: $qualityHighlights,
compactSummary: $this->compactSummaryFromHighlights($qualityHighlights, $snapshotMode),
summaryMessage: $this->singleRecordSummaryMessage($qualityHighlights, $snapshotMode),
nextAction: $degradationFamilies === []
? 'Open the version detail if you need raw settings or diff context.'
: 'Prefer a stronger version or inspect the version detail before restore.',
positiveClaimBoundary: $this->positiveClaimBoundary(),
);
}
/**
* @param list<string> $warnings
*/
private function resolveSnapshotMode(?string $source, array $warnings, bool $hasCapturedPayload): string
{
if ($source === 'metadata_only' || $this->warningsIndicateMetadataOnly($warnings)) {
return 'metadata_only';
}
if ($hasCapturedPayload) {
return 'full';
}
return 'unknown';
}
/**
* @param list<string> $warnings
*/
private function warningsIndicateMetadataOnly(array $warnings): bool
{
return Collection::make($warnings)
->contains(function (mixed $warning): bool {
if (! is_string($warning)) {
return false;
}
$normalized = Str::lower($warning);
return str_contains($normalized, 'metadata')
&& (
str_contains($normalized, 'only')
|| str_contains($normalized, 'fallback')
);
});
}
/**
* @return list<string>
*/
private function singleRecordFamilies(
string $snapshotMode,
bool $hasAssignmentIssues,
bool $hasOrphanedAssignments,
?string $integrityWarning,
): array {
$families = [];
if ($snapshotMode === 'metadata_only') {
$families[] = 'metadata_only';
}
if ($hasAssignmentIssues) {
$families[] = 'assignment_capture_issue';
}
if ($hasOrphanedAssignments) {
$families[] = 'orphaned_assignments';
}
if ($integrityWarning !== null) {
$families[] = 'integrity_warning';
}
if ($families === [] && $snapshotMode === 'unknown') {
$families[] = 'unknown_quality';
}
return $this->orderedFamilies($families);
}
/**
* @return list<string>
*/
private function singleRecordHighlights(
string $snapshotMode,
bool $hasAssignmentIssues,
bool $hasOrphanedAssignments,
?string $integrityWarning,
?string $assignmentCaptureReason = null,
): array {
$highlights = [];
if ($snapshotMode === 'metadata_only') {
$highlights[] = 'Metadata only';
}
if ($hasAssignmentIssues) {
$highlights[] = 'Assignment fetch failed';
} elseif ($assignmentCaptureReason === 'separate_role_assignments') {
$highlights[] = 'Assignments captured separately';
}
if ($hasOrphanedAssignments) {
$highlights[] = 'Orphaned assignments';
}
if ($integrityWarning !== null) {
$highlights[] = 'Integrity warning';
}
if ($snapshotMode === 'unknown' && $highlights === []) {
$highlights[] = 'Unknown quality';
}
return array_values(array_unique($highlights));
}
private function compactSummaryFromHighlights(array $qualityHighlights, string $snapshotMode): string
{
if ($qualityHighlights !== []) {
return implode(' • ', $qualityHighlights);
}
return match ($snapshotMode) {
'full' => 'Full payload',
'unknown' => 'Unknown quality',
default => 'No degradations detected',
};
}
private function singleRecordSummaryMessage(array $qualityHighlights, string $snapshotMode): string
{
if ($qualityHighlights === []) {
return match ($snapshotMode) {
'full' => 'No degradations were detected from the captured snapshot and assignment metadata.',
'unknown' => 'Quality is unknown because this record lacks enough completeness metadata to justify a stronger claim.',
default => 'No degradations were detected.',
};
}
return implode(' • ', $qualityHighlights).'.';
}
private function aggregateSnapshotMode(int $totalItems, int $metadataOnlyCount, int $unknownQualityCount): string
{
if ($totalItems === 0) {
return 'unknown';
}
if ($metadataOnlyCount === $totalItems) {
return 'metadata_only';
}
if ($metadataOnlyCount === 0 && $unknownQualityCount === 0) {
return 'full';
}
return 'unknown';
}
/**
* @return list<string>
*/
private function orderedFamilies(array $families): array
{
$weights = [
'metadata_only' => 10,
'assignment_capture_issue' => 20,
'orphaned_assignments' => 30,
'integrity_warning' => 40,
'unknown_quality' => 50,
];
$families = array_values(array_unique(array_filter(
$families,
static fn (mixed $family): bool => is_string($family) && $family !== '',
)));
usort($families, static function (string $left, string $right) use ($weights): int {
return ($weights[$left] ?? 999) <=> ($weights[$right] ?? 999);
});
return $families;
}
/**
* @return list<string>
*/
private function setHighlights(
int $totalItems,
int $degradedItemCount,
int $metadataOnlyCount,
int $assignmentIssueCount,
int $orphanedAssignmentCount,
int $integrityWarningCount,
int $unknownQualityCount,
): array {
if ($totalItems === 0) {
return [];
}
$highlights = [];
if ($degradedItemCount > 0) {
$highlights[] = sprintf(
'%d degraded item%s',
$degradedItemCount,
$degradedItemCount === 1 ? '' : 's',
);
}
if ($metadataOnlyCount > 0) {
$highlights[] = sprintf(
'%d metadata-only',
$metadataOnlyCount,
);
}
if ($assignmentIssueCount > 0) {
$highlights[] = sprintf(
'%d assignment issue%s',
$assignmentIssueCount,
$assignmentIssueCount === 1 ? '' : 's',
);
}
if ($orphanedAssignmentCount > 0) {
$highlights[] = sprintf(
'%d orphaned assignment%s',
$orphanedAssignmentCount,
$orphanedAssignmentCount === 1 ? '' : 's',
);
}
if ($integrityWarningCount > 0) {
$highlights[] = sprintf(
'%d integrity warning%s',
$integrityWarningCount,
$integrityWarningCount === 1 ? '' : 's',
);
}
if ($unknownQualityCount > 0) {
$highlights[] = sprintf(
'%d unknown quality',
$unknownQualityCount,
);
}
return $highlights;
}
private function defaultSetCompactSummary(int $totalItems): string
{
if ($totalItems === 0) {
return 'No items captured';
}
return sprintf(
'No degradations detected across %d item%s',
$totalItems,
$totalItems === 1 ? '' : 's',
);
}
private function positiveClaimBoundary(): string
{
return 'Input quality signals do not prove safe restore, restore readiness, or tenant-wide recoverability.';
}
}

View File

@ -0,0 +1,44 @@
<?php
declare(strict_types=1);
namespace App\Support\BackupQuality;
final readonly class BackupQualitySummary
{
/**
* @param list<string> $degradationFamilies
* @param list<string> $qualityHighlights
*/
public function __construct(
public string $kind,
public string $snapshotMode,
public int $totalItems,
public int $degradedItemCount,
public int $metadataOnlyCount,
public int $assignmentIssueCount,
public int $orphanedAssignmentCount,
public int $integrityWarningCount,
public int $unknownQualityCount,
public bool $hasAssignmentIssues,
public bool $hasOrphanedAssignments,
public ?string $assignmentCaptureReason,
public ?string $integrityWarning,
public array $degradationFamilies,
public array $qualityHighlights,
public string $compactSummary,
public string $summaryMessage,
public string $nextAction,
public string $positiveClaimBoundary,
) {}
public function hasDegradations(): bool
{
return $this->degradationFamilies !== [];
}
public function hasIntegrityWarning(): bool
{
return $this->integrityWarning !== null;
}
}

View File

@ -18,6 +18,10 @@ public function spec(mixed $value): BadgeSpec
'blocking' => OperatorOutcomeTaxonomy::spec(BadgeDomain::RestoreCheckSeverity, $state, 'heroicon-m-x-circle'), 'blocking' => OperatorOutcomeTaxonomy::spec(BadgeDomain::RestoreCheckSeverity, $state, 'heroicon-m-x-circle'),
'warning' => OperatorOutcomeTaxonomy::spec(BadgeDomain::RestoreCheckSeverity, $state, 'heroicon-m-exclamation-triangle'), 'warning' => OperatorOutcomeTaxonomy::spec(BadgeDomain::RestoreCheckSeverity, $state, 'heroicon-m-exclamation-triangle'),
'safe' => OperatorOutcomeTaxonomy::spec(BadgeDomain::RestoreCheckSeverity, $state, 'heroicon-m-check-circle'), 'safe' => OperatorOutcomeTaxonomy::spec(BadgeDomain::RestoreCheckSeverity, $state, 'heroicon-m-check-circle'),
'current' => new BadgeSpec('Current checks', 'success', 'heroicon-m-check-circle', 'success'),
'invalidated' => new BadgeSpec('Invalidated', 'warning', 'heroicon-m-arrow-path-rounded-square', 'warning'),
'stale' => new BadgeSpec('Legacy stale', 'gray', 'heroicon-m-clock', 'gray'),
'not_run' => new BadgeSpec('Not run', 'gray', 'heroicon-m-eye-slash', 'gray'),
default => BadgeSpec::unknown(), default => BadgeSpec::unknown(),
} ?? BadgeSpec::unknown(); } ?? BadgeSpec::unknown();
} }

View File

@ -18,8 +18,13 @@ public function spec(mixed $value): BadgeSpec
'created' => OperatorOutcomeTaxonomy::spec(BadgeDomain::RestorePreviewDecision, $state, 'heroicon-m-plus-circle'), 'created' => OperatorOutcomeTaxonomy::spec(BadgeDomain::RestorePreviewDecision, $state, 'heroicon-m-plus-circle'),
'created_copy' => OperatorOutcomeTaxonomy::spec(BadgeDomain::RestorePreviewDecision, $state, 'heroicon-m-document-duplicate'), 'created_copy' => OperatorOutcomeTaxonomy::spec(BadgeDomain::RestorePreviewDecision, $state, 'heroicon-m-document-duplicate'),
'mapped_existing' => OperatorOutcomeTaxonomy::spec(BadgeDomain::RestorePreviewDecision, $state, 'heroicon-m-arrow-right-circle'), 'mapped_existing' => OperatorOutcomeTaxonomy::spec(BadgeDomain::RestorePreviewDecision, $state, 'heroicon-m-arrow-right-circle'),
'dry_run' => new BadgeSpec('Preview only', 'warning', 'heroicon-m-exclamation-triangle', 'warning'),
'skipped' => OperatorOutcomeTaxonomy::spec(BadgeDomain::RestorePreviewDecision, $state, 'heroicon-m-minus-circle'), 'skipped' => OperatorOutcomeTaxonomy::spec(BadgeDomain::RestorePreviewDecision, $state, 'heroicon-m-minus-circle'),
'failed' => OperatorOutcomeTaxonomy::spec(BadgeDomain::RestorePreviewDecision, $state, 'heroicon-m-x-circle'), 'failed' => OperatorOutcomeTaxonomy::spec(BadgeDomain::RestorePreviewDecision, $state, 'heroicon-m-x-circle'),
'current' => new BadgeSpec('Current basis', 'success', 'heroicon-m-check-circle', 'success'),
'invalidated' => new BadgeSpec('Invalidated', 'warning', 'heroicon-m-arrow-path-rounded-square', 'warning'),
'stale' => new BadgeSpec('Legacy stale', 'gray', 'heroicon-m-clock', 'gray'),
'not_generated' => new BadgeSpec('Not generated', 'gray', 'heroicon-m-eye-slash', 'gray'),
default => BadgeSpec::unknown(), default => BadgeSpec::unknown(),
} ?? BadgeSpec::unknown(); } ?? BadgeSpec::unknown();
} }

View File

@ -22,6 +22,9 @@ public function spec(mixed $value): BadgeSpec
'partial' => OperatorOutcomeTaxonomy::spec(BadgeDomain::RestoreResultStatus, $state, 'heroicon-m-exclamation-triangle'), 'partial' => OperatorOutcomeTaxonomy::spec(BadgeDomain::RestoreResultStatus, $state, 'heroicon-m-exclamation-triangle'),
'manual_required' => OperatorOutcomeTaxonomy::spec(BadgeDomain::RestoreResultStatus, $state, 'heroicon-m-wrench-screwdriver'), 'manual_required' => OperatorOutcomeTaxonomy::spec(BadgeDomain::RestoreResultStatus, $state, 'heroicon-m-wrench-screwdriver'),
'failed' => OperatorOutcomeTaxonomy::spec(BadgeDomain::RestoreResultStatus, $state, 'heroicon-m-x-circle'), 'failed' => OperatorOutcomeTaxonomy::spec(BadgeDomain::RestoreResultStatus, $state, 'heroicon-m-x-circle'),
'not_executed' => new BadgeSpec('Not executed', 'gray', 'heroicon-m-eye', 'gray'),
'completed' => new BadgeSpec('Completed', 'success', 'heroicon-m-check-circle', 'success'),
'completed_with_follow_up' => new BadgeSpec('Follow-up required', 'warning', 'heroicon-m-exclamation-triangle', 'warning'),
default => BadgeSpec::unknown(), default => BadgeSpec::unknown(),
} ?? BadgeSpec::unknown(); } ?? BadgeSpec::unknown();
} }

View File

@ -0,0 +1,77 @@
<?php
declare(strict_types=1);
namespace App\Support\RestoreSafety;
use InvalidArgumentException;
final readonly class ChecksIntegrityState
{
public const string STATE_NOT_RUN = 'not_run';
public const string STATE_CURRENT = 'current';
public const string STATE_STALE = 'stale';
public const string STATE_INVALIDATED = 'invalidated';
public const string FRESHNESS_POLICY = 'invalidate_after_mutation';
/**
* @param list<string> $invalidationReasons
*/
public function __construct(
public string $state,
public string $freshnessPolicy,
public ?string $fingerprint,
public ?string $ranAt,
public int $blockingCount,
public int $warningCount,
public array $invalidationReasons,
public bool $rerunRequired,
public string $displaySummary,
) {
if (! in_array($this->state, [
self::STATE_NOT_RUN,
self::STATE_CURRENT,
self::STATE_STALE,
self::STATE_INVALIDATED,
], true)) {
throw new InvalidArgumentException('Unsupported checks integrity state.');
}
}
public function isCurrent(): bool
{
return $this->state === self::STATE_CURRENT;
}
/**
* @return array{
* state: string,
* freshness_policy: string,
* fingerprint: ?string,
* ran_at: ?string,
* blocking_count: int,
* warning_count: int,
* invalidation_reasons: list<string>,
* rerun_required: bool,
* display_summary: string
* }
*/
public function toArray(): array
{
return [
'state' => $this->state,
'freshness_policy' => $this->freshnessPolicy,
'fingerprint' => $this->fingerprint,
'ran_at' => $this->ranAt,
'blocking_count' => $this->blockingCount,
'warning_count' => $this->warningCount,
'invalidation_reasons' => $this->invalidationReasons,
'rerun_required' => $this->rerunRequired,
'display_summary' => $this->displaySummary,
];
}
}

View File

@ -0,0 +1,39 @@
<?php
declare(strict_types=1);
namespace App\Support\RestoreSafety;
final readonly class ExecutionReadinessState
{
/**
* @param list<string> $blockingReasons
*/
public function __construct(
public bool $allowed,
public array $blockingReasons,
public string $mutationScope,
public string $requiredCapability,
public string $displaySummary,
) {}
/**
* @return array{
* allowed: bool,
* blocking_reasons: list<string>,
* mutation_scope: string,
* required_capability: string,
* display_summary: string
* }
*/
public function toArray(): array
{
return [
'allowed' => $this->allowed,
'blocking_reasons' => $this->blockingReasons,
'mutation_scope' => $this->mutationScope,
'required_capability' => $this->requiredCapability,
'display_summary' => $this->displaySummary,
];
}
}

View File

@ -0,0 +1,71 @@
<?php
declare(strict_types=1);
namespace App\Support\RestoreSafety;
use InvalidArgumentException;
final readonly class PreviewIntegrityState
{
public const string STATE_NOT_GENERATED = 'not_generated';
public const string STATE_CURRENT = 'current';
public const string STATE_STALE = 'stale';
public const string STATE_INVALIDATED = 'invalidated';
public const string FRESHNESS_POLICY = 'invalidate_after_mutation';
/**
* @param list<string> $invalidationReasons
*/
public function __construct(
public string $state,
public string $freshnessPolicy,
public ?string $fingerprint,
public ?string $generatedAt,
public array $invalidationReasons,
public bool $rerunRequired,
public string $displaySummary,
) {
if (! in_array($this->state, [
self::STATE_NOT_GENERATED,
self::STATE_CURRENT,
self::STATE_STALE,
self::STATE_INVALIDATED,
], true)) {
throw new InvalidArgumentException('Unsupported preview integrity state.');
}
}
public function isCurrent(): bool
{
return $this->state === self::STATE_CURRENT;
}
/**
* @return array{
* state: string,
* freshness_policy: string,
* fingerprint: ?string,
* generated_at: ?string,
* invalidation_reasons: list<string>,
* rerun_required: bool,
* display_summary: string
* }
*/
public function toArray(): array
{
return [
'state' => $this->state,
'freshness_policy' => $this->freshnessPolicy,
'fingerprint' => $this->fingerprint,
'generated_at' => $this->generatedAt,
'invalidation_reasons' => $this->invalidationReasons,
'rerun_required' => $this->rerunRequired,
'display_summary' => $this->displaySummary,
];
}
}

View File

@ -0,0 +1,48 @@
<?php
declare(strict_types=1);
namespace App\Support\RestoreSafety;
final readonly class RestoreExecutionSafetySnapshot
{
public function __construct(
public string $evaluatedAt,
public string $scopeFingerprint,
public string $previewState,
public string $checksState,
public string $safetyState,
public int $blockingCount,
public int $warningCount,
public ?string $primaryIssueCode,
public string $followUpBoundary,
) {}
/**
* @return array{
* evaluated_at: string,
* scope_fingerprint: string,
* preview_state: string,
* checks_state: string,
* safety_state: string,
* blocking_count: int,
* warning_count: int,
* primary_issue_code: ?string,
* follow_up_boundary: string
* }
*/
public function toArray(): array
{
return [
'evaluated_at' => $this->evaluatedAt,
'scope_fingerprint' => $this->scopeFingerprint,
'preview_state' => $this->previewState,
'checks_state' => $this->checksState,
'safety_state' => $this->safetyState,
'blocking_count' => $this->blockingCount,
'warning_count' => $this->warningCount,
'primary_issue_code' => $this->primaryIssueCode,
'follow_up_boundary' => $this->followUpBoundary,
];
}
}

View File

@ -0,0 +1,64 @@
<?php
declare(strict_types=1);
namespace App\Support\RestoreSafety;
use InvalidArgumentException;
final readonly class RestoreResultAttention
{
public const string STATE_NOT_EXECUTED = 'not_executed';
public const string STATE_COMPLETED = 'completed';
public const string STATE_PARTIAL = 'partial';
public const string STATE_FAILED = 'failed';
public const string STATE_COMPLETED_WITH_FOLLOW_UP = 'completed_with_follow_up';
public function __construct(
public string $state,
public bool $followUpRequired,
public string $primaryCauseFamily,
public string $summary,
public string $primaryNextAction,
public string $recoveryClaimBoundary,
public string $tone,
) {
if (! in_array($this->state, [
self::STATE_NOT_EXECUTED,
self::STATE_COMPLETED,
self::STATE_PARTIAL,
self::STATE_FAILED,
self::STATE_COMPLETED_WITH_FOLLOW_UP,
], true)) {
throw new InvalidArgumentException('Unsupported restore result attention state.');
}
}
/**
* @return array{
* state: string,
* follow_up_required: bool,
* primary_cause_family: string,
* summary: string,
* primary_next_action: string,
* recovery_claim_boundary: string,
* tone: string
* }
*/
public function toArray(): array
{
return [
'state' => $this->state,
'follow_up_required' => $this->followUpRequired,
'primary_cause_family' => $this->primaryCauseFamily,
'summary' => $this->summary,
'primary_next_action' => $this->primaryNextAction,
'recovery_claim_boundary' => $this->recoveryClaimBoundary,
'tone' => $this->tone,
];
}
}

View File

@ -0,0 +1,93 @@
<?php
declare(strict_types=1);
namespace App\Support\RestoreSafety;
use InvalidArgumentException;
final readonly class RestoreSafetyAssessment
{
public const string STATE_BLOCKED = 'blocked';
public const string STATE_RISKY = 'risky';
public const string STATE_READY_WITH_CAUTION = 'ready_with_caution';
public const string STATE_READY = 'ready';
public function __construct(
public string $state,
public ExecutionReadinessState $executionReadiness,
public PreviewIntegrityState $previewIntegrity,
public ChecksIntegrityState $checksIntegrity,
public bool $positiveClaimSuppressed,
public ?string $primaryIssueCode,
public string $primaryNextAction,
public string $summary,
) {
if (! in_array($this->state, [
self::STATE_BLOCKED,
self::STATE_RISKY,
self::STATE_READY_WITH_CAUTION,
self::STATE_READY,
], true)) {
throw new InvalidArgumentException('Unsupported restore safety assessment state.');
}
}
public function canSignalReady(): bool
{
return in_array($this->state, [self::STATE_READY, self::STATE_READY_WITH_CAUTION], true);
}
/**
* @return array{
* state: string,
* execution_readiness: array{
* allowed: bool,
* blocking_reasons: list<string>,
* mutation_scope: string,
* required_capability: string,
* display_summary: string
* },
* preview_integrity: array{
* state: string,
* freshness_policy: string,
* fingerprint: ?string,
* generated_at: ?string,
* invalidation_reasons: list<string>,
* rerun_required: bool,
* display_summary: string
* },
* checks_integrity: array{
* state: string,
* freshness_policy: string,
* fingerprint: ?string,
* ran_at: ?string,
* blocking_count: int,
* warning_count: int,
* invalidation_reasons: list<string>,
* rerun_required: bool,
* display_summary: string
* },
* positive_claim_suppressed: bool,
* primary_issue_code: ?string,
* primary_next_action: string,
* summary: string
* }
*/
public function toArray(): array
{
return [
'state' => $this->state,
'execution_readiness' => $this->executionReadiness->toArray(),
'preview_integrity' => $this->previewIntegrity->toArray(),
'checks_integrity' => $this->checksIntegrity->toArray(),
'positive_claim_suppressed' => $this->positiveClaimSuppressed,
'primary_issue_code' => $this->primaryIssueCode,
'primary_next_action' => $this->primaryNextAction,
'summary' => $this->summary,
];
}
}

View File

@ -0,0 +1,86 @@
<?php
declare(strict_types=1);
namespace App\Support\RestoreSafety;
use Illuminate\Support\Str;
final class RestoreSafetyCopy
{
public static function safetyStateLabel(?string $state): string
{
return match ($state) {
RestoreSafetyAssessment::STATE_BLOCKED => 'Blocked',
RestoreSafetyAssessment::STATE_RISKY => 'Risky',
RestoreSafetyAssessment::STATE_READY_WITH_CAUTION => 'Ready with caution',
RestoreSafetyAssessment::STATE_READY => 'Ready',
default => self::headline($state, 'Unknown state'),
};
}
public static function primaryNextAction(?string $action): string
{
return match ($action) {
'resolve_blockers' => 'Resolve the technical blockers before real execution.',
'generate_preview' => 'Generate a preview for the current scope.',
'regenerate_preview' => 'Regenerate the preview for the current scope.',
'rerun_checks' => 'Run the safety checks again for the current scope.',
'review_warnings' => 'Review the warnings before real execution.',
'execute' => 'Queue the real restore execution.',
'review_preview' => 'Review the preview evidence before claiming recovery or queueing execution.',
'review_failures' => 'Review failed items and provider errors before retrying.',
'review_partial_items' => 'Review partial items and incomplete assignments before retrying.',
'review_skipped_items' => 'Review skipped or non-applied items before closing the run.',
'review_result' => 'Review the completed restore details.',
'adjust_scope' => 'Adjust the restore scope, then refresh preview and checks.',
'review_scope' => 'Review the current scope and safety evidence.',
default => self::sentence($action, 'Review the current scope and safety evidence.'),
};
}
public static function primaryCauseFamily(?string $family): string
{
return match ($family) {
'none' => 'No dominant cause recorded',
'execution_failure' => 'Execution failure',
'write_gate_or_rbac' => 'RBAC or write gate',
'provider_operability' => 'Provider operability',
'missing_dependency_or_mapping' => 'Missing dependency or mapping',
'payload_quality' => 'Payload quality',
'scope_mismatch' => 'Scope mismatch',
'item_level_failure' => 'Item-level failure',
default => self::headline($family, 'Unknown cause'),
};
}
public static function recoveryBoundary(?string $boundary): string
{
return match ($boundary) {
'preview_only_no_execution_proven' => 'No execution was performed from this record.',
'execution_failed_no_recovery_claim' => 'Tenant recovery is not proven.',
'run_completed_not_recovery_proven' => 'Tenant-wide recovery is not proven.',
default => 'Tenant-wide recovery is not proven.',
};
}
private static function headline(?string $value, string $fallback): string
{
if (! is_string($value) || trim($value) === '') {
return $fallback;
}
return Str::headline(trim($value));
}
private static function sentence(?string $value, string $fallback): string
{
if (! is_string($value) || trim($value) === '') {
return $fallback;
}
$sentence = Str::headline(trim($value));
return str_ends_with($sentence, '.') ? $sentence : $sentence.'.';
}
}

View File

@ -0,0 +1,619 @@
<?php
declare(strict_types=1);
namespace App\Support\RestoreSafety;
use App\Contracts\Hardening\WriteGateInterface;
use App\Exceptions\Hardening\ProviderAccessHardeningRequired;
use App\Models\RestoreRun;
use App\Models\Tenant;
use App\Models\User;
use App\Services\Auth\CapabilityResolver;
use App\Support\Auth\Capabilities;
final readonly class RestoreSafetyResolver
{
public function __construct(
private CapabilityResolver $capabilityResolver,
private WriteGateInterface $writeGate,
) {}
/**
* @param array<string, mixed> $data
*/
public function scopeFingerprintFromData(array $data): RestoreScopeFingerprint
{
return RestoreScopeFingerprint::fromInputs(
$data['backup_set_id'] ?? null,
$data['scope_mode'] ?? null,
$data['backup_item_ids'] ?? [],
$data['group_mapping'] ?? [],
);
}
/**
* @param array<string, mixed> $data
* @return array{
* backup_set_id: ?int,
* scope_mode: string,
* selected_item_ids: list<int>,
* group_mapping: array<string, string>,
* group_mapping_fingerprint: string,
* fingerprint: string,
* captured_at: string
* }
*/
public function scopeBasisFromData(array $data): array
{
$scope = $this->scopeFingerprintFromData($data);
return $scope->toArray() + [
'captured_at' => now('UTC')->toIso8601String(),
];
}
/**
* @param array<string, mixed> $data
* @return array{
* fingerprint: string,
* ran_at: string,
* blocking_count: int,
* warning_count: int,
* result_codes: list<string>
* }|null
*/
public function checksBasisFromData(array $data): ?array
{
$summary = $data['check_summary'] ?? null;
$ranAt = $data['checks_ran_at'] ?? null;
if (! is_array($summary) || ! is_string($ranAt) || $ranAt === '') {
return is_array($data['check_basis'] ?? null) ? $data['check_basis'] : null;
}
$scope = $this->scopeFingerprintFromData($data);
$results = is_array($data['check_results'] ?? null) ? $data['check_results'] : [];
return [
'fingerprint' => $scope->fingerprint,
'ran_at' => $ranAt,
'blocking_count' => (int) ($summary['blocking'] ?? 0),
'warning_count' => (int) ($summary['warning'] ?? 0),
'result_codes' => array_values(array_filter(array_map(static function (mixed $result): ?string {
$code = is_array($result) ? ($result['code'] ?? null) : null;
return is_string($code) && $code !== '' ? $code : null;
}, $results))),
];
}
/**
* @param array<string, mixed> $data
* @return array{
* fingerprint: string,
* generated_at: string,
* summary: array<string, mixed>
* }|null
*/
public function previewBasisFromData(array $data): ?array
{
$summary = $data['preview_summary'] ?? null;
$ranAt = $data['preview_ran_at'] ?? null;
if (! is_array($summary) || ! is_string($ranAt) || $ranAt === '') {
return is_array($data['preview_basis'] ?? null) ? $data['preview_basis'] : null;
}
$scope = $this->scopeFingerprintFromData($data);
return [
'fingerprint' => $scope->fingerprint,
'generated_at' => $ranAt,
'summary' => $summary,
];
}
/**
* @param array<string, mixed> $data
*/
public function previewIntegrityFromData(array $data): PreviewIntegrityState
{
$scope = $this->scopeFingerprintFromData($data);
$basis = is_array($data['preview_basis'] ?? null) ? $data['preview_basis'] : null;
$generatedAt = is_string($data['preview_ran_at'] ?? null) ? $data['preview_ran_at'] : null;
$hasPreviewEvidence = (is_array($data['preview_summary'] ?? null) && $data['preview_summary'] !== [])
|| ($basis !== null && $basis !== [])
|| (is_string($generatedAt) && $generatedAt !== '');
if (! $hasPreviewEvidence) {
return new PreviewIntegrityState(
state: PreviewIntegrityState::STATE_NOT_GENERATED,
freshnessPolicy: PreviewIntegrityState::FRESHNESS_POLICY,
fingerprint: null,
generatedAt: null,
invalidationReasons: [],
rerunRequired: true,
displaySummary: 'Generate a preview for the current scope before claiming calm execution readiness.',
);
}
$basisFingerprint = is_string($basis['fingerprint'] ?? null) ? $basis['fingerprint'] : null;
$reasons = $this->invalidationReasonsForBasis(
currentScope: $scope,
basis: $basis,
explicitReasons: $data['preview_invalidation_reasons'] ?? null,
);
if ($reasons !== []) {
return new PreviewIntegrityState(
state: PreviewIntegrityState::STATE_INVALIDATED,
freshnessPolicy: PreviewIntegrityState::FRESHNESS_POLICY,
fingerprint: $basisFingerprint,
generatedAt: is_string($basis['generated_at'] ?? null) ? $basis['generated_at'] : $generatedAt,
invalidationReasons: $reasons,
rerunRequired: true,
displaySummary: 'The last preview no longer matches the current restore scope. Regenerate it before real execution.',
);
}
if ($basisFingerprint === null || ! is_string($basis['generated_at'] ?? null)) {
return new PreviewIntegrityState(
state: PreviewIntegrityState::STATE_STALE,
freshnessPolicy: PreviewIntegrityState::FRESHNESS_POLICY,
fingerprint: $basisFingerprint,
generatedAt: is_string($basis['generated_at'] ?? null) ? $basis['generated_at'] : $generatedAt,
invalidationReasons: [],
rerunRequired: true,
displaySummary: 'Preview evidence exists, but it cannot prove it still belongs to the current scope.',
);
}
return new PreviewIntegrityState(
state: PreviewIntegrityState::STATE_CURRENT,
freshnessPolicy: PreviewIntegrityState::FRESHNESS_POLICY,
fingerprint: $basisFingerprint,
generatedAt: $basis['generated_at'],
invalidationReasons: [],
rerunRequired: false,
displaySummary: 'Preview evidence is current for the selected restore scope.',
);
}
/**
* @param array<string, mixed> $data
*/
public function checksIntegrityFromData(array $data): ChecksIntegrityState
{
$scope = $this->scopeFingerprintFromData($data);
$basis = is_array($data['check_basis'] ?? null) ? $data['check_basis'] : null;
$summary = is_array($data['check_summary'] ?? null) ? $data['check_summary'] : [];
$ranAt = is_string($data['checks_ran_at'] ?? null) ? $data['checks_ran_at'] : null;
$blockingCount = (int) ($summary['blocking'] ?? ($basis['blocking_count'] ?? 0));
$warningCount = (int) ($summary['warning'] ?? ($basis['warning_count'] ?? 0));
$hasCheckEvidence = $summary !== []
|| ($basis !== null && $basis !== [])
|| (is_string($ranAt) && $ranAt !== '');
if (! $hasCheckEvidence) {
return new ChecksIntegrityState(
state: ChecksIntegrityState::STATE_NOT_RUN,
freshnessPolicy: ChecksIntegrityState::FRESHNESS_POLICY,
fingerprint: null,
ranAt: null,
blockingCount: 0,
warningCount: 0,
invalidationReasons: [],
rerunRequired: true,
displaySummary: 'Run safety checks for the current scope before offering real execution calmly.',
);
}
$basisFingerprint = is_string($basis['fingerprint'] ?? null) ? $basis['fingerprint'] : null;
$reasons = $this->invalidationReasonsForBasis(
currentScope: $scope,
basis: $basis,
explicitReasons: $data['check_invalidation_reasons'] ?? null,
);
if ($reasons !== []) {
return new ChecksIntegrityState(
state: ChecksIntegrityState::STATE_INVALIDATED,
freshnessPolicy: ChecksIntegrityState::FRESHNESS_POLICY,
fingerprint: $basisFingerprint,
ranAt: is_string($basis['ran_at'] ?? null) ? $basis['ran_at'] : $ranAt,
blockingCount: $blockingCount,
warningCount: $warningCount,
invalidationReasons: $reasons,
rerunRequired: true,
displaySummary: 'The last checks no longer match the current restore scope. Run them again before real execution.',
);
}
if ($basisFingerprint === null || ! is_string($basis['ran_at'] ?? null)) {
return new ChecksIntegrityState(
state: ChecksIntegrityState::STATE_STALE,
freshnessPolicy: ChecksIntegrityState::FRESHNESS_POLICY,
fingerprint: $basisFingerprint,
ranAt: is_string($basis['ran_at'] ?? null) ? $basis['ran_at'] : $ranAt,
blockingCount: $blockingCount,
warningCount: $warningCount,
invalidationReasons: [],
rerunRequired: true,
displaySummary: 'Checks evidence exists, but it cannot prove it still belongs to the current scope.',
);
}
return new ChecksIntegrityState(
state: ChecksIntegrityState::STATE_CURRENT,
freshnessPolicy: ChecksIntegrityState::FRESHNESS_POLICY,
fingerprint: $basisFingerprint,
ranAt: $basis['ran_at'],
blockingCount: $blockingCount,
warningCount: $warningCount,
invalidationReasons: [],
rerunRequired: false,
displaySummary: 'Checks evidence is current for the selected restore scope.',
);
}
/**
* @param array<string, mixed> $data
*/
public function executionReadiness(Tenant $tenant, User $user, array $data, bool $dryRun = false): ExecutionReadinessState
{
$blockingReasons = [];
if (! $this->capabilityResolver->can($user, $tenant, Capabilities::TENANT_MANAGE)) {
$blockingReasons[] = 'missing_capability';
}
if (! $dryRun) {
try {
$this->writeGate->evaluate($tenant, 'restore.execute');
} catch (ProviderAccessHardeningRequired $exception) {
$blockingReasons[] = $exception->reasonCode;
}
}
$checkSummary = is_array($data['check_summary'] ?? null) ? $data['check_summary'] : [];
$blockingCount = (int) ($checkSummary['blocking'] ?? 0);
$hasBlockers = (bool) ($checkSummary['has_blockers'] ?? ($blockingCount > 0));
if ($hasBlockers) {
$blockingReasons[] = 'risk_blocker';
}
$blockingReasons = array_values(array_unique($blockingReasons));
$allowed = $blockingReasons === [];
$displaySummary = $allowed
? 'The platform can start a restore for this tenant once the operator chooses to proceed.'
: 'Technical startability is blocked until capability, write-gate, or hard-blocker issues are resolved.';
return new ExecutionReadinessState(
allowed: $allowed,
blockingReasons: $blockingReasons,
mutationScope: $dryRun ? 'simulation_only' : 'microsoft_tenant',
requiredCapability: Capabilities::TENANT_MANAGE,
displaySummary: $displaySummary,
);
}
/**
* @param array<string, mixed> $data
*/
public function safetyAssessment(Tenant $tenant, User $user, array $data): RestoreSafetyAssessment
{
$previewIntegrity = $this->previewIntegrityFromData($data);
$checksIntegrity = $this->checksIntegrityFromData($data);
$executionReadiness = $this->executionReadiness($tenant, $user, $data, false);
if (! $executionReadiness->allowed) {
return new RestoreSafetyAssessment(
state: RestoreSafetyAssessment::STATE_BLOCKED,
executionReadiness: $executionReadiness,
previewIntegrity: $previewIntegrity,
checksIntegrity: $checksIntegrity,
positiveClaimSuppressed: true,
primaryIssueCode: $executionReadiness->blockingReasons[0] ?? 'execution_blocked',
primaryNextAction: 'resolve_blockers',
summary: 'Real execution is blocked until the technical prerequisites are healthy again.',
);
}
if (! $previewIntegrity->isCurrent()) {
return new RestoreSafetyAssessment(
state: RestoreSafetyAssessment::STATE_RISKY,
executionReadiness: $executionReadiness,
previewIntegrity: $previewIntegrity,
checksIntegrity: $checksIntegrity,
positiveClaimSuppressed: true,
primaryIssueCode: $previewIntegrity->state,
primaryNextAction: 'regenerate_preview',
summary: 'Real execution is technically possible, but the preview basis is not current enough to support a calm go signal.',
);
}
if (! $checksIntegrity->isCurrent()) {
return new RestoreSafetyAssessment(
state: RestoreSafetyAssessment::STATE_RISKY,
executionReadiness: $executionReadiness,
previewIntegrity: $previewIntegrity,
checksIntegrity: $checksIntegrity,
positiveClaimSuppressed: true,
primaryIssueCode: $checksIntegrity->state,
primaryNextAction: 'rerun_checks',
summary: 'Real execution is technically possible, but the checks basis is not current enough to support a calm go signal.',
);
}
if ($checksIntegrity->warningCount > 0) {
return new RestoreSafetyAssessment(
state: RestoreSafetyAssessment::STATE_READY_WITH_CAUTION,
executionReadiness: $executionReadiness,
previewIntegrity: $previewIntegrity,
checksIntegrity: $checksIntegrity,
positiveClaimSuppressed: true,
primaryIssueCode: 'warnings_present',
primaryNextAction: 'review_warnings',
summary: 'Current preview and checks exist, but warnings remain. The restore can start, yet calm safety claims stay suppressed.',
);
}
return new RestoreSafetyAssessment(
state: RestoreSafetyAssessment::STATE_READY,
executionReadiness: $executionReadiness,
previewIntegrity: $previewIntegrity,
checksIntegrity: $checksIntegrity,
positiveClaimSuppressed: false,
primaryIssueCode: null,
primaryNextAction: 'execute',
summary: 'Current preview and checks support real execution for the selected scope.',
);
}
/**
* @param array<string, mixed> $data
*/
public function executionSafetySnapshot(Tenant $tenant, User $user, array $data): RestoreExecutionSafetySnapshot
{
$scope = $this->scopeFingerprintFromData($data);
$assessment = $this->safetyAssessment($tenant, $user, $data);
return new RestoreExecutionSafetySnapshot(
evaluatedAt: now('UTC')->toIso8601String(),
scopeFingerprint: $scope->fingerprint,
previewState: $assessment->previewIntegrity->state,
checksState: $assessment->checksIntegrity->state,
safetyState: $assessment->state,
blockingCount: $assessment->checksIntegrity->blockingCount,
warningCount: $assessment->checksIntegrity->warningCount,
primaryIssueCode: $assessment->primaryIssueCode,
followUpBoundary: 'run_completed_not_recovery_proven',
);
}
public function resultAttentionForRun(RestoreRun $restoreRun): RestoreResultAttention
{
$status = strtolower((string) $restoreRun->status);
$results = is_array($restoreRun->results) ? $restoreRun->results : [];
$items = is_array($results['items'] ?? null) ? array_values($results['items']) : [];
$foundations = is_array($results['foundations'] ?? null) ? array_values($results['foundations']) : [];
$operationOutcome = strtolower((string) ($restoreRun->operationRun?->outcome ?? ''));
$itemStatuses = array_values(array_filter(array_map(static function (mixed $item): ?string {
$status = is_array($item) ? ($item['status'] ?? null) : null;
return is_string($status) && $status !== '' ? strtolower($status) : null;
}, $items)));
$failedItems = count(array_filter($itemStatuses, static fn (string $itemStatus): bool => $itemStatus === 'failed'));
$partialItems = count(array_filter($itemStatuses, static fn (string $itemStatus): bool => in_array($itemStatus, ['partial', 'manual_required'], true)));
$skippedItems = count(array_filter($itemStatuses, static fn (string $itemStatus): bool => $itemStatus === 'skipped'));
$failedAssignments = $restoreRun->getFailedAssignmentsCount();
$skippedAssignments = $restoreRun->getSkippedAssignmentsCount();
$foundationSkips = count(array_filter($foundations, static function (mixed $entry): bool {
return is_array($entry) && in_array(($entry['decision'] ?? null), ['failed', 'skipped'], true);
}));
if ($restoreRun->is_dry_run || in_array($status, ['draft', 'scoped', 'checked', 'previewed'], true)) {
return new RestoreResultAttention(
state: RestoreResultAttention::STATE_NOT_EXECUTED,
followUpRequired: false,
primaryCauseFamily: 'none',
summary: 'This record proves preview truth, not tenant recovery.',
primaryNextAction: 'review_preview',
recoveryClaimBoundary: 'preview_only_no_execution_proven',
tone: 'gray',
);
}
if ($status === 'failed' || $operationOutcome === 'failed') {
return new RestoreResultAttention(
state: RestoreResultAttention::STATE_FAILED,
followUpRequired: true,
primaryCauseFamily: $this->primaryCauseFamilyForRun($restoreRun),
summary: 'The restore did not complete successfully. Follow-up is still required.',
primaryNextAction: 'review_failures',
recoveryClaimBoundary: 'execution_failed_no_recovery_claim',
tone: 'danger',
);
}
if ($failedItems > 0 || $partialItems > 0 || $failedAssignments > 0 || in_array($operationOutcome, ['partially_succeeded', 'blocked'], true)) {
return new RestoreResultAttention(
state: RestoreResultAttention::STATE_PARTIAL,
followUpRequired: true,
primaryCauseFamily: $this->primaryCauseFamilyForRun($restoreRun),
summary: 'The restore reached a terminal state, but some items or assignments still need follow-up.',
primaryNextAction: 'review_partial_items',
recoveryClaimBoundary: 'run_completed_not_recovery_proven',
tone: 'warning',
);
}
if ($skippedItems > 0 || $skippedAssignments > 0 || $foundationSkips > 0 || (int) (($restoreRun->metadata ?? [])['non_applied'] ?? 0) > 0) {
return new RestoreResultAttention(
state: RestoreResultAttention::STATE_COMPLETED_WITH_FOLLOW_UP,
followUpRequired: true,
primaryCauseFamily: $this->primaryCauseFamilyForRun($restoreRun),
summary: 'The restore completed, but follow-up remains for skipped or non-applied work.',
primaryNextAction: 'review_skipped_items',
recoveryClaimBoundary: 'run_completed_not_recovery_proven',
tone: 'warning',
);
}
return new RestoreResultAttention(
state: RestoreResultAttention::STATE_COMPLETED,
followUpRequired: false,
primaryCauseFamily: 'none',
summary: 'The restore completed without visible follow-up, but this still does not prove tenant-wide recovery.',
primaryNextAction: 'review_result',
recoveryClaimBoundary: 'run_completed_not_recovery_proven',
tone: 'success',
);
}
/**
* @param array<string, mixed>|null $basis
* @return list<string>
*/
public function invalidationReasonsForBasis(
RestoreScopeFingerprint $currentScope,
?array $basis,
mixed $explicitReasons = null,
): array {
$reasons = $this->normalizeReasons($explicitReasons);
if ($basis === null) {
return $reasons;
}
if ($reasons !== []) {
return $reasons;
}
$basisFingerprint = is_string($basis['fingerprint'] ?? null) ? $basis['fingerprint'] : null;
if ($basisFingerprint !== null && $currentScope->matches($basisFingerprint)) {
return [];
}
$basisBackupSetId = is_numeric($basis['backup_set_id'] ?? null) ? (int) $basis['backup_set_id'] : null;
$basisScopeMode = $basis['scope_mode'] ?? null;
$basisSelectedItemIds = is_array($basis['selected_item_ids'] ?? null) ? $basis['selected_item_ids'] : [];
$basisGroupMappingFingerprint = is_string($basis['group_mapping_fingerprint'] ?? null)
? $basis['group_mapping_fingerprint']
: null;
$derivedReasons = [];
if ($basisBackupSetId !== null && $basisBackupSetId !== $currentScope->backupSetId) {
$derivedReasons[] = 'backup_set_changed';
}
if (is_string($basisScopeMode) && $basisScopeMode !== $currentScope->scopeMode) {
$derivedReasons[] = 'scope_mode_changed';
}
if ($this->normalizeIds($basisSelectedItemIds) !== $currentScope->selectedItemIds) {
$derivedReasons[] = 'selected_items_changed';
}
if ($basisGroupMappingFingerprint !== null && $basisGroupMappingFingerprint !== $currentScope->groupMappingFingerprint) {
$derivedReasons[] = 'group_mapping_changed';
}
if ($derivedReasons === [] && $basisFingerprint !== null && ! $currentScope->matches($basisFingerprint)) {
$derivedReasons[] = 'scope_mismatch';
}
return $derivedReasons;
}
private function primaryCauseFamilyForRun(RestoreRun $restoreRun): string
{
$operationContext = is_array($restoreRun->operationRun?->context) ? $restoreRun->operationRun->context : [];
$reasonCode = strtolower((string) ($operationContext['reason_code'] ?? ''));
if ($reasonCode !== '' && (str_contains($reasonCode, 'capability') || str_contains($reasonCode, 'rbac') || str_contains($reasonCode, 'write'))) {
return 'write_gate_or_rbac';
}
$results = is_array($restoreRun->results) ? $restoreRun->results : [];
$items = is_array($results['items'] ?? null) ? array_values($results['items']) : [];
foreach ($items as $item) {
if (! is_array($item)) {
continue;
}
$reason = strtolower((string) ($item['reason'] ?? ''));
$graphMessage = strtolower((string) ($item['graph_error_message'] ?? ''));
if (str_contains($reason, 'mapping') || str_contains($reason, 'group') || str_contains($graphMessage, 'mapping')) {
return 'missing_dependency_or_mapping';
}
if (str_contains($reason, 'metadata only') || str_contains($reason, 'manual')) {
return 'payload_quality';
}
if ($graphMessage !== '' || filled($item['graph_error_code'] ?? null)) {
return 'item_level_failure';
}
}
return 'none';
}
/**
* @return list<string>
*/
private function normalizeReasons(mixed $reasons): array
{
if (! is_array($reasons)) {
return [];
}
$normalized = array_values(array_filter(array_map(static function (mixed $reason): ?string {
if (! is_string($reason)) {
return null;
}
$reason = trim($reason);
return $reason === '' ? null : $reason;
}, $reasons)));
return array_values(array_unique($normalized));
}
/**
* @return list<int>
*/
private function normalizeIds(array $ids): array
{
$normalized = [];
foreach ($ids as $id) {
if (is_int($id) && $id > 0) {
$normalized[] = $id;
continue;
}
if (is_string($id) && ctype_digit($id) && (int) $id > 0) {
$normalized[] = (int) $id;
}
}
$normalized = array_values(array_unique($normalized));
sort($normalized);
return $normalized;
}
}

View File

@ -0,0 +1,199 @@
<?php
declare(strict_types=1);
namespace App\Support\RestoreSafety;
use InvalidArgumentException;
use JsonException;
final readonly class RestoreScopeFingerprint
{
public function __construct(
public ?int $backupSetId,
public string $scopeMode,
/**
* @var list<int>
*/
public array $selectedItemIds,
/**
* @var array<string, string>
*/
public array $groupMapping,
public string $groupMappingFingerprint,
public string $fingerprint,
) {
if (! in_array($this->scopeMode, ['all', 'selected'], true)) {
throw new InvalidArgumentException('Restore scope mode must be all or selected.');
}
}
public static function fromInputs(
mixed $backupSetId,
mixed $scopeMode,
mixed $selectedItemIds,
mixed $groupMapping,
): self {
$normalizedBackupSetId = is_numeric($backupSetId) ? max(1, (int) $backupSetId) : null;
$normalizedScopeMode = $scopeMode === 'selected' ? 'selected' : 'all';
$normalizedSelectedItemIds = self::normalizeItemIds(
$normalizedScopeMode === 'selected' ? $selectedItemIds : []
);
$normalizedGroupMapping = self::normalizeGroupMapping($groupMapping);
$groupMappingFingerprint = self::hashPayload($normalizedGroupMapping);
return new self(
backupSetId: $normalizedBackupSetId,
scopeMode: $normalizedScopeMode,
selectedItemIds: $normalizedSelectedItemIds,
groupMapping: $normalizedGroupMapping,
groupMappingFingerprint: $groupMappingFingerprint,
fingerprint: self::hashPayload([
'backup_set_id' => $normalizedBackupSetId,
'scope_mode' => $normalizedScopeMode,
'selected_item_ids' => $normalizedSelectedItemIds,
'group_mapping_fingerprint' => $groupMappingFingerprint,
]),
);
}
public static function fromArray(mixed $payload): ?self
{
if (! is_array($payload)) {
return null;
}
if (
! array_key_exists('backup_set_id', $payload)
&& ! array_key_exists('scope_mode', $payload)
&& ! array_key_exists('fingerprint', $payload)
) {
return null;
}
return self::fromInputs(
$payload['backup_set_id'] ?? null,
$payload['scope_mode'] ?? null,
$payload['selected_item_ids'] ?? [],
$payload['group_mapping'] ?? [],
);
}
public function matches(?string $fingerprint): bool
{
return is_string($fingerprint)
&& $fingerprint !== ''
&& hash_equals($this->fingerprint, $fingerprint);
}
/**
* @return array{
* backup_set_id: ?int,
* scope_mode: string,
* selected_item_ids: list<int>,
* group_mapping: array<string, string>,
* group_mapping_fingerprint: string,
* fingerprint: string
* }
*/
public function toArray(): array
{
return [
'backup_set_id' => $this->backupSetId,
'scope_mode' => $this->scopeMode,
'selected_item_ids' => $this->selectedItemIds,
'group_mapping' => $this->groupMapping,
'group_mapping_fingerprint' => $this->groupMappingFingerprint,
'fingerprint' => $this->fingerprint,
];
}
/**
* @return list<int>
*/
private static function normalizeItemIds(mixed $selectedItemIds): array
{
if (! is_array($selectedItemIds)) {
return [];
}
$normalized = [];
foreach ($selectedItemIds as $itemId) {
if (is_int($itemId) && $itemId > 0) {
$normalized[] = $itemId;
continue;
}
if (is_string($itemId) && ctype_digit($itemId) && (int) $itemId > 0) {
$normalized[] = (int) $itemId;
}
}
$normalized = array_values(array_unique($normalized));
sort($normalized);
return $normalized;
}
/**
* @return array<string, string>
*/
private static function normalizeGroupMapping(mixed $groupMapping): array
{
if ($groupMapping instanceof \Illuminate\Contracts\Support\Arrayable) {
$groupMapping = $groupMapping->toArray();
}
if ($groupMapping instanceof \stdClass) {
$groupMapping = (array) $groupMapping;
}
if (! is_array($groupMapping)) {
return [];
}
$normalized = [];
foreach ($groupMapping as $sourceGroupId => $targetGroupId) {
if (! is_string($sourceGroupId) || trim($sourceGroupId) === '') {
continue;
}
if ($targetGroupId instanceof \BackedEnum) {
$targetGroupId = $targetGroupId->value;
}
if (! is_string($targetGroupId)) {
continue;
}
$targetGroupId = trim($targetGroupId);
if ($targetGroupId === '') {
continue;
}
$normalized[trim($sourceGroupId)] = strtoupper($targetGroupId) === 'SKIP'
? 'SKIP'
: $targetGroupId;
}
ksort($normalized);
return $normalized;
}
/**
* @param array<mixed> $payload
*/
private static function hashPayload(array $payload): string
{
try {
return hash('sha256', json_encode($payload, JSON_THROW_ON_ERROR));
} catch (JsonException $exception) {
throw new InvalidArgumentException('Restore scope payload could not be fingerprinted.', previous: $exception);
}
}
}

View File

@ -120,6 +120,36 @@
], ],
], ],
'backup_health' => [
'freshness_hours' => (int) env('TENANTPILOT_BACKUP_HEALTH_FRESHNESS_HOURS', 24),
'schedule_overdue_grace_minutes' => (int) env('TENANTPILOT_BACKUP_HEALTH_SCHEDULE_OVERDUE_GRACE_MINUTES', 30),
'browser_smoke_fixture' => [
'workspace' => [
'name' => env('TENANTPILOT_BACKUP_HEALTH_SMOKE_WORKSPACE_NAME', 'Spec 180 Backup Health Smoke'),
'slug' => env('TENANTPILOT_BACKUP_HEALTH_SMOKE_WORKSPACE_SLUG', 'spec-180-backup-health-smoke'),
],
'user' => [
'name' => env('TENANTPILOT_BACKUP_HEALTH_SMOKE_USER_NAME', 'Spec 180 Requester'),
'email' => env('TENANTPILOT_BACKUP_HEALTH_SMOKE_USER_EMAIL', 'smoke-requester+180@tenantpilot.local'),
'password' => env('TENANTPILOT_BACKUP_HEALTH_SMOKE_USER_PASSWORD', 'password'),
],
'blocked_drillthrough' => [
'tenant_name' => env('TENANTPILOT_BACKUP_HEALTH_SMOKE_TENANT_NAME', 'Spec 180 Blocked Backup Tenant'),
'tenant_external_id' => env('TENANTPILOT_BACKUP_HEALTH_SMOKE_TENANT_EXTERNAL_ID', '18000000-0000-4000-8000-000000000180'),
'tenant_id' => env('TENANTPILOT_BACKUP_HEALTH_SMOKE_TENANT_ID', '18000000-0000-4000-8000-000000000180'),
'app_client_id' => env('TENANTPILOT_BACKUP_HEALTH_SMOKE_APP_CLIENT_ID', '18000000-0000-4000-8000-000000000182'),
'policy_external_id' => env('TENANTPILOT_BACKUP_HEALTH_SMOKE_POLICY_EXTERNAL_ID', 'spec-180-rbac-stale-policy'),
'policy_type' => env('TENANTPILOT_BACKUP_HEALTH_SMOKE_POLICY_TYPE', 'settingsCatalogPolicy'),
'policy_name' => env('TENANTPILOT_BACKUP_HEALTH_SMOKE_POLICY_NAME', 'Spec 180 RBAC Smoke Policy'),
'backup_set_name' => env('TENANTPILOT_BACKUP_HEALTH_SMOKE_BACKUP_SET_NAME', 'Spec 180 Blocked Stale Backup'),
'stale_age_hours' => (int) env('TENANTPILOT_BACKUP_HEALTH_SMOKE_STALE_AGE_HOURS', 48),
'capability_denials' => [
\App\Support\Auth\Capabilities::TENANT_VIEW,
],
],
],
],
'allow_admin_maintenance_actions' => (bool) env('ALLOW_ADMIN_MAINTENANCE_ACTIONS', false), 'allow_admin_maintenance_actions' => (bool) env('ALLOW_ADMIN_MAINTENANCE_ACTIONS', false),
'supported_policy_types' => [ 'supported_policy_types' => [

View File

@ -5,7 +5,7 @@ # Spec Candidates
> >
> **Flow**: Inbox → Qualified → Planned → Spec created → moved to `Promoted to Spec` > **Flow**: Inbox → Qualified → Planned → Spec created → moved to `Promoted to Spec`
**Last reviewed**: 2026-03-28 (added request-scoped performance foundation candidates for derived state, governance aggregates, and workspace access context) **Last reviewed**: 2026-04-07 (added UI Discipline Trilogy: Record Page Header Discipline, Monitoring Surface Action Hierarchy, Governance Friction & Vocabulary Hardening)
--- ---
@ -1480,6 +1480,91 @@ ### Detail Page Hierarchy & Progressive Disclosure (UI/UX Audit)
- **Status**: Directly covered by Spec 133 (View Page Template Standard for Enterprise Detail Screens). Spec 133 defines the shared enterprise detail-page composition standard including summary-first header, main-and-supporting layout, dedicated related-context section, secondary technical detail separation, optional section support, and degraded-state resilience. Spec.md, plan.md, research.md, data-model.md, and tasks.md (all tasks complete) exist for 4 initial target pages (BaselineSnapshot, BackupSet, EntraGroup, OperationRun). If additional pages require alignment beyond the initial 4 targets, that is a Spec 133 follow-up scope extension, not a new candidate. - **Status**: Directly covered by Spec 133 (View Page Template Standard for Enterprise Detail Screens). Spec 133 defines the shared enterprise detail-page composition standard including summary-first header, main-and-supporting layout, dedicated related-context section, secondary technical detail separation, optional section support, and degraded-state resilience. Spec.md, plan.md, research.md, data-model.md, and tasks.md (all tasks complete) exist for 4 initial target pages (BaselineSnapshot, BackupSet, EntraGroup, OperationRun). If additional pages require alignment beyond the initial 4 targets, that is a Spec 133 follow-up scope extension, not a new candidate.
- **Reference specs**: 133 - **Reference specs**: 133
### Record Page Header Discipline & Contextual Navigation
- **Type**: hardening
- **Source**: Constitution compliance audit 2026-04 — systematic review of Record/Detail page header-action usage across all View/Detail surfaces
- **Problem**: Many Record/Detail pages violate the Constitution by using header actions as a catch-all for navigation, secondary actions, and governance actions. The 5-second-scan rule is broken because the primary action is not clearly prioritized, related navigation sits in the wrong place (equal-weight header buttons instead of inline context), and danger/governance actions are not friction-separated. This is the largest systemic Constitution gap on current View/Detail surfaces.
- **Why it matters**: As long as this pattern drift persists, the UI remains noisy and inconsistent despite strong foundations. Every new View page risks inheriting the same anti-pattern. Fixing this is the single largest visible lever to close the Constitution gap across the product.
- **Proposed direction**:
- Define a binding Constitution rule for Record/Detail page header actions: max one visible primary header action, no navigation in headers, related links inline at context, danger/governance actions separated, rare secondary actions in Action Group
- Define a standard pattern for header actions on Record/Detail pages
- Define a standard pattern for related-context navigation in Infolists (inline, operator-proximate)
- Move navigation out of headers into field/status/relation context
- Roll out the pattern to all affected View/Detail pages
- **Affected surfaces**: Finding Exception, Finding, Tenant Review, Baseline Profile, Evidence Snapshot, Tenant, Provider Connection, and potentially Backup Set, Baseline Snapshot, and other View pages
- **Non-goals**: Queue/Workbench surface restructuring (separate candidate), Monitoring header architecture (separate candidate), general visual redesign, deep layout polish of all pages
- **Acceptance points**:
- Record page Constitution rule is documented
- Affected View pages have no navigation buttons as equal-weight header CTAs
- Each View page has a clearly prioritized primary action
- Danger actions are separated and friction-gated
- Related navigation is inline and operator-proximate
- **Dependencies**: Spec 133 (View Page Template Standard — provides the detail-page layout foundation this candidate builds on for header-action discipline)
- **Related specs / candidates**: Monitoring Surface Action Hierarchy & Workbench Semantics (adjacent but distinct — queue/workbench surfaces need their own rules), Governance Friction & Operator Vocabulary Hardening (complements this with friction/reason-capture/vocabulary hardening)
- **Strategic importance**: This is the central lever to eliminate the largest visible Constitution drift in the product. Recommended as the first of three coordinated UI-discipline specs.
- **Priority**: high
### Monitoring Surface Action Hierarchy & Workbench Semantics
- **Type**: hardening
- **Source**: Constitution compliance audit 2026-04 — Queue/Monitoring/Workbench surfaces mix global surface controls, selection-aware object actions, context navigation, utility actions, and object workflow in the same header/action area
- **Problem**: Queue and Monitoring surfaces mix global surface controls, selection-aware object actions, context navigation, filter/utility actions, and scope/back/context signals in the same equal-weight header area. This is a different problem than Record page header noise — it is an information architecture/workbench semantics question that needs its own rules rather than forcing the Record page header pattern onto surfaces with fundamentally different interaction models.
- **Why it matters**: After Record page header cleanup, these surfaces would remain the next large inconsistent block. Applying Record page rules to Workbench/Queue surfaces would be a category error — they need their own action hierarchy that respects surface-level controls, selection-aware workflows, and scope context.
- **Proposed direction**:
- Define a Constitution/pattern rule for Queue/Workbench/Monitoring surfaces with clear action hierarchy layers: global surface actions, selection-aware object actions, context navigation, filter/utility actions, scope/back/context signals
- Semantically clean up OperateHub/Monitoring headers so these layers are not presented as equal-weight header items
- Extract scope and back-context from header-action noise
- Correctly place selection-based workflow actions
- Move navigation out of global headers
- Make workbench/detail-pane/selection flows cleaner
- **Affected surfaces**: Finding Exceptions Queue, Tenantless Operation Viewer, Operations, and potentially Alerts, Audit Log, and other Monitoring pages with OperateHub/scope/selection patterns
- **Non-goals**: Solving all Record/View page problems (separate candidate), reinventing the sidebar/navigation, building new large Monitoring features
- **Acceptance points**:
- Queue/Monitoring Constitution rule is defined
- Global and object-level actions are clearly separated
- Selection-aware governance actions are not in the same header bucket as utility/navigation
- Scope/back context is cleanly placed
- Monitoring surfaces are noticeably calmer and faster to scan
- **Dependencies**: Record Page Header Discipline (recommended to ship first — establishes the Record page header contract that this candidate explicitly does not reuse for Workbench surfaces)
- **Related specs / candidates**: Record Page Header Discipline & Contextual Navigation (adjacent — Record pages vs Workbench surfaces), Governance Friction & Operator Vocabulary Hardening (complements with friction/vocabulary hardening)
- **Strategic importance**: Prevents applying a wrong solution (Record page header rules) to a fundamentally different surface class. Recommended as the second of three coordinated UI-discipline specs.
- **Priority**: high
### Governance Friction & Operator Vocabulary Hardening
- **Type**: hardening
- **Source**: Constitution compliance audit 2026-04 — residual gaps in friction, reason capture, and UI vocabulary consistency across governance-impacting actions
- **Problem**: Smaller but important gaps exist in governance friction, reason capture, and UI vocabulary. These are not large enough for the Header/Workbench architecture specs but are critical for enterprise trust, auditability, and consistent operator language. Governance-changing actions lack consistent friction rules, reason capture is missing where governance truth or review/acceptance decisions are affected, danger/confirmation standards vary, and UI vocabulary has naming outliers that diverge from the Constitution.
- **Why it matters**: This is the right finishing step after the two architecture-focused specs. Without it, the product would have clean action architecture but inconsistent governance friction and operator language — undermining the enterprise-trust story. It turns a "better UI" into a credible governance-of-record surface.
- **Proposed direction**:
- Close confirmation/reason-capture gaps across governance-impacting actions
- Define a friction heuristic: when only confirm, when optional reason, when mandatory reason
- Unify danger semantics across all destructive actions
- Fix individual naming/label outliers that diverge from Constitution vocabulary
- Harden action naming and operator-facing wording for remaining inconsistencies
- **Affected surfaces**: Exception approval/reject flows, evidence/review publish/expire/revoke/renew patterns, individual Resources with inconsistent labels, shared helpers / review heuristics / guardrails where applicable
- **Non-goals**: Large IA refactor, re-touching all Monitoring/Record patterns, cosmetic text changes without semantic relevance
- **Acceptance points**:
- Governance friction rules are clearly documented
- Relevant actions have consistent confirmation/reason semantics
- Danger semantics are unified
- Named UI outliers are corrected
- Operator-facing wording follows the Constitution more closely
- **Dependencies**: Record Page Header Discipline (recommended first), Monitoring Surface Action Hierarchy (recommended second) — this spec is designed as the targeted finishing step
- **Related specs / candidates**: Record Page Header Discipline & Contextual Navigation, Monitoring Surface Action Hierarchy & Workbench Semantics, Spec 156 (operator-outcome-taxonomy), Spec 157 (reason-code-translation)
- **Strategic importance**: The targeted closer that turns structural UI improvements into a credible governance-of-record surface. Recommended as the third and final spec in the coordinated UI-discipline trilogy.
- **Priority**: high
> **UI Discipline Trilogy — Sequencing Note**
>
> These three candidates form a coordinated trilogy that addresses the largest remaining Constitution drift in the product UI:
>
> 1. **Record Page Header Discipline & Contextual Navigation** — largest visible lever; establishes the binding header-action contract for all Record/Detail pages
> 2. **Monitoring Surface Action Hierarchy & Workbench Semantics** — separates Workbench/Queue surfaces from Record page rules; defines the action hierarchy for Monitoring surfaces
> 3. **Governance Friction & Operator Vocabulary Hardening** — targeted finishing step for friction, reason capture, and vocabulary consistency
>
> **Why this order:** Record pages are the most numerous and most directly visible gap. Monitoring surfaces need their own rules (not a Record page derivative). Governance friction is the smallest scope and benefits from the architectural cleanup of the first two specs.
>
> **Why three specs instead of one:** Each has different affected surfaces, different interaction models, and different implementation patterns. Merging them would create an unshippable monolith. Keeping them sequenced preserves independent delivery while converging on one coherent UI discipline.
--- ---
## Planned ## Planned

View File

@ -7,11 +7,20 @@
$summary = $summary ?? []; $summary = $summary ?? [];
$summary = is_array($summary) ? $summary : []; $summary = is_array($summary) ? $summary : [];
$blocking = (int) ($summary['blocking'] ?? 0); $checksIntegrity = $checksIntegrity ?? [];
$warning = (int) ($summary['warning'] ?? 0); $checksIntegrity = is_array($checksIntegrity) ? $checksIntegrity : [];
$executionReadiness = $executionReadiness ?? [];
$executionReadiness = is_array($executionReadiness) ? $executionReadiness : [];
$safetyAssessment = $safetyAssessment ?? [];
$safetyAssessment = is_array($safetyAssessment) ? $safetyAssessment : [];
$blocking = (int) ($summary['blocking'] ?? ($checksIntegrity['blocking_count'] ?? 0));
$warning = (int) ($summary['warning'] ?? ($checksIntegrity['warning_count'] ?? 0));
$safe = (int) ($summary['safe'] ?? 0); $safe = (int) ($summary['safe'] ?? 0);
$ranAt = $ranAt ?? null; $ranAt = $ranAt ?? ($checksIntegrity['ran_at'] ?? null);
$ranAtLabel = null; $ranAtLabel = null;
if (is_string($ranAt) && $ranAt !== '') { if (is_string($ranAt) && $ranAt !== '') {
@ -26,6 +35,12 @@
return \App\Support\Badges\BadgeRenderer::spec(\App\Support\Badges\BadgeDomain::RestoreCheckSeverity, $severity); return \App\Support\Badges\BadgeRenderer::spec(\App\Support\Badges\BadgeDomain::RestoreCheckSeverity, $severity);
}; };
$integritySpec = $severitySpec($checksIntegrity['state'] ?? 'not_run');
$integritySummary = $checksIntegrity['display_summary'] ?? 'Run checks for the current scope before real execution.';
$nextAction = $safetyAssessment['primary_next_action'] ?? 'rerun_checks';
$nextActionLabel = \App\Support\RestoreSafety\RestoreSafetyCopy::primaryNextAction(is_string($nextAction) ? $nextAction : 'rerun_checks');
$startabilitySummary = $executionReadiness['display_summary'] ?? 'Execution readiness is unavailable.';
$startabilityTone = (bool) ($executionReadiness['allowed'] ?? false) ? 'success' : 'warning';
$limitedList = static function (array $items, int $limit = 5): array { $limitedList = static function (array $items, int $limit = 5): array {
if (count($items) <= $limit) { if (count($items) <= $limit) {
return $items; return $items;
@ -39,25 +54,65 @@
<div class="space-y-4"> <div class="space-y-4">
<x-filament::section <x-filament::section
heading="Safety checks" heading="Safety checks"
:description="$ranAtLabel ? ('Last run: ' . $ranAtLabel) : 'Run checks to evaluate risk before previewing.'" :description="$ranAtLabel ? ('Last run: ' . $ranAtLabel) : 'Checks tell you whether the current scope can be defended, not just whether it can start.'"
> >
<div class="flex flex-wrap gap-2"> <div class="space-y-3">
<x-filament::badge :color="$blocking > 0 ? $severitySpec('blocking')->color : 'gray'"> <div class="flex flex-wrap gap-2">
{{ $blocking }} {{ \Illuminate\Support\Str::lower($severitySpec('blocking')->label) }} <x-filament::badge :color="$integritySpec->color" :icon="$integritySpec->icon" size="sm">
</x-filament::badge> {{ $integritySpec->label }}
<x-filament::badge :color="$warning > 0 ? $severitySpec('warning')->color : 'gray'"> </x-filament::badge>
{{ $warning }} {{ \Illuminate\Support\Str::lower($severitySpec('warning')->label) }} <x-filament::badge :color="$startabilityTone" size="sm">
</x-filament::badge> {{ (bool) ($executionReadiness['allowed'] ?? false) ? 'Technically startable' : 'Technical blocker present' }}
<x-filament::badge :color="$safe > 0 ? $severitySpec('safe')->color : 'gray'"> </x-filament::badge>
{{ $safe }} {{ \Illuminate\Support\Str::lower($severitySpec('safe')->label) }} @if (($safetyAssessment['state'] ?? null) === 'ready_with_caution')
</x-filament::badge> <x-filament::badge color="warning" size="sm">
Ready with caution
</x-filament::badge>
@elseif (($safetyAssessment['state'] ?? null) === 'ready')
<x-filament::badge color="success" size="sm">
Ready
</x-filament::badge>
@endif
</div>
<div class="rounded-lg border border-slate-200 bg-slate-50 px-3 py-3 text-sm text-slate-900 dark:border-white/10 dark:bg-white/5 dark:text-slate-100">
<div class="font-medium">What the current checks prove</div>
<div class="mt-1">{{ $integritySummary }}</div>
<div class="mt-2 text-xs text-slate-600 dark:text-slate-300">
Technical startability: {{ $startabilitySummary }}
</div>
<div class="mt-2 text-xs font-semibold uppercase tracking-wide text-slate-500 dark:text-slate-400">
Primary next step
</div>
<div class="mt-1 text-xs text-slate-600 dark:text-slate-300">
{{ $nextActionLabel }}
</div>
</div>
<div class="flex flex-wrap gap-2">
<x-filament::badge :color="$blocking > 0 ? $severitySpec('blocking')->color : 'gray'">
{{ $blocking }} {{ \Illuminate\Support\Str::lower($severitySpec('blocking')->label) }}
</x-filament::badge>
<x-filament::badge :color="$warning > 0 ? $severitySpec('warning')->color : 'gray'">
{{ $warning }} {{ \Illuminate\Support\Str::lower($severitySpec('warning')->label) }}
</x-filament::badge>
<x-filament::badge :color="$safe > 0 ? $severitySpec('safe')->color : 'gray'">
{{ $safe }} {{ \Illuminate\Support\Str::lower($severitySpec('safe')->label) }}
</x-filament::badge>
</div>
@if (($checksIntegrity['invalidation_reasons'] ?? []) !== [])
<div class="text-xs text-amber-800 dark:text-amber-200">
Invalidated by: {{ implode(', ', array_map(static fn (string $reason): string => \Illuminate\Support\Str::replace('_', ' ', $reason), $checksIntegrity['invalidation_reasons'])) }}
</div>
@endif
</div> </div>
</x-filament::section> </x-filament::section>
@if ($results === []) @if ($results === [])
<x-filament::section> <x-filament::section>
<div class="text-sm text-gray-600 dark:text-gray-300"> <div class="text-sm text-gray-600 dark:text-gray-300">
No checks have been run yet. No checks have been recorded for this scope yet.
</div> </div>
</x-filament::section> </x-filament::section>
@else @else
@ -69,9 +124,9 @@
$message = is_array($result) ? ($result['message'] ?? null) : null; $message = is_array($result) ? ($result['message'] ?? null) : null;
$meta = is_array($result) ? ($result['meta'] ?? []) : []; $meta = is_array($result) ? ($result['meta'] ?? []) : [];
$meta = is_array($meta) ? $meta : []; $meta = is_array($meta) ? $meta : [];
$unmappedGroups = $meta['unmapped'] ?? []; $unmappedGroups = $meta['unmapped'] ?? [];
$unmappedGroups = is_array($unmappedGroups) ? $limitedList($unmappedGroups) : []; $unmappedGroups = is_array($unmappedGroups) ? $limitedList($unmappedGroups) : [];
$spec = $severitySpec($severity);
@endphp @endphp
<x-filament::section> <x-filament::section>
@ -87,10 +142,6 @@
@endif @endif
</div> </div>
@php
$spec = $severitySpec($severity);
@endphp
<x-filament::badge :color="$spec->color" :icon="$spec->icon" size="sm"> <x-filament::badge :color="$spec->color" :icon="$spec->icon" size="sm">
{{ $spec->label }} {{ $spec->label }}
</x-filament::badge> </x-filament::badge>

View File

@ -7,7 +7,16 @@
$summary = $summary ?? []; $summary = $summary ?? [];
$summary = is_array($summary) ? $summary : []; $summary = is_array($summary) ? $summary : [];
$ranAt = $ranAt ?? null; $previewIntegrity = $previewIntegrity ?? [];
$previewIntegrity = is_array($previewIntegrity) ? $previewIntegrity : [];
$checksIntegrity = $checksIntegrity ?? [];
$checksIntegrity = is_array($checksIntegrity) ? $checksIntegrity : [];
$safetyAssessment = $safetyAssessment ?? [];
$safetyAssessment = is_array($safetyAssessment) ? $safetyAssessment : [];
$ranAt = $ranAt ?? ($previewIntegrity['generated_at'] ?? null);
$ranAtLabel = null; $ranAtLabel = null;
if (is_string($ranAt) && $ranAt !== '') { if (is_string($ranAt) && $ranAt !== '') {
@ -18,12 +27,19 @@
} }
} }
$integritySpec = \App\Support\Badges\BadgeRenderer::spec(
\App\Support\Badges\BadgeDomain::RestorePreviewDecision,
$previewIntegrity['state'] ?? 'not_generated'
);
$policiesTotal = (int) ($summary['policies_total'] ?? 0); $policiesTotal = (int) ($summary['policies_total'] ?? 0);
$policiesChanged = (int) ($summary['policies_changed'] ?? 0); $policiesChanged = (int) ($summary['policies_changed'] ?? 0);
$assignmentsChanged = (int) ($summary['assignments_changed'] ?? 0); $assignmentsChanged = (int) ($summary['assignments_changed'] ?? 0);
$scopeTagsChanged = (int) ($summary['scope_tags_changed'] ?? 0); $scopeTagsChanged = (int) ($summary['scope_tags_changed'] ?? 0);
$diffsOmitted = (int) ($summary['diffs_omitted'] ?? 0); $diffsOmitted = (int) ($summary['diffs_omitted'] ?? 0);
$integritySummary = $previewIntegrity['display_summary'] ?? 'Generate a preview before real execution.';
$nextAction = $safetyAssessment['primary_next_action'] ?? 'generate_preview';
$nextActionLabel = \App\Support\RestoreSafety\RestoreSafetyCopy::primaryNextAction(is_string($nextAction) ? $nextAction : 'generate_preview');
$limitedKeys = static function (array $items, int $limit = 8): array { $limitedKeys = static function (array $items, int $limit = 8): array {
$keys = array_keys($items); $keys = array_keys($items);
@ -39,22 +55,57 @@
<div class="space-y-4"> <div class="space-y-4">
<x-filament::section <x-filament::section
heading="Preview" heading="Preview"
:description="$ranAtLabel ? ('Generated: ' . $ranAtLabel) : 'Generate a preview to see what would change.'" :description="$ranAtLabel ? ('Generated: ' . $ranAtLabel) : 'Preview answers what would change for the current scope.'"
> >
<div class="flex flex-wrap gap-2"> <div class="space-y-3">
<x-filament::badge :color="$policiesChanged > 0 ? 'warning' : 'success'"> <div class="flex flex-wrap gap-2">
{{ $policiesChanged }}/{{ $policiesTotal }} policies changed <x-filament::badge :color="$integritySpec->color" :icon="$integritySpec->icon" size="sm">
</x-filament::badge> {{ $integritySpec->label }}
<x-filament::badge :color="$assignmentsChanged > 0 ? 'warning' : 'gray'">
{{ $assignmentsChanged }} assignments changed
</x-filament::badge>
<x-filament::badge :color="$scopeTagsChanged > 0 ? 'warning' : 'gray'">
{{ $scopeTagsChanged }} scope tags changed
</x-filament::badge>
@if ($diffsOmitted > 0)
<x-filament::badge color="gray">
{{ $diffsOmitted }} diffs omitted (limit)
</x-filament::badge> </x-filament::badge>
@if (($checksIntegrity['state'] ?? null) === 'current')
<x-filament::badge color="success" size="sm">
Checks current
</x-filament::badge>
@endif
@if (($safetyAssessment['state'] ?? null) === 'ready_with_caution')
<x-filament::badge color="warning" size="sm">
Calm readiness suppressed
</x-filament::badge>
@endif
</div>
<div class="rounded-lg border border-slate-200 bg-slate-50 px-3 py-3 text-sm text-slate-900 dark:border-white/10 dark:bg-white/5 dark:text-slate-100">
<div class="font-medium">What the preview proves</div>
<div class="mt-1">{{ $integritySummary }}</div>
<div class="mt-2 text-xs font-semibold uppercase tracking-wide text-slate-500 dark:text-slate-400">
Primary next step
</div>
<div class="mt-1 text-xs text-slate-600 dark:text-slate-300">
{{ $nextActionLabel }}
</div>
</div>
<div class="flex flex-wrap gap-2">
<x-filament::badge :color="$policiesChanged > 0 ? 'warning' : 'success'">
{{ $policiesChanged }}/{{ $policiesTotal }} policies changed
</x-filament::badge>
<x-filament::badge :color="$assignmentsChanged > 0 ? 'warning' : 'gray'">
{{ $assignmentsChanged }} assignments changed
</x-filament::badge>
<x-filament::badge :color="$scopeTagsChanged > 0 ? 'warning' : 'gray'">
{{ $scopeTagsChanged }} scope tags changed
</x-filament::badge>
@if ($diffsOmitted > 0)
<x-filament::badge color="gray">
{{ $diffsOmitted }} diffs omitted (limit)
</x-filament::badge>
@endif
</div>
@if (($previewIntegrity['invalidation_reasons'] ?? []) !== [])
<div class="text-xs text-amber-800 dark:text-amber-200">
Invalidated by: {{ implode(', ', array_map(static fn (string $reason): string => \Illuminate\Support\Str::replace('_', ' ', $reason), $previewIntegrity['invalidation_reasons'])) }}
</div>
@endif @endif
</div> </div>
</x-filament::section> </x-filament::section>
@ -62,7 +113,7 @@
@if ($diffs === []) @if ($diffs === [])
<x-filament::section> <x-filament::section>
<div class="text-sm text-gray-600 dark:text-gray-300"> <div class="text-sm text-gray-600 dark:text-gray-300">
No preview generated yet. No preview diff is recorded for this scope yet.
</div> </div>
</x-filament::section> </x-filament::section>
@else @else
@ -76,16 +127,13 @@
$action = $entry['action'] ?? 'update'; $action = $entry['action'] ?? 'update';
$diff = is_array($entry['diff'] ?? null) ? $entry['diff'] : []; $diff = is_array($entry['diff'] ?? null) ? $entry['diff'] : [];
$diffSummary = is_array($diff['summary'] ?? null) ? $diff['summary'] : []; $diffSummary = is_array($diff['summary'] ?? null) ? $diff['summary'] : [];
$added = (int) ($diffSummary['added'] ?? 0); $added = (int) ($diffSummary['added'] ?? 0);
$removed = (int) ($diffSummary['removed'] ?? 0); $removed = (int) ($diffSummary['removed'] ?? 0);
$changed = (int) ($diffSummary['changed'] ?? 0); $changed = (int) ($diffSummary['changed'] ?? 0);
$assignmentsDelta = (bool) ($entry['assignments_changed'] ?? false); $assignmentsDelta = (bool) ($entry['assignments_changed'] ?? false);
$scopeTagsDelta = (bool) ($entry['scope_tags_changed'] ?? false); $scopeTagsDelta = (bool) ($entry['scope_tags_changed'] ?? false);
$diffOmitted = (bool) ($entry['diff_omitted'] ?? false); $diffOmitted = (bool) ($entry['diff_omitted'] ?? false);
$diffTruncated = (bool) ($entry['diff_truncated'] ?? false); $diffTruncated = (bool) ($entry['diff_truncated'] ?? false);
$changedKeys = $limitedKeys(is_array($diff['changed'] ?? null) ? $diff['changed'] : []); $changedKeys = $limitedKeys(is_array($diff['changed'] ?? null) ? $diff['changed'] : []);
$addedKeys = $limitedKeys(is_array($diff['added'] ?? null) ? $diff['added'] : []); $addedKeys = $limitedKeys(is_array($diff['added'] ?? null) ? $diff['added'] : []);
$removedKeys = $limitedKeys(is_array($diff['removed'] ?? null) ? $diff['removed'] : []); $removedKeys = $limitedKeys(is_array($diff['removed'] ?? null) ? $diff['removed'] : []);

View File

@ -1,5 +1,28 @@
@php @php
$preview = $getState() ?? []; $state = $getState() ?? [];
$state = is_array($state) ? $state : [];
$preview = is_array($state['preview'] ?? null) ? $state['preview'] : $state;
$previewIntegrity = is_array($state['previewIntegrity'] ?? null) ? $state['previewIntegrity'] : [];
$checksIntegrity = is_array($state['checksIntegrity'] ?? null) ? $state['checksIntegrity'] : [];
$executionSafetySnapshot = is_array($state['executionSafetySnapshot'] ?? null) ? $state['executionSafetySnapshot'] : [];
$scopeBasis = is_array($state['scopeBasis'] ?? null) ? $state['scopeBasis'] : [];
$integritySpec = \App\Support\Badges\BadgeRenderer::spec(
\App\Support\Badges\BadgeDomain::RestorePreviewDecision,
$previewIntegrity['state'] ?? 'not_generated'
);
$checksSpec = \App\Support\Badges\BadgeRenderer::spec(
\App\Support\Badges\BadgeDomain::RestoreCheckSeverity,
$checksIntegrity['state'] ?? 'not_run'
);
$recoveryBoundary = \App\Support\RestoreSafety\RestoreSafetyCopy::recoveryBoundary(
is_string($executionSafetySnapshot['follow_up_boundary'] ?? null)
? $executionSafetySnapshot['follow_up_boundary']
: 'preview_only_no_execution_proven'
);
$actionPresentation = static function (array $item): array { $actionPresentation = static function (array $item): array {
$action = is_string($item['action'] ?? null) ? $item['action'] : null; $action = is_string($item['action'] ?? null) ? $item['action'] : null;
@ -9,6 +32,7 @@
default => ['label' => \Illuminate\Support\Str::headline((string) ($action ?? 'action')), 'color' => 'gray'], default => ['label' => \Illuminate\Support\Str::headline((string) ($action ?? 'action')), 'color' => 'gray'],
}; };
}; };
$foundationItems = collect($preview)->filter(function ($item) { $foundationItems = collect($preview)->filter(function ($item) {
return is_array($item) && array_key_exists('decision', $item) && array_key_exists('sourceId', $item); return is_array($item) && array_key_exists('decision', $item) && array_key_exists('sourceId', $item);
}); });
@ -21,13 +45,54 @@
<p class="text-sm text-gray-600">No preview has been generated yet.</p> <p class="text-sm text-gray-600">No preview has been generated yet.</p>
@else @else
<div class="space-y-4"> <div class="space-y-4">
<div class="rounded-lg border border-slate-200 bg-slate-50 p-4 text-sm text-slate-900 dark:border-white/10 dark:bg-white/5 dark:text-slate-100">
<div class="flex flex-wrap items-center gap-2">
<x-filament::badge :color="$integritySpec->color" :icon="$integritySpec->icon" size="sm">
{{ $integritySpec->label }}
</x-filament::badge>
<x-filament::badge :color="$checksSpec->color" :icon="$checksSpec->icon" size="sm">
{{ $checksSpec->label }}
</x-filament::badge>
</div>
<div class="mt-3 grid gap-3 md:grid-cols-2">
<div>
<div class="text-xs font-semibold uppercase tracking-wide text-slate-500 dark:text-slate-400">What the preview proves</div>
<div class="mt-1">{{ $previewIntegrity['display_summary'] ?? 'Preview basis is unavailable.' }}</div>
</div>
<div>
<div class="text-xs font-semibold uppercase tracking-wide text-slate-500 dark:text-slate-400">What this record does not prove</div>
<div class="mt-1">{{ $recoveryBoundary }}</div>
</div>
</div>
@if (($scopeBasis['fingerprint'] ?? null) !== null)
<div class="mt-3 text-xs text-slate-600 dark:text-slate-300">
Scope mode: {{ $scopeBasis['scope_mode'] ?? 'all' }}
@if (($scopeBasis['selected_item_ids'] ?? []) !== [])
selected items: {{ count($scopeBasis['selected_item_ids']) }}
@endif
</div>
@endif
</div>
@if ($foundationItems->isNotEmpty()) @if ($foundationItems->isNotEmpty())
<div class="space-y-2"> <div class="space-y-2">
<div class="text-xs font-semibold uppercase tracking-wide text-gray-500">Foundations</div> <div class="text-xs font-semibold uppercase tracking-wide text-gray-500">Foundations</div>
@foreach ($foundationItems as $item) @foreach ($foundationItems as $item)
@php @php
$decision = $item['decision'] ?? 'mapped_existing'; $decision = $item['decision'] ?? 'mapped_existing';
$decisionSpec = \App\Support\Badges\BadgeRenderer::spec(\App\Support\Badges\BadgeDomain::RestorePreviewDecision, $decision); $foundationIsPreviewOnly = ($item['reason'] ?? null) === 'preview_only'
|| ($item['restore_mode'] ?? null) === 'preview-only'
|| $decision === 'dry_run';
$decisionSpec = $foundationIsPreviewOnly
? \App\Support\Badges\BadgeRenderer::spec(\App\Support\Badges\BadgeDomain::PolicyRestoreMode, 'preview_only')
: \App\Support\Badges\BadgeRenderer::spec(\App\Support\Badges\BadgeDomain::RestorePreviewDecision, $decision);
$foundationReason = $item['reason'] ?? null;
if ($foundationReason === 'preview_only') {
$foundationReason = 'Preview only. This foundation type is not applied during execution.';
}
@endphp @endphp
<div class="rounded border border-gray-200 bg-white p-3 shadow-sm"> <div class="rounded border border-gray-200 bg-white p-3 shadow-sm">
<div class="flex items-center justify-between text-sm text-gray-800"> <div class="flex items-center justify-between text-sm text-gray-800">
@ -44,9 +109,9 @@
Target: {{ $item['targetName'] }} Target: {{ $item['targetName'] }}
</div> </div>
@endif @endif
@if (! empty($item['reason'])) @if (! empty($foundationReason))
<div class="mt-2 rounded border border-amber-300 bg-amber-50 px-2 py-1 text-xs text-amber-800"> <div class="mt-2 rounded border border-amber-300 bg-amber-50 px-2 py-1 text-xs text-amber-800">
{{ $item['reason'] }} {{ $foundationReason }}
</div> </div>
@endif @endif
</div> </div>
@ -64,16 +129,16 @@
@endphp @endphp
<div class="rounded border border-gray-200 bg-white p-3 shadow-sm"> <div class="rounded border border-gray-200 bg-white p-3 shadow-sm">
<div class="flex items-center justify-between text-sm text-gray-800"> <div class="flex items-center justify-between text-sm text-gray-800">
<span class="font-semibold">{{ $item['policy_identifier'] ?? 'Policy' }}</span> <span class="font-semibold">{{ $item['policy_identifier'] ?? 'Policy' }}</span>
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
@if ($restoreMode === 'preview-only') @if ($restoreMode === 'preview-only')
@php @php
$restoreModeSpec = \App\Support\Badges\BadgeRenderer::spec(\App\Support\Badges\BadgeDomain::PolicyRestoreMode, $restoreMode); $restoreModeSpec = \App\Support\Badges\BadgeRenderer::spec(\App\Support\Badges\BadgeDomain::PolicyRestoreMode, $restoreMode);
@endphp @endphp
<x-filament::badge :color="$restoreModeSpec->color" :icon="$restoreModeSpec->icon" size="sm"> <x-filament::badge :color="$restoreModeSpec->color" :icon="$restoreModeSpec->icon" size="sm">
{{ $restoreModeSpec->label }} {{ $restoreModeSpec->label }}
</x-filament::badge> </x-filament::badge>
@endif @endif
<span class="rounded bg-gray-100 px-2 py-0.5 text-xs font-medium text-gray-700"> <span class="rounded bg-gray-100 px-2 py-0.5 text-xs font-medium text-gray-700">
{{ $actionState['label'] }} {{ $actionState['label'] }}
</span> </span>

View File

@ -1,5 +1,9 @@
@php @php
$state = $getState() ?? []; $state = $getState() ?? [];
$state = is_array($state) ? $state : [];
$resultAttention = is_array($state['resultAttention'] ?? null) ? $state['resultAttention'] : [];
$executionSafetySnapshot = is_array($state['executionSafetySnapshot'] ?? null) ? $state['executionSafetySnapshot'] : [];
$state = is_array($state['results'] ?? null) ? $state['results'] : $state;
$isFoundationEntry = function ($item) { $isFoundationEntry = function ($item) {
return is_array($item) && array_key_exists('decision', $item) && array_key_exists('sourceId', $item); return is_array($item) && array_key_exists('decision', $item) && array_key_exists('sourceId', $item);
}; };
@ -39,21 +43,85 @@
<p class="text-sm text-gray-600">No restore results have been recorded yet.</p> <p class="text-sm text-gray-600">No restore results have been recorded yet.</p>
@else @else
@php @php
$needsAttention = $policyItems->contains(function ($item) { $needsAttention = (bool) ($resultAttention['follow_up_required'] ?? false)
$status = $item['status'] ?? null; || $policyItems->contains(function ($item) {
$status = $item['status'] ?? null;
return in_array($status, ['partial', 'manual_required'], true); return in_array($status, ['partial', 'manual_required'], true);
}); });
$attentionSpec = \App\Support\Badges\BadgeRenderer::spec(
\App\Support\Badges\BadgeDomain::RestoreResultStatus,
$resultAttention['state'] ?? ($needsAttention ? 'completed_with_follow_up' : 'completed')
);
$executionBasisLabel = \App\Support\RestoreSafety\RestoreSafetyCopy::safetyStateLabel(
is_string($executionSafetySnapshot['safety_state'] ?? null) ? $executionSafetySnapshot['safety_state'] : null
);
$primaryNextAction = \App\Support\RestoreSafety\RestoreSafetyCopy::primaryNextAction(
is_string($resultAttention['primary_next_action'] ?? null) ? $resultAttention['primary_next_action'] : 'review_result'
);
$primaryCauseFamily = \App\Support\RestoreSafety\RestoreSafetyCopy::primaryCauseFamily(
is_string($resultAttention['primary_cause_family'] ?? null) ? $resultAttention['primary_cause_family'] : 'none'
);
$recoveryBoundary = \App\Support\RestoreSafety\RestoreSafetyCopy::recoveryBoundary(
is_string($resultAttention['recovery_claim_boundary'] ?? null)
? $resultAttention['recovery_claim_boundary']
: 'run_completed_not_recovery_proven'
);
@endphp @endphp
<div class="space-y-4"> <div class="space-y-4">
<div class="rounded-lg border border-slate-200 bg-slate-50 p-4 text-sm text-slate-900 dark:border-white/10 dark:bg-white/5 dark:text-slate-100">
<div class="flex flex-wrap items-center gap-2">
<x-filament::badge :color="$attentionSpec->color" :icon="$attentionSpec->icon" size="sm">
{{ $attentionSpec->label }}
</x-filament::badge>
@if (($executionSafetySnapshot['safety_state'] ?? null) !== null)
<x-filament::badge color="gray" size="sm">
Execution basis: {{ $executionBasisLabel }}
</x-filament::badge>
@endif
</div>
<div class="mt-3 grid gap-3 md:grid-cols-2">
<div>
<div class="text-xs font-semibold uppercase tracking-wide text-slate-500 dark:text-slate-400">What this run proves</div>
<div class="mt-1">{{ $resultAttention['summary'] ?? 'Restore result truth is unavailable.' }}</div>
</div>
<div>
<div class="text-xs font-semibold uppercase tracking-wide text-slate-500 dark:text-slate-400">Primary next step</div>
<div class="mt-1">{{ $primaryNextAction }}</div>
</div>
</div>
<div class="mt-3 grid gap-3 md:grid-cols-2">
<div>
<div class="text-xs font-semibold uppercase tracking-wide text-slate-500 dark:text-slate-400">Main follow-up driver</div>
<div class="mt-1 text-xs text-slate-600 dark:text-slate-300">{{ $primaryCauseFamily }}</div>
</div>
<div>
<div class="text-xs font-semibold uppercase tracking-wide text-slate-500 dark:text-slate-400">What this record does not prove</div>
<div class="mt-1 text-xs text-slate-600 dark:text-slate-300">{{ $recoveryBoundary }}</div>
</div>
</div>
</div>
@if ($foundationItems->isNotEmpty()) @if ($foundationItems->isNotEmpty())
<div class="space-y-2"> <div class="space-y-2">
<div class="text-xs font-semibold uppercase tracking-wide text-gray-500">Foundations</div> <div class="text-xs font-semibold uppercase tracking-wide text-gray-500">Foundations</div>
@foreach ($foundationItems as $item) @foreach ($foundationItems as $item)
@php @php
$decision = $item['decision'] ?? 'mapped_existing'; $decision = $item['decision'] ?? 'mapped_existing';
$decisionSpec = \App\Support\Badges\BadgeRenderer::spec(\App\Support\Badges\BadgeDomain::RestorePreviewDecision, $decision); $foundationIsPreviewOnly = ($item['reason'] ?? null) === 'preview_only'
|| ($item['restore_mode'] ?? null) === 'preview-only'
|| $decision === 'dry_run';
$decisionSpec = $foundationIsPreviewOnly
? \App\Support\Badges\BadgeRenderer::spec(\App\Support\Badges\BadgeDomain::PolicyRestoreMode, 'preview_only')
: \App\Support\Badges\BadgeRenderer::spec(\App\Support\Badges\BadgeDomain::RestorePreviewDecision, $decision);
$foundationReason = $item['reason'] ?? null;
if ($foundationReason === 'preview_only') {
$foundationReason = 'Preview only. This foundation type is not applied during execution.';
}
@endphp @endphp
<div class="rounded border border-gray-200 bg-white p-3 shadow-sm"> <div class="rounded border border-gray-200 bg-white p-3 shadow-sm">
<div class="flex items-center justify-between text-sm text-gray-800"> <div class="flex items-center justify-between text-sm text-gray-800">
@ -70,9 +138,9 @@
Target: {{ $item['targetName'] }} Target: {{ $item['targetName'] }}
</div> </div>
@endif @endif
@if (! empty($item['reason'])) @if (! empty($foundationReason))
<div class="mt-2 rounded border border-amber-200 bg-amber-50 px-2 py-1 text-xs text-amber-900"> <div class="mt-2 rounded border border-amber-200 bg-amber-50 px-2 py-1 text-xs text-amber-900">
{{ $item['reason'] }} {{ $foundationReason }}
</div> </div>
@endif @endif
</div> </div>
@ -82,7 +150,7 @@
@if ($needsAttention) @if ($needsAttention)
<div class="rounded border border-amber-200 bg-amber-50 px-3 py-2 text-sm text-amber-900"> <div class="rounded border border-amber-200 bg-amber-50 px-3 py-2 text-sm text-amber-900">
Some items still need follow-up. Review the per-item details below. {{ $resultAttention['summary'] ?? 'Some items still need follow-up. Review the per-item details below.' }}
</div> </div>
@endif @endif

View File

@ -1,4 +1,6 @@
<x-filament-panels::page> <x-filament-panels::page>
@php($selectedException = $this->selectedFindingException())
<x-filament::section> <x-filament::section>
<div class="flex flex-col gap-3"> <div class="flex flex-col gap-3">
<div class="text-lg font-semibold text-gray-900 dark:text-gray-100"> <div class="text-lg font-semibold text-gray-900 dark:text-gray-100">
@ -11,129 +13,13 @@
</div> </div>
</x-filament::section> </x-filament::section>
{{ $this->table }} @if ($this->showSelectedExceptionSummary && $selectedException)
<x-filament::section>
@php @include('filament.pages.monitoring.partials.finding-exception-queue-sidebar', [
$selectedException = $this->selectedFindingException(); 'selectedException' => $selectedException,
@endphp ])
@if ($selectedException)
<x-filament::section
:heading="'Finding exception #'.$selectedException->getKey()"
:description="$selectedException->requested_at?->toDayDateTimeString()"
>
<div class="grid gap-4 lg:grid-cols-3">
<div class="rounded-2xl border border-gray-200 bg-white p-4 shadow-sm dark:border-gray-800 dark:bg-gray-900">
<div class="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">
Status
</div>
<div class="mt-2 text-sm font-medium text-gray-900 dark:text-gray-100">
{{ \App\Support\Badges\BadgeRenderer::label(\App\Support\Badges\BadgeDomain::FindingExceptionStatus)($selectedException->status) }}
</div>
<div class="mt-1 text-sm text-gray-600 dark:text-gray-300">
{{ \App\Support\Badges\BadgeRenderer::label(\App\Support\Badges\BadgeDomain::FindingRiskGovernanceValidity)($selectedException->current_validity_state) }}
</div>
@php
$governanceWarning = app(\App\Services\Findings\FindingRiskGovernanceResolver::class)->resolveWarningMessage($selectedException->finding, $selectedException);
$governanceWarningColor = (string) $selectedException->current_validity_state === \App\Models\FindingException::VALIDITY_EXPIRING
? 'text-warning-700 dark:text-warning-300'
: 'text-danger-700 dark:text-danger-300';
@endphp
@if (filled($governanceWarning))
<div class="mt-3 text-sm {{ $governanceWarningColor }}">
{{ $governanceWarning }}
</div>
@endif
</div>
<div class="rounded-2xl border border-gray-200 bg-white p-4 shadow-sm dark:border-gray-800 dark:bg-gray-900">
<div class="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">
Scope
</div>
<div class="mt-2 text-sm font-medium text-gray-900 dark:text-gray-100">
{{ $selectedException->tenant?->name ?? 'Unknown tenant' }}
</div>
<div class="mt-1 text-sm text-gray-600 dark:text-gray-300">
Finding #{{ $selectedException->finding_id }}
</div>
</div>
<div class="rounded-2xl border border-gray-200 bg-white p-4 shadow-sm dark:border-gray-800 dark:bg-gray-900">
<div class="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">
Review timing
</div>
<div class="mt-2 text-sm font-medium text-gray-900 dark:text-gray-100">
Review due {{ $selectedException->review_due_at?->toDayDateTimeString() ?? '—' }}
</div>
<div class="mt-1 text-sm text-gray-600 dark:text-gray-300">
Expires {{ $selectedException->expires_at?->toDayDateTimeString() ?? '—' }}
</div>
</div>
</div>
<div class="mt-6 grid gap-4 lg:grid-cols-2">
<div class="rounded-2xl border border-gray-200 bg-white p-4 shadow-sm dark:border-gray-800 dark:bg-gray-900">
<div class="text-sm font-semibold text-gray-900 dark:text-gray-100">
Request
</div>
<dl class="mt-3 space-y-3">
<div>
<dt class="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">
Requested by
</dt>
<dd class="mt-1 text-sm text-gray-900 dark:text-gray-100">
{{ $selectedException->requester?->name ?? 'Unknown requester' }}
</dd>
</div>
<div>
<dt class="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">
Owner
</dt>
<dd class="mt-1 text-sm text-gray-900 dark:text-gray-100">
{{ $selectedException->owner?->name ?? 'Unassigned' }}
</dd>
</div>
<div>
<dt class="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">
Reason
</dt>
<dd class="mt-1 text-sm text-gray-900 dark:text-gray-100">
{{ $selectedException->request_reason }}
</dd>
</div>
</dl>
</div>
<div class="rounded-2xl border border-gray-200 bg-white p-4 shadow-sm dark:border-gray-800 dark:bg-gray-900">
<div class="text-sm font-semibold text-gray-900 dark:text-gray-100">
Decision history
</div>
@if ($selectedException->decisions->isEmpty())
<div class="mt-3 text-sm text-gray-600 dark:text-gray-300">
No decisions have been recorded yet.
</div>
@else
<div class="mt-3 space-y-3">
@foreach ($selectedException->decisions as $decision)
<div class="rounded-xl border border-gray-200 px-3 py-3 dark:border-gray-800">
<div class="text-sm font-medium text-gray-900 dark:text-gray-100">
{{ ucfirst(str_replace('_', ' ', $decision->decision_type)) }}
</div>
<div class="mt-1 text-xs text-gray-500 dark:text-gray-400">
{{ $decision->actor?->name ?? 'Unknown actor' }} · {{ $decision->decided_at?->toDayDateTimeString() ?? '—' }}
</div>
@if (filled($decision->reason))
<div class="mt-2 text-sm text-gray-700 dark:text-gray-300">
{{ $decision->reason }}
</div>
@endif
</div>
@endforeach
</div>
@endif
</div>
</div>
</x-filament::section> </x-filament::section>
@endif @endif
{{ $this->table }}
</x-filament-panels::page> </x-filament-panels::page>

View File

@ -0,0 +1,110 @@
<div data-testid="finding-exception-slide-over" class="grid gap-4">
<div class="rounded-2xl border border-gray-200 bg-white p-4 shadow-sm dark:border-gray-800 dark:bg-gray-900">
<div class="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">
Status
</div>
<div class="mt-2 text-sm font-medium text-gray-900 dark:text-gray-100">
{{ \App\Support\Badges\BadgeRenderer::label(\App\Support\Badges\BadgeDomain::FindingExceptionStatus)($selectedException->status) }}
</div>
<div class="mt-1 text-sm text-gray-600 dark:text-gray-300">
{{ \App\Support\Badges\BadgeRenderer::label(\App\Support\Badges\BadgeDomain::FindingRiskGovernanceValidity)($selectedException->current_validity_state) }}
</div>
@php
$governanceWarning = app(\App\Services\Findings\FindingRiskGovernanceResolver::class)->resolveWarningMessage($selectedException->finding, $selectedException);
$governanceWarningColor = (string) $selectedException->current_validity_state === \App\Models\FindingException::VALIDITY_EXPIRING
? 'text-warning-700 dark:text-warning-300'
: 'text-danger-700 dark:text-danger-300';
@endphp
@if (filled($governanceWarning))
<div class="mt-3 text-sm {{ $governanceWarningColor }}">
{{ $governanceWarning }}
</div>
@endif
</div>
<div class="rounded-2xl border border-gray-200 bg-white p-4 shadow-sm dark:border-gray-800 dark:bg-gray-900">
<div class="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">
Scope
</div>
<div class="mt-2 text-sm font-medium text-gray-900 dark:text-gray-100">
{{ $selectedException->tenant?->name ?? 'Unknown tenant' }}
</div>
<div class="mt-1 text-sm text-gray-600 dark:text-gray-300">
Finding #{{ $selectedException->finding_id }}
</div>
</div>
<div class="rounded-2xl border border-gray-200 bg-white p-4 shadow-sm dark:border-gray-800 dark:bg-gray-900">
<div class="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">
Review timing
</div>
<div class="mt-2 text-sm font-medium text-gray-900 dark:text-gray-100">
Review due {{ $selectedException->review_due_at?->toDayDateTimeString() ?? '—' }}
</div>
<div class="mt-1 text-sm text-gray-600 dark:text-gray-300">
Expires {{ $selectedException->expires_at?->toDayDateTimeString() ?? '—' }}
</div>
</div>
<div class="rounded-2xl border border-gray-200 bg-white p-4 shadow-sm dark:border-gray-800 dark:bg-gray-900">
<div class="text-sm font-semibold text-gray-900 dark:text-gray-100">
Request
</div>
<dl class="mt-3 space-y-3">
<div>
<dt class="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">
Requested by
</dt>
<dd class="mt-1 text-sm text-gray-900 dark:text-gray-100">
{{ $selectedException->requester?->name ?? 'Unknown requester' }}
</dd>
</div>
<div>
<dt class="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">
Owner
</dt>
<dd class="mt-1 text-sm text-gray-900 dark:text-gray-100">
{{ $selectedException->owner?->name ?? 'Unassigned' }}
</dd>
</div>
<div>
<dt class="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">
Reason
</dt>
<dd class="mt-1 text-sm text-gray-900 dark:text-gray-100">
{{ $selectedException->request_reason }}
</dd>
</div>
</dl>
</div>
<div class="rounded-2xl border border-gray-200 bg-white p-4 shadow-sm dark:border-gray-800 dark:bg-gray-900">
<div class="text-sm font-semibold text-gray-900 dark:text-gray-100">
Decision history
</div>
@if ($selectedException->decisions->isEmpty())
<div class="mt-3 text-sm text-gray-600 dark:text-gray-300">
No decisions have been recorded yet.
</div>
@else
<div class="mt-3 space-y-3">
@foreach ($selectedException->decisions as $decision)
<div class="rounded-xl border border-gray-200 px-3 py-3 dark:border-gray-800">
<div class="text-sm font-medium text-gray-900 dark:text-gray-100">
{{ ucfirst(str_replace('_', ' ', $decision->decision_type)) }}
</div>
<div class="mt-1 text-xs text-gray-500 dark:text-gray-400">
{{ $decision->actor?->name ?? 'Unknown actor' }} · {{ $decision->decided_at?->toDayDateTimeString() ?? '—' }}
</div>
@if (filled($decision->reason))
<div class="mt-2 text-sm text-gray-700 dark:text-gray-300">
{{ $decision->reason }}
</div>
@endif
</div>
@endforeach
</div>
@endif
</div>
</div>

View File

@ -0,0 +1,3 @@
<div class="rounded-2xl border border-dashed border-gray-300 bg-white/80 p-4 text-sm text-gray-600 dark:border-gray-700 dark:bg-gray-900/80 dark:text-gray-300">
Exception details are unavailable for this inspection state.
</div>

View File

@ -2,6 +2,7 @@
$contextBanner = $this->canonicalContextBanner(); $contextBanner = $this->canonicalContextBanner();
$blockedBanner = $this->blockedExecutionBanner(); $blockedBanner = $this->blockedExecutionBanner();
$lifecycleBanner = $this->lifecycleBanner(); $lifecycleBanner = $this->lifecycleBanner();
$restoreContinuationBanner = $this->restoreContinuationBanner();
$pollInterval = $this->pollInterval(); $pollInterval = $this->pollInterval();
@endphp @endphp
@ -49,6 +50,27 @@
</div> </div>
@endif @endif
@if ($restoreContinuationBanner !== null)
@php
$restoreContinuationClasses = match ($restoreContinuationBanner['tone']) {
'amber' => 'border-amber-200 bg-amber-50 text-amber-900 dark:border-amber-500/30 dark:bg-amber-500/10 dark:text-amber-100',
default => 'border-sky-200 bg-sky-50 text-sky-900 dark:border-sky-500/30 dark:bg-sky-500/10 dark:text-sky-100',
};
@endphp
<div class="mb-6 rounded-lg border px-4 py-3 text-sm {{ $restoreContinuationClasses }}">
<p class="font-semibold">{{ $restoreContinuationBanner['title'] }}</p>
<p class="mt-1">{{ $restoreContinuationBanner['body'] }}</p>
@if ($restoreContinuationBanner['url'] !== null && $restoreContinuationBanner['link_label'] !== null)
<p class="mt-3">
<a href="{{ $restoreContinuationBanner['url'] }}" class="font-semibold underline underline-offset-2">
{{ $restoreContinuationBanner['link_label'] }}
</a>
</p>
@endif
</div>
@endif
@if ($this->redactionIntegrityNote()) @if ($this->redactionIntegrityNote())
<div class="mb-6 rounded-lg border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-900 dark:border-amber-500/30 dark:bg-amber-500/10 dark:text-amber-100"> <div class="mb-6 rounded-lg border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-900 dark:border-amber-500/30 dark:bg-amber-500/10 dark:text-amber-100">
{{ $this->redactionIntegrityNote() }} {{ $this->redactionIntegrityNote() }}

View File

@ -25,6 +25,7 @@
use Filament\Http\Middleware\DisableBladeIconComponents; use Filament\Http\Middleware\DisableBladeIconComponents;
use Filament\Http\Middleware\DispatchServingFilamentEvent; use Filament\Http\Middleware\DispatchServingFilamentEvent;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Route; use Illuminate\Support\Facades\Route;
Route::get('/', function () { Route::get('/', function () {
@ -64,6 +65,32 @@
->middleware('throttle:entra-callback') ->middleware('throttle:entra-callback')
->name('auth.entra.callback'); ->name('auth.entra.callback');
Route::get('/admin/local/backup-health-browser-fixture-login', function (Request $request) {
abort_unless(app()->environment(['local', 'testing']), 404);
$fixture = config('tenantpilot.backup_health.browser_smoke_fixture');
$userEmail = is_array($fixture) ? data_get($fixture, 'user.email') : null;
$tenantRouteKey = is_array($fixture)
? (data_get($fixture, 'blocked_drillthrough.tenant_id') ?? data_get($fixture, 'blocked_drillthrough.tenant_external_id'))
: null;
abort_unless(is_string($userEmail) && $userEmail !== '', 404);
abort_unless(is_string($tenantRouteKey) && $tenantRouteKey !== '', 404);
$user = User::query()->where('email', $userEmail)->firstOrFail();
$tenant = Tenant::query()->where('external_id', $tenantRouteKey)->firstOrFail();
$workspace = Workspace::query()->whereKey($tenant->workspace_id)->firstOrFail();
Auth::login($user);
$request->session()->regenerate();
$workspaceContext = app(WorkspaceContext::class);
$workspaceContext->setCurrentWorkspace($workspace, $user, $request);
$workspaceContext->rememberTenantContext($tenant, $request);
return redirect()->to('/admin/t/'.$tenant->external_id);
})->name('admin.local.backup-health-browser-fixture-login');
Route::middleware(['web', 'auth', 'ensure-correct-guard:web']) Route::middleware(['web', 'auth', 'ensure-correct-guard:web'])
->post('/admin/switch-workspace', SwitchWorkspaceController::class) ->post('/admin/switch-workspace', SwitchWorkspaceController::class)
->name('admin.switch-workspace'); ->name('admin.switch-workspace');

View File

@ -0,0 +1,34 @@
# Specification Quality Checklist: Backup Quality Truth Surfaces
**Purpose**: Validate specification completeness and quality before proceeding to planning
**Created**: 2026-04-07
**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
- Validated on 2026-04-07. The spec keeps solution details out of the behavior sections; the only structural references are the mandatory surface-identification fields required by this repository's constitution and spec template.

View File

@ -0,0 +1,498 @@
openapi: 3.1.0
info:
title: Backup Quality Truth Surface Contracts
version: 1.0.0
description: >-
Internal reference contract for backup-quality truth surfaces. The application
continues to return rendered HTML through Filament and Livewire. The vendor
media types below document the structured list, detail, and selection models
that must be derivable before rendering. This is not a public API commitment.
paths:
/admin/t/{tenant}/backup-sets:
get:
summary: Backup-set list surface
description: >-
Returns the rendered backup-set list page. The vendor media type documents
the quality summary model that each visible row must expose.
parameters:
- name: tenant
in: path
required: true
schema:
type: integer
responses:
'200':
description: Rendered backup-set list page
content:
text/html:
schema:
type: string
application/vnd.tenantpilot.backup-set-collection+json:
schema:
$ref: '#/components/schemas/BackupSetCollectionSurface'
'403':
description: Viewer is in scope but lacks backup or version viewing capability
'404':
description: Tenant scope is not visible because workspace or tenant membership is missing
/admin/t/{tenant}/backup-sets/{backupSet}:
get:
summary: Backup-set detail surface
description: >-
Returns the rendered backup-set detail page. The vendor media type documents
the summary-first quality model and the related per-item quality rows.
parameters:
- name: tenant
in: path
required: true
schema:
type: integer
- name: backupSet
in: path
required: true
schema:
type: integer
responses:
'200':
description: Rendered backup-set detail page
content:
text/html:
schema:
type: string
application/vnd.tenantpilot.backup-set-detail+json:
schema:
$ref: '#/components/schemas/BackupSetDetailSurface'
'403':
description: Viewer is in scope but lacks required capability for a linked maintenance action
'404':
description: Backup set is not visible because workspace or tenant membership is missing
/admin/t/{tenant}/policy-versions:
get:
summary: Policy-version list surface
description: >-
Returns the rendered policy-version list page. The vendor media type documents
the snapshot mode and backup-quality model that each row must expose.
parameters:
- name: tenant
in: path
required: true
schema:
type: integer
responses:
'200':
description: Rendered policy-version list page
content:
text/html:
schema:
type: string
application/vnd.tenantpilot.policy-version-collection+json:
schema:
$ref: '#/components/schemas/PolicyVersionCollectionSurface'
'403':
description: Viewer is in scope but lacks policy-version viewing capability
'404':
description: Tenant scope is not visible because workspace or tenant membership is missing
/admin/t/{tenant}/policy-versions/{policyVersion}:
get:
summary: Policy-version detail surface
description: >-
Returns the rendered policy-version detail page. The vendor media type documents
the explicit backup-quality model that must be available before rendering.
parameters:
- name: tenant
in: path
required: true
schema:
type: integer
- name: policyVersion
in: path
required: true
schema:
type: integer
responses:
'200':
description: Rendered policy-version detail page
content:
text/html:
schema:
type: string
application/vnd.tenantpilot.policy-version-detail+json:
schema:
$ref: '#/components/schemas/PolicyVersionDetailSurface'
'403':
description: Viewer is in scope but lacks capability for a linked mutation action
'404':
description: Policy version is not visible because workspace or tenant membership is missing
/admin/t/{tenant}/restore-runs/create:
get:
summary: Restore selection surface with backup-quality hints
description: >-
Returns the rendered restore wizard. The vendor media type documents the
selection-stage backup-quality hints that must appear before risk checks.
parameters:
- name: tenant
in: path
required: true
schema:
type: integer
- name: backup_set_id
in: query
required: false
schema:
type: integer
responses:
'200':
description: Rendered restore wizard page
content:
text/html:
schema:
type: string
application/vnd.tenantpilot.restore-selection-quality+json:
schema:
$ref: '#/components/schemas/RestoreSelectionSurface'
'403':
description: Viewer is in scope but lacks restore capability
'404':
description: Restore surface is not visible because workspace or tenant membership is missing
components:
schemas:
BackupSetCollectionSurface:
type: object
required:
- rows
properties:
rows:
type: array
items:
$ref: '#/components/schemas/BackupSetRow'
BackupSetRow:
type: object
required:
- id
- name
- lifecycleStatus
- itemCount
- qualitySummary
properties:
id:
type: integer
name:
type: string
lifecycleStatus:
$ref: '#/components/schemas/Fact'
itemCount:
type: integer
capturedAt:
type:
- string
- 'null'
format: date-time
completedAt:
type:
- string
- 'null'
format: date-time
qualitySummary:
$ref: '#/components/schemas/QualitySummary'
BackupSetDetailSurface:
type: object
required:
- header
- qualitySummary
- itemRows
properties:
header:
$ref: '#/components/schemas/BackupSetHeader'
qualitySummary:
$ref: '#/components/schemas/QualitySummary'
itemRows:
type: array
items:
$ref: '#/components/schemas/BackupItemQualityRow'
positiveClaimBoundary:
$ref: '#/components/schemas/Fact'
BackupSetHeader:
type: object
required:
- id
- name
- lifecycleStatus
properties:
id:
type: integer
name:
type: string
lifecycleStatus:
$ref: '#/components/schemas/Fact'
archived:
type: boolean
itemCount:
type: integer
BackupItemQualityRow:
type: object
required:
- id
- label
- policyType
- snapshotCompleteness
- assignmentCapture
- hasDegradations
- summaryMessage
properties:
id:
type: integer
label:
type: string
policyType:
type: string
platform:
type:
- string
- 'null'
versionNumber:
type:
- integer
- 'null'
snapshotCompleteness:
$ref: '#/components/schemas/SnapshotCompleteness'
assignmentCapture:
$ref: '#/components/schemas/AssignmentCapture'
integrityWarning:
type:
- string
- 'null'
hasDegradations:
type: boolean
degradationFamilies:
type: array
items:
type: string
summaryMessage:
type: string
nextAction:
$ref: '#/components/schemas/Fact'
PolicyVersionCollectionSurface:
type: object
required:
- rows
properties:
rows:
type: array
items:
$ref: '#/components/schemas/PolicyVersionRow'
PolicyVersionRow:
type: object
required:
- id
- label
- versionNumber
- snapshotCompleteness
- hasDegradations
- summaryMessage
properties:
id:
type: integer
label:
type: string
versionNumber:
type: integer
capturedAt:
type:
- string
- 'null'
format: date-time
snapshotCompleteness:
$ref: '#/components/schemas/SnapshotCompleteness'
assignmentCapture:
$ref: '#/components/schemas/AssignmentCapture'
integrityWarning:
type:
- string
- 'null'
hasDegradations:
type: boolean
degradationFamilies:
type: array
items:
type: string
summaryMessage:
type: string
PolicyVersionDetailSurface:
type: object
required:
- header
- qualityFact
- positiveClaimBoundary
properties:
header:
$ref: '#/components/schemas/PolicyVersionHeader'
qualityFact:
$ref: '#/components/schemas/QualityFact'
positiveClaimBoundary:
$ref: '#/components/schemas/Fact'
PolicyVersionHeader:
type: object
required:
- id
- label
- versionNumber
properties:
id:
type: integer
label:
type: string
versionNumber:
type: integer
capturedAt:
type:
- string
- 'null'
format: date-time
RestoreSelectionSurface:
type: object
required:
- backupSetOptions
- positiveClaimBoundary
properties:
backupSetOptions:
type: array
items:
$ref: '#/components/schemas/RestoreBackupSetOption'
itemOptions:
type: array
items:
$ref: '#/components/schemas/RestoreBackupItemOption'
positiveClaimBoundary:
$ref: '#/components/schemas/Fact'
RestoreBackupSetOption:
type: object
required:
- id
- label
- qualitySummary
properties:
id:
type: integer
label:
type: string
qualitySummary:
$ref: '#/components/schemas/QualitySummary'
RestoreBackupItemOption:
type: object
required:
- id
- label
- qualityFact
properties:
id:
type: integer
label:
type: string
qualityFact:
$ref: '#/components/schemas/QualityFact'
QualitySummary:
type: object
required:
- hasDegradations
- degradedItemCount
- metadataOnlyCount
- assignmentIssueCount
- orphanedAssignmentCount
- summaryLabel
properties:
hasDegradations:
type: boolean
degradedItemCount:
type: integer
metadataOnlyCount:
type: integer
assignmentIssueCount:
type: integer
orphanedAssignmentCount:
type: integer
integrityWarningCount:
type: integer
unknownQualityCount:
type: integer
degradationFamilies:
type: array
items:
type: string
summaryLabel:
type: string
nextAction:
$ref: '#/components/schemas/Fact'
QualityFact:
type: object
required:
- snapshotCompleteness
- assignmentCapture
- hasDegradations
- summaryMessage
properties:
snapshotCompleteness:
$ref: '#/components/schemas/SnapshotCompleteness'
assignmentCapture:
$ref: '#/components/schemas/AssignmentCapture'
integrityWarning:
type:
- string
- 'null'
hasDegradations:
type: boolean
degradationFamilies:
type: array
items:
type: string
summaryMessage:
type: string
nextAction:
$ref: '#/components/schemas/Fact'
SnapshotCompleteness:
type: object
required:
- mode
- badgeLabel
properties:
mode:
type: string
enum:
- full
- metadata_only
- unknown
badgeLabel:
type: string
sourceSignal:
type:
- string
- 'null'
AssignmentCapture:
type: object
required:
- issuePresent
- orphanedAssignments
properties:
issuePresent:
type: boolean
fetchFailed:
type: boolean
captureReason:
type:
- string
- 'null'
orphanedAssignments:
type: boolean
assignmentCount:
type:
- integer
- 'null'
Fact:
type: object
required:
- label
properties:
label:
type: string
description:
type:
- string
- 'null'

View File

@ -0,0 +1,247 @@
# Data Model: Backup Quality Truth Surfaces
## Overview
This feature does not add or change a top-level persisted domain entity. It introduces a tighter derived backup-quality model around the existing tenant-owned backup, version, and restore-selection surfaces.
The central design task is to make existing backup truth visible without changing:
- `BackupSet`, `BackupItem`, or `PolicyVersion` ownership
- existing backup or restore route identity
- existing restore-safety, preview, and execution authority
- existing audit and RBAC responsibilities
- the no-new-table boundary of this feature
## Existing Persistent Entities
### 1. BackupSet
- Purpose: Tenant-owned backup collection that records lifecycle state and groups captured backup items.
- Existing persistent fields used by this feature:
- `id`
- `tenant_id`
- `name`
- `status`
- `item_count`
- `metadata`
- `created_by`
- `completed_at`
- `created_at`
- Existing relationships used by this feature:
- `tenant`
- `items`
- `restoreRuns`
#### Proposed nested metadata additions
None. Backup-set quality is derived from related backup items and existing set facts. No new backup-set status or metadata field is required.
### 2. BackupItem
- Purpose: Tenant-owned captured recovery input for one backed-up policy or foundation record.
- Existing persistent fields used by this feature:
- `id`
- `tenant_id`
- `backup_set_id`
- `policy_id`
- `policy_version_id`
- `policy_identifier`
- `policy_type`
- `platform`
- `payload`
- `assignments`
- `metadata`
- `captured_at`
- Existing relationships used by this feature:
- `tenant`
- `backupSet`
- `policy`
- `policyVersion`
#### Existing metadata signals used by this feature
| Key | Type | Meaning |
|---|---|---|
| `source` | string or null | Primary source marker; may be `metadata_only` |
| `snapshot_source` | string or null | Copied source marker from a linked policy version when a backup item is created from a version |
| `warnings` | array<string> | Warning messages; may include metadata-only fallback wording |
| `assignments_fetch_failed` | boolean | Assignment capture failed for this item |
| `assignment_capture_reason` | string or null | Informational reason such as `separate_role_assignments`; not all reasons are degradations |
| `has_orphaned_assignments` | boolean | One or more resolved assignment targets were orphaned |
| `assignment_count` | integer or null | Captured assignment count |
| `scope_tag_ids` | array<int|string> | Captured scope-tag identifiers |
| `scope_tag_names` | array<string> | Captured scope-tag names |
| `integrity_warning` | string or null | Existing integrity or redaction warning copied into the backup item |
| `protected_paths_count` | integer or null | Count of protected or redacted paths copied from the policy version context |
### 3. PolicyVersion
- Purpose: Tenant-owned immutable version record for a policy snapshot.
- Existing persistent fields used by this feature:
- `id`
- `tenant_id`
- `policy_id`
- `version_number`
- `snapshot`
- `metadata`
- `assignments`
- `scope_tags`
- `secret_fingerprints`
- `redaction_version`
- `captured_at`
- `capture_purpose`
- Existing relationships used by this feature:
- `tenant`
- `policy`
- `operationRun`
#### Existing metadata signals used by this feature
| Key | Type | Meaning |
|---|---|---|
| `source` | string or null | Snapshot source marker; `metadata_only` is the primary degraded completeness signal |
| `warnings` | array<string> | Snapshot warnings; may include metadata-only fallback language |
| `assignments_fetch_failed` | boolean | Assignment capture failed during version capture |
| `assignments_fetch_error` | string or null | Human-readable assignment capture error |
| `assignments_fetch_error_code` | int or string or null | Technical assignment capture error code |
| `has_orphaned_assignments` | boolean | One or more captured assignment targets were orphaned |
| `capture_source` | string or null | Existing capture context such as `version_capture` |
#### Related persisted integrity context used by this feature
| Field | Type | Meaning |
|---|---|---|
| `secret_fingerprints` | array | Existing redaction context used to expose integrity notes on version-derived restore inputs |
| `redaction_version` | integer | Existing redaction version for operator diagnostics |
| `scope_tags` | array | Existing scope-tag context surfaced alongside quality truth where useful |
### 4. Restore selection context
- Purpose: Existing wizard state that lets operators choose a backup set and optional backup-item subset before running risk checks.
- Existing state used by this feature:
- `backup_set_id`
- `scope_mode`
- `backup_item_ids`
- `group_mapping`
- `is_dry_run`
No new persisted restore-selection state is planned. This feature only enriches the current rendered option models.
## Derived Models
### 1. SnapshotCompletenessFact
Derived completeness truth shared by backup items and policy versions.
| Field | Type | Source | Notes |
|---|---|---|---|
| `mode` | string | metadata-derived | `full`, `metadata_only`, or `unknown` |
| `sourceSignal` | string or null | `metadata.source` or `metadata.snapshot_source` | Authoritative direct signal when present |
| `warningEvidence` | list<string> | `metadata.warnings` | Secondary fallback signal |
| `badgeState` | string | derived | Routes to the existing `PolicySnapshotModeBadge` state |
Rules:
- `metadata_only` when `source` or `snapshot_source` equals `metadata_only`, or when warning evidence clearly states metadata-only capture.
- `full` only when there is no metadata-only evidence and the record contains enough captured payload context to justify a complete-snapshot claim.
- `unknown` only when existing metadata cannot prove either `full` or `metadata_only`.
### 2. AssignmentCaptureFact
Derived assignment-quality truth for backup items and policy versions.
| Field | Type | Source | Notes |
|---|---|---|---|
| `fetchFailed` | boolean | `assignments_fetch_failed` | Primary degraded assignment signal |
| `captureReason` | string or null | `assignment_capture_reason` | Informational reason; not always degraded |
| `orphanedAssignments` | boolean | `has_orphaned_assignments` | Secondary degraded signal |
| `assignmentCount` | integer or null | `assignment_count` or `assignments` length | Informational support data |
| `issuePresent` | boolean | derived | True when fetch failed or orphaned targets exist |
Rules:
- `assignment_capture_reason = separate_role_assignments` is informative and must not be misread as a failure on its own.
- `fetchFailed = true` is a degraded quality signal.
- `orphanedAssignments = true` is a degraded quality signal even if fetch succeeded.
### 3. BackupItemQualityFact
Default item-level backup-quality model for backup items.
| Field | Type | Source | Notes |
|---|---|---|---|
| `backupItemId` | integer | record id | Identity |
| `snapshotCompleteness` | `SnapshotCompletenessFact` | derived | Primary completeness truth |
| `assignmentCapture` | `AssignmentCaptureFact` | derived | Assignment quality truth |
| `integrityWarning` | string or null | `metadata.integrity_warning` | Existing integrity signal |
| `degradationFamilies` | list<string> | derived | Examples: `metadata_only`, `assignment_capture_issue`, `orphaned_assignments`, `integrity_warning`, `unknown_quality` |
| `hasDegradations` | boolean | derived | True when one or more degradation families apply |
| `summaryMessage` | string | derived | Concise operator-facing truth |
| `nextAction` | string | derived | Primary next step such as inspect detail or continue with caution |
### 4. BackupSetQualitySummary
Aggregate backup-quality truth for one backup set.
| Field | Type | Source | Notes |
|---|---|---|---|
| `backupSetId` | integer | record id | Identity |
| `totalItems` | integer | `item_count` or related count | Informational total |
| `degradedItemCount` | integer | aggregated item facts | Number of degraded items |
| `metadataOnlyCount` | integer | aggregated item facts | Count of metadata-only items |
| `assignmentIssueCount` | integer | aggregated item facts | Count of assignment capture failures |
| `orphanedAssignmentCount` | integer | aggregated item facts | Count of orphaned-assignment signals |
| `integrityWarningCount` | integer | aggregated item facts | Count of integrity warnings carried into backup items |
| `unknownQualityCount` | integer | aggregated item facts | Count of items whose quality is truly unknown |
| `degradationFamilies` | list<string> | derived | Set-level union of degradation families |
| `summaryMessage` | string | derived | Compact summary for list and detail |
| `nextAction` | string | derived | Open detail, inspect degraded items, prefer stronger version, or continue with caution |
| `positiveClaimBoundary` | string | derived | Explains that quality does not equal safe restore or tenant recoverability |
Rules:
- Aggregate counts are computed from related `BackupItemQualityFact` values, never from `BackupSet.status`.
- `completed but degraded` remains a display combination of lifecycle plus quality summary, not a new persisted backup-set status.
### 5. PolicyVersionQualityFact
Version-level backup-quality truth for policy versions.
| Field | Type | Source | Notes |
|---|---|---|---|
| `policyVersionId` | integer | record id | Identity |
| `snapshotCompleteness` | `SnapshotCompletenessFact` | derived from version metadata | Primary completeness truth |
| `assignmentCapture` | `AssignmentCaptureFact` | derived from version metadata and assignments | Assignment quality truth |
| `integrityWarning` | string or null | derived from existing redaction or integrity context | Existing warning already present in current restore and version flows |
| `degradationFamilies` | list<string> | derived | Same family as backup items where applicable |
| `hasDegradations` | boolean | derived | True when one or more degradation families apply |
| `summaryMessage` | string | derived | Concise operator-facing truth |
| `nextAction` | string | derived | Prefer stronger version, inspect raw settings, or continue to restore with caution |
### 6. RestoreSelectionQualityHint
Selection-stage quality model for restore wizard step 1 and step 2.
| Field | Type | Source | Notes |
|---|---|---|---|
| `targetType` | string | derived | `backup_set` or `backup_item` |
| `targetId` | integer | selected record id | Identity |
| `summaryMessage` | string | derived | Early warning before risk checks |
| `degradationFamilies` | list<string> | derived | Carries through set-level or item-level truth |
| `nextAction` | string | derived | Inspect detail or continue with caution |
| `positiveClaimBoundary` | string | derived | Explicitly states that input quality is not restore safety |
Rules:
- Step 1 uses `BackupSetQualitySummary` facts.
- Step 2 uses `BackupItemQualityFact` facts.
- Neither step may claim `safe to restore`, `restore ready`, or `tenant recoverable`.
## Validation Rules
- Never derive backup quality from `BackupSet.status`, `PolicyVersion` action availability, or restore gating alone.
- `assignments_fetch_failed` and `has_orphaned_assignments` are distinct signals and must be surfaced separately where the UI can support it.
- `assignment_capture_reason` is explanatory metadata, not automatically a degraded state.
- `unknown quality` is permitted only when current metadata cannot justify `full` or `metadata_only` and cannot justify an assignment-quality claim.
- `TENANT_VIEW` visibility for backup-quality truth must remain independent from `TENANT_MANAGE` restore capability.
- Restore selection hints must explicitly preserve the claim boundary that backup quality is not restore safety.

View File

@ -0,0 +1,288 @@
# Implementation Plan: Backup Quality Truth Surfaces
**Branch**: `176-backup-quality-truth` | **Date**: 2026-04-07 | **Spec**: `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/specs/176-backup-quality-truth/spec.md`
**Input**: Feature specification from `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/specs/176-backup-quality-truth/spec.md`
## Summary
Harden the backup and versioning surfaces so operators can distinguish `stored` from `usable` and `degraded` recovery input before they reach restore-safety or execution surfaces. The implementation keeps `BackupSet`, `BackupItem`, and `PolicyVersion` as the existing sources of truth, introduces only a narrow derived backup-quality layer over current metadata and relationships, aggregates existing metadata-only and assignment-quality signals into summary facts, and hardens backup-set list and detail, backup-item relation, policy-version list and detail, and restore wizard step 1 and step 2 selection seams without adding a new persistence model.
Key approach: work inside the existing `BackupSetResource`, `BackupItemsRelationManager`, `PolicyVersionResource`, `RestoreRunResource`, and `CreateRestoreRun` seams; derive per-item and aggregate quality from existing metadata keys such as `source`, `snapshot_source`, `assignments_fetch_failed`, `assignment_capture_reason`, and `has_orphaned_assignments`; reuse Filament v5 tables, infolists, enterprise-detail builders, and shared badge infrastructure; keep all changes Livewire v4 compliant; avoid new tables, new Graph calls, and new asset registration; validate the result with focused Pest, Livewire, RBAC, and regression coverage.
## Technical Context
**Language/Version**: PHP 8.4, Laravel 12, Blade, Filament v5, Livewire v4
**Primary Dependencies**: Filament v5, Livewire v4, Pest v4, Laravel Sail, existing `BackupSetResource`, `BackupItemsRelationManager`, `PolicyVersionResource`, `RestoreRunResource`, `CreateRestoreRun`, `AssignmentBackupService`, `VersionService`, `PolicySnapshotService`, `RestoreRiskChecker`, `BadgeRenderer`, `PolicySnapshotModeBadge`, `EnterpriseDetailBuilder`, and existing RBAC helpers
**Storage**: PostgreSQL with existing tenant-owned `backup_sets`, `backup_items`, `policy_versions`, and restore wizard input state; JSON-backed `metadata`, `snapshot`, `assignments`, and `scope_tags`; no schema change planned
**Testing**: Pest feature tests, Livewire page or action tests, unit tests for narrow derived backup-quality helpers, all run through Sail
**Target Platform**: Laravel web application in Sail locally and containerized Linux deployment in staging and production
**Project Type**: Laravel monolith web application
**Performance Goals**: Keep backup, version, and restore-selection surfaces server-driven and DB-backed at render time, avoid new render-time external calls, preserve fast list scanability, and avoid introducing new N+1 query hotspots while computing quality summaries
**Constraints**: No new backup-health table, no new Graph contract path, no new queue or `OperationRun`, no route identity change, no RBAC drift, no conflation of backup quality with restore safety or tenant recoverability, no page-local badge mappings, and no new global Filament assets
**Scale/Scope**: One tenant-scoped backup-set list and detail flow, one backup-items relation-manager table, one tenant-scoped policy-version list and detail flow, restore wizard step 1 and step 2 selection surfaces, one narrow derived backup-quality helper layer, and focused regression coverage across truth presentation and RBAC behavior
## Constitution Check
*GATE: Passed before Phase 0 research. Re-checked after Phase 1 design and still passing.*
| Principle | Status | Notes |
|-----------|--------|-------|
| Inventory-first | Pass | Backups and versions remain immutable snapshot truth; no inventory ownership rule changes |
| Read/write separation | Pass | This slice is read-first truth hardening; existing restore and delete flows retain their current confirmations, audits, and tests |
| Graph contract path | Pass | No new Graph endpoints, no new Graph calls, and no contract registry changes are introduced |
| Deterministic capabilities | Pass | Existing capability registry, `CapabilityResolver`, and `UiEnforcement` remain authoritative |
| RBAC-UX planes and 404 vs 403 | Pass | All changed surfaces remain tenant-scoped; non-members still get 404, in-scope members without mutation capability still get 403 on execution |
| Workspace isolation | Pass | No workspace-scope broadening or cross-workspace visibility changes are planned |
| Tenant isolation | Pass | `BackupSet`, `BackupItem`, and `PolicyVersion` stay tenant-owned and tenant-entitled across list, detail, and wizard selection surfaces |
| Dangerous and destructive confirmations | Pass | Existing archive, restore, force-delete, and remove actions stay confirmation-gated and server-authorized |
| Global search safety | Pass | This feature adds no new globally searchable resource. `PolicyVersionResource` remains non-globally-searchable. `BackupSetResource` already has a view page if current configuration exposes it to search, and this slice adds no new cross-tenant hints |
| Run observability | Pass | No new long-running work or `OperationRun` usage is introduced |
| Ops-UX 3-surface feedback | Pass | No new operation start, toast, progress, or terminal notification surface is added |
| Ops-UX lifecycle ownership | Pass | `OperationRun.status` and `OperationRun.outcome` are untouched |
| Ops-UX summary counts | Pass | No new `summary_counts` keys or operation metrics are required |
| Data minimization | Pass | The slice reuses existing metadata and keeps diagnostics secondary; no new secret or raw payload exposure is planned |
| Proportionality (PROP-001) | Pass | Added logic is limited to a narrow derived backup-quality helper and direct surface integration across existing resources |
| Persisted truth (PERSIST-001) | Pass | No new table, column, or stored mirror is introduced; quality remains derived |
| Behavioral state (STATE-001) | Pass | Quality distinctions remain derived presentation truth from existing metadata, not new persisted lifecycle state |
| Badge semantics (BADGE-001) | Pass | Snapshot-mode rendering continues through `BadgeDomain::PolicySnapshotMode`; any new quality chips or labels stay inside shared badge or copy seams |
| Filament-native UI (UI-FIL-001) | Pass | Existing Filament tables, infolists, enterprise-detail cards, and wizard form descriptions remain the primary seams |
| UI naming (UI-NAMING-001) | Pass | The plan preserves operator vocabulary such as `metadata-only`, `assignment issues`, `degraded`, `full payload`, and `recovery input`, while avoiding `safe to restore` claims |
| Operator surfaces (OPSURF-001) | Pass | Changed surfaces become more operator-first by surfacing quality summary before diagnostics or later restore checks |
| Filament Action Surface Contract | Pass | No new inspect model, redundant View action, or empty action group is introduced; action placement remains unchanged |
| Filament UX-001 | Pass with documented variance | Backup-set detail continues to use the existing enterprise-detail layout and relation manager, but the plan adds a summary-first quality section before technical detail |
| Filament v5 / Livewire v4 compliance | Pass | The implementation stays inside the current Filament v5 and Livewire v4 stack |
| Provider registration location | Pass | No provider or panel changes; Laravel 11+ registration remains in `bootstrap/providers.php` |
| Asset strategy | Pass | No new panel assets are planned; deployment keeps the existing `php artisan filament:assets` step unchanged |
## Phase 0 Research
Research outcomes are captured in `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/specs/176-backup-quality-truth/research.md`.
Key decisions:
- Derive backup quality from existing item and version metadata rather than introducing a persisted backup-health model.
- Treat backup lifecycle status and backup quality as separate truths on every affected surface.
- Reuse the central snapshot-mode badge and shared badge semantics instead of introducing page-local color or status logic.
- Extend the existing backup-set enterprise-detail builder, backup-items relation manager, policy-version resource, and restore wizard descriptions instead of creating a parallel dashboard or UI shell.
- Surface backup-set and item quality in restore wizard selection steps before the current restore-safety checks and preview steps, without turning quality hints into safety claims.
- Keep quality truth visible for `TENANT_VIEW` users even when restore actions remain unavailable.
- Use `unknown quality` only when the existing record does not contain authoritative metadata that can justify a stronger claim.
- Extend the existing Pest and Livewire test surfaces rather than creating a new browser-first harness.
## Phase 1 Design
Design artifacts are created under `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/specs/176-backup-quality-truth/`:
- `research.md`: design and framework decisions for deriving and surfacing backup quality
- `data-model.md`: existing entities, current metadata signals, and narrow derived backup-quality models
- `contracts/backup-quality-truth.openapi.yaml`: internal logical contract for backup-set list and detail, backup-item relation rows, policy-version list and detail, and restore wizard selection surfaces
- `quickstart.md`: focused automated and manual validation workflow for backup-quality truth hardening
Design decisions:
- No schema migration is required; the design derives quality from existing `backup_items.metadata`, `policy_versions.metadata`, relationships, and current restore wizard state.
- A narrow derived helper layer is justified because the same quality truth must appear consistently across backup-set list, backup-set detail, backup-items, policy versions, and restore selection surfaces.
- Backup-set detail hardening stays inside `BackupSetResource::enterpriseDetailPage()` and existing enterprise-detail cards or sections rather than a new page shell.
- Policy-version hardening stays inside the existing table and infolist schema, replacing disabled-action-only signaling with explicit quality truth.
- Restore selection hardening stays inside `RestoreRunResource::getWizardSteps()` and `restoreItemOptionData()` so input quality appears before the existing checks and preview steps.
- Snapshot mode remains the primary quality badge, while aggregate counts and next-action language stay derived and secondary.
## Project Structure
### Documentation (this feature)
```text
specs/176-backup-quality-truth/
├── spec.md
├── plan.md
├── research.md
├── data-model.md
├── quickstart.md
├── contracts/
│ └── backup-quality-truth.openapi.yaml
├── checklists/
│ └── requirements.md
└── tasks.md
```
### Source Code (repository root)
```text
app/
├── Filament/
│ └── Resources/
│ ├── BackupSetResource.php
│ ├── PolicyVersionResource.php
│ ├── RestoreRunResource.php
│ └── BackupSetResource/
│ └── RelationManagers/
│ └── BackupItemsRelationManager.php
├── Models/
│ ├── BackupItem.php
│ ├── BackupSet.php
│ └── PolicyVersion.php
├── Services/
│ ├── AssignmentBackupService.php
│ └── Intune/
│ ├── PolicySnapshotService.php
│ ├── RestoreRiskChecker.php
│ ├── RestoreService.php
│ └── VersionService.php
└── Support/
├── BackupQuality/
│ ├── BackupQualityResolver.php
│ └── BackupQualitySummary.php
├── Badges/
│ └── Domains/
│ └── PolicySnapshotModeBadge.php
├── Ui/
│ └── EnterpriseDetail/
tests/
├── Feature/
│ ├── Filament/
│ │ ├── BackupSetUiEnforcementTest.php
│ │ ├── BackupSetEnterpriseDetailPageTest.php
│ │ ├── BackupItemsRelationManagerFiltersTest.php
│ │ ├── BackupQualityTruthSurfaceTest.php
│ │ ├── PolicyVersionQualityTruthSurfaceTest.php
│ │ ├── PolicyVersionTest.php
│ │ ├── PolicyVersionRestoreViaWizardTest.php
│ │ ├── RestoreItemSelectionTest.php
│ │ └── RestoreSelectionQualityTruthTest.php
│ └── Rbac/
│ ├── BackupItemsRelationManagerUiEnforcementTest.php
│ ├── BackupQualityVisibilityTest.php
│ ├── CreateRestoreRunAuthorizationTest.php
│ └── PolicyVersionsRestoreToIntuneUiEnforcementTest.php
│ └── RestoreRiskChecksWizardTest.php
└── Unit/
├── Support/
│ └── BackupQuality/
│ ├── BackupQualityResolverTest.php
│ └── BackupSetQualitySummaryTest.php
├── AssignmentBackupServiceTest.php
└── BackupItemTest.php
```
**Structure Decision**: Standard Laravel monolith. The implementation stays inside existing Filament resources, existing models and services that already hold the underlying metadata, and the current test structure. Any new helper types stay under the existing `app/Support/BackupQuality/` namespace as a narrow derived layer shared across backup, version, and restore-selection surfaces.
## Implementation Strategy
### Phase A — Introduce Narrow Derived Backup-Quality Facts
**Goal**: Create one reusable derivation path for backup quality from current metadata without adding a new persistence model.
| Step | File | Change |
|------|------|--------|
| A.1 | New narrow helper(s) under `app/Support/` if needed | Introduce a minimal backup-quality resolver or read-model helper that computes snapshot mode, assignment capture issues, orphaned assignment flags, integrity warnings, aggregate counts, and next-action guidance from existing `BackupItem` and `PolicyVersion` metadata |
| A.2 | `app/Models/BackupItem.php` and, only if clearly justified, `app/Models/PolicyVersion.php` | Add small convenience helpers for repeated metadata checks where this reduces duplication without embedding presentation language into the models |
| A.3 | `app/Support/Badges/Domains/PolicySnapshotModeBadge.php` and shared copy seams only if needed | Reuse the current snapshot-mode badge as the canonical item or version completeness signal; add no new badge domain unless a shared value cannot be expressed through current badge semantics |
### Phase B — Harden Backup-Set List And Detail Truth
**Goal**: Make backup-set surfaces answer `stored versus degraded` before diagnostics or restore intent.
| Step | File | Change |
|------|------|--------|
| B.1 | `app/Filament/Resources/BackupSetResource.php` | Add a compact backup-quality summary to the table that stays separate from lifecycle status and uses aggregate degraded counts rather than `status` to imply quality |
| B.2 | `app/Filament/Resources/BackupSetResource.php` | Update `enterpriseDetailPage()` to place a quality summary card or section ahead of technical detail, including metadata-only count, assignment issue count, orphaned assignment count, one primary next action, and contextual related links that stay out of the header |
| B.3 | `app/Filament/Resources/BackupSetResource.php` query seams | Ensure the list and detail surfaces eager-load or aggregate the needed backup-item quality facts without introducing a new N+1 hotspot |
### Phase C — Harden Backup-Item And Policy-Version Truth
**Goal**: Expose item-level and version-level input quality directly where operators inspect captured records.
| Step | File | Change |
|------|------|--------|
| C.1 | `app/Filament/Resources/BackupSetResource/RelationManagers/BackupItemsRelationManager.php` | Add per-item snapshot mode, assignment capture issue, and orphaned-assignment truth to the relation table, preserving the current inspect model and action placement |
| C.2 | `app/Filament/Resources/PolicyVersionResource.php` | Add explicit snapshot mode or quality columns plus a single empty-state CTA to the policy-version list so metadata-only versions are visible at scan speed |
| C.3 | `app/Filament/Resources/PolicyVersionResource.php` | Add an explicit backup-quality section to the policy-version detail infolist so restore availability no longer acts as the only quality signal |
| C.4 | `app/Filament/Resources/PolicyVersionResource.php` | Preserve current restore-via-wizard gating and tooltip behavior while making quality truth visible independently from action disablement |
### Phase D — Harden Restore Selection Entry Points
**Goal**: Expose weak backup inputs before existing restore-safety checks and preview steps begin.
| Step | File | Change |
|------|------|--------|
| D.1 | `app/Filament/Resources/RestoreRunResource.php` | Enrich backup-set option labels or helper copy on wizard step 1 with backup-quality summary facts and degraded counts |
| D.2 | `app/Filament/Resources/RestoreRunResource.php` | Enrich `restoreItemOptionData()` so wizard step 2 descriptions include snapshot mode and item-level degradation truth before any risk checks run |
| D.3 | `app/Filament/Resources/RestoreRunResource.php` and `app/Filament/Resources/RestoreRunResource/Pages/CreateRestoreRun.php` | Preserve the current step order and restore-safety authority, while ensuring backup-quality messaging stops short of `safe to restore` or `recovery guaranteed` language |
### Phase E — Regression Protection And Focused Verification
**Goal**: Lock the new truth semantics into automated tests without weakening existing backup or restore behavior.
| Step | File | Change |
|------|------|--------|
| E.1 | Existing and new unit tests under `tests/Unit/Support/` | Add deterministic coverage for item-level quality derivation, aggregate backup-set counts, metadata-only detection, assignment failure mapping, and unknown-quality fallback |
| E.2 | `tests/Feature/Filament/BackupSetEnterpriseDetailPageTest.php` and new backup-set truth tests | Cover list or detail quality summary visibility, mixed-quality aggregation, and summary-first ordering |
| E.3 | `tests/Feature/Filament/PolicyVersionTest.php`, `tests/Feature/Filament/PolicyVersionRestoreViaWizardTest.php`, and new policy-version truth tests | Cover snapshot mode visibility, explicit detail quality truth, and non-reliance on disabled actions |
| E.4 | `tests/Feature/Filament/RestoreItemSelectionTest.php` and new restore-selection truth tests | Cover backup-set quality in step 1 and per-item quality in step 2 before risk checks |
| E.5 | RBAC tests under `tests/Feature/Rbac/` | Preserve 404 versus 403 behavior and verify that `TENANT_VIEW` users still see quality truth without restore rights |
| E.6 | `vendor/bin/sail bin pint --dirty --format agent` and focused Pest runs | Required formatting and targeted verification before implementation is considered complete |
## Key Design Decisions
### D-001 — Backup quality is derived from existing capture truth, not stored separately
The current product already records the signals that matter: metadata-only source markers, assignment fetch failures, orphaned assignments, warnings, and integrity hints. The missing piece is a consistent way to aggregate and display them across surfaces.
### D-002 — Backup lifecycle status and backup quality stay orthogonal
`completed`, `partial`, and `failed` remain capture-lifecycle truth. Aggregate backup-quality summaries answer whether the captured inputs appear strong or degraded as recovery input. The plan never reuses lifecycle status as a proxy for quality.
### D-003 — Snapshot completeness stays on the central badge system
The existing `PolicySnapshotModeBadge` already defines the primary `full` versus `metadata only` language. This slice reuses that badge instead of introducing a second status vocabulary for the same truth.
### D-004 — Restore selection surfaces expose input quality, not safety approval
Step 1 and step 2 only need to tell the operator whether the chosen backup set or items look degraded. Restore safety, preview decisions, and execution readiness remain owned by the later steps and existing restore-safety logic.
### D-005 — RBAC can suppress actions, not truth
Users with view rights must still see backup-quality truth even when restore entry points or maintenance actions are unavailable. Hiding or muting quality because of missing restore capability would falsify the surface.
### D-006 — Existing Filament seams are sufficient
The current enterprise-detail builder, table columns, infolist sections, and checkbox-list descriptions already provide the UI seams this slice needs. A dashboard, custom shell, or new client-side state layer would be disproportionate.
### D-007 — Unknown quality is an explicit fallback, not the default
The product should only emit `unknown quality` where current records truly lack authoritative metadata. If existing metadata can justify `metadata-only`, `assignment issue`, or `orphaned assignments`, the surface must say so directly.
## Risk Assessment
| Risk | Impact | Likelihood | Mitigation |
|------|--------|------------|------------|
| Aggregation logic diverges between backup items, policy versions, and restore selection descriptions | High | Medium | Use one narrow derived helper path and cover it with mixed-quality unit and feature tests |
| Quality summary introduces N+1 queries or heavy per-row work on backup-set list pages | High | Medium | Preload relations or aggregate counts deliberately and add list-focused regression coverage |
| UI wording slips from backup quality into restore safety or tenant recoverability claims | High | Medium | Keep operator copy centralized and test for explicit non-claims on degraded and healthy-looking cases |
| Read-only users lose quality visibility because existing restore gating is accidentally reused | High | Medium | Add dedicated RBAC visibility tests for `TENANT_VIEW` members without restore capability |
| Metadata-only restore blocking semantics regress because selection hints are coupled too tightly to risk checks | Medium | Medium | Keep restore selection quality read-only and rerun focused restore-safety regression tests alongside the new surface tests |
## Test Strategy
- Extend existing backup-set, backup-items, policy-version, restore-selection, and RBAC Pest coverage before introducing any new harness.
- Add unit tests for the narrow backup-quality helper so metadata-only detection, assignment issue mapping, orphaned-assignment mapping, and aggregate counts remain deterministic.
- Add feature tests that prove `completed` and `good backup` are no longer visually conflated on backup-set list and detail surfaces.
- Add feature tests that prove metadata-only and assignment-capture issues are visible on backup items and policy versions without relying on disabled actions or late restore checks.
- Add feature tests that prove restore wizard step 1 and step 2 expose degraded input before risk checks or preview generation.
- Add RBAC tests that prove `TENANT_VIEW` users still see backup-quality truth while restore actions remain unavailable, and non-members still receive 404 semantics.
- Re-run existing restore-safety and restore-selection tests so earlier input-quality visibility does not change existing risk-check or execution behavior.
- Keep all tests Livewire v4 compatible and run the smallest affected subset through Sail before asking for a full-suite pass.
## Complexity Tracking
No constitution violations or exception-driven complexity were identified. The only added structure is a narrow derived backup-quality helper layer justified by cross-surface reuse and the need to keep current metadata interpretation consistent across list, detail, and wizard selection surfaces.
## Proportionality Review
- **Current operator problem**: Operators can currently tell that a backup set, backup item, or policy version exists, but they cannot quickly tell whether it is strong, degraded, or metadata-only as recovery input before they reach deep diagnostics or restore-safety surfaces.
- **Existing structure is insufficient because**: The relevant truth is fragmented across backup metadata, version metadata, assignment fetch flags, orphaned-assignment markers, and disabled restore actions. Presence is visible earlier than usefulness, which creates false trust.
- **Narrowest correct implementation**: Add one narrow derived backup-quality helper path and integrate it directly into existing backup-set, backup-item, policy-version, and restore-selection surfaces without adding new persistence or a broad taxonomy framework.
- **Ownership cost created**: A small amount of derivation logic, additional list or detail wiring, and focused unit and feature tests to keep the mapping stable.
- **Alternative intentionally rejected**: A persisted backup-health table, a recovery-confidence score, or a dashboard-wide backup-health program. Each would create broader truth and ownership cost than the current operator problem requires.
- **Release truth**: Current-release truth. This slice corrects the truth on already-shipped backup and version surfaces before later backup-health or recovery-confidence work builds on them.

View File

@ -0,0 +1,132 @@
# Quickstart: Backup Quality Truth Surfaces
## Goal
Validate that backup-set, backup-item, policy-version, and restore-selection surfaces now communicate backup quality truth early and explicitly without overstating restore safety, restore readiness, or tenant recoverability.
## Prerequisites
1. Start Sail if it is not already running.
2. Ensure the workspace has representative fixtures for:
- a backup set with only full-payload items
- a backup set with at least one metadata-only item
- a backup set with assignment fetch failure metadata
- a backup set with orphaned-assignment metadata
- one policy version captured as full payload
- one policy version captured as metadata-only
- one user with `TENANT_VIEW` but without restore capability
- one user with restore capability for the same tenant
3. Ensure the acting users are valid workspace and tenant members.
4. Ensure archived backup-set and policy-version fixtures exist if lifecycle plus quality combinations need validation.
## Focused Automated Verification
Run the smallest existing backup, version, and restore-selection pack first:
```bash
vendor/bin/sail artisan test --compact tests/Feature/Filament/BackupSetEnterpriseDetailPageTest.php
vendor/bin/sail artisan test --compact tests/Feature/Filament/BackupItemsRelationManagerFiltersTest.php
vendor/bin/sail artisan test --compact tests/Feature/Filament/BackupSetUiEnforcementTest.php
vendor/bin/sail artisan test --compact tests/Feature/Filament/PolicyVersionTest.php
vendor/bin/sail artisan test --compact tests/Feature/Filament/PolicyVersionRestoreViaWizardTest.php
vendor/bin/sail artisan test --compact tests/Feature/Filament/RestoreItemSelectionTest.php
vendor/bin/sail artisan test --compact tests/Feature/Rbac/BackupItemsRelationManagerUiEnforcementTest.php
vendor/bin/sail artisan test --compact tests/Feature/Rbac/PolicyVersionsRestoreToIntuneUiEnforcementTest.php
vendor/bin/sail artisan test --compact tests/Feature/Rbac/CreateRestoreRunAuthorizationTest.php
vendor/bin/sail artisan test --compact tests/Feature/RestoreRiskChecksWizardTest.php
vendor/bin/sail artisan test --compact tests/Unit/AssignmentBackupServiceTest.php
vendor/bin/sail artisan test --compact tests/Unit/BackupItemTest.php
```
Expected new or expanded spec-scoped tests:
```bash
vendor/bin/sail artisan test --compact tests/Feature/Filament/BackupQualityTruthSurfaceTest.php
vendor/bin/sail artisan test --compact tests/Feature/Filament/PolicyVersionQualityTruthSurfaceTest.php
vendor/bin/sail artisan test --compact tests/Feature/Filament/RestoreSelectionQualityTruthTest.php
vendor/bin/sail artisan test --compact tests/Feature/Rbac/BackupQualityVisibilityTest.php
vendor/bin/sail artisan test --compact tests/Unit/Support/BackupQuality/
```
Use `--filter` for a smaller pass while iterating.
## Manual Validation Pass
### 1. Verify backup-set list truth
Open `/admin/t/{tenant}/backup-sets` and confirm:
- lifecycle status remains visible and separate from backup-quality summary
- a full-quality set reads as `no degradations detected` or equivalent without implying safe restore
- a degraded set shows metadata-only, assignment issues, orphaned assignments, or degraded-item count within one scan
### 2. Verify backup-set detail summary-first layout
Open a degraded backup set and confirm:
- the first visible summary answers whether the set is strong or weak as recovery input
- metadata-only count, assignment issue count, and orphaned-assignment count appear before raw metadata
- one primary next action is visible when degraded truth exists
### 3. Verify backup-items relation truth
Within the same backup-set detail page, confirm the relation table shows:
- snapshot mode per item
- assignment capture issue truth per item
- orphaned-assignment truth per item
- current inspect model and action placement remain unchanged
### 4. Verify policy-version list and detail truth
Open `/admin/t/{tenant}/policy-versions` and confirm:
- metadata-only versions are visible at scan speed in the list itself
- full-payload and degraded versions are distinguishable without hovering disabled actions
Open a degraded policy version and confirm:
- an explicit backup-quality section appears on the detail surface
- the page explains degraded input quality without claiming safe restore or meaningful rollback certainty
### 5. Verify restore-selection truth before risk checks
Open `/admin/t/{tenant}/restore-runs/create` and confirm:
- step 1 backup-set choices expose degraded input before the wizard reaches checks or preview
- step 2 item descriptions expose metadata-only and assignment-quality truth before risk checks run
- the page still treats backup quality as input truth, not restore safety approval
### 6. Verify RBAC-safe truth visibility
Repeat the list and detail checks as a user with `TENANT_VIEW` but without restore permission and confirm:
- backup-quality truth remains visible
- restore entry points remain unavailable or disabled with the current RBAC behavior
- non-members still receive deny-as-not-found behavior rather than resource hints
## Non-Regression Checks
Confirm the feature did not change:
- tenant route identity for backup, version, or restore pages
- current archive, restore, force-delete, or remove confirmation behavior
- existing restore-safety blocking behavior for metadata-only input
- existing assignment capture semantics and orphaned-assignment detection
- current global asset registration and deployment requirements
## Formatting And Final Verification
Before finalizing implementation work:
```bash
vendor/bin/sail bin pint --dirty --format agent
```
Then rerun the smallest affected test set and offer the full suite only after the focused backup-quality pack passes.
Close the feature only after manual validation confirms:
- an operator can identify degraded versus full-looking backup input within 10 seconds on backup-set list and detail surfaces
- the first restore selection step exposes weak inputs before risk-check work begins
- reduced-permission users still see truthful quality signals without gaining restore capability

View File

@ -0,0 +1,65 @@
# Research: Backup Quality Truth Surfaces
## Decision 1: Derive backup quality from existing backup and version metadata instead of creating a persisted backup-health model
- Decision: Build backup quality from the metadata already present on `BackupItem` and `PolicyVersion`, then aggregate backup-set truth from those per-item facts. Do not add a new table, column, or stored backup-health projection.
- Rationale: The current data model already records the core quality signals this feature needs: metadata-only source markers, assignment fetch failures, orphaned assignments, warnings, scope-tag context, and integrity notes. The product problem is weak surfacing, not missing persistence.
- Alternatives considered:
- Persist a `backup_quality` or `backup_health` table. Rejected because it would create a second source of truth for information that is already derivable.
- Add materialized quality fields to `backup_sets` or `policy_versions`. Rejected because the feature does not need independent lifecycle state.
## Decision 2: Keep capture lifecycle and backup quality as separate truths on every affected surface
- Decision: Render capture lifecycle (`completed`, `partial`, `failed`, `archived`) independently from backup quality (`metadata-only present`, `assignment issues present`, degraded-count summary, or no degradations detected).
- Rationale: Operators currently overread `completed` as `good backup`. The feature must stop that conflation without erasing the lifecycle truth that the system already tracks.
- Alternatives considered:
- Blend quality into one stronger status badge. Rejected because that would collapse two different truths into one ambiguous state.
- Treat `completed` plus degraded counts as a new status family. Rejected because it would introduce new state where derived summary is sufficient.
## Decision 3: Reuse the central snapshot-mode badge and shared badge infrastructure instead of page-local mapping
- Decision: Use the existing `BadgeDomain::PolicySnapshotMode` and `PolicySnapshotModeBadge` semantics for `full` versus `metadata only`. Any new quality chips or labels should stay inside shared badge or copy seams rather than page-local `match` statements.
- Rationale: The codebase already centralizes status-like badge semantics, and Filament v5 tables or schema text badges can render those shared specs directly. This keeps backup quality aligned with BADGE-001 and avoids a second vocabulary for snapshot completeness.
- Alternatives considered:
- Add local badge mapping per surface. Rejected because it would drift from the central badge catalog.
- Introduce a generic trust score badge. Rejected because the spec explicitly avoids a new scoring engine.
## Decision 4: Use existing Filament tables, infolists, enterprise-detail sections, and checkbox-list descriptions instead of a new UI shell
- Decision: Implement the feature inside `BackupSetResource`, `BackupItemsRelationManager`, `PolicyVersionResource`, and `RestoreRunResource` using the current Filament table columns, infolist sections, enterprise-detail builder, and wizard descriptions.
- Rationale: Filament v5 already supports badge columns, summary-first view content, relation-manager tables, and descriptive checkbox list options. The repository also already uses `enterpriseDetailPage()` for backup-set detail and schema-driven wizard steps for restore selection.
- Alternatives considered:
- Build a dedicated backup-health dashboard. Rejected because it is explicitly out of scope.
- Add a custom client-side wizard overlay. Rejected because the needed truth is server-driven and fits existing Filament seams.
## Decision 5: Surface backup-set and item quality in restore selection before risk checks, but keep restore safety as a separate authority
- Decision: Enrich restore wizard step 1 backup-set labels or helper copy and step 2 item descriptions with input-quality truth before preview or risk checks run. Do not block degraded selections at this stage unless existing restore safety already blocks them later.
- Rationale: Operators need early warning before the risk-check stage, but this spec is about backup quality, not execution safety. The restore-safety layer already owns blocker and preview-only semantics.
- Alternatives considered:
- Leave degraded truth exclusively to restore risk checks. Rejected because it preserves the current late-discovery trust failure.
- Prevent selecting degraded inputs in step 1 or step 2. Rejected because the spec requires truthful surfacing, not a new restore policy.
## Decision 6: Preserve truth visibility for `TENANT_VIEW` users even when restore actions remain unavailable
- Decision: Quality truth remains visible on backup-set and policy-version surfaces for users who can view tenant backup or version records, even if they cannot create restore runs or use maintenance actions.
- Rationale: Missing restore permission must not make degraded inputs appear calmer or cleaner. Authorization can suppress mutation, but it must not suppress source-of-truth visibility.
- Alternatives considered:
- Couple quality sections to restore permissions. Rejected because it would falsify the operator surface.
- Rely on disabled restore actions as the quality indicator for lower-privilege users. Rejected because disabled actions are not an adequate explanation of input quality.
## Decision 7: Use `unknown quality` only when existing metadata cannot justify a stronger claim
- Decision: Emit `unknown quality` only when the record lacks authoritative metadata for snapshot completeness or assignment-quality interpretation. Absence of an error is not enough to call an item or version `full` if the record never captured the relevant quality signal.
- Rationale: Defaulting to `unknown` too often would hide real degradations, while defaulting to `full` from silence would overstate confidence. This feature needs a narrow, evidence-based fallback.
- Alternatives considered:
- Default all older records to `unknown`. Rejected because many records already carry usable source metadata.
- Infer `full` whenever `metadata_only` is absent. Rejected because silence is not always proof of completeness.
## Decision 8: Extend the existing Pest and Livewire test surface rather than introducing a browser-first harness
- Decision: Add focused unit coverage for backup-quality derivation and extend existing backup, version, restore-selection, and RBAC feature tests for UI truth. Keep the current Pest and Livewire patterns as the main verification path.
- Rationale: The affected behavior is server-driven list, detail, and wizard state, which the current test suite already covers well. The repo also already has restore and RBAC tests that should remain authoritative.
- Alternatives considered:
- Rely only on manual validation. Rejected because the feature is specifically about preventing subtle trust regressions.
- Introduce a large browser-only test pack. Rejected because the most important assertions are deterministic server-side state and rendered truth.

View File

@ -0,0 +1,193 @@
# Feature Specification: Backup Quality Truth Surfaces
**Feature Branch**: `[176-backup-quality-truth]`
**Created**: 2026-04-07
**Status**: Draft
**Input**: User description: "Spec 176 - Backup Quality Truth Surfaces"
## Spec Scope Fields *(mandatory)*
- **Scope**: tenant
- **Primary Routes**: `/admin/t/{tenant}/backup-sets`, `/admin/t/{tenant}/backup-sets/{record}`, `/admin/t/{tenant}/policy-versions`, `/admin/t/{tenant}/policy-versions/{record}`, `/admin/t/{tenant}/restore-runs/create`
- **Data Ownership**: Tenant-owned `BackupSet`, `BackupItem`, `PolicyVersion`, and `RestoreRun` draft-selection state within the active workspace and tenant scope.
- **RBAC**: Workspace plus tenant membership is required on every affected surface. Members with `TENANT_VIEW` must see backup-quality truth on backup and version surfaces. Restore creation remains gated by `TENANT_MANAGE`. Backup-set mutation actions remain gated by existing `TENANT_SYNC`, `TENANT_MANAGE`, and `TENANT_DELETE` capabilities.
## 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 |
|---|---|---|---|---|---|---|---|---|---|---|---|
| Backup sets page | CRUD / list-first resource | Full-row click to backup-set detail | required | One inline safe shortcut plus More | More menu and bulk More | `/admin/t/{tenant}/backup-sets` | `/admin/t/{tenant}/backup-sets/{record}` | Workspace context plus tenant context | Backup sets / Backup set | Capture lifecycle and backup quality summary | none |
| Backup set detail | Detail plus relation manager | Direct detail page | forbidden | Contextual summary links plus relation header actions | Resource More and relation-manager More | `/admin/t/{tenant}/backup-sets` | `/admin/t/{tenant}/backup-sets/{record}` | Tenant context plus related policy context | Backup set | Quality summary before per-item diagnostics | none |
| Backup items table | Relation-manager table | Full-row click within backup-set detail | required | Relation header actions plus More | More menu and bulk More | `/admin/t/{tenant}/backup-sets/{record}` | `/admin/t/{tenant}/backup-sets/{record}` | Parent backup set plus tenant context | Backup items / Backup item | Snapshot mode and assignment-quality truth per item | existing relation-manager pattern |
| Policy versions page | CRUD / list-first resource | Full-row click to policy-version detail | required | More menu | More menu and bulk More | `/admin/t/{tenant}/policy-versions` | `/admin/t/{tenant}/policy-versions/{record}` | Workspace context plus tenant context | Policy versions / Policy version | Snapshot mode and version input quality | Empty-state CTA routes to backup sets |
| Policy version detail | Detail / infolist page | Direct detail page | forbidden | Minimal related navigation only | No new destructive detail action placement | `/admin/t/{tenant}/policy-versions` | `/admin/t/{tenant}/policy-versions/{record}` | Tenant context plus related policy context | Policy version | Explicit backup-quality truth separate from restore availability | existing minimal header pattern |
| Restore run create wizard | Wizard / selection workflow | Step-driven selection inside restore-run creation | forbidden | Inline descriptions and next-action guidance | None at selection stage | `/admin/t/{tenant}/restore-runs` | `/admin/t/{tenant}/restore-runs/create` | Tenant context plus selected backup set | Restore run / Backup selection | Backup-set and item quality before safety checks | none |
## 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 |
|---|---|---|---|---|---|---|---|---|---|
| Backup sets page | Tenant operator | List | Which backup sets look strong or weak as recovery input? | Name, item count, capture timing, lifecycle status, compact backup-quality summary | Raw item metadata, per-item details, restore safety analysis | Capture lifecycle, input quality | TenantPilot only for existing archive and restore maintenance | Open backup set, Create backup set | Archive, Restore archived set, Force delete |
| Backup set detail | Tenant operator | Detail | Is this backup set a strong or weak recovery input, and why? | Quality summary, degraded counts, next actions, related context | Raw payloads, raw assignment JSON, integrity detail | Input quality, assignment completeness, lifecycle status | TenantPilot only for existing maintenance actions | Inspect backup items, open related context from contextual links | Archive, Restore archived set, Force delete |
| Backup items table | Tenant operator | Table inside detail | Which items are degraded inside this backup set? | Display name, type, snapshot mode, assignment issue signal, orphaned-assignment signal | Full metadata, raw assignments, low-level IDs | Snapshot completeness, assignment completeness | TenantPilot only for add and remove maintenance; none for visibility | Refresh, Add Policies, inspect row | Remove, Remove selected |
| Policy versions page | Tenant operator | List | Which versions are full-payload versus metadata-only or otherwise degraded? | Policy identity, version number, capture time, snapshot mode, quality signal | Raw JSON, diff payload, redaction detail | Version lifecycle, input quality | TenantPilot only for existing archive and maintenance actions | Open version, open related policy, open backup sets from empty state | Restore via Wizard, Archive, Restore archived version, Force delete, bulk prune |
| Policy version detail | Tenant operator | Detail | Is this version worth using as restore input? | Version identity, explicit backup-quality section, related context | Normalized settings, raw JSON, diff, redaction detail | Input quality, version lineage | None for visibility; existing restore entry remains separately gated | Open related policy | No new destructive detail action |
| Restore run create wizard | Tenant operator with restore rights | Wizard | Which backup set or items should I avoid or inspect before running safety checks? | Backup-set quality summary, per-item quality descriptions, stronger or weaker input hints | Risk-check output, preview diff, unresolved mapping detail | Input quality first, restore safety later | Simulation only until later confirmation and execution steps | Select backup set, select items, continue through wizard | Final restore execution remains later in the flow |
## Proportionality Review *(mandatory when structural complexity is introduced)*
- **New source of truth?**: no
- **New persisted entity/table/artifact?**: no
- **New abstraction?**: yes
- **New enum/state/reason family?**: no
- **New cross-domain UI framework/taxonomy?**: no
- **Current operator problem**: Operators can currently tell that a backup, item, or version exists, but they cannot quickly tell whether it is strong, degraded, or metadata-only as recovery input before they reach deep detail or restore-safety surfaces.
- **Existing structure is insufficient because**: The relevant truth is split across backup metadata, assignment metadata, disabled restore actions, and later restore-safety checks. That fragmentation causes false confidence and late discovery.
- **Narrowest correct implementation**: Introduce at most one narrow derived backup-quality helper that reads existing `BackupSet`, `BackupItem`, and `PolicyVersion` metadata and exposes a compact summary for existing list, detail, and wizard surfaces.
- **Ownership cost**: A small amount of shared derivation logic plus unit, feature, and RBAC regression tests that keep quality labels aligned with the underlying metadata keys.
- **Alternative intentionally rejected**: A persisted backup-health table, a tenant-wide scoring model, or a new recovery-confidence engine were rejected because they would create new truth, new state, and new ownership cost before the current surfaces tell the existing truth well.
- **Release truth**: current-release truth hardening
## User Scenarios & Testing *(mandatory)*
### User Story 1 - Judge Backup Sets Early (Priority: P1)
A tenant operator opens the backup-set list or detail page and needs to tell within seconds whether a backup set is merely stored or also looks strong enough to inspect further as recovery input.
**Why this priority**: This is the earliest point where false confidence must be prevented. If the operator misreads `completed` as `good backup`, every later restore decision inherits that error.
**Independent Test**: Can be fully tested by loading backup-set list and detail pages with full-quality and degraded fixtures and verifying that lifecycle status and backup quality are shown separately.
**Acceptance Scenarios**:
1. **Given** a tenant has one full-quality backup set and one degraded backup set, **When** the operator opens the backup-set list, **Then** each row shows capture status separately from a compact backup-quality summary.
2. **Given** a backup set contains degraded items, **When** the operator opens backup-set detail, **Then** the page shows a quality summary with degradation counts before per-item diagnostics or raw metadata.
3. **Given** a completed backup set contains only metadata-only items, **When** the operator scans the list or detail surface, **Then** the surface does not imply that the set is safe to restore or broadly recoverable.
---
### User Story 2 - Inspect Item and Version Strength (Priority: P2)
A tenant operator reviewing backup items or policy versions needs to distinguish full payloads from metadata-only or assignment-degraded inputs without inferring that truth from disabled actions or hidden metadata.
**Why this priority**: Item-level and version-level truth determines whether a backup set is actually useful. If this information stays implicit, operators cannot compare restore inputs confidently.
**Independent Test**: Can be fully tested by loading the backup-items table, policy-version list, and policy-version detail page with mixed-quality records and verifying explicit per-record quality signals.
**Acceptance Scenarios**:
1. **Given** backup items include full payload, metadata-only, assignment-fetch-failed, and orphaned-assignment examples, **When** the operator reviews the backup-items table, **Then** each item shows explicit snapshot mode and assignment-quality signals.
2. **Given** policy versions include both full payload and metadata-only snapshots, **When** the operator reviews the policy-version list, **Then** snapshot mode is visible without needing to hover disabled actions.
3. **Given** a policy-version detail page represents a degraded version, **When** the operator opens the page, **Then** the page shows an explicit backup-quality section that explains the weakness without using restore availability as the only signal.
---
### User Story 3 - Select Restore Inputs With Early Warning (Priority: P3)
A tenant operator starting a restore run needs to see weak backup sets and weak items before risk checks or preview steps so that poor input quality is visible at the first selection point.
**Why this priority**: Restore-safety hardening already exists later in the flow. This story closes the trust gap before the operator commits to a candidate backup set or item selection.
**Independent Test**: Can be fully tested by opening the restore-run creation wizard with degraded backup-set and backup-item fixtures and verifying that selection step labels or descriptions expose quality truth before safety checks run.
**Acceptance Scenarios**:
1. **Given** a degraded backup set is available for restore, **When** the operator opens restore wizard step 1, **Then** the backup-set selection surface shows that the set contains degraded input before the operator reaches safety checks.
2. **Given** selected restore items include metadata-only and assignment-degraded inputs, **When** the operator reviews restore wizard step 2, **Then** each affected item is clearly marked as degraded before any risk-check action occurs.
3. **Given** a backup set is full-quality, **When** the operator reviews steps 1 and 2, **Then** the wizard can communicate that no degradations are currently detected without claiming that restore is safe.
---
### User Story 4 - Preserve Truth Under RBAC Boundaries (Priority: P4)
A tenant member with backup or version viewing rights but without restore or maintenance rights still needs to see the same backup-quality truth so that authorization boundaries do not make weak inputs look calmer than they are.
**Why this priority**: Security boundaries must not distort source-of-truth visibility. Otherwise the UI becomes less truthful for read-only operators than for managers.
**Independent Test**: Can be fully tested by signing in as a tenant member with `TENANT_VIEW` but without restore capabilities and verifying that list and detail surfaces still expose quality truth while restore actions remain unavailable.
**Acceptance Scenarios**:
1. **Given** a tenant member has backup and version viewing rights but lacks restore permission, **When** they open backup-set or policy-version surfaces, **Then** backup-quality signals remain visible while restore actions stay unavailable.
2. **Given** a non-member requests the same tenant-scoped surfaces, **When** the request is made, **Then** the system responds with deny-as-not-found semantics instead of exposing resource existence.
### Edge Cases
- A backup set is `completed` and has zero degradations; the surface must explicitly show that no degradations are detected rather than leaving quality unstated.
- A backup set mixes full payload items with metadata-only and assignment-degraded items; the summary must show mixed quality without collapsing to a single misleading label.
- Assignment capture is marked not applicable for a policy type; the surface must not mislabel that condition as a failure.
- Older items or versions lack enough metadata to derive quality; the surface may show `unknown` only when no existing authoritative signal is available.
- Archived backup sets and archived policy versions must retain the same quality truth on list and detail surfaces as active records.
## Requirements *(mandatory)*
This feature introduces no new Microsoft Graph calls, no new background work, no new `OperationRun`, and no new persistence. It is a read-first truth-hardening feature that makes existing backup and version metadata visible earlier and more clearly.
Authorization remains in the tenant/admin plane under `/admin/t/{tenant}/...`. Non-members must continue to receive 404 responses. Established members missing mutation capabilities must continue to receive 403 on execution. Members with `TENANT_VIEW` must still see backup-quality truth on backup and version surfaces even when restore entry points remain unavailable.
Badge and UI semantics must stay centralized. Existing shared badge semantics, especially snapshot-mode badges, remain the canonical language for status-like signals. Any new quality labels or summaries must be derived from shared backup-quality rules rather than page-local color or wording decisions.
The affected Filament surfaces must keep exactly one primary inspect or open model, must not add redundant View actions, and must preserve destructive-action placement and confirmation behavior already defined by the action-surface contract. Quality truth is additive to existing surfaces, not a new local action framework.
If a shared backup-quality helper is introduced, it must replace duplicated page-local derivation instead of layering a second semantic system on top of existing restore-safety logic. Restore safety, preview eligibility, and execution outcome remain separate truths.
### Functional Requirements
- **FR-176-001**: The system MUST present backup existence truth separately from backup quality truth so that `completed`, `partial`, and `failed` remain capture-lifecycle states rather than quality claims.
- **FR-176-002**: The backup-set list MUST show a compact backup-quality summary per row that indicates either no detected degradations or the presence of one or more degradation families.
- **FR-176-003**: The backup-set detail surface MUST show a default-visible quality summary before deep diagnostics, including counts for metadata-only items, assignment-capture issues, orphaned-assignment signals, and any other degradation families that are already authoritatively derivable.
- **FR-176-004**: The backup-items table MUST show per-item snapshot mode and per-item assignment-quality signals without requiring the operator to open raw JSON or later restore surfaces.
- **FR-176-005**: The policy-version list MUST show snapshot mode for every visible version and MUST make degraded versions distinguishable from full-payload versions at scan speed.
- **FR-176-006**: The policy-version detail page MUST show explicit backup-quality truth and MUST NOT rely on disabled restore actions or tooltips as the only signal that a version is weak.
- **FR-176-007**: Restore wizard step 1 MUST expose backup-set quality before the operator reaches safety checks, preview generation, or execution confirmation.
- **FR-176-008**: Restore wizard step 2 MUST expose item-level quality before safety checks, including metadata-only and assignment-quality degradations where the underlying data already exists.
- **FR-176-009**: Metadata-only state MUST appear on backup and version surfaces as soon as the source metadata can establish it, and MUST NOT first surface as a restore-stage surprise.
- **FR-176-010**: Assignment-capture failures and orphaned-assignment signals MUST be operator-visible on backup-quality surfaces whenever the metadata already records them.
- **FR-176-011**: Backup-quality surfaces MUST NOT claim that a backup set, item, or version is safe to restore, restore-ready, or guaranteed to succeed.
- **FR-176-012**: Backup-quality surfaces MUST NOT imply that a strong-looking backup set proves tenant-wide recoverability, a guaranteed rollback path, or a recovery certification outcome.
- **FR-176-013**: Version history surfaces MUST separate three truths: version exists, version is selectable under current permissions and lifecycle state, and version has stronger or weaker payload quality.
- **FR-176-014**: When a backup set, item, or version is weak, the surface MUST suggest meaningful next actions such as opening detail, inspecting degraded items, preferring a stronger version, or continuing into restore with caution.
- **FR-176-015**: Quality signals MUST remain visible to users with backup or version viewing rights even when deeper restore or operations surfaces are inaccessible.
- **FR-176-016**: The feature MUST derive backup-quality truth from existing tenant-owned records and metadata and MUST NOT require a new persistence model, new materialized state, or a new cross-tenant scoring engine.
## Assumptions
- Existing metadata keys such as `source`, `snapshot_source`, `assignments_fetch_failed`, `assignment_capture_reason`, `has_orphaned_assignments`, scope-tag metadata, and redaction or integrity notes are authoritative enough for first-pass backup-quality truth.
- Existing restore-safety checks remain the sole owner of blocker, warning, preview-only, and execution-gating language.
- Older records may lack some quality metadata; in those cases the product may show `unknown quality` only when the existing record truly does not contain enough information to derive a stronger statement.
## Dependencies
- Existing tenant-scoped backup, version, and restore resources remain the operator entry points.
- Existing centralized badge semantics, especially snapshot-mode badges, remain the canonical language for visible status.
- Existing restore-safety integrity behavior and metadata-only execution blocking remain unchanged and continue to run after the earlier backup-quality surfaces.
## Out of Scope and Follow-up
- No redesign of restore execution, restore-safety logic, backup capture, retention or pruning, tenant-wide recovery scoring, notification domains, or new persisted backup-health artifacts.
- Reasonable follow-up work includes a backup-health dashboard, a broader recovery-confidence rollup, and version-rollback usefulness guidance after the current truth-hardening slice is complete.
## 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 |
|---|---|---|---|---|---|---|---|---|---|---|
| BackupSetResource | `app/Filament/Resources/BackupSetResource.php` | Create backup set | `recordUrl()` clickable row | Primary related action, More: Restore / Archive / Force delete | More: Archive Backup Sets / Restore Backup Sets / Force Delete Backup Sets | Create backup set | Grouped existing mutations remain; related navigation stays in contextual summary links, not the header | Create backup set submit plus cancel | Existing audit logging remains for restore, archive, and force delete; read-only quality truth adds no new audit event | Action surface contract stays satisfied. Quality summary is additive only. |
| BackupItemsRelationManager | `app/Filament/Resources/BackupSetResource/RelationManagers/BackupItemsRelationManager.php` | Refresh, Add Policies | Clickable row | More: Remove | More: Remove selected | Add Policies | n/a | n/a | Existing operation-run and audit behavior for remove flows remains; visibility changes are read-only | Existing relation-manager exception remains; no redundant View action is added. |
| PolicyVersionResource | `app/Filament/Resources/PolicyVersionResource.php` | none | `recordUrl()` clickable row | Primary related action, More: Restore via Wizard / Archive / Force delete / Restore archived version | More: Prune Versions / Restore Versions / Force Delete Versions | Open backup sets | Existing detail header remains intentionally minimal | n/a | Existing audit logging remains for archive, force delete, and restore; restore-via-wizard keeps existing restore-run and backup creation behavior | Policy-version detail gains explicit quality truth so disabled actions stop being the only signal. |
| Restore run create wizard | `app/Filament/Resources/RestoreRunResource.php`, `app/Filament/Resources/RestoreRunResource/Pages/CreateRestoreRun.php` | none | n/a | n/a | n/a | none | n/a | Wizard Previous / Next / Create restore run | Existing restore-run create and execute audit behavior remains unchanged; selection-stage quality visibility is read-only | Step 1 and step 2 gain quality descriptions only. No new destructive action is introduced. |
## Key Entities *(include if feature involves data)*
- **BackupSet**: A tenant-owned capture collection that already records lifecycle state, timestamps, item count, and metadata describing how the set was produced.
- **BackupItem**: A tenant-owned captured recovery input for one policy or foundation item, including payload, assignments, and metadata that can expose snapshot completeness and assignment-quality issues.
- **PolicyVersion**: An immutable tenant-owned version record that stores captured snapshot data, related metadata, assignments, redaction context, and capture timing.
- **Restore selection context**: The tenant-scoped backup-set and optional item selection that an operator builds before restore-safety checks and preview generation.
## Success Criteria *(mandatory)*
### Measurable Outcomes
- **SC-001**: In validation sessions and acceptance tests, an operator can identify whether a backup set is full-quality or degraded from the list or detail surface in under 10 seconds without opening raw JSON or preview surfaces.
- **SC-002**: In 100% of tested cases where existing records contain metadata-only, assignment-fetch-failed, or orphaned-assignment signals, at least one default-visible backup-quality signal appears on every affected list, detail, or wizard selection surface.
- **SC-003**: In 100% of RBAC test cases, users with backup or version viewing rights but without restore rights can still see backup-quality truth on list and detail surfaces while restore actions remain unavailable.
- **SC-004**: In 100% of degraded restore-input scenarios covered by acceptance tests, backup-set and item quality is visible before the operator reaches restore-safety checks or preview generation.

View File

@ -0,0 +1,249 @@
# Tasks: Backup Quality Truth Surfaces
**Input**: Design documents from `/specs/176-backup-quality-truth/`
**Prerequisites**: `plan.md` (required), `spec.md` (required for user stories), `research.md`, `data-model.md`, `contracts/`, `quickstart.md`
**Tests**: Tests are REQUIRED for this feature. Use focused Pest coverage in existing backup, version, restore-selection, and RBAC suites, plus new backup-quality tests under `tests/Feature/Filament/`, `tests/Feature/Rbac/`, and `tests/Unit/Support/BackupQuality/`.
**Operations**: This feature introduces no new `OperationRun`, no queued or scheduled work, and no new audit event family. Work is limited to read-first quality truth on existing backup, version, and restore-selection surfaces.
**RBAC**: Existing tenant membership, capability-registry usage, and `404` vs `403` behavior must remain unchanged across `/admin/t/{tenant}/backup-sets/...`, `/admin/t/{tenant}/policy-versions/...`, and `/admin/t/{tenant}/restore-runs/create`. Users with `TENANT_VIEW` must see backup-quality truth on read surfaces even when restore actions remain unavailable.
**Operator Surfaces**: Backup-set list and detail, backup-items relation rows, policy-version list and detail, and restore wizard step 1 and step 2 must show backup-quality truth before diagnostics or later restore-safety conclusions. Quality copy must remain distinct from lifecycle, restore readiness, and tenant recoverability claims.
**Filament UI Action Surfaces**: No new primary inspect model, redundant View action, or destructive-action placement is introduced. Existing archive, restore, force-delete, remove, and bulk actions remain confirmation-gated and server-authorized.
**Filament UI UX-001**: This feature keeps the existing Filament list, relation-manager, infolist, enterprise-detail, and wizard layouts. New quality sections must be summary-first and diagnostics-second.
**Badges**: Snapshot completeness must continue to use centralized badge semantics through `app/Support/Badges/Domains/PolicySnapshotModeBadge.php`. Any additional quality wording must come from the shared backup-quality layer rather than page-local mappings.
**Organization**: Tasks are grouped by user story so each story can be implemented and validated as an independent increment after the shared backup-quality scaffolding is in place.
## Phase 1: Setup (Shared Backup-Quality Scaffolding)
**Purpose**: Add the narrow derived backup-quality layer and base test scaffolding used by every story.
- [X] T001 Create the shared backup-quality resolver and summary types in `app/Support/BackupQuality/BackupQualityResolver.php` and `app/Support/BackupQuality/BackupQualitySummary.php`
- [X] T002 [P] Add unit test scaffolding for resolver rules and aggregate summaries in `tests/Unit/Support/BackupQuality/BackupQualityResolverTest.php` and `tests/Unit/Support/BackupQuality/BackupSetQualitySummaryTest.php`
- [X] T003 Add metadata convenience helpers for item evidence in `app/Models/BackupItem.php` and mirror them in `app/Models/PolicyVersion.php` only if resolver wiring still leaves justified duplication there
- [X] T004 [P] Extend metadata semantics regression coverage in `tests/Unit/BackupItemTest.php` and `tests/Unit/AssignmentBackupServiceTest.php`
---
## Phase 2: Foundational (Blocking Shared Wiring)
**Purpose**: Wire the shared backup-quality contract into the existing Filament seams before any story-specific surface work begins.
**⚠️ CRITICAL**: No user story work should begin until this phase is complete.
- [X] T005 Thread shared backup-quality loading and aggregation hooks through `app/Filament/Resources/BackupSetResource.php`, `app/Filament/Resources/PolicyVersionResource.php`, and `app/Filament/Resources/RestoreRunResource.php`
- [X] T006 [P] Reuse centralized snapshot-mode and summary copy seams in `app/Support/Badges/Domains/PolicySnapshotModeBadge.php` and `app/Support/BackupQuality/BackupQualitySummary.php`
- [X] T007 [P] Add shared mixed-signal, integrity-warning, and unknown-quality regression coverage in `tests/Unit/Support/BackupQuality/BackupQualityResolverTest.php` and `tests/Feature/Filament/BackupSetEnterpriseDetailPageTest.php`
**Checkpoint**: Backup, version, and restore-selection resources can now consume one shared backup-quality contract.
---
## Phase 3: User Story 1 - Judge Backup Sets Early (Priority: P1) 🎯 MVP
**Goal**: Let operators distinguish stored backup sets from degraded recovery input within one scan of the list or detail surface.
**Independent Test**: Load backup-set list and detail pages with full-quality, mixed-quality, and metadata-only fixtures and verify lifecycle truth stays separate from a default-visible quality summary.
### Tests for User Story 1
- [X] T008 [P] [US1] Add backup-set list truth coverage for full, mixed, metadata-only, and archived sets in `tests/Feature/Filament/BackupQualityTruthSurfaceTest.php`
- [X] T009 [P] [US1] Extend summary-first backup-set detail assertions to cover archived parity and integrity-warning counts in `tests/Feature/Filament/BackupSetEnterpriseDetailPageTest.php`
### Implementation for User Story 1
- [X] T010 [US1] Add compact backup-quality summary columns and row copy to `app/Filament/Resources/BackupSetResource.php`
- [X] T011 [US1] Add a default-visible quality summary section with integrity-warning counts, next-action guidance, and contextual related links that stay out of the header in `app/Filament/Resources/BackupSetResource.php`
- [X] T012 [US1] Ensure backup-set quality summaries use aggregated item facts without new N+1 queries in `app/Support/BackupQuality/BackupQualityResolver.php` and `app/Filament/Resources/BackupSetResource.php`
- [X] T013 [US1] Run the focused backup-set truth pack in `tests/Feature/Filament/BackupQualityTruthSurfaceTest.php` and `tests/Feature/Filament/BackupSetEnterpriseDetailPageTest.php`
**Checkpoint**: Backup-set list and detail now expose input quality early without implying restore safety.
---
## Phase 4: User Story 2 - Inspect Item and Version Strength (Priority: P2)
**Goal**: Make item-level and version-level payload quality explicit instead of forcing operators to infer it from disabled actions or hidden metadata.
**Independent Test**: Load backup-items and policy-versions surfaces with full, metadata-only, assignment-fetch-failed, orphaned-assignment, and not-applicable fixtures and verify each surface renders explicit quality truth at scan speed.
### Tests for User Story 2
- [X] T014 [P] [US2] Extend backup-item relation truth coverage for snapshot mode, assignment failures, orphaned assignments, non-failure capture reasons, and inspect-next-step cues in `tests/Feature/Filament/BackupItemsRelationManagerFiltersTest.php`
- [X] T015 [P] [US2] Add policy-version quality list and detail coverage for full, degraded, integrity-warning, and archived versions in `tests/Feature/Filament/PolicyVersionQualityTruthSurfaceTest.php` and `tests/Feature/Filament/PolicyVersionTest.php`
- [X] T016 [P] [US2] Add regression coverage that quality truth remains visible independently from restore action gating in `tests/Feature/Filament/PolicyVersionRestoreViaWizardTest.php` and `tests/Unit/Support/BackupQuality/BackupQualityResolverTest.php`
### Implementation for User Story 2
- [X] T017 [US2] Add per-item snapshot mode, assignment-quality signals, and inspect-detail next-step cues to `app/Filament/Resources/BackupSetResource/RelationManagers/BackupItemsRelationManager.php`
- [X] T018 [US2] Add scan-speed snapshot mode, integrity-warning visibility, stronger-version or open-detail cues, and a single empty-state CTA to the table schema in `app/Filament/Resources/PolicyVersionResource.php`
- [X] T019 [US2] Add an explicit backup-quality infolist section with integrity-warning truth and non-overclaiming guidance in `app/Filament/Resources/PolicyVersionResource.php`
- [X] T020 [US2] Remove page-local quality derivation from item and version surfaces by routing them through `app/Support/BackupQuality/BackupQualityResolver.php`, `app/Filament/Resources/BackupSetResource/RelationManagers/BackupItemsRelationManager.php`, and `app/Filament/Resources/PolicyVersionResource.php`
- [X] T021 [US2] Run the focused item and version truth pack in `tests/Feature/Filament/BackupItemsRelationManagerFiltersTest.php`, `tests/Feature/Filament/PolicyVersionQualityTruthSurfaceTest.php`, `tests/Feature/Filament/PolicyVersionTest.php`, and `tests/Feature/Filament/PolicyVersionRestoreViaWizardTest.php`
**Checkpoint**: Backup items and policy versions now expose quality truth directly where operators inspect them.
---
## Phase 5: User Story 3 - Select Restore Inputs With Early Warning (Priority: P3)
**Goal**: Show weak backup sets and items at the first restore-selection step, before later restore-safety checks or preview work begins.
**Independent Test**: Open the restore wizard with degraded backup-set and backup-item fixtures and verify step 1 and step 2 expose input quality before any risk-check output is shown.
### Tests for User Story 3
- [X] T022 [P] [US3] Add step-1 degraded backup-set hint coverage in `tests/Feature/Filament/RestoreSelectionQualityTruthTest.php`
- [X] T023 [P] [US3] Extend pre-risk-check item-quality assertions in `tests/Feature/Filament/RestoreItemSelectionTest.php` and `tests/Feature/RestoreRiskChecksWizardTest.php`
### Implementation for User Story 3
- [X] T024 [US3] Add backup-set quality summaries to step-1 option labels and helper text in `app/Filament/Resources/RestoreRunResource.php`
- [X] T025 [US3] Add item-level snapshot mode and assignment-quality descriptions to restore item option data in `app/Filament/Resources/RestoreRunResource.php`
- [X] T026 [US3] Preserve the backup-quality versus restore-safety claim boundary in `app/Filament/Resources/RestoreRunResource.php` and `app/Filament/Resources/RestoreRunResource/Pages/CreateRestoreRun.php`
- [X] T027 [US3] Run the focused restore-selection truth pack in `tests/Feature/Filament/RestoreSelectionQualityTruthTest.php`, `tests/Feature/Filament/RestoreItemSelectionTest.php`, and `tests/Feature/RestoreRiskChecksWizardTest.php`
**Checkpoint**: Restore selection now exposes degraded input before later restore-safety logic runs.
---
## Phase 6: User Story 4 - Preserve Truth Under RBAC Boundaries (Priority: P4)
**Goal**: Keep backup-quality truth visible to read-only tenant viewers while preserving existing restore and mutation restrictions plus deny-as-not-found behavior.
**Independent Test**: Sign in as a tenant member with `TENANT_VIEW` but without restore capability and verify backup-set and policy-version surfaces still show quality truth, while non-members still receive `404` and in-scope users without mutation capability still receive `403` on execution.
### Tests for User Story 4
- [X] T028 [P] [US4] Add tenant-view visibility coverage for backup-set, backup-item relation rows, and policy-version quality truth in `tests/Feature/Rbac/BackupQualityVisibilityTest.php`
- [X] T029 [P] [US4] Extend deny-as-not-found and missing-capability regressions for backup and restore entry points in `tests/Feature/Filament/BackupSetUiEnforcementTest.php`, `tests/Feature/Rbac/BackupItemsRelationManagerUiEnforcementTest.php`, `tests/Feature/Rbac/CreateRestoreRunAuthorizationTest.php`, and `tests/Feature/Rbac/PolicyVersionsRestoreToIntuneUiEnforcementTest.php`
### Implementation for User Story 4
- [X] T030 [US4] Adjust quality-section visibility so read surfaces and backup-item relation rows remain available to `TENANT_VIEW` users in `app/Filament/Resources/BackupSetResource.php`, `app/Filament/Resources/BackupSetResource/RelationManagers/BackupItemsRelationManager.php`, and `app/Filament/Resources/PolicyVersionResource.php`
- [X] T031 [US4] Preserve `404` vs `403` semantics around restore-linked quality hints in `app/Filament/Resources/RestoreRunResource.php` and `app/Filament/Resources/RestoreRunResource/Pages/CreateRestoreRun.php`
- [X] T032 [US4] Run the focused RBAC truth pack in `tests/Feature/Rbac/BackupQualityVisibilityTest.php`, `tests/Feature/Filament/BackupSetUiEnforcementTest.php`, `tests/Feature/Rbac/BackupItemsRelationManagerUiEnforcementTest.php`, `tests/Feature/Rbac/CreateRestoreRunAuthorizationTest.php`, and `tests/Feature/Rbac/PolicyVersionsRestoreToIntuneUiEnforcementTest.php`
**Checkpoint**: Quality truth remains visible under read-only permissions without weakening authorization boundaries.
---
## Phase 7: Polish & Cross-Cutting Concerns
**Purpose**: Final consistency, cleanup, formatting, and focused verification across all stories.
- [X] T033 [P] Review and align operator-facing backup-quality copy and next-action wording in `app/Support/BackupQuality/BackupQualitySummary.php`, `app/Filament/Resources/BackupSetResource.php`, `app/Filament/Resources/PolicyVersionResource.php`, and `app/Filament/Resources/RestoreRunResource.php`
- [X] T034 Remove dead fallback derivation and duplicate helper logic left after story implementation in `app/Support/BackupQuality/BackupQualityResolver.php`, `app/Filament/Resources/BackupSetResource.php`, `app/Filament/Resources/PolicyVersionResource.php`, and `app/Filament/Resources/RestoreRunResource.php`
- [X] T035 Run formatting with `vendor/bin/sail bin pint --dirty --format agent` for `app/Support/BackupQuality/BackupQualityResolver.php`, `app/Filament/Resources/BackupSetResource.php`, `app/Filament/Resources/PolicyVersionResource.php`, `app/Filament/Resources/RestoreRunResource.php`, and `tests/Feature/Filament/BackupQualityTruthSurfaceTest.php`
- [X] T036 Run the focused verification pack from `specs/176-backup-quality-truth/quickstart.md` against `tests/Feature/Filament/BackupQualityTruthSurfaceTest.php`, `tests/Feature/Filament/BackupSetEnterpriseDetailPageTest.php`, `tests/Feature/Filament/BackupItemsRelationManagerFiltersTest.php`, `tests/Feature/Filament/BackupSetUiEnforcementTest.php`, `tests/Feature/Filament/PolicyVersionQualityTruthSurfaceTest.php`, `tests/Feature/Filament/PolicyVersionTest.php`, `tests/Feature/Filament/PolicyVersionRestoreViaWizardTest.php`, `tests/Feature/Filament/RestoreSelectionQualityTruthTest.php`, `tests/Feature/Filament/RestoreItemSelectionTest.php`, `tests/Feature/RestoreRiskChecksWizardTest.php`, `tests/Feature/Rbac/BackupQualityVisibilityTest.php`, `tests/Feature/Rbac/BackupItemsRelationManagerUiEnforcementTest.php`, `tests/Feature/Rbac/CreateRestoreRunAuthorizationTest.php`, `tests/Feature/Rbac/PolicyVersionsRestoreToIntuneUiEnforcementTest.php`, `tests/Unit/Support/BackupQuality/BackupQualityResolverTest.php`, `tests/Unit/Support/BackupQuality/BackupSetQualitySummaryTest.php`, `tests/Unit/AssignmentBackupServiceTest.php`, and `tests/Unit/BackupItemTest.php`
- [ ] T037 Run the manual validation pass in `specs/176-backup-quality-truth/quickstart.md` for backup-set, policy-version, restore-selection, and RBAC truth surfaces
---
## Dependencies & Execution Order
### Phase Dependencies
- **Setup (Phase 1)**: Starts immediately and establishes the shared backup-quality namespace and test scaffolding.
- **Foundational (Phase 2)**: Depends on Setup and blocks all story work until existing Filament resources consume the shared resolver and summary contract.
- **User Story 1 (Phase 3)**: Starts after Foundational and delivers the MVP truth surface on backup-set list and detail pages.
- **User Story 2 (Phase 4)**: Starts after Foundational and can proceed alongside User Story 1 once the shared resolver contract is stable.
- **User Story 3 (Phase 5)**: Starts after User Story 1 and User Story 2 because restore selection reuses both aggregate backup-set truth and item-level quality facts.
- **User Story 4 (Phase 6)**: Starts after User Story 1 and User Story 2 and should finish after User Story 3 if restore-selection RBAC visibility is included in the same pass.
- **Polish (Phase 7)**: Starts after the desired user stories are complete.
### User Story Dependencies
- **US1**: Depends only on Setup and Foundational work.
- **US2**: Depends only on Setup and Foundational work and shares the same resolver contract as US1.
- **US3**: Depends on US1 and US2 because step 1 and step 2 reuse the backup-set and item-level quality models introduced there.
- **US4**: Depends on US1 and US2 for truth surfaces and should include US3 when restore-wizard visibility checks are part of the same test pass.
### Within Each User Story
- Tests should be added or updated before the corresponding behavior change is considered complete.
- Shared resolver and resource wiring should land before story-specific copy cleanup for the same surface.
- Story-level focused test runs should pass before moving to the next priority slice.
### Parallel Opportunities
- `T002` and `T004` can run in parallel once `T001` and `T003` establish the helper signatures and metadata rules.
- `T006` and `T007` can run in parallel after `T005` lands the shared wiring points.
- `T008` and `T009` can run in parallel for US1.
- `T014`, `T015`, and `T016` can run in parallel for US2.
- `T022` and `T023` can run in parallel for US3.
- `T028` and `T029` can run in parallel for US4.
- `T033` and story-specific cleanup reviews can run in parallel once feature behavior is stable.
---
## Parallel Example: User Story 1
```bash
# Story 1 tests in parallel:
Task: T008 tests/Feature/Filament/BackupQualityTruthSurfaceTest.php
Task: T009 tests/Feature/Filament/BackupSetEnterpriseDetailPageTest.php
# Story 1 implementation after assertions are set:
Task: T010 app/Filament/Resources/BackupSetResource.php
Task: T012 app/Support/BackupQuality/BackupQualityResolver.php
```
## Parallel Example: User Story 2
```bash
# Story 2 tests in parallel:
Task: T014 tests/Feature/Filament/BackupItemsRelationManagerFiltersTest.php
Task: T015 tests/Feature/Filament/PolicyVersionQualityTruthSurfaceTest.php
Task: T016 tests/Feature/Filament/PolicyVersionRestoreViaWizardTest.php
# Story 2 implementation split after the resolver contract is fixed:
Task: T017 app/Filament/Resources/BackupSetResource/RelationManagers/BackupItemsRelationManager.php
Task: T018 app/Filament/Resources/PolicyVersionResource.php
```
## Parallel Example: User Story 3
```bash
# Story 3 tests in parallel:
Task: T022 tests/Feature/Filament/RestoreSelectionQualityTruthTest.php
Task: T023 tests/Feature/Filament/RestoreItemSelectionTest.php
# Story 3 implementation split after the selection copy is agreed:
Task: T024 app/Filament/Resources/RestoreRunResource.php
Task: T025 app/Filament/Resources/RestoreRunResource.php
```
## Parallel Example: User Story 4
```bash
# Story 4 tests in parallel:
Task: T028 tests/Feature/Rbac/BackupQualityVisibilityTest.php
Task: T029 tests/Feature/Rbac/CreateRestoreRunAuthorizationTest.php
# Story 4 implementation split after RBAC expectations are locked:
Task: T030 app/Filament/Resources/BackupSetResource.php
Task: T031 app/Filament/Resources/RestoreRunResource/Pages/CreateRestoreRun.php
```
---
## Implementation Strategy
### MVP First
- Complete Phase 1 and Phase 2.
- Deliver User Story 1 as the first usable increment so operators can judge backup-set quality early.
- Validate that lifecycle truth and backup-quality truth are clearly separated on backup-set list and detail surfaces.
### Incremental Delivery
- Add User Story 2 next so item and version strength become explicit everywhere operators inspect backup inputs.
- Add User Story 3 after that so restore selection inherits the same quality truth before risk checks.
- Add User Story 4 last to verify RBAC-safe truth visibility across read and restore-linked surfaces.
### Verification Finish
- Run Pint on touched files.
- Run the focused verification pack from `quickstart.md`.
- Run the manual quickstart validation pass for backup-set, policy-version, restore-selection, and RBAC outcomes.
- Offer the broader test suite only after the focused pack passes.

View File

@ -0,0 +1,36 @@
# Specification Quality Checklist: Tenant Backup Health Signals
**Purpose**: Validate specification completeness and quality before proceeding to planning
**Created**: 2026-04-07
**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
- Validated on 2026-04-07 against the completed Spec 180 draft.
- No clarification markers remain.
- The spec stays bounded to tenant backup-health truth on dashboard and follow-up surfaces and does not expand into recovery-confidence or new persistence.

View File

@ -0,0 +1,420 @@
openapi: 3.1.0
info:
title: Tenant Backup Health Surface Contracts
version: 1.0.0
description: >-
Internal reference contract for tenant backup-health surfaces. The application
continues to render HTML through Filament and Livewire. The vendor media types
below document the structured dashboard, backup-set, and backup-schedule models
that must be derivable before rendering. This is not a public API commitment.
paths:
/admin/t/{tenant}:
get:
summary: Tenant dashboard backup-health surface
description: >-
Returns the rendered tenant dashboard. The vendor media type documents the
backup-health summary, backup-health attention item, and healthy-check model
that the dashboard must expose.
parameters:
- name: tenant
in: path
required: true
schema:
type: integer
responses:
'200':
description: Rendered tenant dashboard
content:
text/html:
schema:
type: string
application/vnd.tenantpilot.tenant-backup-health-dashboard+json:
schema:
$ref: '#/components/schemas/TenantBackupHealthDashboardSurface'
'404':
description: Tenant scope is not visible because workspace or tenant membership is missing
/admin/t/{tenant}/backup-sets:
get:
summary: Backup-set list confirmation surface
description: >-
Returns the rendered backup-set list page. The vendor media type documents
how the latest relevant backup basis and no-backup posture are confirmed.
parameters:
- name: tenant
in: path
required: true
schema:
type: integer
responses:
'200':
description: Rendered backup-set list page
content:
text/html:
schema:
type: string
application/vnd.tenantpilot.backup-health-backup-set-collection+json:
schema:
$ref: '#/components/schemas/BackupSetCollectionSurface'
'403':
description: Viewer is in scope but lacks backup viewing capability
'404':
description: Tenant scope is not visible because workspace or tenant membership is missing
/admin/t/{tenant}/backup-sets/{backupSet}:
get:
summary: Backup-set detail confirmation surface
description: >-
Returns the rendered backup-set detail page. The vendor media type documents
the recency and quality facts that must confirm stale or degraded latest-backup posture.
parameters:
- name: tenant
in: path
required: true
schema:
type: integer
- name: backupSet
in: path
required: true
schema:
type: integer
responses:
'200':
description: Rendered backup-set detail page
content:
text/html:
schema:
type: string
application/vnd.tenantpilot.backup-health-backup-set-detail+json:
schema:
$ref: '#/components/schemas/BackupSetDetailSurface'
'403':
description: Viewer is in scope but lacks backup viewing capability for the linked backup-set detail surface
'404':
description: Backup set is not visible because workspace or tenant membership is missing
/admin/t/{tenant}/backup-schedules:
get:
summary: Backup-schedule follow-up confirmation surface
description: >-
Returns the rendered backup-schedule list page. The vendor media type documents
the schedule-follow-up facts that must confirm overdue or missed schedule execution.
parameters:
- name: tenant
in: path
required: true
schema:
type: integer
responses:
'200':
description: Rendered backup-schedule list page
content:
text/html:
schema:
type: string
application/vnd.tenantpilot.backup-health-schedule-collection+json:
schema:
$ref: '#/components/schemas/BackupScheduleCollectionSurface'
'403':
description: Viewer is in scope but lacks schedule viewing capability
'404':
description: Tenant scope is not visible because workspace or tenant membership is missing
components:
schemas:
TenantBackupHealthDashboardSurface:
type: object
required:
- summary
properties:
summary:
$ref: '#/components/schemas/BackupHealthSummary'
attentionItem:
description: Present when a backup-health caution remains. `healthyCheck` must be null in that case.
oneOf:
- $ref: '#/components/schemas/BackupHealthAttentionItem'
- type: 'null'
healthyCheck:
description: Present only when no backup-health attention item remains unresolved, including `schedule_follow_up`.
oneOf:
- $ref: '#/components/schemas/HealthyCheck'
- type: 'null'
BackupHealthSummary:
type: object
required:
- posture
- primaryReason
- headline
- tone
- healthyClaimAllowed
properties:
posture:
type: string
enum: [absent, stale, degraded, healthy]
description: Describes the state of the latest relevant backup basis itself.
primaryReason:
description: The active follow-up reason. This may be `schedule_follow_up` even when `posture` remains `healthy`.
oneOf:
- type: string
enum: [no_backup_basis, latest_backup_stale, latest_backup_degraded, schedule_follow_up]
- type: 'null'
headline:
type: string
supportingMessage:
type:
- string
- 'null'
tone:
type: string
enum: [danger, warning, success, gray]
latestRelevantBackupSetId:
type:
- integer
- 'null'
latestRelevantCompletedAt:
type:
- string
- 'null'
format: date-time
freshness:
$ref: '#/components/schemas/FreshnessEvaluation'
scheduleFollowUp:
$ref: '#/components/schemas/ScheduleFollowUpEvaluation'
healthyClaimAllowed:
type: boolean
description: True only when the backup basis is healthy and no unresolved `schedule_follow_up` or stronger caution remains.
actionTarget:
oneOf:
- $ref: '#/components/schemas/ActionTarget'
- type: 'null'
actionDisabled:
type: boolean
helperText:
type:
- string
- 'null'
positiveClaimBoundary:
type: string
BackupHealthAttentionItem:
type: object
required:
- title
- body
- badge
- badgeColor
properties:
title:
type: string
body:
type: string
badge:
type: string
badgeColor:
type: string
actionTarget:
oneOf:
- $ref: '#/components/schemas/ActionTarget'
- type: 'null'
actionDisabled:
type: boolean
helperText:
type:
- string
- 'null'
HealthyCheck:
type: object
required:
- title
- body
properties:
title:
type: string
body:
type: string
actionTarget:
oneOf:
- $ref: '#/components/schemas/ActionTarget'
- type: 'null'
FreshnessEvaluation:
type: object
required:
- isFresh
- policySource
properties:
latestCompletedAt:
type:
- string
- 'null'
format: date-time
cutoffAt:
type: string
format: date-time
isFresh:
type: boolean
policySource:
type: string
enum: [configured_window]
ScheduleFollowUpEvaluation:
type: object
required:
- hasEnabledSchedules
- needsFollowUp
properties:
hasEnabledSchedules:
type: boolean
enabledScheduleCount:
type: integer
overdueScheduleCount:
type: integer
failedRecentRunCount:
type: integer
neverSuccessfulCount:
type: integer
needsFollowUp:
type: boolean
summaryMessage:
type:
- string
- 'null'
ActionTarget:
type: object
required:
- surface
- label
- reason
properties:
surface:
type: string
enum: [backup_sets_index, backup_set_view, backup_schedules_index]
recordId:
type:
- integer
- 'null'
label:
type: string
reason:
type: string
BackupSetCollectionSurface:
type: object
required:
- postureConfirmation
- rows
properties:
latestRelevantBackupSetId:
type:
- integer
- 'null'
postureConfirmation:
type: string
description: Explicit confirmation of the current collection meaning, including `no usable completed backup basis exists` when the dashboard drillthrough is resolving a `no_backup_basis` state.
rows:
type: array
items:
$ref: '#/components/schemas/BackupSetRow'
BackupSetRow:
type: object
required:
- id
- name
- lifecycleStatus
- completedAt
- qualitySummary
properties:
id:
type: integer
name:
type: string
lifecycleStatus:
type: string
completedAt:
type:
- string
- 'null'
format: date-time
isLatestRelevant:
type: boolean
qualitySummary:
type: string
BackupSetDetailSurface:
type: object
required:
- header
- freshness
- qualitySummary
- postureConfirmation
properties:
header:
$ref: '#/components/schemas/BackupSetHeader'
freshness:
$ref: '#/components/schemas/FreshnessEvaluation'
qualitySummary:
type: string
postureConfirmation:
type: string
positiveClaimBoundary:
type: string
BackupSetHeader:
type: object
required:
- id
- name
- lifecycleStatus
properties:
id:
type: integer
name:
type: string
lifecycleStatus:
type: string
completedAt:
type:
- string
- 'null'
format: date-time
BackupScheduleCollectionSurface:
type: object
required:
- rows
- followUpPresent
properties:
followUpPresent:
type: boolean
summaryMessage:
type:
- string
- 'null'
rows:
type: array
items:
$ref: '#/components/schemas/BackupScheduleRow'
BackupScheduleRow:
type: object
required:
- id
- name
- isEnabled
- followUpState
properties:
id:
type: integer
name:
type: string
isEnabled:
type: boolean
lastRunStatus:
type:
- string
- 'null'
lastRunAt:
type:
- string
- 'null'
format: date-time
nextRunAt:
type:
- string
- 'null'
format: date-time
followUpState:
type: string
enum: [none, overdue, failed_recently, never_successful, disabled]
followUpMessage:
type:
- string
- 'null'

View File

@ -0,0 +1,202 @@
# Data Model: Tenant Backup Health Signals
## Overview
This feature does not add or change a top-level persisted domain entity. It introduces a narrow derived tenant backup-health model over existing tenant-owned backup and schedule records and integrates that derived truth into dashboard and drillthrough surfaces.
The central design task is to make the tenant dashboard answer four operator-visible states without changing:
- `BackupSet`, `BackupItem`, or `BackupSchedule` ownership
- existing backup-quality source-of-truth rules
- existing backup or schedule route identity
- existing mutation, audit, or `OperationRun` responsibilities
- the no-new-table and no-recovery-score boundary of the feature
## Existing Persistent Entities
### 1. BackupSet
- Purpose: Tenant-owned backup collection that records capture lifecycle state and groups captured backup items.
- Existing persistent fields used by this feature:
- `id`
- `tenant_id`
- `workspace_id`
- `name`
- `status`
- `item_count`
- `metadata`
- `completed_at`
- `created_at`
- Existing relationships used by this feature:
- `tenant`
- `items`
- `restoreRuns`
#### Notes
- `completed_at` is the primary timestamp used to decide whether a completed backup basis exists and whether it is fresh enough.
- No new `backup_health` or `freshness_state` column is introduced on `backup_sets`.
### 2. BackupItem
- Purpose: Tenant-owned captured recovery input for one backed-up policy or foundation record.
- Existing persistent fields used by this feature:
- `id`
- `tenant_id`
- `backup_set_id`
- `payload`
- `assignments`
- `metadata`
- `captured_at`
- Existing relationships used by this feature:
- `tenant`
- `backupSet`
- `policyVersion`
#### Existing metadata signals used by this feature
| Key | Type | Meaning |
|---|---|---|
| `source` | string or null | Direct source marker; may indicate metadata-only capture |
| `snapshot_source` | string or null | Copied source marker from a linked policy version |
| `warnings` | array<string> | Warning messages that may imply metadata-only fallback or other quality concerns |
| `assignments_fetch_failed` | boolean | Assignment capture failed for the item |
| `assignment_capture_reason` | string or null | Explanatory capture reason; not all values are degradations |
| `has_orphaned_assignments` | boolean | One or more captured assignment targets were orphaned |
| `integrity_warning` | string or null | Existing integrity or redaction warning carried into the backup item |
### 3. BackupSchedule
- Purpose: Tenant-owned schedule configuration for automated backup execution.
- Existing persistent fields used by this feature:
- `id`
- `tenant_id`
- `workspace_id`
- `name`
- `is_enabled`
- `frequency`
- `time_of_day`
- `days_of_week`
- `policy_types`
- `last_run_status`
- `last_run_at`
- `next_run_at`
- `timezone`
- `created_at`
- Existing relationships used by this feature:
- `tenant`
- `operationRuns`
#### Notes
- `last_run_at`, `last_run_status`, and `next_run_at` are sufficient to derive `schedule_follow_up`.
- No new `schedule_health_state` or `backup_health_state` column is introduced.
## Derived Inputs
### 1. BackupHealthConfig
This is configuration, not persistence.
| Key | Type | Purpose |
|---|---|---|
| `tenantpilot.backup_health.freshness_hours` | integer | Defines the canonical age window for the latest relevant completed backup basis |
| `tenantpilot.backup_health.schedule_overdue_grace_minutes` | integer | Defines how far past `next_run_at` an enabled schedule may drift before the feature raises `schedule_follow_up` |
## Derived Models
All derived models in this section are lightweight concrete value objects in `app/Support/BackupHealth/`. The concrete file set is `TenantBackupHealthAssessment.php`, `BackupFreshnessEvaluation.php`, `BackupScheduleFollowUpEvaluation.php`, `BackupHealthActionTarget.php`, and `BackupHealthDashboardSignal.php`, with `TenantBackupHealthResolver.php` orchestrating them.
### 1. TenantBackupHealthAssessment
Tenant-level backup-health truth used by dashboard and drillthrough logic.
| Field | Type | Source | Notes |
|---|---|---|---|
| `tenantId` | integer | tenant context | Identity |
| `posture` | string | derived | One of `absent`, `stale`, `degraded`, `healthy` |
| `primaryReason` | string or null | derived | One of `no_backup_basis`, `latest_backup_stale`, `latest_backup_degraded`, or `schedule_follow_up`; `null` only when posture is healthy and no remaining follow-up reason exists |
| `headline` | string | derived | Primary operator-facing summary for dashboard surfaces |
| `supportingMessage` | string or null | derived | Secondary operator-facing explanation |
| `latestRelevantBackupSetId` | integer or null | derived | The backup set that currently governs posture |
| `latestRelevantCompletedAt` | datetime or null | derived from `BackupSet.completed_at` | Null when no usable completed basis exists |
| `qualitySummary` | `BackupQualitySummary` or null | reused `BackupQualityResolver` output | Reused quality truth for the latest relevant backup basis |
| `freshnessEvaluation` | `BackupFreshnessEvaluation` | derived | Separates recency truth from degradation truth |
| `scheduleFollowUp` | `BackupScheduleFollowUpEvaluation` | derived | Secondary caution about automation timing |
| `healthyClaimAllowed` | boolean | derived | True only when the evidence supports a positive healthy statement and no unresolved `schedule_follow_up` remains |
| `primaryActionTarget` | `BackupHealthActionTarget` | derived | Reason-driven drillthrough destination |
| `positiveClaimBoundary` | string | derived | Explains that backup health does not imply recoverability or restore success |
### 2. BackupFreshnessEvaluation
Derived recency truth for the latest relevant completed backup basis.
| Field | Type | Source | Notes |
|---|---|---|---|
| `latestCompletedAt` | datetime or null | `BackupSet.completed_at` | Null when no usable completed basis exists |
| `cutoffAt` | datetime | derived from config and `now()` | The point after which a backup basis becomes stale |
| `isFresh` | boolean | derived | False when no basis exists or when `latestCompletedAt` is older than `cutoffAt` |
| `policySource` | string | derived | Initially `configured_window` to make the canonical freshness rule explicit and testable |
#### Rules
- If there is no relevant completed backup basis, the feature does not produce `fresh`; it produces `absent`.
- If a latest relevant completed backup basis exists but predates `cutoffAt`, the primary posture becomes `stale`.
- `stale` outranks `degraded` as a primary posture when both conditions are true.
### 3. BackupScheduleFollowUpEvaluation
Derived automation follow-up truth for enabled schedules.
| Field | Type | Source | Notes |
|---|---|---|---|
| `hasEnabledSchedules` | boolean | derived from `BackupSchedule.is_enabled` | False when no enabled schedules exist |
| `enabledScheduleCount` | integer | derived | Informational count |
| `overdueScheduleCount` | integer | derived from `next_run_at` and grace window | Counts enabled schedules that appear overdue |
| `failedRecentRunCount` | integer | derived from `last_run_status` | Counts enabled schedules whose recent status indicates failure or warning |
| `neverSuccessfulCount` | integer | derived from `last_run_at` and schedule timing | Counts enabled schedules with no success evidence even though they should already have produced it |
| `needsFollowUp` | boolean | derived | True when schedule timing or status indicates operator review |
| `primaryScheduleId` | integer or null | derived | Optional single record for direct continuity if the result is unambiguous |
| `summaryMessage` | string or null | derived | Operator-facing explanation of the schedule caution |
#### Rules
- Schedule follow-up is secondary. It adds attention but does not upgrade a tenant into `healthy`.
- Enabled schedules with `next_run_at` past the grace window or with missing success evidence after the schedule should already have produced its first successful run count toward `schedule_follow_up`.
### 4. BackupHealthActionTarget
Reason-driven drillthrough target chosen by the resolver.
| Field | Type | Notes |
|---|---|---|
| `surface` | string | `backup_sets_index`, `backup_set_view`, or `backup_schedules_index` |
| `recordId` | integer or null | Used only when the target is a specific backup set |
| `label` | string | Operator-facing action label such as `Open backup sets` or `Open latest backup` |
| `reason` | string | The problem class the destination is expected to confirm |
### 5. BackupHealthDashboardSignal
Shared dashboard-facing model for KPI, attention, and healthy-check rendering.
| Field | Type | Source |
|---|---|---|
| `title` | string | derived |
| `body` | string | derived |
| `tone` | string | derived |
| `badge` | string or null | derived via shared badge primitives when the signal is rendered as an attention item |
| `badgeColor` | string or null | derived via shared badge primitives when the signal is rendered as an attention item |
| `actionTarget` | `BackupHealthActionTarget` or null | derived |
| `actionDisabled` | boolean | derived from authorization and target availability |
| `helperText` | string or null | derived |
## Validation Rules
- `absent` applies when no usable completed backup basis exists for the tenant.
- `stale` applies when the latest relevant completed backup basis exists but fails the freshness rule, regardless of whether it is also degraded.
- `degraded` applies when the latest relevant completed backup basis is fresh enough but existing `BackupQualitySummary` shows material degradation.
- `healthy` applies when a latest relevant completed backup basis exists, passes freshness, and shows no material degradation.
- `schedule_follow_up` is additive and must never be used as proof of health.
- If `schedule_follow_up` is the only remaining caution, posture may remain `healthy`, but `primaryReason` must be `schedule_follow_up` and `healthyClaimAllowed` must be `false` until the follow-up resolves.
- Existing `BackupQualitySummary` produced by `BackupQualityResolver` remains the authority for degradation families and degraded item counts.
- Dashboard summary truth must remain visible to entitled tenant-dashboard viewers even when a downstream action target is suppressed or disabled.

View File

@ -0,0 +1,273 @@
# Implementation Plan: Tenant Backup Health Signals
**Branch**: `180-tenant-backup-health` | **Date**: 2026-04-07 | **Spec**: `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/specs/180-tenant-backup-health/spec.md`
**Input**: Feature specification from `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/specs/180-tenant-backup-health/spec.md`
## Summary
Harden the tenant dashboard so operators can tell within seconds whether a tenant has no usable backup basis, a stale latest backup basis, a degraded latest backup basis, or a healthy recent backup basis without opening deep backup surfaces first. The implementation keeps `BackupSet`, `BackupItem`, `BackupSchedule`, and the existing backup-quality layer as the only sources of truth, introduces one narrow derived tenant backup-health resolver over those records, adds a config-backed freshness policy with schedule follow-up semantics, integrates the result into `DashboardKpis` and `NeedsAttention`, and preserves reason-driven drillthrough into existing backup-set and backup-schedule surfaces without adding a new persistence model or recovery-confidence framework.
Key approach: work inside the existing `TenantDashboard`, `DashboardKpis`, `NeedsAttention`, `BackupSetResource`, `BackupScheduleResource`, and `BackupQualityResolver` seams; derive tenant posture from the latest relevant completed backup set plus existing backup-quality truth and enabled-schedule timing; keep the feature Filament v5 and Livewire v4 compliant; avoid new tables, Graph calls, jobs, or asset registration; validate the result with focused Pest, Livewire, truth-alignment, and RBAC coverage.
## Technical Context
**Language/Version**: PHP 8.4, Laravel 12, Blade, Filament v5, Livewire v4
**Primary Dependencies**: Filament v5, Livewire v4, Pest v4, Laravel Sail, existing `DashboardKpis`, `NeedsAttention`, `BackupSetResource`, `BackupScheduleResource`, `BackupQualityResolver`, `BackupQualitySummary`, `ScheduleTimeService`, shared badge infrastructure, and existing RBAC helpers
**Storage**: PostgreSQL with existing tenant-owned `backup_sets`, `backup_items`, and `backup_schedules` records plus existing JSON-backed backup metadata; no schema change planned
**Testing**: Pest feature tests, Livewire widget and resource tests, and unit tests for the narrow backup-health derivation layer, all run through Sail
**Target Platform**: Laravel web application in Sail locally and containerized Linux deployment in staging and production
**Project Type**: Laravel monolith web application
**Performance Goals**: Keep tenant-dashboard rendering DB-only and query-bounded, avoid new N+1 query hotspots while deriving the latest relevant backup basis, and preserve 5 to 10 second operator scanability on tenant dashboard and drillthrough destinations
**Constraints**: No new backup-health table, no recovery-confidence score, no new Graph contract path, no new queue or `OperationRun`, no RBAC drift, no calmness leakage beyond evidence, no ad-hoc badge mappings, and no new global Filament assets
**Scale/Scope**: One tenant-scoped dashboard composition, two existing dashboard widgets, one narrow derived backup-health layer, optional config additions in `config/tenantpilot.php`, and focused regression coverage across resolver, widget, drillthrough, and RBAC behavior
## Constitution Check
*GATE: Passed before Phase 0 research. Re-checked after Phase 1 design and still passing.*
| Principle | Status | Notes |
|-----------|--------|-------|
| Inventory-first | Pass | Backups remain immutable snapshot truth; the feature only summarizes existing backup and schedule state on read |
| Read/write separation | Pass | This is a read-first dashboard hardening slice; existing backup and schedule mutations remain unchanged and separately confirmed or audited |
| Graph contract path | Pass | No new Microsoft Graph calls or contract-registry changes are introduced |
| Deterministic capabilities | Pass | Existing capability registry and tenant-scoped authorization remain authoritative; no raw capability strings are introduced |
| RBAC-UX planes and 404 vs 403 | Pass | The feature stays in the tenant/admin plane under `/admin/t/{tenant}/...`; non-members remain `404`, and existing in-scope authorization stays server-side |
| Workspace isolation | Pass | No workspace-scope broadening or cross-workspace aggregation is added |
| Tenant isolation | Pass | Backup sets, backup items, schedules, and dashboard summaries stay tenant-owned and tenant-scoped |
| Dangerous and destructive confirmations | Pass | No new destructive action is introduced. Existing backup and schedule destructive actions remain `->requiresConfirmation()` and capability-gated |
| Global search safety | Pass | No new globally searchable resource is introduced or changed. `BackupSetResource` already has a view page, `BackupScheduleResource` already has an edit page, and global search configuration remains unchanged |
| Run observability | Pass | No new long-running work or `OperationRun` usage is introduced |
| Ops-UX 3-surface feedback | Pass | No new queued action or run feedback surface is added |
| Ops-UX lifecycle ownership | Pass | `OperationRun.status` and `OperationRun.outcome` are untouched |
| Ops-UX summary counts | Pass | No new `summary_counts` keys are required |
| Data minimization | Pass | The feature reuses existing metadata and timestamps only; no new secret or payload exposure is planned |
| Proportionality (PROP-001) | Pass | Added logic is limited to one narrow tenant backup-health layer plus config-backed freshness semantics |
| Persisted truth (PERSIST-001) | Pass | No new table, column, or stored backup-health mirror is introduced |
| Behavioral state (STATE-001) | Pass | New posture and reason families are derived only because they change operator guidance and dashboard calmness behavior |
| Badge semantics (BADGE-001) | Pass | Existing badge and tag infrastructure remains the semantic source; any new backup-health tone stays inside shared UI primitives rather than local mappings |
| Filament-native UI (UI-FIL-001) | Pass | Existing Filament widgets, stats, tables, and shared primitives remain the implementation seams |
| UI naming (UI-NAMING-001) | Pass | Operator-facing vocabulary stays bounded to backup health, last backup, stale, degraded, no backups, and schedule follow-up, without `recoverable` or `proven` claims |
| Operator surfaces (OPSURF-001) | Pass | Default-visible tenant-dashboard content becomes more operator-first by exposing backup posture before deep diagnostics |
| Filament Action Surface Contract | Pass | `BackupSetResource` and `BackupScheduleResource` keep existing inspect models and destructive placement; `TenantDashboard` remains under the current dashboard exemption |
| Filament UX-001 | Pass with documented variance | No new create or edit screen is added. Existing backup-set and backup-schedule resources remain the canonical follow-up surfaces, with summary-first truth added where needed |
| Filament v5 / Livewire v4 compliance | Pass | The implementation stays inside the current Filament v5 and Livewire v4 stack |
| Provider registration location | Pass | No panel or provider changes are planned; Laravel 11+ provider registration remains in `bootstrap/providers.php` |
| Asset strategy | Pass | No new panel assets are planned; deployment keeps the existing `php artisan filament:assets` step unchanged |
## Phase 0 Research
Research outcomes are captured in `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/specs/180-tenant-backup-health/research.md`.
Key decisions:
- Derive tenant backup health from existing `BackupSet`, `BackupItem`, `BackupSchedule`, and `BackupQualityResolver` truth instead of introducing persisted backup-health state.
- Let the latest relevant completed backup set govern tenant posture rather than allowing older healthier history to calm the dashboard.
- Reuse existing backup-quality summaries for degradation truth and add no competing backup-quality taxonomy.
- Define backup freshness through one config-backed fallback window on the latest relevant completed backup set, while treating schedule timing as a secondary follow-up signal rather than health proof.
- Derive schedule follow-up from enabled schedules whose current `next_run_at` or `last_run_at` semantics indicate missed or overdue execution beyond a small grace window.
- Integrate backup health into the existing `DashboardKpis` and `NeedsAttention` widgets and keep healthy wording suppressed unless the backing evidence is fully supportive.
- Route dashboard drillthroughs by problem class: no usable backup basis opens the backup-set list, stale or degraded latest backup opens the latest relevant backup-set detail, and schedule follow-up opens the backup-schedules list.
- Extend the current widget, truth-alignment, backup-set, schedule, and tenant-scope Pest coverage instead of creating a browser-first harness.
## Phase 1 Design
Design artifacts are created under `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/specs/180-tenant-backup-health/`:
- `research.md`: implementation and domain decisions for tenant backup-health derivation
- `data-model.md`: existing entities, config inputs, and derived backup-health models
- `contracts/tenant-backup-health.openapi.yaml`: internal logical contract for dashboard summary, backup-set confirmation, and schedule follow-up surfaces
- `quickstart.md`: focused automated and manual validation workflow for tenant backup-health signals
Design decisions:
- No schema migration is required. The design adds only a narrow derived resolver layer and a small config section in `config/tenantpilot.php` for backup-health freshness semantics.
- Tenant backup health is derived at render time from the latest relevant completed backup set, existing `BackupQualitySummary`, and enabled-schedule timing. No new `Tenant` field, cache table, or materialized rollup is planned.
- Stale versus degraded precedence is deterministic: `absent` outranks everything, `stale` outranks `degraded`, `degraded` outranks `healthy`, and `schedule_follow_up` remains a secondary reason family. When the latest backup basis is fresh and non-degraded, posture may remain `healthy`, but `schedule_follow_up` becomes the active reason and suppresses any positive healthy confirmation until resolved.
- `DashboardKpis` owns the primary backup-health stat or card, while `NeedsAttention` owns reason-specific backup follow-up items and the positive healthy backup check.
- Backup-set detail remains the confirmation surface for stale and degraded latest-backup posture by combining recency and existing backup-quality summary. Backup-schedules list remains the confirmation surface for schedule-follow-up posture and must foreground one derived follow-up indicator so the missed-run or overdue reason stays scan-fast.
- The feature stays Filament v5 and Livewire v4 compliant, introduces no new panel provider, and requires no new asset registration.
## Project Structure
### Documentation (this feature)
```text
specs/180-tenant-backup-health/
├── spec.md
├── plan.md
├── research.md
├── data-model.md
├── quickstart.md
├── contracts/
│ └── tenant-backup-health.openapi.yaml
├── checklists/
│ └── requirements.md
└── tasks.md
```
### Source Code (repository root, including planned additions for this feature)
```text
app/
├── Filament/
│ ├── Pages/
│ │ └── TenantDashboard.php
│ ├── Resources/
│ │ ├── BackupScheduleResource.php
│ │ └── BackupSetResource.php
│ └── Widgets/
│ └── Dashboard/
│ ├── DashboardKpis.php
│ └── NeedsAttention.php
├── Models/
│ ├── BackupItem.php
│ ├── BackupSchedule.php
│ ├── BackupSet.php
│ └── Tenant.php
├── Support/
│ ├── BackupHealth/
│ │ ├── TenantBackupHealthAssessment.php
│ │ ├── BackupFreshnessEvaluation.php
│ │ ├── BackupScheduleFollowUpEvaluation.php
│ │ ├── BackupHealthActionTarget.php
│ │ ├── BackupHealthDashboardSignal.php
│ │ └── TenantBackupHealthResolver.php
│ ├── BackupQuality/
│ │ ├── BackupQualityResolver.php
│ │ └── BackupQualitySummary.php
│ └── Badges/
│ └── [existing shared badge seams only if new backup-health tone mapping is needed]
config/
└── tenantpilot.php
tests/
├── Feature/
│ ├── BackupScheduling/
│ │ └── BackupScheduleLifecycleTest.php
│ └── Filament/
│ ├── BackupSetListContinuityTest.php
│ ├── BackupSetEnterpriseDetailPageTest.php
│ ├── DashboardKpisWidgetTest.php
│ ├── NeedsAttentionWidgetTest.php
│ ├── TenantDashboardDbOnlyTest.php
│ ├── TenantDashboardTenantScopeTest.php
│ └── TenantDashboardTruthAlignmentTest.php
└── Unit/
└── Support/
└── BackupHealth/
└── TenantBackupHealthResolverTest.php
```
**Structure Decision**: Standard Laravel monolith. The implementation stays inside existing dashboard widgets, backup resources, shared support helpers, and current test structure. Any new helper types and lightweight dashboard-facing value objects live under `app/Support/BackupHealth/` as a narrow derived layer shared by the dashboard and drillthrough logic.
## Implementation Strategy
### Phase A — Introduce Narrow Tenant Backup-Health Derivation
**Goal**: Create one derived path that can answer absent, stale, degraded, or healthy from existing backup and schedule truth without introducing new persistence.
| Step | File | Change |
|------|------|--------|
| A.1 | New narrow helper(s) under `app/Support/BackupHealth/` | Introduce `TenantBackupHealthResolver` plus lightweight `TenantBackupHealthAssessment`, `BackupFreshnessEvaluation`, `BackupScheduleFollowUpEvaluation`, `BackupHealthActionTarget`, and `BackupHealthDashboardSignal` value objects that derive the latest relevant completed backup basis, posture, primary reason, supporting message, drillthrough target, and healthy-claim boundary with query-bounded latest-basis loading |
| A.2 | `app/Support/BackupQuality/BackupQualityResolver.php` plus the new backup-health layer | Explicitly reuse `BackupQualityResolver` and `BackupQualitySummary` output to classify material degradation instead of creating a second backup-quality system |
| A.3 | `config/tenantpilot.php` | Add a small `backup_health` config section for canonical freshness hours and schedule overdue grace so stale logic is explicit, testable, and not hard-coded in widgets |
### Phase B — Integrate Backup Health Into Primary Tenant Dashboard Surfaces
**Goal**: Make tenant backup posture visible on the dashboard before the operator has to open deep backup pages.
| Step | File | Change |
|------|------|--------|
| B.1 | `app/Filament/Widgets/Dashboard/DashboardKpis.php` | Add a backup-health stat or card that reflects the derived posture, last relevant backup timing, current reason, color tone, and one reason-driven destination |
| B.2 | `app/Filament/Widgets/Dashboard/NeedsAttention.php` | Add backup-health attention items for no usable backup basis, stale latest backup, degraded latest backup, and schedule follow-up |
| B.3 | `app/Filament/Widgets/Dashboard/NeedsAttention.php` | Add `Backups are recent and healthy` to the healthy-check set only when the derived assessment positively supports it and no backup-health attention item, including `schedule_follow_up`, remains |
### Phase C — Preserve Drillthrough Continuity On Backup And Schedule Surfaces
**Goal**: Ensure the dashboard warning or healthy claim can be rediscovered on the destination surface without guesswork.
| Step | File | Change |
|------|------|--------|
| C.1 | `app/Support/BackupHealth/TenantBackupHealthResolver.php` plus `app/Support/BackupHealth/BackupHealthActionTarget.php` | Centralize reason-driven URL selection in the existing backup-health layer so no-basis goes to backup-set index, stale or degraded latest backup goes to the relevant backup-set detail, and schedule follow-up goes to backup-schedules index |
| C.2 | `app/Filament/Resources/BackupSetResource.php` | Reuse or slightly harden the backup-set list and detail presentation so the index confirms no usable backup basis and the latest relevant backup-set detail clearly confirms stale or degraded posture on arrival |
| C.3 | `app/Filament/Resources/BackupScheduleResource.php` | Add one derived schedule-follow-up confirmation signal on the list surface so existing `last_run_at`, `last_run_status`, and `next_run_at` evidence remains scan-fast on arrival |
### Phase D — Lock Semantics With Focused Regression Coverage
**Goal**: Protect resolver truth, dashboard truth, continuity, and tenant safety from regression.
| Step | File | Change |
|------|------|--------|
| D.1 | New unit tests under `tests/Unit/Support/BackupHealth/` | Cover no-backup, stale, degraded, healthy, schedule-follow-up, and latest-history-governs derivation |
| D.2 | `tests/Feature/Filament/DashboardKpisWidgetTest.php` | Extend KPI payload and URL assertions for backup-health posture and reason-driven drillthrough |
| D.3 | `tests/Feature/Filament/NeedsAttentionWidgetTest.php` | Extend attention and healthy-check coverage for no-backup, stale-backup, degraded-latest-backup, schedule-follow-up, and healthy-backup scenarios |
| D.4 | `tests/Feature/Filament/TenantDashboardTruthAlignmentTest.php` | Ensure backup-health calmness and caution align with the rest of the tenant dashboard and do not reintroduce calmness leakage |
| D.5 | `tests/Feature/Filament/BackupSetListContinuityTest.php`, `tests/Feature/Filament/BackupSetEnterpriseDetailPageTest.php`, and `tests/Feature/BackupScheduling/BackupScheduleLifecycleTest.php` | Prove that no-basis, stale, degraded, and schedule-follow-up drillthrough destinations confirm the same problem class the dashboard named |
| D.6 | `tests/Feature/Filament/TenantDashboardTenantScopeTest.php` or a new RBAC-safe visibility test | Preserve tenant-scope truth and non-member-safe behavior for dashboard summary and backup follow-up routes |
| D.7 | `vendor/bin/sail bin pint --dirty --format agent` and focused Pest runs | Required formatting and targeted verification before implementation is considered complete |
## Key Design Decisions
### D-001 — Tenant backup health is derived, not stored
The product already stores the facts this slice needs: completed backup sets, backup-item quality metadata, and backup schedule timing. The missing piece is a tenant-level interpretation layer for overview truth, not a new persistence model.
### D-002 — The latest relevant completed backup set governs posture
Older healthy history cannot calm the dashboard if the latest relevant completed backup is stale or degraded. This keeps the overview aligned with the operator's current recovery starting point.
### D-003 — Stale and degraded remain distinct, with deterministic precedence
`absent`, `stale`, `degraded`, and `healthy` are mutually exclusive primary posture states. When the latest relevant backup is both old and degraded, `stale` becomes the primary posture while degradation remains visible as supporting detail rather than disappearing.
### D-004 — Schedule timing is follow-up truth, not health proof
An enabled schedule can support the operator's diagnosis, but it cannot prove healthy backup posture. Overdue or never-successful schedules add `schedule_follow_up`; they do not substitute for a recent healthy completed backup basis. If the backup basis is otherwise healthy, posture may stay `healthy`, but `schedule_follow_up` becomes the active reason and suppresses calm confirmation until the schedule concern clears.
### D-005 — Healthy wording is stricter than mere backup existence
`Backups are recent and healthy` is reserved for tenants whose latest relevant completed backup exists, meets the freshness window, and carries no material degradation under existing backup-quality truth. Lack of evidence must suppress calmness.
### D-006 — Existing Filament seams are sufficient
The current `DashboardKpis`, `NeedsAttention`, `BackupSetResource`, and `BackupScheduleResource` surfaces already provide the right seams. This slice does not need a new page shell, a new dashboard module, or a new front-end state layer.
### D-007 — Keep the claim boundary below recovery confidence
The feature can say that backups are absent, stale, degraded, or healthy as backup inputs. It cannot say that the tenant is recoverable, that restore will succeed, or that recovery posture is proven.
## Risk Assessment
| Risk | Impact | Likelihood | Mitigation |
|------|--------|------------|------------|
| Latest-basis selection drifts from operator expectation and lets older history calm the dashboard | High | Medium | Make latest relevant completed backup selection explicit in the resolver and cover mixed-history precedence with unit tests |
| Dashboard calmness returns because schedule presence is treated as a proxy for health | High | Medium | Keep schedule follow-up secondary in the resolver and test that schedules never make a tenant healthy on their own |
| Backup health duplicates or contradicts existing backup-quality truth | High | Medium | Reuse `BackupQualityResolver` and existing degradation families rather than adding a second backup-quality mapping |
| Schedule drillthrough lands on a surface that does not clearly confirm the warning | Medium | Medium | Use the schedule list as the primary follow-up destination and add one scan-fast confirmation signal if timestamps alone are insufficient |
| Tight stale thresholds create noise or false calmness over time | Medium | Medium | Externalize fallback freshness and schedule grace in config and pin the semantics with unit and feature tests |
## Test Strategy
- Add unit tests for the narrow backup-health resolver so latest-basis selection, stale precedence, degraded detection reuse, healthy-gate logic, and schedule-follow-up derivation remain deterministic.
- Extend `DashboardKpisWidgetTest` to assert the backup-health stat label, value, description, color, and destination across absent, stale, degraded, and healthy scenarios.
- Extend `NeedsAttentionWidgetTest` to assert backup-health attention items, healthy-check inclusion or suppression, and safe degraded-link behavior when appropriate.
- Extend `TenantDashboardTruthAlignmentTest` so backup-health calmness or caution cannot contradict the rest of the dashboard's operator truth.
- Extend backup-set and schedule surface tests so dashboard drillthroughs recover the same problem class on the target page.
- Extend tenant-scope or RBAC coverage so entitled users see truthful summary state and non-members receive deny-as-not-found semantics without cross-tenant hints.
- Keep all tests Livewire v4 compatible and run the smallest affected subset through Sail before asking for a full-suite pass.
- Run `vendor/bin/sail bin pint --dirty --format agent` before final verification.
## Complexity Tracking
No constitution violations or exception-driven complexity were identified. The only added structure is a narrow derived backup-health layer and a small derived posture or reason family already justified by the proportionality review.
## Proportionality Review
- **Current operator problem**: The tenant dashboard can look healthy while backup posture is missing, stale, or degraded, which hides a recovery-relevant truth from the operator's primary overview surface.
- **Existing structure is insufficient because**: Existing backup-quality truth lives in backup-set, item, version, and restore-adjacent surfaces, but there is no tenant-level rollup that answers the dashboard question directly.
- **Narrowest correct implementation**: Add one narrow derived tenant backup-health layer, wire it into the existing dashboard widgets, and reuse current backup and schedule destinations for continuity without creating new persistence or a broader recovery-confidence system.
- **Ownership cost created**: A small amount of resolver logic, a small config-backed freshness policy, limited widget wiring, and focused unit and feature tests.
- **Alternative intentionally rejected**: A persisted backup-health table, a workspace-wide recovery rollup, or a recovery-confidence score. Each adds broader truth and maintenance cost than the current tenant-dashboard problem requires.
- **Release truth**: Current-release truth. The feature corrects a trust gap on already-shipped tenant overview surfaces.

View File

@ -0,0 +1,123 @@
# Quickstart: Tenant Backup Health Signals
## Goal
Validate that the tenant dashboard now answers the operator's backup question within seconds, that backup-health warnings drill into confirming surfaces, and that positive backup-health wording only appears when the available evidence genuinely supports it.
## Prerequisites
1. Start Sail if it is not already running.
2. Ensure the workspace has representative tenant fixtures for:
- no usable completed backup basis
- one latest completed backup basis that is older than the backup-health freshness window
- one latest completed backup basis that is recent but degraded under existing backup-quality truth
- one latest completed backup basis that is recent and not materially degraded
- one enabled backup schedule whose `next_run_at` is overdue or whose `last_run_at` indicates missing or failed execution
- for a deterministic local/testing fixture that reproduces the established-member dashboard-visible but backup-drillthrough-`403` case, run `vendor/bin/sail artisan tenantpilot:backup-health:seed-browser-fixture --no-interaction`, then open `/admin/local/backup-health-browser-fixture-login` when the integrated browser cannot complete Microsoft SSO for the seeded local user
3. Ensure the acting users are valid workspace and tenant members.
4. Ensure at least one tenant-scoped user exists for positive summary visibility and one non-member or wrong-tenant case exists for tenant-scope isolation checks.
## Focused Automated Verification
Run the smallest existing dashboard and backup pack first:
```bash
vendor/bin/sail artisan test --compact tests/Feature/Filament/DashboardKpisWidgetTest.php
vendor/bin/sail artisan test --compact tests/Feature/Filament/NeedsAttentionWidgetTest.php
vendor/bin/sail artisan test --compact tests/Feature/Filament/TenantDashboardTruthAlignmentTest.php
vendor/bin/sail artisan test --compact tests/Feature/Filament/TenantDashboardTenantScopeTest.php
vendor/bin/sail artisan test --compact tests/Feature/Filament/TenantDashboardDbOnlyTest.php
vendor/bin/sail artisan test --compact tests/Feature/Filament/BackupSetEnterpriseDetailPageTest.php
vendor/bin/sail artisan test --compact tests/Feature/BackupScheduling/BackupScheduleLifecycleTest.php
```
Expected new or expanded spec-scoped coverage:
```bash
vendor/bin/sail artisan test --compact tests/Unit/Support/BackupHealth/TenantBackupHealthResolverTest.php
vendor/bin/sail artisan test --compact --filter=backup tests/Feature/Filament/DashboardKpisWidgetTest.php tests/Feature/Filament/NeedsAttentionWidgetTest.php tests/Feature/Filament/TenantDashboardTruthAlignmentTest.php
vendor/bin/sail artisan test --compact --filter=backup tests/Feature/Filament/BackupSetListContinuityTest.php tests/Feature/Filament/BackupSetEnterpriseDetailPageTest.php tests/Feature/BackupScheduling/BackupScheduleLifecycleTest.php
```
Use `--filter` for smaller iteration passes while implementing specific scenarios.
The tenant-scope pack must include one established-member scenario where the dashboard still renders truthful summary state with a disabled or downgraded backup-health action target while the protected downstream route returns `403`.
## Manual Validation Pass
### 1. Verify no-backup posture
Open `/admin/t/{tenant}` for a tenant without a usable completed backup basis and confirm:
- the tenant dashboard explicitly shows a backup-health warning rather than a calm omission
- `NeedsAttention` includes a no-backup item or equivalent primary backup warning
- the drillthrough opens the backup-set list, which confirms that no usable completed basis exists
### 2. Verify stale latest-backup posture
Open `/admin/t/{tenant}` for a tenant whose latest relevant completed backup basis is older than the configured freshness window and confirm:
- the dashboard does not show `Backups are recent and healthy`
- the stale reason is visible on a primary summary surface
- the drillthrough opens the latest relevant backup set and its recency confirms the stale posture immediately
- if the latest relevant backup basis is both stale and degraded, the dashboard still leads with stale posture while the destination preserves degradation as supporting detail
### 3. Verify degraded latest-backup posture
Open `/admin/t/{tenant}` for a tenant whose latest relevant completed backup basis is recent but degraded and confirm:
- the dashboard shows degraded backup posture rather than a stale or healthy fallback
- the drillthrough opens the latest relevant backup set
- the destination confirms degradation through existing backup-quality summary without implying restore safety
### 4. Verify healthy backup posture
Open `/admin/t/{tenant}` for a tenant with a recent, non-degraded latest relevant completed backup basis and confirm:
- the dashboard may show a positive backup-health check
- no backup-health attention item, including `schedule_follow_up`, is present
- the healthy wording remains bounded to backup posture and does not imply recoverability or restore success
### 5. Verify schedule-follow-up posture
Open `/admin/t/{tenant}` for a tenant with an enabled schedule that looks overdue or never-successful and confirm:
- schedule follow-up appears as a backup-health attention reason when appropriate
- a never-successful schedule triggers follow-up only when it is old enough that it should already have produced success evidence
- if the latest backup basis is otherwise fresh and non-degraded, the basis posture may remain healthy, but `schedule_follow_up` stays the active reason and no positive healthy backup check appears
- schedule presence alone does not make the dashboard healthy and suppresses any positive healthy backup check while the follow-up remains active
- the drillthrough opens the backup-schedules list and the destination makes the overdue or missed-execution condition scan-fast
### 6. Verify tenant-scope and RBAC consistency
Repeat the dashboard checks as:
- an entitled tenant member, to confirm backup-health summary truth is visible
- an entitled tenant member who can load the dashboard but cannot open one backup destination, to confirm the summary still renders, the affordance degrades safely, and the blocked downstream route fails with `403`; the deterministic local/testing fixture command above seeds exactly this case for `smoke-requester+180@tenantpilot.local`, and `/admin/local/backup-health-browser-fixture-login` starts the local/testing browser session for that user
- a non-member or wrong-tenant actor, to confirm tenant dashboard and drillthrough routes remain deny-as-not-found
## Non-Regression Checks
Confirm the feature did not change:
- current backup-set and backup-schedule route identity
- existing destructive action confirmation behavior on backup resources
- existing backup-quality semantics owned by `BackupQualityResolver`
- existing `OperationRun` behavior and operations widgets
- current global asset registration and deployment requirements
## Formatting And Final Verification
Before finalizing implementation work:
```bash
vendor/bin/sail bin pint --dirty --format agent
```
Then rerun the smallest affected test set and offer the full suite only after the focused backup-health pack passes.
Close the feature only after manual validation confirms:
- an operator can identify absent, stale, degraded, or healthy backup posture within 10 seconds on the tenant dashboard
- warning drillthroughs land on surfaces that confirm the same problem class
- positive backup-health wording appears only when the evidence truly supports it

View File

@ -0,0 +1,73 @@
# Research: Tenant Backup Health Signals
## Decision 1: Derive tenant backup health from existing backup and schedule truth instead of creating a persisted health model
- Decision: Build tenant backup health from existing `BackupSet`, `BackupItem`, `BackupSchedule`, and `BackupQualityResolver` facts at render time. Do not add a `tenant_backup_health` table, a cached rollup row, or a recovery-confidence ledger.
- Rationale: The repository already stores the facts this feature needs: completed backup timestamps, backup-quality degradations, and schedule timing. The product gap is missing tenant-level overview truth, not missing persistence.
- Alternatives considered:
- Persist a backup-health row per tenant. Rejected because it would create a second source of truth for data that is already derivable.
- Piggyback on `Tenant` model columns. Rejected because this slice does not need a new lifecycle-bearing tenant field.
## Decision 2: Let the latest relevant completed backup set govern posture
- Decision: The latest relevant completed backup set is the primary tenant backup-health basis. Older healthier backup history cannot override a newer stale or degraded latest basis.
- Rationale: The operator's first question is about the current recovery starting point, not whether an older good snapshot exists somewhere in history.
- Alternatives considered:
- Choose the healthiest recent backup in the tenant. Rejected because it would calm the dashboard while the most recent relevant backup is weak.
- Aggregate all backup history into a composite score. Rejected because the feature explicitly avoids a scoring engine.
## Decision 3: Reuse existing backup-quality truth instead of introducing a second degradation system
- Decision: Material degradation for tenant backup health is derived from existing `BackupQualitySummary` output, especially degraded item counts and existing degradation families. No new competing backup-quality taxonomy is introduced.
- Rationale: Backup-quality truth was already hardened in the dedicated backup-quality work. Re-deriving the same concepts differently at tenant level would create contradiction and extra maintenance.
- Alternatives considered:
- Add a tenant-specific degradation matrix. Rejected because it would drift from backup-set and item detail truth.
- Use only raw backup-item metadata in the dashboard resolver. Rejected because the shared quality resolver already exists and should remain authoritative.
## Decision 4: Define one config-backed freshness window for backup posture and keep schedule timing secondary
- Decision: Backup posture freshness is evaluated against the latest relevant completed backup set using one config-backed canonical freshness window in `config/tenantpilot.php`, initially aligned with the repo's existing 24-hour freshness posture for other safety-critical truth. Enabled schedule timing can add follow-up pressure but cannot replace or override that single freshness rule.
- Rationale: There is no current backup-health freshness rule in the codebase. Hard-coding a threshold inside widgets would be brittle, while a small config value keeps the semantics explicit and testable.
- Alternatives considered:
- Make freshness entirely schedule-driven. Rejected because schedule state is secondary in the spec and cannot replace actual backup existence.
- Hard-code a stale threshold inside the dashboard widget. Rejected because it would hide an important product rule in presentation code.
## Decision 5: Detect schedule follow-up from enabled schedules that look overdue or never-successful beyond a grace window
- Decision: `schedule_follow_up` is derived from enabled schedules whose `next_run_at` is overdue beyond a small grace window, whose `last_run_at` is missing after they should have started producing evidence, or whose last schedule status indicates a failed or non-successful recent run. If the latest backup basis is otherwise fresh and non-degraded, posture may remain `healthy`, but `schedule_follow_up` becomes the active reason and suppresses any positive healthy confirmation.
- Rationale: `BackupSchedule` already tracks `last_run_at`, `last_run_status`, and `next_run_at`. Those fields are enough to signal that automation needs attention without conflating it with actual backup health proof.
- Alternatives considered:
- Ignore schedule state entirely. Rejected because the spec explicitly wants schedule follow-up represented.
- Treat any enabled schedule as positive backup evidence. Rejected because schedule existence is not backup proof.
## Decision 6: Surface backup health through the existing dashboard widgets, not a new page or module
- Decision: Add backup health to the current `DashboardKpis` and `NeedsAttention` widgets and extend the existing healthy-check pattern instead of building a dedicated dashboard module or alternate tenant overview page.
- Rationale: The tenant dashboard is already the operator's primary overview surface, and the spec is explicitly a small hardening slice rather than a new product area.
- Alternatives considered:
- Build a standalone backup-health page. Rejected because it does not solve the tenant-dashboard truth gap.
- Defer backup health until a full recovery-confidence initiative. Rejected because the current dashboard already needs a narrower truth fix.
## Decision 7: Keep drillthrough reason-driven and use the least surprising existing surface for each reason
- Decision: Use reason-driven drillthroughs. `no_backup_basis` opens the backup-set list, `latest_backup_stale` and `latest_backup_degraded` open the latest relevant backup-set detail, and `schedule_follow_up` opens the backup-schedules list as the primary confirmation surface.
- Rationale: These destinations already exist and are tenant-scoped. The backup-set detail can confirm stale or degraded latest-basis truth through recency and backup-quality summary. The schedule list is safer than an edit screen as the first follow-up destination and already shows last-run and next-run timing.
- Alternatives considered:
- Send every backup-health state to the backup-set list. Rejected because it weakens reason continuity for degraded latest-backup scenarios.
- Send schedule follow-up directly to edit pages. Rejected because the first operator need is confirmation, not mutation.
## Decision 8: Add only the minimum continuity hardening needed on target surfaces
- Decision: Reuse the current backup-set detail and list surfaces for stale or degraded continuity and add one minimal schedule-follow-up confirmation signal on the backup-schedules list so the current timestamp columns remain scan-fast enough by themselves.
- Rationale: Spec 176 already moved backup quality into backup surfaces. This slice should not rebuild those surfaces again; it should only ensure the dashboard reason can be rediscovered quickly.
- Alternatives considered:
- Add a banner framework or page-level explanation system. Rejected because it would overgrow the slice.
- Leave continuity entirely to raw timestamps. Rejected because schedule follow-up may be too implicit at scan speed.
## Decision 9: Extend existing widget and dashboard truth tests before introducing new test harnesses
- Decision: Extend `DashboardKpisWidgetTest`, `NeedsAttentionWidgetTest`, `TenantDashboardTruthAlignmentTest`, and existing backup or schedule feature tests, and add one narrow unit test file for the resolver.
- Rationale: The affected behavior is server-driven widget and resource truth, which the current Pest and Livewire suite already covers well.
- Alternatives considered:
- Rely only on manual validation. Rejected because the feature is specifically about preventing subtle trust regressions.
- Add a large browser-only pack. Rejected because the highest-value assertions are deterministic server-side state and rendered truth.

View File

@ -0,0 +1,267 @@
# Feature Specification: Tenant Backup Health Signals
**Feature Branch**: `180-tenant-backup-health`
**Created**: 2026-04-07
**Status**: Proposed
**Input**: User description: "Spec 180 — Tenant Backup Health Signals"
## Spec Scope Fields *(mandatory)*
- **Scope**: tenant
- **Primary Routes**:
- `/admin/t/{tenant}` as the tenant dashboard where `DashboardKpis` and `NeedsAttention` currently establish the first tenant-level posture impression
- `/admin/t/{tenant}/backup-sets` and `/admin/t/{tenant}/backup-sets/{record}` as the primary backup truth surfaces for recentness, degradation, and latest-backup follow-up
- `/admin/t/{tenant}/backup-schedules` as the primary schedule follow-up confirmation surface when automation exists but does not appear to be running successfully, with `/admin/t/{tenant}/backup-schedules/{record}/edit` remaining a secondary maintenance surface when configuration changes are needed
- **Data Ownership**:
- Tenant-owned `BackupSet`, `BackupItem`, and existing backup-quality metadata remain the source of truth for whether a usable recent backup basis exists and whether the latest relevant backup is degraded
- Tenant-owned `BackupSchedule` remains the source of truth for schedule presence, last successful run timing, and next-run follow-up signals
- Tenant dashboard backup health remains a derived tenant summary over those existing tenant-owned records; this feature introduces no new persisted tenant-health record, score table, or confidence ledger
- **RBAC**:
- Workspace membership plus tenant entitlement remain required to view the tenant dashboard and every backup follow-up destination
- Existing backup and backup-schedule viewing permissions remain the enforcement source for drill-through destinations; this feature must not introduce raw capability checks or new role semantics
- Tenant dashboard viewers must still receive truthful tenant-level backup-health signals even when a deeper backup destination is unavailable; in that case the signal may remain visible but the affordance must degrade safely rather than becoming a dead-end link
## 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 |
|---|---|---|---|---|---|---|---|---|---|---|---|
| Tenant dashboard backup health summary | Embedded status summary / drill-in surface | One backup-health stat or card opens one matching backup follow-up destination for the strongest current reason | forbidden | none | none | `/admin/t/{tenant}/backup-sets` or `/admin/t/{tenant}/backup-schedules` depending on the reason | `/admin/t/{tenant}/backup-sets/{record}` when the latest relevant backup record is the clearest destination | Active tenant context remains visible on the dashboard and on every destination | Backup health / Backup | Whether the tenant has no usable backup basis, a stale basis, a degraded latest basis, or a healthy recent basis | Dashboard summary exemption |
| Tenant dashboard `Needs Attention` backup item | Embedded attention summary | One attention item opens one matching backup or schedule follow-up destination | forbidden | none | none | `/admin/t/{tenant}/backup-sets` or `/admin/t/{tenant}/backup-schedules` depending on the reason | `/admin/t/{tenant}/backup-sets/{record}` when the attention reason is tied to the latest relevant backup set | Active tenant context plus reason-specific copy must stay visible before navigation | Backup health / Backup | The strongest backup problem class and the next place to inspect it | Multi-destination summary item |
| Backup sets page | CRUD / list-first resource | Full-row click to the backup-set detail page | required | Existing inline safe shortcuts and More menu remain unchanged | Existing destructive actions remain grouped under existing More placement | `/admin/t/{tenant}/backup-sets` | `/admin/t/{tenant}/backup-sets/{record}` | Tenant context plus backup quality and recency context keep the list anchored to the current tenant | Backup sets / Backup set | Which backup set is the current basis and whether it is recent or degraded enough to explain dashboard posture | none |
| Backup schedules page | CRUD / list-first resource | Record confirmation happens on the existing schedule list; edit remains secondary when maintenance is needed | required | Existing inline safe shortcuts and More menu remain unchanged | Existing destructive actions remain grouped under existing More placement | `/admin/t/{tenant}/backup-schedules` | `/admin/t/{tenant}/backup-schedules/{record}/edit` | Tenant context plus schedule timing and success context keep schedule follow-up anchored to the current tenant | Backup schedules / Backup schedule | Whether configured schedules are actually running often enough to support calm automation assumptions | none |
## 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 |
|---|---|---|---|---|---|---|---|---|---|
| Tenant dashboard backup health summary | Tenant operator | Embedded status summary / drill-in surface | Are this tenant's backups recent and usable enough that I should feel calm, or do I need to follow up right now? | Backup-health class, last relevant backup timing, concise reason, and one matching next destination | Per-item degradation families, raw metadata, and restore detail remain secondary | backup existence, recency, backup quality, schedule follow-up | none | Open backup sets or backup schedules based on the current reason | none |
| Tenant dashboard `Needs Attention` backup item | Tenant operator | Embedded attention summary | Why is backup posture weak right now, and where should I click next? | One reason-focused attention item such as no backups, stale backup, degraded latest backup, or schedule needs follow-up | Deep per-item diagnostics, full history, and raw schedule metadata remain secondary | backup absence, stale posture, degraded posture, schedule follow-up | none | Open the matching follow-up surface for the named reason | none |
| Backup sets page | Tenant operator | List / detail | Which backup set is the relevant current basis, and does it confirm the dashboard warning or healthy claim? | Backup-set identity, recency, lifecycle, and quality summary that lets the operator recover the same problem class | Raw item metadata, deep assignment or payload diagnostics, and restore-specific detail remain secondary | capture lifecycle, recency, backup quality | TenantPilot-only existing backup maintenance remains unchanged and secondary to inspection | Open backup set, inspect latest relevant record | Existing archive, restore, or force-delete actions remain unchanged |
| Backup schedules page | Tenant operator | List / edit | Is backup automation configured and actually running often enough, or does it need follow-up? | Schedule timing, last-success context, and overdue or missed-run context that confirms schedule follow-up | Low-level schedule configuration detail and related run history remain secondary | schedule presence, schedule freshness, schedule execution follow-up | TenantPilot-only existing schedule maintenance remains unchanged and secondary to inspection | Open the relevant backup schedule | Existing delete or maintenance actions remain unchanged |
## Proportionality Review *(mandatory when structural complexity is introduced)*
- **New source of truth?**: No. Existing `BackupSet`, `BackupItem`, existing backup-quality summaries, and existing `BackupSchedule` timing remain authoritative.
- **New persisted entity/table/artifact?**: No. This slice explicitly forbids a new persisted backup-health table, score, or tenant-confidence ledger.
- **New abstraction?**: Yes. A narrow derived tenant-level backup-health rollup is justified so the tenant dashboard can present one truthful tenant-level answer instead of forcing operators into multiple deep surfaces.
- **New enum/state/reason family?**: Yes, but derived only. The feature needs a small tenant backup-health posture family such as `absent`, `stale`, `degraded`, and `healthy`, plus reason-level follow-up signals such as `no_backup_basis`, `latest_backup_stale`, `latest_backup_degraded`, and `schedule_follow_up`.
- **New cross-domain UI framework/taxonomy?**: No. This is a tenant backup hardening slice only, not a new portfolio posture framework or recovery taxonomy.
- **Current operator problem**: The tenant dashboard can currently look operationally healthy while the tenant's backup posture is weak, stale, degraded, or simply missing, which means the overview hides a recovery-relevant truth that operators need immediately.
- **Existing structure is insufficient because**: Backup-quality truth already exists deeper in backup-set, version, and restore-adjacent surfaces, but there is no tenant-level rollup that answers the operator's first question on the tenant dashboard.
- **Narrowest correct implementation**: Derive tenant backup health from the latest relevant completed backup basis, existing backup-quality truth, and existing backup-schedule timing, then inject that derived truth into the current tenant dashboard and attention surfaces without adding new persistence or a broader recovery-confidence system.
- **Ownership cost**: The repo takes on one small rollup layer, one small derived posture family, dashboard and drill-through copy alignment, and regression coverage for absent, stale, degraded, healthy, and schedule-follow-up scenarios.
- **Alternative intentionally rejected**: A full recovery-confidence framework, restore-proving system, workspace-level recovery score, or new persisted backup-health model was rejected because it solves a larger future problem than the one the current tenant dashboard actually has.
- **Release truth**: Current-release truth. The false calmness already exists on the current tenant dashboard.
## User Scenarios & Testing *(mandatory)*
### User Story 1 - See Backup Posture Immediately (Priority: P1)
As a tenant operator, I want the tenant dashboard to tell me within seconds whether this tenant has no usable backups, stale backups, degraded latest backups, or healthy recent backups so that I do not have to open backup-set detail just to know whether backup posture needs attention.
**Why this priority**: This is the operator's first recovery-relevant question on the tenant dashboard. If the page stays quiet while backup posture is weak, the entire overview becomes less trustworthy.
**Independent Test**: Can be fully tested by seeding one tenant with no backups, stale backups, degraded latest backups, and fresh healthy backups, then verifying that the tenant dashboard surfaces the correct posture class on a primary summary surface.
**Acceptance Scenarios**:
1. **Given** a tenant has no usable completed backup basis, **When** an entitled operator opens the tenant dashboard, **Then** the dashboard explicitly shows that backup posture needs attention because no usable backup basis exists.
2. **Given** a tenant has a latest relevant completed backup that is older than the accepted freshness window, **When** the tenant dashboard renders, **Then** the dashboard surfaces a stale-backup state instead of a calm or healthy backup message.
3. **Given** a tenant has a latest relevant completed backup that is recent but materially degraded, **When** the tenant dashboard renders, **Then** the dashboard surfaces a degraded-backup state instead of a healthy backup message.
---
### User Story 2 - Click The Warning And Recover The Same Reason (Priority: P1)
As a tenant operator, I want every backup-health KPI, card, or attention item to send me to a surface that confirms the same problem family I clicked, so that the dashboard feels truthful instead of hand-wavy.
**Why this priority**: Dashboard trust breaks immediately if the operator clicks a backup warning and lands on a neutral or mismatched target page.
**Independent Test**: Can be fully tested by clicking backup-health summary and attention states in seeded scenarios and verifying that the destination clearly confirms the same problem family through recency, degradation, or schedule timing context.
**Acceptance Scenarios**:
1. **Given** the dashboard shows a stale-backup warning, **When** the operator opens the linked destination, **Then** the destination visibly confirms that the latest relevant backup basis is stale.
2. **Given** the dashboard shows a degraded-latest-backup warning, **When** the operator opens the linked destination, **Then** the destination visibly confirms that the latest relevant backup basis carries the degradation.
3. **Given** the dashboard shows a schedule-follow-up warning, **When** the operator opens the linked destination, **Then** the destination visibly confirms that schedule execution timing needs follow-up.
---
### User Story 3 - Trust Positive Backup Calmness Only When Earned (Priority: P2)
As a tenant operator, I want the tenant dashboard to show a positive backup-health message only when the available signals actually support it, so that a green or calm backup state does not overpromise recoverability.
**Why this priority**: Positive wording is more dangerous than negative wording here. A false healthy signal can make the operator skip backup follow-up entirely.
**Independent Test**: Can be fully tested by rendering the tenant dashboard for one fresh healthy scenario and several almost-healthy scenarios, then verifying that only the fully supported case emits a positive backup-health statement.
**Acceptance Scenarios**:
1. **Given** a tenant has a recent relevant completed backup with no material degradation and no stronger backup-health caution, **When** the tenant dashboard renders, **Then** a positive backup-health confirmation may appear.
2. **Given** a tenant has a recent backup but the latest relevant backup is materially degraded, **When** the tenant dashboard renders, **Then** no positive backup-health confirmation appears.
3. **Given** a tenant has a configured schedule but no recent successful backup basis, **When** the tenant dashboard renders, **Then** schedule presence does not produce a positive backup-health confirmation.
---
### User Story 4 - Preserve Truth Under Schedule And Permission Nuance (Priority: P3)
As a tenant operator, I want backup-health summary truth to remain accurate even when schedules exist, older good backups exist, or I cannot open every downstream surface, so that tenant-level truth does not become calmer under edge conditions or RBAC boundaries.
**Why this priority**: The feature only improves trust if the tenant-level rollup stays stronger than schedule optimism, older-history optimism, or permission gaps.
**Independent Test**: Can be fully tested by seeding tenants with mixed backup history, schedule-follow-up conditions, and reduced downstream visibility, then verifying that the tenant dashboard still reflects the strongest truthful backup-health state.
**Acceptance Scenarios**:
1. **Given** an older healthy backup exists but the latest relevant completed backup is degraded, **When** the dashboard renders, **Then** the tenant is still shown as degraded rather than healthy.
2. **Given** a tenant dashboard viewer can see the dashboard but lacks one downstream destination capability, **When** the backup-health summary renders, **Then** the truth remains visible and the affordance degrades safely instead of becoming a dead-end link.
3. **Given** a configured schedule exists but has not run successfully in an expected interval, **When** the dashboard renders, **Then** the tenant may receive schedule follow-up attention without the schedule being treated as proof of healthy backup posture.
### Edge Cases
- Backup history may exist without any completed backup set that can serve as a usable tenant backup basis; the dashboard must not treat mere backup records as healthy backup posture.
- The latest relevant completed backup may be degraded even when an older backup looks healthier; the latest relevant completed backup must govern the tenant posture instead of allowing older history to calm the dashboard.
- A backup schedule may exist and even have a future `next_run_at`, while the last successful backup basis is already stale; the tenant posture must still remain stale or otherwise attention-worthy.
- Multiple schedules may exist for one tenant; schedule follow-up should remain additive and must not erase stronger absent, stale, or degraded backup-health reasons.
- Legacy or incomplete backup metadata may be insufficient to positively prove that the latest relevant backup is healthy; in that case the dashboard must avoid a positive healthy claim.
- A tenant dashboard viewer may be entitled to summary truth but not to every backup destination; summary truth must remain visible while navigation degrades safely.
## Requirements *(mandatory)*
**Constitution alignment (required):** This feature introduces no new Microsoft Graph calls, no new write workflow, and no new queued or scheduled execution path. It hardens tenant overview truth by reusing already-existing tenant-owned backup and schedule records.
**Constitution alignment (PROP-001 / ABSTR-001 / PERSIST-001 / STATE-001 / BLOAT-001):** A narrow derived tenant backup-health rollup and a small derived posture family are justified because direct backup existence, direct schedule presence, or direct backup-quality detail alone do not answer the tenant dashboard question truthfully. The slice must remain derived-first, must not persist a second backup-health truth, and must avoid a broader recovery-confidence framework.
**Constitution alignment (OPS-UX):** No new `OperationRun` type, progress surface, or execution flow is introduced. Existing backup schedules and backup runs remain the only execution-truth surfaces for actual backup activity. This slice only summarizes and links to those existing truths.
**Constitution alignment (RBAC-UX):** The feature lives in the tenant/admin plane under `/admin/t/{tenant}/...`. Non-members remain `404`. In-scope members who can see the tenant dashboard but lack one deeper destination capability must still receive truthful backup-health summary state, while the drill-through affordance degrades safely or uses an allowed fallback. Authorization for downstream destinations remains server-side and must continue to use the canonical capability registry and existing scoped record resolution. No destructive action is added.
**Constitution alignment (OPS-EX-AUTH-001):** Not applicable. No authentication-handshake behavior changes.
**Constitution alignment (BADGE-001):** Existing centralized badge and status semantics for backup lifecycle, snapshot quality, and related status-like signals remain the semantic source. This feature may add backup-health wording or grouping, but it must not introduce page-local color or badge mappings that create a second backup status language.
**Constitution alignment (UI-FIL-001):** The feature reuses existing Filament dashboard widgets, stats, cards, alerts, resource tables, and shared UI primitives. Semantic emphasis must come from aligned wording and existing shared status primitives rather than custom local markup or ad-hoc color borders.
**Constitution alignment (UI-NAMING-001):** Operator-facing vocabulary must remain explicit and bounded to `Backup health`, `Last backup`, `No backups`, `Backup stale`, `Backup degraded`, `Schedule needs follow-up`, and `Backups are recent and healthy` or closely equivalent wording. The feature must not rename this slice into `recovery confidence`, `recoverable`, `proven`, or other stronger claims.
**Constitution alignment (UI-CONST-001 / UI-SURF-001 / UI-HARD-001 / UI-EX-001 / UI-REVIEW-001):** The tenant dashboard backup-health summary is an embedded drill-in surface with one primary destination per current reason. `NeedsAttention` remains a multi-destination summary surface with one reason-focused destination per item. `BackupSetResource` remains the canonical backup inspection surface, and `BackupScheduleResource` remains the canonical schedule follow-up surface. No redundant `View` actions are added, and no destructive placement changes are introduced.
**Constitution alignment (OPSURF-001):** Default-visible content on the tenant dashboard must answer the operator's backup question within 5 to 10 seconds. Deep diagnostics such as per-item degradation causes, raw payload truth, and long schedule histories remain secondary to the default-visible backup-health class and the next destination.
**Constitution alignment (UI-SEM-001 / LAYER-001 / TEST-TRUTH-001):** Direct mapping from `backup exists` or `schedule exists` to calm dashboard wording is insufficient. This feature may introduce one narrow derived tenant backup-health interpretation layer, but only to replace weaker widget-local calmness checks and to keep dashboard truth aligned with deeper backup-quality truth. Tests must focus on the business consequences: absent, stale, degraded, healthy, schedule-follow-up, and drill-through continuity.
**Constitution alignment (Filament Action Surfaces):** The Action Surface Contract remains satisfied for `BackupSetResource` and `BackupScheduleResource`; their existing inspect models, row-click behavior, and destructive placement remain unchanged. `TenantDashboard` and its widgets remain dashboard summary surfaces covered by the existing dashboard exemption, with no new destructive action, no empty action groups, and no redundant `View` affordances. UI-FIL-001 remains satisfied and no new exception is required.
**Constitution alignment (UX-001 — Layout & Information Architecture):** This feature does not add new create or edit flows. It refines the tenant dashboard summary area and existing backup follow-up surfaces. Backup health must appear in the primary tenant-summary zone rather than only in deep diagnostics. Existing backup-set and backup-schedule list screens must retain specific empty states and current table affordances while making the dashboard reason recoverable.
### Functional Requirements
- **FR-180-001**: The system MUST derive an explicit tenant-level backup-health assessment from existing tenant-owned backup and schedule records rather than leaving backup posture implicit in deep backup pages.
- **FR-180-002**: The tenant-level backup-health assessment MUST determine whether a usable completed backup basis exists, which backup basis is currently relevant, when that basis last completed, whether it is fresh enough, whether it is materially degraded, and whether schedule timing adds follow-up pressure.
- **FR-180-003**: The latest relevant completed backup basis MUST be the primary basis for tenant backup-health posture. Older healthier backups MUST NOT override a newer relevant stale or degraded backup basis.
- **FR-180-004**: Tenant backup-health summary surfaces MUST distinguish at least `absent`, `stale`, `degraded`, and `healthy` as primary posture classes.
- **FR-180-005**: `No backup` or `no usable completed backup basis` MUST remain a first-class attention state and MUST NOT disappear into neutral empty states.
- **FR-180-006**: Backup freshness semantics MUST be defined once and used consistently across the tenant dashboard summary, `NeedsAttention`, and any positive healthy backup confirmation.
- **FR-180-007**: Tenant backup-health degradation MUST be derived from existing authoritative backup-quality truth and MUST NOT invent a competing backup-quality system.
- **FR-180-008**: Material degradation on the latest relevant completed backup basis MUST suppress healthy backup wording and MUST be sufficient to put tenant backup health into attention. If the latest relevant completed backup basis is both stale and materially degraded, `stale` MUST remain the primary posture while degradation remains visible as supporting detail.
- **FR-180-009**: A configured backup schedule alone MUST NOT count as proof of healthy backup posture.
- **FR-180-010**: Schedule state MAY add a `schedule_follow_up` reason when configured automation appears overdue or no longer successful, but it MUST complement rather than replace real backup-basis truth. When `schedule_follow_up` is the only remaining caution, the latest backup basis MAY keep `healthy` as its primary posture, but `schedule_follow_up` MUST become the active reason and any positive healthy confirmation MUST remain suppressed until the follow-up resolves.
- **FR-180-011**: The tenant dashboard MUST expose backup health on a primary summary surface such as a KPI, stat, or summary card rather than only inside backup-set detail.
- **FR-180-012**: `NeedsAttention` MUST be able to surface backup-health attention for at least no usable backup basis, stale latest backup basis, degraded latest backup basis, and schedule follow-up.
- **FR-180-013**: A positive backup-health message may appear only when the latest relevant completed backup basis exists, satisfies the defined freshness rule, shows no material degradation under existing backup-quality truth, and no stronger backup-health caution remains unresolved.
- **FR-180-014**: If existing signals cannot positively prove that the latest relevant backup basis is healthy, the system MUST avoid calm or healthy backup wording.
- **FR-180-015**: Backup-health summary surfaces MUST make the current problem class visible to the operator, not just a generic `attention needed` state.
- **FR-180-016**: Every backup-health KPI, summary, or attention item that implies follow-up MUST resolve to one semantically matching destination surface where the operator can recover the same problem class without guesswork.
- **FR-180-017**: When the matching destination is backup-basis related, the destination MUST preserve or foreground the latest relevant backup basis so that the dashboard reason remains recognizable.
- **FR-180-018**: When the matching destination is schedule related, the destination MUST foreground the schedule timing or missed-run reason rather than forcing the operator to infer it from generic schedule metadata.
- **FR-180-019**: Summary surfaces on the tenant dashboard MUST NOT appear calmer than the underlying backup-set and backup-quality detail surfaces.
- **FR-180-020**: The feature MUST NOT claim that the tenant is recoverable, that restore will succeed, or that backup posture proves recovery confidence.
- **FR-180-021**: Tenant-scoped backup-health truth MUST remain visible and accurate for entitled tenant-dashboard users even when not every deeper backup surface is accessible; navigation must degrade safely instead of hiding the truth.
- **FR-180-022**: The feature MUST ship without a new table, a persisted backup-health model, a tenant-wide recovery score, or a workspace-level recovery rollup.
- **FR-180-023**: Regression coverage MUST prove no-backup, stale-backup, degraded-latest-backup, healthy-backup, schedule-follow-up, mixed-history latest-governs behavior, dashboard surfacing, attention surfacing, healthy-check suppression and allowance, drill-through continuity, and RBAC-safe degradation.
### Derived State Semantics
- **Relevant backup basis**: The latest tenant-scoped completed backup set that the product treats as the primary current backup-health basis for the tenant. Backup records that never reached a usable completed state do not qualify as healthy proof.
- **Primary posture family**: `absent`, `stale`, `degraded`, `healthy`.
- **Reason family**: `no_backup_basis`, `latest_backup_stale`, `latest_backup_degraded`, `schedule_follow_up`. The reason family explains why the posture is not calm or why follow-up still matters, and `schedule_follow_up` may remain the active reason even when the backup basis posture itself is `healthy`.
- **Freshness policy**: One consistent freshness window over the latest relevant completed backup basis determines whether backup posture is recent enough. Schedule timing may add follow-up pressure but cannot turn a stale or missing backup basis into a healthy posture.
- **Healthy evidence rule**: `healthy` describes the state of the latest relevant completed backup basis. A positive healthy confirmation is reserved for tenants whose latest relevant completed backup basis exists, is recent enough, is not materially degraded under existing backup-quality truth, and carries no unresolved `schedule_follow_up` or other stronger backup-health caution.
## 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 |
|---|---|---|---|---|---|---|---|---|---|---|
| Tenant dashboard page composition | `app/Filament/Pages/TenantDashboard.php` | Existing tenant dashboard header actions remain unchanged | n/a | n/a | n/a | n/a | n/a | n/a | no new audit behavior | Composition-only dashboard surface. Backup health becomes a primary summary truth but the existing dashboard action-surface exemption remains in place. |
| `DashboardKpis` | `app/Filament/Widgets/Dashboard/DashboardKpis.php` | none | One explicit stat or card click for actionable backup-health states | none | none | Any non-actionable healthy reassurance remains intentionally bounded and must not overpromise recoverability | n/a | n/a | no new audit behavior | One primary destination per backup-health reason. No local recovery-confidence wording. |
| `NeedsAttention` | `app/Filament/Widgets/Dashboard/NeedsAttention.php` | none | One explicit destination or safe disabled state per backup-health attention item | none | none | Healthy fallback may include positive backup-health copy only when no covered backup-health attention state exists | n/a | n/a | no new audit behavior | Multi-destination summary widget. Backup-health item joins existing attention logic without adding destructive controls. |
| `BackupSetResource` | `app/Filament/Resources/BackupSetResource.php` | Existing `Create backup set` header action remains unchanged | `recordUrl()` clickable row to backup-set detail | Existing row actions remain unchanged | Existing grouped bulk actions remain unchanged | Existing empty-state CTA remains unchanged | Existing detail header actions remain unchanged | Existing create flow remains unchanged | existing audit behavior remains authoritative for existing mutations | Target surface may gain reason-confirming framing for last-backup, stale, or degraded continuity only. The action-surface contract remains satisfied. |
| `BackupScheduleResource` | `app/Filament/Resources/BackupScheduleResource.php` | Existing schedule-create header action remains unchanged | Existing schedule list inspect or edit affordance remains the primary open model | Existing row actions remain unchanged | Existing grouped bulk actions remain unchanged | Existing empty-state CTA remains unchanged | Existing edit header actions remain unchanged | Existing create and edit save or cancel remain unchanged | existing audit behavior remains authoritative for existing mutations | Target surface may foreground schedule follow-up timing only. Schedule presence itself must not be styled as health proof. |
### Key Entities *(include if feature involves data)*
- **Tenant backup-health assessment**: The derived tenant-level answer to whether the tenant currently has no usable backup basis, a stale basis, a degraded latest basis, or a healthy recent basis.
- **Relevant backup basis**: The latest completed backup set that counts as the current tenant backup-health basis and whose recency and quality govern the dashboard posture.
- **Backup schedule follow-up**: A tenant-level caution that configured backup automation appears overdue or no longer successfully running and therefore needs operator review.
- **Backup-health drill-through contract**: The semantic promise that a dashboard warning or healthy statement can be rediscovered on its destination surface without changing problem class.
## Success Criteria *(mandatory)*
### Measurable Outcomes
- **SC-180-001**: In seeded tenant review and acceptance coverage, an entitled operator can determine within 10 seconds whether a tenant is in a no-backup, stale-backup, degraded-latest-backup, or healthy-backup posture.
- **SC-180-002**: In 100% of covered regression scenarios, no-backup, stale-backup, degraded-latest-backup, fresh-healthy-backup, and schedule-follow-up cases map to the correct tenant-dashboard summary and attention behavior without contradictory calm wording.
- **SC-180-003**: In 100% of covered regression scenarios, positive backup-health wording appears only when the latest relevant completed backup basis is recent enough, not materially degraded, and not superseded by a stronger backup-health caution.
- **SC-180-004**: In 100% of covered drill-through tests, backup-health KPI or attention links land on a surface whose default-visible framing confirms the originating problem class.
- **SC-180-005**: In RBAC regression coverage, entitled tenant-dashboard users continue to see truthful backup-health summary state even when at least one deeper destination is unavailable, and the UI degrades safely without broken or misleading links.
- **SC-180-006**: The feature ships without a required schema migration, a new persisted backup-health model, or a new tenant-wide or workspace-wide recovery-confidence score.
## Assumptions
- Existing `BackupSet`, `BackupItem`, backup-quality summary, and `BackupSchedule` timing fields are sufficient to derive a truthful tenant backup-health rollup without introducing new persistence.
- Existing backup-set and backup-schedule surfaces remain the correct destinations for dashboard backup-health drill-through.
- Older legacy records may not always contain enough metadata to prove a positive healthy posture; in those cases the product should stay non-calm rather than infer health.
- The current tenant dashboard composition remains in place for this slice; the work changes truth visibility and continuity, not the broader dashboard layout.
## Non-Goals
- Building a full recovery-confidence or proved-recoverability framework
- Using restore history as the primary basis of tenant backup-health truth
- Introducing workspace-level or portfolio-level recovery posture rollups
- Creating a new persisted backup-health table, score, or ledger
- Reworking backup-quality detail semantics that were already hardened in prior backup-quality work
- Redesigning restore safety or restore result truth beyond the minimum truth-boundary wording needed here
## Dependencies
- Existing `TenantDashboard`, `DashboardKpis`, and `NeedsAttention` tenant overview surfaces
- Existing `BackupSet`, `BackupItem`, backup-quality summary, and related backup-quality truth work
- Existing `BackupSchedule` resource, schedule timing fields, and schedule follow-up surfaces
- Existing tenant-scoped RBAC and safe drill-through behavior for backup resources
## Risks
- If the tenant backup-health rollup is weaker than the underlying backup-quality truth, the dashboard will remain calmer than the detail surfaces.
- If schedule-follow-up is treated as health proof instead of as a secondary caution, the dashboard can still overstate calmness.
- If the latest relevant backup basis is not consistently used, older healthier history may hide a newer degraded or stale backup posture.
- If healthy wording is allowed when evidence is incomplete, the feature will recreate the same trust problem with nicer copy.
## Follow-up Spec Candidates
- Recovery confidence or proved-recoverability work after stronger restore evidence exists
- Workspace or portfolio backup-posture rollups after tenant backup health is stable and tenant-safe
- Backup and restore continuity work that combines backup-health truth with later restore-evidence truth without overclaiming recovery
## Definition of Done
Spec 180 is complete when:
- a tenant-level backup-health derivation exists over current backup and schedule truth,
- the tenant dashboard shows backup health on a primary summary surface,
- `NeedsAttention` can surface no-backup, stale-backup, degraded-latest-backup, and schedule-follow-up conditions,
- positive backup-health wording appears only when supported by current evidence,
- no-backup, stale, degraded, and healthy states are clearly distinguishable,
- drill-through destinations confirm the same backup-health problem class,
- RBAC-safe summary truth remains intact for entitled tenant-dashboard viewers,
- and the semantics are protected by focused resolver, dashboard, attention, healthy-state, drill-through, and RBAC regression tests.

View File

@ -0,0 +1,250 @@
# Tasks: Tenant Backup Health Signals
**Input**: Design documents from `/specs/180-tenant-backup-health/`
**Prerequisites**: `plan.md` (required), `spec.md` (required for user stories), `research.md`, `data-model.md`, `contracts/`, `quickstart.md`
**Tests**: Tests are REQUIRED for this feature. Use focused Pest coverage in `tests/Unit/Support/BackupHealth/TenantBackupHealthResolverTest.php`, `tests/Feature/Filament/DashboardKpisWidgetTest.php`, `tests/Feature/Filament/NeedsAttentionWidgetTest.php`, `tests/Feature/Filament/TenantDashboardTruthAlignmentTest.php`, `tests/Feature/Filament/TenantDashboardTenantScopeTest.php`, `tests/Feature/Filament/TenantDashboardDbOnlyTest.php`, `tests/Feature/Filament/BackupSetListContinuityTest.php`, `tests/Feature/Filament/BackupSetEnterpriseDetailPageTest.php`, and `tests/Feature/BackupScheduling/BackupScheduleLifecycleTest.php`.
**Operations**: No new `OperationRun`, queue, or scheduled execution flow is introduced. Work stays limited to read-time tenant dashboard truth and existing backup or schedule follow-up surfaces.
**RBAC**: Existing workspace membership, tenant entitlement, capability-registry usage, and `404` vs `403` semantics must remain unchanged across `/admin/t/{tenant}`, `/admin/t/{tenant}/backup-sets`, and `/admin/t/{tenant}/backup-schedules`. Tests must cover both positive and negative access paths plus safe degradation when an action target is unavailable.
**Operator Surfaces**: The tenant dashboard must expose backup posture in the primary summary zone, `NeedsAttention` must show reason-specific backup follow-up, and backup-set or schedule destinations must make the clicked reason recognizable without forcing the operator into raw diagnostics first.
**Filament UI Action Surfaces**: `DashboardKpis` and `NeedsAttention` remain summary-only surfaces with one primary reason-driven destination; `BackupSetResource` and `BackupScheduleResource` keep their current inspect models, row-click behavior, and destructive placement unchanged.
**Filament UI UX-001**: No new create or edit flow is introduced. The work is limited to summary-first truth on the existing dashboard and list or detail surfaces.
**Badges**: Reuse existing shared badge or tone primitives if backup-health tone mapping is needed; do not add page-local semantic mappings in widgets or resources.
**Organization**: Tasks are grouped by user story so each story can be implemented and validated as an independent increment after the shared backup-health scaffolding is in place.
## Phase 1: Setup (Shared Backup-Health Scaffolding)
**Purpose**: Add the narrow derived backup-health types and configuration used by every story.
- [X] T001 Create the shared backup-health value objects in `app/Support/BackupHealth/TenantBackupHealthAssessment.php`, `app/Support/BackupHealth/BackupFreshnessEvaluation.php`, `app/Support/BackupHealth/BackupScheduleFollowUpEvaluation.php`, `app/Support/BackupHealth/BackupHealthActionTarget.php`, and `app/Support/BackupHealth/BackupHealthDashboardSignal.php`
- [X] T002 Create the central backup-health resolver skeleton in `app/Support/BackupHealth/TenantBackupHealthResolver.php`
- [X] T003 [P] Add unit test scaffolding for the new backup-health namespace in `tests/Unit/Support/BackupHealth/TenantBackupHealthResolverTest.php`
- [X] T004 [P] Add explicit backup-health freshness and schedule grace configuration in `config/tenantpilot.php`
---
## Phase 2: Foundational (Blocking Shared Wiring)
**Purpose**: Wire the shared derivation contract before any story-specific dashboard or continuity behavior lands.
**⚠️ CRITICAL**: No user story work should begin until this phase is complete.
- [X] T005 Implement query-bounded latest-basis selection, `BackupQualityResolver`-backed degradation reuse, posture precedence, schedule-follow-up derivation, and reason target selection in `app/Support/BackupHealth/TenantBackupHealthResolver.php`
- [X] T006 [P] Extend core resolver coverage for absent, stale, degraded, healthy, stale-over-degraded precedence, mixed-history latest-governs, `BackupQualityResolver`-backed degradation reuse, and schedule-follow-up behavior in `tests/Unit/Support/BackupHealth/TenantBackupHealthResolverTest.php`
- [X] T007 Add shared backup-health loading helpers and dashboard-signal adapters for dashboard widgets in `app/Filament/Widgets/Dashboard/DashboardKpis.php` and `app/Filament/Widgets/Dashboard/NeedsAttention.php`
- [X] T008 [P] Extend DB-only and query-bounded tenant dashboard guard coverage for backup-health rendering in `tests/Feature/Filament/TenantDashboardDbOnlyTest.php`
**Checkpoint**: The repository has one authoritative derived backup-health contract that dashboard and follow-up surfaces can consume without new persistence.
---
## Phase 3: User Story 1 - See Backup Posture Immediately (Priority: P1) 🎯 MVP
**Goal**: Show absent, stale, degraded, or healthy backup posture on the tenant dashboard's primary summary surface.
**Independent Test**: Seed one tenant each for no usable backup basis, stale latest backup, degraded latest backup, and fresh healthy backup, then verify the tenant dashboard renders the correct primary backup-health posture without opening deeper pages.
### Tests for User Story 1
- [X] T009 [P] [US1] Extend primary backup-health posture coverage in `tests/Feature/Filament/DashboardKpisWidgetTest.php`
- [X] T010 [P] [US1] Extend tenant dashboard calmness-versus-caution coverage for backup-health summary states in `tests/Feature/Filament/TenantDashboardTruthAlignmentTest.php`
### Implementation for User Story 1
- [X] T011 [US1] Render the primary backup-health stat or card with posture, last relevant backup timing, and bounded summary copy in `app/Filament/Widgets/Dashboard/DashboardKpis.php`
- [X] T012 [US1] Keep summary wording aligned to `absent`, `stale`, `degraded`, and `healthy` semantics in `app/Support/BackupHealth/TenantBackupHealthAssessment.php` and `app/Filament/Widgets/Dashboard/DashboardKpis.php`
- [X] T013 [US1] Run the focused dashboard posture verification pack from `specs/180-tenant-backup-health/quickstart.md` against `tests/Unit/Support/BackupHealth/TenantBackupHealthResolverTest.php`, `tests/Feature/Filament/DashboardKpisWidgetTest.php`, `tests/Feature/Filament/TenantDashboardTruthAlignmentTest.php`, and `tests/Feature/Filament/TenantDashboardDbOnlyTest.php`
**Checkpoint**: The tenant dashboard now answers the backup-health question within seconds on a primary summary surface.
---
## Phase 4: User Story 2 - Click The Warning And Recover The Same Reason (Priority: P1)
**Goal**: Make dashboard backup warnings and attention items open destination surfaces that confirm the same problem family.
**Independent Test**: Trigger stale, degraded, no-basis, and schedule-follow-up scenarios, click the dashboard KPI or attention destination, and verify the target surface immediately confirms the same reason.
### Tests for User Story 2
- [X] T014 [P] [US2] Extend reason-driven dashboard action coverage in `tests/Feature/Filament/DashboardKpisWidgetTest.php` and `tests/Feature/Filament/NeedsAttentionWidgetTest.php`
- [X] T015 [P] [US2] Create or extend no-basis, stale, degraded, and schedule-follow-up continuity coverage in `tests/Feature/Filament/BackupSetListContinuityTest.php`, `tests/Feature/Filament/BackupSetEnterpriseDetailPageTest.php`, and `tests/Feature/BackupScheduling/BackupScheduleLifecycleTest.php`
### Implementation for User Story 2
- [X] T016 [US2] Add backup-health attention items with safe action-target rendering in `app/Filament/Widgets/Dashboard/NeedsAttention.php`
- [X] T017 [US2] Route backup-health KPI and attention destinations by reason in `app/Support/BackupHealth/TenantBackupHealthResolver.php` and `app/Support/BackupHealth/BackupHealthActionTarget.php`
- [X] T018 [US2] Make no-basis list continuity and stale or degraded latest-basis continuity scan-fast on the backup-set surfaces in `app/Filament/Resources/BackupSetResource.php`
- [X] T019 [US2] Make schedule-follow-up continuity scan-fast on the backup-schedules surface in `app/Filament/Resources/BackupScheduleResource.php`
- [X] T020 [US2] Run the focused drillthrough continuity pack from `specs/180-tenant-backup-health/quickstart.md` against `tests/Feature/Filament/DashboardKpisWidgetTest.php`, `tests/Feature/Filament/NeedsAttentionWidgetTest.php`, `tests/Feature/Filament/BackupSetListContinuityTest.php`, `tests/Feature/Filament/BackupSetEnterpriseDetailPageTest.php`, and `tests/Feature/BackupScheduling/BackupScheduleLifecycleTest.php`
**Checkpoint**: Backup-health warnings on the tenant dashboard now lead to destinations that confirm the same problem class without guesswork.
---
## Phase 5: User Story 3 - Trust Positive Backup Calmness Only When Earned (Priority: P2)
**Goal**: Allow positive backup-health wording only when the latest relevant backup basis genuinely supports it.
**Independent Test**: Render the tenant dashboard for one clearly healthy case and several almost-healthy cases, then verify that only the fully supported case emits positive backup-health wording.
### Tests for User Story 3
- [X] T021 [P] [US3] Extend healthy-check inclusion and suppression coverage in `tests/Feature/Filament/NeedsAttentionWidgetTest.php`
- [X] T022 [P] [US3] Extend healthy-claim gating coverage in `tests/Unit/Support/BackupHealth/TenantBackupHealthResolverTest.php` and `tests/Feature/Filament/TenantDashboardTruthAlignmentTest.php`
### Implementation for User Story 3
- [X] T023 [US3] Enforce healthy-claim gating and supporting-message rules in `app/Support/BackupHealth/TenantBackupHealthResolver.php` and `app/Support/BackupHealth/TenantBackupHealthAssessment.php`
- [X] T024 [US3] Render `Backups are recent and healthy` only when the assessment permits it in `app/Filament/Widgets/Dashboard/NeedsAttention.php`
- [X] T025 [US3] Keep positive dashboard copy bounded to backup posture rather than recoverability in `app/Filament/Widgets/Dashboard/DashboardKpis.php` and `app/Filament/Widgets/Dashboard/NeedsAttention.php`
- [X] T026 [US3] Run the focused healthy-wording pack from `specs/180-tenant-backup-health/quickstart.md` against `tests/Unit/Support/BackupHealth/TenantBackupHealthResolverTest.php`, `tests/Feature/Filament/NeedsAttentionWidgetTest.php`, and `tests/Feature/Filament/TenantDashboardTruthAlignmentTest.php`
**Checkpoint**: Positive backup calmness now appears only when the current evidence earns it.
---
## Phase 6: User Story 4 - Preserve Truth Under Schedule And Permission Nuance (Priority: P3)
**Goal**: Keep tenant backup-health truth strong under mixed history, schedule nuance, and restricted downstream access.
**Independent Test**: Seed mixed-history, overdue-schedule, and reduced-destination-access scenarios, then verify the dashboard still surfaces the strongest truthful backup-health state and degrades navigation safely.
### Tests for User Story 4
- [X] T027 [P] [US4] Extend tenant-scope `404` coverage plus established-member blocked-destination `403` coverage in `tests/Feature/Filament/TenantDashboardTenantScopeTest.php`
- [X] T028 [P] [US4] Extend mixed-history latest-governs and schedule-secondary coverage in `tests/Unit/Support/BackupHealth/TenantBackupHealthResolverTest.php` and `tests/Feature/Filament/TenantDashboardTruthAlignmentTest.php`
- [X] T029 [P] [US4] Extend disabled-action, member-without-capability, and schedule-follow-up nuance coverage in `tests/Feature/Filament/NeedsAttentionWidgetTest.php`
### Implementation for User Story 4
- [X] T030 [US4] Finalize schedule-follow-up secondary semantics and latest-history precedence in `app/Support/BackupHealth/TenantBackupHealthResolver.php`
- [X] T031 [US4] Degrade unavailable backup drillthroughs safely while preserving summary truth in `app/Filament/Widgets/Dashboard/DashboardKpis.php` and `app/Filament/Widgets/Dashboard/NeedsAttention.php`
- [X] T032 [US4] Keep backup follow-up routes and record resolution tenant-scoped, capability-registry-backed, and `403`-after-membership authorization-safe in `app/Filament/Resources/BackupSetResource.php` and `app/Filament/Resources/BackupScheduleResource.php`
- [X] T033 [US4] Run the focused nuance and RBAC pack from `specs/180-tenant-backup-health/quickstart.md` against `tests/Unit/Support/BackupHealth/TenantBackupHealthResolverTest.php`, `tests/Feature/Filament/NeedsAttentionWidgetTest.php`, `tests/Feature/Filament/TenantDashboardTruthAlignmentTest.php`, and `tests/Feature/Filament/TenantDashboardTenantScopeTest.php`
**Checkpoint**: Backup-health truth remains accurate even when schedules exist, history is mixed, or one downstream destination is unavailable.
---
## Phase 7: Polish & Cross-Cutting Concerns
**Purpose**: Final consistency, formatting, and focused verification across all stories.
- [X] T034 [P] Review and align operator-facing backup-health copy in `app/Support/BackupHealth/TenantBackupHealthAssessment.php`, `app/Filament/Widgets/Dashboard/DashboardKpis.php`, `app/Filament/Widgets/Dashboard/NeedsAttention.php`, `app/Filament/Resources/BackupSetResource.php`, and `app/Filament/Resources/BackupScheduleResource.php`
- [X] T035 [P] Run the focused verification pack from `specs/180-tenant-backup-health/quickstart.md` against `tests/Unit/Support/BackupHealth/TenantBackupHealthResolverTest.php`, `tests/Feature/Filament/DashboardKpisWidgetTest.php`, `tests/Feature/Filament/NeedsAttentionWidgetTest.php`, `tests/Feature/Filament/TenantDashboardTruthAlignmentTest.php`, `tests/Feature/Filament/TenantDashboardTenantScopeTest.php`, `tests/Feature/Filament/TenantDashboardDbOnlyTest.php`, `tests/Feature/Filament/BackupSetListContinuityTest.php`, `tests/Feature/Filament/BackupSetEnterpriseDetailPageTest.php`, and `tests/Feature/BackupScheduling/BackupScheduleLifecycleTest.php`
- [X] T036 Run formatting with `vendor/bin/sail bin pint --dirty --format agent` as required by `specs/180-tenant-backup-health/quickstart.md`
- [ ] T037 Run the manual validation pass in `specs/180-tenant-backup-health/quickstart.md` for no-backup, stale, stale-over-degraded, degraded, healthy, schedule-follow-up, and tenant-scope scenarios
---
## Dependencies & Execution Order
### Phase Dependencies
- **Setup (Phase 1)**: Starts immediately and establishes the new backup-health namespace and config.
- **Foundational (Phase 2)**: Depends on Setup and blocks all story work until one authoritative backup-health derivation contract exists.
- **User Story 1 (Phase 3)**: Starts after Foundational and delivers the first operator-visible backup-health truth.
- **User Story 2 (Phase 4)**: Starts after Foundational and should follow User Story 1 closely because it adds reason continuity to the summary surfaces.
- **User Story 3 (Phase 5)**: Starts after Foundational and can proceed once the primary posture contract from User Story 1 is in place.
- **User Story 4 (Phase 6)**: Starts after Foundational and should follow User Stories 2 and 3 because it hardens action degradation and final nuance on top of those surfaces.
- **Polish (Phase 7)**: Starts after the desired stories are complete.
### User Story Dependencies
- **US1**: Depends only on Setup and Foundational work.
- **US2**: Depends on Setup and Foundational work and should reuse the posture contract delivered for US1.
- **US3**: Depends on Setup and Foundational work and should reuse the posture contract delivered for US1.
- **US4**: Depends on Setup and Foundational work plus the action-target and healthy-claim behavior hardened in US2 and US3.
### Within Each User Story
- Test updates should land before the corresponding behavior change is considered complete.
- Resolver and value-object changes should land before widget or resource rendering tasks for the same story.
- Story-level focused test runs should pass before moving to the next priority slice.
### Parallel Opportunities
- `T003` and `T004` can run in parallel after the backup-health namespace from `T001` is agreed.
- `T006` and `T008` can run in parallel once `T005` defines the derivation contract.
- `T009` and `T010` can run in parallel for US1.
- `T014` and `T015` can run in parallel for US2.
- `T018` and `T019` can run in parallel for US2 once `T017` fixes the reason-target contract.
- `T021` and `T022` can run in parallel for US3.
- `T027`, `T028`, and `T029` can run in parallel for US4.
- `T034` and `T035` can run in parallel once feature code is stable.
---
## Parallel Example: User Story 1
```bash
# Story 1 tests in parallel:
Task: T009 tests/Feature/Filament/DashboardKpisWidgetTest.php
Task: T010 tests/Feature/Filament/TenantDashboardTruthAlignmentTest.php
# Story 1 implementation split after the resolver contract is stable:
Task: T011 app/Filament/Widgets/Dashboard/DashboardKpis.php
Task: T012 app/Support/BackupHealth/TenantBackupHealthAssessment.php
```
## Parallel Example: User Story 2
```bash
# Story 2 tests in parallel:
Task: T014 tests/Feature/Filament/DashboardKpisWidgetTest.php
Task: T015 tests/Feature/Filament/BackupSetEnterpriseDetailPageTest.php
# Story 2 follow-up surfaces in parallel after reason-target mapping is fixed:
Task: T018 app/Filament/Resources/BackupSetResource.php
Task: T019 app/Filament/Resources/BackupScheduleResource.php
```
## Parallel Example: User Story 3
```bash
# Story 3 tests in parallel:
Task: T021 tests/Feature/Filament/NeedsAttentionWidgetTest.php
Task: T022 tests/Unit/Support/BackupHealth/TenantBackupHealthResolverTest.php
# Story 3 implementation split after gating rules are defined:
Task: T023 app/Support/BackupHealth/TenantBackupHealthResolver.php
Task: T024 app/Filament/Widgets/Dashboard/NeedsAttention.php
```
## Parallel Example: User Story 4
```bash
# Story 4 tests in parallel:
Task: T027 tests/Feature/Filament/TenantDashboardTenantScopeTest.php
Task: T028 tests/Feature/Filament/TenantDashboardTruthAlignmentTest.php
Task: T029 tests/Feature/Filament/NeedsAttentionWidgetTest.php
# Story 4 hardening split after expectations are locked:
Task: T031 app/Filament/Widgets/Dashboard/DashboardKpis.php
Task: T032 app/Filament/Resources/BackupSetResource.php
```
---
## Implementation Strategy
### MVP First
- Complete Phase 1 and Phase 2.
- Deliver User Story 1 first so the tenant dashboard stops hiding backup posture at the summary layer.
- If the slice is intended to ship immediately after MVP, include User Story 2 before release so dashboard warnings also preserve drillthrough trust.
### Incremental Delivery
- Add User Story 2 next to make every warning recoverable on the correct destination surface.
- Add User Story 3 after that to prevent positive calm wording from overclaiming health.
- Add User Story 4 last to harden mixed-history, schedule nuance, and RBAC-safe degradation.
### Verification Finish
- Run Pint on touched files.
- Run the focused backup-health pack from `quickstart.md`.
- Run the manual quickstart validation pass for absent, stale, degraded, healthy, schedule-follow-up, and tenant-scope cases.
- Offer the broader suite only after the focused pack passes.

View File

@ -0,0 +1,36 @@
# Specification Quality Checklist: Restore Safety Integrity
**Purpose**: Validate specification completeness and quality before proceeding to planning
**Created**: 2026-04-06
**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-06.
- The spec keeps constitution-mandated surface, route, and capability references, but avoids code-structure or file-level implementation design.
- No clarification markers were required; scope, constraints, and acceptance boundaries are explicit enough to move directly into planning.

View File

@ -0,0 +1,602 @@
openapi: 3.1.0
info:
title: Restore Safety Integrity Contracts
version: 1.0.0
description: >-
Internal reference contract for the restore safety surfaces. The routes continue
to return rendered HTML through Filament and Livewire. The vendor media types below
document the structured page and mutation models that must be derivable before rendering
or execution. This is not a public API commitment.
paths:
/admin/t/{tenant}/restore-runs/create:
get:
summary: Restore run wizard page
description: >-
Returns the rendered restore wizard. The vendor media type documents the
safety-integrity page model that the wizard must expose.
parameters:
- name: tenant
in: path
required: true
schema:
type: integer
responses:
'200':
description: Rendered restore wizard page
content:
text/html:
schema:
type: string
application/vnd.tenantpilot.restore-safety-wizard+json:
schema:
$ref: '#/components/schemas/RestoreSafetyWizardPage'
'403':
description: Viewer is in scope but lacks restore execution capability
'404':
description: Tenant or restore surface is not visible because tenant membership or workspace context is missing
/admin/t/{tenant}/restore-runs:
post:
summary: Create a restore run or queue a real restore execution
description: >-
Internal logical contract for the wizard submission. The real implementation is
Filament and Livewire driven, but the same validation truth must hold.
parameters:
- name: tenant
in: path
required: true
schema:
type: integer
requestBody:
required: true
content:
application/vnd.tenantpilot.restore-run-create+json:
schema:
$ref: '#/components/schemas/CreateRestoreRunRequest'
responses:
'201':
description: Restore run created or queued successfully
content:
application/vnd.tenantpilot.restore-run-created+json:
schema:
$ref: '#/components/schemas/CreateRestoreRunResponse'
'403':
description: Viewer is in scope but lacks restore execution capability
'404':
description: Tenant or backup scope is not visible because tenant membership or workspace context is missing
'422':
description: Preview, checks, scope fingerprint, or hard-confirm validation failed
/admin/t/{tenant}/restore-runs/{restoreRun}:
get:
summary: Restore run detail and result page
description: >-
Returns the rendered restore detail page. The vendor media type documents the
result-attention and basis-truth model that must be available for rendering.
parameters:
- name: tenant
in: path
required: true
schema:
type: integer
- name: restoreRun
in: path
required: true
schema:
type: integer
responses:
'200':
description: Rendered restore detail page
content:
text/html:
schema:
type: string
application/vnd.tenantpilot.restore-run-detail+json:
schema:
$ref: '#/components/schemas/RestoreRunDetailPage'
'403':
description: Viewer is in scope but lacks required capability for a linked follow-up action
'404':
description: Restore run is not visible because tenant membership or workspace context is missing
/admin/operations/{run}:
get:
summary: Canonical operation detail for a restore-linked run
description: >-
Returns the rendered canonical operation detail page. The vendor media type documents
the restore-specific continuation truth that must remain visible when the run represents
restore execution.
parameters:
- name: run
in: path
required: true
schema:
type: integer
responses:
'200':
description: Rendered canonical operation detail page
content:
text/html:
schema:
type: string
application/vnd.tenantpilot.restore-linked-operation+json:
schema:
$ref: '#/components/schemas/RestoreLinkedOperationSurface'
'403':
description: Viewer is in scope but lacks a linked follow-up capability
'404':
description: Run is not visible because workspace or tenant entitlement is missing
components:
schemas:
RestoreSafetyWizardPage:
type: object
required:
- currentScope
- previewIntegrity
- checksIntegrity
- executionReadiness
- safetyAssessment
- primaryGuidance
properties:
currentScope:
$ref: '#/components/schemas/ScopeBasis'
previewIntegrity:
$ref: '#/components/schemas/IntegrityState'
checksIntegrity:
$ref: '#/components/schemas/IntegrityState'
executionReadiness:
$ref: '#/components/schemas/ExecutionReadiness'
safetyAssessment:
$ref: '#/components/schemas/SafetyAssessment'
primaryGuidance:
$ref: '#/components/schemas/PrimaryGuidance'
lastValidatedAt:
type:
- string
- 'null'
format: date-time
CreateRestoreRunRequest:
type: object
required:
- backupSetId
- scopeMode
- groupMapping
- isDryRun
- scopeFingerprint
properties:
backupSetId:
type: integer
scopeMode:
type: string
enum:
- all
- selected
backupItemIds:
type: array
items:
type: integer
groupMapping:
type: object
additionalProperties:
type: string
isDryRun:
type: boolean
acknowledgedImpact:
type: boolean
tenantConfirm:
type:
- string
- 'null'
scopeFingerprint:
type: string
previewEvidence:
oneOf:
- $ref: '#/components/schemas/IntegrityEvidence'
- type: 'null'
checksEvidence:
oneOf:
- $ref: '#/components/schemas/IntegrityEvidence'
- type: 'null'
CreateRestoreRunResponse:
type: object
required:
- restoreRunId
- status
- executionMode
- executionSafetySnapshot
properties:
restoreRunId:
type: integer
status:
type: string
operationRunId:
type:
- integer
- 'null'
executionMode:
type: string
enum:
- preview_only
- execute
executionSafetySnapshot:
$ref: '#/components/schemas/SafetySnapshot'
RestoreRunDetailPage:
type: object
required:
- header
- basisTruth
- resultAttention
- primaryNextAction
properties:
header:
$ref: '#/components/schemas/RestoreRunHeader'
basisTruth:
$ref: '#/components/schemas/BasisTruth'
resultAttention:
$ref: '#/components/schemas/ResultAttention'
primaryNextAction:
$ref: '#/components/schemas/PrimaryGuidance'
itemBreakdown:
type: array
items:
$ref: '#/components/schemas/ResultItem'
diagnostics:
type: array
items:
$ref: '#/components/schemas/DiagnosticBlock'
relatedOperation:
oneOf:
- $ref: '#/components/schemas/RestoreOperationLink'
- type: 'null'
RestoreLinkedOperationSurface:
type: object
required:
- operationLifecycle
- operationOutcome
- restoreContinuation
properties:
operationLifecycle:
$ref: '#/components/schemas/Fact'
operationOutcome:
$ref: '#/components/schemas/Fact'
restoreContinuation:
$ref: '#/components/schemas/RestoreOperationLink'
ScopeBasis:
type: object
required:
- backupSetId
- scopeMode
- selectedItemIds
- groupMapping
- fingerprint
properties:
backupSetId:
type: integer
scopeMode:
type: string
enum:
- all
- selected
selectedItemIds:
type: array
items:
type: integer
groupMapping:
type: object
additionalProperties:
type: string
fingerprint:
type: string
IntegrityState:
type: object
required:
- state
- rerunRequired
properties:
state:
type: string
enum:
- not_generated
- not_run
- current
- stale
- invalidated
fingerprint:
type:
- string
- 'null'
capturedAt:
type:
- string
- 'null'
format: date-time
blockingCount:
type:
- integer
- 'null'
warningCount:
type:
- integer
- 'null'
invalidationReasons:
type: array
items:
type: string
rerunRequired:
type: boolean
displaySummary:
type:
- string
- 'null'
IntegrityEvidence:
type: object
required:
- fingerprint
- capturedAt
properties:
fingerprint:
type: string
capturedAt:
type: string
format: date-time
ExecutionReadiness:
type: object
required:
- allowed
- blockingReasons
- mutationScope
properties:
allowed:
type: boolean
blockingReasons:
type: array
items:
type: string
mutationScope:
type: string
enum:
- simulation_only
- microsoft_tenant
requiredCapability:
type:
- string
- 'null'
SafetyAssessment:
type: object
required:
- state
- positiveClaimSuppressed
properties:
state:
type: string
enum:
- blocked
- risky
- ready_with_caution
- ready
positiveClaimSuppressed:
type: boolean
blockerCount:
type: integer
warningCount:
type: integer
primaryIssueCode:
type:
- string
- 'null'
primaryNextAction:
type:
- string
- 'null'
SafetySnapshot:
type: object
required:
- evaluatedAt
- scopeFingerprint
- previewState
- checksState
- safetyState
properties:
evaluatedAt:
type: string
format: date-time
scopeFingerprint:
type: string
previewState:
type: string
checksState:
type: string
safetyState:
type: string
blockingCount:
type: integer
warningCount:
type: integer
primaryIssueCode:
type:
- string
- 'null'
followUpBoundary:
type:
- string
- 'null'
RestoreRunHeader:
type: object
required:
- restoreRunId
- backupSetLabel
- status
- executionMode
properties:
restoreRunId:
type: integer
backupSetLabel:
type: string
status:
$ref: '#/components/schemas/Fact'
executionMode:
$ref: '#/components/schemas/Fact'
requestedBy:
oneOf:
- $ref: '#/components/schemas/Fact'
- type: 'null'
startedAt:
type:
- string
- 'null'
format: date-time
completedAt:
type:
- string
- 'null'
format: date-time
BasisTruth:
type: object
properties:
scopeBasis:
oneOf:
- $ref: '#/components/schemas/ScopeBasis'
- type: 'null'
previewIntegrity:
oneOf:
- $ref: '#/components/schemas/IntegrityState'
- type: 'null'
checksIntegrity:
oneOf:
- $ref: '#/components/schemas/IntegrityState'
- type: 'null'
executionSafetySnapshot:
oneOf:
- $ref: '#/components/schemas/SafetySnapshot'
- type: 'null'
ResultAttention:
type: object
required:
- state
- followUpRequired
- primaryCauseFamily
- summary
- recoveryClaimBoundary
properties:
state:
type: string
enum:
- not_executed
- completed
- partial
- failed
- completed_with_follow_up
followUpRequired:
type: boolean
primaryCauseFamily:
type: string
enum:
- execution_failure
- write_gate_or_rbac
- provider_operability
- missing_dependency_or_mapping
- payload_quality
- scope_mismatch
- item_level_failure
- none
summary:
type: string
recoveryClaimBoundary:
type: string
counts:
type: object
additionalProperties:
type: integer
PrimaryGuidance:
type: object
required:
- title
- body
- actionLabel
- actionKind
properties:
title:
type: string
body:
type: string
actionLabel:
type: string
actionKind:
type: string
enum:
- rerun_checks
- regenerate_preview
- adjust_scope
- review_warnings
- execute_preview
- execute_restore
- review_result
- open_operation
- inspect_blocker
ResultItem:
type: object
required:
- label
- status
properties:
label:
type: string
status:
type: string
causeFamily:
type:
- string
- 'null'
nextAction:
type:
- string
- 'null'
DiagnosticBlock:
type: object
required:
- title
properties:
title:
type: string
description:
type:
- string
- 'null'
collapsible:
type: boolean
collapsed:
type: boolean
RestoreOperationLink:
type: object
required:
- accessState
properties:
restoreRunId:
type:
- integer
- 'null'
resultAttention:
oneOf:
- $ref: '#/components/schemas/ResultAttention'
- type: 'null'
restoreDetailUrl:
type:
- string
- 'null'
accessState:
type: string
enum:
- linked
- unavailable
- forbidden_by_scope
unavailableReason:
type:
- string
- 'null'
Fact:
type: object
required:
- label
- value
properties:
label:
type: string
value:
type: string

View File

@ -0,0 +1,276 @@
# Data Model: Restore Safety Integrity
## Overview
This feature does not add or change a top-level persisted domain entity. It introduces a tighter derived safety model around the existing restore flow using current `RestoreRun`, `OperationRun`, risk-check, preview, and result data.
The central design task is to turn existing restore inputs and outputs into explicit operator truth without changing:
- `RestoreRun` ownership or route identity
- `OperationRun` ownership or lifecycle ownership
- existing backup, policy-version, and assignment storage
- existing write-gate, RBAC, and audit responsibilities
- the no-new-table boundary of this feature
## Existing Persistent Entities
### 1. RestoreRun
- Purpose: Tenant-owned restore record for scope selection, preview basis, checks basis, execution intent, and restore result detail.
- Existing persistent fields used by this feature:
- `id`
- `tenant_id`
- `backup_set_id`
- `operation_run_id`
- `status`
- `is_dry_run`
- `requested_items`
- `group_mapping`
- `preview`
- `results`
- `metadata`
- `requested_by`
- `started_at`
- `completed_at`
- Existing relationships used by this feature:
- `tenant`
- `backupSet`
- `operationRun`
#### Proposed nested metadata additions
No new columns are required. If persisted historical truth is needed, this feature may add the following nested structures inside `RestoreRun.metadata`:
| Key | Type | Purpose |
|---|---|---|
| `scope_basis` | object | Historical snapshot of the restore scope used for checks, preview, or execution |
| `check_basis` | object | Fingerprint and timing for the last checks considered valid enough to persist with the run |
| `preview_basis` | object | Fingerprint and timing for the last preview considered valid enough to persist with the run |
| `execution_safety_snapshot` | object | Exact safety truth captured when a real restore was queued or executed |
Minimal persisted shape:
```text
metadata
├── scope_basis
│ ├── fingerprint
│ ├── scope_mode
│ ├── selected_item_ids
│ ├── group_mapping_fingerprint
│ └── captured_at
├── check_basis
│ ├── fingerprint
│ ├── ran_at
│ ├── blocking_count
│ ├── warning_count
│ └── result_codes
├── preview_basis
│ ├── fingerprint
│ ├── generated_at
│ └── summary
└── execution_safety_snapshot
├── evaluated_at
├── scope_fingerprint
├── preview_state
├── checks_state
├── safety_state
├── blocking_count
├── warning_count
├── primary_issue_code
└── follow_up_boundary
```
Notes:
- `scope_basis`, `check_basis`, and `preview_basis` may be persisted only when needed for historical result truth. They do not require independent lifecycle behavior.
- The snapshot is intentionally narrow. It stores the safety basis used at execution time, not a tenant-wide recovery claim.
### 2. OperationRun
- Purpose: Canonical workspace-owned monitoring record for restore execution.
- Existing persistent fields used by this feature:
- `id`
- `workspace_id`
- `tenant_id`
- `type`
- `status`
- `outcome`
- `context`
- `summary_counts`
- `created_at`
- `started_at`
- `completed_at`
- Existing relationship and linkage used by this feature:
- restore execution runs already carry `context.restore_run_id` or a direct `RestoreRun.operation_run_id` link
No schema change is planned for `OperationRun`.
## Derived Models
### 1. RestoreScopeFingerprint
Deterministic representation of the current restore scope.
| Field | Type | Source | Notes |
|---|---|---|---|
| `backupSetId` | integer | `backup_set_id` | Required |
| `scopeMode` | string | `scope_mode` | `all` or `selected` |
| `selectedItemIds` | list<integer> | `backup_item_ids` or `requested_items` | Sorted, unique, empty for `all` scope |
| `groupMapping` | object | normalized `group_mapping` | Keys sorted, explicit `SKIP` retained |
| `fingerprint` | string | derived hash | Canonical equality signal |
Rules:
- The fingerprint must change whenever any execution-affecting restore input changes.
- Pure confirmation inputs like `tenant_confirm` or `acknowledged_impact` are not part of the scope fingerprint.
### 2. PreviewIntegrityState
Derived trust state for preview.
| Field | Type | Source | Notes |
|---|---|---|---|
| `state` | string | derived | `not_generated`, `current`, `stale`, `invalidated` |
| `freshnessPolicy` | string | derived | Fixed to `invalidate_after_mutation` for this feature |
| `fingerprint` | string or null | `preview_basis.fingerprint` or wizard state | Null if never generated |
| `generatedAt` | datetime or null | `preview_ran_at` or `preview_basis.generated_at` | Null if never generated |
| `invalidationReasons` | list<string> | derived | e.g. `scope_mismatch`, `mapping_changed`, `backup_set_changed` |
| `rerunRequired` | boolean | derived | True for all states except `current` |
| `displaySummary` | string | derived | Operator-facing explanation |
### 3. ChecksIntegrityState
Derived trust state for restore checks.
| Field | Type | Source | Notes |
|---|---|---|---|
| `state` | string | derived | `not_run`, `current`, `stale`, `invalidated` |
| `freshnessPolicy` | string | derived | Fixed to `invalidate_after_mutation` for this feature |
| `fingerprint` | string or null | `check_basis.fingerprint` or wizard state | Null if never run |
| `ranAt` | datetime or null | `checks_ran_at` or `check_basis.ran_at` | Null if never run |
| `blockingCount` | integer | `check_summary.blocking` | Preserved even if the state becomes invalid |
| `warningCount` | integer | `check_summary.warning` | Preserved even if the state becomes invalid |
| `invalidationReasons` | list<string> | derived | Same family as preview invalidation |
| `rerunRequired` | boolean | derived | True for all states except `current` |
### 4. ExecutionReadinessState
Technical ability to start restore execution.
| Field | Type | Source | Notes |
|---|---|---|---|
| `allowed` | boolean | derived from RBAC, write-gate, provider operability, hard blockers | Answers “can the system start?” |
| `blockingReasons` | list<string> | derived | `missing_capability`, `write_gate_blocked`, `provider_unavailable`, `risk_blocker` |
| `mutationScope` | string | derived | `simulation_only` or `microsoft_tenant` |
| `requiredCapability` | string | derived | existing registry entry, not a raw string literal in feature code |
### 5. RestoreSafetyAssessment
Decision-layer state that separates executable from safe.
| Field | Type | Source | Notes |
|---|---|---|---|
| `state` | string | derived | `blocked`, `risky`, `ready_with_caution`, `ready` |
| `executionReadiness` | object | `ExecutionReadinessState` | Technical startability |
| `previewIntegrity` | object | `PreviewIntegrityState` | Decision basis currentness |
| `checksIntegrity` | object | `ChecksIntegrityState` | Decision basis currentness |
| `positiveClaimSuppressed` | boolean | derived | True when warnings or integrity issues suppress calm claims |
| `primaryIssueCode` | string or null | derived | Most important blocker or warning reason |
| `primaryNextAction` | string | derived | e.g. `rerun_checks`, `regenerate_preview`, `adjust_scope`, `review_warnings` |
Derived-state rules:
- `blocked`: execution readiness is false, or risk blockers are present.
- `risky`: execution may be technically possible, but preview or checks are not current enough to support calm execution, or another integrity problem suppresses approval.
- `ready_with_caution`: current preview and current checks exist, blockers are absent, but warnings remain suppressive.
- `ready`: current preview and current checks exist, blockers are absent, warnings are absent or non-suppressive, and the operator can receive a calm execution signal.
### 6. RestoreExecutionSafetySnapshot
Historical snapshot stored on the existing restore run when a real restore is queued.
| Field | Type | Source | Notes |
|---|---|---|---|
| `evaluatedAt` | datetime | confirmation time | Historical anchor |
| `scopeFingerprint` | string | `RestoreScopeFingerprint` | Basis used to queue execution |
| `previewState` | string | `PreviewIntegrityState.state` | Historical truth at queue time |
| `checksState` | string | `ChecksIntegrityState.state` | Historical truth at queue time |
| `safetyState` | string | `RestoreSafetyAssessment.state` | Historical decision truth |
| `blockingCount` | integer | checks summary | Historical fact |
| `warningCount` | integer | checks summary | Historical fact |
| `primaryIssueCode` | string or null | `RestoreSafetyAssessment.primaryIssueCode` | Audit-friendly summary |
| `followUpBoundary` | string | derived | e.g. `run_completed_not_recovery_proven` |
### 7. RestoreResultAttention
Derived result-follow-up truth for restore detail and linked monitoring surfaces.
| Field | Type | Source | Notes |
|---|---|---|---|
| `state` | string | derived | `not_executed`, `completed`, `partial`, `failed`, `completed_with_follow_up` |
| `followUpRequired` | boolean | derived | Primary operator signal |
| `primaryCauseFamily` | string | derived | `execution_failure`, `write_gate_or_rbac`, `provider_operability`, `missing_dependency_or_mapping`, `payload_quality`, `scope_mismatch`, `item_level_failure`, `none` |
| `summary` | string | derived | Short operator-facing summary |
| `primaryNextAction` | string | derived | One leading next step |
| `recoveryClaimBoundary` | string | derived | Explicitly states what the surface is not proving |
Decision rules:
- `partial`: mixed item outcomes or mixed assignment outcomes remain after execution.
- `completed_with_follow_up`: execution reached a terminal completed path, but unresolved warnings, skipped items, or open recovery work remain.
- `completed`: execution finished and no derived follow-up remains visible at the restore-run truth level, without implying tenant recovery.
### 8. RestoreWizardPageModel
Server-driven page model for the wizard.
| Field | Type | Purpose |
|---|---|---|
| `currentScope` | `RestoreScopeFingerprint` | Shows what the operator is about to restore |
| `previewIntegrity` | `PreviewIntegrityState` | Shows whether preview still applies |
| `checksIntegrity` | `ChecksIntegrityState` | Shows whether checks still apply |
| `executionReadiness` | `ExecutionReadinessState` | Shows whether the system can technically start |
| `safetyAssessment` | `RestoreSafetyAssessment` | Shows whether the action is safe enough to claim calm readiness |
| `primaryGuidance` | object | One primary next step and supporting explanation |
### 9. RestoreRunDetailPageModel
Page model for the restore-run detail and result surface.
| Field | Type | Purpose |
|---|---|---|
| `header` | object | identity, backup set, mode, requested by, timestamps |
| `basisTruth` | object | preview basis, checks basis, execution safety snapshot |
| `resultAttention` | `RestoreResultAttention` | overall result truth and next step |
| `itemBreakdown` | list<object> | per-item and assignment outcomes |
| `diagnostics` | list<object> | raw preview, raw results, provider details, mapping detail |
### 10. RestoreOperationContinuationModel
Minimal restore-specific truth exposed on the canonical operation detail.
| Field | Type | Purpose |
|---|---|---|
| `restoreRunId` | integer | linked restore record |
| `resultAttention` | `RestoreResultAttention` | restore follow-up truth summary |
| `restoreDetailUrl` | string or null | safe deep link when entitled |
| `accessState` | string | `linked`, `unavailable`, `forbidden_by_scope` |
| `unavailableReason` | string or null | truthful degradation without broken links |
## Validation Rules
- Preview is `current` only when a preview basis exists, its fingerprint matches the current scope fingerprint, a parseable generated timestamp exists, and no covered mutation has invalidated the basis.
- Checks are `current` only when a check basis exists, its fingerprint matches the current scope fingerprint, a parseable checks timestamp exists, and no covered mutation has invalidated the basis.
- A fingerprint mismatch must classify preview or checks as `invalidated`, not merely `stale`.
- Preview or checks classify as `stale` when evidence exists but required basis markers are incomplete, legacy, or otherwise insufficient to prove currentness on a persisted draft or run, even though an explicit fingerprint mismatch is not available.
- This feature uses freshness policy `invalidate_after_mutation`; it does not add a separate age-based timeout for preview or checks inside the active wizard draft.
- `ready` requires `ExecutionReadinessState.allowed = true`, `PreviewIntegrityState.state = current`, `ChecksIntegrityState.state = current`, and no suppressive warnings or blockers.
- `ready_with_caution` requires current integrity and zero blockers, but at least one suppressive warning remains.
- `risky` remains possible when execution readiness is true but calm approval is suppressed by integrity or warning truth.
- `completed` on the result surface must never imply tenant recovery unless another feature later supplies external reconciliation proof.
## State Notes
- `RestoreRunStatus` remains the persisted execution lifecycle enum. This feature does not replace it.
- Preview integrity, checks integrity, restore safety, and result attention are derived state families. They are not new top-level persisted enums.
- The only persisted addition this design allows is a narrow snapshot of the safety basis used for an actual restore run.

View File

@ -0,0 +1,287 @@
# Implementation Plan: Restore Safety Integrity
**Branch**: `181-restore-safety-integrity` | **Date**: 2026-04-06 | **Spec**: `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/specs/181-restore-safety-integrity/spec.md`
**Input**: Feature specification from `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/specs/181-restore-safety-integrity/spec.md`
## Summary
Harden the restore flow so operators can distinguish stale versus current preview truth, stale versus current checks truth, technical startability versus safety readiness, and run completion versus real follow-up truth without adding a new recovery persistence model. The implementation keeps `RestoreRun` and `OperationRun` as the existing sources of truth, introduces a narrow derived restore-safety layer for scope fingerprinting and integrity assessment, persists only a compact execution-time safety snapshot inside existing `RestoreRun.metadata` when needed, hardens the wizard and detail surfaces, and preserves restore-specific truth on the canonical operation detail page.
Key approach: work inside the existing `RestoreRunResource`, `CreateRestoreRun`, restore form component views, restore infolist entry views, and restore-linked `OperationRunResource` seams; add derived restore safety helpers under the existing application structure; keep all changes Filament v5 and Livewire v4 compliant; avoid new tables and new Graph contract paths; validate the result with focused Pest, Livewire, hardening, ops-UX, and RBAC coverage.
## Technical Context
**Language/Version**: PHP 8.4, Laravel 12, Blade, Filament v5, Livewire v4
**Primary Dependencies**: Filament v5, Livewire v4, Pest v4, Laravel Sail, existing `RestoreRunResource`, `RestoreService`, `RestoreRiskChecker`, `RestoreDiffGenerator`, `OperationRunResource`, `TenantlessOperationRunViewer`, shared badge infrastructure, and existing RBAC or write-gate helpers
**Storage**: PostgreSQL with existing `restore_runs` and `operation_runs` records plus JSON or array-backed `metadata`, `preview`, `results`, and `context`; no schema change planned
**Testing**: Pest feature tests, Livewire page and action tests, unit tests for narrow derived restore-safety helpers, all run through Sail
**Target Platform**: Laravel web application in Sail locally and containerized Linux deployment in staging and production
**Project Type**: Laravel monolith web application
**Performance Goals**: Keep restore wizard and detail surfaces server-driven, avoid new render-time external calls, preserve quick operator scanability on confirm and result surfaces, and keep canonical operation detail DB-only at render time
**Constraints**: No new central recovery-state table, no new Graph contract path, no route identity change, no RBAC drift, no collapse of executable versus safe versus recovered semantics, no ad-hoc badge mappings, and no new global Filament assets
**Scale/Scope**: One tenant-scoped restore wizard, one tenant restore detail surface, one restore-linked canonical operation detail surface, a narrow derived restore-safety layer, and focused regression coverage across wizard, result, RBAC, and ops-UX behavior
## Constitution Check
*GATE: Passed before Phase 0 research. Re-checked after Phase 1 design and still passing.*
| Principle | Status | Notes |
|-----------|--------|-------|
| Inventory-first | Pass | Backups remain immutable snapshots and no inventory ownership rule changes |
| Read/write separation | Pass | Real restore execution stays behind preview, checks, hard confirmation, audit, and tests |
| Graph contract path | Pass | No new Graph endpoints or contract registry changes; existing restore calls stay behind current restore services and `GraphClientInterface` |
| Deterministic capabilities | Pass | Existing capability registry and `UiEnforcement` or capability resolver remain authoritative |
| RBAC-UX planes and 404 vs 403 | Pass | Tenant restore surfaces remain tenant-scoped; canonical `/admin/operations/{run}` remains workspace-safe and tenant-safe |
| Workspace isolation | Pass | No workspace scope broadening; canonical monitoring remains workspace-member gated |
| Tenant isolation | Pass | Restore runs, restore previews, checks, and result detail stay tenant-owned and tenant-entitled |
| Dangerous and destructive confirmations | Pass | Existing archive, restore, rerun, and force-delete actions remain confirmation-gated; real execution remains hard-confirmed in the wizard |
| Global search safety | Pass | `OperationRunResource` already remains non-globally-searchable; this feature adds no new globally searchable resource. `RestoreRunResource` is not made newly searchable, and it already has a view page if search is later enabled |
| Run observability | Pass | Existing `restore.execute` operations continue to create or reuse `OperationRun`; no new run model is introduced |
| Ops-UX 3-surface feedback | Pass | Existing queued toast, progress surfaces, and terminal monitoring behavior remain authoritative |
| Ops-UX lifecycle ownership | Pass | `OperationRun.status` and `OperationRun.outcome` remain service-owned; this feature only adds restore-specific read truth |
| Ops-UX summary counts | Pass | No new `OperationRun` summary-count keys are required; restore-specific integrity stays on restore context |
| Data minimization | Pass | No new secrets or external payload exposure; detail diagnostics remain secondary |
| Proportionality (PROP-001) | Pass | New logic is limited to derived restore-safety helpers and optional nested metadata snapshotting on existing records |
| Persisted truth (PERSIST-001) | Pass | No new table; only a narrow execution-time safety snapshot may be stored on the existing restore run |
| Behavioral state (STATE-001) | Pass | New integrity, safety, and follow-up states directly change operator guidance and execution gating semantics |
| Badge semantics (BADGE-001) | Pass | Any new restore safety badges or chips must route through central badge or shared primitive semantics, not page-local mapping |
| Filament-native UI (UI-FIL-001) | Pass | Existing Filament wizard, sections, view fields, infolist entries, and shared primitives remain the primary UI seams |
| UI naming (UI-NAMING-001) | Pass | The plan preserves `preview`, `checks`, `dry-run`, `restore`, `partial`, and `follow-up` as operator vocabulary |
| Operator surfaces (OPSURF-001) | Pass | Wizard and result surfaces become more operator-first, not more diagnostic-first |
| Filament Action Surface Contract | Pass | No redundant view actions or empty action groups are introduced; list inspect model remains row click |
| Filament UX-001 | Pass with documented variance | The wizard remains structured and the detail page remains infolist-based with custom entry views, but still follows summary-first information architecture |
| Filament v5 / Livewire v4 compliance | Pass | The implementation stays inside the current Filament v5 and Livewire v4 stack |
| Provider registration location | Pass | No panel or provider changes; Laravel 11+ provider registration remains in `bootstrap/providers.php` |
| Asset strategy | Pass | No new panel assets are planned; deployment keeps the existing `php artisan filament:assets` step unchanged |
## Phase 0 Research
Research outcomes are captured in `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/specs/181-restore-safety-integrity/research.md`.
Key decisions:
- Derive a deterministic restore scope fingerprint from existing wizard inputs instead of introducing a new persisted scope entity.
- Separate preview and checks integrity from blocker and warning severity so `no blockers` can no longer be misread as `safe`.
- Preserve invalidation evidence in wizard state instead of silently clearing prior preview and checks truth.
- Persist only a narrow execution-time safety snapshot inside `RestoreRun.metadata` when historical truth is required for restore detail.
- Derive result follow-up truth from existing results, assignment outcomes, and linked `OperationRun` outcome without adding a recovery entity.
- Preserve restore-specific follow-up truth on canonical operation detail via enrichment or a safe deep link rather than an `OperationRun` schema change.
- Reuse Filament wizard, action, and infolist seams plus existing Pest and Livewire test patterns instead of introducing a new UI shell or browser-first harness.
## Phase 1 Design
Design artifacts are created under `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/specs/181-restore-safety-integrity/`:
- `data-model.md`: existing entities, narrow metadata additions, and derived restore safety models
- `contracts/restore-safety-integrity.openapi.yaml`: internal logical contract for the wizard, create submission, restore detail, and restore-linked canonical operation detail
- `quickstart.md`: focused automated and manual validation workflow for restore safety hardening
Design decisions:
- No schema migration is required; the design reuses `RestoreRun`, `OperationRun`, and existing JSON-backed fields.
- Historical execution truth may be captured inside existing `RestoreRun.metadata` as a narrow safety snapshot rather than as a new entity.
- Wizard hardening remains inside `RestoreRunResource::getWizardSteps()` and `CreateRestoreRun`, with restore form component views displaying integrity state and guidance.
- Result hardening remains inside existing restore detail infolist entry views and the restore-linked canonical operation detail seams.
- Test coverage stays focused on restore wizard, restore detail, linked operation detail, hardening, ops-UX, and RBAC behavior.
## Project Structure
### Documentation (this feature)
```text
specs/181-restore-safety-integrity/
├── spec.md
├── plan.md
├── research.md
├── data-model.md
├── quickstart.md
├── contracts/
│ └── restore-safety-integrity.openapi.yaml
├── checklists/
│ └── requirements.md
└── tasks.md
```
### Source Code (repository root)
```text
app/
├── Filament/
│ ├── Pages/
│ │ └── Operations/
│ │ └── TenantlessOperationRunViewer.php
│ └── Resources/
│ ├── OperationRunResource.php
│ └── RestoreRunResource.php
│ └── Pages/
│ ├── CreateRestoreRun.php
│ ├── ListRestoreRuns.php
│ └── ViewRestoreRun.php
├── Models/
│ └── RestoreRun.php
├── Services/
│ └── Intune/
│ ├── RestoreDiffGenerator.php
│ ├── RestoreRiskChecker.php
│ └── RestoreService.php
├── Support/
│ ├── Badges/
│ │ └── Domains/
│ │ ├── RestoreCheckSeverityBadge.php
│ │ ├── RestorePreviewDecisionBadge.php
│ │ ├── RestoreResultStatusBadge.php
│ │ └── RestoreRunStatusBadge.php
│ ├── OpsUx/
│ │ └── OperationUxPresenter.php
│ └── RestoreRunStatus.php
resources/
└── views/
└── filament/
├── forms/
│ └── components/
│ ├── restore-run-checks.blade.php
│ └── restore-run-preview.blade.php
└── infolists/
└── entries/
├── restore-preview.blade.php
└── restore-results.blade.php
tests/
├── Feature/
│ ├── Filament/
│ │ ├── RestorePreviewTest.php
│ │ ├── RestoreRunUiEnforcementTest.php
│ │ └── [new or expanded restore safety integrity page tests]
│ ├── OpsUx/
│ │ └── RestoreExecutionOperationRunSyncTest.php
│ ├── Operations/
│ │ └── [new or expanded restore-linked operation detail tests]
│ ├── Hardening/
│ │ └── [existing restore start gate tests]
│ ├── RestoreRiskChecksWizardTest.php
│ └── RestoreRunWizardExecuteTest.php
└── Unit/
└── [new narrow restore safety resolver tests under app/Support]
```
**Structure Decision**: Standard Laravel monolith. The implementation stays inside existing Filament resources, Blade views, restore services, and monitoring seams. Any new helper types stay under existing `app/Support` or another already-established application namespace. No new base folders or standalone subsystems are required.
## Implementation Strategy
### Phase A — Introduce Scope Fingerprinting And Derived Integrity State
**Goal**: Create the smallest possible restore-safety layer that can explain whether preview and checks still apply to the current scope.
| Step | File | Change |
|------|------|--------|
| A.1 | `app/Support/RestoreRunStatus.php` plus a new narrow restore safety helper namespace under `app/Support/` | Introduce derived scope fingerprint and integrity assessment helpers without changing persisted `RestoreRunStatus`, and make `invalidate_after_mutation` the explicit freshness policy for wizard-scoped evidence |
| A.2 | `app/Models/RestoreRun.php` | Add narrow metadata accessors or helpers for `scope_basis`, `check_basis`, `preview_basis`, and `execution_safety_snapshot` |
| A.3 | `app/Support/Badges/Domains/` and any shared primitive seam needed | Add central state-to-badge or label mappings only if the new integrity or safety states are surfaced as badges |
### Phase B — Harden Wizard Invalidation And Confirmation
**Goal**: Turn the existing wizard into an explicit restore safety gate instead of a sequence that silently forgets prior evaluation work.
| Step | File | Change |
|------|------|--------|
| B.1 | `app/Filament/Resources/RestoreRunResource.php` | Extend `getWizardSteps()` to compute and compare scope fingerprints, preserve invalidation evidence, and separate execution readiness from safety readiness |
| B.2 | `app/Filament/Resources/RestoreRunResource/Pages/CreateRestoreRun.php` | Ensure the final create flow validates current preview, current checks, matching fingerprint, and hard-confirm state before a real restore queues |
| B.3 | `resources/views/filament/forms/components/restore-run-checks.blade.php` | Surface `current`, `stale`, `invalidated`, or `not_run` states with one primary next step |
| B.4 | `resources/views/filament/forms/components/restore-run-preview.blade.php` | Surface preview integrity state, generated-at truth, and rerun guidance without calm false positives |
| B.5 | `app/Filament/Resources/RestoreRunResource.php` or `app/Services/Intune/RestoreService.php` | Persist a narrow `execution_safety_snapshot` inside existing `RestoreRun.metadata` when a real restore is queued |
### Phase C — Harden Restore Result And Detail Truth
**Goal**: Ensure restore detail answers follow-up truth and next action before raw result lists.
| Step | File | Change |
|------|------|--------|
| C.1 | `app/Filament/Resources/RestoreRunResource.php` | Build a result-attention model from existing `results`, assignment outcomes, and linked run context |
| C.2 | `resources/views/filament/infolists/entries/restore-preview.blade.php` | Show which preview basis applied and whether it was current, stale, or invalidated |
| C.3 | `resources/views/filament/infolists/entries/restore-results.blade.php` | Elevate overall result truth, follow-up truth, primary cause family, and one primary next action above raw item detail |
### Phase D — Preserve Restore Truth On Canonical Operation Detail
**Goal**: Keep restore-specific follow-up truth visible in canonical monitoring without duplicating restore persistence.
| Step | File | Change |
|------|------|--------|
| D.1 | `app/Filament/Resources/OperationRunResource.php` | Add restore-linked continuation truth for `restore.execute` runs using existing restore linkage and tenant-safe deep-link behavior |
| D.2 | `app/Filament/Pages/Operations/TenantlessOperationRunViewer.php` | Preserve restore-specific guidance or safe restore-detail links without broken navigation when deeper access is unavailable |
### Phase E — Regression Protection And Focused Verification
**Goal**: Lock the new safety semantics into automated tests and protect existing restore orchestration behavior.
| Step | File | Change |
|------|------|--------|
| E.1 | `tests/Feature/RestoreRunWizardExecuteTest.php` | Extend confirmation coverage to include fingerprint and integrity-state validation |
| E.2 | `tests/Feature/RestoreRiskChecksWizardTest.php` | Extend checks-state persistence and invalidation coverage |
| E.3 | `tests/Feature/Filament/RestorePreviewTest.php` and new restore safety detail tests | Cover preview integrity, stale versus invalidated display, and calmness suppression |
| E.4 | `tests/Feature/Filament/RestoreRunUiEnforcementTest.php` | Preserve 404 versus 403 behavior and disabled-action truth under reduced capability |
| E.5 | `tests/Feature/OpsUx/RestoreExecutionOperationRunSyncTest.php` and new restore-linked operation detail tests | Preserve `OperationRun` continuity and restore-specific follow-up visibility from canonical monitoring |
| E.6 | New unit tests under `tests/Unit/Support/` | Cover scope fingerprint generation, integrity classification, safety assessment, and result attention derivation |
| E.7 | `vendor/bin/sail bin pint --dirty --format agent` and focused Pest runs | Required formatting and targeted verification before implementation is considered complete |
## Key Design Decisions
### D-001 — Scope mismatch must be explicit, not inferred from missing data
The current wizard safety behavior already clears preview and checks when some scope inputs change. This plan formalizes that behavior as explicit invalidation truth so the operator can see that prior work existed and was invalidated by a specific change.
### D-002 — Execution-time safety truth belongs to the restore run, not a new recovery entity
The operator needs historical truth about what basis was used when a real restore was queued. That justifies a narrow metadata snapshot on the existing `RestoreRun` but does not justify a second persisted model.
### D-003 — Result meaning must be derived from existing restore outputs, not from `RestoreRun.status` alone
`completed`, `partial`, and `failed` remain important lifecycle statuses, but the operator-facing follow-up truth comes from the combination of lifecycle, item results, assignment outcomes, and linked operation context.
### D-004 — Canonical operation detail must acknowledge restore-specific follow-up without becoming the restore source of truth
`OperationRun` stays the monitoring record. `RestoreRun` stays the restore truth. The canonical operation surface should expose restore continuation meaning or link to it, not clone restore persistence.
### D-005 — Filament-native seams are sufficient for this hardening slice
Filament wizard steps, view fields, custom infolist views, confirmation patterns, and Livewire action tests already fit the feature. The plan therefore avoids a parallel UI framework or custom client-side state layer.
### D-006 — Restore evidence freshness is mutation-sensitive, not age-window-driven
This slice uses the repo's existing `invalidate_after_mutation` freshness language for wizard-scoped derived state. Matching fingerprint plus valid capture markers is enough for `current` inside the active draft. `invalidated` represents explicit scope drift after a covered mutation, while `stale` is reserved for legacy or incomplete persisted evidence that cannot prove currentness.
## Risk Assessment
| Risk | Impact | Likelihood | Mitigation |
|------|--------|------------|------------|
| Scope fingerprint is too narrow and misses a real execution-affecting change | High | Medium | Define the fingerprint from actual restore inputs used by checks and preview, cover it with unit tests and wizard regression tests |
| Historical safety truth drifts if the detail page recomputes everything from current logic | High | Medium | Persist a narrow execution-time safety snapshot on the existing restore run |
| New integrity states exist but the UI still reads calmly | High | Medium | Lock calmness suppression into wizard and detail tests, not only into helper code |
| Restore-specific truth disappears on canonical operation detail | Medium | Medium | Add explicit restore continuation coverage on the operation detail seams |
| The slice grows into a recovery dashboard or new persisted health system | Medium | Low | Keep the design constrained to existing restore and operation records, with no new table |
## Test Strategy
- Extend existing restore wizard, preview, hardening, RBAC, and ops-UX Pest coverage before adding any new test harness.
- Add unit tests for the narrow derived restore safety helpers so fingerprint, integrity, safety, and result attention logic stay deterministic.
- Extend existing restore audit, execution-job, and preview-diff tests so invalidation reasoning remains derivable from restore records and the current execution and diff flows remain behaviorally intact.
- Add feature tests that prove stale or invalidated preview and checks suppress calm execution language.
- Add feature tests that prove scope changes invalidate prior readiness and that confirm-step validation refuses calm execution when integrity conditions are not met.
- Add feature tests that prove partial or completed-with-follow-up results are elevated above raw item lists and do not imply tenant recovery.
- Add canonical operation-detail tests that prove restore follow-up truth remains visible or safely linked.
- Re-run the existing ops-UX constitution and notification guards for direct status transitions, terminal DB notifications, canonical View run links, queued toast copy, and whitelisted `summary_counts` so reuse of `OperationRun` cannot regress the three-surface feedback contract.
- Keep the manual `quickstart.md` validation pass as an explicit completion step so the 15-second and one-click operator outcomes are verified, not merely assumed from automated coverage.
- Keep all tests Livewire v4 compatible and run the smallest affected subset through Sail before asking for a full-suite pass.
## Complexity Tracking
No constitution violations or exception-driven complexity were identified. The only added complexity is the narrow derived restore-safety layer and the compact persisted execution-time safety snapshot already justified by the proportionality review.
## Proportionality Review
- **Current operator problem**: Operators can currently treat stale preview or stale checks as if they still authorize the current restore scope, and can read `completed` as calmer than the product can prove.
- **Existing structure is insufficient because**: Existing restore flow data exists, but presence alone does not distinguish current versus invalid or safe versus merely executable. Existing result rendering does not elevate follow-up truth strongly enough.
- **Narrowest correct implementation**: Add a narrow derived restore-safety layer plus optional nested metadata snapshotting on the existing restore run. Reuse existing wizard, result, and operation-detail surfaces instead of creating a second workflow or persistence model.
- **Ownership cost created**: A small set of derived helpers, central state mapping, new view-model wiring, and additional unit and feature tests.
- **Alternative intentionally rejected**: A new recovery-health table, a tenant-wide recovery dashboard, or a generalized trust framework. Each was rejected as too broad for the current operator problem.
- **Release truth**: Current-release truth. This feature hardens already-shipped restore behavior before broader backup-quality or recovery-confidence work depends on it.

View File

@ -0,0 +1,152 @@
# Quickstart: Restore Safety Integrity
## Goal
Validate that restore wizard, restore detail, and canonical operation detail now communicate restore safety truth without overstating calmness, scope validity, or recovery completion.
This slice uses freshness policy `invalidate_after_mutation` for preview and checks. Inside one active wizard draft, there is no separate age-based timeout; `stale` is reserved for legacy or incomplete persisted evidence, while `invalidated` is used for explicit scope drift after a covered mutation.
## Prerequisites
1. Start Sail if it is not already running.
2. Ensure the workspace has representative restore fixtures for:
- a scope with current checks and preview
- a scope where preview or checks become invalid after a scope change
- a scope with warnings but no blockers
- a real restore run that ends `completed`
- a real restore run that ends `partial` or `completed_with_follow_up`
- a restore-linked `OperationRun`
3. Ensure the acting user is a valid workspace member and tenant member.
4. Ensure at least one lower-privilege user exists to verify 404 versus 403 and safe degradation.
## Focused Automated Verification
Run the smallest restore-related suite first:
```bash
vendor/bin/sail artisan test --compact tests/Feature/RestoreRunWizardExecuteTest.php
vendor/bin/sail artisan test --compact tests/Feature/RestoreRiskChecksWizardTest.php
vendor/bin/sail artisan test --compact tests/Feature/Filament/RestorePreviewTest.php
vendor/bin/sail artisan test --compact tests/Feature/Filament/RestoreRunUiEnforcementTest.php
vendor/bin/sail artisan test --compact tests/Feature/OpsUx/RestoreExecutionOperationRunSyncTest.php
vendor/bin/sail artisan test --compact tests/Feature/RestoreAuditLoggingTest.php
vendor/bin/sail artisan test --compact tests/Feature/ExecuteRestoreRunJobTest.php
vendor/bin/sail artisan test --compact tests/Feature/RestorePreviewDiffWizardTest.php
vendor/bin/sail artisan test --compact tests/Feature/OpsUx/Constitution/DirectStatusTransitionGuardTest.php
vendor/bin/sail artisan test --compact tests/Feature/OpsUx/Constitution/JobDbNotificationGuardTest.php
vendor/bin/sail artisan test --compact tests/Feature/OpsUx/Constitution/LegacyNotificationGuardTest.php
vendor/bin/sail artisan test --compact tests/Feature/OpsUx/OperationRunSummaryCountsIncrementTest.php
vendor/bin/sail artisan test --compact tests/Feature/OpsUx/Regression/RestoreRunTerminalNotificationTest.php
vendor/bin/sail artisan test --compact tests/Feature/OpsUx/NotificationViewRunLinkTest.php
vendor/bin/sail artisan test --compact tests/Feature/OpsUx/QueuedToastCopyTest.php
```
Expected new or expanded spec-scoped tests:
```bash
vendor/bin/sail artisan test --compact tests/Feature/Filament/RestoreSafetyIntegrityWizardTest.php
vendor/bin/sail artisan test --compact tests/Feature/Filament/RestoreResultAttentionSurfaceTest.php
vendor/bin/sail artisan test --compact tests/Feature/Operations/RestoreLinkedOperationDetailTest.php
vendor/bin/sail artisan test --compact tests/Unit/Support/RestoreSafety/
```
Use `--filter` for a smaller pass while iterating.
## Manual Validation Pass
### 1. Establish current preview and checks
Open `/admin/t/{tenant}/restore-runs/create` and:
- choose a backup set
- choose `selected` scope or keep `all`
- run checks
- generate preview
Confirm the page shows:
- what scope is currently selected
- when preview and checks were generated
- whether each basis is current
- the difference between execution readiness and safety readiness
### 2. Trigger explicit invalidation
After preview and checks exist, change one scope-defining input:
- selected items
- scope mode
- group mapping
- backup set
Confirm the page no longer behaves like preview and checks were never run.
It must clearly show:
- previous preview or checks were invalidated by the change
- rerun is required
- calm execution language is suppressed
### 3. Verify warning suppression
Use a scope with warnings but no blockers and confirm:
- the restore may still be technically executable
- the page does not say `safe`, `ready`, or `looks good` in a calm way
- the operator sees one primary cautionary next step
### 4. Verify real execution confirmation
On the final wizard step, confirm that real execution requires:
- current checks
- current preview
- matching scope fingerprint
- hard-confirm inputs
- passing execution readiness
If any of those conditions fail, confirm the page prefers corrective guidance over calm execute messaging.
### 5. Verify result truth after execution
Open the restore-run detail page and confirm the first visible area answers:
- what completed
- what only partially completed
- whether follow-up is still required
- what the primary next action is
- that `completed` does not imply `tenant recovered`
### 6. Verify canonical operation continuity
Open the linked canonical operation detail and confirm:
- restore-specific follow-up truth is visible or reachable in one click
- the page does not reduce restore meaning to generic operation telemetry alone
- unauthorized deeper links are suppressed or explained safely
## Non-Regression Checks
Confirm the feature did not change:
- tenant route and canonical route identity
- 404 versus 403 semantics for restore surfaces and linked operation surfaces
- existing write-gate and execution authorization behavior
- `OperationRun` lifecycle ownership and sync behavior
- existing archive, restore, rerun, and force-delete confirmation behavior
- render-time prohibition on new external calls for detail surfaces
## Formatting And Final Verification
Before finalizing implementation work:
```bash
vendor/bin/sail bin pint --dirty --format agent
```
Then rerun the smallest affected test set and offer the full suite only after the focused restore safety pack passes.
Close the feature only after the manual validation confirms:
- operators can identify the next safe action within 15 seconds on the wizard and result surfaces
- restore-specific follow-up truth is visible or reachable from canonical operation detail within one click

View File

@ -0,0 +1,65 @@
# Research: Restore Safety Integrity
## Decision 1: Derive a deterministic scope fingerprint from existing restore inputs instead of creating a new persisted scope entity
- Decision: Represent restore scope identity as a deterministic fingerprint derived from the existing restore inputs that materially change checked or written behavior: `backup_set_id`, scope mode, sorted selected item IDs, and normalized group mapping values. Persist that fingerprint only inside existing `RestoreRun` metadata when historical execution truth needs to be retained.
- Rationale: The current restore domain already has the raw inputs on `RestoreRun` and in the wizard state. The missing truth is not a new entity but a reliable way to say whether checks and preview still apply to the current selection. A derived fingerprint solves the mismatch problem without introducing a new table or second scope model.
- Alternatives considered:
- Use only timestamps to decide whether preview or checks are current. Rejected because time alone cannot detect scope mismatch.
- Create a dedicated persisted `restore_scope_snapshots` table. Rejected because the scope has no independent lifecycle outside the restore run and would violate the feature's proportionality goal.
## Decision 2: Keep integrity states separate from risk severity
- Decision: Model preview integrity and checks integrity as derived state families separate from `RestoreRiskChecker` severities. `current`, `stale`, `invalidated`, and `not_generated` or `not_run` answer whether the basis is still trustworthy; blocking and warning counts continue to answer what the risk checker found.
- Rationale: Existing risk checks already classify blockers and warnings, but they do not answer whether the evaluated scope still matches the operator's current selection. Treating these as one concept would continue the current trust failure where `no blockers` can be misread as `safe`.
- Alternatives considered:
- Reuse blocker or warning severity to encode staleness and mismatch. Rejected because severity and integrity have different operator consequences.
- Collapse integrity into one generic `needs rerun` label. Rejected because the UI needs to distinguish `never run`, `stale`, and `invalidated` as different truths.
## Decision 3: Preserve invalidation evidence in wizard state instead of silently clearing prior work
- Decision: Replace the current silent reset behavior for preview and checks with explicit invalidation evidence in the wizard state. The last generated basis may still be cleared from being executable truth, but the operator should see that a prior preview or check existed and no longer applies.
- Rationale: The current wizard already resets `check_summary`, `check_results`, `checks_ran_at`, `preview_summary`, `preview_diffs`, and `preview_ran_at` when scope-affecting inputs change. That preserves safety mechanically, but it does not preserve the operator truth that the prior work was invalidated by a change they just made.
- Alternatives considered:
- Keep the current silent clearing behavior and add helper text only. Rejected because it still reads too much like `not generated` instead of `invalidated by your change`.
- Keep the old values visible without marking them invalid. Rejected because it risks making stale truth look reusable.
## Decision 4: Persist only a narrow execution-time safety snapshot on the existing restore run
- Decision: When a real restore is queued, persist a compact execution-time safety snapshot inside existing `RestoreRun` metadata. The snapshot should capture the scope fingerprint, preview basis, checks basis, derived safety state, and primary blocker or warning context that justified or constrained execution.
- Rationale: The result and detail surfaces need historical truth about what basis was used at confirmation time. Re-deriving that later from mutable thresholds or current UI logic risks rewriting history. A narrow metadata snapshot keeps the audit-relevant truth on the existing restore record without creating a second persisted model.
- Alternatives considered:
- Recompute execution-time safety state dynamically from the current code and current timestamps. Rejected because historical truth can drift as code or thresholds change.
- Persist a full recovery-health document. Rejected because this feature does not claim tenant-wide recovery truth.
## Decision 5: Derive result follow-up truth from existing restore results and operation outcomes instead of adding a recovery entity
- Decision: Compute `completed`, `partial`, `failed`, and `completed_with_follow_up` from existing restore results, assignment outcomes, metadata, and linked `OperationRun` outcome. Treat cause families and next actions as derived read-model fields for the detail surfaces.
- Rationale: `RestoreRun.results`, assignment outcomes, and operation-run linkage already contain enough signal to decide whether operator follow-up remains. The product problem is weak surfacing of that truth, not missing domain storage.
- Alternatives considered:
- Add a dedicated persisted recovery status column or table. Rejected because the feature does not need a second source of truth.
- Use only `RestoreRun.status` as the result meaning. Rejected because `completed` does not mean `recovered` and `partial` does not explain the operator consequence on its own.
## Decision 6: Keep restore-specific follow-up truth visible on the canonical operation detail through enrichment or a safe deep link
- Decision: Reuse the existing restore-to-operation linkage and enrich the canonical operation detail for `restore.execute` runs with restore follow-up truth or a single safe route into the restore detail page. Do not add new `OperationRun` persistence for restore-specific state.
- Rationale: Canonical monitoring is already the shared destination for operational truth. The feature must keep restore meaning visible there, but the restore-specific source of truth still belongs to `RestoreRun`.
- Alternatives considered:
- Persist restore-follow-up labels directly on `OperationRun`. Rejected because it duplicates restore truth into the monitoring record.
- Leave canonical operation detail generic and rely entirely on restore detail for follow-up truth. Rejected because it breaks continuity from monitoring.
## Decision 7: Reuse Filament wizard, action, and infolist seams already present in the codebase
- Decision: Implement the feature inside the existing `RestoreRunResource::getWizardSteps()`, `CreateRestoreRun`, restore form component views, restore infolist entry views, and the existing canonical operation detail seams. Rely on Filament wizard lifecycle hooks and action testing patterns rather than inventing a new UI shell.
- Rationale: Filament v5 already supports wizard step validation hooks, confirmation modals for actions, and direct action testing. Existing restore surfaces are already built on these seams, so a narrow hardening slice should stay inside them.
- Alternatives considered:
- Rebuild restore safety as a custom standalone screen outside Filament. Rejected because it would duplicate current routing, RBAC, and UI patterns.
- Push interactivity into custom infolist entry classes. Rejected because Filament custom infolist entries are display-oriented, not Livewire components, and the current restore detail need is presentation hardening rather than a new client-side interaction model.
## Decision 8: Extend the existing Pest and Livewire test surface instead of creating a new browser-first harness
- Decision: Add focused unit and feature coverage around the new integrity resolvers, wizard invalidation, confirmation hardening, result attention, canonical operation continuity, and RBAC-safe degradation by extending the existing restore-related Pest and Livewire tests.
- Rationale: The repository already has strong restore wizard, preview, execution, hardening, RBAC, and ops-UX regression coverage. Filament's testing guidance supports direct action invocation and visibility assertions, which fit this feature precisely.
- Alternatives considered:
- Rely only on manual UI validation. Rejected because this slice is specifically about preventing subtle trust regressions.
- Add a large browser-only suite as the primary guard. Rejected because the critical assertions are server-driven state and action consequences that fit existing Pest and Livewire tests better.

View File

@ -0,0 +1,267 @@
# Feature Specification: Restore Safety Integrity
**Feature Branch**: `181-restore-safety-integrity`
**Created**: 2026-04-06
**Status**: Proposed
**Input**: User description: "Spec 181 - Restore Safety Integrity"
## Spec Scope Fields *(mandatory)*
- **Scope**: tenant + canonical-view
- **Primary Routes**:
- `/admin/t/{tenant}/restore-runs`
- `/admin/t/{tenant}/restore-runs/create`
- `/admin/t/{tenant}/restore-runs/{restoreRun}`
- `/admin/operations`
- `/admin/operations/{run}`
- **Data Ownership**:
- `RestoreRun` remains the tenant-owned restore source of truth for selected scope, preview payload, check results, execution intent, and restore result details.
- `OperationRun` remains the canonical workspace-owned execution record for queued or running restore execution, with tenant linkage preserved through existing relationships and authorization rules.
- Preview integrity, checks integrity, restore safety, and result follow-up truth remain derived from existing restore inputs and outcomes. The feature may add structured metadata on existing `RestoreRun` records when needed, but it must not add a new table or central recovery-state store.
- Existing backup artifacts, policy versions, assignment mappings, and write-gate decisions remain owned by their current domains.
- **RBAC**:
- Tenant membership remains required for every restore-run list, create, and detail surface.
- Real restore execution remains gated by the existing tenant-manage capability from the canonical capability registry.
- Canonical operation detail remains workspace-scoped first and tenant-entitlement-safe for any restore-linked context or deep link.
- Non-members remain `404`; in-scope actors who can view but cannot execute remain limited to truthful read surfaces without misleading execution affordances.
For canonical-view specs, the spec MUST define:
- **Default filter behavior when tenant-context is active**: When operators open canonical monitoring from a tenant restore surface, `/admin/operations` may prefilter to the active tenant and preserve the originating restore-follow-up context. The destination must not flatten restore-specific follow-up into a generic operations list state.
- **Explicit entitlement checks preventing cross-tenant leakage**: Restore-linked canonical operation detail and cross-links from restore surfaces must resolve only after workspace membership and tenant entitlement checks against the referenced restore run or operation run. Unauthorized actors must not see related restore warnings, counts, result hints, or deep-link affordances.
## 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 |
|---|---|---|---|---|---|---|---|---|---|---|---|
| Restore run wizard | Mutation-first wizard | Dedicated create wizard with one safety decision flow | forbidden | Step-level hint actions only | Final execution action inside hard-confirm step only | `/admin/t/{tenant}/restore-runs` | `/admin/t/{tenant}/restore-runs/{restoreRun}` | Active tenant context, backup set, scope mode, selected item count, preview state, checks state | Restore runs / Restore run | Preview currentness, checks currentness, execution readiness, safety readiness | Wizard surface |
| Restore run detail and result | Detail-first operational surface | Dedicated restore-run detail page | forbidden | Related navigation and diagnostics appear after summary and next action | No new destructive action on the detail page | `/admin/t/{tenant}/restore-runs` | `/admin/t/{tenant}/restore-runs/{restoreRun}` | Active tenant context, backup set, execution mode, preview/check basis, result attention | Restore runs / Restore run | Overall result truth, follow-up truth, primary next action, basis integrity | Existing custom infolist entry surface |
| Canonical operation detail for restore-linked runs | Canonical detail | Dedicated operation-run detail page | forbidden | Detail-header navigation and related links only | None introduced by this feature | `/admin/operations` | `/admin/operations/{run}` | Workspace scope, entitled tenant context, run identity, restore linkage | Operations / Operation | Restore-specific follow-up truth is visible or explicitly linked from the canonical run detail | Restore-linked canonical detail |
## 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 |
|---|---|---|---|---|---|---|---|---|---|
| Restore run wizard | Tenant operator | Mutation-first wizard | Can I responsibly execute this restore for the currently selected scope? | Selected scope, preview state, checks state, primary blocker or warning, execution readiness, safety readiness, rerun requirement | Raw diff rows, item-level checker output, low-level mapping detail | preview integrity, checks integrity, execution readiness, safety readiness, mutation mode | Preview and checks are simulation-only; real execution mutates the Microsoft tenant | Run checks, Generate preview, Adjust scope, Execute restore | Execute restore |
| Restore run detail and result | Tenant operator | Detail-first operational surface | What did this restore actually mean, and what do I need to do next? | Overall result truth, follow-up truth, summary of applied or failed work, primary next action, whether recovery is still open | Item-by-item failure detail, raw provider diagnostics, deep mapping detail | execution outcome, result follow-up, cause family, recovery confidence boundary | Read-only result interpretation; follow-up actions may lead to simulation-only or future tenant mutation paths outside this surface | Review result, Open related operation, Follow primary next action | None introduced by this feature |
| Canonical operation detail for restore-linked runs | Workspace operator or entitled tenant operator | Canonical detail | How does this operation outcome relate to restore safety and restore follow-up truth? | Operation lifecycle, outcome, restore linkage, visible restore follow-up or direct path to it | Generic operation telemetry, technical traces, low-level run internals | operation lifecycle, operation outcome, restore follow-up continuity | Read-only monitoring surface | Return to operations, Open restore context, Refresh | None introduced by this feature |
## Proportionality Review *(mandatory when structural complexity is introduced)*
- **New source of truth?**: No
- **New persisted entity/table/artifact?**: No
- **New abstraction?**: Yes, but only a narrow derived resolver or presenter layer for preview integrity, checks integrity, restore safety, and result follow-up truth over existing restore data.
- **New enum/state/reason family?**: Yes, as derived restore-domain state families for preview integrity, checks integrity, safety readiness, and result follow-up.
- **New cross-domain UI framework/taxonomy?**: No. This is restore-domain hardening only, not a new product-wide trust framework.
- **Current operator problem**: Operators can currently mistake presence of a preview for currentness, prior checks for current scope coverage, technical startability for safety, and restore completion for tenant recovery.
- **Existing structure is insufficient because**: The existing restore flow exposes checks, preview, and result data, but it does not enforce their time-bound and scope-bound meaning strongly enough at the decision surfaces where operators choose whether to execute or stop.
- **Narrowest correct implementation**: Derive integrity and follow-up states from existing `RestoreRun`, `OperationRun`, risk-check, diff, and result data. Allow limited structured metadata on the current `RestoreRun` context if required for scope fingerprinting or invalidation reasons, but do not create a second persisted restore-health model.
- **Ownership cost**: The codebase takes on fingerprint derivation rules, state mapping rules, shared UI semantics for restore safety, and regression tests that keep wizard, detail, and canonical operation surfaces aligned.
- **Alternative intentionally rejected**: A tenant-wide recovery confidence dashboard, a new persisted recovery health table, or a global restore risk engine were rejected because the immediate trust problem is narrower: the current restore surfaces overstate calmness and understate invalidation.
- **Release truth**: Current-release truth. This feature hardens already shipped restore behavior before broader recovery-confidence or backup-quality work builds on it.
## User Scenarios & Testing *(mandatory)*
### User Story 1 - Decide Whether Real Execution Is Responsible (Priority: P1)
As a tenant operator preparing a restore, I want the wizard to tell me whether the current preview and checks still apply to the scope I selected, so that I do not launch a real restore on stale assumptions.
**Why this priority**: The most dangerous failure is a confident real restore based on outdated or mismatched truth.
**Independent Test**: Can be fully tested by opening the wizard, generating checks and preview, then verifying that the final step clearly distinguishes safe readiness from mere technical startability.
**Acceptance Scenarios**:
1. **Given** a restore scope with current checks, current preview, and no blockers, **When** the operator opens the confirm step, **Then** the surface shows that the restore is technically startable and safety-reviewed for the current scope.
2. **Given** a restore scope with no blockers but unresolved warnings, **When** the operator opens the confirm step, **Then** the surface does not present a calm `safe` or `looks good` message and instead frames the action as risky or cautionary.
3. **Given** preview or checks were never run, **When** the operator reaches the confirm step, **Then** the surface shows rerun requirements before real execution is presented as available.
---
### User Story 2 - Notice Scope Drift Immediately (Priority: P1)
As a tenant operator changing the selected restore items or mapping inputs, I want previously generated preview and checks to become visibly invalid, so that I do not assume the old safety work still applies.
**Why this priority**: Scope drift is the clearest source-of-truth failure in the current flow and must become operator-visible immediately.
**Independent Test**: Can be fully tested by generating checks and preview, then changing selected items, mapping choices, or scope mode and verifying that both states invalidate without subtle or quiet fallback.
**Acceptance Scenarios**:
1. **Given** an operator generated checks for one selected restore scope, **When** the operator changes the scope, **Then** the previous checks are shown as invalid for the current scope and the wizard asks for a rerun.
2. **Given** an operator generated preview before changing group-mapping inputs that affect restore behavior, **When** the mapping changes, **Then** the previous preview no longer appears as current decision truth.
3. **Given** the operator narrows or broadens the selection after preview and checks exist, **When** the confirm step is revisited, **Then** the calm execution state is suppressed until preview and checks are regenerated for the current fingerprint.
---
### User Story 3 - Interpret Restore Results Without Overclaiming Recovery (Priority: P2)
As a tenant operator reviewing a finished restore, I want the result surface to tell me whether the run merely ended or whether follow-up work remains, so that I do not confuse completion with recovery.
**Why this priority**: Partial or mixed restore outcomes are currently diagnosable but not operator-hard enough, which creates false calm after the run ends.
**Independent Test**: Can be fully tested by opening completed, partial, failed, and completed-with-follow-up results and verifying that the top of the page communicates result truth and next action before raw item lists.
**Acceptance Scenarios**:
1. **Given** a restore run completed with mixed item outcomes, **When** the operator opens the detail page, **Then** the page frames the run as partial or completed with follow-up rather than as a calm success.
2. **Given** a restore run finished with no hard failure but unresolved follow-up work, **When** the operator opens the detail page, **Then** the page states that the run ended but recovery work remains open.
3. **Given** a restore run failed because of provider, write-gate, or item-level errors, **When** the operator opens the detail page, **Then** the page highlights the primary cause family and the next recommended action before low-level diagnostics.
---
### User Story 4 - Preserve Restore Truth In Canonical Run Monitoring (Priority: P3)
As a workspace or entitled tenant operator inspecting the linked canonical operation run, I want restore-specific follow-up truth to remain discoverable there, so that generic operation telemetry does not hide restore safety meaning.
**Why this priority**: The canonical run detail is often the first or shared monitoring destination, and it must not flatten restore meaning into generic execution status alone.
**Independent Test**: Can be fully tested by opening restore-linked operation runs from restore surfaces and monitoring surfaces and confirming that restore-specific follow-up truth is visible or reachable within one click.
**Acceptance Scenarios**:
1. **Given** a restore-linked operation run completed but the restore result requires follow-up, **When** the canonical operation detail is opened, **Then** the operator can see or open restore follow-up truth without hunting through unrelated telemetry.
2. **Given** the operator lacks access to a deeper diagnostic surface, **When** the canonical operation detail renders, **Then** the page avoids broken or misleading links while still preserving truthful restore attention.
### Edge Cases
- A restore may be technically executable because write-gate and RBAC checks pass, while preview or checks are stale; the surface must not collapse this into a calm `ready` signal.
- A preview may still exist for the same backup set but a different item selection, scope mode, or execution-affecting mapping input; the UI must treat that as scope mismatch, not as acceptable reuse.
- Checks may report no blockers but still include suppressive warnings; the decision surface must remain cautious and avoid positive calmness claims.
- A restore result may show all queued work completed but still leave unresolved assignment, dependency, or payload-quality issues; the result surface must not imply that the tenant is recovered.
- An operator may be allowed to view the restore run but not entitled to all deeper operation or diagnostic targets; the surface must degrade safely with truthful messaging and safe links only.
- A restore may remain preview-only by design for some items or policies; result and confirmation surfaces must keep simulation truth separate from real mutation truth.
## Requirements *(mandatory)*
**Constitution alignment (required):** This feature changes an existing write-capable restore workflow and an existing long-running restore execution path, but it does not introduce a new Microsoft Graph contract, a new queued job family, or a new persisted run model. Existing restore preview, confirmation, audit, and `OperationRun` observability remain authoritative. This spec hardens the safety meaning of those existing steps so a real restore cannot appear calmer than the underlying truth.
**Constitution alignment (PROP-001 / ABSTR-001 / PERSIST-001 / STATE-001 / BLOAT-001):** The feature may introduce narrow derived state families and a restore-domain resolver or presenter because direct presence checks are no longer enough to express currentness, scope binding, or follow-up truth. The solution must remain derived-first. No new persisted restore-health table, no dashboard-grade recovery state, and no cross-domain trust taxonomy are allowed.
**Constitution alignment (OPS-UX):** Existing restore execution continues to create or reuse `OperationRun` records under the existing ops-UX contract. Intent feedback remains toast-only. Progress remains on existing operations surfaces. Terminal truth remains in the canonical monitoring record. `OperationRun.status` and `OperationRun.outcome` remain service-owned; this feature must not add ad-hoc status mutation from UI surfaces. Existing `summary_counts` rules remain authoritative; restore-specific integrity or follow-up truth should stay in restore-specific context unless an allowed numeric summary key already exists. Regression coverage must protect wizard gating, execution blocking or degradation, result truth, linked operation detail continuity, and non-regression of run observability.
**Constitution alignment (RBAC-UX):** This feature spans the tenant admin plane and the admin canonical-view plane. Tenant membership and tenant entitlement remain isolation boundaries for restore surfaces and related links. Non-members remain `404`. Members who can view restore context but lack the capability to start or rerun a restore remain `403` for those execution actions. Authorization must remain server-side through existing scoped record resolution, policies or Gates, and the canonical capability registry. No raw capability strings or role-name shortcuts may be introduced.
**Constitution alignment (OPS-EX-AUTH-001):** Not applicable. Restore monitoring and execution remain outside any `/auth/*` exception.
**Constitution alignment (BADGE-001):** Any new preview, checks, safety, warning, or result badges must come from centralized status semantics or shared primitives. The feature must not introduce page-local color or border conventions that invent a second restore status language.
**Constitution alignment (UI-FIL-001):** The feature reuses existing Filament wizard steps, Sections, view fields, infolist entries, notifications, and shared badge mappings. Semantic emphasis must come from those existing primitives or other approved shared primitives rather than custom page-local status markup. Existing custom restore infolist entry views remain acceptable only if they are hardened around shared truth rather than bespoke color logic.
**Constitution alignment (UI-NAMING-001):** The target object is the restore run. Existing operator verbs such as `Run checks`, `Generate preview`, `Preview only (dry-run)`, `Restore`, and `Open operation` remain the base vocabulary. New operator-facing labels must keep the distinctions between `preview`, `checks`, `safe`, `risky`, `partial`, and `follow-up` explicit and must not replace them with vague implementation-first language.
**Constitution alignment (UI-CONST-001 / UI-SURF-001 / UI-HARD-001 / UI-EX-001 / UI-REVIEW-001):** The wizard remains the only primary execution surface, the restore-run detail page remains the restore result truth surface, and the canonical operation detail remains the monitoring truth surface. No redundant `View` action is introduced. Row click remains the primary inspect affordance on upstream list surfaces. Dangerous actions remain grouped or confirm-gated according to the existing action-surface contract.
**Constitution alignment (OPSURF-001):** Default-visible content must stay operator-first. The restore wizard must answer whether the scope is current, whether preview and checks still apply, and whether real execution is responsible before raw diffs or item-level diagnostics. The restore result page must answer whether follow-up is still required before long results. The canonical operation detail must not hide restore-specific follow-up truth behind generic run telemetry.
**Constitution alignment (UI-SEM-001 / LAYER-001 / TEST-TRUTH-001):** Direct mapping from existing `preview`, `results`, and check presence to UI calmness is no longer sufficient because those values do not encode whether the truth is current, scope-bound, or still decision-worthy. A narrow derived read model is acceptable only if it replaces calmness-by-presence and avoids storing a redundant second truth. Tests must focus on operator consequences: whether stale inputs suppress execution calmness, whether scope drift invalidates prior truth, and whether finished runs avoid overclaiming recovery.
**Constitution alignment (Filament Action Surfaces):** The Action Surface Contract remains satisfied. The restore-run list keeps one primary inspect model through row click. The wizard remains the only primary execution surface. The detail surface remains read-first. Existing destructive actions continue to require confirmation and stay outside the wizard. No empty action groups or redundant view actions are introduced.
**Constitution alignment (UX-001 — Layout & Information Architecture):** The restore create flow remains a structured wizard with explicit sections. The restore detail page remains an infolist-based view surface, not a disabled edit form. The feature must create a clear reading order of summary first, decision second, diagnostics third on restore result surfaces, and must elevate safety truth before operators commit to execution.
### Functional Requirements
- **FR-181-001**: The system MUST treat restore preview as time-bound and scope-bound decision truth, not as a generic `preview exists` flag.
- **FR-181-002**: The restore preview surface MUST show when the preview was generated, which restore scope it represents, whether it is current, and whether rerun is required.
- **FR-181-003**: The system MUST derive a deterministic restore scope fingerprint from every execution-affecting restore input needed to judge whether preview and checks still match the current restore scope.
- **FR-181-004**: Restore checks MUST be bound to the fingerprint of the scope they evaluated, and the surface MUST make it visible when the current scope no longer matches that evaluated fingerprint.
- **FR-181-005**: Preview truth MUST likewise remain bound to the fingerprint of the scope it previewed, and the surface MUST make it visible when the current scope no longer matches that previewed fingerprint.
- **FR-181-006**: Any operator change to scope mode, selected items, backup source, or execution-affecting mapping input after checks or preview were generated MUST invalidate the prior calm readiness state for the current restore scope.
- **FR-181-007**: Preview integrity MUST surface at least `not_generated`, `current`, `stale`, and `invalidated` as real operator-visible states.
- **FR-181-008**: Checks integrity MUST surface at least `not_run`, `current`, `stale`, and `invalidated` as real operator-visible states.
- **FR-181-009**: The wizard and confirmation surface MUST show execution readiness and safety readiness as separate truths.
- **FR-181-010**: A restore MAY be technically startable while still being safety-risky or integrity-invalid, and the UI MUST not collapse those states into one calm `ready` state.
- **FR-181-011**: Blocking issues MUST continue to prevent calm execution approval, and warning-level issues MUST suppress calm `safe`, `ready`, or `looks good` claims even when real execution remains technically possible.
- **FR-181-012**: The final confirm and execute step MUST validate current preview state, current checks state, matching scope fingerprint, absence of blocking issues, and current execution readiness before presenting real execution as available.
- **FR-181-013**: If one or more integrity conditions fail at confirmation time, the surface MUST present the next corrective step, such as rerunning checks, regenerating preview, or correcting scope inputs, before real execution can appear calm.
- **FR-181-014**: The confirmation surface MUST keep simulation-only actions and real tenant mutation clearly separated in operator wording.
- **FR-181-015**: The restore result surface MUST answer what succeeded, what partially succeeded, what failed, whether follow-up is required, and whether the recovery goal is still uncertain.
- **FR-181-016**: The restore result surface MUST treat `partial` and `completed_with_follow_up` as non-calm operator states and MUST not present them as an uncomplicated success.
- **FR-181-017**: The restore result surface MUST present one primary next action whenever follow-up is required and MAY present additional secondary actions only after the primary action is visible.
- **FR-181-018**: The restore result surface MUST expose operator-usable cause families for follow-up truth, including execution failure, write-gate or RBAC blocking, provider operability, missing dependency or mapping, payload-quality limitation, scope mismatch, and item-level failure.
- **FR-181-019**: The restore-run detail surface MUST show which preview basis and which checks basis applied to the run or draft, including whether those bases were current, stale, or invalidated when the operator reviewed them.
- **FR-181-020**: No restore surface may imply that `completed` means `tenant recovered`, `restore guaranteed successful`, or `target state confirmed` unless a different feature later proves that truth.
- **FR-181-021**: The canonical operation detail for restore-linked runs MUST show restore-specific follow-up truth directly or provide one safe, entitled path to it.
- **FR-181-022**: The feature MUST remain implementable without a new central recovery-state table, a new tenant-wide recovery dashboard, or a new global risk-scoring model.
- **FR-181-023**: Auditability of scope invalidation and staleness reasoning MUST remain derivable from existing restore records and existing run context without breaking current restore audit flows.
- **FR-181-024**: Regression coverage MUST prove integrity-state classification, wizard invalidation, confirmation hardening, result follow-up truth, restore-linked operation continuity, and RBAC-safe degradation.
### Derived State Semantics
- **Preview integrity family**: `not_generated`, `current`, `stale`, `invalidated`, with optional finer labels such as `scope_mismatch` or `superseded` as derived explanation only.
- **Checks integrity family**: `not_run`, `current`, `stale`, `invalidated`, with optional finer labels such as `scope_mismatch` or `requires_rerun` as derived explanation only.
- **Restore safety family**: `blocked`, `risky`, `ready_with_caution`, `ready`. `ready` is reserved for scopes with current integrity and no suppressive blocker or warning conditions.
- **Restore result family**: `not_executed`, `completed`, `partial`, `failed`, `completed_with_follow_up`. `completed_with_follow_up` means execution finished but operator work is still open; it does not mean full recovery.
- **Freshness policy**: Preview and checks use `invalidate_after_mutation` for this feature. Within an active wizard draft, the system does not introduce a separate age-based timeout; matching scope fingerprint plus required captured-at evidence is sufficient for `current`. `invalidated` is reserved for explicit scope mismatch after a covered mutation. `stale` is reserved for legacy or incomplete persisted evidence whose currentness can no longer be proven even though a direct mismatch is unavailable.
### Non-Functional Requirements
- **NFR-181-001**: The feature SHOULD ship without a new table, a new globally persisted recovery-state model, or a new tenant-wide reconciliation dashboard.
- **NFR-181-002**: Existing restore orchestration, write-gate evaluation, risk checking, diff generation, and operation-run tracking MUST remain behaviorally intact outside the new integrity and follow-up hardening.
- **NFR-181-003**: New restore safety labels and states MUST remain centrally mappable and regression-testable rather than page-local.
- **NFR-181-004**: The feature MUST preserve current route identity and existing deep-link stability for restore-run detail and canonical operation detail pages.
- **NFR-181-005**: Restore-specific truth added to canonical monitoring MUST remain readable without forcing operators into low-level technical diagnostics.
### Non-Goals
- Redesigning backup-quality surfaces or backup fidelity scoring
- Building a tenant-wide recovery confidence dashboard
- Introducing a semantic version-diff system for backup history choice
- Adding a new post-restore full reconciliation engine for every policy type
- Creating a new global restore risk-scoring model or provider-hardening subsystem
- Creating a new central recovery-health persistence table or workflow hub
### Assumptions
- Existing restore preview, restore checks, and restore execution pipelines already carry enough underlying truth that this feature can harden interpretation without re-architecting the restore domain.
- The scope fingerprint should include every restore input that materially changes what will be checked or what could be written, including backup source, scope selection, and execution-affecting mapping inputs.
- Preview and checks use `invalidate_after_mutation` for this feature. Active wizard drafts do not introduce a separate age-based timeout; `invalidated` covers explicit scope drift after a covered mutation, while `stale` remains reserved for legacy or incomplete persisted evidence whose currentness cannot be proven.
- Existing risk checker outputs, diff outputs, item-level result data, and operation-run linkage remain available to support result attention and next-action derivation.
### Dependencies
- Existing `RestoreRun` domain model and status lifecycle
- Existing `RestoreRiskChecker` logic and output semantics
- Existing `RestoreDiffGenerator` logic and preview semantics
- Existing write-gate and RBAC hardening foundations
- Existing `OperationRun` and restore-run coupling, including canonical operation detail surfaces
- Existing centralized badge or status semantics and tenant-safe navigation rules
### Risks
- If integrity states are added but calm UI language remains unchanged, the feature will add terminology without removing the core trust failure.
- If scope fingerprinting is narrower than the real execution scope, operators may still reuse stale safety truth incorrectly.
- If restore result follow-up truth is only appended below diagnostics, operators will continue to misread completion as recovery.
- If canonical operation detail remains generic while restore detail becomes strict, trust will drift between monitoring and restore surfaces.
- If the feature tries to solve tenant-wide recovery truth now, it will overgrow the slice and violate the proportionality goal.
## 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 |
|---|---|---|---|---|---|---|---|---|---|---|
| Restore runs list | `/admin/t/{tenant}/restore-runs` | `New restore run` | Row click to restore-run detail | `Rerun`; `More -> Restore / Archive / Force delete` by record state | `More -> Archive Restore Runs / Restore Restore Runs / Force Delete Restore Runs` | `New restore run` | n/a | n/a | Existing archive, restore, delete, rerun audit behavior remains | The list keeps one primary inspect model and no redundant `View` action. Destructive actions stay grouped and confirmed. |
| Restore run wizard | `/admin/t/{tenant}/restore-runs/create` | No new destructive header action | n/a | Step hint actions such as `Run checks`, `Generate preview`, `Select all`, `Clear`, and `Sync Groups` remain local to the relevant step | n/a | n/a | n/a | Final create or execute flow remains the single primary save path; real execution remains hard-confirmed | Existing restore queueing and execution audit behavior remains authoritative | The wizard is the only primary execution surface. This feature hardens calmness and gating, not action sprawl. |
| Restore run detail and result | `/admin/t/{tenant}/restore-runs/{restoreRun}` | None introduced by this feature | n/a | n/a | n/a | n/a | Existing page remains read-first; restore-linked operation navigation may be surfaced when entitled | n/a | No new audit event introduced by this feature | The detail surface must elevate result truth and next action above diagnostics without adding destructive controls. |
| Canonical operation detail for restore-linked runs | `/admin/operations/{run}` | Existing `Back`, `Refresh`, and related navigation only | n/a | n/a | n/a | n/a | Existing header navigation only; restore follow-up link may appear when entitled | n/a | No new audit event introduced by this feature | No new destructive action is introduced. Restore-specific truth must remain visible or reachable within one click. |
### Key Entities *(include if feature involves data)*
- **Restore Run**: The tenant-owned restore record that carries scope choice, preview data, checks data, execution intent, and restore results.
- **Scope Fingerprint**: The deterministic representation of the restore scope used to prove whether preview and checks still apply to the current restore inputs.
- **Preview Integrity State**: The derived state that answers whether preview exists, is current enough, and still matches the active scope.
- **Checks Integrity State**: The derived state that answers whether safety checks exist, are current enough, and still match the active scope.
- **Restore Safety State**: The derived decision state that separates `blocked`, `risky`, `ready_with_caution`, and `ready` instead of treating all non-blocked restores as safe.
- **Restore Result Follow-Up State**: The derived truth that answers whether the run is merely finished, partially successful, failed, or completed with operator follow-up still required.
- **Restore Safety Summary**: The operator-facing summary that combines integrity, readiness, primary warning or blocker, and next step without claiming tenant recovery.
## Success Criteria *(mandatory)*
### Measurable Outcomes
- **SC-181-001**: In seeded acceptance scenarios, operators can determine within 15 seconds whether the current preview still applies, whether checks still match the current scope, whether the restore is merely executable or actually safety-reviewed, and what the next required action is.
- **SC-181-002**: In covered stale or invalidated preview or checks scenarios, 100% of the affected wizard and confirmation surfaces suppress calm `safe` or `ready` claims and visibly require rerun or correction.
- **SC-181-003**: In covered scope-change scenarios, previously generated preview and checks are visibly invalidated before real execution is presented as calm or approved.
- **SC-181-004**: In covered partial, failed, and completed-with-follow-up scenarios, 100% of restore result surfaces elevate follow-up-required truth above raw item lists and do not imply that the tenant is recovered.
- **SC-181-005**: In covered restore-linked operation scenarios, operators can reach restore-specific follow-up truth from canonical operation detail in one click or less without encountering broken or misleading links.
- **SC-181-006**: The feature ships without a new central recovery-state table, a new tenant-wide recovery dashboard, or a new global restore risk model.

View File

@ -0,0 +1,257 @@
# Tasks: Restore Safety Integrity
**Input**: Design documents from `/specs/181-restore-safety-integrity/`
**Prerequisites**: `plan.md` (required), `spec.md` (required for user stories), `research.md`, `data-model.md`, `contracts/`, `quickstart.md`
**Tests**: Tests are REQUIRED for this feature. Use focused Pest coverage in `tests/Feature/RestoreRunWizardExecuteTest.php`, `tests/Feature/RestoreRiskChecksWizardTest.php`, `tests/Feature/Filament/RestorePreviewTest.php`, `tests/Feature/Filament/RestoreRunUiEnforcementTest.php`, `tests/Feature/OpsUx/RestoreExecutionOperationRunSyncTest.php`, `tests/Feature/RestoreAuditLoggingTest.php`, `tests/Feature/ExecuteRestoreRunJobTest.php`, `tests/Feature/RestorePreviewDiffWizardTest.php`, existing ops-UX constitution and notification guards under `tests/Feature/OpsUx/`, and new restore-safety tests under `tests/Feature/Filament/`, `tests/Feature/Operations/`, and `tests/Unit/Support/RestoreSafety/`.
**Operations**: This feature reuses existing `RestoreRun` and `OperationRun` execution records. No new run type, lifecycle transition owner, terminal notification flow, or `summary_counts` producer is introduced; work is limited to restore-specific safety truth and canonical-detail continuity for existing `restore.execute` runs.
**RBAC**: Existing tenant membership, tenant-manage capability gating, capability-registry usage, and `404` vs `403` semantics must remain unchanged across `/admin/t/{tenant}/restore-runs/...` and `/admin/operations/{run}`. Tests must cover both positive and negative access paths.
**Operator Surfaces**: The restore wizard must show scope, integrity, execution readiness, and one corrective next step before raw preview or check details. The restore detail surface must elevate follow-up truth and next action above raw result lists. The canonical operation detail must keep restore-specific follow-up truth visible or safely linked.
**Filament UI Action Surfaces**: No new list, bulk, or destructive actions are introduced. Existing rerun, restore, archive, and force-delete actions remain confirmation-gated and server-authorized; the wizard remains the only primary execution surface.
**Filament UI UX-001**: The create flow remains a Filament wizard with sectioned steps, and the restore detail remains an infolist-based read surface. New safety messaging must be summary-first and diagnostics-second.
**Badges**: Any new integrity, safety, or result-attention badge states must route through existing centralized restore badge semantics in `app/Support/Badges/Domains/`.
**Organization**: Tasks are grouped by user story so each story can be implemented and validated as an independent increment after the shared restore-safety scaffolding is in place.
## Phase 1: Setup (Shared Restore-Safety Scaffolding)
**Purpose**: Add the narrow shared restore-safety types and test scaffolding used by every story.
- [X] T001 Create the shared restore-safety value objects in `app/Support/RestoreSafety/RestoreScopeFingerprint.php`, `app/Support/RestoreSafety/PreviewIntegrityState.php`, `app/Support/RestoreSafety/ChecksIntegrityState.php`, and `app/Support/RestoreSafety/ExecutionReadinessState.php`
- [X] T002 [P] Create the shared decision-layer types in `app/Support/RestoreSafety/RestoreSafetyAssessment.php`, `app/Support/RestoreSafety/RestoreExecutionSafetySnapshot.php`, and `app/Support/RestoreSafety/RestoreResultAttention.php`
- [X] T003 Create the central restore-safety resolver with explicit `invalidate_after_mutation` freshness handling and legacy-stale classification in `app/Support/RestoreSafety/RestoreSafetyResolver.php`
- [X] T004 [P] Add unit test scaffolding for the new restore-safety namespace, including `current` vs `invalidated` vs legacy `stale` classification, in `tests/Unit/Support/RestoreSafety/RestoreScopeFingerprintTest.php`, `tests/Unit/Support/RestoreSafety/RestoreSafetyAssessmentTest.php`, and `tests/Unit/Support/RestoreSafety/RestoreResultAttentionTest.php`
---
## Phase 2: Foundational (Blocking Shared Wiring)
**Purpose**: Wire the shared restore-safety contract into existing restore models, badges, and Filament resource seams before story-specific behavior changes.
**⚠️ CRITICAL**: No user story work should begin until this phase is complete.
- [X] T005 Extend restore-run basis and snapshot helpers in `app/Models/RestoreRun.php`
- [X] T006 [P] Add centralized integrity and result-attention badge mappings in `app/Support/Badges/Domains/RestorePreviewDecisionBadge.php`, `app/Support/Badges/Domains/RestoreCheckSeverityBadge.php`, and `app/Support/Badges/Domains/RestoreResultStatusBadge.php`
- [X] T007 Thread shared restore-safety page-model inputs through `app/Filament/Resources/RestoreRunResource.php` and `app/Filament/Resources/RestoreRunResource/Pages/ViewRestoreRun.php`
- [X] T008 [P] Add shared helper and badge regression coverage in `tests/Unit/RestoreRunTest.php`, `tests/Unit/Badges/RestoreUiBadgesTest.php`, and `tests/Unit/Badges/RestoreRunBadgesTest.php`
**Checkpoint**: Restore pages can now consume one shared safety contract for wizard, detail, and monitoring surfaces.
---
## Phase 3: User Story 1 - Decide Whether Real Execution Is Responsible (Priority: P1) 🎯 MVP
**Goal**: Make the wizard distinguish current decision evidence, technical startability, and actual safety readiness before real execution is offered calmly.
**Independent Test**: Open the restore wizard, generate or omit checks and preview, and verify the confirm step clearly separates current safe readiness from mere technical startability and warning-suppressed caution.
### Tests for User Story 1
- [X] T009 [P] [US1] Extend confirm-step execution gating coverage for current evidence, missing evidence, and warning suppression in `tests/Feature/RestoreRunWizardExecuteTest.php`
- [X] T010 [P] [US1] Add wizard safety-state rendering coverage for `not_generated`, `current`, `risky`, and `ready_with_caution` scenarios in `tests/Feature/Filament/RestoreSafetyIntegrityWizardTest.php`
### Implementation for User Story 1
- [X] T011 [US1] Compute preview integrity, checks integrity, execution readiness, and safety readiness in `app/Filament/Resources/RestoreRunResource.php`
- [ ] T012 [US1] Enforce current fingerprint, current evidence, and hard-confirm validation before real execution queues in `app/Filament/Resources/RestoreRunResource/Pages/CreateRestoreRun.php`
- [X] T013 [US1] Render checks integrity state and one corrective next step in `resources/views/filament/forms/components/restore-run-checks.blade.php`
- [X] T014 [US1] Render preview basis truth, generated-at context, and calmness suppression in `resources/views/filament/forms/components/restore-run-preview.blade.php`
- [X] T015 [US1] Persist execution-time safety snapshot data for real restore submissions in `app/Models/RestoreRun.php` and `app/Filament/Resources/RestoreRunResource/Pages/CreateRestoreRun.php`
- [X] T016 [US1] Run the focused wizard safety regression pack in `tests/Feature/RestoreRunWizardExecuteTest.php` and `tests/Feature/Filament/RestoreSafetyIntegrityWizardTest.php`
**Checkpoint**: The wizard now answers whether the current scope is responsibly executable without collapsing warnings or missing evidence into a calm ready state.
---
## Phase 4: User Story 2 - Notice Scope Drift Immediately (Priority: P1)
**Goal**: Make prior preview and checks visibly invalid when the selected restore scope changes, instead of silently falling back to a neutral state.
**Independent Test**: Generate preview and checks, change selected items, scope mode, backup set, or group mapping, and verify the wizard shows explicit invalidation with rerun guidance before calm execution is available again.
### Tests for User Story 2
- [X] T017 [P] [US2] Extend scope-drift invalidation coverage for selected items, scope mode, backup set, and group mapping mutations in `tests/Feature/RestoreRiskChecksWizardTest.php`
- [ ] T018 [P] [US2] Add basis-persistence and invalidation-reason coverage for prior preview and checks evidence in `tests/Feature/RestoreRunWizardMetadataTest.php`
- [ ] T019 [P] [US2] Add stale-versus-invalidated start-gate regressions in `tests/Feature/Hardening/RestoreStartGateStaleTest.php` and `tests/Feature/Hardening/RestoreStartGateUnhealthyTest.php`
### Implementation for User Story 2
- [X] T020 [US2] Preserve last-known preview and checks basis plus invalidation reasons when scope-affecting inputs change in `app/Filament/Resources/RestoreRunResource.php`
- [X] T021 [US2] Store comparison-ready scope, preview, and checks basis payloads on draft and persisted restore runs in `app/Models/RestoreRun.php`
- [X] T022 [US2] Render explicit `stale` and `invalidated` guidance instead of silent fallback in `resources/views/filament/forms/components/restore-run-checks.blade.php` and `resources/views/filament/forms/components/restore-run-preview.blade.php`
- [ ] T023 [US2] Run the focused scope-drift regression pack in `tests/Feature/RestoreRiskChecksWizardTest.php`, `tests/Feature/RestoreRunWizardMetadataTest.php`, and `tests/Feature/Hardening/RestoreStartGateStaleTest.php`
**Checkpoint**: Scope changes now invalidate prior safety work visibly and suppress calm execution messaging until the evidence is regenerated.
---
## Phase 5: User Story 3 - Interpret Restore Results Without Overclaiming Recovery (Priority: P2)
**Goal**: Make restore detail tell operators what the run meant, whether follow-up remains, and what to do next before showing raw item diagnostics.
**Independent Test**: Open completed, partial, failed, and completed-with-follow-up restore runs and verify the first visible detail section communicates result truth, follow-up truth, cause family, and one primary next action without implying tenant recovery.
### Tests for User Story 3
- [X] T024 [P] [US3] Add result-attention coverage for completed, partial, failed, and completed-with-follow-up restore runs in `tests/Feature/Filament/RestoreResultAttentionSurfaceTest.php`
- [ ] T025 [P] [US3] Extend restore detail rendering assertions for basis truth and non-calm result messaging in `tests/Feature/Filament/RestorePreviewTest.php`
### Implementation for User Story 3
- [X] T026 [US3] Build the restore result-attention page model from `results`, assignment outcomes, and execution snapshot data in `app/Filament/Resources/RestoreRunResource.php`
- [X] T027 [US3] Show preview-basis and checks-basis truth on the detail surface in `resources/views/filament/infolists/entries/restore-preview.blade.php`
- [X] T028 [US3] Elevate follow-up truth, cause family, and one primary next action above raw item lists in `resources/views/filament/infolists/entries/restore-results.blade.php`
- [ ] T029 [US3] Preserve non-overclaiming restore wording for completed and partial outcomes in `app/Filament/Resources/RestoreRunResource/Pages/ViewRestoreRun.php` and `app/Support/Badges/Domains/RestoreResultStatusBadge.php`
- [X] T030 [US3] Run the focused restore detail regression pack in `tests/Feature/Filament/RestoreResultAttentionSurfaceTest.php` and `tests/Feature/Filament/RestorePreviewTest.php`
**Checkpoint**: Restore detail now communicates execution outcome and open follow-up work without overstating recovery certainty.
---
## Phase 6: User Story 4 - Preserve Restore Truth In Canonical Run Monitoring (Priority: P3)
**Goal**: Keep restore-specific follow-up truth visible or safely reachable from the canonical operation detail page for restore-linked runs.
**Independent Test**: Open restore-linked operation runs from monitoring and restore surfaces and verify restore follow-up truth is visible or reachable within one click, with safe degradation when deeper restore access is unavailable.
### Tests for User Story 4
- [X] T031 [P] [US4] Add restore-linked canonical detail coverage for visible follow-up truth and safe deep-link behavior in `tests/Feature/Operations/RestoreLinkedOperationDetailTest.php`
- [ ] T032 [P] [US4] Extend restore execution sync coverage so canonical monitoring preserves restore continuation context in `tests/Feature/OpsUx/RestoreExecutionOperationRunSyncTest.php`
- [ ] T033 [P] [US4] Extend RBAC-safe degradation coverage for restore-linked operation access and denied restore deep links in `tests/Feature/Filament/RestoreRunUiEnforcementTest.php`
### Implementation for User Story 4
- [X] T034 [US4] Enrich restore-linked `restore.execute` operation detail payloads with restore continuation truth in `app/Filament/Resources/OperationRunResource.php`
- [X] T035 [US4] Render safe restore-detail navigation and entitled degradation states on canonical monitoring pages in `app/Filament/Pages/Operations/TenantlessOperationRunViewer.php`
- [X] T036 [US4] Run the focused canonical continuation regression pack in `tests/Feature/Operations/RestoreLinkedOperationDetailTest.php`, `tests/Feature/OpsUx/RestoreExecutionOperationRunSyncTest.php`, and `tests/Feature/Filament/RestoreRunUiEnforcementTest.php`
**Checkpoint**: Canonical operation detail now preserves restore meaning instead of flattening the run to generic telemetry alone.
---
## Phase 7: Polish & Cross-Cutting Concerns
**Purpose**: Final consistency, formatting, and focused verification across all stories.
- [X] T037 [P] Review and align operator-facing restore safety copy in `app/Filament/Resources/RestoreRunResource.php`, `app/Filament/Resources/RestoreRunResource/Pages/CreateRestoreRun.php`, `resources/views/filament/forms/components/restore-run-checks.blade.php`, `resources/views/filament/forms/components/restore-run-preview.blade.php`, and `resources/views/filament/infolists/entries/restore-results.blade.php`
- [X] T038 [P] Run shared helper and badge verification in `tests/Unit/Support/RestoreSafety/RestoreScopeFingerprintTest.php`, `tests/Unit/Support/RestoreSafety/RestoreSafetyAssessmentTest.php`, `tests/Unit/Support/RestoreSafety/RestoreResultAttentionTest.php`, and `tests/Unit/Badges/RestoreUiBadgesTest.php`
- [X] T039 Run formatting with `vendor/bin/sail bin pint --dirty --format agent` as required by `specs/181-restore-safety-integrity/quickstart.md`
- [X] T040 Run the final focused verification pack from `specs/181-restore-safety-integrity/quickstart.md` against `tests/Feature/RestoreRunWizardExecuteTest.php`, `tests/Feature/RestoreRiskChecksWizardTest.php`, `tests/Feature/Filament/RestoreSafetyIntegrityWizardTest.php`, `tests/Feature/Filament/RestoreResultAttentionSurfaceTest.php`, `tests/Feature/Operations/RestoreLinkedOperationDetailTest.php`, and `tests/Feature/OpsUx/RestoreExecutionOperationRunSyncTest.php`
- [ ] T041 [P] Extend invalidation audit-derivability coverage in `tests/Feature/RestoreAuditLoggingTest.php` and `tests/Feature/RestoreRunWizardMetadataTest.php`
- [ ] T042 [P] Extend restore execution and preview-diff non-regression coverage in `tests/Feature/ExecuteRestoreRunJobTest.php` and `tests/Feature/RestorePreviewDiffWizardTest.php`
- [ ] T043 [P] Run ops-UX constitution and notification guard coverage in `tests/Feature/OpsUx/Constitution/DirectStatusTransitionGuardTest.php`, `tests/Feature/OpsUx/Constitution/JobDbNotificationGuardTest.php`, `tests/Feature/OpsUx/Constitution/LegacyNotificationGuardTest.php`, `tests/Feature/OpsUx/OperationRunSummaryCountsIncrementTest.php`, `tests/Feature/OpsUx/Regression/RestoreRunTerminalNotificationTest.php`, `tests/Feature/OpsUx/NotificationViewRunLinkTest.php`, and `tests/Feature/OpsUx/QueuedToastCopyTest.php`
- [ ] T044 Run the manual validation pass in `specs/181-restore-safety-integrity/quickstart.md` to verify the 15-second and one-click operator success criteria
---
## Dependencies & Execution Order
### Phase Dependencies
- **Setup (Phase 1)**: Starts immediately and establishes the shared restore-safety types.
- **Foundational (Phase 2)**: Depends on Setup and blocks all story work until restore models, badges, and resource seams consume the shared contract.
- **User Story 1 (Phase 3)**: Starts after Foundational and delivers the first operator-safe execution decision surface.
- **User Story 2 (Phase 4)**: Starts after Foundational and should follow User Story 1 closely because it reuses the same wizard safety contract while hardening scope invalidation.
- **User Story 3 (Phase 5)**: Starts after Foundational and depends on the shared execution-snapshot and result-attention contract introduced in earlier phases.
- **User Story 4 (Phase 6)**: Starts after User Story 3 because canonical monitoring reuses restore result-attention truth.
- **Polish (Phase 7)**: Starts after the desired user stories are complete.
### User Story Dependencies
- **US1**: Depends only on Setup and Foundational work.
- **US2**: Depends on Setup and Foundational work and should reuse the wizard safety contract delivered in US1.
- **US3**: Depends on Setup and Foundational work plus the execution-snapshot plumbing from US1.
- **US4**: Depends on Setup and Foundational work plus the restore result-attention contract from US3.
### Within Each User Story
- Tests should be added or updated before the corresponding behavior change is considered complete.
- Shared resource and model wiring should land before Blade rendering tasks for the same story.
- Story-level focused test runs should pass before moving to the next priority slice.
### Parallel Opportunities
- `T002` and `T004` can run in parallel after the core namespace shape from `T001` is agreed.
- `T006` and `T008` can run in parallel after `T005` defines the shared restore-run basis helpers.
- `T009` and `T010` can run in parallel for US1.
- `T017`, `T018`, and `T019` can run in parallel for US2.
- `T024` and `T025` can run in parallel for US3.
- `T031`, `T032`, and `T033` can run in parallel for US4.
- `T037` and `T038` can run in parallel once feature code is stable.
- `T041`, `T042`, and `T043` can run in parallel during final verification.
---
## Parallel Example: User Story 1
```bash
# Story 1 tests in parallel:
Task: T009 tests/Feature/RestoreRunWizardExecuteTest.php
Task: T010 tests/Feature/Filament/RestoreSafetyIntegrityWizardTest.php
# Story 1 implementation split after expectations are locked:
Task: T011 app/Filament/Resources/RestoreRunResource.php
Task: T014 resources/views/filament/forms/components/restore-run-preview.blade.php
```
## Parallel Example: User Story 2
```bash
# Story 2 regressions in parallel:
Task: T017 tests/Feature/RestoreRiskChecksWizardTest.php
Task: T018 tests/Feature/RestoreRunWizardMetadataTest.php
Task: T019 tests/Feature/Hardening/RestoreStartGateStaleTest.php
# Story 2 implementation split after invalidation rules are fixed:
Task: T020 app/Filament/Resources/RestoreRunResource.php
Task: T022 resources/views/filament/forms/components/restore-run-checks.blade.php
```
## Parallel Example: User Story 3
```bash
# Story 3 tests in parallel:
Task: T024 tests/Feature/Filament/RestoreResultAttentionSurfaceTest.php
Task: T025 tests/Feature/Filament/RestorePreviewTest.php
# Story 3 implementation split after attention-model assertions are clear:
Task: T026 app/Filament/Resources/RestoreRunResource.php
Task: T028 resources/views/filament/infolists/entries/restore-results.blade.php
```
## Parallel Example: User Story 4
```bash
# Story 4 tests in parallel:
Task: T031 tests/Feature/Operations/RestoreLinkedOperationDetailTest.php
Task: T032 tests/Feature/OpsUx/RestoreExecutionOperationRunSyncTest.php
Task: T033 tests/Feature/Filament/RestoreRunUiEnforcementTest.php
# Story 4 implementation split after restore-continuation expectations are set:
Task: T034 app/Filament/Resources/OperationRunResource.php
Task: T035 app/Filament/Pages/Operations/TenantlessOperationRunViewer.php
```
---
## Implementation Strategy
### MVP First
- Complete Phase 1 and Phase 2.
- Deliver User Story 1 and User Story 2 as the minimum safe restore-decision slice.
- Validate that the wizard now distinguishes current evidence, invalidated evidence, and warning-suppressed caution before real execution is offered calmly.
### Incremental Delivery
- Add User Story 3 next to harden restore detail truth and follow-up guidance.
- Add User Story 4 last to preserve restore meaning on canonical monitoring without duplicating persistence.
### Verification Finish
- Run Pint on touched files.
- Run the focused restore safety pack from `quickstart.md`.
- Run the manual quickstart validation pass for the 15-second and one-click operator outcomes.
- Offer the broader suite only after the focused pack passes.

View File

@ -0,0 +1,40 @@
<?php
declare(strict_types=1);
use App\Filament\Pages\TenantDashboard;
use App\Filament\Resources\BackupSetResource;
use App\Models\Tenant;
use App\Models\User;
use App\Models\Workspace;
use App\Support\Workspaces\WorkspaceContext;
it('logs into the seeded backup-health browser fixture through the local helper', function (): void {
$this->artisan('tenantpilot:backup-health:seed-browser-fixture', ['--no-interaction' => true])
->assertSuccessful();
$workspaceConfig = config('tenantpilot.backup_health.browser_smoke_fixture.workspace');
$userConfig = config('tenantpilot.backup_health.browser_smoke_fixture.user');
$scenarioConfig = config('tenantpilot.backup_health.browser_smoke_fixture.blocked_drillthrough');
$tenantRouteKey = $scenarioConfig['tenant_id'] ?? $scenarioConfig['tenant_external_id'];
$workspace = Workspace::query()->where('slug', $workspaceConfig['slug'])->first();
$user = User::query()->where('email', $userConfig['email'])->first();
$tenant = Tenant::query()->where('external_id', $tenantRouteKey)->first();
expect($workspace)->not->toBeNull();
expect($user)->not->toBeNull();
expect($tenant)->not->toBeNull();
$this->get(route('admin.local.backup-health-browser-fixture-login'))
->assertRedirect(TenantDashboard::getUrl(tenant: $tenant));
$this->assertAuthenticatedAs($user);
expect(session(WorkspaceContext::SESSION_KEY))->toBe((int) $workspace->getKey());
$this->get(TenantDashboard::getUrl(tenant: $tenant))
->assertOk();
$this->get(BackupSetResource::getUrl('index', tenant: $tenant))
->assertForbidden();
});

View File

@ -1,5 +1,6 @@
<?php <?php
use App\Filament\Resources\BackupScheduleResource;
use App\Filament\Resources\BackupScheduleResource\Pages\EditBackupSchedule; use App\Filament\Resources\BackupScheduleResource\Pages\EditBackupSchedule;
use App\Filament\Resources\BackupScheduleResource\Pages\ListBackupSchedules; use App\Filament\Resources\BackupScheduleResource\Pages\ListBackupSchedules;
use App\Jobs\ApplyBackupScheduleRetentionJob; use App\Jobs\ApplyBackupScheduleRetentionJob;
@ -7,6 +8,7 @@
use App\Models\BackupSet; use App\Models\BackupSet;
use App\Models\OperationRun; use App\Models\OperationRun;
use App\Models\WorkspaceSetting; use App\Models\WorkspaceSetting;
use App\Support\BackupHealth\TenantBackupHealthAssessment;
use Filament\Facades\Filament; use Filament\Facades\Filament;
use Filament\Tables\Filters\TrashedFilter; use Filament\Tables\Filters\TrashedFilter;
use Illuminate\Auth\Access\AuthorizationException; use Illuminate\Auth\Access\AuthorizationException;
@ -341,3 +343,25 @@ function makeBackupScheduleForLifecycle(\App\Models\Tenant $tenant, array $attri
expect($keptIds)->toHaveCount(3); expect($keptIds)->toHaveCount(3);
}); });
it('confirms schedule follow-up continuity on the backup-schedules list surface', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'owner');
$this->actingAs($user);
$schedule = makeBackupScheduleForLifecycle($tenant, [
'name' => 'Overdue continuity schedule',
'last_run_at' => null,
'last_run_status' => null,
'next_run_at' => now()->subHours(2),
]);
Filament::setTenant($tenant, true);
$this->get(BackupScheduleResource::getUrl('index', [
'backup_health_reason' => TenantBackupHealthAssessment::REASON_SCHEDULE_FOLLOW_UP,
], panel: 'tenant', tenant: $tenant))
->assertOk()
->assertSee('not produced a successful run yet')
->assertSee($schedule->name)
->assertSee('No successful run has been recorded yet.');
});

View File

@ -0,0 +1,74 @@
<?php
declare(strict_types=1);
use App\Filament\Pages\TenantDashboard;
use App\Filament\Resources\BackupSetResource;
use App\Filament\Widgets\Dashboard\DashboardKpis;
use App\Filament\Widgets\Dashboard\NeedsAttention;
use App\Models\BackupItem;
use App\Models\BackupSet;
use App\Models\Tenant;
use App\Models\User;
use App\Models\Workspace;
use App\Services\Auth\CapabilityResolver;
use App\Support\Auth\Capabilities;
use App\Support\Rbac\UiTooltips;
use App\Support\Workspaces\WorkspaceContext;
use Filament\Facades\Filament;
use Livewire\Livewire;
it('seeds a deterministic blocked drill-through browser fixture for backup health', function (): void {
$this->artisan('tenantpilot:backup-health:seed-browser-fixture', ['--no-interaction' => true])
->assertSuccessful()
->expectsOutputToContain('Fixture login URL')
->expectsOutputToContain('Dashboard URL');
$workspaceConfig = config('tenantpilot.backup_health.browser_smoke_fixture.workspace');
$userConfig = config('tenantpilot.backup_health.browser_smoke_fixture.user');
$scenarioConfig = config('tenantpilot.backup_health.browser_smoke_fixture.blocked_drillthrough');
$tenantRouteKey = $scenarioConfig['tenant_id'] ?? $scenarioConfig['tenant_external_id'];
$workspace = Workspace::query()->where('slug', $workspaceConfig['slug'])->first();
$user = User::query()->where('email', $userConfig['email'])->first();
$tenant = Tenant::query()->where('external_id', $tenantRouteKey)->first();
expect($workspace)->not->toBeNull();
expect($user)->not->toBeNull();
expect($tenant)->not->toBeNull();
expect(BackupSet::query()->where('tenant_id', $tenant->getKey())->where('name', $scenarioConfig['backup_set_name'])->exists())->toBeTrue();
expect(BackupItem::query()->where('tenant_id', $tenant->getKey())->where('policy_identifier', $scenarioConfig['policy_external_id'])->exists())->toBeTrue();
$resolver = app(CapabilityResolver::class);
$resolver->clearCache();
expect($resolver->isMember($user, $tenant))->toBeTrue();
expect($resolver->can($user, $tenant, Capabilities::TENANT_VIEW))->toBeFalse();
$this->actingAs($user)->withSession([
WorkspaceContext::SESSION_KEY => (int) $workspace->getKey(),
]);
session()->put(WorkspaceContext::SESSION_KEY, (int) $workspace->getKey());
$this->get(route('filament.admin.pages.choose-tenant'))
->assertOk()
->assertSee((string) $tenant->name);
$this->get(TenantDashboard::getUrl(tenant: $tenant))
->assertOk();
Filament::setCurrentPanel(Filament::getPanel('tenant'));
Filament::setTenant($tenant, true);
Livewire::test(DashboardKpis::class)
->assertSee('Backup posture')
->assertSee('Stale');
Livewire::test(NeedsAttention::class)
->assertSee('Latest backup is stale')
->assertSee('Open latest backup')
->assertSee(UiTooltips::INSUFFICIENT_PERMISSION);
$this->get(BackupSetResource::getUrl('index', tenant: $tenant))
->assertForbidden();
});

View File

@ -190,3 +190,45 @@ function backupItemsRelationManagerComponent(BackupSet $backupSet)
->assertSet('tableSort', 'policy.display_name:desc') ->assertSet('tableSort', 'policy.display_name:desc')
->assertSet('tableFilters.policy_type.value', 'intuneRoleDefinition'); ->assertSet('tableFilters.policy_type.value', 'intuneRoleDefinition');
}); });
it('shows snapshot mode and backup quality hints for backup items', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'owner');
$this->actingAs($user);
$tenant->makeCurrent();
Filament::setTenant($tenant, true);
$backupSet = BackupSet::factory()->create([
'tenant_id' => (int) $tenant->getKey(),
]);
$policy = Policy::factory()->create([
'tenant_id' => (int) $tenant->getKey(),
'external_id' => 'quality-policy',
'policy_type' => 'settingsCatalogPolicy',
'display_name' => 'Quality Policy',
'platform' => 'windows',
]);
BackupItem::factory()->for($backupSet)->for($tenant)->create([
'policy_id' => (int) $policy->getKey(),
'policy_identifier' => 'quality-policy',
'policy_type' => 'settingsCatalogPolicy',
'platform' => 'windows',
'payload' => [],
'metadata' => [
'displayName' => 'Quality Policy',
'source' => 'metadata_only',
'assignment_capture_reason' => 'separate_role_assignments',
'has_orphaned_assignments' => true,
],
'assignments' => [],
]);
backupItemsRelationManagerComponent($backupSet)
->assertSee('Snapshot')
->assertSee('Backup quality')
->assertSee('Metadata only')
->assertSee('Assignments captured separately')
->assertSee('Orphaned assignments');
});

View File

@ -0,0 +1,65 @@
<?php
declare(strict_types=1);
use App\Filament\Resources\BackupSetResource;
use App\Models\BackupItem;
use App\Models\BackupSet;
use Filament\Facades\Filament;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
it('shows lifecycle status separately from backup quality on the backup-set list', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'owner');
$this->actingAs($user);
Filament::setTenant($tenant, true);
$fullSet = BackupSet::factory()->for($tenant)->create([
'name' => 'Full quality set',
'status' => 'completed',
'item_count' => 1,
]);
BackupItem::factory()->for($tenant)->for($fullSet)->create([
'payload' => ['id' => 'policy-full'],
'metadata' => [],
'assignments' => [],
]);
$degradedSet = BackupSet::factory()->for($tenant)->create([
'name' => 'Degraded quality set',
'status' => 'completed',
'item_count' => 2,
]);
BackupItem::factory()->for($tenant)->for($degradedSet)->create([
'payload' => [],
'metadata' => [
'source' => 'metadata_only',
'assignments_fetch_failed' => true,
],
'assignments' => [],
]);
BackupItem::factory()->for($tenant)->for($degradedSet)->create([
'payload' => ['id' => 'policy-warning'],
'metadata' => [
'has_orphaned_assignments' => true,
'integrity_warning' => 'Protected values are intentionally hidden.',
],
'assignments' => [],
]);
$this->get(BackupSetResource::getUrl('index', tenant: $tenant))
->assertOk()
->assertSee('Backup quality')
->assertSee('Full quality set')
->assertSee('No degradations detected across 1 item')
->assertSee('Degraded quality set')
->assertSee('2 degraded items')
->assertSee('1 metadata-only')
->assertSee('1 assignment issue')
->assertSee('Completed');
});

View File

@ -3,10 +3,17 @@
declare(strict_types=1); declare(strict_types=1);
use App\Filament\Resources\BackupSetResource; use App\Filament\Resources\BackupSetResource;
use App\Models\BackupItem;
use App\Models\BackupSet; use App\Models\BackupSet;
use App\Models\OperationRun; use App\Models\OperationRun;
use App\Support\BackupHealth\TenantBackupHealthAssessment;
use Carbon\CarbonImmutable;
use Filament\Facades\Filament; use Filament\Facades\Filament;
afterEach(function (): void {
CarbonImmutable::setTestNow();
});
it('renders backup sets with lifecycle summary, related context, and secondary technical detail', function (): void { it('renders backup sets with lifecycle summary, related context, and secondary technical detail', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'owner'); [$user, $tenant] = createUserWithTenant(role: 'owner');
$this->actingAs($user); $this->actingAs($user);
@ -24,6 +31,16 @@
], ],
]); ]);
\App\Models\BackupItem::factory()->for($backupSet)->for($tenant)->create([
'payload' => [],
'metadata' => [
'source' => 'metadata_only',
'assignments_fetch_failed' => true,
'integrity_warning' => 'Protected values are intentionally hidden.',
],
'assignments' => [],
]);
$run = OperationRun::factory()->for($tenant)->create([ $run = OperationRun::factory()->for($tenant)->create([
'workspace_id' => (int) $tenant->workspace_id, 'workspace_id' => (int) $tenant->workspace_id,
'type' => 'backup_set.add_policies', 'type' => 'backup_set.add_policies',
@ -34,14 +51,18 @@
$this->get(BackupSetResource::getUrl('view', ['record' => $backupSet], tenant: $tenant)) $this->get(BackupSetResource::getUrl('view', ['record' => $backupSet], tenant: $tenant))
->assertOk() ->assertOk()
->assertSee('Recovery readiness') ->assertSee('Backup quality')
->assertSee('1 degraded item')
->assertSee('1 metadata-only')
->assertSee('1 assignment issue')
->assertSee('1 integrity warning')
->assertSee('Timing') ->assertSee('Timing')
->assertSee('Archive') ->assertSee('Archive')
->assertSee('More') ->assertSee('More')
->assertSee('/admin/operations/'.$run->getKey(), false) ->assertSee('/admin/operations/'.$run->getKey(), false)
->assertDontSee('Related record') ->assertDontSee('Related record')
->assertDontSee('>Completed</span>', false) ->assertDontSee('>Completed</span>', false)
->assertSeeInOrder(['Nightly backup', 'Lifecycle overview', 'Related context', 'Technical detail']); ->assertSeeInOrder(['Nightly backup', 'Backup quality', 'Lifecycle overview', 'Related context', 'Technical detail']);
}); });
it('keeps operations context and technical empty states readable for sparse backup sets', function (): void { it('keeps operations context and technical empty states readable for sparse backup sets', function (): void {
@ -53,13 +74,109 @@
$backupSet = BackupSet::factory()->create([ $backupSet = BackupSet::factory()->create([
'tenant_id' => (int) $tenant->getKey(), 'tenant_id' => (int) $tenant->getKey(),
'name' => 'Sparse backup', 'name' => 'Sparse backup',
'item_count' => 0,
'metadata' => [], 'metadata' => [],
]); ]);
$this->get(BackupSetResource::getUrl('view', ['record' => $backupSet], tenant: $tenant)) $this->get(BackupSetResource::getUrl('view', ['record' => $backupSet], tenant: $tenant))
->assertOk() ->assertOk()
->assertSee('Operations') ->assertSee('No items captured')
->assertSee('No backup metadata was recorded for this backup set.') ->assertSee('No backup metadata was recorded for this backup set.')
->assertSee('Metadata keys') ->assertSee('Metadata keys')
->assertDontSee('Related record'); ->assertDontSee('Related record');
}); });
it('keeps backup quality counts visible for archived backup sets', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'owner');
$this->actingAs($user);
Filament::setTenant($tenant, true);
$backupSet = BackupSet::factory()->create([
'tenant_id' => (int) $tenant->getKey(),
'name' => 'Archived backup',
'item_count' => 1,
]);
$item = \App\Models\BackupItem::factory()->for($backupSet)->for($tenant)->create([
'payload' => [],
'metadata' => [
'source' => 'metadata_only',
],
'assignments' => [],
]);
$item->delete();
$backupSet->delete();
$this->get(BackupSetResource::getUrl('view', ['record' => $backupSet], tenant: $tenant))
->assertOk()
->assertSee('Archived')
->assertSee('1 degraded item')
->assertSee('1 metadata-only');
});
it('confirms stale latest-backup continuity on the enterprise detail page', function (): void {
CarbonImmutable::setTestNow(CarbonImmutable::create(2026, 4, 7, 12, 0, 0, 'UTC'));
[$user, $tenant] = createUserWithTenant(role: 'owner', ensureDefaultMicrosoftProviderConnection: false);
$this->actingAs($user);
Filament::setTenant($tenant, true);
$backupSet = BackupSet::factory()->create([
'tenant_id' => (int) $tenant->getKey(),
'name' => 'Stale dashboard backup',
'item_count' => 1,
'completed_at' => now()->subDays(2),
]);
BackupItem::factory()->for($backupSet)->for($tenant)->create([
'payload' => ['id' => 'policy-stale'],
'metadata' => [],
'assignments' => [],
]);
$this->get(BackupSetResource::getUrl('view', [
'record' => $backupSet,
'backup_health_reason' => TenantBackupHealthAssessment::REASON_LATEST_BACKUP_STALE,
], panel: 'tenant', tenant: $tenant))
->assertOk()
->assertSee('Backup posture')
->assertSee('Latest backup is stale')
->assertSee('The latest completed backup was 2 days ago.');
});
it('confirms degraded latest-backup continuity on the enterprise detail page', function (): void {
CarbonImmutable::setTestNow(CarbonImmutable::create(2026, 4, 7, 12, 0, 0, 'UTC'));
[$user, $tenant] = createUserWithTenant(role: 'owner', ensureDefaultMicrosoftProviderConnection: false);
$this->actingAs($user);
Filament::setTenant($tenant, true);
$backupSet = BackupSet::factory()->create([
'tenant_id' => (int) $tenant->getKey(),
'name' => 'Degraded dashboard backup',
'item_count' => 1,
'completed_at' => now()->subMinutes(45),
]);
BackupItem::factory()->for($backupSet)->for($tenant)->create([
'payload' => [],
'metadata' => [
'source' => 'metadata_only',
'assignments_fetch_failed' => true,
],
'assignments' => [],
]);
$this->get(BackupSetResource::getUrl('view', [
'record' => $backupSet,
'backup_health_reason' => TenantBackupHealthAssessment::REASON_LATEST_BACKUP_DEGRADED,
], panel: 'tenant', tenant: $tenant))
->assertOk()
->assertSee('Backup posture')
->assertSee('Latest backup is degraded')
->assertSee('degraded input quality');
});

View File

@ -0,0 +1,37 @@
<?php
declare(strict_types=1);
use App\Filament\Resources\BackupSetResource;
use App\Support\BackupHealth\TenantBackupHealthAssessment;
use Filament\Facades\Filament;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
it('confirms no usable completed backup basis on the backup-set list surface', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'owner', ensureDefaultMicrosoftProviderConnection: false);
$this->actingAs($user);
Filament::setTenant($tenant, true);
$this->get(BackupSetResource::getUrl('index', [
'backup_health_reason' => TenantBackupHealthAssessment::REASON_NO_BACKUP_BASIS,
], panel: 'tenant', tenant: $tenant))
->assertOk()
->assertSee('No usable completed backup basis is currently available for this tenant.')
->assertSee('No backup sets');
});
it('keeps fallback continuity copy readable on the backup-set list when the latest backup detail is unavailable', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'owner', ensureDefaultMicrosoftProviderConnection: false);
$this->actingAs($user);
Filament::setTenant($tenant, true);
$this->get(BackupSetResource::getUrl('index', [
'backup_health_reason' => TenantBackupHealthAssessment::REASON_LATEST_BACKUP_STALE,
], panel: 'tenant', tenant: $tenant))
->assertOk()
->assertSee('The latest backup detail is no longer available, so this view stays on the backup-set list.');
});

View File

@ -112,3 +112,32 @@ function getTableEmptyStateAction($component, string $name): ?\Filament\Actions\
expect($action->isDisabled())->toBeTrue(); expect($action->isDisabled())->toBeTrue();
expect($action->getTooltip())->toBe(UiTooltips::insufficientPermission()); expect($action->getTooltip())->toBe(UiTooltips::insufficientPermission());
}); });
test('readonly members still see backup quality truth on the backup-set list', function () {
$tenant = Tenant::factory()->create();
[$user] = createUserWithTenant($tenant, role: 'readonly');
$backupSet = BackupSet::factory()->create([
'tenant_id' => $tenant->getKey(),
'status' => 'completed',
'item_count' => 1,
]);
\App\Models\BackupItem::factory()->create([
'tenant_id' => $tenant->getKey(),
'backup_set_id' => $backupSet->getKey(),
'payload' => [],
'metadata' => [
'source' => 'metadata_only',
],
'assignments' => [],
]);
Filament::setTenant($tenant, true);
$this->actingAs($user)
->get(BackupSetResource::getUrl('index', tenant: $tenant))
->assertOk()
->assertSee('Backup quality')
->assertSee('1 metadata-only');
});

View File

@ -2,15 +2,23 @@
declare(strict_types=1); declare(strict_types=1);
use App\Filament\Resources\BackupScheduleResource;
use App\Filament\Resources\BackupSetResource;
use App\Filament\Resources\FindingResource; use App\Filament\Resources\FindingResource;
use App\Filament\Widgets\Dashboard\DashboardKpis; use App\Filament\Widgets\Dashboard\DashboardKpis;
use App\Filament\Widgets\Dashboard\NeedsAttention;
use App\Models\BackupItem;
use App\Models\BackupSchedule;
use App\Models\BackupSet;
use App\Models\Finding; use App\Models\Finding;
use App\Models\OperationRun; use App\Models\OperationRun;
use App\Support\Auth\Capabilities; use App\Support\Auth\Capabilities;
use App\Support\BackupHealth\TenantBackupHealthAssessment;
use App\Support\OperationRunLinks; use App\Support\OperationRunLinks;
use App\Support\OperationRunOutcome; use App\Support\OperationRunOutcome;
use App\Support\OperationRunStatus; use App\Support\OperationRunStatus;
use App\Support\Rbac\UiTooltips; use App\Support\Rbac\UiTooltips;
use Carbon\CarbonImmutable;
use Filament\Facades\Filament; use Filament\Facades\Filament;
use Filament\Widgets\StatsOverviewWidget\Stat; use Filament\Widgets\StatsOverviewWidget\Stat;
use Illuminate\Support\Facades\Gate; use Illuminate\Support\Facades\Gate;
@ -35,6 +43,38 @@ function dashboardKpiStatPayloads($component): array
->all(); ->all();
} }
/**
* @return array{value:string,description:string|null,url:string|null}
*/
function backupPostureStatPayload(\App\Models\Tenant $tenant): array
{
Filament::setCurrentPanel(Filament::getPanel('tenant'));
Filament::setTenant($tenant, true);
return dashboardKpiStatPayloads(Livewire::test(DashboardKpis::class))['Backup posture'];
}
function makeBackupHealthScheduleForKpi(\App\Models\Tenant $tenant, array $attributes = []): BackupSchedule
{
return BackupSchedule::query()->create(array_merge([
'tenant_id' => (int) $tenant->getKey(),
'name' => 'KPI schedule',
'is_enabled' => true,
'timezone' => 'UTC',
'frequency' => 'daily',
'time_of_day' => '01:00:00',
'days_of_week' => null,
'policy_types' => ['deviceConfiguration'],
'include_foundations' => true,
'retention_keep_last' => 30,
'next_run_at' => now()->addHour(),
], $attributes));
}
afterEach(function (): void {
CarbonImmutable::setTestNow();
});
it('aligns dashboard KPI counts and drill-throughs to canonical findings and operations semantics', function (): void { it('aligns dashboard KPI counts and drill-throughs to canonical findings and operations semantics', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'owner'); [$user, $tenant] = createUserWithTenant(role: 'owner');
$this->actingAs($user); $this->actingAs($user);
@ -180,3 +220,188 @@ function dashboardKpiStatPayloads($component): array
'url' => null, 'url' => null,
]); ]);
}); });
it('shows absent backup posture and routes the KPI to the backup-set list', function (): void {
[$user, $tenant] = createUserWithTenant(ensureDefaultMicrosoftProviderConnection: false);
$this->actingAs($user);
$stat = backupPostureStatPayload($tenant);
expect($stat)->toMatchArray([
'value' => 'Absent',
'url' => BackupSetResource::getUrl('index', [
'backup_health_reason' => TenantBackupHealthAssessment::REASON_NO_BACKUP_BASIS,
], panel: 'tenant', tenant: $tenant),
]);
expect($stat['description'])->toContain('Create or finish a backup set');
});
it('shows stale backup posture and routes the KPI to the latest backup detail', function (): void {
CarbonImmutable::setTestNow(CarbonImmutable::create(2026, 4, 7, 12, 0, 0, 'UTC'));
[$user, $tenant] = createUserWithTenant(ensureDefaultMicrosoftProviderConnection: false);
$this->actingAs($user);
$backupSet = BackupSet::factory()->for($tenant)->create([
'name' => 'Stale latest backup',
'item_count' => 1,
'completed_at' => now()->subDays(2),
]);
BackupItem::factory()->for($tenant)->for($backupSet)->create([
'payload' => ['id' => 'policy-stale'],
'metadata' => [],
'assignments' => [],
]);
$stat = backupPostureStatPayload($tenant);
expect($stat)->toMatchArray([
'value' => 'Stale',
'url' => BackupSetResource::getUrl('view', [
'record' => (int) $backupSet->getKey(),
'backup_health_reason' => TenantBackupHealthAssessment::REASON_LATEST_BACKUP_STALE,
], panel: 'tenant', tenant: $tenant),
]);
expect($stat['description'])->toContain('2 days');
});
it('shows degraded backup posture and routes the KPI to the latest backup detail', function (): void {
CarbonImmutable::setTestNow(CarbonImmutable::create(2026, 4, 7, 12, 0, 0, 'UTC'));
[$user, $tenant] = createUserWithTenant(ensureDefaultMicrosoftProviderConnection: false);
$this->actingAs($user);
$backupSet = BackupSet::factory()->for($tenant)->create([
'name' => 'Degraded latest backup',
'item_count' => 1,
'completed_at' => now()->subMinutes(45),
]);
BackupItem::factory()->for($tenant)->for($backupSet)->create([
'payload' => [],
'metadata' => [
'source' => 'metadata_only',
'assignments_fetch_failed' => true,
],
'assignments' => [],
]);
$stat = backupPostureStatPayload($tenant);
expect($stat)->toMatchArray([
'value' => 'Degraded',
'url' => BackupSetResource::getUrl('view', [
'record' => (int) $backupSet->getKey(),
'backup_health_reason' => TenantBackupHealthAssessment::REASON_LATEST_BACKUP_DEGRADED,
], panel: 'tenant', tenant: $tenant),
]);
expect($stat['description'])->toContain('degraded input quality');
});
it('shows healthy backup posture when the latest backup is recent and clean', function (): void {
CarbonImmutable::setTestNow(CarbonImmutable::create(2026, 4, 7, 12, 0, 0, 'UTC'));
[$user, $tenant] = createUserWithTenant(ensureDefaultMicrosoftProviderConnection: false);
$this->actingAs($user);
$backupSet = BackupSet::factory()->for($tenant)->create([
'name' => 'Healthy latest backup',
'item_count' => 1,
'completed_at' => now()->subMinutes(20),
]);
BackupItem::factory()->for($tenant)->for($backupSet)->create([
'payload' => ['id' => 'healthy-policy'],
'metadata' => [],
'assignments' => [],
]);
$stat = backupPostureStatPayload($tenant);
expect($stat)->toMatchArray([
'value' => 'Healthy',
'url' => null,
]);
expect($stat['description'])->toContain('20 minutes');
});
it('keeps the posture healthy but routes the KPI to schedules when backup automation needs follow-up', function (): void {
CarbonImmutable::setTestNow(CarbonImmutable::create(2026, 4, 7, 12, 0, 0, 'UTC'));
[$user, $tenant] = createUserWithTenant(ensureDefaultMicrosoftProviderConnection: false);
$this->actingAs($user);
$backupSet = BackupSet::factory()->for($tenant)->create([
'name' => 'Healthy latest backup',
'item_count' => 1,
'completed_at' => now()->subMinutes(15),
]);
BackupItem::factory()->for($tenant)->for($backupSet)->create([
'payload' => ['id' => 'healthy-policy'],
'metadata' => [],
'assignments' => [],
]);
makeBackupHealthScheduleForKpi($tenant, [
'name' => 'Overdue KPI schedule',
'last_run_at' => null,
'last_run_status' => null,
'next_run_at' => now()->subHours(2),
]);
$stat = backupPostureStatPayload($tenant);
expect($stat)->toMatchArray([
'value' => 'Healthy',
'url' => BackupScheduleResource::getUrl('index', [
'backup_health_reason' => TenantBackupHealthAssessment::REASON_SCHEDULE_FOLLOW_UP,
], panel: 'tenant', tenant: $tenant),
]);
expect($stat['description'])->toContain('not produced a successful run');
});
it('keeps backup posture truth visible while disabling backup drill-throughs for members without backup view access', function (): void {
CarbonImmutable::setTestNow(CarbonImmutable::create(2026, 4, 7, 12, 0, 0, 'UTC'));
[$user, $tenant] = createUserWithTenant(ensureDefaultMicrosoftProviderConnection: false);
$this->actingAs($user);
$backupSet = BackupSet::factory()->for($tenant)->create([
'name' => 'Stale inaccessible backup',
'item_count' => 1,
'completed_at' => now()->subDays(2),
]);
BackupItem::factory()->for($tenant)->for($backupSet)->create([
'payload' => ['id' => 'policy-stale'],
'metadata' => [],
'assignments' => [],
]);
Gate::define(Capabilities::TENANT_VIEW, fn (): bool => false);
$stat = backupPostureStatPayload($tenant);
expect($stat)->toMatchArray([
'value' => 'Stale',
'url' => null,
]);
expect($stat['description'])
->toContain('2 days')
->toContain(UiTooltips::INSUFFICIENT_PERMISSION);
Filament::setCurrentPanel(Filament::getPanel('tenant'));
Filament::setTenant($tenant, true);
Livewire::test(NeedsAttention::class)
->assertSee('Latest backup is stale')
->assertSee(UiTooltips::INSUFFICIENT_PERMISSION);
});

View File

@ -2,8 +2,13 @@
declare(strict_types=1); declare(strict_types=1);
use App\Filament\Resources\BackupScheduleResource;
use App\Filament\Resources\BackupSetResource;
use App\Filament\Resources\FindingResource; use App\Filament\Resources\FindingResource;
use App\Filament\Widgets\Dashboard\NeedsAttention; use App\Filament\Widgets\Dashboard\NeedsAttention;
use App\Models\BackupItem;
use App\Models\BackupSchedule;
use App\Models\BackupSet;
use App\Models\BaselineProfile; use App\Models\BaselineProfile;
use App\Models\BaselineSnapshot; use App\Models\BaselineSnapshot;
use App\Models\BaselineTenantAssignment; use App\Models\BaselineTenantAssignment;
@ -11,11 +16,13 @@
use App\Models\FindingException; use App\Models\FindingException;
use App\Models\OperationRun; use App\Models\OperationRun;
use App\Support\Auth\Capabilities; use App\Support\Auth\Capabilities;
use App\Support\BackupHealth\TenantBackupHealthAssessment;
use App\Support\Baselines\BaselineCompareReasonCode; use App\Support\Baselines\BaselineCompareReasonCode;
use App\Support\OperationRunOutcome; use App\Support\OperationRunOutcome;
use App\Support\OperationRunStatus; use App\Support\OperationRunStatus;
use App\Support\OperationRunType; use App\Support\OperationRunType;
use App\Support\Rbac\UiTooltips; use App\Support\Rbac\UiTooltips;
use Carbon\CarbonImmutable;
use Filament\Facades\Filament; use Filament\Facades\Filament;
use Illuminate\Support\Facades\Gate; use Illuminate\Support\Facades\Gate;
use Livewire\Livewire; use Livewire\Livewire;
@ -44,6 +51,27 @@ function createNeedsAttentionTenant(): array
return [$user, $tenant, $profile, $snapshot]; return [$user, $tenant, $profile, $snapshot];
} }
function makeBackupHealthScheduleForNeedsAttention(\App\Models\Tenant $tenant, array $attributes = []): BackupSchedule
{
return BackupSchedule::query()->create(array_merge([
'tenant_id' => (int) $tenant->getKey(),
'name' => 'Needs Attention backup schedule',
'is_enabled' => true,
'timezone' => 'UTC',
'frequency' => 'daily',
'time_of_day' => '01:00:00',
'days_of_week' => null,
'policy_types' => ['deviceConfiguration'],
'include_foundations' => true,
'retention_keep_last' => 30,
'next_run_at' => now()->addHour(),
], $attributes));
}
afterEach(function (): void {
CarbonImmutable::setTestNow();
});
it('shows a cautionary baseline posture in needs-attention when compare trust is limited', function (): void { it('shows a cautionary baseline posture in needs-attention when compare trust is limited', function (): void {
[$user, $tenant, $profile, $snapshot] = createNeedsAttentionTenant(); [$user, $tenant, $profile, $snapshot] = createNeedsAttentionTenant();
$this->actingAs($user); $this->actingAs($user);
@ -93,6 +121,18 @@ function createNeedsAttentionTenant(): array
[$user, $tenant, $profile, $snapshot] = createNeedsAttentionTenant(); [$user, $tenant, $profile, $snapshot] = createNeedsAttentionTenant();
$this->actingAs($user); $this->actingAs($user);
$healthyBackup = BackupSet::factory()->for($tenant)->create([
'name' => 'Healthy compare backup',
'item_count' => 1,
'completed_at' => now()->subMinutes(20),
]);
BackupItem::factory()->for($tenant)->for($healthyBackup)->create([
'payload' => ['id' => 'healthy-policy'],
'metadata' => [],
'assignments' => [],
]);
OperationRun::factory()->create([ OperationRun::factory()->create([
'tenant_id' => (int) $tenant->getKey(), 'tenant_id' => (int) $tenant->getKey(),
'workspace_id' => (int) $tenant->workspace_id, 'workspace_id' => (int) $tenant->workspace_id,
@ -316,3 +356,211 @@ function createNeedsAttentionTenant(): array
->assertSee('Open terminal follow-up') ->assertSee('Open terminal follow-up')
->assertDontSee('Current governance and findings signals look trustworthy.'); ->assertDontSee('Current governance and findings signals look trustworthy.');
}); });
it('surfaces a no-backup attention item with a backup-sets destination', function (): void {
[$user, $tenant] = createNeedsAttentionTenant();
$this->actingAs($user);
Filament::setCurrentPanel(Filament::getPanel('tenant'));
Filament::setTenant($tenant, true);
$component = Livewire::test(NeedsAttention::class)
->assertSee('No usable backup basis')
->assertSee('Create or finish a backup set before relying on restore input.')
->assertSee('Open backup sets')
->assertDontSee('Backups are recent and healthy');
expect($component->html())->toContain(BackupSetResource::getUrl('index', [
'backup_health_reason' => TenantBackupHealthAssessment::REASON_NO_BACKUP_BASIS,
], panel: 'tenant', tenant: $tenant));
});
it('surfaces stale latest-backup attention with the matching latest-backup drill-through', function (): void {
CarbonImmutable::setTestNow(CarbonImmutable::create(2026, 4, 7, 12, 0, 0, 'UTC'));
[$user, $tenant] = createNeedsAttentionTenant();
$this->actingAs($user);
$staleBackup = BackupSet::factory()->for($tenant)->create([
'name' => 'Stale backup',
'item_count' => 1,
'completed_at' => now()->subDays(2),
]);
BackupItem::factory()->for($tenant)->for($staleBackup)->create([
'payload' => ['id' => 'policy-stale'],
'metadata' => [],
'assignments' => [],
]);
Filament::setCurrentPanel(Filament::getPanel('tenant'));
Filament::setTenant($tenant, true);
$staleComponent = Livewire::test(NeedsAttention::class)
->assertSee('Latest backup is stale')
->assertSee('Open latest backup')
->assertDontSee('Backups are recent and healthy');
expect($staleComponent->html())->toContain(BackupSetResource::getUrl('view', [
'record' => (int) $staleBackup->getKey(),
'backup_health_reason' => TenantBackupHealthAssessment::REASON_LATEST_BACKUP_STALE,
], panel: 'tenant', tenant: $tenant));
});
it('surfaces degraded latest-backup attention with the matching latest-backup drill-through', function (): void {
CarbonImmutable::setTestNow(CarbonImmutable::create(2026, 4, 7, 12, 0, 0, 'UTC'));
[$user, $tenant] = createNeedsAttentionTenant();
$this->actingAs($user);
$degradedBackup = BackupSet::factory()->for($tenant)->create([
'name' => 'Degraded backup',
'item_count' => 1,
'completed_at' => now()->subMinutes(45),
]);
BackupItem::factory()->for($tenant)->for($degradedBackup)->create([
'payload' => [],
'metadata' => [
'source' => 'metadata_only',
'assignments_fetch_failed' => true,
],
'assignments' => [],
]);
Filament::setCurrentPanel(Filament::getPanel('tenant'));
Filament::setTenant($tenant, true);
$degradedComponent = Livewire::test(NeedsAttention::class)
->assertSee('Latest backup is degraded')
->assertSee('Open latest backup')
->assertDontSee('Backups are recent and healthy');
expect($degradedComponent->html())->toContain(BackupSetResource::getUrl('view', [
'record' => (int) $degradedBackup->getKey(),
'backup_health_reason' => TenantBackupHealthAssessment::REASON_LATEST_BACKUP_DEGRADED,
], panel: 'tenant', tenant: $tenant));
});
it('surfaces schedule follow-up instead of a healthy backup check when automation needs review', function (): void {
CarbonImmutable::setTestNow(CarbonImmutable::create(2026, 4, 7, 12, 0, 0, 'UTC'));
[$user, $tenant] = createNeedsAttentionTenant();
$this->actingAs($user);
$healthyBackup = BackupSet::factory()->for($tenant)->create([
'name' => 'Healthy backup',
'item_count' => 1,
'completed_at' => now()->subMinutes(20),
]);
BackupItem::factory()->for($tenant)->for($healthyBackup)->create([
'payload' => ['id' => 'healthy-policy'],
'metadata' => [],
'assignments' => [],
]);
makeBackupHealthScheduleForNeedsAttention($tenant, [
'name' => 'Overdue schedule',
'last_run_at' => null,
'last_run_status' => null,
'next_run_at' => now()->subHours(2),
]);
Filament::setCurrentPanel(Filament::getPanel('tenant'));
Filament::setTenant($tenant, true);
$component = Livewire::test(NeedsAttention::class)
->assertSee('Backup schedules need follow-up')
->assertSee('not produced a successful run')
->assertSee('Open backup schedules')
->assertDontSee('Backups are recent and healthy');
expect($component->html())->toContain(BackupScheduleResource::getUrl('index', [
'backup_health_reason' => TenantBackupHealthAssessment::REASON_SCHEDULE_FOLLOW_UP,
], panel: 'tenant', tenant: $tenant));
});
it('adds the healthy backup check only when the latest backup basis genuinely earns it', function (): void {
CarbonImmutable::setTestNow(CarbonImmutable::create(2026, 4, 7, 12, 0, 0, 'UTC'));
[$user, $tenant, $profile, $snapshot] = createNeedsAttentionTenant();
$this->actingAs($user);
$healthyBackup = BackupSet::factory()->for($tenant)->create([
'name' => 'Healthy backup',
'item_count' => 1,
'completed_at' => now()->subMinutes(10),
]);
BackupItem::factory()->for($tenant)->for($healthyBackup)->create([
'payload' => ['id' => 'healthy-policy'],
'metadata' => [],
'assignments' => [],
]);
OperationRun::factory()->create([
'tenant_id' => (int) $tenant->getKey(),
'workspace_id' => (int) $tenant->workspace_id,
'type' => OperationRunType::BaselineCompare->value,
'status' => OperationRunStatus::Completed->value,
'outcome' => OperationRunOutcome::Succeeded->value,
'completed_at' => now()->subHour(),
'context' => [
'baseline_profile_id' => (int) $profile->getKey(),
'baseline_snapshot_id' => (int) $snapshot->getKey(),
'baseline_compare' => [
'reason_code' => BaselineCompareReasonCode::NoDriftDetected->value,
'coverage' => [
'effective_types' => ['deviceConfiguration'],
'covered_types' => ['deviceConfiguration'],
'uncovered_types' => [],
'proof' => true,
],
],
],
]);
Filament::setCurrentPanel(Filament::getPanel('tenant'));
Filament::setTenant($tenant, true);
Livewire::test(NeedsAttention::class)
->assertSee('Backups are recent and healthy')
->assertSee('Baseline compare looks trustworthy')
->assertDontSee('Backup schedules need follow-up')
->assertDontSee('No usable backup basis');
});
it('keeps backup-health attention visible but non-clickable when the member lacks backup view access', function (): void {
CarbonImmutable::setTestNow(CarbonImmutable::create(2026, 4, 7, 12, 0, 0, 'UTC'));
[$user, $tenant] = createNeedsAttentionTenant();
$this->actingAs($user);
$backupSet = BackupSet::factory()->for($tenant)->create([
'name' => 'Stale hidden backup',
'item_count' => 1,
'completed_at' => now()->subDays(2),
]);
BackupItem::factory()->for($tenant)->for($backupSet)->create([
'payload' => ['id' => 'policy-stale'],
'metadata' => [],
'assignments' => [],
]);
Gate::define(Capabilities::TENANT_VIEW, fn (): bool => false);
Filament::setCurrentPanel(Filament::getPanel('tenant'));
Filament::setTenant($tenant, true);
$component = Livewire::test(NeedsAttention::class)
->assertSee('Latest backup is stale')
->assertSee('Open latest backup')
->assertSee(UiTooltips::INSUFFICIENT_PERMISSION);
expect($component->html())->not->toContain(BackupSetResource::getUrl('view', [
'record' => (int) $backupSet->getKey(),
'backup_health_reason' => TenantBackupHealthAssessment::REASON_LATEST_BACKUP_STALE,
], panel: 'tenant', tenant: $tenant));
});

View File

@ -0,0 +1,88 @@
<?php
declare(strict_types=1);
use App\Filament\Resources\PolicyVersionResource;
use App\Models\Policy;
use App\Models\PolicyVersion;
use Filament\Facades\Filament;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
it('shows snapshot mode and backup quality on the policy-version list', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'owner');
$this->actingAs($user);
Filament::setTenant($tenant, true);
$policy = Policy::factory()->create([
'tenant_id' => (int) $tenant->getKey(),
'display_name' => 'Windows Policy',
'policy_type' => 'settingsCatalogPolicy',
'platform' => 'windows',
]);
PolicyVersion::factory()->create([
'tenant_id' => (int) $tenant->getKey(),
'policy_id' => (int) $policy->getKey(),
'version_number' => 1,
'snapshot' => ['id' => 'policy-1'],
'metadata' => [],
]);
PolicyVersion::factory()->create([
'tenant_id' => (int) $tenant->getKey(),
'policy_id' => (int) $policy->getKey(),
'version_number' => 2,
'snapshot' => [],
'metadata' => [
'source' => 'metadata_only',
'assignments_fetch_failed' => true,
],
]);
$this->get(PolicyVersionResource::getUrl('index', tenant: $tenant))
->assertOk()
->assertSee('Snapshot')
->assertSee('Backup quality')
->assertSee('Full payload')
->assertSee('Metadata only')
->assertSee('Assignment fetch failed');
});
it('shows explicit backup quality on the policy-version detail page', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'owner');
$this->actingAs($user);
Filament::setTenant($tenant, true);
$policy = Policy::factory()->create([
'tenant_id' => (int) $tenant->getKey(),
'display_name' => 'Versioned policy',
'policy_type' => 'settingsCatalogPolicy',
'platform' => 'windows',
]);
$version = PolicyVersion::factory()->create([
'tenant_id' => (int) $tenant->getKey(),
'policy_id' => (int) $policy->getKey(),
'snapshot' => ['id' => 'policy-1'],
'metadata' => [
'has_orphaned_assignments' => true,
],
'secret_fingerprints' => [
'snapshot' => ['/clientSecret' => 'abc123'],
'assignments' => [],
'scope_tags' => [],
],
]);
$this->get(PolicyVersionResource::getUrl('view', ['record' => $version], tenant: $tenant))
->assertOk()
->assertSee('Backup quality')
->assertSee('Orphaned assignments')
->assertSee('Integrity note')
->assertSee('Boundary')
->assertSee('Input quality signals do not prove safe restore');
});

View File

@ -124,12 +124,55 @@
Livewire::test(ListPolicyVersions::class) Livewire::test(ListPolicyVersions::class)
->assertTableActionDisabled('restore_via_wizard', $version) ->assertTableActionDisabled('restore_via_wizard', $version)
->assertSee('Full payload')
->callTableAction('restore_via_wizard', $version); ->callTableAction('restore_via_wizard', $version);
expect(BackupSet::query()->where('metadata->source', 'policy_version')->exists())->toBeFalse(); expect(BackupSet::query()->where('metadata->source', 'policy_version')->exists())->toBeFalse();
expect(BackupItem::query()->exists())->toBeFalse(); expect(BackupItem::query()->exists())->toBeFalse();
}); });
test('metadata-only versions keep quality visible while restore-via-wizard stays disabled', function () {
$tenant = Tenant::create([
'tenant_id' => 'tenant-policy-version-wizard-quality',
'name' => 'Tenant',
'metadata' => [],
]);
$tenant->makeCurrent();
$policy = Policy::create([
'tenant_id' => $tenant->id,
'external_id' => 'policy-quality',
'policy_type' => 'settingsCatalogPolicy',
'display_name' => 'Settings Catalog',
'platform' => 'windows',
]);
$version = PolicyVersion::create([
'tenant_id' => $tenant->id,
'policy_id' => $policy->id,
'version_number' => 1,
'policy_type' => $policy->policy_type,
'platform' => $policy->platform,
'captured_at' => now(),
'snapshot' => [],
'metadata' => [
'source' => 'metadata_only',
],
]);
$user = User::factory()->create(['email' => 'owner@example.com']);
$user->tenants()->syncWithoutDetaching([
$tenant->getKey() => ['role' => 'owner'],
]);
$this->actingAs($user);
Filament::setTenant($tenant, true);
Livewire::test(ListPolicyVersions::class)
->assertSee('Metadata only')
->assertTableActionDisabled('restore_via_wizard', $version);
});
test('restore run wizard can be prefilled from query params for policy version backup set', function () { test('restore run wizard can be prefilled from query params for policy version backup set', function () {
$tenant = Tenant::create([ $tenant = Tenant::create([
'tenant_id' => 'tenant-policy-version-prefill', 'tenant_id' => 'tenant-policy-version-prefill',
@ -215,9 +258,9 @@
'backup_item_ids' => [$backupItem->id], 'backup_item_ids' => [$backupItem->id],
])->test(CreateRestoreRun::class); ])->test(CreateRestoreRun::class);
expect($component->get('data.backup_set_id'))->toBe($backupSet->id); expect((int) $component->get('data.backup_set_id'))->toBe($backupSet->id);
expect($component->get('data.scope_mode'))->toBe('selected'); expect($component->get('data.scope_mode'))->toBe('selected');
expect($component->get('data.backup_item_ids'))->toBe([$backupItem->id]); expect(array_map('intval', $component->get('data.backup_item_ids')))->toBe([$backupItem->id]);
$mapping = $component->get('data.group_mapping'); $mapping = $component->get('data.group_mapping');
expect($mapping)->toBeArray(); expect($mapping)->toBeArray();

View File

@ -32,6 +32,8 @@
->get(route('filament.admin.resources.policy-versions.index', filamentTenantRouteParams($tenant))) ->get(route('filament.admin.resources.policy-versions.index', filamentTenantRouteParams($tenant)))
->assertOk() ->assertOk()
->assertSee('Policy A') ->assertSee('Policy A')
->assertSee('Backup quality')
->assertSee('Full payload')
->assertSee((string) PolicyVersion::max('version_number')); ->assertSee((string) PolicyVersion::max('version_number'));
}); });
@ -78,6 +80,7 @@
->get(\App\Filament\Resources\PolicyVersionResource::getUrl('view', ['record' => $version]).'?tab=normalized-settings&tenant='.(string) $tenant->external_id); ->get(\App\Filament\Resources\PolicyVersionResource::getUrl('view', ['record' => $version]).'?tab=normalized-settings&tenant='.(string) $tenant->external_id);
$response->assertOk(); $response->assertOk();
$response->assertSee('Backup quality');
$response->assertSee('Helpdesk Assignment'); $response->assertSee('Helpdesk Assignment');
$response->assertSee('Role assignment'); $response->assertSee('Role assignment');
$response->assertSee('Policy and Profile Manager (role-1)'); $response->assertSee('Policy and Profile Manager (role-1)');

View File

@ -129,13 +129,15 @@
->reduce(fn (array $carry, array $options): array => $carry + $options, []); ->reduce(fn (array $carry, array $options): array => $carry + $options, []);
expect($flattenedOptions)->toHaveKey($policyItem->id); expect($flattenedOptions)->toHaveKey($policyItem->id);
expect($flattenedOptions[$policyItem->id])->toBe('Policy Display'); expect($flattenedOptions[$policyItem->id])->toContain('Policy Display')
->and($flattenedOptions[$policyItem->id])->toContain('Full payload');
expect($flattenedOptions)->not->toHaveKey($ignoredPolicyItem->id); expect($flattenedOptions)->not->toHaveKey($ignoredPolicyItem->id);
expect($flattenedOptions)->toHaveKey($scopeTagItem->id); expect($flattenedOptions)->toHaveKey($scopeTagItem->id);
expect($flattenedOptions[$scopeTagItem->id])->toBe('Scope Tag Alpha'); expect($flattenedOptions[$scopeTagItem->id])->toContain('Scope Tag Alpha');
expect($flattenedOptions)->toHaveKey($previewOnlyItem->id); expect($flattenedOptions)->toHaveKey($previewOnlyItem->id);
expect($flattenedOptions[$previewOnlyItem->id])->toBe('Conditional Access Policy'); expect($flattenedOptions[$previewOnlyItem->id])->toContain('Conditional Access Policy')
->and($flattenedOptions[$previewOnlyItem->id])->toContain('Full payload');
}); });

View File

@ -0,0 +1,217 @@
<?php
declare(strict_types=1);
use App\Filament\Resources\RestoreRunResource\Pages\ViewRestoreRun;
use App\Models\BackupSet;
use App\Models\RestoreRun;
use App\Support\RestoreSafety\RestoreSafetyResolver;
use Filament\Facades\Filament;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Livewire\Livewire;
uses(RefreshDatabase::class);
it('elevates restore result attention above raw item diagnostics', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'owner');
$this->actingAs($user);
$backupSet = BackupSet::factory()->create([
'tenant_id' => $tenant->id,
]);
$restoreRun = RestoreRun::factory()->create([
'tenant_id' => $tenant->id,
'backup_set_id' => $backupSet->id,
'status' => 'completed',
'results' => [
'foundations' => [],
'items' => [
1 => [
'status' => 'applied',
'policy_identifier' => 'policy-1',
'assignment_outcomes' => [
['status' => 'skipped', 'assignment' => []],
],
],
],
],
'metadata' => [
'non_applied' => 1,
'scope_basis' => [
'fingerprint' => 'scope-1',
'scope_mode' => 'selected',
'selected_item_ids' => [1],
],
'preview_basis' => [
'fingerprint' => 'scope-1',
'generated_at' => now('UTC')->toIso8601String(),
],
'check_basis' => [
'fingerprint' => 'scope-1',
'ran_at' => now('UTC')->toIso8601String(),
'blocking_count' => 0,
'warning_count' => 0,
],
'execution_safety_snapshot' => [
'safety_state' => 'ready_with_caution',
'follow_up_boundary' => 'run_completed_not_recovery_proven',
],
],
]);
Filament::setTenant($tenant, true);
Livewire::test(ViewRestoreRun::class, ['record' => $restoreRun->getKey()])
->assertSee('Follow-up required')
->assertSee('Review skipped or non-applied items before closing the run.')
->assertSee('No dominant cause recorded')
->assertSee('Tenant-wide recovery is not proven.')
->assertDontSee('review_skipped_items')
->assertDontSee('run_completed_not_recovery_proven');
});
it('shows not run checks instead of legacy stale for preview-only runs without checks evidence', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'owner');
$this->actingAs($user);
$backupSet = BackupSet::factory()->create([
'tenant_id' => $tenant->id,
]);
/** @var RestoreSafetyResolver $resolver */
$resolver = app(RestoreSafetyResolver::class);
$previewData = [
'backup_set_id' => (int) $backupSet->getKey(),
'scope_mode' => 'all',
'backup_item_ids' => [],
'group_mapping' => [],
'preview_summary' => [
'generated_at' => now('UTC')->toIso8601String(),
'policies_total' => 1,
'policies_changed' => 1,
'assignments_changed' => 0,
'scope_tags_changed' => 0,
],
'preview_ran_at' => now('UTC')->toIso8601String(),
];
$previewBasis = $resolver->previewBasisFromData($previewData);
$restoreRun = RestoreRun::factory()->create([
'tenant_id' => $tenant->id,
'backup_set_id' => $backupSet->id,
'status' => 'previewed',
'is_dry_run' => true,
'preview' => [
[
'policy_identifier' => 'policy-1',
'policy_type' => 'settingsCatalogPolicy',
'platform' => 'windows',
'action' => 'update',
],
],
'metadata' => [
'scope_basis' => [
'fingerprint' => 'scope-1',
'scope_mode' => 'all',
'selected_item_ids' => [],
],
'preview_summary' => $previewData['preview_summary'],
'preview_ran_at' => $previewData['preview_ran_at'],
'preview_basis' => $previewBasis,
'check_basis' => [],
'check_summary' => [],
],
]);
Filament::setTenant($tenant, true);
Livewire::test(ViewRestoreRun::class, ['record' => $restoreRun->getKey()])
->assertSee('Current basis')
->assertSee('Not run')
->assertSee('Preview evidence is current for the selected restore scope.')
->assertSee('No execution was performed from this record.')
->assertDontSee('preview_only_no_execution_proven')
->assertDontSee('Legacy stale');
});
it('renders preview-only foundations without falling back to unknown', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'owner');
$this->actingAs($user);
$backupSet = BackupSet::factory()->create([
'tenant_id' => $tenant->id,
]);
$restoreRun = RestoreRun::factory()->create([
'tenant_id' => $tenant->id,
'backup_set_id' => $backupSet->id,
'status' => 'previewed',
'is_dry_run' => true,
'preview' => [
[
'type' => 'intuneRoleDefinition',
'sourceId' => 'role-def-1',
'sourceName' => 'Application Manager',
'decision' => 'dry_run',
'reason' => 'preview_only',
],
],
'metadata' => [
'scope_basis' => [
'fingerprint' => 'scope-1',
'scope_mode' => 'all',
'selected_item_ids' => [],
],
'preview_basis' => [
'fingerprint' => 'scope-1',
'generated_at' => now('UTC')->toIso8601String(),
],
],
]);
Filament::setTenant($tenant, true);
Livewire::test(ViewRestoreRun::class, ['record' => $restoreRun->getKey()])
->assertSee('Application Manager')
->assertSee('Preview only')
->assertSee('Preview only. This foundation type is not applied during execution.')
->assertDontSee('Unknown');
});
it('renders preview-only result foundations without exposing raw reason codes', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'owner');
$this->actingAs($user);
$backupSet = BackupSet::factory()->create([
'tenant_id' => $tenant->id,
]);
$restoreRun = RestoreRun::factory()->create([
'tenant_id' => $tenant->id,
'backup_set_id' => $backupSet->id,
'status' => 'completed',
'is_dry_run' => false,
'results' => [
'foundations' => [
[
'type' => 'intuneRoleDefinition',
'sourceId' => 'role-def-1',
'sourceName' => 'Application Manager',
'decision' => 'dry_run',
'reason' => 'preview_only',
],
],
'items' => [],
],
]);
Filament::setTenant($tenant, true);
Livewire::test(ViewRestoreRun::class, ['record' => $restoreRun->getKey()])
->assertSee('Application Manager')
->assertSee('Preview only')
->assertSee('Preview only. This foundation type is not applied during execution.')
->assertDontSee('Unknown');
});

View File

@ -0,0 +1,50 @@
<?php
declare(strict_types=1);
use App\Filament\Resources\RestoreRunResource\Pages\CreateRestoreRun;
use App\Models\Tenant;
use App\Support\RestoreSafety\RestoreSafetyResolver;
use Filament\Facades\Filament;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Livewire\Livewire;
uses(RefreshDatabase::class);
it('renders warning-suppressed execution guidance when current evidence still has warnings', function (): void {
$tenant = Tenant::factory()->create([
'rbac_status' => 'ok',
'rbac_last_checked_at' => now(),
]);
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner');
ensureDefaultProviderConnection($tenant, 'microsoft');
/** @var RestoreSafetyResolver $resolver */
$resolver = app(RestoreSafetyResolver::class);
$data = [
'backup_set_id' => 10,
'scope_mode' => 'selected',
'backup_item_ids' => [1],
'group_mapping' => [],
'check_summary' => ['blocking' => 0, 'warning' => 1, 'safe' => 0],
'check_results' => [['code' => 'warning', 'severity' => 'warning']],
'checks_ran_at' => now('UTC')->toIso8601String(),
'preview_summary' => ['generated_at' => now('UTC')->toIso8601String(), 'policies_total' => 1, 'policies_changed' => 1],
'preview_diffs' => [['policy_identifier' => 'policy-1', 'action' => 'update']],
'preview_ran_at' => now('UTC')->toIso8601String(),
];
$data['check_basis'] = $resolver->checksBasisFromData($data);
$data['preview_basis'] = $resolver->previewBasisFromData($data);
$data = \App\Filament\Resources\RestoreRunResource::synchronizeRestoreSafetyDraft($data);
Filament::setTenant($tenant, true);
Livewire::actingAs($user)
->test(CreateRestoreRun::class)
->set('data', $data)
->goToWizardStep(5)
->assertSee('Ready with caution')
->assertSee('warnings remain')
->assertSee('Review the warnings before real execution.');
});

View File

@ -0,0 +1,39 @@
<?php
declare(strict_types=1);
use App\Filament\Resources\RestoreRunResource\Pages\CreateRestoreRun;
use App\Models\BackupItem;
use App\Models\BackupSet;
use Filament\Facades\Filament;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Livewire\Livewire;
uses(RefreshDatabase::class);
it('shows degraded backup-set hints before restore safety checks run', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'owner');
$this->actingAs($user);
Filament::setTenant($tenant, true);
$backupSet = BackupSet::factory()->for($tenant)->create([
'name' => 'Recovery candidate',
'item_count' => 1,
]);
BackupItem::factory()->for($tenant)->for($backupSet)->create([
'payload' => [],
'metadata' => [
'source' => 'metadata_only',
'assignments_fetch_failed' => true,
],
'assignments' => [],
]);
Livewire::test(CreateRestoreRun::class)
->assertSee('Backup quality is visible here before safety checks run.')
->assertSee('Recovery candidate')
->assertSee('1 degraded item')
->assertSee('Backup quality hints describe input strength only.');
});

View File

@ -191,7 +191,7 @@ public function request(string $method, string $path, array $options = []): Grap
$response = $this->get(route('filament.admin.resources.restore-runs.view', array_merge(filamentTenantRouteParams($tenant), ['record' => $run]))); $response = $this->get(route('filament.admin.resources.restore-runs.view', array_merge(filamentTenantRouteParams($tenant), ['record' => $run])));
$response->assertOk(); $response->assertOk();
$response->assertSee('Some items still need follow-up. Review the per-item details below.'); $response->assertSee('The restore reached a terminal state, but some items or assignments still need follow-up.');
$response->assertSee('Manual follow-up needed'); $response->assertSee('Manual follow-up needed');
$response->assertSee('Graph bulk apply failed'); $response->assertSee('Graph bulk apply failed');
$response->assertSee('Setting missing'); $response->assertSee('Setting missing');

View File

@ -5,6 +5,8 @@
use App\Filament\Pages\TenantDashboard; use App\Filament\Pages\TenantDashboard;
use App\Filament\Widgets\Dashboard\DashboardKpis; use App\Filament\Widgets\Dashboard\DashboardKpis;
use App\Filament\Widgets\Dashboard\RecentOperations as DashboardRecentOperations; use App\Filament\Widgets\Dashboard\RecentOperations as DashboardRecentOperations;
use App\Models\BackupItem;
use App\Models\BackupSet;
use App\Models\Finding; use App\Models\Finding;
use App\Models\OperationRun; use App\Models\OperationRun;
use Filament\Facades\Filament; use Filament\Facades\Filament;
@ -29,6 +31,21 @@
'initiator_name' => 'System', 'initiator_name' => 'System',
]); ]);
$backupSet = BackupSet::factory()->create([
'tenant_id' => (int) $tenant->getKey(),
'name' => 'DB-only healthy backup',
'item_count' => 1,
'completed_at' => now()->subMinutes(30),
]);
BackupItem::factory()->create([
'tenant_id' => (int) $tenant->getKey(),
'backup_set_id' => (int) $backupSet->getKey(),
'payload' => ['id' => 'healthy-policy'],
'metadata' => [],
'assignments' => [],
]);
$this->actingAs($user); $this->actingAs($user);
Bus::fake(); Bus::fake();
@ -43,6 +60,8 @@
// server-rendered HTML. // server-rendered HTML.
Livewire::test(DashboardKpis::class) Livewire::test(DashboardKpis::class)
->assertSee('Backup posture')
->assertSee('Healthy')
->assertSee('Active operations') ->assertSee('Active operations')
->assertSee('healthy queued or running tenant work'); ->assertSee('healthy queued or running tenant work');

View File

@ -3,10 +3,23 @@
declare(strict_types=1); declare(strict_types=1);
use App\Filament\Pages\TenantDashboard; use App\Filament\Pages\TenantDashboard;
use App\Filament\Resources\BackupSetResource;
use App\Filament\Widgets\Dashboard\DashboardKpis;
use App\Filament\Widgets\Dashboard\NeedsAttention;
use App\Models\BackupItem;
use App\Models\BackupSet;
use App\Models\Finding; use App\Models\Finding;
use App\Models\InventoryItem; use App\Models\InventoryItem;
use App\Models\OperationRun; use App\Models\OperationRun;
use App\Models\Tenant; use App\Models\Tenant;
use App\Services\Auth\CapabilityResolver;
use App\Support\Auth\Capabilities;
use App\Support\Rbac\UiTooltips;
use Filament\Facades\Filament;
use Illuminate\Support\Facades\Gate;
use Livewire\Livewire;
use function Pest\Laravel\mock;
it('does not leak data across tenants on the dashboard', function (): void { it('does not leak data across tenants on the dashboard', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'owner'); [$user, $tenant] = createUserWithTenant(role: 'owner');
@ -38,3 +51,55 @@
->assertOk() ->assertOk()
->assertDontSee('Other Tenant Policy'); ->assertDontSee('Other Tenant Policy');
}); });
it('keeps backup summary truth visible on the dashboard while blocked backup drill-through routes still fail with 403', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'owner', ensureDefaultMicrosoftProviderConnection: false);
$this->actingAs($user);
$backupSet = BackupSet::factory()->for($tenant)->create([
'name' => 'Stale scoped backup',
'item_count' => 1,
'completed_at' => now()->subDays(2),
]);
BackupItem::factory()->for($tenant)->for($backupSet)->create([
'payload' => ['id' => 'policy-stale'],
'metadata' => [],
'assignments' => [],
]);
Gate::define(Capabilities::TENANT_VIEW, fn (): bool => false);
mock(CapabilityResolver::class, function ($mock) use ($tenant): void {
$mock->shouldReceive('isMember')
->andReturnUsing(static fn ($user, Tenant $resolvedTenant): bool => (int) $resolvedTenant->getKey() === (int) $tenant->getKey());
$mock->shouldReceive('can')
->andReturnUsing(static function ($user, Tenant $resolvedTenant, string $capability) use ($tenant): bool {
expect((int) $resolvedTenant->getKey())->toBe((int) $tenant->getKey());
return match ($capability) {
Capabilities::TENANT_VIEW => false,
default => true,
};
});
});
Filament::setCurrentPanel(Filament::getPanel('tenant'));
Filament::setTenant($tenant, true);
Livewire::test(NeedsAttention::class)
->assertSee('Latest backup is stale')
->assertSee(UiTooltips::INSUFFICIENT_PERMISSION)
->assertSee('Open latest backup');
Livewire::test(DashboardKpis::class)
->assertSee('Backup posture')
->assertSee('Stale');
$this->get(TenantDashboard::getUrl(tenant: $tenant))
->assertOk();
$this->get(BackupSetResource::getUrl('index', tenant: $tenant))
->assertForbidden();
});

View File

@ -4,6 +4,9 @@
use App\Filament\Widgets\Dashboard\BaselineCompareNow; use App\Filament\Widgets\Dashboard\BaselineCompareNow;
use App\Filament\Widgets\Dashboard\NeedsAttention; use App\Filament\Widgets\Dashboard\NeedsAttention;
use App\Models\BackupItem;
use App\Models\BackupSchedule;
use App\Models\BackupSet;
use App\Models\BaselineProfile; use App\Models\BaselineProfile;
use App\Models\BaselineSnapshot; use App\Models\BaselineSnapshot;
use App\Models\BaselineTenantAssignment; use App\Models\BaselineTenantAssignment;
@ -14,6 +17,7 @@
use App\Support\OperationRunOutcome; use App\Support\OperationRunOutcome;
use App\Support\OperationRunStatus; use App\Support\OperationRunStatus;
use App\Support\OperationRunType; use App\Support\OperationRunType;
use Carbon\CarbonImmutable;
use Filament\Facades\Filament; use Filament\Facades\Filament;
use Livewire\Livewire; use Livewire\Livewire;
@ -69,6 +73,10 @@ function seedTrustworthyCompare(array $tenantContext): void
]); ]);
} }
afterEach(function (): void {
CarbonImmutable::setTestNow();
});
it('suppresses calm dashboard wording when stale and terminal operations both need attention', function (): void { it('suppresses calm dashboard wording when stale and terminal operations both need attention', function (): void {
$tenantContext = createTruthAlignedDashboardTenant(); $tenantContext = createTruthAlignedDashboardTenant();
[$user, $tenant] = $tenantContext; [$user, $tenant] = $tenantContext;
@ -145,6 +153,18 @@ function seedTrustworthyCompare(array $tenantContext): void
seedTrustworthyCompare($tenantContext); seedTrustworthyCompare($tenantContext);
$healthyBackup = BackupSet::factory()->for($tenant)->create([
'name' => 'Healthy truth-aligned backup',
'item_count' => 1,
'completed_at' => now()->subMinutes(30),
]);
BackupItem::factory()->for($tenant)->for($healthyBackup)->create([
'payload' => ['id' => 'healthy-policy'],
'metadata' => [],
'assignments' => [],
]);
OperationRun::factory()->create([ OperationRun::factory()->create([
'tenant_id' => (int) $tenant->getKey(), 'tenant_id' => (int) $tenant->getKey(),
'workspace_id' => (int) $tenant->workspace_id, 'workspace_id' => (int) $tenant->workspace_id,
@ -219,3 +239,94 @@ function seedTrustworthyCompare(array $tenantContext): void
->assertSee('Open findings') ->assertSee('Open findings')
->assertDontSee('Aligned'); ->assertDontSee('Aligned');
}); });
it('suppresses calm dashboard wording when the latest backup basis is stale even if older history looked healthier', function (): void {
CarbonImmutable::setTestNow(CarbonImmutable::create(2026, 4, 7, 12, 0, 0, 'UTC'));
$tenantContext = createTruthAlignedDashboardTenant();
[$user, $tenant] = $tenantContext;
$this->actingAs($user);
seedTrustworthyCompare($tenantContext);
$olderHealthy = BackupSet::factory()->for($tenant)->create([
'name' => 'Older healthy backup',
'item_count' => 1,
'completed_at' => now()->subDays(3),
]);
BackupItem::factory()->for($tenant)->for($olderHealthy)->create([
'payload' => ['id' => 'healthy-policy'],
'metadata' => [],
'assignments' => [],
]);
$latestStale = BackupSet::factory()->for($tenant)->create([
'name' => 'Latest stale backup',
'item_count' => 1,
'completed_at' => now()->subDays(2),
]);
BackupItem::factory()->for($tenant)->for($latestStale)->create([
'payload' => ['id' => 'stale-policy'],
'metadata' => [],
'assignments' => [],
]);
Filament::setCurrentPanel(Filament::getPanel('tenant'));
Filament::setTenant($tenant, true);
Livewire::test(NeedsAttention::class)
->assertSee('Latest backup is stale')
->assertDontSee('Backups are recent and healthy')
->assertDontSee('Current governance and findings signals look trustworthy.');
});
it('adds positive backup calmness only when the latest backup basis is recent, clean, and schedules do not need follow-up', function (): void {
CarbonImmutable::setTestNow(CarbonImmutable::create(2026, 4, 7, 12, 0, 0, 'UTC'));
$tenantContext = createTruthAlignedDashboardTenant();
[$user, $tenant] = $tenantContext;
$this->actingAs($user);
seedTrustworthyCompare($tenantContext);
$healthyBackup = BackupSet::factory()->for($tenant)->create([
'name' => 'Healthy backup',
'item_count' => 1,
'completed_at' => now()->subMinutes(20),
]);
BackupItem::factory()->for($tenant)->for($healthyBackup)->create([
'payload' => ['id' => 'healthy-policy'],
'metadata' => [],
'assignments' => [],
]);
Filament::setCurrentPanel(Filament::getPanel('tenant'));
Filament::setTenant($tenant, true);
Livewire::test(NeedsAttention::class)
->assertSee('Backups are recent and healthy')
->assertDontSee('Backup schedules need follow-up');
BackupSchedule::query()->create([
'tenant_id' => (int) $tenant->getKey(),
'name' => 'Overdue dashboard schedule',
'is_enabled' => true,
'timezone' => 'UTC',
'frequency' => 'daily',
'time_of_day' => '01:00:00',
'days_of_week' => null,
'policy_types' => ['deviceConfiguration'],
'include_foundations' => true,
'retention_keep_last' => 30,
'last_run_at' => null,
'last_run_status' => null,
'next_run_at' => now()->subHours(2),
]);
Livewire::test(NeedsAttention::class)
->assertSee('Backup schedules need follow-up')
->assertDontSee('Backups are recent and healthy');
});

View File

@ -88,6 +88,16 @@
'exception' => (int) $expiring->getKey(), 'exception' => (int) $expiring->getKey(),
]) ])
->test(FindingExceptionsQueue::class) ->test(FindingExceptionsQueue::class)
->assertSet('selectedFindingExceptionId', (int) $expiring->getKey())
->assertSet('showSelectedExceptionSummary', true)
->assertActionVisible('clear_selected_exception')
->assertSee('Queue visibility test')
->assertSee('Expiring') ->assertSee('Expiring')
->assertSee($tenantA->name); ->assertSee($tenantA->name);
Livewire::test(FindingExceptionsQueue::class)
->mountTableAction('inspect_exception', (string) $expiring->getKey())
->assertMountedActionModalSee('Finding exception #'.$expiring->getKey())
->assertMountedActionModalSee('Queue visibility test')
->assertMountedActionModalSee('Close details');
}); });

View File

@ -0,0 +1,72 @@
<?php
declare(strict_types=1);
use App\Filament\Pages\Operations\TenantlessOperationRunViewer;
use App\Models\BackupSet;
use App\Models\OperationRun;
use App\Models\RestoreRun;
use App\Support\OperationRunOutcome;
use App\Support\OperationRunStatus;
use Filament\Facades\Filament;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Livewire\Livewire;
uses(RefreshDatabase::class);
it('shows restore continuation truth and deep link for restore-linked operation runs', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'owner');
$this->actingAs($user);
$backupSet = BackupSet::factory()->create([
'tenant_id' => $tenant->id,
]);
$operationRun = OperationRun::factory()->create([
'tenant_id' => (int) $tenant->getKey(),
'workspace_id' => (int) $tenant->workspace_id,
'user_id' => (int) $user->getKey(),
'type' => 'restore.execute',
'status' => OperationRunStatus::Completed->value,
'outcome' => OperationRunOutcome::Succeeded->value,
'context' => [],
]);
$restoreRun = RestoreRun::factory()->create([
'tenant_id' => $tenant->id,
'backup_set_id' => $backupSet->id,
'operation_run_id' => $operationRun->id,
'status' => 'completed',
'results' => [
'foundations' => [],
'items' => [
1 => [
'status' => 'applied',
'policy_identifier' => 'policy-1',
'assignment_outcomes' => [
['status' => 'skipped', 'assignment' => []],
],
],
],
],
'metadata' => [
'non_applied' => 1,
],
]);
$operationRun->update([
'context' => [
'restore_run_id' => (int) $restoreRun->getKey(),
],
]);
Filament::setTenant(null, true);
Livewire::actingAs($user)
->withQueryParams([])
->test(TenantlessOperationRunViewer::class, ['run' => $operationRun])
->assertSee('Restore continuation')
->assertSee('Follow-up required')
->assertSee('Tenant-wide recovery is not proven.')
->assertSee('Open restore run');
});

Some files were not shown because too many files have changed in this diff Show More