feat: restore safety integrity and queue slide-over #210

Merged
ahmido merged 1 commits from 181-restore-safety-integrity into dev 2026-04-06 23:37:15 +00:00
55 changed files with 5285 additions and 321 deletions

View File

@ -135,6 +135,8 @@ ## 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.15 (feat/005-bulk-operations) - PHP 8.4.15 (feat/005-bulk-operations)
@ -154,8 +156,8 @@ ## Code Style
PHP 8.4.15: Follow standard conventions PHP 8.4.15: Follow standard conventions
## Recent Changes ## Recent Changes
- 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
- 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 - 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
- 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 - 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
- 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
<!-- 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

@ -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

@ -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

@ -37,6 +37,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;
@ -330,23 +334,30 @@ public static function getWizardSteps(): array
}) })
->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(),
]), ]),
@ -367,27 +378,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 +443,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')
@ -495,13 +533,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 +595,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 +612,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 +662,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 +706,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 +720,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 +731,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 +780,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 +819,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 +844,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 +1416,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)),
]); ]);
} }
@ -1471,37 +1597,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 +1615,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 +1647,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 +1764,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 +1788,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 +1815,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 +1950,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

@ -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

@ -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

@ -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

@ -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

@ -215,9 +215,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

@ -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

@ -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

@ -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');
});

View File

@ -8,6 +8,7 @@
use App\Models\Tenant; use App\Models\Tenant;
use App\Models\User; use App\Models\User;
use App\Services\Graph\GroupResolver; use App\Services\Graph\GroupResolver;
use App\Support\RestoreSafety\RestoreScopeFingerprint;
use Filament\Facades\Filament; use Filament\Facades\Filament;
use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Foundation\Testing\RefreshDatabase;
use Livewire\Livewire; use Livewire\Livewire;
@ -228,12 +229,45 @@
$sourceGroupId = fake()->uuid(); $sourceGroupId = fake()->uuid();
$targetGroupId = fake()->uuid(); $targetGroupId = fake()->uuid();
$existingCheckSummary = [
'blocking' => 0,
'warning' => 0,
'safe' => 1,
];
$existingCheckResults = [[
'code' => 'safe',
'severity' => 'safe',
]];
$oldGroupMapping = [
$sourceGroupId => 'old',
];
$oldScopeFingerprint = RestoreScopeFingerprint::fromInputs(
backupSetId: 10,
scopeMode: 'all',
selectedItemIds: [],
groupMapping: $oldGroupMapping,
);
Livewire::test(CreateRestoreRun::class) $component = Livewire::test(CreateRestoreRun::class)
->set('data.check_summary', 'old') ->set('data.backup_set_id', 10)
->set('data.check_results', ['x']) ->set('data.scope_mode', 'all')
->set('data.group_mapping', $oldGroupMapping)
->set('data.check_summary', $existingCheckSummary)
->set('data.check_results', $existingCheckResults)
->set('data.check_basis', [
'fingerprint' => $oldScopeFingerprint->fingerprint,
'ran_at' => now('UTC')->toIso8601String(),
'blocking_count' => 0,
'warning_count' => 0,
'result_codes' => ['safe'],
])
->call('applyEntraGroupCachePick', sourceGroupId: $sourceGroupId, entraId: $targetGroupId) ->call('applyEntraGroupCachePick', sourceGroupId: $sourceGroupId, entraId: $targetGroupId)
->assertSet("data.group_mapping.{$sourceGroupId}", $targetGroupId) ->assertSet("data.group_mapping.{$sourceGroupId}", $targetGroupId)
->assertSet('data.check_summary', null) ->assertSet('data.is_dry_run', true)
->assertSet('data.check_results', []); ->assertSet('data.acknowledged_impact', false)
->assertSet('data.tenant_confirm', null);
expect($component->get('data.check_summary'))->toBe($existingCheckSummary)
->and($component->get('data.check_results'))->toBe($existingCheckResults)
->and($component->get('data.check_invalidation_reasons'))->toContain('scope_mismatch');
}); });

View File

@ -242,6 +242,89 @@
expect($skippedGroups[0]['id'] ?? null)->toBe('source-group-1'); expect($skippedGroups[0]['id'] ?? null)->toBe('source-group-1');
}); });
test('restore wizard keeps prior checks evidence visible and marks it invalidated after scope drift', function () {
$tenant = Tenant::create([
'tenant_id' => 'tenant-drift',
'name' => 'Tenant Drift',
'metadata' => [],
'rbac_status' => 'ok',
'rbac_last_checked_at' => now(),
]);
$tenant->makeCurrent();
ensureDefaultProviderConnection($tenant, 'microsoft');
$policy = Policy::create([
'tenant_id' => $tenant->id,
'external_id' => 'policy-drift',
'policy_type' => 'deviceConfiguration',
'display_name' => 'Config Drift',
'platform' => 'windows',
]);
$backupSet = BackupSet::create([
'tenant_id' => $tenant->id,
'name' => 'Backup',
'status' => 'completed',
'item_count' => 2,
]);
$firstItem = BackupItem::create([
'tenant_id' => $tenant->id,
'backup_set_id' => $backupSet->id,
'policy_id' => $policy->id,
'policy_identifier' => 'policy-first',
'policy_type' => $policy->policy_type,
'platform' => $policy->platform,
'payload' => ['id' => 'policy-first'],
]);
$secondItem = BackupItem::create([
'tenant_id' => $tenant->id,
'backup_set_id' => $backupSet->id,
'policy_id' => $policy->id,
'policy_identifier' => 'policy-second',
'policy_type' => $policy->policy_type,
'platform' => $policy->platform,
'payload' => ['id' => 'policy-second'],
]);
$user = User::factory()->create();
$this->actingAs($user);
$user->tenants()->syncWithoutDetaching([
$tenant->getKey() => ['role' => 'owner'],
]);
Filament::setTenant($tenant, true);
$component = Livewire::test(CreateRestoreRun::class)
->fillForm([
'backup_set_id' => $backupSet->id,
])
->goToNextWizardStep()
->fillForm([
'scope_mode' => 'selected',
'backup_item_ids' => [$firstItem->id],
])
->goToNextWizardStep()
->callFormComponentAction('check_results', 'run_restore_checks');
expect($component->get('data.check_summary'))->toBeArray();
expect($component->get('data.check_basis'))->toBeArray();
$component
->goToPreviousWizardStep()
->fillForm([
'backup_item_ids' => [$secondItem->id],
])
->goToWizardStep(3)
->assertSee('Invalidated')
->assertSee('selected items changed');
expect($component->get('data.check_summary'))->toBeArray();
expect($component->get('data.check_invalidation_reasons'))->toContain('selected_items_changed');
});
test('restore wizard flags metadata-only snapshots as blocking for restore-enabled types', function () { test('restore wizard flags metadata-only snapshots as blocking for restore-enabled types', function () {
$tenant = Tenant::create([ $tenant = Tenant::create([
'tenant_id' => 'tenant-1', 'tenant_id' => 'tenant-1',

View File

@ -84,6 +84,13 @@
], ],
'check_results' => [], 'check_results' => [],
'checks_ran_at' => now()->toIso8601String(), 'checks_ran_at' => now()->toIso8601String(),
'preview_summary' => [
'generated_at' => now()->toIso8601String(),
'policies_total' => 1,
'policies_changed' => 1,
'assignments_changed' => 0,
'scope_tags_changed' => 0,
],
'preview_ran_at' => now()->toIso8601String(), 'preview_ran_at' => now()->toIso8601String(),
'acknowledged_impact' => true, 'acknowledged_impact' => true,
'tenant_confirm' => 'Tenant Idempotency', 'tenant_confirm' => 'Tenant Idempotency',

View File

@ -198,6 +198,95 @@
}); });
}); });
test('restore run wizard blocks execution when scope drift invalidates preview and checks evidence', function () {
$tenant = Tenant::create([
'tenant_id' => 'tenant-3',
'name' => 'Tenant Three',
'metadata' => [],
'rbac_status' => 'ok',
'rbac_last_checked_at' => now(),
]);
$tenant->makeCurrent();
ensureDefaultProviderConnection($tenant, 'microsoft');
$policy = Policy::create([
'tenant_id' => $tenant->id,
'external_id' => 'policy-3',
'policy_type' => 'deviceConfiguration',
'display_name' => 'Device Config Policy',
'platform' => 'windows',
]);
$backupSet = BackupSet::create([
'tenant_id' => $tenant->id,
'name' => 'Backup',
'status' => 'completed',
'item_count' => 2,
]);
$firstItem = BackupItem::create([
'tenant_id' => $tenant->id,
'backup_set_id' => $backupSet->id,
'policy_id' => $policy->id,
'policy_identifier' => 'policy-a',
'policy_type' => $policy->policy_type,
'platform' => $policy->platform,
'payload' => ['id' => 'policy-a'],
'metadata' => ['displayName' => 'Policy A'],
]);
$secondItem = BackupItem::create([
'tenant_id' => $tenant->id,
'backup_set_id' => $backupSet->id,
'policy_id' => $policy->id,
'policy_identifier' => 'policy-b',
'policy_type' => $policy->policy_type,
'platform' => $policy->platform,
'payload' => ['id' => 'policy-b'],
'metadata' => ['displayName' => 'Policy B'],
]);
$user = User::factory()->create([
'email' => 'drift@example.com',
'name' => 'Drift Tester',
]);
$this->actingAs($user);
$user->tenants()->syncWithoutDetaching([
$tenant->getKey() => ['role' => 'owner'],
]);
Filament::setTenant($tenant, true);
Livewire::test(CreateRestoreRun::class)
->fillForm([
'backup_set_id' => $backupSet->id,
])
->goToNextWizardStep()
->fillForm([
'scope_mode' => 'selected',
'backup_item_ids' => [$firstItem->id],
])
->goToNextWizardStep()
->callFormComponentAction('check_results', 'run_restore_checks')
->goToNextWizardStep()
->callFormComponentAction('preview_diffs', 'run_restore_preview')
->goToPreviousWizardStep()
->goToPreviousWizardStep()
->fillForm([
'backup_item_ids' => [$secondItem->id],
])
->goToWizardStep(5)
->fillForm([
'is_dry_run' => false,
'acknowledged_impact' => true,
'tenant_confirm' => 'Tenant Three',
])
->call('create');
expect(RestoreRun::query()->count())->toBe(0);
});
test('admin restore run wizard ignores prefill query params for backup sets outside the canonical tenant', function () { test('admin restore run wizard ignores prefill query params for backup sets outside the canonical tenant', function () {
$tenantA = Tenant::factory()->create([ $tenantA = Tenant::factory()->create([
'name' => 'Phoenicon', 'name' => 'Phoenicon',

View File

@ -83,6 +83,7 @@
'scope_mode', 'scope_mode',
'environment', 'environment',
'highlander_label', 'highlander_label',
'scope_basis',
'failed', 'failed',
'non_applied', 'non_applied',
'total', 'total',
@ -92,4 +93,5 @@
expect($run->metadata['scope_mode'])->toBe('selected'); expect($run->metadata['scope_mode'])->toBe('selected');
expect($run->metadata['environment'])->toBe('test'); expect($run->metadata['environment'])->toBe('test');
expect($run->metadata['highlander_label'])->toBe('Tenant One'); expect($run->metadata['highlander_label'])->toBe('Tenant One');
expect($run->metadata['scope_basis']['fingerprint'] ?? null)->toBeString();
}); });

View File

@ -61,4 +61,12 @@
$safe = BadgeCatalog::spec(BadgeDomain::RestoreCheckSeverity, 'safe'); $safe = BadgeCatalog::spec(BadgeDomain::RestoreCheckSeverity, 'safe');
expect($safe->label)->toBe('Ready to continue'); expect($safe->label)->toBe('Ready to continue');
expect($safe->color)->toBe('success'); expect($safe->color)->toBe('success');
$current = BadgeCatalog::spec(BadgeDomain::RestoreCheckSeverity, 'current');
expect($current->label)->toBe('Current checks');
expect($current->color)->toBe('success');
$invalidated = BadgeCatalog::spec(BadgeDomain::RestoreCheckSeverity, 'invalidated');
expect($invalidated->label)->toBe('Invalidated');
expect($invalidated->color)->toBe('warning');
}); });

View File

@ -25,6 +25,18 @@
$failed = BadgeCatalog::spec(BadgeDomain::RestorePreviewDecision, 'failed'); $failed = BadgeCatalog::spec(BadgeDomain::RestorePreviewDecision, 'failed');
expect($failed->label)->toBe('Cannot apply'); expect($failed->label)->toBe('Cannot apply');
expect($failed->color)->toBe('danger'); expect($failed->color)->toBe('danger');
$dryRun = BadgeCatalog::spec(BadgeDomain::RestorePreviewDecision, 'dry_run');
expect($dryRun->label)->toBe('Preview only');
expect($dryRun->color)->toBe('warning');
$current = BadgeCatalog::spec(BadgeDomain::RestorePreviewDecision, 'current');
expect($current->label)->toBe('Current basis');
expect($current->color)->toBe('success');
$invalidated = BadgeCatalog::spec(BadgeDomain::RestorePreviewDecision, 'invalidated');
expect($invalidated->label)->toBe('Invalidated');
expect($invalidated->color)->toBe('warning');
}); });
it('maps restore results statuses to canonical badge semantics', function (): void { it('maps restore results statuses to canonical badge semantics', function (): void {
@ -51,4 +63,12 @@
$failed = BadgeCatalog::spec(BadgeDomain::RestoreResultStatus, 'failed'); $failed = BadgeCatalog::spec(BadgeDomain::RestoreResultStatus, 'failed');
expect($failed->label)->toBe('Apply failed'); expect($failed->label)->toBe('Apply failed');
expect($failed->color)->toBe('danger'); expect($failed->color)->toBe('danger');
$completed = BadgeCatalog::spec(BadgeDomain::RestoreResultStatus, 'completed');
expect($completed->label)->toBe('Completed');
expect($completed->color)->toBe('success');
$followUp = BadgeCatalog::spec(BadgeDomain::RestoreResultStatus, 'completed_with_follow_up');
expect($followUp->label)->toBe('Follow-up required');
expect($followUp->color)->toBe('warning');
}); });

View File

@ -177,3 +177,19 @@
expect($restoreRun->getSkippedAssignmentsCount())->toBe(3); expect($restoreRun->getSkippedAssignmentsCount())->toBe(3);
}); });
test('restore safety metadata helpers expose nested metadata payloads', function () {
$restoreRun = RestoreRun::factory()->create([
'metadata' => [
'scope_basis' => ['fingerprint' => 'scope-1'],
'check_basis' => ['fingerprint' => 'checks-1'],
'preview_basis' => ['fingerprint' => 'preview-1'],
'execution_safety_snapshot' => ['scope_fingerprint' => 'scope-1'],
],
]);
expect($restoreRun->scopeBasis())->toBe(['fingerprint' => 'scope-1'])
->and($restoreRun->checkBasis())->toBe(['fingerprint' => 'checks-1'])
->and($restoreRun->previewBasis())->toBe(['fingerprint' => 'preview-1'])
->and($restoreRun->executionSafetySnapshot())->toBe(['scope_fingerprint' => 'scope-1']);
});

View File

@ -0,0 +1,70 @@
<?php
declare(strict_types=1);
use App\Models\BackupSet;
use App\Models\RestoreRun;
use App\Support\RestoreSafety\RestoreResultAttention;
use App\Support\RestoreSafety\RestoreSafetyResolver;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
it('treats preview-only restore runs as not executed', 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,
'is_dry_run' => true,
'status' => 'previewed',
'results' => [],
]);
$attention = app(RestoreSafetyResolver::class)->resultAttentionForRun($restoreRun);
expect($attention->state)->toBe(RestoreResultAttention::STATE_NOT_EXECUTED)
->and($attention->followUpRequired)->toBeFalse();
});
it('surfaces completed runs with skipped work as follow-up required', 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,
'is_dry_run' => false,
'status' => 'completed',
'results' => [
'foundations' => [],
'items' => [
9 => [
'status' => 'applied',
'policy_identifier' => 'policy-9',
'assignment_outcomes' => [
['status' => 'skipped', 'assignment' => []],
],
],
],
],
'metadata' => [
'non_applied' => 1,
],
]);
$attention = app(RestoreSafetyResolver::class)->resultAttentionForRun($restoreRun);
expect($attention->state)->toBe(RestoreResultAttention::STATE_COMPLETED_WITH_FOLLOW_UP)
->and($attention->followUpRequired)->toBeTrue()
->and($attention->primaryNextAction)->toBe('review_skipped_items');
});

View File

@ -0,0 +1,105 @@
<?php
declare(strict_types=1);
use App\Models\Tenant;
use App\Support\RestoreSafety\RestoreSafetyAssessment;
use App\Support\RestoreSafety\RestoreSafetyResolver;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
it('marks current evidence with warnings as ready with caution', function (): void {
$tenant = Tenant::factory()->create([
'rbac_status' => 'ok',
'rbac_last_checked_at' => now(),
]);
[$user] = createUserWithTenant(tenant: $tenant, role: 'owner');
ensureDefaultProviderConnection($tenant, 'microsoft');
/** @var RestoreSafetyResolver $resolver */
$resolver = app(RestoreSafetyResolver::class);
$data = [
'backup_set_id' => 50,
'scope_mode' => 'selected',
'backup_item_ids' => [4, 5],
'group_mapping' => ['group-a' => 'target-a'],
'check_summary' => ['blocking' => 0, 'warning' => 2, 'safe' => 1],
'check_results' => [['code' => 'warning', 'severity' => 'warning']],
'checks_ran_at' => now('UTC')->toIso8601String(),
'preview_summary' => ['generated_at' => now('UTC')->toIso8601String()],
'preview_diffs' => [['policy_identifier' => 'policy-1']],
'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);
$assessment = $resolver->safetyAssessment($tenant, $user, $data);
expect($assessment->state)->toBe(RestoreSafetyAssessment::STATE_READY_WITH_CAUTION)
->and($assessment->positiveClaimSuppressed)->toBeTrue()
->and($assessment->primaryNextAction)->toBe('review_warnings');
});
it('marks invalidated preview evidence as risky', function (): void {
$tenant = Tenant::factory()->create([
'rbac_status' => 'ok',
'rbac_last_checked_at' => now(),
]);
[$user] = createUserWithTenant(tenant: $tenant, role: 'owner');
ensureDefaultProviderConnection($tenant, 'microsoft');
/** @var RestoreSafetyResolver $resolver */
$resolver = app(RestoreSafetyResolver::class);
$currentData = [
'backup_set_id' => 50,
'scope_mode' => 'selected',
'backup_item_ids' => [4, 6],
'group_mapping' => ['group-a' => 'target-a'],
'check_summary' => ['blocking' => 0, 'warning' => 0, 'safe' => 1],
'check_results' => [['code' => 'safe', 'severity' => 'safe']],
'checks_ran_at' => now('UTC')->toIso8601String(),
'preview_summary' => ['generated_at' => now('UTC')->toIso8601String()],
'preview_diffs' => [['policy_identifier' => 'policy-1']],
'preview_ran_at' => now('UTC')->toIso8601String(),
];
$previousPreview = [
...$currentData,
'backup_item_ids' => [4, 5],
];
$currentData['check_basis'] = $resolver->checksBasisFromData($currentData);
$currentData['preview_basis'] = $resolver->previewBasisFromData($previousPreview);
$currentData = \App\Filament\Resources\RestoreRunResource::synchronizeRestoreSafetyDraft($currentData);
$assessment = $resolver->safetyAssessment($tenant, $user, $currentData);
expect($assessment->state)->toBe(RestoreSafetyAssessment::STATE_RISKY)
->and($assessment->previewIntegrity->state)->toBe('invalidated')
->and($assessment->primaryNextAction)->toBe('regenerate_preview');
});
it('treats empty stored basis arrays as missing evidence instead of legacy stale', function (): void {
/** @var RestoreSafetyResolver $resolver */
$resolver = app(RestoreSafetyResolver::class);
$data = [
'backup_set_id' => 50,
'scope_mode' => 'all',
'backup_item_ids' => [],
'group_mapping' => [],
'check_basis' => [],
'preview_basis' => [],
'check_summary' => [],
'preview_summary' => [],
'checks_ran_at' => null,
'preview_ran_at' => null,
];
expect($resolver->checksIntegrityFromData($data)->state)->toBe('not_run')
->and($resolver->previewIntegrityFromData($data)->state)->toBe('not_generated');
});

View File

@ -0,0 +1,56 @@
<?php
declare(strict_types=1);
use App\Support\RestoreSafety\RestoreScopeFingerprint;
it('normalizes selected item and group mapping order into a stable fingerprint', function (): void {
$first = RestoreScopeFingerprint::fromInputs(
backupSetId: 10,
scopeMode: 'selected',
selectedItemIds: [9, 3, '3', 1],
groupMapping: [
'group-b' => 'target-b',
'group-a' => 'target-a',
],
);
$second = RestoreScopeFingerprint::fromInputs(
backupSetId: 10,
scopeMode: 'selected',
selectedItemIds: ['1', 3, 9],
groupMapping: [
'group-a' => 'target-a',
'group-b' => 'target-b',
],
);
expect($first->selectedItemIds)->toBe([1, 3, 9])
->and($first->fingerprint)->toBe($second->fingerprint);
});
it('changes fingerprint when scope-affecting values change', function (): void {
$base = RestoreScopeFingerprint::fromInputs(
backupSetId: 10,
scopeMode: 'selected',
selectedItemIds: [1, 2],
groupMapping: ['group-a' => 'target-a'],
);
$changedSelection = RestoreScopeFingerprint::fromInputs(
backupSetId: 10,
scopeMode: 'selected',
selectedItemIds: [1, 3],
groupMapping: ['group-a' => 'target-a'],
);
$changedMapping = RestoreScopeFingerprint::fromInputs(
backupSetId: 10,
scopeMode: 'selected',
selectedItemIds: [1, 2],
groupMapping: ['group-a' => 'SKIP'],
);
expect($base->fingerprint)->not->toBe($changedSelection->fingerprint)
->and($base->fingerprint)->not->toBe($changedMapping->fingerprint);
});