Compare commits

..

6 Commits

Author SHA1 Message Date
e840007127 feat: add backup quality truth surfaces (#211)
## Summary
- add a shared backup-quality resolver and summary model for backup sets, backup items, policy versions, and restore selection
- surface backup-quality truth across Filament backup-set, policy-version, and restore-wizard entry points
- add focused Pest coverage and the full Spec Kit artifact set for spec 176

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

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

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

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

Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de>
Reviewed-on: #210
2026-04-06 23:37:14 +00:00
1142d283eb feat: Spec 178 — Operations Lifecycle Alignment & Cross-Surface Truth Consistency (#209)
## Spec 178 — Operations Lifecycle Alignment & Cross-Surface Truth Consistency

Härtet die Run-Lifecycle-Wahrheit und Cross-Surface-Konsistenz über alle zentralen Operator-Flächen hinweg.

### Kern-Änderungen

**Lifecycle Truth Alignment**
- Einheitliche stale/stuck-Semantik zwischen Tenant-, Workspace-, Admin- und System-Surfaces
- `OperationRunFreshnessState` wird konsistent über alle Widgets und Seiten propagiert
- Gemeinsame Problem-Klassen-Trennung: `terminal_follow_up` vs. `active_stale_attention`

**BulkOperationProgress Freshness**
- Overlay zeigt nur noch `healthyActive()` Runs statt alle aktiven Runs
- Likely-stale Runs halten das Polling nicht mehr künstlich aktiv
- Terminal Runs verschwinden zeitnah aus dem Progress-Overlay

**Decision Zone im Run Detail**
- Stale/reconciled Attention in der primären Decision-Hierarchie
- Klare Antworten: aktiv? stale? reconciled? nächster Schritt?
- Artifact-reiche Runs behalten Lifecycle-Truth vor Deep-Diagnostics

**Cross-Surface Link-Continuity**
- Dashboard → Operations Hub → Run Detail erzählen dieselbe Geschichte
- Notifications referenzieren korrekte Problem-Klasse
- Workspace/Tenant-Attention verlinken problemklassengerecht

**System-Plane Fixes**
- `/system/ops/failures` 500-Error behoben (panel-sichere Artifact-URLs)
- System-Stuck/Failures zeigen reconciled stale lineage

### Weitere Fixes
- Inventory auth guard bereinigt (Gate statt ad-hoc Facades)
- Browser-Smoke-Tests stabilisiert (DOM-Assertions statt fragile Klicks)
- Test-Assertion-Drift für Verification/Lifecycle-Texte korrigiert

### Test-Ergebnis
Full Suite: **3269 passed**, 8 skipped, 0 failed

### Spec-Artefakte
- `specs/178-ops-truth-alignment/spec.md`
- `specs/178-ops-truth-alignment/plan.md`
- `specs/178-ops-truth-alignment/tasks.md`
- `specs/178-ops-truth-alignment/research.md`
- `specs/178-ops-truth-alignment/data-model.md`
- `specs/178-ops-truth-alignment/quickstart.md`
- `specs/178-ops-truth-alignment/contracts/operations-truth-alignment.openapi.yaml`

Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de>
Reviewed-on: #209
2026-04-05 22:42:24 +00:00
f52d52540c feat: implement inventory coverage truth (#208)
## Summary
- implement Spec 177 inventory coverage truth across resolver, badges, KPIs, coverage page, and operation run detail surfaces
- add repo-native spec artifacts for the feature under `specs/177-inventory-coverage-truth`
- add unit, feature, and browser coverage for truth derivation, continuity, and inventory item filter/pagination smoke paths

## Testing
- `vendor/bin/sail bin pint --dirty --format agent`
- focused Spec 177 browser smoke file passed with 2 tests / 57 assertions
- extended inventory-focused test pack passed with 52 tests / 434 assertions

Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de>
Reviewed-on: #208
2026-04-05 12:35:20 +00:00
dc46c4fa58 feat: complete provider truth cleanup (#207)
## Summary
- implement Spec 179 to make tenant lifecycle, provider consent, and provider verification the primary truth axes on the targeted Filament surfaces
- demote legacy tenant app status and legacy provider status and health to diagnostic-only roles, add centralized badge mappings for provider consent and verification, and keep provider connections excluded from global search
- add the full Spec 179 artifact set under `specs/179-provider-truth-cleanup/` plus focused Pest coverage for tenant truth cleanup, provider truth cleanup, RBAC, discovery safety, and badge semantics
- fix the numeric out-of-scope tenant route regression so inaccessible `/admin/tenants/{id}` paths return `404 Not Found` instead of `500`

## Testing
- `vendor/bin/sail artisan test --compact tests/Feature/Filament/TenantLifecycleStatusDomainSeparationTest.php`
- `vendor/bin/sail artisan test --compact tests/Feature/Filament/TenantTruthCleanupSpec179Test.php`
- `vendor/bin/sail artisan test --compact tests/Feature/Filament/ProviderConnectionsDbOnlyTest.php`
- `vendor/bin/sail artisan test --compact tests/Feature/ProviderConnections/ProviderConnectionTruthCleanupSpec179Test.php`
- `vendor/bin/sail artisan test --compact tests/Feature/ProviderConnections/RequiredFiltersTest.php`
- `vendor/bin/sail artisan test --compact tests/Feature/Tenants/TenantProviderConnectionsCtaTest.php`
- `vendor/bin/sail artisan test --compact tests/Feature/Rbac/TenantResourceAuthorizationTest.php`
- `vendor/bin/sail artisan test --compact tests/Feature/ProviderConnections/ProviderConnectionListAuthorizationTest.php`
- `vendor/bin/sail artisan test --compact tests/Feature/ProviderConnections/ProviderConnectionAuthorizationTest.php`
- `vendor/bin/sail artisan test --compact tests/Feature/Rbac/AdminGlobalSearchContextSafetyTest.php`
- `vendor/bin/sail artisan test --compact tests/Feature/Filament/TenantGlobalSearchLifecycleScopeTest.php`
- `vendor/bin/sail artisan test --compact tests/Feature/Filament/TenantScopingTest.php`
- `vendor/bin/sail artisan test --compact tests/Unit/Badges/TenantBadgesTest.php`
- `vendor/bin/sail artisan test --compact tests/Unit/Badges/ProviderConnectionBadgesTest.php`

## Manual validation
- integrated-browser smoke on `/admin/tenants`, tenant detail, `/admin/provider-connections`, provider detail, and provider edit
- verified out-of-scope tenant and provider URLs return `404 Not Found` with the current session

## Notes
- branch: `179-provider-truth-cleanup`
- commit: `e54c6632`
- target: `dev`

Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de>
Reviewed-on: #207
2026-04-05 00:48:31 +00:00
98be510362 feat: harden workspace governance attention foundation (#206)
## Summary
- harden the workspace overview into a governance-aware attention surface that separates governance risk from activity and keeps calm states honest
- add tenant-bound attention, workspace-wide operations continuity, and low-permission fallback behavior for workspace-originated operations drill-through
- add the full Spec 175 artifact set and focused workspace overview regression coverage, plus align remaining operation-viewer wording and guard expectations so the suite stays green

## Testing
- `vendor/bin/sail artisan test --compact tests/Feature/Filament/WorkspaceOverviewAccessTest.php tests/Feature/Filament/WorkspaceOverviewAuthorizationTest.php tests/Feature/Filament/WorkspaceOverviewLandingTest.php tests/Feature/Filament/WorkspaceOverviewNavigationTest.php tests/Feature/Filament/WorkspaceOverviewContentTest.php tests/Feature/Filament/WorkspaceOverviewEmptyStatesTest.php tests/Feature/Filament/WorkspaceOverviewPermissionVisibilityTest.php tests/Feature/Filament/WorkspaceOverviewOperationsTest.php tests/Feature/Filament/WorkspaceOverviewDbOnlyTest.php tests/Feature/Filament/WorkspaceOverviewGovernanceAttentionTest.php tests/Feature/Filament/WorkspaceOverviewSummaryMetricsTest.php tests/Feature/Filament/WorkspaceOverviewDrilldownContinuityTest.php`
- `vendor/bin/sail artisan test --compact tests/Unit/Support/RelatedActionLabelCatalogTest.php tests/Feature/078/VerificationReportTenantlessTest.php tests/Feature/144/CanonicalOperationViewerContextMismatchTest.php tests/Feature/Baselines/BaselineCompareSummaryAssessmentTest.php tests/Feature/Baselines/TenantGovernanceAggregateResolverTest.php tests/Feature/Filament/ReferencedTenantLifecyclePresentationTest.php tests/Feature/Guards/NoAdHocFilamentAuthPatternsTest.php tests/Feature/Monitoring/AuditLogInspectFlowTest.php tests/Feature/Monitoring/HeaderContextBarTest.php tests/Feature/Monitoring/OperationLifecycleFreshnessPresentationTest.php tests/Feature/Monitoring/OperationRunResolvedReferencePresentationTest.php tests/Feature/Notifications/OperationRunNotificationTest.php tests/Feature/OpsUx/QueuedToastCopyTest.php tests/Feature/OpsUx/TerminalNotificationFailureMessageTest.php tests/Feature/System/OpsRunbooks/OpsUxStartSurfaceContractTest.php tests/Feature/Verification/VerificationReportRedactionTest.php`
- `vendor/bin/sail bin pint --dirty --format agent`
- `vendor/bin/sail artisan test --compact`

## Notes
- branch pushed as `175-workspace-governance-attention`
- full suite result: `3235 passed, 8 skipped`

Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de>
Reviewed-on: #206
2026-04-04 21:14:43 +00:00
204 changed files with 17587 additions and 1151 deletions

View File

@ -129,6 +129,16 @@ ## Active Technologies
- PostgreSQL with existing `evidence_snapshots`, `evidence_snapshot_items`, `tenant_reviews`, and `review_packs` tables using current summary JSON and timestamps; no schema change planned (174-evidence-freshness-publication-trust) - PostgreSQL with existing `evidence_snapshots`, `evidence_snapshot_items`, `tenant_reviews`, and `review_packs` tables using current summary JSON and timestamps; no schema change planned (174-evidence-freshness-publication-trust)
- PHP 8.4.15 + Laravel 12, Filament v5, Livewire v4, Pest v4, existing `WorkspaceOverviewBuilder`, `TenantGovernanceAggregateResolver`, `BaselineCompareStats`, `BaselineCompareSummaryAssessor`, `WorkspaceSummaryStats`, `WorkspaceNeedsAttention`, `WorkspaceRecentOperations`, `FindingResource`, `BaselineCompareLanding`, `EvidenceSnapshotResource`, `TenantReviewResource`, and canonical admin Operations routes (175-workspace-governance-attention) - PHP 8.4.15 + Laravel 12, Filament v5, Livewire v4, Pest v4, existing `WorkspaceOverviewBuilder`, `TenantGovernanceAggregateResolver`, `BaselineCompareStats`, `BaselineCompareSummaryAssessor`, `WorkspaceSummaryStats`, `WorkspaceNeedsAttention`, `WorkspaceRecentOperations`, `FindingResource`, `BaselineCompareLanding`, `EvidenceSnapshotResource`, `TenantReviewResource`, and canonical admin Operations routes (175-workspace-governance-attention)
- PostgreSQL unchanged; no new persistence, cache table, or materialized aggregate is introduced (175-workspace-governance-attention) - PostgreSQL unchanged; no new persistence, cache table, or materialized aggregate is introduced (175-workspace-governance-attention)
- 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 (179-provider-truth-cleanup)
- PostgreSQL unchanged; no new table, column, or persisted artifact is introduced (179-provider-truth-cleanup)
- 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)
- 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)
- PostgreSQL unchanged; existing `operation_runs` JSONB-backed `context`, `summary_counts`, and `failure_summary`; no schema change (178-ops-truth-alignment)
- PHP 8.4, Laravel 12, Blade, Filament v5, Livewire v4 + Filament v5, Livewire v4, Pest v4, Laravel Sail, existing `RestoreRunResource`, `RestoreService`, `RestoreRiskChecker`, `RestoreDiffGenerator`, `OperationRunResource`, `TenantlessOperationRunViewer`, shared badge infrastructure, and existing RBAC or write-gate helpers (181-restore-safety-integrity)
- PostgreSQL with existing `restore_runs` and `operation_runs` records plus JSON or array-backed `metadata`, `preview`, `results`, and `context`; no schema change planned (181-restore-safety-integrity)
- PHP 8.4, Laravel 12, Blade, Filament v5, Livewire v4 + Filament v5, Livewire v4, Pest v4, Laravel Sail, existing `BackupSetResource`, `BackupItemsRelationManager`, `PolicyVersionResource`, `RestoreRunResource`, `CreateRestoreRun`, `AssignmentBackupService`, `VersionService`, `PolicySnapshotService`, `RestoreRiskChecker`, `BadgeRenderer`, `PolicySnapshotModeBadge`, `EnterpriseDetailBuilder`, and existing RBAC helpers (176-backup-quality-truth)
- PostgreSQL with existing tenant-owned `backup_sets`, `backup_items`, `policy_versions`, and restore wizard input state; JSON-backed `metadata`, `snapshot`, `assignments`, and `scope_tags`; no schema change planned (176-backup-quality-truth)
- PHP 8.4.15 (feat/005-bulk-operations) - PHP 8.4.15 (feat/005-bulk-operations)
@ -148,8 +158,8 @@ ## Code Style
PHP 8.4.15: Follow standard conventions PHP 8.4.15: Follow standard conventions
## Recent Changes ## Recent Changes
- 175-workspace-governance-attention: Added PHP 8.4.15 + Laravel 12, Filament v5, Livewire v4, Pest v4, existing `WorkspaceOverviewBuilder`, `TenantGovernanceAggregateResolver`, `BaselineCompareStats`, `BaselineCompareSummaryAssessor`, `WorkspaceSummaryStats`, `WorkspaceNeedsAttention`, `WorkspaceRecentOperations`, `FindingResource`, `BaselineCompareLanding`, `EvidenceSnapshotResource`, `TenantReviewResource`, and canonical admin Operations routes - 176-backup-quality-truth: Added PHP 8.4, Laravel 12, Blade, Filament v5, Livewire v4 + Filament v5, Livewire v4, Pest v4, Laravel Sail, existing `BackupSetResource`, `BackupItemsRelationManager`, `PolicyVersionResource`, `RestoreRunResource`, `CreateRestoreRun`, `AssignmentBackupService`, `VersionService`, `PolicySnapshotService`, `RestoreRiskChecker`, `BadgeRenderer`, `PolicySnapshotModeBadge`, `EnterpriseDetailBuilder`, and existing RBAC helpers
- 174-evidence-freshness-publication-trust: Added PHP 8.4, Laravel 12, Filament v5, Livewire v4, Blade + Filament v5, Livewire v4, Pest v4, Laravel Sail, existing `ArtifactTruthPresenter`, `ArtifactTruthEnvelope`, `TenantReviewReadinessGate`, `EvidenceSnapshotService`, `TenantReviewRegisterService`, and current evidence/review/review-pack resources and pages - 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
- 173-tenant-dashboard-truth-alignment: Added PHP 8.4.15 + Laravel 12, Filament v5, Livewire v4, Pest v4, existing `TenantDashboard`, `DashboardKpis`, `NeedsAttention`, `BaselineCompareNow`, `RecentDriftFindings`, `RecentOperations`, `TenantGovernanceAggregateResolver`, `BaselineCompareStats`, `BaselineCompareSummaryAssessor`, `FindingResource`, `OperationRunLinks`, and canonical admin Operations page - 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
<!-- 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

@ -6,19 +6,20 @@
use App\Filament\Clusters\Inventory\InventoryCluster; use App\Filament\Clusters\Inventory\InventoryCluster;
use App\Filament\Concerns\ResolvesPanelTenantContext; use App\Filament\Concerns\ResolvesPanelTenantContext;
use App\Filament\Resources\InventoryItemResource;
use App\Filament\Widgets\Inventory\InventoryKpiHeader; use App\Filament\Widgets\Inventory\InventoryKpiHeader;
use App\Models\OperationRun;
use App\Models\Tenant; use App\Models\Tenant;
use App\Models\User; use App\Models\User;
use App\Services\Auth\CapabilityResolver; use App\Services\Auth\CapabilityResolver;
use App\Services\Inventory\CoverageCapabilitiesResolver;
use App\Support\Auth\Capabilities; use App\Support\Auth\Capabilities;
use App\Support\Badges\BadgeCatalog; use App\Support\Badges\BadgeCatalog;
use App\Support\Badges\BadgeDomain; use App\Support\Badges\BadgeDomain;
use App\Support\Badges\BadgeRenderer; use App\Support\Badges\BadgeRenderer;
use App\Support\Badges\TagBadgeCatalog; use App\Support\Badges\TagBadgeCatalog;
use App\Support\Badges\TagBadgeDomain; use App\Support\Badges\TagBadgeDomain;
use App\Support\Badges\TagBadgeRenderer; use App\Support\Inventory\TenantCoverageTruth;
use App\Support\Inventory\InventoryPolicyTypeMeta; use App\Support\Inventory\TenantCoverageTruthResolver;
use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration; use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration;
use App\Support\Ui\ActionSurface\ActionSurfaceDefaults; use App\Support\Ui\ActionSurface\ActionSurfaceDefaults;
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile; use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile;
@ -56,6 +57,8 @@ class InventoryCoverage extends Page implements HasTable
protected string $view = 'filament.pages.inventory-coverage'; protected string $view = 'filament.pages.inventory-coverage';
protected ?TenantCoverageTruth $cachedCoverageTruth = null;
public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
{ {
return ActionSurfaceDeclaration::forPage(ActionSurfaceProfile::ListOnlyReadOnly) return ActionSurfaceDeclaration::forPage(ActionSurfaceProfile::ListOnlyReadOnly)
@ -67,7 +70,7 @@ public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
->exempt(ActionSurfaceSlot::InspectAffordance, 'Inventory coverage rows are runtime-derived metadata and intentionally omit inspect affordances.') ->exempt(ActionSurfaceSlot::InspectAffordance, 'Inventory coverage rows are runtime-derived metadata and intentionally omit inspect affordances.')
->exempt(ActionSurfaceSlot::ListRowMoreMenu, 'Derived coverage rows do not expose row actions.') ->exempt(ActionSurfaceSlot::ListRowMoreMenu, 'Derived coverage rows do not expose row actions.')
->exempt(ActionSurfaceSlot::ListBulkMoreGroup, 'Derived coverage rows do not expose bulk actions.') ->exempt(ActionSurfaceSlot::ListBulkMoreGroup, 'Derived coverage rows do not expose bulk actions.')
->satisfy(ActionSurfaceSlot::ListEmptyState, 'The empty state provides a clear-filters CTA to return to the full coverage matrix.'); ->satisfy(ActionSurfaceSlot::ListEmptyState, 'The empty state provides a clear-filters CTA to return to the full tenant coverage report.');
} }
public static function shouldRegisterNavigation(): bool public static function shouldRegisterNavigation(): bool
@ -110,9 +113,12 @@ protected function getHeaderWidgets(): array
public function table(Table $table): Table public function table(Table $table): Table
{ {
return $table return $table
->persistFiltersInSession()
->persistSearchInSession()
->persistSortInSession()
->searchable() ->searchable()
->searchPlaceholder('Search by policy type or label') ->searchPlaceholder('Search by type or label')
->defaultSort('label') ->defaultSort('follow_up_priority')
->defaultPaginationPageOption(50) ->defaultPaginationPageOption(50)
->paginated(\App\Support\Filament\TablePaginationProfiles::customPage()) ->paginated(\App\Support\Filament\TablePaginationProfiles::customPage())
->records(function ( ->records(function (
@ -142,14 +148,16 @@ public function table(Table $table): Table
); );
}) })
->columns([ ->columns([
TextColumn::make('type') TextColumn::make('coverage_state')
->label('Type') ->label('Coverage state')
->sortable() ->badge()
->fontFamily(FontFamily::Mono) ->formatStateUsing(BadgeRenderer::label(BadgeDomain::InventoryCoverageState))
->copyable() ->color(BadgeRenderer::color(BadgeDomain::InventoryCoverageState))
->wrap(), ->icon(BadgeRenderer::icon(BadgeDomain::InventoryCoverageState))
->iconColor(BadgeRenderer::iconColor(BadgeDomain::InventoryCoverageState))
->sortable(),
TextColumn::make('label') TextColumn::make('label')
->label('Label') ->label('Type')
->sortable() ->sortable()
->badge() ->badge()
->formatStateUsing(function (?string $state, array $record): string { ->formatStateUsing(function (?string $state, array $record): string {
@ -179,17 +187,29 @@ public function table(Table $table): Table
return $spec->iconColor ?? $spec->color; return $spec->iconColor ?? $spec->color;
}) })
->wrap(), ->wrap(),
TextColumn::make('risk') TextColumn::make('follow_up_guidance')
->label('Risk') ->label('Follow-up guidance')
->wrap()
->toggleable(),
TextColumn::make('observed_item_count')
->label('Observed items')
->numeric()
->sortable(),
TextColumn::make('category')
->badge() ->badge()
->formatStateUsing(BadgeRenderer::label(BadgeDomain::PolicyRisk)) ->formatStateUsing(fn (?string $state): string => TagBadgeCatalog::spec(TagBadgeDomain::PolicyCategory, $state)->label)
->color(BadgeRenderer::color(BadgeDomain::PolicyRisk)) ->color(fn (?string $state): string => TagBadgeCatalog::spec(TagBadgeDomain::PolicyCategory, $state)->color)
->icon(BadgeRenderer::icon(BadgeDomain::PolicyRisk)) ->icon(fn (?string $state): ?string => TagBadgeCatalog::spec(TagBadgeDomain::PolicyCategory, $state)->icon)
->iconColor(BadgeRenderer::iconColor(BadgeDomain::PolicyRisk)), ->iconColor(function (?string $state): ?string {
$spec = TagBadgeCatalog::spec(TagBadgeDomain::PolicyCategory, $state);
return $spec->iconColor ?? $spec->color;
})
->toggleable()
->wrap(),
TextColumn::make('restore') TextColumn::make('restore')
->label('Restore') ->label('Restore')
->badge() ->badge()
->state(fn (array $record): ?string => $record['restore'])
->formatStateUsing(function (?string $state): string { ->formatStateUsing(function (?string $state): string {
return filled($state) return filled($state)
? BadgeCatalog::spec(BadgeDomain::PolicyRestoreMode, $state)->label ? BadgeCatalog::spec(BadgeDomain::PolicyRestoreMode, $state)->label
@ -213,20 +233,7 @@ public function table(Table $table): Table
$spec = BadgeCatalog::spec(BadgeDomain::PolicyRestoreMode, $state); $spec = BadgeCatalog::spec(BadgeDomain::PolicyRestoreMode, $state);
return $spec->iconColor ?? $spec->color; return $spec->iconColor ?? $spec->color;
}), })
TextColumn::make('category')
->badge()
->formatStateUsing(TagBadgeRenderer::label(TagBadgeDomain::PolicyCategory))
->color(TagBadgeRenderer::color(TagBadgeDomain::PolicyCategory))
->icon(TagBadgeRenderer::icon(TagBadgeDomain::PolicyCategory))
->iconColor(TagBadgeRenderer::iconColor(TagBadgeDomain::PolicyCategory))
->toggleable()
->wrap(),
TextColumn::make('segment')
->label('Segment')
->badge()
->formatStateUsing(fn (?string $state): string => $state === 'foundation' ? 'Foundation' : 'Policy')
->color(fn (?string $state): string => $state === 'foundation' ? 'gray' : 'info')
->toggleable(), ->toggleable(),
IconColumn::make('dependencies') IconColumn::make('dependencies')
->label('Dependencies') ->label('Dependencies')
@ -237,10 +244,31 @@ public function table(Table $table): Table
->falseColor('gray') ->falseColor('gray')
->alignCenter() ->alignCenter()
->toggleable(), ->toggleable(),
TextColumn::make('type')
->label('Type key')
->sortable()
->fontFamily(FontFamily::Mono)
->copyable()
->wrap()
->toggleable(isToggledHiddenByDefault: true),
TextColumn::make('segment')
->label('Segment')
->badge()
->formatStateUsing(fn (?string $state): string => $state === 'foundation' ? 'Foundation' : 'Policy')
->color(fn (?string $state): string => $state === 'foundation' ? 'gray' : 'info')
->toggleable(isToggledHiddenByDefault: true),
TextColumn::make('risk')
->label('Risk')
->badge()
->formatStateUsing(BadgeRenderer::label(BadgeDomain::PolicyRisk))
->color(BadgeRenderer::color(BadgeDomain::PolicyRisk))
->icon(BadgeRenderer::icon(BadgeDomain::PolicyRisk))
->iconColor(BadgeRenderer::iconColor(BadgeDomain::PolicyRisk))
->toggleable(isToggledHiddenByDefault: true),
]) ])
->filters($this->tableFilters()) ->filters($this->tableFilters())
->emptyStateHeading('No coverage entries match this view') ->emptyStateHeading('No coverage rows match this report')
->emptyStateDescription('Clear the current search or filters to return to the full coverage matrix.') ->emptyStateDescription('Clear the current search or filters to return to the full tenant coverage report.')
->emptyStateIcon('heroicon-o-funnel') ->emptyStateIcon('heroicon-o-funnel')
->emptyStateActions([ ->emptyStateActions([
Action::make('clear_filters') Action::make('clear_filters')
@ -261,6 +289,14 @@ public function table(Table $table): Table
protected function tableFilters(): array protected function tableFilters(): array
{ {
$filters = [ $filters = [
SelectFilter::make('coverage_state')
->label('Coverage state')
->options([
'succeeded' => BadgeCatalog::spec(BadgeDomain::InventoryCoverageState, 'succeeded')->label,
'failed' => BadgeCatalog::spec(BadgeDomain::InventoryCoverageState, 'failed')->label,
'skipped' => BadgeCatalog::spec(BadgeDomain::InventoryCoverageState, 'skipped')->label,
'unknown' => BadgeCatalog::spec(BadgeDomain::InventoryCoverageState, 'unknown')->label,
]),
SelectFilter::make('category') SelectFilter::make('category')
->label('Category') ->label('Category')
->options($this->categoryFilterOptions()), ->options($this->categoryFilterOptions()),
@ -279,84 +315,36 @@ protected function tableFilters(): array
* @return Collection<string, array{ * @return Collection<string, array{
* __key: string, * __key: string,
* key: string, * key: string,
* segment: string,
* type: string, * type: string,
* segment: string,
* label: string, * label: string,
* category: string, * category: string,
* dependencies: bool, * platform: ?string,
* coverage_state: string,
* follow_up_required: bool,
* follow_up_priority: int,
* follow_up_guidance: string,
* observed_item_count: int,
* basis_item_count: ?int,
* basis_error_code: ?string,
* restore: ?string, * restore: ?string,
* risk: string, * risk: ?string,
* source_order: int * dependencies: bool,
* is_basis_payload_backed: bool
* }> * }>
*/ */
protected function coverageRows(): Collection protected function coverageRows(): Collection
{ {
$resolver = app(CoverageCapabilitiesResolver::class); $truth = $this->coverageTruth();
$supported = $this->mapCoverageRows( if (! $truth instanceof TenantCoverageTruth) {
rows: InventoryPolicyTypeMeta::supported(), return collect();
segment: 'policy',
sourceOrderOffset: 0,
resolver: $resolver,
);
return $supported->merge($this->mapCoverageRows(
rows: InventoryPolicyTypeMeta::foundations(),
segment: 'foundation',
sourceOrderOffset: $supported->count(),
resolver: $resolver,
));
} }
/** return collect($truth->rows)
* @param array<int, array<string, mixed>> $rows ->mapWithKeys(static fn ($row): array => [
* @return Collection<string, array{ $row->key => $row->toArray(),
* __key: string, ]);
* key: string,
* segment: string,
* type: string,
* label: string,
* category: string,
* dependencies: bool,
* restore: ?string,
* risk: string,
* source_order: int
* }>
*/
protected function mapCoverageRows(
array $rows,
string $segment,
int $sourceOrderOffset,
CoverageCapabilitiesResolver $resolver
): Collection {
return collect($rows)
->values()
->mapWithKeys(function (array $row, int $index) use ($resolver, $segment, $sourceOrderOffset): array {
$type = (string) ($row['type'] ?? '');
if ($type === '') {
return [];
}
$key = "{$segment}:{$type}";
$restore = $row['restore'] ?? null;
$risk = $row['risk'] ?? 'n/a';
return [
$key => [
'__key' => $key,
'key' => $key,
'segment' => $segment,
'type' => $type,
'label' => (string) ($row['label'] ?? $type),
'category' => (string) ($row['category'] ?? 'Other'),
'dependencies' => $segment === 'policy' && $resolver->supportsDependencies($type),
'restore' => is_string($restore) ? $restore : null,
'risk' => is_string($risk) ? $risk : 'n/a',
'source_order' => $sourceOrderOffset + $index,
],
];
});
} }
/** /**
@ -367,6 +355,7 @@ protected function mapCoverageRows(
protected function filterRows(Collection $rows, ?string $search, array $filters): Collection protected function filterRows(Collection $rows, ?string $search, array $filters): Collection
{ {
$normalizedSearch = Str::lower(trim((string) $search)); $normalizedSearch = Str::lower(trim((string) $search));
$coverageState = $filters['coverage_state']['value'] ?? null;
$category = $filters['category']['value'] ?? null; $category = $filters['category']['value'] ?? null;
$restore = $filters['restore']['value'] ?? null; $restore = $filters['restore']['value'] ?? null;
@ -380,6 +369,10 @@ function (Collection $rows) use ($normalizedSearch): Collection {
}); });
}, },
) )
->when(
filled($coverageState),
fn (Collection $rows): Collection => $rows->where('coverage_state', (string) $coverageState),
)
->when( ->when(
filled($category), filled($category),
fn (Collection $rows): Collection => $rows->where('category', (string) $category), fn (Collection $rows): Collection => $rows->where('category', (string) $category),
@ -396,22 +389,35 @@ function (Collection $rows) use ($normalizedSearch): Collection {
*/ */
protected function sortRows(Collection $rows, ?string $sortColumn, ?string $sortDirection): Collection protected function sortRows(Collection $rows, ?string $sortColumn, ?string $sortDirection): Collection
{ {
$sortColumn = in_array($sortColumn, ['type', 'label'], true) ? $sortColumn : null; $sortColumn = in_array($sortColumn, ['type', 'label', 'observed_item_count', 'coverage_state', 'follow_up_priority'], true)
? $sortColumn
: null;
if ($sortColumn === null) { if ($sortColumn === null) {
return $rows->sortBy('source_order'); return $rows;
} }
$records = $rows->all(); $records = $rows->all();
uasort($records, function (array $left, array $right) use ($sortColumn, $sortDirection): int { uasort($records, function (array $left, array $right) use ($sortColumn, $sortDirection): int {
$comparison = strnatcasecmp( $comparison = match ($sortColumn) {
'observed_item_count' => ((int) ($left[$sortColumn] ?? 0)) <=> ((int) ($right[$sortColumn] ?? 0)),
'follow_up_priority' => ((int) ($left[$sortColumn] ?? 0)) <=> ((int) ($right[$sortColumn] ?? 0)),
default => strnatcasecmp(
(string) ($left[$sortColumn] ?? ''), (string) ($left[$sortColumn] ?? ''),
(string) ($right[$sortColumn] ?? ''), (string) ($right[$sortColumn] ?? ''),
); ),
};
if ($comparison === 0 && $sortColumn === 'follow_up_priority') {
$comparison = ((int) ($right['observed_item_count'] ?? 0)) <=> ((int) ($left['observed_item_count'] ?? 0));
}
if ($comparison === 0) { if ($comparison === 0) {
$comparison = ((int) ($left['source_order'] ?? 0)) <=> ((int) ($right['source_order'] ?? 0)); $comparison = strnatcasecmp(
(string) ($left['label'] ?? ''),
(string) ($right['label'] ?? ''),
);
} }
return $sortDirection === 'desc' ? ($comparison * -1) : $comparison; return $sortDirection === 'desc' ? ($comparison * -1) : $comparison;
@ -468,4 +474,99 @@ protected function restoreFilterOptions(): array
}) })
->all(); ->all();
} }
/**
* @return array<string, mixed>
*/
public function coverageSummary(): array
{
$truth = $this->coverageTruth();
if (! $truth instanceof TenantCoverageTruth) {
return [];
}
return [
'supportedTypes' => $truth->supportedTypeCount,
'succeededTypes' => $truth->succeededTypeCount,
'followUpTypes' => $truth->followUpTypeCount,
'observedItems' => $truth->observedItemTotal,
'observedTypes' => $truth->observedTypeCount(),
'topFollowUpLabel' => $truth->topPriorityFollowUpRow()?->label,
'topFollowUpGuidance' => $truth->topPriorityFollowUpRow()?->followUpGuidance,
'hasCurrentCoverageResult' => $truth->hasCurrentCoverageResult,
];
}
/**
* @return array<string, mixed>
*/
public function basisRunSummary(): array
{
$truth = $this->coverageTruth();
$tenant = static::resolveTenantContextForCurrentPanel();
$user = auth()->user();
if (! $truth instanceof TenantCoverageTruth || ! $tenant instanceof Tenant) {
return [];
}
if (! $truth->basisRun instanceof OperationRun) {
return [
'title' => 'No current coverage basis',
'body' => $user instanceof User && $user->can(Capabilities::TENANT_INVENTORY_SYNC_RUN, $tenant)
? 'Run Inventory Sync from Inventory Items to establish current tenant coverage truth.'
: 'A tenant operator with inventory sync permission must establish current tenant coverage truth.',
'badgeLabel' => null,
'badgeColor' => null,
'runUrl' => null,
'historyUrl' => null,
'inventoryItemsUrl' => InventoryItemResource::getUrl('index', tenant: $tenant),
];
}
$badge = BadgeRenderer::spec(BadgeDomain::OperationRunOutcome, (string) $truth->basisRun->outcome);
$canViewRun = $user instanceof User && $user->can('view', $truth->basisRun);
return [
'title' => sprintf('Latest coverage-bearing sync completed %s.', $truth->basisCompletedAtLabel() ?? 'recently'),
'body' => $canViewRun
? 'Review the cited inventory sync to inspect provider or permission issues in detail.'
: 'The coverage basis is current, but your role cannot open the cited run detail.',
'badgeLabel' => $badge->label,
'badgeColor' => $badge->color,
'runUrl' => $canViewRun ? route('admin.operations.view', ['run' => (int) $truth->basisRun->getKey()]) : null,
'historyUrl' => $canViewRun ? $this->inventorySyncHistoryUrl($tenant) : null,
'inventoryItemsUrl' => InventoryItemResource::getUrl('index', tenant: $tenant),
];
}
protected function coverageTruth(): ?TenantCoverageTruth
{
if ($this->cachedCoverageTruth instanceof TenantCoverageTruth) {
return $this->cachedCoverageTruth;
}
$tenant = static::resolveTenantContextForCurrentPanel();
if (! $tenant instanceof Tenant) {
return null;
}
$this->cachedCoverageTruth = app(TenantCoverageTruthResolver::class)->resolve($tenant);
return $this->cachedCoverageTruth;
}
private function inventorySyncHistoryUrl(Tenant $tenant): string
{
return route('admin.operations.index', [
'tenant_id' => (int) $tenant->getKey(),
'tableFilters' => [
'type' => [
'value' => 'inventory_sync',
],
],
]);
}
} }

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

@ -233,6 +233,8 @@ private function applyActiveTab(Builder $query): Builder
{ {
return match ($this->activeTab) { return match ($this->activeTab) {
'active' => $query->healthyActive(), 'active' => $query->healthyActive(),
OperationRun::PROBLEM_CLASS_ACTIVE_STALE_ATTENTION => $query->activeStaleAttention(),
OperationRun::PROBLEM_CLASS_TERMINAL_FOLLOW_UP => $query->terminalFollowUp(),
'blocked' => $query->dashboardNeedsFollowUp(), 'blocked' => $query->dashboardNeedsFollowUp(),
'succeeded' => $query 'succeeded' => $query
->where('status', OperationRunStatus::Completed->value) ->where('status', OperationRunStatus::Completed->value)
@ -281,9 +283,29 @@ private function applyRequestedDashboardPrefilter(): void
} }
} }
$requestedProblemClass = request()->query('problemClass');
if (in_array($requestedProblemClass, [
OperationRun::PROBLEM_CLASS_ACTIVE_STALE_ATTENTION,
OperationRun::PROBLEM_CLASS_TERMINAL_FOLLOW_UP,
], true)) {
$this->activeTab = (string) $requestedProblemClass;
return;
}
$requestedTab = request()->query('activeTab'); $requestedTab = request()->query('activeTab');
if (in_array($requestedTab, ['all', 'active', 'blocked', 'succeeded', 'partial', 'failed'], true)) { if (in_array($requestedTab, [
'all',
'active',
'blocked',
'succeeded',
'partial',
'failed',
OperationRun::PROBLEM_CLASS_ACTIVE_STALE_ATTENTION,
OperationRun::PROBLEM_CLASS_TERMINAL_FOLLOW_UP,
], true)) {
$this->activeTab = (string) $requestedTab; $this->activeTab = (string) $requestedTab;
} }
} }

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

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

View File

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

View File

@ -5,6 +5,7 @@
use App\Filament\Support\VerificationReportChangeIndicator; use App\Filament\Support\VerificationReportChangeIndicator;
use App\Filament\Support\VerificationReportViewer; use App\Filament\Support\VerificationReportViewer;
use App\Models\OperationRun; use App\Models\OperationRun;
use App\Models\RestoreRun;
use App\Models\Tenant; use App\Models\Tenant;
use App\Models\User; use App\Models\User;
use App\Models\VerificationCheckAcknowledgement; use App\Models\VerificationCheckAcknowledgement;
@ -16,6 +17,9 @@
use App\Support\Filament\FilterOptionCatalog; use App\Support\Filament\FilterOptionCatalog;
use App\Support\Filament\FilterPresets; use App\Support\Filament\FilterPresets;
use App\Support\Filament\TablePaginationProfiles; use App\Support\Filament\TablePaginationProfiles;
use App\Support\Inventory\InventoryCoverage;
use App\Support\Inventory\InventoryPolicyTypeMeta;
use App\Support\Inventory\TenantCoverageTruthResolver;
use App\Support\Navigation\CrossResourceNavigationMatrix; use App\Support\Navigation\CrossResourceNavigationMatrix;
use App\Support\Navigation\RelatedNavigationResolver; use App\Support\Navigation\RelatedNavigationResolver;
use App\Support\OperateHub\OperateHubShell; use App\Support\OperateHub\OperateHubShell;
@ -27,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;
@ -264,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,
@ -316,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'],
@ -472,6 +485,21 @@ private static function enterpriseDetailPage(OperationRun $record): \App\Support
} }
} }
$inventorySyncCoverageSection = static::inventorySyncCoverageSection($record);
if ($inventorySyncCoverageSection !== null) {
$builder->addSection(
$factory->viewSection(
id: 'inventory_sync_coverage',
kind: 'type_specific_detail',
title: 'Inventory sync coverage',
description: 'Per-type run results explain what this sync established without forcing operators into raw JSON first.',
view: 'filament.infolists.entries.inventory-coverage-truth',
viewData: $inventorySyncCoverageSection,
),
);
}
if (VerificationReportViewer::shouldRenderForRun($record)) { if (VerificationReportViewer::shouldRenderForRun($record)) {
$builder->addSection( $builder->addSection(
$factory->viewSection( $factory->viewSection(
@ -766,7 +794,7 @@ private static function artifactTruthFact(
private static function decisionAttentionNote(OperationRun $record): ?string private static function decisionAttentionNote(OperationRun $record): ?string
{ {
return null; return OperationUxPresenter::decisionAttentionNote($record);
} }
private static function detailHintUnlessDuplicate(?string $hint, ?string $duplicateOf): ?string private static function detailHintUnlessDuplicate(?string $hint, ?string $duplicateOf): ?string
@ -1169,6 +1197,106 @@ private static function reconciliationPayload(OperationRun $record): array
return $reconciliation; return $reconciliation;
} }
/**
* @return array{
* rows: list<array{
* type: string,
* label: string,
* segment: string,
* category: string,
* coverageState: string,
* followUpRequired: bool,
* followUpPriority: int,
* followUpGuidance: string,
* itemCount: int,
* errorCode: ?string
* }>,
* summary: array{
* totalTypes: int,
* succeededTypes: int,
* failedTypes: int,
* skippedTypes: int,
* followUpTypes: int,
* observedItems: int
* },
* runOutcomeLabel: string,
* runOutcomeColor: string,
* runOutcomeIcon: ?string
* }|null
*/
private static function inventorySyncCoverageSection(OperationRun $record): ?array
{
if ((string) $record->type !== 'inventory_sync') {
return null;
}
$coverage = $record->inventoryCoverage();
if (! $coverage instanceof InventoryCoverage) {
return null;
}
$rows = collect($coverage->rows())
->map(function (array $row): array {
$type = (string) ($row['type'] ?? '');
$meta = InventoryPolicyTypeMeta::metaFor($type);
$status = is_string($row['status'] ?? null) ? (string) $row['status'] : InventoryCoverage::StatusFailed;
$errorCode = is_string($row['error_code'] ?? null) ? (string) $row['error_code'] : null;
$itemCount = is_int($row['item_count'] ?? null) ? (int) $row['item_count'] : 0;
return [
'type' => $type,
'label' => is_string($meta['label'] ?? null) && $meta['label'] !== ''
? (string) $meta['label']
: $type,
'segment' => (string) ($row['segment'] ?? 'policy'),
'category' => is_string($meta['category'] ?? null) && $meta['category'] !== ''
? (string) $meta['category']
: 'Other',
'coverageState' => $status,
'followUpRequired' => $status !== InventoryCoverage::StatusSucceeded,
'followUpPriority' => TenantCoverageTruthResolver::followUpPriorityForState($status),
'followUpGuidance' => TenantCoverageTruthResolver::followUpGuidanceForState($status, $errorCode),
'itemCount' => $itemCount,
'errorCode' => $errorCode,
];
})
->sort(function (array $left, array $right): int {
$priority = ((int) ($left['followUpPriority'] ?? 0)) <=> ((int) ($right['followUpPriority'] ?? 0));
if ($priority !== 0) {
return $priority;
}
$items = ((int) ($right['itemCount'] ?? 0)) <=> ((int) ($left['itemCount'] ?? 0));
if ($items !== 0) {
return $items;
}
return strnatcasecmp((string) ($left['label'] ?? ''), (string) ($right['label'] ?? ''));
})
->values()
->all();
$outcomeSpec = BadgeRenderer::spec(BadgeDomain::OperationRunOutcome, static::outcomeBadgeState($record));
return [
'rows' => $rows,
'summary' => [
'totalTypes' => count($rows),
'succeededTypes' => count(array_filter($rows, static fn (array $row): bool => ($row['coverageState'] ?? null) === InventoryCoverage::StatusSucceeded)),
'failedTypes' => count(array_filter($rows, static fn (array $row): bool => ($row['coverageState'] ?? null) === InventoryCoverage::StatusFailed)),
'skippedTypes' => count(array_filter($rows, static fn (array $row): bool => ($row['coverageState'] ?? null) === InventoryCoverage::StatusSkipped)),
'followUpTypes' => count(array_filter($rows, static fn (array $row): bool => (bool) ($row['followUpRequired'] ?? false))),
'observedItems' => array_sum(array_map(static fn (array $row): int => (int) ($row['itemCount'] ?? 0), $rows)),
],
'runOutcomeLabel' => $outcomeSpec->label,
'runOutcomeColor' => $outcomeSpec->color,
'runOutcomeIcon' => $outcomeSpec->icon,
];
}
private static function formatDetailTimestamp(mixed $value): string private static function formatDetailTimestamp(mixed $value): string
{ {
if (! $value instanceof \Illuminate\Support\Carbon) { if (! $value instanceof \Illuminate\Support\Carbon) {
@ -1210,6 +1338,58 @@ private static function surfaceGuidance(OperationRun $record, bool $fresh = fals
: OperationUxPresenter::surfaceGuidance($record); : OperationUxPresenter::surfaceGuidance($record);
} }
/**
* @return array{
* restore_run_id: int,
* state: string,
* summary: string,
* primary_next_action: string,
* recovery_claim_boundary: string,
* follow_up_required: bool,
* badge_label: string,
* link_url: ?string,
* link_available: bool
* }|null
*/
public static function restoreContinuation(OperationRun $record): ?array
{
if ($record->type !== 'restore.execute') {
return null;
}
$context = is_array($record->context) ? $record->context : [];
$restoreRunId = is_numeric($context['restore_run_id'] ?? null) ? (int) $context['restore_run_id'] : null;
$restoreRun = $restoreRunId !== null
? RestoreRun::query()->find($restoreRunId)
: RestoreRun::query()->where('operation_run_id', (int) $record->getKey())->latest('id')->first();
if (! $restoreRun instanceof RestoreRun) {
return null;
}
$attention = app(RestoreSafetyResolver::class)->resultAttentionForRun($restoreRun);
$tenant = $record->tenant;
$user = auth()->user();
$canOpenRestore = $tenant instanceof Tenant
&& $user instanceof User
&& app(\App\Services\Auth\CapabilityResolver::class)->isMember($user, $tenant);
return [
'restore_run_id' => (int) $restoreRun->getKey(),
'state' => $attention->state,
'summary' => $attention->summary,
'primary_next_action' => $attention->primaryNextAction,
'recovery_claim_boundary' => $attention->recoveryClaimBoundary,
'follow_up_required' => $attention->followUpRequired,
'badge_label' => \App\Support\Badges\BadgeRenderer::spec(\App\Support\Badges\BadgeDomain::RestoreResultStatus, $attention->state)->label,
'link_url' => $canOpenRestore
? RestoreRunResource::getUrl('view', ['record' => $restoreRun], panel: 'tenant', tenant: $tenant)
: null,
'link_available' => $canOpenRestore,
];
}
/** /**
* @return list<array{ * @return list<array{
* key: string, * key: string,

View File

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

View File

@ -469,29 +469,12 @@ private static function migrationReviewDescription(?ProviderConnection $record):
private static function consentStatusLabelFromState(mixed $state): string private static function consentStatusLabelFromState(mixed $state): string
{ {
$value = $state instanceof BackedEnum ? $state->value : (is_string($state) ? $state : 'unknown'); return BadgeRenderer::spec(BadgeDomain::ProviderConsentStatus, $state)->label;
return match ($value) {
'required' => 'Required',
'granted' => 'Granted',
'failed' => 'Failed',
'revoked' => 'Revoked',
default => 'Unknown',
};
} }
private static function verificationStatusLabelFromState(mixed $state): string private static function verificationStatusLabelFromState(mixed $state): string
{ {
$value = $state instanceof BackedEnum ? $state->value : (is_string($state) ? $state : 'unknown'); return BadgeRenderer::spec(BadgeDomain::ProviderVerificationStatus, $state)->label;
return match ($value) {
'pending' => 'Pending',
'healthy' => 'Healthy',
'degraded' => 'Degraded',
'blocked' => 'Blocked',
'error' => 'Error',
default => 'Unknown',
};
} }
public static function form(Schema $schema): Schema public static function form(Schema $schema): Schema
@ -527,7 +510,7 @@ public static function form(Schema $schema): Schema
]) ])
->columns(2) ->columns(2)
->columnSpanFull(), ->columnSpanFull(),
Section::make('Status') Section::make('Current state')
->schema([ ->schema([
Placeholder::make('consent_status_display') Placeholder::make('consent_status_display')
->label('Consent') ->label('Consent')
@ -535,18 +518,31 @@ public static function form(Schema $schema): Schema
Placeholder::make('verification_status_display') Placeholder::make('verification_status_display')
->label('Verification') ->label('Verification')
->content(fn (?ProviderConnection $record): string => static::verificationStatusLabelFromState($record?->verification_status)), ->content(fn (?ProviderConnection $record): string => static::verificationStatusLabelFromState($record?->verification_status)),
TextInput::make('status') Placeholder::make('last_health_check_at_display')
->label('Status') ->label('Last check')
->disabled() ->content(fn (?ProviderConnection $record): string => $record?->last_health_check_at?->diffForHumans() ?? 'Never'),
->dehydrated(false), ])
TextInput::make('health_status') ->columns(2)
->label('Health') ->columnSpanFull(),
->disabled() Section::make('Diagnostics')
->dehydrated(false), ->schema([
Placeholder::make('status_display')
->label('Legacy status')
->content(fn (?ProviderConnection $record): string => BadgeRenderer::spec(BadgeDomain::ProviderConnectionStatus, $record?->status)->label),
Placeholder::make('health_status_display')
->label('Legacy health')
->content(fn (?ProviderConnection $record): string => BadgeRenderer::spec(BadgeDomain::ProviderConnectionHealth, $record?->health_status)->label),
Placeholder::make('migration_review_status_display') Placeholder::make('migration_review_status_display')
->label('Migration review') ->label('Migration review')
->content(fn (?ProviderConnection $record): string => static::migrationReviewLabel($record)) ->content(fn (?ProviderConnection $record): string => static::migrationReviewLabel($record))
->hint(fn (?ProviderConnection $record): ?string => static::migrationReviewDescription($record)), ->hint(fn (?ProviderConnection $record): ?string => static::migrationReviewDescription($record)),
Placeholder::make('last_error_reason_code_display')
->label('Last error reason')
->content(fn (?ProviderConnection $record): string => filled($record?->last_error_reason_code) ? (string) $record->last_error_reason_code : 'n/a'),
Placeholder::make('last_error_message_display')
->label('Last error message')
->content(fn (?ProviderConnection $record): string => static::sanitizeErrorMessage($record?->last_error_message) ?? 'n/a')
->columnSpanFull(),
]) ])
->columns(2) ->columns(2)
->columnSpanFull(), ->columnSpanFull(),
@ -580,22 +576,55 @@ public static function infolist(Schema $schema): Schema
->state(fn (ProviderConnection $record): string => static::credentialSourceLabel($record)), ->state(fn (ProviderConnection $record): string => static::credentialSourceLabel($record)),
]) ])
->columns(2), ->columns(2),
Section::make('Status') Section::make('Current state')
->schema([ ->schema([
Infolists\Components\TextEntry::make('consent_status') Infolists\Components\TextEntry::make('consent_status')
->label('Consent') ->label('Consent')
->formatStateUsing(fn ($state): string => static::consentStatusLabelFromState($state)), ->badge()
->formatStateUsing(fn ($state): string => static::consentStatusLabelFromState($state))
->color(BadgeRenderer::color(BadgeDomain::ProviderConsentStatus))
->icon(BadgeRenderer::icon(BadgeDomain::ProviderConsentStatus))
->iconColor(BadgeRenderer::iconColor(BadgeDomain::ProviderConsentStatus)),
Infolists\Components\TextEntry::make('verification_status') Infolists\Components\TextEntry::make('verification_status')
->label('Verification') ->label('Verification')
->formatStateUsing(fn ($state): string => static::verificationStatusLabelFromState($state)), ->badge()
->formatStateUsing(fn ($state): string => static::verificationStatusLabelFromState($state))
->color(BadgeRenderer::color(BadgeDomain::ProviderVerificationStatus))
->icon(BadgeRenderer::icon(BadgeDomain::ProviderVerificationStatus))
->iconColor(BadgeRenderer::iconColor(BadgeDomain::ProviderVerificationStatus)),
Infolists\Components\TextEntry::make('last_health_check_at')
->label('Last check')
->since(),
])
->columns(2),
Section::make('Diagnostics')
->schema([
Infolists\Components\TextEntry::make('status') Infolists\Components\TextEntry::make('status')
->label('Status'), ->label('Legacy status')
->badge()
->formatStateUsing(BadgeRenderer::label(BadgeDomain::ProviderConnectionStatus))
->color(BadgeRenderer::color(BadgeDomain::ProviderConnectionStatus))
->icon(BadgeRenderer::icon(BadgeDomain::ProviderConnectionStatus))
->iconColor(BadgeRenderer::iconColor(BadgeDomain::ProviderConnectionStatus)),
Infolists\Components\TextEntry::make('health_status') Infolists\Components\TextEntry::make('health_status')
->label('Health'), ->label('Legacy health')
->badge()
->formatStateUsing(BadgeRenderer::label(BadgeDomain::ProviderConnectionHealth))
->color(BadgeRenderer::color(BadgeDomain::ProviderConnectionHealth))
->icon(BadgeRenderer::icon(BadgeDomain::ProviderConnectionHealth))
->iconColor(BadgeRenderer::iconColor(BadgeDomain::ProviderConnectionHealth)),
Infolists\Components\TextEntry::make('migration_review_required') Infolists\Components\TextEntry::make('migration_review_required')
->label('Migration review') ->label('Migration review')
->formatStateUsing(fn (ProviderConnection $record): string => static::migrationReviewLabel($record)) ->formatStateUsing(fn (ProviderConnection $record): string => static::migrationReviewLabel($record))
->tooltip(fn (ProviderConnection $record): ?string => static::migrationReviewDescription($record)), ->tooltip(fn (ProviderConnection $record): ?string => static::migrationReviewDescription($record)),
Infolists\Components\TextEntry::make('last_error_reason_code')
->label('Last error reason')
->placeholder('n/a'),
Infolists\Components\TextEntry::make('last_error_message')
->label('Last error message')
->formatStateUsing(fn (?string $state): ?string => static::sanitizeErrorMessage($state))
->placeholder('n/a')
->columnSpanFull(),
]) ])
->columns(2), ->columns(2),
]); ]);
@ -655,20 +684,36 @@ public static function table(Table $table): Table
? 'Dedicated' ? 'Dedicated'
: 'Platform') : 'Platform')
->color(fn (?ProviderConnectionType $state): string => $state === ProviderConnectionType::Dedicated ? 'info' : 'gray'), ->color(fn (?ProviderConnectionType $state): string => $state === ProviderConnectionType::Dedicated ? 'info' : 'gray'),
Tables\Columns\TextColumn::make('consent_status')
->label('Consent')
->badge()
->formatStateUsing(BadgeRenderer::label(BadgeDomain::ProviderConsentStatus))
->color(BadgeRenderer::color(BadgeDomain::ProviderConsentStatus))
->icon(BadgeRenderer::icon(BadgeDomain::ProviderConsentStatus))
->iconColor(BadgeRenderer::iconColor(BadgeDomain::ProviderConsentStatus)),
Tables\Columns\TextColumn::make('verification_status')
->label('Verification')
->badge()
->formatStateUsing(BadgeRenderer::label(BadgeDomain::ProviderVerificationStatus))
->color(BadgeRenderer::color(BadgeDomain::ProviderVerificationStatus))
->icon(BadgeRenderer::icon(BadgeDomain::ProviderVerificationStatus))
->iconColor(BadgeRenderer::iconColor(BadgeDomain::ProviderVerificationStatus)),
Tables\Columns\TextColumn::make('status') Tables\Columns\TextColumn::make('status')
->label('Status') ->label('Legacy status')
->badge() ->badge()
->formatStateUsing(BadgeRenderer::label(BadgeDomain::ProviderConnectionStatus)) ->formatStateUsing(BadgeRenderer::label(BadgeDomain::ProviderConnectionStatus))
->color(BadgeRenderer::color(BadgeDomain::ProviderConnectionStatus)) ->color(BadgeRenderer::color(BadgeDomain::ProviderConnectionStatus))
->icon(BadgeRenderer::icon(BadgeDomain::ProviderConnectionStatus)) ->icon(BadgeRenderer::icon(BadgeDomain::ProviderConnectionStatus))
->iconColor(BadgeRenderer::iconColor(BadgeDomain::ProviderConnectionStatus)), ->iconColor(BadgeRenderer::iconColor(BadgeDomain::ProviderConnectionStatus))
->toggleable(isToggledHiddenByDefault: true),
Tables\Columns\TextColumn::make('health_status') Tables\Columns\TextColumn::make('health_status')
->label('Health') ->label('Legacy health')
->badge() ->badge()
->formatStateUsing(BadgeRenderer::label(BadgeDomain::ProviderConnectionHealth)) ->formatStateUsing(BadgeRenderer::label(BadgeDomain::ProviderConnectionHealth))
->color(BadgeRenderer::color(BadgeDomain::ProviderConnectionHealth)) ->color(BadgeRenderer::color(BadgeDomain::ProviderConnectionHealth))
->icon(BadgeRenderer::icon(BadgeDomain::ProviderConnectionHealth)) ->icon(BadgeRenderer::icon(BadgeDomain::ProviderConnectionHealth))
->iconColor(BadgeRenderer::iconColor(BadgeDomain::ProviderConnectionHealth)), ->iconColor(BadgeRenderer::iconColor(BadgeDomain::ProviderConnectionHealth))
->toggleable(isToggledHiddenByDefault: true),
Tables\Columns\TextColumn::make('migration_review_required') Tables\Columns\TextColumn::make('migration_review_required')
->label('Migration review') ->label('Migration review')
->badge() ->badge()
@ -714,8 +759,45 @@ public static function table(Table $table): Table
return $query->where('provider_connections.provider', $value); return $query->where('provider_connections.provider', $value);
}), }),
SelectFilter::make('consent_status')
->label('Consent')
->options([
'unknown' => 'Unknown',
'required' => 'Required',
'granted' => 'Granted',
'failed' => 'Failed',
'revoked' => 'Revoked',
])
->query(function (Builder $query, array $data): Builder {
$value = $data['value'] ?? null;
if (! is_string($value) || $value === '') {
return $query;
}
return $query->where('provider_connections.consent_status', $value);
}),
SelectFilter::make('verification_status')
->label('Verification')
->options([
'unknown' => 'Unknown',
'pending' => 'Pending',
'healthy' => 'Healthy',
'degraded' => 'Degraded',
'blocked' => 'Blocked',
'error' => 'Error',
])
->query(function (Builder $query, array $data): Builder {
$value = $data['value'] ?? null;
if (! is_string($value) || $value === '') {
return $query;
}
return $query->where('provider_connections.verification_status', $value);
}),
SelectFilter::make('status') SelectFilter::make('status')
->label('Status') ->label('Diagnostic status')
->options([ ->options([
'connected' => 'Connected', 'connected' => 'Connected',
'needs_consent' => 'Needs consent', 'needs_consent' => 'Needs consent',
@ -732,7 +814,7 @@ public static function table(Table $table): Table
return $query->where('provider_connections.status', $value); return $query->where('provider_connections.status', $value);
}), }),
SelectFilter::make('health_status') SelectFilter::make('health_status')
->label('Health') ->label('Diagnostic health')
->options([ ->options([
'ok' => 'OK', 'ok' => 'OK',
'degraded' => 'Degraded', 'degraded' => 'Degraded',

View File

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

View File

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

View File

@ -284,13 +284,6 @@ public static function table(Table $table): Table
->iconColor(BadgeRenderer::iconColor(BadgeDomain::TenantStatus)) ->iconColor(BadgeRenderer::iconColor(BadgeDomain::TenantStatus))
->description(fn (Tenant $record): string => static::tenantLifecyclePresentation($record)->shortDescription) ->description(fn (Tenant $record): string => static::tenantLifecyclePresentation($record)->shortDescription)
->sortable(), ->sortable(),
Tables\Columns\TextColumn::make('app_status')
->badge()
->formatStateUsing(BadgeRenderer::label(BadgeDomain::TenantAppStatus))
->color(BadgeRenderer::color(BadgeDomain::TenantAppStatus))
->icon(BadgeRenderer::icon(BadgeDomain::TenantAppStatus))
->iconColor(BadgeRenderer::iconColor(BadgeDomain::TenantAppStatus))
->toggleable(isToggledHiddenByDefault: true),
Tables\Columns\TextColumn::make('created_at') Tables\Columns\TextColumn::make('created_at')
->dateTime() ->dateTime()
->since() ->since()
@ -310,13 +303,6 @@ public static function table(Table $table): Table
'staging' => 'STAGING', 'staging' => 'STAGING',
'other' => 'Other', 'other' => 'Other',
]), ]),
Tables\Filters\SelectFilter::make('app_status')
->options([
'ok' => 'OK',
'consent_required' => 'Consent required',
'error' => 'Error',
'unknown' => 'Unknown',
]),
]) ])
->actions([ ->actions([
Actions\Action::make('related_onboarding') Actions\Action::make('related_onboarding')
@ -842,12 +828,6 @@ public static function infolist(Schema $schema): Schema
->label('Lifecycle summary') ->label('Lifecycle summary')
->state(fn (Tenant $record): string => static::tenantLifecyclePresentation($record)->longDescription) ->state(fn (Tenant $record): string => static::tenantLifecyclePresentation($record)->longDescription)
->columnSpanFull(), ->columnSpanFull(),
Infolists\Components\TextEntry::make('app_status')
->badge()
->formatStateUsing(BadgeRenderer::label(BadgeDomain::TenantAppStatus))
->color(BadgeRenderer::color(BadgeDomain::TenantAppStatus))
->icon(BadgeRenderer::icon(BadgeDomain::TenantAppStatus))
->iconColor(BadgeRenderer::iconColor(BadgeDomain::TenantAppStatus)),
]) ])
->columns(2) ->columns(2)
->columnSpanFull(), ->columnSpanFull(),
@ -1492,19 +1472,30 @@ private static function providerConnectionState(Tenant $tenant): array
{ {
$ctaUrl = ProviderConnectionResource::getUrl('index', ['tenant_id' => (string) $tenant->external_id], panel: 'admin'); $ctaUrl = ProviderConnectionResource::getUrl('index', ['tenant_id' => (string) $tenant->external_id], panel: 'admin');
$connection = ProviderConnection::query() $defaultConnection = ProviderConnection::query()
->where('tenant_id', (int) $tenant->getKey())
->where('provider', 'microsoft')
->where('is_default', true)
->orderBy('id')
->first();
$connection = $defaultConnection instanceof ProviderConnection
? $defaultConnection
: ProviderConnection::query()
->where('tenant_id', (int) $tenant->getKey()) ->where('tenant_id', (int) $tenant->getKey())
->where('provider', 'microsoft') ->where('provider', 'microsoft')
->orderByDesc('is_default')
->orderBy('id') ->orderBy('id')
->first(); ->first();
if (! $connection instanceof ProviderConnection) { if (! $connection instanceof ProviderConnection) {
return [ return [
'state' => 'needs_action', 'state' => 'missing',
'cta_url' => $ctaUrl, 'cta_url' => $ctaUrl,
'needs_default_connection' => false,
'display_name' => null, 'display_name' => null,
'provider' => null, 'provider' => null,
'consent_status' => null,
'verification_status' => null,
'status' => null, 'status' => null,
'health_status' => null, 'health_status' => null,
'last_health_check_at' => null, 'last_health_check_at' => null,
@ -1515,8 +1506,15 @@ private static function providerConnectionState(Tenant $tenant): array
return [ return [
'state' => $connection->is_default ? 'default_configured' : 'configured', 'state' => $connection->is_default ? 'default_configured' : 'configured',
'cta_url' => $ctaUrl, 'cta_url' => $ctaUrl,
'needs_default_connection' => ! $connection->is_default,
'display_name' => (string) $connection->display_name, 'display_name' => (string) $connection->display_name,
'provider' => (string) $connection->provider, 'provider' => (string) $connection->provider,
'consent_status' => $connection->consent_status instanceof BackedEnum
? (string) $connection->consent_status->value
: (is_string($connection->consent_status) ? $connection->consent_status : null),
'verification_status' => $connection->verification_status instanceof BackedEnum
? (string) $connection->verification_status->value
: (is_string($connection->verification_status) ? $connection->verification_status : null),
'status' => is_string($connection->status) ? $connection->status : null, 'status' => is_string($connection->status) ? $connection->status : null,
'health_status' => is_string($connection->health_status) ? $connection->health_status : null, 'health_status' => is_string($connection->health_status) ? $connection->health_status : null,
'last_health_check_at' => optional($connection->last_health_check_at)->toDateTimeString(), 'last_health_check_at' => optional($connection->last_health_check_at)->toDateTimeString(),

View File

@ -12,6 +12,7 @@
use App\Support\OperationCatalog; use App\Support\OperationCatalog;
use App\Support\OperationRunOutcome; use App\Support\OperationRunOutcome;
use App\Support\OperationRunStatus; use App\Support\OperationRunStatus;
use App\Support\OpsUx\OperationUxPresenter;
use App\Support\System\SystemOperationRunLinks; use App\Support\System\SystemOperationRunLinks;
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;
@ -113,16 +114,46 @@ public function table(Table $table): Table
->state(fn (OperationRun $record): string => '#'.$record->getKey()), ->state(fn (OperationRun $record): string => '#'.$record->getKey()),
TextColumn::make('status') TextColumn::make('status')
->badge() ->badge()
->formatStateUsing(BadgeRenderer::label(BadgeDomain::OperationRunStatus)) ->formatStateUsing(fn (mixed $state, OperationRun $record): string => BadgeRenderer::spec(BadgeDomain::OperationRunStatus, [
->color(BadgeRenderer::color(BadgeDomain::OperationRunStatus)) 'status' => (string) $record->status,
->icon(BadgeRenderer::icon(BadgeDomain::OperationRunStatus)) 'freshness_state' => $record->freshnessState()->value,
->iconColor(BadgeRenderer::iconColor(BadgeDomain::OperationRunStatus)), ])->label)
->color(fn (mixed $state, OperationRun $record): string => BadgeRenderer::spec(BadgeDomain::OperationRunStatus, [
'status' => (string) $record->status,
'freshness_state' => $record->freshnessState()->value,
])->color)
->icon(fn (mixed $state, OperationRun $record): ?string => BadgeRenderer::spec(BadgeDomain::OperationRunStatus, [
'status' => (string) $record->status,
'freshness_state' => $record->freshnessState()->value,
])->icon)
->iconColor(fn (mixed $state, OperationRun $record): ?string => BadgeRenderer::spec(BadgeDomain::OperationRunStatus, [
'status' => (string) $record->status,
'freshness_state' => $record->freshnessState()->value,
])->iconColor)
->description(fn (OperationRun $record): ?string => OperationUxPresenter::lifecycleAttentionSummary($record)),
TextColumn::make('outcome') TextColumn::make('outcome')
->badge() ->badge()
->formatStateUsing(BadgeRenderer::label(BadgeDomain::OperationRunOutcome)) ->formatStateUsing(fn (mixed $state, OperationRun $record): string => BadgeRenderer::spec(BadgeDomain::OperationRunOutcome, [
->color(BadgeRenderer::color(BadgeDomain::OperationRunOutcome)) 'outcome' => (string) $record->outcome,
->icon(BadgeRenderer::icon(BadgeDomain::OperationRunOutcome)) 'status' => (string) $record->status,
->iconColor(BadgeRenderer::iconColor(BadgeDomain::OperationRunOutcome)), 'freshness_state' => $record->freshnessState()->value,
])->label)
->color(fn (mixed $state, OperationRun $record): string => BadgeRenderer::spec(BadgeDomain::OperationRunOutcome, [
'outcome' => (string) $record->outcome,
'status' => (string) $record->status,
'freshness_state' => $record->freshnessState()->value,
])->color)
->icon(fn (mixed $state, OperationRun $record): ?string => BadgeRenderer::spec(BadgeDomain::OperationRunOutcome, [
'outcome' => (string) $record->outcome,
'status' => (string) $record->status,
'freshness_state' => $record->freshnessState()->value,
])->icon)
->iconColor(fn (mixed $state, OperationRun $record): ?string => BadgeRenderer::spec(BadgeDomain::OperationRunOutcome, [
'outcome' => (string) $record->outcome,
'status' => (string) $record->status,
'freshness_state' => $record->freshnessState()->value,
])->iconColor)
->description(fn (OperationRun $record): ?string => OperationUxPresenter::surfaceGuidance($record)),
TextColumn::make('type') TextColumn::make('type')
->label('Operation') ->label('Operation')
->formatStateUsing(fn (?string $state): string => OperationCatalog::label((string) $state)) ->formatStateUsing(fn (?string $state): string => OperationCatalog::label((string) $state))

View File

@ -10,6 +10,7 @@
use App\Support\Badges\BadgeDomain; use App\Support\Badges\BadgeDomain;
use App\Support\Badges\BadgeRenderer; use App\Support\Badges\BadgeRenderer;
use App\Support\OperationCatalog; use App\Support\OperationCatalog;
use App\Support\OpsUx\OperationUxPresenter;
use App\Support\System\SystemOperationRunLinks; use App\Support\System\SystemOperationRunLinks;
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;
@ -94,16 +95,46 @@ public function table(Table $table): Table
->state(fn (OperationRun $record): string => '#'.$record->getKey()), ->state(fn (OperationRun $record): string => '#'.$record->getKey()),
TextColumn::make('status') TextColumn::make('status')
->badge() ->badge()
->formatStateUsing(BadgeRenderer::label(BadgeDomain::OperationRunStatus)) ->formatStateUsing(fn (mixed $state, OperationRun $record): string => BadgeRenderer::spec(BadgeDomain::OperationRunStatus, [
->color(BadgeRenderer::color(BadgeDomain::OperationRunStatus)) 'status' => (string) $record->status,
->icon(BadgeRenderer::icon(BadgeDomain::OperationRunStatus)) 'freshness_state' => $record->freshnessState()->value,
->iconColor(BadgeRenderer::iconColor(BadgeDomain::OperationRunStatus)), ])->label)
->color(fn (mixed $state, OperationRun $record): string => BadgeRenderer::spec(BadgeDomain::OperationRunStatus, [
'status' => (string) $record->status,
'freshness_state' => $record->freshnessState()->value,
])->color)
->icon(fn (mixed $state, OperationRun $record): ?string => BadgeRenderer::spec(BadgeDomain::OperationRunStatus, [
'status' => (string) $record->status,
'freshness_state' => $record->freshnessState()->value,
])->icon)
->iconColor(fn (mixed $state, OperationRun $record): ?string => BadgeRenderer::spec(BadgeDomain::OperationRunStatus, [
'status' => (string) $record->status,
'freshness_state' => $record->freshnessState()->value,
])->iconColor)
->description(fn (OperationRun $record): ?string => OperationUxPresenter::lifecycleAttentionSummary($record)),
TextColumn::make('outcome') TextColumn::make('outcome')
->badge() ->badge()
->formatStateUsing(BadgeRenderer::label(BadgeDomain::OperationRunOutcome)) ->formatStateUsing(fn (mixed $state, OperationRun $record): string => BadgeRenderer::spec(BadgeDomain::OperationRunOutcome, [
->color(BadgeRenderer::color(BadgeDomain::OperationRunOutcome)) 'outcome' => (string) $record->outcome,
->icon(BadgeRenderer::icon(BadgeDomain::OperationRunOutcome)) 'status' => (string) $record->status,
->iconColor(BadgeRenderer::iconColor(BadgeDomain::OperationRunOutcome)), 'freshness_state' => $record->freshnessState()->value,
])->label)
->color(fn (mixed $state, OperationRun $record): string => BadgeRenderer::spec(BadgeDomain::OperationRunOutcome, [
'outcome' => (string) $record->outcome,
'status' => (string) $record->status,
'freshness_state' => $record->freshnessState()->value,
])->color)
->icon(fn (mixed $state, OperationRun $record): ?string => BadgeRenderer::spec(BadgeDomain::OperationRunOutcome, [
'outcome' => (string) $record->outcome,
'status' => (string) $record->status,
'freshness_state' => $record->freshnessState()->value,
])->icon)
->iconColor(fn (mixed $state, OperationRun $record): ?string => BadgeRenderer::spec(BadgeDomain::OperationRunOutcome, [
'outcome' => (string) $record->outcome,
'status' => (string) $record->status,
'freshness_state' => $record->freshnessState()->value,
])->iconColor)
->description(fn (OperationRun $record): ?string => OperationUxPresenter::surfaceGuidance($record)),
TextColumn::make('type') TextColumn::make('type')
->label('Operation') ->label('Operation')
->formatStateUsing(fn (?string $state): string => OperationCatalog::label((string) $state)) ->formatStateUsing(fn (?string $state): string => OperationCatalog::label((string) $state))

View File

@ -11,6 +11,7 @@
use App\Support\Badges\BadgeRenderer; use App\Support\Badges\BadgeRenderer;
use App\Support\OperationCatalog; use App\Support\OperationCatalog;
use App\Support\OperationRunStatus; use App\Support\OperationRunStatus;
use App\Support\OpsUx\OperationUxPresenter;
use App\Support\System\SystemOperationRunLinks; use App\Support\System\SystemOperationRunLinks;
use App\Support\SystemConsole\StuckRunClassifier; use App\Support\SystemConsole\StuckRunClassifier;
use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration; use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration;
@ -112,10 +113,23 @@ public function table(Table $table): Table
->state(fn (OperationRun $record): string => '#'.$record->getKey()), ->state(fn (OperationRun $record): string => '#'.$record->getKey()),
TextColumn::make('status') TextColumn::make('status')
->badge() ->badge()
->formatStateUsing(BadgeRenderer::label(BadgeDomain::OperationRunStatus)) ->formatStateUsing(fn (mixed $state, OperationRun $record): string => BadgeRenderer::spec(BadgeDomain::OperationRunStatus, [
->color(BadgeRenderer::color(BadgeDomain::OperationRunStatus)) 'status' => (string) $record->status,
->icon(BadgeRenderer::icon(BadgeDomain::OperationRunStatus)) 'freshness_state' => $record->freshnessState()->value,
->iconColor(BadgeRenderer::iconColor(BadgeDomain::OperationRunStatus)), ])->label)
->color(fn (mixed $state, OperationRun $record): string => BadgeRenderer::spec(BadgeDomain::OperationRunStatus, [
'status' => (string) $record->status,
'freshness_state' => $record->freshnessState()->value,
])->color)
->icon(fn (mixed $state, OperationRun $record): ?string => BadgeRenderer::spec(BadgeDomain::OperationRunStatus, [
'status' => (string) $record->status,
'freshness_state' => $record->freshnessState()->value,
])->icon)
->iconColor(fn (mixed $state, OperationRun $record): ?string => BadgeRenderer::spec(BadgeDomain::OperationRunStatus, [
'status' => (string) $record->status,
'freshness_state' => $record->freshnessState()->value,
])->iconColor)
->description(null),
TextColumn::make('stuck_class') TextColumn::make('stuck_class')
->label('Stuck class') ->label('Stuck class')
->state(function (OperationRun $record): string { ->state(function (OperationRun $record): string {
@ -126,6 +140,7 @@ public function table(Table $table): Table
TextColumn::make('type') TextColumn::make('type')
->label('Operation') ->label('Operation')
->formatStateUsing(fn (?string $state): string => OperationCatalog::label((string) $state)) ->formatStateUsing(fn (?string $state): string => OperationCatalog::label((string) $state))
->description(fn (OperationRun $record): ?string => OperationUxPresenter::lifecycleAttentionSummary($record))
->searchable(), ->searchable(),
TextColumn::make('workspace.name') TextColumn::make('workspace.name')
->label('Workspace') ->label('Workspace')

View File

@ -23,13 +23,7 @@ class DashboardKpis extends StatsOverviewWidget
protected function getPollingInterval(): ?string protected function getPollingInterval(): ?string
{ {
$tenant = Filament::getTenant(); return ActiveRuns::pollingIntervalForTenant(Filament::getTenant());
if (! $tenant instanceof Tenant) {
return null;
}
return ActiveRuns::existForTenant($tenant) ? '10s' : null;
} }
/** /**
@ -60,9 +54,14 @@ protected function getStats(): array
->healthyActive() ->healthyActive()
->count(); ->count();
$followUpRuns = (int) OperationRun::query() $staleActiveRuns = (int) OperationRun::query()
->where('tenant_id', $tenantId) ->where('tenant_id', $tenantId)
->dashboardNeedsFollowUp() ->activeStaleAttention()
->count();
$terminalFollowUpRuns = (int) OperationRun::query()
->where('tenant_id', $tenantId)
->terminalFollowUp()
->count(); ->count();
$openDriftUrl = $openDriftFindings > 0 $openDriftUrl = $openDriftFindings > 0
@ -96,10 +95,26 @@ protected function getStats(): array
->description('healthy queued or running tenant work') ->description('healthy queued or running tenant work')
->color($activeRuns > 0 ? 'info' : 'gray') ->color($activeRuns > 0 ? 'info' : 'gray')
->url($activeRuns > 0 ? OperationRunLinks::index($tenant, activeTab: 'active') : null), ->url($activeRuns > 0 ? OperationRunLinks::index($tenant, activeTab: 'active') : null),
Stat::make('Operations needing follow-up', $followUpRuns) Stat::make('Likely stale operations', $staleActiveRuns)
->description('failed, warning, or stalled runs') ->description('queued or running past the lifecycle window')
->color($followUpRuns > 0 ? 'danger' : 'gray') ->color($staleActiveRuns > 0 ? 'warning' : 'gray')
->url($followUpRuns > 0 ? OperationRunLinks::index($tenant, activeTab: 'blocked') : null), ->url($staleActiveRuns > 0
? OperationRunLinks::index(
$tenant,
activeTab: OperationRun::PROBLEM_CLASS_ACTIVE_STALE_ATTENTION,
problemClass: OperationRun::PROBLEM_CLASS_ACTIVE_STALE_ATTENTION,
)
: null),
Stat::make('Terminal follow-up operations', $terminalFollowUpRuns)
->description('blocked, partial, failed, or auto-reconciled runs')
->color($terminalFollowUpRuns > 0 ? 'danger' : 'gray')
->url($terminalFollowUpRuns > 0
? OperationRunLinks::index(
$tenant,
activeTab: OperationRun::PROBLEM_CLASS_TERMINAL_FOLLOW_UP,
problemClass: OperationRun::PROBLEM_CLASS_TERMINAL_FOLLOW_UP,
)
: null),
]; ];
} }
@ -112,7 +127,8 @@ private function emptyStats(): array
Stat::make('Open drift findings', 0), Stat::make('Open drift findings', 0),
Stat::make('High severity active findings', 0), Stat::make('High severity active findings', 0),
Stat::make('Active operations', 0), Stat::make('Active operations', 0),
Stat::make('Operations needing follow-up', 0), Stat::make('Likely stale operations', 0),
Stat::make('Terminal follow-up operations', 0),
]; ];
} }

View File

@ -48,9 +48,13 @@ protected function getViewData(): array
$lapsedGovernanceCount = $aggregate->lapsedGovernanceCount; $lapsedGovernanceCount = $aggregate->lapsedGovernanceCount;
$expiringGovernanceCount = $aggregate->expiringGovernanceCount; $expiringGovernanceCount = $aggregate->expiringGovernanceCount;
$highSeverityCount = $aggregate->highSeverityActiveFindingsCount; $highSeverityCount = $aggregate->highSeverityActiveFindingsCount;
$operationsFollowUpCount = (int) OperationRun::query() $staleActiveOperationsCount = (int) OperationRun::query()
->where('tenant_id', $tenantId) ->where('tenant_id', $tenantId)
->dashboardNeedsFollowUp() ->activeStaleAttention()
->count();
$terminalFollowUpOperationsCount = (int) OperationRun::query()
->where('tenant_id', $tenantId)
->terminalFollowUp()
->count(); ->count();
$activeRuns = (int) OperationRun::query() $activeRuns = (int) OperationRun::query()
->where('tenant_id', $tenantId) ->where('tenant_id', $tenantId)
@ -139,15 +143,35 @@ protected function getViewData(): array
]; ];
} }
if ($operationsFollowUpCount > 0) { if ($staleActiveOperationsCount > 0) {
$items[] = [ $items[] = [
'key' => 'operations_follow_up', 'key' => 'operations_stale_attention',
'title' => 'Operations need follow-up', 'title' => 'Active operations look stale',
'body' => "{$operationsFollowUpCount} run(s) failed, completed with warnings, or look stalled.", 'body' => "{$staleActiveOperationsCount} run(s) are still marked active but are past the lifecycle window.",
'badge' => 'Operations',
'badgeColor' => 'warning',
'actionLabel' => 'Open stale operations',
'actionUrl' => OperationRunLinks::index(
$tenant,
activeTab: OperationRun::PROBLEM_CLASS_ACTIVE_STALE_ATTENTION,
problemClass: OperationRun::PROBLEM_CLASS_ACTIVE_STALE_ATTENTION,
),
];
}
if ($terminalFollowUpOperationsCount > 0) {
$items[] = [
'key' => 'operations_terminal_follow_up',
'title' => 'Terminal operations need follow-up',
'body' => "{$terminalFollowUpOperationsCount} run(s) finished blocked, partially, failed, or were automatically reconciled.",
'badge' => 'Operations', 'badge' => 'Operations',
'badgeColor' => 'danger', 'badgeColor' => 'danger',
'actionLabel' => 'Open operations', 'actionLabel' => 'Open terminal follow-up',
'actionUrl' => OperationRunLinks::index($tenant, activeTab: 'blocked'), 'actionUrl' => OperationRunLinks::index(
$tenant,
activeTab: OperationRun::PROBLEM_CLASS_TERMINAL_FOLLOW_UP,
problemClass: OperationRun::PROBLEM_CLASS_TERMINAL_FOLLOW_UP,
),
]; ];
} }
@ -184,7 +208,7 @@ protected function getViewData(): array
} }
return [ return [
'pollingInterval' => ActiveRuns::existForTenant($tenant) ? '10s' : null, 'pollingInterval' => ActiveRuns::pollingIntervalForTenant($tenant),
'items' => $items, 'items' => $items,
'healthyChecks' => $healthyChecks, 'healthyChecks' => $healthyChecks,
]; ];

View File

@ -29,7 +29,7 @@ public function table(Table $table): Table
return $table return $table
->heading('Recent Operations') ->heading('Recent Operations')
->query($this->getQuery()) ->query($this->getQuery())
->poll(fn (): ?string => ($tenant instanceof Tenant) && ActiveRuns::existForTenant($tenant) ? '10s' : null) ->poll(fn (): ?string => ActiveRuns::pollingIntervalForTenant($tenant instanceof Tenant ? $tenant : null))
->defaultSort('created_at', 'desc') ->defaultSort('created_at', 'desc')
->paginated(\App\Support\Filament\TablePaginationProfiles::widget()) ->paginated(\App\Support\Filament\TablePaginationProfiles::widget())
->columns([ ->columns([
@ -43,22 +43,52 @@ public function table(Table $table): Table
->sortable() ->sortable()
->formatStateUsing(fn (?string $state): string => OperationCatalog::label((string) $state)) ->formatStateUsing(fn (?string $state): string => OperationCatalog::label((string) $state))
->limit(40) ->limit(40)
->description(fn (OperationRun $record): ?string => OperationUxPresenter::lifecycleAttentionSummary($record))
->tooltip(fn (OperationRun $record): string => OperationCatalog::label((string) $record->type)), ->tooltip(fn (OperationRun $record): string => OperationCatalog::label((string) $record->type)),
TextColumn::make('status') TextColumn::make('status')
->badge() ->badge()
->sortable() ->sortable()
->toggleable(isToggledHiddenByDefault: true) ->toggleable(isToggledHiddenByDefault: true)
->formatStateUsing(BadgeRenderer::label(BadgeDomain::OperationRunStatus)) ->formatStateUsing(fn (mixed $state, OperationRun $record): string => BadgeRenderer::spec(BadgeDomain::OperationRunStatus, [
->color(BadgeRenderer::color(BadgeDomain::OperationRunStatus)) 'status' => (string) $record->status,
->icon(BadgeRenderer::icon(BadgeDomain::OperationRunStatus)) 'freshness_state' => $record->freshnessState()->value,
->iconColor(BadgeRenderer::iconColor(BadgeDomain::OperationRunStatus)), ])->label)
->color(fn (mixed $state, OperationRun $record): string => BadgeRenderer::spec(BadgeDomain::OperationRunStatus, [
'status' => (string) $record->status,
'freshness_state' => $record->freshnessState()->value,
])->color)
->icon(fn (mixed $state, OperationRun $record): ?string => BadgeRenderer::spec(BadgeDomain::OperationRunStatus, [
'status' => (string) $record->status,
'freshness_state' => $record->freshnessState()->value,
])->icon)
->iconColor(fn (mixed $state, OperationRun $record): ?string => BadgeRenderer::spec(BadgeDomain::OperationRunStatus, [
'status' => (string) $record->status,
'freshness_state' => $record->freshnessState()->value,
])->iconColor)
->description(null),
TextColumn::make('outcome') TextColumn::make('outcome')
->badge() ->badge()
->sortable() ->sortable()
->formatStateUsing(BadgeRenderer::label(BadgeDomain::OperationRunOutcome)) ->formatStateUsing(fn (mixed $state, OperationRun $record): string => BadgeRenderer::spec(BadgeDomain::OperationRunOutcome, [
->color(BadgeRenderer::color(BadgeDomain::OperationRunOutcome)) 'outcome' => (string) $record->outcome,
->icon(BadgeRenderer::icon(BadgeDomain::OperationRunOutcome)) 'status' => (string) $record->status,
->iconColor(BadgeRenderer::iconColor(BadgeDomain::OperationRunOutcome)) 'freshness_state' => $record->freshnessState()->value,
])->label)
->color(fn (mixed $state, OperationRun $record): string => BadgeRenderer::spec(BadgeDomain::OperationRunOutcome, [
'outcome' => (string) $record->outcome,
'status' => (string) $record->status,
'freshness_state' => $record->freshnessState()->value,
])->color)
->icon(fn (mixed $state, OperationRun $record): ?string => BadgeRenderer::spec(BadgeDomain::OperationRunOutcome, [
'outcome' => (string) $record->outcome,
'status' => (string) $record->status,
'freshness_state' => $record->freshnessState()->value,
])->icon)
->iconColor(fn (mixed $state, OperationRun $record): ?string => BadgeRenderer::spec(BadgeDomain::OperationRunOutcome, [
'outcome' => (string) $record->outcome,
'status' => (string) $record->status,
'freshness_state' => $record->freshnessState()->value,
])->iconColor)
->description(fn (OperationRun $record): ?string => OperationUxPresenter::surfaceGuidance($record)), ->description(fn (OperationRun $record): ?string => OperationUxPresenter::surfaceGuidance($record)),
TextColumn::make('created_at') TextColumn::make('created_at')
->label('Started') ->label('Started')

View File

@ -5,16 +5,16 @@
namespace App\Filament\Widgets\Inventory; namespace App\Filament\Widgets\Inventory;
use App\Filament\Concerns\ResolvesPanelTenantContext; use App\Filament\Concerns\ResolvesPanelTenantContext;
use App\Models\InventoryItem;
use App\Models\OperationRun; use App\Models\OperationRun;
use App\Models\Tenant; use App\Models\Tenant;
use App\Services\Inventory\CoverageCapabilitiesResolver; use App\Models\User;
use App\Support\Auth\Capabilities;
use App\Support\Badges\BadgeDomain; use App\Support\Badges\BadgeDomain;
use App\Support\Badges\BadgeRenderer; use App\Support\Badges\BadgeRenderer;
use App\Support\Inventory\InventoryKpiBadges; use App\Support\Inventory\InventoryKpiBadges;
use App\Support\Inventory\InventoryPolicyTypeMeta; use App\Support\Inventory\TenantCoverageTruth;
use App\Support\OperationRunOutcome; use App\Support\Inventory\TenantCoverageTruthResolver;
use App\Support\OperationRunStatus; use App\Support\OperationRunLinks;
use Filament\Widgets\StatsOverviewWidget; use Filament\Widgets\StatsOverviewWidget;
use Filament\Widgets\StatsOverviewWidget\Stat; use Filament\Widgets\StatsOverviewWidget\Stat;
use Illuminate\Support\Facades\Blade; use Illuminate\Support\Facades\Blade;
@ -28,12 +28,9 @@ class InventoryKpiHeader extends StatsOverviewWidget
protected int|string|array $columnSpan = 'full'; protected int|string|array $columnSpan = 'full';
protected ?string $pollingInterval = null;
/** /**
* Inventory KPI aggregation source-of-truth:
* - `inventory_items.policy_type`
* - `config('tenantpilot.supported_policy_types')` + `config('tenantpilot.foundation_types')` meta (`restore`, `risk`)
* - dependency capability via `CoverageCapabilitiesResolver`
*
* @return array<Stat> * @return array<Stat>
*/ */
protected function getStats(): array protected function getStats(): array
@ -43,126 +40,85 @@ protected function getStats(): array
if (! $tenant instanceof Tenant) { if (! $tenant instanceof Tenant) {
return [ return [
Stat::make('Total items', 0), Stat::make('Total items', 0),
Stat::make('Coverage', '0%')->description('Select a tenant to load coverage.'), Stat::make('Covered types', '—')->description('Select a tenant to load coverage truth.'),
Stat::make('Last inventory sync', '—')->description('Select a tenant to see the latest sync.'), Stat::make('Need follow-up', '—')->description('Select a tenant to review follow-up types.'),
Stat::make('Coverage basis', '—')->description('Select a tenant to see the latest coverage basis.'),
Stat::make('Active ops', 0), Stat::make('Active ops', 0),
Stat::make('Inventory ops', 0)->description('Select a tenant to load dependency and risk counts.'),
]; ];
} }
$tenantId = (int) $tenant->getKey(); $truth = app(TenantCoverageTruthResolver::class)->resolve($tenant);
/** @var array<string, int> $countsByPolicyType */
$countsByPolicyType = InventoryItem::query()
->where('tenant_id', $tenantId)
->selectRaw('policy_type, COUNT(*) as aggregate')
->groupBy('policy_type')
->pluck('aggregate', 'policy_type')
->map(fn ($value): int => (int) $value)
->all();
$totalItems = array_sum($countsByPolicyType);
$restorableItems = 0;
$partialItems = 0;
$riskItems = 0;
foreach ($countsByPolicyType as $policyType => $count) {
if (InventoryPolicyTypeMeta::isRestorable($policyType)) {
$restorableItems += $count;
} elseif (InventoryPolicyTypeMeta::isPartial($policyType)) {
$partialItems += $count;
}
if (InventoryPolicyTypeMeta::isHighRisk($policyType)) {
$riskItems += $count;
}
}
$coveragePercent = $totalItems > 0
? (int) round(($restorableItems / $totalItems) * 100)
: 0;
$lastRun = OperationRun::query()
->where('tenant_id', $tenantId)
->where('type', 'inventory_sync')
->where('status', OperationRunStatus::Completed->value)
->whereNotNull('completed_at')
->latest('completed_at')
->latest('id')
->first();
$lastInventorySyncTimeLabel = '—';
$lastInventorySyncStatusLabel = '—';
$lastInventorySyncStatusColor = 'gray';
$lastInventorySyncStatusIcon = 'heroicon-m-clock';
$lastInventorySyncViewUrl = null;
if ($lastRun instanceof OperationRun) {
$timestamp = $lastRun->completed_at ?? $lastRun->started_at ?? $lastRun->created_at;
if ($timestamp) {
$lastInventorySyncTimeLabel = $timestamp->diffForHumans(['short' => true]);
}
$outcome = (string) ($lastRun->outcome ?? OperationRunOutcome::Pending->value);
$badge = BadgeRenderer::spec(BadgeDomain::OperationRunOutcome, $outcome);
$lastInventorySyncStatusLabel = $badge->label;
$lastInventorySyncStatusColor = $badge->color;
$lastInventorySyncStatusIcon = (string) ($badge->icon ?? 'heroicon-m-clock');
$lastInventorySyncViewUrl = route('admin.operations.view', ['run' => (int) $lastRun->getKey()]);
}
$badgeColor = $lastInventorySyncStatusColor;
$lastInventorySyncDescription = Blade::render(<<<'BLADE'
<div class="flex items-center gap-2">
<x-filament::badge :color="$badgeColor" size="sm">
{{ $statusLabel }}
</x-filament::badge>
@if ($viewUrl)
<x-filament::link :href="$viewUrl" size="sm">
Open operation
</x-filament::link>
@endif
</div>
BLADE, [
'badgeColor' => $badgeColor,
'statusLabel' => $lastInventorySyncStatusLabel,
'viewUrl' => $lastInventorySyncViewUrl,
]);
$activeOps = (int) OperationRun::query() $activeOps = (int) OperationRun::query()
->where('tenant_id', $tenantId) ->where('tenant_id', (int) $tenant->getKey())
->active() ->active()
->count(); ->count();
$inventoryOps = (int) OperationRun::query() $inventoryOps = (int) OperationRun::query()
->where('tenant_id', $tenantId) ->where('tenant_id', (int) $tenant->getKey())
->where('type', 'inventory_sync') ->where('type', 'inventory_sync')
->active() ->active()
->count(); ->count();
$resolver = app(CoverageCapabilitiesResolver::class);
$dependenciesItems = 0;
foreach ($countsByPolicyType as $policyType => $count) {
if ($policyType !== '' && $resolver->supportsDependencies($policyType)) {
$dependenciesItems += $count;
}
}
return [ return [
Stat::make('Total items', $totalItems), Stat::make('Total items', $truth->observedItemTotal)
Stat::make('Coverage', $coveragePercent.'%') ->description(sprintf('Observed across %d supported types.', $truth->observedTypeCount())),
->description(new HtmlString(InventoryKpiBadges::coverage($restorableItems, $partialItems))), Stat::make('Covered types', sprintf('%d / %d', $truth->succeededTypeCount, $truth->supportedTypeCount))
Stat::make('Last inventory sync', $lastInventorySyncTimeLabel) ->description(new HtmlString(InventoryKpiBadges::coverageBreakdown(
->description(new HtmlString($lastInventorySyncDescription)), $truth->failedTypeCount,
Stat::make('Active ops', $activeOps), $truth->skippedTypeCount,
Stat::make('Inventory ops', $inventoryOps) $truth->unknownTypeCount,
->description(new HtmlString(InventoryKpiBadges::inventoryOps($dependenciesItems, $riskItems))), ))),
Stat::make('Need follow-up', $truth->followUpTypeCount)
->description(new HtmlString(InventoryKpiBadges::followUpSummary(
$truth->topPriorityFollowUpRow(),
$truth->observedItemTotal,
$truth->observedTypeCount(),
))),
$this->coverageBasisStat($truth, $tenant),
Stat::make('Active ops', $activeOps)
->description($inventoryOps > 0 ? 'A tenant inventory sync is queued or running.' : 'No inventory sync is currently active.'),
]; ];
} }
private function coverageBasisStat(TenantCoverageTruth $truth, Tenant $tenant): Stat
{
$user = auth()->user();
if (! $truth->basisRun instanceof OperationRun) {
return Stat::make('Coverage basis', 'No current result')
->description($user instanceof User && $user->can(Capabilities::TENANT_INVENTORY_SYNC_RUN, $tenant)
? 'Run Inventory Sync from Inventory Items to establish current coverage truth.'
: 'A tenant operator with inventory sync permission must establish current coverage truth.');
}
$outcomeBadge = BadgeRenderer::spec(BadgeDomain::OperationRunOutcome, (string) $truth->basisRun->outcome);
$canViewRun = $user instanceof User && $user->can('view', $truth->basisRun);
$description = Blade::render(<<<'BLADE'
<div class="flex flex-wrap items-center gap-2">
<x-filament::badge :color="$badgeColor" size="sm">
{{ $statusLabel }}
</x-filament::badge>
@if ($canViewRun && $viewUrl)
<x-filament::link :href="$viewUrl" size="sm">
Open basis run
</x-filament::link>
@else
<span class="text-xs text-gray-600 dark:text-gray-300">
Latest run detail is not available with your current role.
</span>
@endif
</div>
BLADE, [
'badgeColor' => $outcomeBadge->color,
'statusLabel' => $outcomeBadge->label,
'canViewRun' => $canViewRun,
'viewUrl' => $canViewRun ? OperationRunLinks::view($truth->basisRun, $tenant) : null,
]);
return Stat::make('Coverage basis', $truth->basisCompletedAtLabel() ?? 'Completed')
->description(new HtmlString($description));
}
} }

View File

@ -58,6 +58,8 @@ protected function getViewData(): array
'type', 'type',
'status', 'status',
'outcome', 'outcome',
'context',
'failure_summary',
'created_at', 'created_at',
'started_at', 'started_at',
'completed_at', 'completed_at',

View File

@ -201,7 +201,7 @@ protected function getViewData(): array
&& $user->can(Capabilities::PROVIDER_RUN, $tenant); && $user->can(Capabilities::PROVIDER_RUN, $tenant);
$lifecycleNotice = $isTenantMember && ! $canOperate $lifecycleNotice = $isTenantMember && ! $canOperate
? 'Verification can be started from tenant management only while the tenant is active.' ? 'Verification can be started from tenant management only while the tenant is active. Consent and connection configuration remain separate from this stored verification report.'
: null; : null;
$runData = null; $runData = null;

View File

@ -23,6 +23,7 @@ class WorkspaceRecentOperations extends Widget
* status_color: string, * status_color: string,
* outcome_label: string, * outcome_label: string,
* outcome_color: string, * outcome_color: string,
* lifecycle_label: ?string,
* guidance: ?string, * guidance: ?string,
* started_at: string, * started_at: string,
* destination: array<string, mixed>, * destination: array<string, mixed>,
@ -50,6 +51,7 @@ class WorkspaceRecentOperations extends Widget
* status_color: string, * status_color: string,
* outcome_label: string, * outcome_label: string,
* outcome_color: string, * outcome_color: string,
* lifecycle_label: ?string,
* guidance: ?string, * guidance: ?string,
* started_at: string, * started_at: string,
* destination: array<string, mixed>, * destination: array<string, mixed>,

View File

@ -103,11 +103,15 @@ public function handle(InventorySyncService $inventorySyncService, AuditLogger $
$this->operationRun, $this->operationRun,
$tenant, $tenant,
$context, $context,
function (string $policyType, bool $success, ?string $errorCode) use (&$processedPolicyTypes, &$coverageStatusByType, &$successCount, &$failedCount): void { function (string $policyType, bool $success, ?string $errorCode, int $itemCount) use (&$processedPolicyTypes, &$coverageStatusByType, &$successCount, &$failedCount): void {
$processedPolicyTypes[] = $policyType; $processedPolicyTypes[] = $policyType;
$coverageStatusByType[$policyType] = $success $coverageStatusByType[$policyType] = array_filter([
'status' => $success
? InventoryCoverage::StatusSucceeded ? InventoryCoverage::StatusSucceeded
: InventoryCoverage::StatusFailed; : InventoryCoverage::StatusFailed,
'item_count' => $itemCount,
'error_code' => $success ? null : $errorCode,
], static fn (mixed $value): bool => $value !== null);
if ($success) { if ($success) {
$successCount++; $successCount++;
@ -126,7 +130,10 @@ function (string $policyType, bool $success, ?string $errorCode) use (&$processe
continue; continue;
} }
$statusByType[$type] = InventoryCoverage::StatusSkipped; $statusByType[$type] = [
'status' => InventoryCoverage::StatusSkipped,
'item_count' => 0,
];
} }
foreach ($coverageStatusByType as $type => $status) { foreach ($coverageStatusByType as $type => $status) {
@ -138,8 +145,16 @@ function (string $policyType, bool $success, ?string $errorCode) use (&$processe
} }
if ((string) ($result['status'] ?? '') === 'skipped') { if ((string) ($result['status'] ?? '') === 'skipped') {
$skippedErrorCode = is_string($result['error_codes'][0] ?? null)
? (string) $result['error_codes'][0]
: null;
foreach ($statusByType as $type => $status) { foreach ($statusByType as $type => $status) {
$statusByType[$type] = InventoryCoverage::StatusSkipped; $statusByType[$type] = array_filter([
'status' => InventoryCoverage::StatusSkipped,
'item_count' => 0,
'error_code' => $skippedErrorCode,
], static fn (mixed $value): bool => $value !== null);
} }
} }

View File

@ -4,6 +4,7 @@
use App\Models\OperationRun; use App\Models\OperationRun;
use App\Models\Tenant; use App\Models\Tenant;
use App\Support\OpsUx\ActiveRuns;
use App\Support\OpsUx\OpsUxBrowserEvents; use App\Support\OpsUx\OpsUxBrowserEvents;
use Filament\Facades\Filament; use Filament\Facades\Filament;
use Illuminate\Support\Collection; use Illuminate\Support\Collection;
@ -85,13 +86,13 @@ public function refreshRuns(): void
$query = OperationRun::query() $query = OperationRun::query()
->where('tenant_id', $tenantId) ->where('tenant_id', $tenantId)
->active() ->healthyActive()
->orderByDesc('created_at'); ->orderByDesc('created_at');
$activeCount = (clone $query)->count(); $activeCount = (clone $query)->count();
$this->runs = (clone $query)->limit(6)->get(); $this->runs = (clone $query)->limit(6)->get();
$this->overflowCount = max(0, $activeCount - 5); $this->overflowCount = max(0, $activeCount - 5);
$this->hasActiveRuns = $this->runs->isNotEmpty(); $this->hasActiveRuns = ActiveRuns::existForTenantId($tenantId);
} }
public function render(): \Illuminate\Contracts\View\View public function render(): \Illuminate\Contracts\View\View

View File

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

View File

@ -2,6 +2,7 @@
namespace App\Models; namespace App\Models;
use App\Support\Inventory\InventoryCoverage;
use App\Support\OperationCatalog; use App\Support\OperationCatalog;
use App\Support\OperationRunOutcome; use App\Support\OperationRunOutcome;
use App\Support\OperationRunStatus; use App\Support\OperationRunStatus;
@ -17,6 +18,12 @@ class OperationRun extends Model
{ {
use HasFactory; use HasFactory;
public const string PROBLEM_CLASS_NONE = 'none';
public const string PROBLEM_CLASS_ACTIVE_STALE_ATTENTION = 'active_stale_attention';
public const string PROBLEM_CLASS_TERMINAL_FOLLOW_UP = 'terminal_follow_up';
protected $guarded = []; protected $guarded = [];
protected $casts = [ protected $casts = [
@ -178,17 +185,31 @@ public function scopeDashboardNeedsFollowUp(Builder $query): Builder
return $query->where(function (Builder $query): void { return $query->where(function (Builder $query): void {
$query $query
->where(function (Builder $terminalQuery): void { ->where(function (Builder $terminalQuery): void {
$terminalQuery $terminalQuery->terminalFollowUp();
})
->orWhere(function (Builder $activeQuery): void {
$activeQuery->activeStaleAttention();
});
});
}
public function scopeActiveStaleAttention(Builder $query, ?OperationLifecyclePolicy $policy = null): Builder
{
return $query->likelyStale($policy);
}
public function scopeTerminalFollowUp(Builder $query): Builder
{
return $query
->where('status', OperationRunStatus::Completed->value) ->where('status', OperationRunStatus::Completed->value)
->where(function (Builder $query): void {
$query
->whereIn('outcome', [ ->whereIn('outcome', [
OperationRunOutcome::Blocked->value, OperationRunOutcome::Blocked->value,
OperationRunOutcome::PartiallySucceeded->value, OperationRunOutcome::PartiallySucceeded->value,
OperationRunOutcome::Failed->value, OperationRunOutcome::Failed->value,
]); ])
}) ->orWhereNotNull('context->reconciliation->reconciled_at');
->orWhere(function (Builder $activeQuery): void {
$activeQuery->likelyStale();
});
}); });
} }
@ -253,11 +274,33 @@ public function setFinishedAtAttribute(mixed $value): void
$this->completed_at = $value; $this->completed_at = $value;
} }
public function inventoryCoverage(): ?InventoryCoverage
{
return InventoryCoverage::fromContext($this->context);
}
public function isGovernanceArtifactOperation(): bool public function isGovernanceArtifactOperation(): bool
{ {
return OperationCatalog::isGovernanceArtifactOperation((string) $this->type); return OperationCatalog::isGovernanceArtifactOperation((string) $this->type);
} }
public static function latestCompletedCoverageBearingInventorySyncForTenant(int $tenantId): ?self
{
if ($tenantId <= 0) {
return null;
}
return static::query()
->where('tenant_id', $tenantId)
->where('type', 'inventory_sync')
->where('status', OperationRunStatus::Completed->value)
->whereNotNull('completed_at')
->latest('completed_at')
->latest('id')
->cursor()
->first(static fn (self $run): bool => $run->inventoryCoverage() instanceof InventoryCoverage);
}
public function supportsOperatorExplanation(): bool public function supportsOperatorExplanation(): bool
{ {
return OperationCatalog::supportsOperatorExplanation((string) $this->type); return OperationCatalog::supportsOperatorExplanation((string) $this->type);
@ -317,17 +360,64 @@ public function freshnessState(): OperationRunFreshnessState
return OperationRunFreshnessState::forRun($this); return OperationRunFreshnessState::forRun($this);
} }
public function requiresDashboardFollowUp(): bool /**
* @return list<string>
*/
public static function problemClasses(): array
{ {
return [
self::PROBLEM_CLASS_NONE,
self::PROBLEM_CLASS_ACTIVE_STALE_ATTENTION,
self::PROBLEM_CLASS_TERMINAL_FOLLOW_UP,
];
}
public function problemClass(): string
{
$freshnessState = $this->freshnessState();
if ($freshnessState->isLikelyStale()) {
return self::PROBLEM_CLASS_ACTIVE_STALE_ATTENTION;
}
if ($freshnessState->isReconciledFailed()) {
return self::PROBLEM_CLASS_TERMINAL_FOLLOW_UP;
}
if ((string) $this->status === OperationRunStatus::Completed->value) { if ((string) $this->status === OperationRunStatus::Completed->value) {
return in_array((string) $this->outcome, [ return in_array((string) $this->outcome, [
OperationRunOutcome::Blocked->value, OperationRunOutcome::Blocked->value,
OperationRunOutcome::PartiallySucceeded->value, OperationRunOutcome::PartiallySucceeded->value,
OperationRunOutcome::Failed->value, OperationRunOutcome::Failed->value,
], true)
? self::PROBLEM_CLASS_TERMINAL_FOLLOW_UP
: self::PROBLEM_CLASS_NONE;
}
return self::PROBLEM_CLASS_NONE;
}
public function hasStaleLineage(): bool
{
return $this->freshnessState()->isReconciledFailed();
}
public function isCurrentlyActive(): bool
{
return in_array((string) $this->status, [
OperationRunStatus::Queued->value,
OperationRunStatus::Running->value,
], true); ], true);
} }
return $this->freshnessState()->isLikelyStale(); public function requiresOperatorReview(): bool
{
return $this->problemClass() !== self::PROBLEM_CLASS_NONE;
}
public function requiresDashboardFollowUp(): bool
{
return $this->requiresOperatorReview();
} }
/** /**

View File

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

View File

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

View File

@ -82,17 +82,24 @@ public function syncNow(Tenant $tenant, array $selectionPayload): OperationRun
continue; continue;
} }
$statusByType[$type] = InventoryCoverage::StatusSkipped; $statusByType[$type] = [
'status' => InventoryCoverage::StatusSkipped,
'item_count' => 0,
];
} }
$result = $this->executeSelection( $result = $this->executeSelection(
$operationRun, $operationRun,
$tenant, $tenant,
$normalizedSelection, $normalizedSelection,
function (string $policyType, bool $success, ?string $errorCode) use (&$statusByType): void { function (string $policyType, bool $success, ?string $errorCode, int $itemCount) use (&$statusByType): void {
$statusByType[$policyType] = $success $statusByType[$policyType] = array_filter([
'status' => $success
? InventoryCoverage::StatusSucceeded ? InventoryCoverage::StatusSucceeded
: InventoryCoverage::StatusFailed; : InventoryCoverage::StatusFailed,
'item_count' => $itemCount,
'error_code' => $success ? null : $errorCode,
], static fn (mixed $value): bool => $value !== null);
}, },
); );
@ -126,10 +133,15 @@ function (string $policyType, bool $success, ?string $errorCode) use (&$statusBy
$updatedContext = is_array($operationRun->context) ? $operationRun->context : []; $updatedContext = is_array($operationRun->context) ? $operationRun->context : [];
$coverageStatusByType = $statusByType; $coverageStatusByType = $statusByType;
$skippedErrorCode = is_string($errorCodes[0] ?? null) ? (string) $errorCodes[0] : null;
if ($status === 'skipped') { if ($status === 'skipped') {
foreach ($coverageStatusByType as $type => $coverageStatus) { foreach ($coverageStatusByType as $type => $coverageStatus) {
$coverageStatusByType[$type] = InventoryCoverage::StatusSkipped; $coverageStatusByType[$type] = array_filter([
'status' => InventoryCoverage::StatusSkipped,
'item_count' => 0,
'error_code' => $skippedErrorCode,
], static fn (mixed $value): bool => $value !== null);
} }
} }
@ -176,7 +188,7 @@ function (string $policyType, bool $success, ?string $errorCode) use (&$statusBy
* This method MUST NOT create or update legacy sync-run rows; OperationRun is canonical. * This method MUST NOT create or update legacy sync-run rows; OperationRun is canonical.
* *
* @param array<string, mixed> $selectionPayload * @param array<string, mixed> $selectionPayload
* @param null|callable(string $policyType, bool $success, ?string $errorCode): void $onPolicyTypeProcessed * @param null|callable(string $policyType, bool $success, ?string $errorCode, int $itemCount): void $onPolicyTypeProcessed
* @return array{status: string, had_errors: bool, error_codes: list<string>, error_context: array<string, mixed>|null, items_observed_count: int, items_upserted_count: int, errors_count: int} * @return array{status: string, had_errors: bool, error_codes: list<string>, error_context: array<string, mixed>|null, items_observed_count: int, items_upserted_count: int, errors_count: int}
*/ */
public function executeSelection(OperationRun $operationRun, Tenant $tenant, array $selectionPayload, ?callable $onPolicyTypeProcessed = null): array public function executeSelection(OperationRun $operationRun, Tenant $tenant, array $selectionPayload, ?callable $onPolicyTypeProcessed = null): array
@ -245,7 +257,7 @@ public function normalizeAndHashSelection(array $selectionPayload): array
/** /**
* @param array{policy_types: list<string>, categories: list<string>, include_foundations: bool, include_dependencies: bool} $normalizedSelection * @param array{policy_types: list<string>, categories: list<string>, include_foundations: bool, include_dependencies: bool} $normalizedSelection
* @param null|callable(string $policyType, bool $success, ?string $errorCode): void $onPolicyTypeProcessed * @param null|callable(string $policyType, bool $success, ?string $errorCode, int $itemCount): void $onPolicyTypeProcessed
* @return array{status: string, had_errors: bool, error_codes: list<string>, error_context: array<string, mixed>|null, items_observed_count: int, items_upserted_count: int, errors_count: int} * @return array{status: string, had_errors: bool, error_codes: list<string>, error_context: array<string, mixed>|null, items_observed_count: int, items_upserted_count: int, errors_count: int}
*/ */
private function executeSelectionUnderLock(OperationRun $operationRun, Tenant $tenant, array $normalizedSelection, ?callable $onPolicyTypeProcessed = null): array private function executeSelectionUnderLock(OperationRun $operationRun, Tenant $tenant, array $normalizedSelection, ?callable $onPolicyTypeProcessed = null): array
@ -256,6 +268,7 @@ private function executeSelectionUnderLock(OperationRun $operationRun, Tenant $t
$errorCodes = []; $errorCodes = [];
$hadErrors = false; $hadErrors = false;
$warnings = []; $warnings = [];
$observedByType = [];
try { try {
$connection = $this->resolveProviderConnection($tenant); $connection = $this->resolveProviderConnection($tenant);
@ -277,7 +290,7 @@ private function executeSelectionUnderLock(OperationRun $operationRun, Tenant $t
$hadErrors = true; $hadErrors = true;
$errors++; $errors++;
$errorCodes[] = 'unsupported_type'; $errorCodes[] = 'unsupported_type';
$onPolicyTypeProcessed && $onPolicyTypeProcessed($policyType, false, 'unsupported_type'); $onPolicyTypeProcessed && $onPolicyTypeProcessed($policyType, false, 'unsupported_type', 0);
continue; continue;
} }
@ -293,7 +306,7 @@ private function executeSelectionUnderLock(OperationRun $operationRun, Tenant $t
$errors++; $errors++;
$errorCode = $this->mapGraphFailureToErrorCode($response); $errorCode = $this->mapGraphFailureToErrorCode($response);
$errorCodes[] = $errorCode; $errorCodes[] = $errorCode;
$onPolicyTypeProcessed && $onPolicyTypeProcessed($policyType, false, $errorCode); $onPolicyTypeProcessed && $onPolicyTypeProcessed($policyType, false, $errorCode, 0);
continue; continue;
} }
@ -313,6 +326,7 @@ private function executeSelectionUnderLock(OperationRun $operationRun, Tenant $t
} }
$observed++; $observed++;
$observedByType[$policyType] = (int) ($observedByType[$policyType] ?? 0) + 1;
$includeDeps = (bool) ($normalizedSelection['include_dependencies'] ?? true); $includeDeps = (bool) ($normalizedSelection['include_dependencies'] ?? true);
@ -384,7 +398,12 @@ private function executeSelectionUnderLock(OperationRun $operationRun, Tenant $t
} }
} }
$onPolicyTypeProcessed && $onPolicyTypeProcessed($policyType, true, null); $onPolicyTypeProcessed && $onPolicyTypeProcessed(
$policyType,
true,
null,
(int) ($observedByType[$policyType] ?? 0),
);
} }
return [ return [

View File

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

View File

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

View File

@ -27,6 +27,7 @@ final class BadgeCatalog
BadgeDomain::BaselineSnapshotGapStatus->value => Domains\BaselineSnapshotGapStatusBadge::class, BadgeDomain::BaselineSnapshotGapStatus->value => Domains\BaselineSnapshotGapStatusBadge::class,
BadgeDomain::OperationRunStatus->value => Domains\OperationRunStatusBadge::class, BadgeDomain::OperationRunStatus->value => Domains\OperationRunStatusBadge::class,
BadgeDomain::OperationRunOutcome->value => Domains\OperationRunOutcomeBadge::class, BadgeDomain::OperationRunOutcome->value => Domains\OperationRunOutcomeBadge::class,
BadgeDomain::InventoryCoverageState->value => Domains\InventoryCoverageStateBadge::class,
BadgeDomain::BackupSetStatus->value => Domains\BackupSetStatusBadge::class, BadgeDomain::BackupSetStatus->value => Domains\BackupSetStatusBadge::class,
BadgeDomain::RestoreRunStatus->value => Domains\RestoreRunStatusBadge::class, BadgeDomain::RestoreRunStatus->value => Domains\RestoreRunStatusBadge::class,
BadgeDomain::RestoreCheckSeverity->value => Domains\RestoreCheckSeverityBadge::class, BadgeDomain::RestoreCheckSeverity->value => Domains\RestoreCheckSeverityBadge::class,
@ -46,6 +47,8 @@ final class BadgeCatalog
BadgeDomain::IgnoredAt->value => Domains\IgnoredAtBadge::class, BadgeDomain::IgnoredAt->value => Domains\IgnoredAtBadge::class,
BadgeDomain::RestorePreviewDecision->value => Domains\RestorePreviewDecisionBadge::class, BadgeDomain::RestorePreviewDecision->value => Domains\RestorePreviewDecisionBadge::class,
BadgeDomain::RestoreResultStatus->value => Domains\RestoreResultStatusBadge::class, BadgeDomain::RestoreResultStatus->value => Domains\RestoreResultStatusBadge::class,
BadgeDomain::ProviderConsentStatus->value => Domains\ProviderConsentStatusBadge::class,
BadgeDomain::ProviderVerificationStatus->value => Domains\ProviderVerificationStatusBadge::class,
BadgeDomain::ProviderConnectionStatus->value => Domains\ProviderConnectionStatusBadge::class, BadgeDomain::ProviderConnectionStatus->value => Domains\ProviderConnectionStatusBadge::class,
BadgeDomain::ProviderConnectionHealth->value => Domains\ProviderConnectionHealthBadge::class, BadgeDomain::ProviderConnectionHealth->value => Domains\ProviderConnectionHealthBadge::class,
BadgeDomain::ManagedTenantOnboardingVerificationStatus->value => Domains\ManagedTenantOnboardingVerificationStatusBadge::class, BadgeDomain::ManagedTenantOnboardingVerificationStatus->value => Domains\ManagedTenantOnboardingVerificationStatusBadge::class,
@ -166,6 +169,32 @@ public static function normalizeProviderConnectionStatus(mixed $value): ?string
}; };
} }
public static function normalizeProviderConsentStatus(mixed $value): ?string
{
$state = self::normalizeState($value);
return match ($state) {
'needs_admin_consent', 'needs_consent', 'consent_required' => 'required',
'connected' => 'granted',
'error' => 'failed',
default => $state,
};
}
public static function normalizeProviderVerificationStatus(mixed $value): ?string
{
$state = self::normalizeState($value);
return match ($state) {
'not_started', 'never_checked' => 'unknown',
'in_progress' => 'pending',
'ok' => 'healthy',
'warning', 'needs_attention' => 'degraded',
'failed' => 'error',
default => $state,
};
}
public static function normalizeProviderConnectionHealth(mixed $value): ?string public static function normalizeProviderConnectionHealth(mixed $value): ?string
{ {
$state = self::normalizeState($value); $state = self::normalizeState($value);

View File

@ -18,6 +18,7 @@ enum BadgeDomain: string
case BaselineSnapshotGapStatus = 'baseline_snapshot_gap_status'; case BaselineSnapshotGapStatus = 'baseline_snapshot_gap_status';
case OperationRunStatus = 'operation_run_status'; case OperationRunStatus = 'operation_run_status';
case OperationRunOutcome = 'operation_run_outcome'; case OperationRunOutcome = 'operation_run_outcome';
case InventoryCoverageState = 'inventory_coverage_state';
case BackupSetStatus = 'backup_set_status'; case BackupSetStatus = 'backup_set_status';
case RestoreRunStatus = 'restore_run_status'; case RestoreRunStatus = 'restore_run_status';
case RestoreCheckSeverity = 'restore_check_severity'; case RestoreCheckSeverity = 'restore_check_severity';
@ -37,6 +38,8 @@ enum BadgeDomain: string
case IgnoredAt = 'ignored_at'; case IgnoredAt = 'ignored_at';
case RestorePreviewDecision = 'restore_preview_decision'; case RestorePreviewDecision = 'restore_preview_decision';
case RestoreResultStatus = 'restore_result_status'; case RestoreResultStatus = 'restore_result_status';
case ProviderConsentStatus = 'provider_connection.consent_status';
case ProviderVerificationStatus = 'provider_connection.verification_status';
case ProviderConnectionStatus = 'provider_connection.status'; case ProviderConnectionStatus = 'provider_connection.status';
case ProviderConnectionHealth = 'provider_connection.health'; case ProviderConnectionHealth = 'provider_connection.health';
case ManagedTenantOnboardingVerificationStatus = 'managed_tenant_onboarding.verification_status'; case ManagedTenantOnboardingVerificationStatus = 'managed_tenant_onboarding.verification_status';

View File

@ -0,0 +1,25 @@
<?php
declare(strict_types=1);
namespace App\Support\Badges\Domains;
use App\Support\Badges\BadgeCatalog;
use App\Support\Badges\BadgeMapper;
use App\Support\Badges\BadgeSpec;
final class InventoryCoverageStateBadge implements BadgeMapper
{
public function spec(mixed $value): BadgeSpec
{
$state = BadgeCatalog::normalizeState($value);
return match ($state) {
'succeeded' => new BadgeSpec('Succeeded', 'success', 'heroicon-m-check-circle'),
'failed' => new BadgeSpec('Failed', 'danger', 'heroicon-m-x-circle'),
'skipped' => new BadgeSpec('Skipped', 'warning', 'heroicon-m-minus-circle'),
'unknown' => new BadgeSpec('Unknown', 'gray', 'heroicon-m-question-mark-circle'),
default => BadgeSpec::unknown(),
};
}
}

View File

@ -0,0 +1,24 @@
<?php
namespace App\Support\Badges\Domains;
use App\Support\Badges\BadgeCatalog;
use App\Support\Badges\BadgeMapper;
use App\Support\Badges\BadgeSpec;
final class ProviderConsentStatusBadge implements BadgeMapper
{
public function spec(mixed $value): BadgeSpec
{
$state = BadgeCatalog::normalizeProviderConsentStatus($value);
return match ($state) {
'required' => new BadgeSpec('Required', 'warning', 'heroicon-m-exclamation-triangle'),
'granted' => new BadgeSpec('Granted', 'success', 'heroicon-m-check-circle'),
'failed' => new BadgeSpec('Failed', 'danger', 'heroicon-m-x-circle'),
'revoked' => new BadgeSpec('Revoked', 'danger', 'heroicon-m-no-symbol'),
'unknown' => new BadgeSpec('Unknown', 'gray', 'heroicon-m-question-mark-circle'),
default => BadgeSpec::unknown(),
};
}
}

View File

@ -0,0 +1,25 @@
<?php
namespace App\Support\Badges\Domains;
use App\Support\Badges\BadgeCatalog;
use App\Support\Badges\BadgeMapper;
use App\Support\Badges\BadgeSpec;
final class ProviderVerificationStatusBadge implements BadgeMapper
{
public function spec(mixed $value): BadgeSpec
{
$state = BadgeCatalog::normalizeProviderVerificationStatus($value);
return match ($state) {
'pending' => new BadgeSpec('Pending', 'info', 'heroicon-m-clock'),
'healthy' => new BadgeSpec('Healthy', 'success', 'heroicon-m-check-circle'),
'degraded' => new BadgeSpec('Degraded', 'warning', 'heroicon-m-exclamation-triangle'),
'blocked' => new BadgeSpec('Blocked', 'danger', 'heroicon-m-no-symbol'),
'error' => new BadgeSpec('Error', 'danger', 'heroicon-m-x-circle'),
'unknown' => new BadgeSpec('Unknown', 'gray', 'heroicon-m-question-mark-circle'),
default => BadgeSpec::unknown(),
};
}
}

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

@ -68,10 +68,56 @@ public function coveredTypes(): array
return array_values(array_unique($covered)); return array_values(array_unique($covered));
} }
/**
* @return array<string, array{
* segment: 'policy'|'foundation',
* type: string,
* status: string,
* item_count?: int,
* error_code?: string|null
* }>
*/
public function rows(): array
{
$rows = [];
foreach ($this->policyTypes as $type => $meta) {
$rows[$type] = array_merge($meta, [
'segment' => 'policy',
'type' => $type,
]);
}
foreach ($this->foundationTypes as $type => $meta) {
$rows[$type] = array_merge($meta, [
'segment' => 'foundation',
'type' => $type,
]);
}
ksort($rows);
return $rows;
}
/**
* @return array{
* segment: 'policy'|'foundation',
* type: string,
* status: string,
* item_count?: int,
* error_code?: string|null
* }|null
*/
public function row(string $type): ?array
{
return $this->rows()[$type] ?? null;
}
/** /**
* Build the canonical `inventory.coverage.*` payload for OperationRun.context. * Build the canonical `inventory.coverage.*` payload for OperationRun.context.
* *
* @param array<string, string> $statusByType * @param array<string, string|array{status: string, item_count?: int, error_code?: string|null}> $statusByType
* @param list<string> $foundationTypes * @param list<string> $foundationTypes
* @return array{policy_types: array<string, array{status: string}>, foundation_types: array<string, array{status: string}>} * @return array{policy_types: array<string, array{status: string}>, foundation_types: array<string, array{status: string}>}
*/ */
@ -88,14 +134,12 @@ public static function buildPayload(array $statusByType, array $foundationTypes)
continue; continue;
} }
$normalizedStatus = self::normalizeStatus($status); $row = self::normalizeBuildRow($status);
if ($normalizedStatus === null) { if ($row === null) {
continue; continue;
} }
$row = ['status' => $normalizedStatus];
if (array_key_exists($type, $foundationLookup)) { if (array_key_exists($type, $foundationLookup)) {
$foundations[$type] = $row; $foundations[$type] = $row;
@ -114,6 +158,40 @@ public static function buildPayload(array $statusByType, array $foundationTypes)
]; ];
} }
/**
* @return array{status: string, item_count?: int, error_code?: string|null}|null
*/
private static function normalizeBuildRow(mixed $value): ?array
{
if (is_string($value)) {
$status = self::normalizeStatus($value);
return $status === null ? null : ['status' => $status];
}
if (! is_array($value)) {
return null;
}
$status = self::normalizeStatus($value['status'] ?? null);
if ($status === null) {
return null;
}
$row = ['status' => $status];
if (array_key_exists('item_count', $value) && is_int($value['item_count'])) {
$row['item_count'] = $value['item_count'];
}
if (array_key_exists('error_code', $value) && (is_string($value['error_code']) || $value['error_code'] === null)) {
$row['error_code'] = $value['error_code'];
}
return $row;
}
private static function normalizeStatus(mixed $status): ?string private static function normalizeStatus(mixed $status): ?string
{ {
if (! is_string($status)) { if (! is_string($status)) {

View File

@ -8,39 +8,75 @@
class InventoryKpiBadges class InventoryKpiBadges
{ {
public static function coverage(int $restorableCount, int $partialCount): string public static function coverageBreakdown(int $failedCount, int $skippedCount, int $unknownCount): string
{ {
if ($failedCount === 0 && $skippedCount === 0 && $unknownCount === 0) {
return Blade::render(<<<'BLADE' return Blade::render(<<<'BLADE'
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<x-filament::badge color="success" size="sm"> <x-filament::badge color="success" size="sm">
Restorable {{ $restorableCount }} No follow-up
</x-filament::badge>
<x-filament::badge color="warning" size="sm">
Partial {{ $partialCount }}
</x-filament::badge> </x-filament::badge>
</div> </div>
BLADE);
}
return Blade::render(<<<'BLADE'
<div class="flex flex-wrap items-center gap-2">
@if ($failedCount > 0)
<x-filament::badge color="danger" size="sm">
Failed {{ $failedCount }}
</x-filament::badge>
@endif
@if ($skippedCount > 0)
<x-filament::badge color="warning" size="sm">
Skipped {{ $skippedCount }}
</x-filament::badge>
@endif
@if ($unknownCount > 0)
<x-filament::badge color="gray" size="sm">
Unknown {{ $unknownCount }}
</x-filament::badge>
@endif
</div>
BLADE, [ BLADE, [
'restorableCount' => $restorableCount, 'failedCount' => $failedCount,
'partialCount' => $partialCount, 'skippedCount' => $skippedCount,
'unknownCount' => $unknownCount,
]); ]);
} }
public static function inventoryOps(int $dependenciesCount, int $riskCount): string public static function followUpSummary(?TenantCoverageTypeTruth $topPriorityRow, int $observedItemTotal, int $observedTypeCount): string
{ {
if (! $topPriorityRow instanceof TenantCoverageTypeTruth) {
return Blade::render(<<<'BLADE' return Blade::render(<<<'BLADE'
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<x-filament::badge color="gray" size="sm"> <x-filament::badge color="success" size="sm">
Dependencies {{ $dependenciesCount }} All covered
</x-filament::badge>
<x-filament::badge color="danger" size="sm">
Risk {{ $riskCount }}
</x-filament::badge> </x-filament::badge>
</div> </div>
BLADE);
}
return Blade::render(<<<'BLADE'
<div class="flex flex-wrap items-center gap-2">
<x-filament::badge color="gray" size="sm">
{{ $topPriorityLabel }}
</x-filament::badge>
<x-filament::badge color="info" size="sm">
Observed {{ $observedItemTotal }}
</x-filament::badge>
<span class="text-xs text-gray-600 dark:text-gray-300">
{{ $observedTypeCount }} supported types currently observed
</span>
</div>
BLADE, [ BLADE, [
'dependenciesCount' => $dependenciesCount, 'topPriorityLabel' => $topPriorityRow->label,
'riskCount' => $riskCount, 'observedItemTotal' => $observedItemTotal,
'observedTypeCount' => $observedTypeCount,
]); ]);
} }
} }

View File

@ -0,0 +1,136 @@
<?php
declare(strict_types=1);
namespace App\Support\Inventory;
use App\Models\OperationRun;
use InvalidArgumentException;
final readonly class TenantCoverageTruth
{
/**
* @param list<TenantCoverageTypeTruth> $rows
*/
public function __construct(
public int $tenantId,
public ?OperationRun $basisRun,
public bool $hasCurrentCoverageResult,
public int $supportedTypeCount,
public int $succeededTypeCount,
public int $failedTypeCount,
public int $skippedTypeCount,
public int $unknownTypeCount,
public int $followUpTypeCount,
public int $observedItemTotal,
public array $rows,
) {
if ($this->tenantId <= 0) {
throw new InvalidArgumentException('Tenant coverage truth requires a positive tenant id.');
}
if ($this->supportedTypeCount < 0 || $this->observedItemTotal < 0) {
throw new InvalidArgumentException('Tenant coverage truth counts must be zero or greater.');
}
}
public function basisRunId(): ?int
{
return $this->basisRun instanceof OperationRun
? (int) $this->basisRun->getKey()
: null;
}
public function basisRunOutcome(): ?string
{
return $this->basisRun instanceof OperationRun
? (string) $this->basisRun->outcome
: null;
}
public function basisCompletedAtLabel(): ?string
{
if (! $this->basisRun instanceof OperationRun) {
return null;
}
$timestamp = $this->basisRun->completed_at ?? $this->basisRun->started_at ?? $this->basisRun->created_at;
return $timestamp?->diffForHumans(['short' => true]);
}
public function topPriorityFollowUpRow(): ?TenantCoverageTypeTruth
{
foreach ($this->rows as $row) {
if ($row->followUpRequired) {
return $row;
}
}
return null;
}
public function observedTypeCount(): int
{
return count(array_filter(
$this->rows,
static fn (TenantCoverageTypeTruth $row): bool => $row->observedItemCount > 0,
));
}
/**
* @return list<TenantCoverageTypeTruth>
*/
public function followUpRows(): array
{
return array_values(array_filter(
$this->rows,
static fn (TenantCoverageTypeTruth $row): bool => $row->followUpRequired,
));
}
/**
* @return array{
* tenantId: int,
* basisRun: array{id: int, outcome: string, completedAt: string|null}|null,
* hasCurrentCoverageResult: bool,
* summary: array{
* supportedTypes: int,
* succeededTypes: int,
* failedTypes: int,
* skippedTypes: int,
* unknownTypes: int,
* followUpTypes: int,
* observedItems: int
* },
* rows: list<array<string, mixed>>
* }
*/
public function toArray(): array
{
return [
'tenantId' => $this->tenantId,
'basisRun' => $this->basisRun instanceof OperationRun
? [
'id' => (int) $this->basisRun->getKey(),
'outcome' => (string) $this->basisRun->outcome,
'completedAt' => $this->basisRun->completed_at?->toIso8601String(),
]
: null,
'hasCurrentCoverageResult' => $this->hasCurrentCoverageResult,
'summary' => [
'supportedTypes' => $this->supportedTypeCount,
'succeededTypes' => $this->succeededTypeCount,
'failedTypes' => $this->failedTypeCount,
'skippedTypes' => $this->skippedTypeCount,
'unknownTypes' => $this->unknownTypeCount,
'followUpTypes' => $this->followUpTypeCount,
'observedItems' => $this->observedItemTotal,
],
'rows' => array_map(
static fn (TenantCoverageTypeTruth $row): array => $row->toArray(),
$this->rows,
),
];
}
}

View File

@ -0,0 +1,170 @@
<?php
declare(strict_types=1);
namespace App\Support\Inventory;
use App\Models\InventoryItem;
use App\Models\OperationRun;
use App\Models\Tenant;
use App\Services\Inventory\CoverageCapabilitiesResolver;
use Illuminate\Support\Collection;
final class TenantCoverageTruthResolver
{
public function __construct(
private readonly CoverageCapabilitiesResolver $coverageCapabilities,
) {}
public function resolve(Tenant|int $tenant): TenantCoverageTruth
{
$tenantId = $tenant instanceof Tenant
? (int) $tenant->getKey()
: (int) $tenant;
$basisRun = OperationRun::latestCompletedCoverageBearingInventorySyncForTenant($tenantId);
$basisCoverage = $basisRun?->inventoryCoverage();
/** @var array<string, int> $countsByType */
$countsByType = InventoryItem::query()
->where('tenant_id', $tenantId)
->selectRaw('policy_type, COUNT(*) as aggregate')
->groupBy('policy_type')
->pluck('aggregate', 'policy_type')
->map(static fn (mixed $value): int => (int) $value)
->all();
$rows = $this->supportedTypes()
->map(function (array $meta) use ($basisCoverage, $countsByType): TenantCoverageTypeTruth {
$type = (string) $meta['type'];
$segment = (string) $meta['segment'];
$basisRow = $basisCoverage?->row($type);
$coverageState = is_string($basisRow['status'] ?? null)
? (string) $basisRow['status']
: TenantCoverageTypeTruth::StateUnknown;
$observedItemCount = (int) ($countsByType[$type] ?? 0);
$basisItemCount = is_int($basisRow['item_count'] ?? null)
? (int) $basisRow['item_count']
: null;
$basisErrorCode = is_string($basisRow['error_code'] ?? null)
? (string) $basisRow['error_code']
: null;
$followUpRequired = $coverageState !== TenantCoverageTypeTruth::StateSucceeded;
return new TenantCoverageTypeTruth(
key: sprintf('%s:%s', $segment, $type),
type: $type,
segment: $segment,
label: (string) ($meta['label'] ?? $type),
category: (string) ($meta['category'] ?? 'Other'),
platform: is_string($meta['platform'] ?? null) ? (string) $meta['platform'] : null,
coverageState: $coverageState,
followUpRequired: $followUpRequired,
followUpPriority: self::followUpPriorityForState($coverageState),
observedItemCount: $observedItemCount,
basisItemCount: $basisItemCount,
basisErrorCode: $basisErrorCode,
restoreMode: is_string($meta['restore'] ?? null) ? (string) $meta['restore'] : null,
riskLevel: is_string($meta['risk'] ?? null) ? (string) $meta['risk'] : null,
supportsDependencies: $segment === 'policy' && $this->coverageCapabilities->supportsDependencies($type),
followUpGuidance: self::followUpGuidanceForState($coverageState, $basisErrorCode),
isBasisPayloadBacked: $basisRow !== null,
);
})
->sort(function (TenantCoverageTypeTruth $left, TenantCoverageTypeTruth $right): int {
$priority = $left->followUpPriority <=> $right->followUpPriority;
if ($priority !== 0) {
return $priority;
}
$observed = $right->observedItemCount <=> $left->observedItemCount;
if ($observed !== 0) {
return $observed;
}
return strnatcasecmp($left->label, $right->label);
})
->values()
->all();
return new TenantCoverageTruth(
tenantId: $tenantId,
basisRun: $basisRun,
hasCurrentCoverageResult: $basisCoverage instanceof InventoryCoverage,
supportedTypeCount: count($rows),
succeededTypeCount: $this->countRowsByState($rows, TenantCoverageTypeTruth::StateSucceeded),
failedTypeCount: $this->countRowsByState($rows, TenantCoverageTypeTruth::StateFailed),
skippedTypeCount: $this->countRowsByState($rows, TenantCoverageTypeTruth::StateSkipped),
unknownTypeCount: $this->countRowsByState($rows, TenantCoverageTypeTruth::StateUnknown),
followUpTypeCount: count(array_filter(
$rows,
static fn (TenantCoverageTypeTruth $row): bool => $row->followUpRequired,
)),
observedItemTotal: array_sum($countsByType),
rows: $rows,
);
}
/**
* @return Collection<int, array{type: string, label: string, category: string, platform?: string|null, restore?: string|null, risk?: string|null, segment: 'policy'|'foundation'}>
*/
private function supportedTypes(): Collection
{
$supported = collect(InventoryPolicyTypeMeta::supported())
->filter(static fn (array $row): bool => is_string($row['type'] ?? null) && $row['type'] !== '')
->map(static fn (array $row): array => array_merge($row, ['segment' => 'policy']));
$foundations = collect(InventoryPolicyTypeMeta::foundations())
->filter(static fn (array $row): bool => is_string($row['type'] ?? null) && $row['type'] !== '')
->map(static fn (array $row): array => array_merge($row, ['segment' => 'foundation']));
return $supported
->merge($foundations)
->values();
}
public static function followUpPriorityForState(string $coverageState): int
{
return match ($coverageState) {
TenantCoverageTypeTruth::StateFailed => 0,
TenantCoverageTypeTruth::StateUnknown => 1,
TenantCoverageTypeTruth::StateSkipped => 2,
default => 3,
};
}
public static function followUpGuidanceForState(string $coverageState, ?string $basisErrorCode): string
{
return match (true) {
$coverageState === TenantCoverageTypeTruth::StateFailed && in_array($basisErrorCode, [
'graph_forbidden',
'provider_consent_missing',
'provider_permission_missing',
'provider_permission_denied',
], true) => 'Review provider consent or permissions, then rerun inventory sync.',
$coverageState === TenantCoverageTypeTruth::StateFailed && in_array($basisErrorCode, [
'graph_throttled',
'graph_transient',
'rate_limited',
'network_unreachable',
], true) => 'Retry inventory sync after the provider recovers.',
$coverageState === TenantCoverageTypeTruth::StateFailed => 'Review the latest inventory sync details before retrying.',
$coverageState === TenantCoverageTypeTruth::StateSkipped => 'Run inventory sync again with the required types selected.',
$coverageState === TenantCoverageTypeTruth::StateUnknown => 'No current basis result exists for this type. Run inventory sync to confirm coverage.',
default => 'No follow-up is currently required.',
};
}
/**
* @param list<TenantCoverageTypeTruth> $rows
*/
private function countRowsByState(array $rows, string $state): int
{
return count(array_filter(
$rows,
static fn (TenantCoverageTypeTruth $row): bool => $row->coverageState === $state,
));
}
}

View File

@ -0,0 +1,88 @@
<?php
declare(strict_types=1);
namespace App\Support\Inventory;
use InvalidArgumentException;
final readonly class TenantCoverageTypeTruth
{
public const string StateSucceeded = InventoryCoverage::StatusSucceeded;
public const string StateFailed = InventoryCoverage::StatusFailed;
public const string StateSkipped = InventoryCoverage::StatusSkipped;
public const string StateUnknown = 'unknown';
public function __construct(
public string $key,
public string $type,
public string $segment,
public string $label,
public string $category,
public ?string $platform,
public string $coverageState,
public bool $followUpRequired,
public int $followUpPriority,
public int $observedItemCount,
public ?int $basisItemCount,
public ?string $basisErrorCode,
public ?string $restoreMode,
public ?string $riskLevel,
public bool $supportsDependencies,
public string $followUpGuidance,
public bool $isBasisPayloadBacked,
) {
if ($this->key === '' || $this->type === '' || $this->label === '') {
throw new InvalidArgumentException('Coverage truth rows require non-empty identity fields.');
}
}
/**
* @return array{
* __key: string,
* key: string,
* type: string,
* segment: string,
* label: string,
* category: string,
* platform: ?string,
* coverage_state: string,
* follow_up_required: bool,
* follow_up_priority: int,
* follow_up_guidance: string,
* observed_item_count: int,
* basis_item_count: ?int,
* basis_error_code: ?string,
* restore: ?string,
* risk: ?string,
* dependencies: bool,
* is_basis_payload_backed: bool
* }
*/
public function toArray(): array
{
return [
'__key' => $this->key,
'key' => $this->key,
'type' => $this->type,
'segment' => $this->segment,
'label' => $this->label,
'category' => $this->category,
'platform' => $this->platform,
'coverage_state' => $this->coverageState,
'follow_up_required' => $this->followUpRequired,
'follow_up_priority' => $this->followUpPriority,
'follow_up_guidance' => $this->followUpGuidance,
'observed_item_count' => $this->observedItemCount,
'basis_item_count' => $this->basisItemCount,
'basis_error_code' => $this->basisErrorCode,
'restore' => $this->restoreMode,
'risk' => $this->riskLevel,
'dependencies' => $this->supportsDependencies,
'is_basis_payload_backed' => $this->isBasisPayloadBacked,
];
}
}

View File

@ -188,13 +188,15 @@ private function resolveTenantRouteParameter(mixed $routeTenant): ?Tenant
return null; return null;
} }
$tenantKeyColumn = (new Tenant)->getQualifiedKeyName();
return Tenant::query() return Tenant::query()
->withTrashed() ->withTrashed()
->where(static function ($query) use ($routeTenant): void { ->where(static function ($query) use ($routeTenant, $tenantKeyColumn): void {
$query->where('external_id', $routeTenant); $query->where('external_id', $routeTenant);
if (ctype_digit($routeTenant)) { if (ctype_digit($routeTenant)) {
$query->orWhereKey((int) $routeTenant); $query->orWhere($tenantKeyColumn, (int) $routeTenant);
} }
}) })
->first(); ->first();

View File

@ -80,6 +80,7 @@ public static function index(
?CanonicalNavigationContext $context = null, ?CanonicalNavigationContext $context = null,
?string $activeTab = null, ?string $activeTab = null,
bool $allTenants = false, bool $allTenants = false,
?string $problemClass = null,
): string { ): string {
$parameters = $context?->toQuery() ?? []; $parameters = $context?->toQuery() ?? [];
@ -93,6 +94,18 @@ public static function index(
$parameters['activeTab'] = $activeTab; $parameters['activeTab'] = $activeTab;
} }
if (
is_string($problemClass)
&& in_array($problemClass, OperationRun::problemClasses(), true)
&& $problemClass !== OperationRun::PROBLEM_CLASS_NONE
) {
$parameters['problemClass'] = $problemClass;
if (! is_string($activeTab) || $activeTab === '') {
$parameters['activeTab'] = $problemClass;
}
}
return route('admin.operations.index', $parameters); return route('admin.operations.index', $parameters);
} }

View File

@ -11,9 +11,30 @@ final class ActiveRuns
{ {
public static function existForTenant(Tenant $tenant): bool public static function existForTenant(Tenant $tenant): bool
{ {
return self::existForTenantId((int) $tenant->getKey());
}
public static function existForTenantId(?int $tenantId): bool
{
if (! is_int($tenantId) || $tenantId <= 0) {
return false;
}
return OperationRun::query() return OperationRun::query()
->where('tenant_id', $tenant->getKey()) ->where('tenant_id', $tenantId)
->active() ->healthyActive()
->exists(); ->exists();
} }
public static function pollingIntervalForTenant(?Tenant $tenant): ?string
{
return $tenant instanceof Tenant
? self::pollingIntervalForTenantId((int) $tenant->getKey())
: null;
}
public static function pollingIntervalForTenantId(?int $tenantId): ?string
{
return self::existForTenantId($tenantId) ? '10s' : null;
}
} }

View File

@ -223,6 +223,70 @@ public static function freshnessState(OperationRun $run): OperationRunFreshnessS
return $run->freshnessState(); return $run->freshnessState();
} }
public static function problemClass(OperationRun $run): string
{
return $run->problemClass();
}
public static function problemClassLabel(OperationRun $run): ?string
{
return match (self::problemClass($run)) {
OperationRun::PROBLEM_CLASS_ACTIVE_STALE_ATTENTION => 'Likely stale active run',
OperationRun::PROBLEM_CLASS_TERMINAL_FOLLOW_UP => 'Terminal follow-up',
default => null,
};
}
public static function staleLineageNote(OperationRun $run): ?string
{
if (! $run->hasStaleLineage()) {
return null;
}
return 'This terminal run was automatically reconciled after stale lifecycle truth was lost.';
}
/**
* @return array{
* freshnessState:string,
* freshnessLabel:?string,
* problemClass:string,
* problemClassLabel:?string,
* isCurrentlyActive:bool,
* isReconciled:bool,
* staleLineageNote:?string,
* primaryNextAction:string,
* attentionNote:?string
* }
*/
public static function decisionZoneTruth(OperationRun $run): array
{
$freshnessState = self::freshnessState($run);
return [
'freshnessState' => $freshnessState->value,
'freshnessLabel' => self::lifecycleAttentionSummary($run),
'problemClass' => self::problemClass($run),
'problemClassLabel' => self::problemClassLabel($run),
'isCurrentlyActive' => $run->isCurrentlyActive(),
'isReconciled' => $run->isLifecycleReconciled(),
'staleLineageNote' => self::staleLineageNote($run),
'primaryNextAction' => self::surfaceGuidance($run) ?? 'No action needed.',
'attentionNote' => self::decisionAttentionNote($run),
];
}
public static function decisionAttentionNote(OperationRun $run): ?string
{
return match (self::problemClass($run)) {
OperationRun::PROBLEM_CLASS_ACTIVE_STALE_ATTENTION => 'Still active: Yes. Automatic reconciliation: No. This run is past its lifecycle window and needs stale-run investigation before retrying.',
OperationRun::PROBLEM_CLASS_TERMINAL_FOLLOW_UP => $run->hasStaleLineage()
? 'Still active: No. Automatic reconciliation: Yes. This terminal failure preserves stale-run lineage so operators can recover why the run stopped.'
: 'Still active: No. Automatic reconciliation: No. This run is terminal and still needs follow-up.',
default => null,
};
}
public static function lifecycleAttentionSummary(OperationRun $run): ?string public static function lifecycleAttentionSummary(OperationRun $run): ?string
{ {
return self::memoizeExplanation( return self::memoizeExplanation(
@ -247,7 +311,9 @@ private static function buildLifecycleAttentionSummary(OperationRun $run): ?stri
return match (self::freshnessState($run)) { return match (self::freshnessState($run)) {
OperationRunFreshnessState::LikelyStale => 'Likely stale', OperationRunFreshnessState::LikelyStale => 'Likely stale',
OperationRunFreshnessState::ReconciledFailed => 'Automatically reconciled', OperationRunFreshnessState::ReconciledFailed => 'Automatically reconciled',
default => null, default => self::problemClass($run) === OperationRun::PROBLEM_CLASS_TERMINAL_FOLLOW_UP
? 'Terminal follow-up'
: null,
}; };
} }

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

@ -33,6 +33,7 @@
use App\Support\Ui\DerivedState\RequestScopedDerivedStateStore; use App\Support\Ui\DerivedState\RequestScopedDerivedStateStore;
use App\Support\Ui\OperatorExplanation\CountDescriptor; use App\Support\Ui\OperatorExplanation\CountDescriptor;
use App\Support\Ui\OperatorExplanation\OperatorExplanationBuilder; use App\Support\Ui\OperatorExplanation\OperatorExplanationBuilder;
use Filament\Facades\Filament;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Arr; use Illuminate\Support\Arr;
@ -359,7 +360,7 @@ private function buildEvidenceSnapshotEnvelope(EvidenceSnapshot $snapshot): Arti
nextActionUrl: $nextActionUrl, nextActionUrl: $nextActionUrl,
relatedRunId: $snapshot->operation_run_id !== null ? (int) $snapshot->operation_run_id : null, relatedRunId: $snapshot->operation_run_id !== null ? (int) $snapshot->operation_run_id : null,
relatedArtifactUrl: $snapshot->tenant !== null relatedArtifactUrl: $snapshot->tenant !== null
? EvidenceSnapshotResource::getUrl('view', ['record' => $snapshot], tenant: $snapshot->tenant) ? $this->panelSafeTenantArtifactUrl(fn (): string => EvidenceSnapshotResource::getUrl('view', ['record' => $snapshot], tenant: $snapshot->tenant))
: null, : null,
includePublicationDimension: false, includePublicationDimension: false,
countDescriptors: [ countDescriptors: [
@ -500,9 +501,13 @@ private function buildTenantReviewEnvelope(TenantReview $review): ArtifactTruthE
: null; : null;
if ($publishBlockers !== [] && $review->tenant !== null) { if ($publishBlockers !== [] && $review->tenant !== null) {
$nextActionUrl = TenantReviewResource::tenantScopedUrl('view', ['record' => $review], $review->tenant); $nextActionUrl = $this->panelSafeTenantArtifactUrl(
fn (): string => TenantReviewResource::tenantScopedUrl('view', ['record' => $review], $review->tenant)
);
} elseif (($freshnessState === 'stale' || $contentState === 'partial') && $review->tenant !== null && $review->evidenceSnapshot !== null) { } elseif (($freshnessState === 'stale' || $contentState === 'partial') && $review->tenant !== null && $review->evidenceSnapshot !== null) {
$nextActionUrl = EvidenceSnapshotResource::getUrl('view', ['record' => $review->evidenceSnapshot], tenant: $review->tenant); $nextActionUrl = $this->panelSafeTenantArtifactUrl(
fn (): string => EvidenceSnapshotResource::getUrl('view', ['record' => $review->evidenceSnapshot], tenant: $review->tenant)
);
} }
return $this->makeEnvelope( return $this->makeEnvelope(
@ -538,7 +543,9 @@ private function buildTenantReviewEnvelope(TenantReview $review): ArtifactTruthE
nextActionUrl: $nextActionUrl, nextActionUrl: $nextActionUrl,
relatedRunId: $review->operation_run_id !== null ? (int) $review->operation_run_id : null, relatedRunId: $review->operation_run_id !== null ? (int) $review->operation_run_id : null,
relatedArtifactUrl: $review->tenant !== null relatedArtifactUrl: $review->tenant !== null
? TenantReviewResource::tenantScopedUrl('view', ['record' => $review], $review->tenant) ? $this->panelSafeTenantArtifactUrl(
fn (): string => TenantReviewResource::tenantScopedUrl('view', ['record' => $review], $review->tenant)
)
: null, : null,
includePublicationDimension: true, includePublicationDimension: true,
countDescriptors: [ countDescriptors: [
@ -675,9 +682,13 @@ private function buildReviewPackEnvelope(ReviewPack $pack): ArtifactTruthEnvelop
$nextActionUrl = null; $nextActionUrl = null;
if ($sourceReview instanceof TenantReview && $pack->tenant !== null) { if ($sourceReview instanceof TenantReview && $pack->tenant !== null) {
$nextActionUrl = TenantReviewResource::tenantScopedUrl('view', ['record' => $sourceReview], $pack->tenant); $nextActionUrl = $this->panelSafeTenantArtifactUrl(
fn (): string => TenantReviewResource::tenantScopedUrl('view', ['record' => $sourceReview], $pack->tenant)
);
} elseif (($freshnessState === 'stale' || $contentState === 'partial') && $pack->tenant !== null && $pack->evidenceSnapshot !== null) { } elseif (($freshnessState === 'stale' || $contentState === 'partial') && $pack->tenant !== null && $pack->evidenceSnapshot !== null) {
$nextActionUrl = EvidenceSnapshotResource::getUrl('view', ['record' => $pack->evidenceSnapshot], tenant: $pack->tenant); $nextActionUrl = $this->panelSafeTenantArtifactUrl(
fn (): string => EvidenceSnapshotResource::getUrl('view', ['record' => $pack->evidenceSnapshot], tenant: $pack->tenant)
);
} elseif ($pack->operation_run_id !== null) { } elseif ($pack->operation_run_id !== null) {
$nextActionUrl = OperationRunLinks::tenantlessView((int) $pack->operation_run_id); $nextActionUrl = OperationRunLinks::tenantlessView((int) $pack->operation_run_id);
} }
@ -715,7 +726,9 @@ private function buildReviewPackEnvelope(ReviewPack $pack): ArtifactTruthEnvelop
nextActionUrl: $nextActionUrl, nextActionUrl: $nextActionUrl,
relatedRunId: $pack->operation_run_id !== null ? (int) $pack->operation_run_id : null, relatedRunId: $pack->operation_run_id !== null ? (int) $pack->operation_run_id : null,
relatedArtifactUrl: $pack->tenant !== null relatedArtifactUrl: $pack->tenant !== null
? ReviewPackResource::getUrl('view', ['record' => $pack], tenant: $pack->tenant) ? $this->panelSafeTenantArtifactUrl(
fn (): string => ReviewPackResource::getUrl('view', ['record' => $pack], tenant: $pack->tenant)
)
: null, : null,
includePublicationDimension: true, includePublicationDimension: true,
countDescriptors: [ countDescriptors: [
@ -1084,6 +1097,13 @@ classification: $classification,
); );
} }
private function panelSafeTenantArtifactUrl(callable $resolver): ?string
{
return Filament::getCurrentPanel()?->getId() === 'system'
? null
: $resolver();
}
/** /**
* @return array<int, CountDescriptor> * @return array<int, CountDescriptor>
*/ */

View File

@ -63,6 +63,12 @@ public function build(Workspace $workspace, User $user): array
static fn (array $context): bool => (bool) ($context['has_governance_attention'] ?? false), static fn (array $context): bool => (bool) ($context['has_governance_attention'] ?? false),
)); ));
$totalProblemOperationsCount = array_sum(array_map(
static fn (array $context): int => (int) ($context['terminal_follow_up_operations_count'] ?? 0)
+ (int) ($context['stale_attention_operations_count'] ?? 0),
$tenantContexts,
));
$totalActiveOperationsCount = (int) $this->scopeToAuthorizedTenants( $totalActiveOperationsCount = (int) $this->scopeToAuthorizedTenants(
OperationRun::query(), OperationRun::query(),
$workspaceId, $workspaceId,
@ -86,6 +92,7 @@ public function build(Workspace $workspace, User $user): array
accessibleTenantCount: $accessibleTenants->count(), accessibleTenantCount: $accessibleTenants->count(),
attentionItems: $attentionItems, attentionItems: $attentionItems,
governanceAttentionTenantCount: $governanceAttentionTenantCount, governanceAttentionTenantCount: $governanceAttentionTenantCount,
totalProblemOperationsCount: $totalProblemOperationsCount,
totalActiveOperationsCount: $totalActiveOperationsCount, totalActiveOperationsCount: $totalActiveOperationsCount,
totalAlertFailuresCount: $totalAlertFailuresCount, totalAlertFailuresCount: $totalAlertFailuresCount,
canViewAlerts: $canViewAlerts, canViewAlerts: $canViewAlerts,
@ -176,18 +183,28 @@ private function tenantContexts(Collection $accessibleTenants, int $workspaceId,
return []; return [];
} }
$followUpRuns = $this->scopeToVisibleTenants( $terminalFollowUpCounts = $this->scopeToVisibleTenants(
OperationRun::query()->with('tenant'), OperationRun::query(),
$workspaceId, $workspaceId,
$accessibleTenantIds, $accessibleTenantIds,
) )
->dashboardNeedsFollowUp() ->terminalFollowUp()
->latest('created_at') ->selectRaw('tenant_id, count(*) as aggregate_count')
->get() ->groupBy('tenant_id')
->groupBy(static fn (OperationRun $run): int => (int) $run->tenant_id); ->pluck('aggregate_count', 'tenant_id')
->map(static fn (mixed $count): int => (int) $count)
->all();
$followUpCounts = $followUpRuns $staleAttentionCounts = $this->scopeToVisibleTenants(
->map(static fn (Collection $runs): int => $runs->count()) OperationRun::query(),
$workspaceId,
$accessibleTenantIds,
)
->activeStaleAttention()
->selectRaw('tenant_id, count(*) as aggregate_count')
->groupBy('tenant_id')
->pluck('aggregate_count', 'tenant_id')
->map(static fn (mixed $count): int => (int) $count)
->all(); ->all();
$activeOperationCounts = $this->scopeToVisibleTenants( $activeOperationCounts = $this->scopeToVisibleTenants(
@ -218,7 +235,7 @@ private function tenantContexts(Collection $accessibleTenants, int $workspaceId,
: []; : [];
return $accessibleTenants return $accessibleTenants
->map(function (Tenant $tenant) use ($followUpCounts, $followUpRuns, $activeOperationCounts, $alertFailureCounts): array { ->map(function (Tenant $tenant) use ($terminalFollowUpCounts, $staleAttentionCounts, $activeOperationCounts, $alertFailureCounts): array {
$tenantId = (int) $tenant->getKey(); $tenantId = (int) $tenant->getKey();
$aggregate = $this->governanceAggregate($tenant); $aggregate = $this->governanceAggregate($tenant);
@ -226,8 +243,8 @@ private function tenantContexts(Collection $accessibleTenants, int $workspaceId,
'tenant' => $tenant, 'tenant' => $tenant,
'aggregate' => $aggregate, 'aggregate' => $aggregate,
'has_governance_attention' => $this->hasGovernanceAttention($aggregate), 'has_governance_attention' => $this->hasGovernanceAttention($aggregate),
'follow_up_operations_count' => (int) ($followUpCounts[$tenantId] ?? 0), 'terminal_follow_up_operations_count' => (int) ($terminalFollowUpCounts[$tenantId] ?? 0),
'latest_follow_up_run' => $followUpRuns->get($tenantId)?->first(), 'stale_attention_operations_count' => (int) ($staleAttentionCounts[$tenantId] ?? 0),
'active_operations_count' => (int) ($activeOperationCounts[$tenantId] ?? 0), 'active_operations_count' => (int) ($activeOperationCounts[$tenantId] ?? 0),
'alert_failures_count' => (int) ($alertFailureCounts[$tenantId] ?? 0), 'alert_failures_count' => (int) ($alertFailureCounts[$tenantId] ?? 0),
]; ];
@ -281,16 +298,16 @@ private function attentionItems(
CanonicalNavigationContext $navigationContext, CanonicalNavigationContext $navigationContext,
): array { ): array {
$items = collect($tenantContexts) $items = collect($tenantContexts)
->map(function (array $context) use ($user, $canViewAlerts, $navigationContext): ?array { ->flatMap(function (array $context) use ($user, $canViewAlerts, $navigationContext): array {
$tenant = $context['tenant'] ?? null; $tenant = $context['tenant'] ?? null;
$aggregate = $context['aggregate'] ?? null; $aggregate = $context['aggregate'] ?? null;
if (! $tenant instanceof Tenant || ! $aggregate instanceof TenantGovernanceAggregate) { if (! $tenant instanceof Tenant || ! $aggregate instanceof TenantGovernanceAggregate) {
return null; return [];
} }
if ($aggregate->lapsedGovernanceCount > 0) { if ($aggregate->lapsedGovernanceCount > 0) {
return $this->makeAttentionItem( return [$this->makeAttentionItem(
tenant: $tenant, tenant: $tenant,
key: 'tenant_lapsed_governance', key: 'tenant_lapsed_governance',
family: 'governance', family: 'governance',
@ -305,11 +322,11 @@ private function attentionItems(
badgeColor: 'danger', badgeColor: 'danger',
destination: $this->tenantDashboardTarget($tenant, $user, 'Open tenant dashboard'), destination: $this->tenantDashboardTarget($tenant, $user, 'Open tenant dashboard'),
supportingMessage: 'Open the tenant dashboard to review the full invalid-governance family without narrowing the findings set.', supportingMessage: 'Open the tenant dashboard to review the full invalid-governance family without narrowing the findings set.',
); )];
} }
if ($aggregate->overdueOpenFindingsCount > 0) { if ($aggregate->overdueOpenFindingsCount > 0) {
return $this->makeAttentionItem( return [$this->makeAttentionItem(
tenant: $tenant, tenant: $tenant,
key: 'tenant_overdue_findings', key: 'tenant_overdue_findings',
family: 'findings', family: 'findings',
@ -327,11 +344,11 @@ private function attentionItems(
user: $user, user: $user,
filters: ['tab' => 'overdue'], filters: ['tab' => 'overdue'],
), ),
); )];
} }
if ($this->shouldPromoteCompareAttention($aggregate)) { if ($this->shouldPromoteCompareAttention($aggregate)) {
return $this->makeAttentionItem( return [$this->makeAttentionItem(
tenant: $tenant, tenant: $tenant,
key: 'tenant_compare_attention', key: 'tenant_compare_attention',
family: 'compare', family: 'compare',
@ -342,11 +359,11 @@ private function attentionItems(
badgeColor: $aggregate->tone, badgeColor: $aggregate->tone,
destination: $this->baselineCompareTarget($tenant, $user), destination: $this->baselineCompareTarget($tenant, $user),
supportingMessage: $aggregate->supportingMessage, supportingMessage: $aggregate->supportingMessage,
); )];
} }
if ($aggregate->highSeverityActiveFindingsCount > 0) { if ($aggregate->highSeverityActiveFindingsCount > 0) {
return $this->makeAttentionItem( return [$this->makeAttentionItem(
tenant: $tenant, tenant: $tenant,
key: 'tenant_high_severity_findings', key: 'tenant_high_severity_findings',
family: 'findings', family: 'findings',
@ -364,11 +381,11 @@ private function attentionItems(
user: $user, user: $user,
filters: ['tab' => 'needs_action', 'high_severity' => true], filters: ['tab' => 'needs_action', 'high_severity' => true],
), ),
); )];
} }
if ($aggregate->expiringGovernanceCount > 0) { if ($aggregate->expiringGovernanceCount > 0) {
return $this->makeAttentionItem( return [$this->makeAttentionItem(
tenant: $tenant, tenant: $tenant,
key: 'tenant_expiring_governance', key: 'tenant_expiring_governance',
family: 'governance', family: 'governance',
@ -389,33 +406,71 @@ private function attentionItems(
'governance_validity' => FindingException::VALIDITY_EXPIRING, 'governance_validity' => FindingException::VALIDITY_EXPIRING,
], ],
), ),
); )];
} }
$followUpOperationsCount = (int) ($context['follow_up_operations_count'] ?? 0); $items = [];
if ($followUpOperationsCount > 0) { $terminalFollowUpOperationsCount = (int) ($context['terminal_follow_up_operations_count'] ?? 0);
return $this->makeAttentionItem(
if ($terminalFollowUpOperationsCount > 0) {
$items[] = $this->makeAttentionItem(
tenant: $tenant, tenant: $tenant,
key: 'tenant_operations_follow_up', key: 'tenant_operations_terminal_follow_up',
family: 'operations', family: 'operations',
urgency: 'medium', urgency: 'medium',
title: 'Operations need follow-up', title: 'Terminal operations need follow-up',
body: sprintf( body: sprintf(
'%d run%s failed, completed with warnings, or still need operator follow-up.', '%d run%s finished blocked, partially, failed, or were automatically reconciled.',
$followUpOperationsCount, $terminalFollowUpOperationsCount,
$followUpOperationsCount === 1 ? '' : 's', $terminalFollowUpOperationsCount === 1 ? '' : 's',
), ),
badge: 'Operations', badge: 'Operations',
badgeColor: 'danger', badgeColor: 'danger',
destination: $this->operationsIndexTarget($tenant, $navigationContext, 'blocked'), destination: $this->operationsIndexTarget(
$tenant,
$navigationContext,
OperationRun::PROBLEM_CLASS_TERMINAL_FOLLOW_UP,
OperationRun::PROBLEM_CLASS_TERMINAL_FOLLOW_UP,
'Open terminal follow-up',
),
); );
} }
$staleAttentionOperationsCount = (int) ($context['stale_attention_operations_count'] ?? 0);
if ($staleAttentionOperationsCount > 0) {
$items[] = $this->makeAttentionItem(
tenant: $tenant,
key: 'tenant_operations_stale_attention',
family: 'operations',
urgency: 'medium',
title: 'Active operations look stale',
body: sprintf(
'%d run%s are still marked active but are past the lifecycle window.',
$staleAttentionOperationsCount,
$staleAttentionOperationsCount === 1 ? '' : 's',
),
badge: 'Operations',
badgeColor: 'warning',
destination: $this->operationsIndexTarget(
$tenant,
$navigationContext,
OperationRun::PROBLEM_CLASS_ACTIVE_STALE_ATTENTION,
OperationRun::PROBLEM_CLASS_ACTIVE_STALE_ATTENTION,
'Open stale operations',
),
);
}
if ($items !== []) {
return $items;
}
$activeOperationsCount = (int) ($context['active_operations_count'] ?? 0); $activeOperationsCount = (int) ($context['active_operations_count'] ?? 0);
if ($activeOperationsCount > 0) { if ($activeOperationsCount > 0) {
return $this->makeAttentionItem( return [$this->makeAttentionItem(
tenant: $tenant, tenant: $tenant,
key: 'tenant_active_operations', key: 'tenant_active_operations',
family: 'operations', family: 'operations',
@ -429,13 +484,13 @@ private function attentionItems(
badge: 'Operations', badge: 'Operations',
badgeColor: 'warning', badgeColor: 'warning',
destination: $this->operationsIndexTarget($tenant, $navigationContext, 'active'), destination: $this->operationsIndexTarget($tenant, $navigationContext, 'active'),
); )];
} }
$alertFailuresCount = (int) ($context['alert_failures_count'] ?? 0); $alertFailuresCount = (int) ($context['alert_failures_count'] ?? 0);
if ($canViewAlerts && $alertFailuresCount > 0) { if ($canViewAlerts && $alertFailuresCount > 0) {
return $this->makeAttentionItem( return [$this->makeAttentionItem(
tenant: $tenant, tenant: $tenant,
key: 'tenant_alert_delivery_failures', key: 'tenant_alert_delivery_failures',
family: 'alerts', family: 'alerts',
@ -449,12 +504,11 @@ private function attentionItems(
badge: 'Alerts', badge: 'Alerts',
badgeColor: 'danger', badgeColor: 'danger',
destination: $this->alertsOverviewTarget($navigationContext, true), destination: $this->alertsOverviewTarget($navigationContext, true),
); )];
} }
return null; return [];
}) })
->filter()
->values() ->values()
->all(); ->all();
@ -480,7 +534,8 @@ private function attentionPriority(array $item): int
'tenant_compare_attention' => 90, 'tenant_compare_attention' => 90,
'tenant_high_severity_findings' => 80, 'tenant_high_severity_findings' => 80,
'tenant_expiring_governance' => 70, 'tenant_expiring_governance' => 70,
'tenant_operations_follow_up' => 40, 'tenant_operations_stale_attention' => 45,
'tenant_operations_terminal_follow_up' => 40,
'tenant_active_operations' => 20, 'tenant_active_operations' => 20,
'tenant_alert_delivery_failures' => 10, 'tenant_alert_delivery_failures' => 10,
default => 0, default => 0,
@ -625,11 +680,6 @@ private function recentOperations(
array $accessibleTenantIds, array $accessibleTenantIds,
CanonicalNavigationContext $navigationContext, CanonicalNavigationContext $navigationContext,
): array { ): array {
$statusSpec = BadgeRenderer::label(BadgeDomain::OperationRunStatus);
$statusColorSpec = BadgeRenderer::color(BadgeDomain::OperationRunStatus);
$outcomeSpec = BadgeRenderer::label(BadgeDomain::OperationRunOutcome);
$outcomeColorSpec = BadgeRenderer::color(BadgeDomain::OperationRunOutcome);
return $this->scopeToAuthorizedTenants( return $this->scopeToAuthorizedTenants(
OperationRun::query()->with('tenant'), OperationRun::query()->with('tenant'),
$workspaceId, $workspaceId,
@ -638,17 +688,27 @@ private function recentOperations(
->latest('created_at') ->latest('created_at')
->limit(5) ->limit(5)
->get() ->get()
->map(function (OperationRun $run) use ($navigationContext, $statusSpec, $statusColorSpec, $outcomeSpec, $outcomeColorSpec): array { ->map(function (OperationRun $run) use ($navigationContext): array {
$destination = $this->operationDetailTarget($run, $navigationContext); $destination = $this->operationDetailTarget($run, $navigationContext);
$statusSpec = BadgeRenderer::spec(BadgeDomain::OperationRunStatus, [
'status' => (string) $run->status,
'freshness_state' => $run->freshnessState()->value,
]);
$outcomeSpec = BadgeRenderer::spec(BadgeDomain::OperationRunOutcome, [
'outcome' => (string) $run->outcome,
'status' => (string) $run->status,
'freshness_state' => $run->freshnessState()->value,
]);
return [ return [
'id' => (int) $run->getKey(), 'id' => (int) $run->getKey(),
'title' => OperationCatalog::label((string) $run->type), 'title' => OperationCatalog::label((string) $run->type),
'tenant_label' => $run->tenant instanceof Tenant ? (string) $run->tenant->name : null, 'tenant_label' => $run->tenant instanceof Tenant ? (string) $run->tenant->name : null,
'status_label' => $statusSpec($run->status), 'status_label' => $statusSpec->label,
'status_color' => $statusColorSpec($run->status), 'status_color' => $statusSpec->color,
'outcome_label' => $outcomeSpec($run->outcome), 'outcome_label' => $outcomeSpec->label,
'outcome_color' => $outcomeColorSpec($run->outcome), 'outcome_color' => $outcomeSpec->color,
'lifecycle_label' => OperationUxPresenter::lifecycleAttentionSummary($run),
'guidance' => OperationUxPresenter::surfaceGuidance($run), 'guidance' => OperationUxPresenter::surfaceGuidance($run),
'started_at' => $run->created_at?->diffForHumans() ?? 'just now', 'started_at' => $run->created_at?->diffForHumans() ?? 'just now',
'destination' => $destination, 'destination' => $destination,
@ -665,6 +725,7 @@ private function calmnessState(
int $accessibleTenantCount, int $accessibleTenantCount,
array $attentionItems, array $attentionItems,
int $governanceAttentionTenantCount, int $governanceAttentionTenantCount,
int $totalProblemOperationsCount,
int $totalActiveOperationsCount, int $totalActiveOperationsCount,
int $totalAlertFailuresCount, int $totalAlertFailuresCount,
bool $canViewAlerts, bool $canViewAlerts,
@ -686,7 +747,9 @@ private function calmnessState(
]; ];
} }
$hasActivityAttention = $totalActiveOperationsCount > 0 || ($canViewAlerts && $totalAlertFailuresCount > 0); $hasActivityAttention = $totalActiveOperationsCount > 0
|| $totalProblemOperationsCount > 0
|| ($canViewAlerts && $totalAlertFailuresCount > 0);
$isCalm = $governanceAttentionTenantCount === 0 && ! $hasActivityAttention; $isCalm = $governanceAttentionTenantCount === 0 && ! $hasActivityAttention;
if ($isCalm) { if ($isCalm) {
@ -705,7 +768,7 @@ private function calmnessState(
'checked_domains' => $checkedDomains, 'checked_domains' => $checkedDomains,
'title' => 'Workspace activity still needs review', 'title' => 'Workspace activity still needs review',
'body' => 'This workspace is not calm in the domains it can check right now, but your current scope does not expose a more specific tenant drill-through here. Review operations first.', 'body' => 'This workspace is not calm in the domains it can check right now, but your current scope does not expose a more specific tenant drill-through here. Review operations first.',
'next_action' => $this->operationsIndexTarget(null, $navigationContext, 'blocked'), 'next_action' => $this->operationsIndexTarget(null, $navigationContext, 'active'),
]; ];
} }
@ -934,16 +997,18 @@ private function operationsIndexTarget(
?Tenant $tenant, ?Tenant $tenant,
CanonicalNavigationContext $navigationContext, CanonicalNavigationContext $navigationContext,
?string $activeTab = null, ?string $activeTab = null,
?string $problemClass = null,
string $label = 'Open operations', string $label = 'Open operations',
): array { ): array {
return $this->destination( return $this->destination(
kind: 'operations_index', kind: 'operations_index',
url: OperationRunLinks::index($tenant, $navigationContext, $activeTab, $tenant === null), url: OperationRunLinks::index($tenant, $navigationContext, $activeTab, $tenant === null, $problemClass),
label: $label, label: $label,
tenant: $tenant, tenant: $tenant,
filters: array_filter([ filters: array_filter([
'tenant_id' => $tenant?->getKey(), 'tenant_id' => $tenant?->getKey(),
'activeTab' => $activeTab, 'activeTab' => $activeTab,
'problemClass' => $problemClass,
], static fn (mixed $value): bool => $value !== null && $value !== ''), ], static fn (mixed $value): bool => $value !== null && $value !== ''),
); );
} }

View File

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

View File

@ -7,11 +7,20 @@
$summary = $summary ?? []; $summary = $summary ?? [];
$summary = is_array($summary) ? $summary : []; $summary = is_array($summary) ? $summary : [];
$blocking = (int) ($summary['blocking'] ?? 0); $checksIntegrity = $checksIntegrity ?? [];
$warning = (int) ($summary['warning'] ?? 0); $checksIntegrity = is_array($checksIntegrity) ? $checksIntegrity : [];
$executionReadiness = $executionReadiness ?? [];
$executionReadiness = is_array($executionReadiness) ? $executionReadiness : [];
$safetyAssessment = $safetyAssessment ?? [];
$safetyAssessment = is_array($safetyAssessment) ? $safetyAssessment : [];
$blocking = (int) ($summary['blocking'] ?? ($checksIntegrity['blocking_count'] ?? 0));
$warning = (int) ($summary['warning'] ?? ($checksIntegrity['warning_count'] ?? 0));
$safe = (int) ($summary['safe'] ?? 0); $safe = (int) ($summary['safe'] ?? 0);
$ranAt = $ranAt ?? null; $ranAt = $ranAt ?? ($checksIntegrity['ran_at'] ?? null);
$ranAtLabel = null; $ranAtLabel = null;
if (is_string($ranAt) && $ranAt !== '') { if (is_string($ranAt) && $ranAt !== '') {
@ -26,6 +35,12 @@
return \App\Support\Badges\BadgeRenderer::spec(\App\Support\Badges\BadgeDomain::RestoreCheckSeverity, $severity); return \App\Support\Badges\BadgeRenderer::spec(\App\Support\Badges\BadgeDomain::RestoreCheckSeverity, $severity);
}; };
$integritySpec = $severitySpec($checksIntegrity['state'] ?? 'not_run');
$integritySummary = $checksIntegrity['display_summary'] ?? 'Run checks for the current scope before real execution.';
$nextAction = $safetyAssessment['primary_next_action'] ?? 'rerun_checks';
$nextActionLabel = \App\Support\RestoreSafety\RestoreSafetyCopy::primaryNextAction(is_string($nextAction) ? $nextAction : 'rerun_checks');
$startabilitySummary = $executionReadiness['display_summary'] ?? 'Execution readiness is unavailable.';
$startabilityTone = (bool) ($executionReadiness['allowed'] ?? false) ? 'success' : 'warning';
$limitedList = static function (array $items, int $limit = 5): array { $limitedList = static function (array $items, int $limit = 5): array {
if (count($items) <= $limit) { if (count($items) <= $limit) {
return $items; return $items;
@ -39,8 +54,41 @@
<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="space-y-3">
<div class="flex flex-wrap gap-2">
<x-filament::badge :color="$integritySpec->color" :icon="$integritySpec->icon" size="sm">
{{ $integritySpec->label }}
</x-filament::badge>
<x-filament::badge :color="$startabilityTone" size="sm">
{{ (bool) ($executionReadiness['allowed'] ?? false) ? 'Technically startable' : 'Technical blocker present' }}
</x-filament::badge>
@if (($safetyAssessment['state'] ?? null) === 'ready_with_caution')
<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"> <div class="flex flex-wrap gap-2">
<x-filament::badge :color="$blocking > 0 ? $severitySpec('blocking')->color : 'gray'"> <x-filament::badge :color="$blocking > 0 ? $severitySpec('blocking')->color : 'gray'">
{{ $blocking }} {{ \Illuminate\Support\Str::lower($severitySpec('blocking')->label) }} {{ $blocking }} {{ \Illuminate\Support\Str::lower($severitySpec('blocking')->label) }}
@ -52,12 +100,19 @@
{{ $safe }} {{ \Illuminate\Support\Str::lower($severitySpec('safe')->label) }} {{ $safe }} {{ \Illuminate\Support\Str::lower($severitySpec('safe')->label) }}
</x-filament::badge> </x-filament::badge>
</div> </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>
</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,8 +55,36 @@
<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="space-y-3">
<div class="flex flex-wrap gap-2">
<x-filament::badge :color="$integritySpec->color" :icon="$integritySpec->icon" size="sm">
{{ $integritySpec->label }}
</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"> <div class="flex flex-wrap gap-2">
<x-filament::badge :color="$policiesChanged > 0 ? 'warning' : 'success'"> <x-filament::badge :color="$policiesChanged > 0 ? 'warning' : 'success'">
{{ $policiesChanged }}/{{ $policiesTotal }} policies changed {{ $policiesChanged }}/{{ $policiesTotal }} policies changed
@ -57,12 +101,19 @@
</x-filament::badge> </x-filament::badge>
@endif @endif
</div> </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
</div>
</x-filament::section> </x-filament::section>
@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

@ -0,0 +1,140 @@
@php
use App\Support\Badges\BadgeCatalog;
use App\Support\Badges\BadgeDomain;
use App\Support\Badges\TagBadgeCatalog;
use App\Support\Badges\TagBadgeDomain;
$rows = array_values(array_filter($rows ?? [], 'is_array'));
$summary = is_array($summary ?? null) ? $summary : [];
$followUpRows = array_values(array_filter($rows, static fn (array $row): bool => (bool) ($row['followUpRequired'] ?? false)));
$topFollowUp = $followUpRows[0] ?? null;
@endphp
<div class="space-y-4">
<div class="grid gap-3 sm:grid-cols-2 xl:grid-cols-4">
<div class="rounded-2xl border border-gray-200 bg-white px-4 py-4 shadow-sm dark:border-gray-800 dark:bg-gray-900/60">
<div class="text-xs font-medium uppercase tracking-[0.18em] text-gray-500 dark:text-gray-400">
Types in run
</div>
<div class="mt-2 text-2xl font-semibold text-gray-950 dark:text-white">
{{ (int) ($summary['totalTypes'] ?? count($rows)) }}
</div>
<div class="mt-2 text-sm text-gray-600 dark:text-gray-300">
Succeeded: {{ (int) ($summary['succeededTypes'] ?? 0) }}. Failed: {{ (int) ($summary['failedTypes'] ?? 0) }}. Skipped: {{ (int) ($summary['skippedTypes'] ?? 0) }}.
</div>
</div>
<div class="rounded-2xl border border-gray-200 bg-white px-4 py-4 shadow-sm dark:border-gray-800 dark:bg-gray-900/60">
<div class="text-xs font-medium uppercase tracking-[0.18em] text-gray-500 dark:text-gray-400">
Need follow-up
</div>
<div class="mt-2 text-2xl font-semibold text-gray-950 dark:text-white">
{{ (int) ($summary['followUpTypes'] ?? count($followUpRows)) }}
</div>
<div class="mt-2 text-sm text-gray-600 dark:text-gray-300">
Execution outcome stays separate from the per-type results below.
</div>
</div>
<div class="rounded-2xl border border-gray-200 bg-white px-4 py-4 shadow-sm dark:border-gray-800 dark:bg-gray-900/60">
<div class="text-xs font-medium uppercase tracking-[0.18em] text-gray-500 dark:text-gray-400">
Observed items
</div>
<div class="mt-2 text-2xl font-semibold text-gray-950 dark:text-white">
{{ (int) ($summary['observedItems'] ?? 0) }}
</div>
<div class="mt-2 text-sm text-gray-600 dark:text-gray-300">
Item counts show what this run observed for the listed types.
</div>
</div>
<div class="rounded-2xl border border-gray-200 bg-white px-4 py-4 shadow-sm dark:border-gray-800 dark:bg-gray-900/60">
<div class="text-xs font-medium uppercase tracking-[0.18em] text-gray-500 dark:text-gray-400">
Run outcome
</div>
<div class="mt-2">
<x-filament::badge :color="$runOutcomeColor ?? 'gray'" :icon="$runOutcomeIcon ?? null">
{{ $runOutcomeLabel ?? 'Unknown' }}
</x-filament::badge>
</div>
<div class="mt-2 text-sm text-gray-600 dark:text-gray-300">
Coverage truth below explains which types created the follow-up.
</div>
</div>
</div>
@if ($topFollowUp !== null)
<div class="rounded-2xl border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-900 dark:border-amber-900/60 dark:bg-amber-950/30 dark:text-amber-100">
Highest-priority follow-up: {{ $topFollowUp['label'] ?? ($topFollowUp['type'] ?? 'Unknown type') }}. {{ $topFollowUp['followUpGuidance'] ?? 'Review the latest inventory sync details before retrying.' }}
</div>
@endif
<div class="space-y-3">
@foreach ($rows as $row)
@php
$typeSpec = TagBadgeCatalog::spec(TagBadgeDomain::PolicyType, $row['type'] ?? null);
$categorySpec = TagBadgeCatalog::spec(TagBadgeDomain::PolicyCategory, $row['category'] ?? null);
$stateSpec = BadgeCatalog::spec(BadgeDomain::InventoryCoverageState, $row['coverageState'] ?? null);
@endphp
<div class="rounded-2xl border border-gray-200 bg-white px-4 py-4 shadow-sm dark:border-gray-800 dark:bg-gray-900/60">
<div class="flex flex-wrap items-start justify-between gap-3">
<div class="min-w-0 space-y-2">
<div class="flex flex-wrap items-center gap-2">
<x-filament::badge :color="$typeSpec->color" :icon="$typeSpec->icon">
{{ $typeSpec->label }}
</x-filament::badge>
<x-filament::badge :color="$stateSpec->color" :icon="$stateSpec->icon">
{{ $stateSpec->label }}
</x-filament::badge>
<x-filament::badge :color="$categorySpec->color" :icon="$categorySpec->icon">
{{ $categorySpec->label }}
</x-filament::badge>
<x-filament::badge color="{{ ($row['segment'] ?? 'policy') === 'foundation' ? 'gray' : 'info' }}">
{{ ($row['segment'] ?? 'policy') === 'foundation' ? 'Foundation' : 'Policy' }}
</x-filament::badge>
</div>
<div class="text-sm font-medium text-gray-900 dark:text-white">
{{ $row['label'] ?? ($row['type'] ?? 'Unknown type') }}
</div>
<div class="text-xs text-gray-500 dark:text-gray-400">
{{ $row['type'] ?? 'unknown' }}
</div>
</div>
<div class="rounded-xl bg-gray-50 px-3 py-2 text-right dark:bg-gray-950/60">
<div class="text-[11px] font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">
Observed items
</div>
<div class="mt-1 text-lg font-semibold text-gray-950 dark:text-white">
{{ (int) ($row['itemCount'] ?? 0) }}
</div>
</div>
</div>
<div class="mt-3 text-sm text-gray-700 dark:text-gray-200">
{{ $row['followUpGuidance'] ?? 'No follow-up is currently required.' }}
</div>
@if (filled($row['errorCode'] ?? null))
<div class="mt-2 text-xs text-gray-500 dark:text-gray-400">
Reason code: {{ $row['errorCode'] }}
</div>
@endif
</div>
@endforeach
</div>
</div>

View File

@ -2,17 +2,24 @@
$state = $getState(); $state = $getState();
$state = is_array($state) ? $state : []; $state = is_array($state) ? $state : [];
$connectionState = is_string($state['state'] ?? null) ? (string) $state['state'] : 'needs_action'; $connectionState = is_string($state['state'] ?? null) ? (string) $state['state'] : 'missing';
$ctaUrl = is_string($state['cta_url'] ?? null) ? (string) $state['cta_url'] : '#'; $ctaUrl = is_string($state['cta_url'] ?? null) ? (string) $state['cta_url'] : '#';
$needsDefaultConnection = (bool) ($state['needs_default_connection'] ?? false);
$displayName = is_string($state['display_name'] ?? null) ? (string) $state['display_name'] : null; $displayName = is_string($state['display_name'] ?? null) ? (string) $state['display_name'] : null;
$provider = is_string($state['provider'] ?? null) ? (string) $state['provider'] : null; $provider = is_string($state['provider'] ?? null) ? (string) $state['provider'] : null;
$consentStatus = is_string($state['consent_status'] ?? null) ? (string) $state['consent_status'] : null;
$verificationStatus = is_string($state['verification_status'] ?? null) ? (string) $state['verification_status'] : null;
$status = is_string($state['status'] ?? null) ? (string) $state['status'] : null; $status = is_string($state['status'] ?? null) ? (string) $state['status'] : null;
$healthStatus = is_string($state['health_status'] ?? null) ? (string) $state['health_status'] : null; $healthStatus = is_string($state['health_status'] ?? null) ? (string) $state['health_status'] : null;
$lastCheck = is_string($state['last_health_check_at'] ?? null) ? (string) $state['last_health_check_at'] : null; $lastCheck = is_string($state['last_health_check_at'] ?? null) ? (string) $state['last_health_check_at'] : null;
$lastErrorReason = is_string($state['last_error_reason_code'] ?? null) ? (string) $state['last_error_reason_code'] : null; $lastErrorReason = is_string($state['last_error_reason_code'] ?? null) ? (string) $state['last_error_reason_code'] : null;
$isMissing = $connectionState === 'needs_action'; $isMissing = $connectionState === 'missing';
$consentSpec = \App\Support\Badges\BadgeRenderer::spec(\App\Support\Badges\BadgeDomain::ProviderConsentStatus, $consentStatus);
$verificationSpec = \App\Support\Badges\BadgeRenderer::spec(\App\Support\Badges\BadgeDomain::ProviderVerificationStatus, $verificationStatus);
$legacyStatusSpec = \App\Support\Badges\BadgeRenderer::spec(\App\Support\Badges\BadgeDomain::ProviderConnectionStatus, $status);
$legacyHealthSpec = \App\Support\Badges\BadgeRenderer::spec(\App\Support\Badges\BadgeDomain::ProviderConnectionHealth, $healthStatus);
@endphp @endphp
<div class="space-y-3 rounded-md border border-gray-200 bg-white p-4 shadow-sm"> <div class="space-y-3 rounded-md border border-gray-200 bg-white p-4 shadow-sm">
@ -20,7 +27,9 @@
<div> <div>
<div class="text-sm font-semibold text-gray-800">Provider connection</div> <div class="text-sm font-semibold text-gray-800">Provider connection</div>
@if ($isMissing) @if ($isMissing)
<div class="mt-1 text-sm text-amber-700">Needs action: no default Microsoft provider connection is configured.</div> <div class="mt-1 text-sm text-amber-700">Needs action: no Microsoft provider connection is configured.</div>
@elseif ($needsDefaultConnection)
<div class="mt-1 text-sm text-amber-700">Needs action: set a default Microsoft provider connection.</div>
@else @else
<div class="mt-1 text-sm text-gray-700">{{ $displayName ?? 'Unnamed connection' }}</div> <div class="mt-1 text-sm text-gray-700">{{ $displayName ?? 'Unnamed connection' }}</div>
@endif @endif
@ -32,18 +41,32 @@
</div> </div>
@unless ($isMissing) @unless ($isMissing)
@if ($needsDefaultConnection && $displayName)
<div class="text-sm text-gray-700">
Current connection: <span class="font-medium">{{ $displayName }}</span>
</div>
@endif
<dl class="grid grid-cols-1 gap-2 text-sm text-gray-700 sm:grid-cols-2"> <dl class="grid grid-cols-1 gap-2 text-sm text-gray-700 sm:grid-cols-2">
<div> <div>
<dt class="text-xs uppercase tracking-wide text-gray-500">Provider</dt> <dt class="text-xs uppercase tracking-wide text-gray-500">Provider</dt>
<dd>{{ $provider ?? 'n/a' }}</dd> <dd>{{ $provider ?? 'n/a' }}</dd>
</div> </div>
<div> <div>
<dt class="text-xs uppercase tracking-wide text-gray-500">Status</dt> <dt class="text-xs uppercase tracking-wide text-gray-500">Consent</dt>
<dd>{{ $status ?? 'n/a' }}</dd> <dd>
<x-filament::badge :color="$consentSpec->color" :icon="$consentSpec->icon" size="sm">
{{ $consentSpec->label }}
</x-filament::badge>
</dd>
</div> </div>
<div> <div>
<dt class="text-xs uppercase tracking-wide text-gray-500">Health</dt> <dt class="text-xs uppercase tracking-wide text-gray-500">Verification</dt>
<dd>{{ $healthStatus ?? 'n/a' }}</dd> <dd>
<x-filament::badge :color="$verificationSpec->color" :icon="$verificationSpec->icon" size="sm">
{{ $verificationSpec->label }}
</x-filament::badge>
</dd>
</div> </div>
<div> <div>
<dt class="text-xs uppercase tracking-wide text-gray-500">Last check</dt> <dt class="text-xs uppercase tracking-wide text-gray-500">Last check</dt>
@ -51,10 +74,32 @@
</div> </div>
</dl> </dl>
<div class="space-y-2 rounded-md border border-gray-200 bg-gray-50 p-3 text-sm text-gray-700">
<div class="text-xs font-semibold uppercase tracking-wide text-gray-500">Diagnostics</div>
<dl class="grid grid-cols-1 gap-2 sm:grid-cols-2">
<div>
<dt class="text-xs uppercase tracking-wide text-gray-500">Legacy status</dt>
<dd>
<x-filament::badge :color="$legacyStatusSpec->color" :icon="$legacyStatusSpec->icon" size="sm">
{{ $legacyStatusSpec->label }}
</x-filament::badge>
</dd>
</div>
<div>
<dt class="text-xs uppercase tracking-wide text-gray-500">Legacy health</dt>
<dd>
<x-filament::badge :color="$legacyHealthSpec->color" :icon="$legacyHealthSpec->icon" size="sm">
{{ $legacyHealthSpec->label }}
</x-filament::badge>
</dd>
</div>
</dl>
@if ($lastErrorReason) @if ($lastErrorReason)
<div class="rounded-md border border-amber-300 bg-amber-50 p-2 text-xs text-amber-800"> <div class="rounded-md border border-amber-300 bg-amber-50 p-2 text-xs text-amber-800">
Last error reason: {{ $lastErrorReason }} Last error reason: {{ $lastErrorReason }}
</div> </div>
@endif @endif
</div>
@endunless @endunless
</div> </div>

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>

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)
|| $policyItems->contains(function ($item) {
$status = $item['status'] ?? null; $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,16 +1,121 @@
<x-filament-panels::page> <x-filament-panels::page>
@php
$summary = $this->coverageSummary();
$basis = $this->basisRunSummary();
@endphp
<x-filament::section> <x-filament::section>
<div class="flex flex-col gap-3"> <div class="grid gap-4 xl:grid-cols-[minmax(0,1.8fr)_minmax(0,1fr)]">
<div class="text-lg font-semibold text-gray-900 dark:text-gray-100"> <div class="space-y-4">
Searchable support matrix <div class="space-y-2">
<div class="text-lg font-semibold text-gray-900 dark:text-white">
Tenant coverage truth
</div> </div>
<div class="text-sm text-gray-600 dark:text-gray-300"> <div class="text-sm text-gray-600 dark:text-gray-300">
Search by policy type or label, sort the primary columns, and filter the runtime-derived coverage matrix without leaving the tenant inventory workspace. This report shows which supported inventory types are currently covered for the active tenant, which ones still need follow-up, and what the statement is based on.
</div>
</div>
<div class="grid gap-3 sm:grid-cols-3">
<div class="rounded-2xl border border-gray-200 bg-white px-4 py-4 shadow-sm dark:border-gray-800 dark:bg-gray-900/60">
<div class="text-xs font-medium uppercase tracking-[0.18em] text-gray-500 dark:text-gray-400">
Covered types
</div>
<div class="mt-2 text-2xl font-semibold text-gray-950 dark:text-white">
{{ $summary['succeededTypes'] ?? 0 }} / {{ $summary['supportedTypes'] ?? 0 }}
</div>
<div class="mt-2 text-sm text-gray-600 dark:text-gray-300">
Current supported types with a successful basis result.
</div>
</div>
<div class="rounded-2xl border border-gray-200 bg-white px-4 py-4 shadow-sm dark:border-gray-800 dark:bg-gray-900/60">
<div class="text-xs font-medium uppercase tracking-[0.18em] text-gray-500 dark:text-gray-400">
Need follow-up
</div>
<div class="mt-2 text-2xl font-semibold text-gray-950 dark:text-white">
{{ $summary['followUpTypes'] ?? 0 }}
</div>
<div class="mt-2 text-sm text-gray-600 dark:text-gray-300">
@if (filled($summary['topFollowUpLabel'] ?? null))
Highest-priority type: {{ $summary['topFollowUpLabel'] }}.
@else
No follow-up types are currently highlighted.
@endif
</div>
</div>
<div class="rounded-2xl border border-gray-200 bg-white px-4 py-4 shadow-sm dark:border-gray-800 dark:bg-gray-900/60">
<div class="text-xs font-medium uppercase tracking-[0.18em] text-gray-500 dark:text-gray-400">
Observed items
</div>
<div class="mt-2 text-2xl font-semibold text-gray-950 dark:text-white">
{{ $summary['observedItems'] ?? 0 }}
</div>
<div class="mt-2 text-sm text-gray-600 dark:text-gray-300">
{{ $summary['observedTypes'] ?? 0 }} supported types currently have observed inventory rows.
</div>
</div>
</div>
@if (filled($summary['topFollowUpGuidance'] ?? null))
<div class="rounded-2xl border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-900 dark:border-amber-900/60 dark:bg-amber-950/30 dark:text-amber-100">
{{ $summary['topFollowUpGuidance'] }}
</div>
@endif
</div>
<div class="rounded-2xl border border-gray-200 bg-white px-4 py-4 shadow-sm dark:border-gray-800 dark:bg-gray-900/60">
<div class="space-y-3">
<div class="flex flex-wrap items-start justify-between gap-3">
<div class="space-y-1">
<div class="text-xs font-medium uppercase tracking-[0.18em] text-gray-500 dark:text-gray-400">
Coverage basis
</div>
<div class="text-base font-semibold text-gray-950 dark:text-white">
{{ $basis['title'] ?? 'No current coverage basis' }}
</div>
</div>
@if (filled($basis['badgeLabel'] ?? null))
<x-filament::badge :color="$basis['badgeColor'] ?? 'gray'" size="sm">
{{ $basis['badgeLabel'] }}
</x-filament::badge>
@endif
</div> </div>
<div class="text-sm text-gray-600 dark:text-gray-300"> <div class="text-sm text-gray-600 dark:text-gray-300">
Coverage rows combine supported policy types and foundations in a single read-only table so Segment and Dependencies stay easy to scan. {{ $basis['body'] ?? 'No current coverage basis is available.' }}
</div>
<div class="flex flex-wrap items-center gap-3">
@if (filled($basis['runUrl'] ?? null))
<x-filament::link :href="$basis['runUrl']" size="sm">
Open basis run
</x-filament::link>
@endif
@if (filled($basis['historyUrl'] ?? null))
<x-filament::link :href="$basis['historyUrl']" size="sm">
Inventory sync history
</x-filament::link>
@endif
@if (filled($basis['inventoryItemsUrl'] ?? null))
<x-filament::link :href="$basis['inventoryItemsUrl']" size="sm">
Open inventory items
</x-filament::link>
@endif
</div>
</div>
</div> </div>
</div> </div>
</x-filament::section> </x-filament::section>

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

@ -1,5 +1,7 @@
<x-filament-panels::page> <x-filament-panels::page>
@php($lifecycleSummary = $this->lifecycleVisibilitySummary()) @php($lifecycleSummary = $this->lifecycleVisibilitySummary())
@php($staleAttentionTab = \App\Models\OperationRun::PROBLEM_CLASS_ACTIVE_STALE_ATTENTION)
@php($terminalFollowUpTab = \App\Models\OperationRun::PROBLEM_CLASS_TERMINAL_FOLLOW_UP)
<x-filament::tabs label="Operations tabs"> <x-filament::tabs label="Operations tabs">
<x-filament::tabs.item <x-filament::tabs.item
@ -15,10 +17,16 @@
Active Active
</x-filament::tabs.item> </x-filament::tabs.item>
<x-filament::tabs.item <x-filament::tabs.item
:active="$this->activeTab === 'blocked'" :active="$this->activeTab === $staleAttentionTab"
wire:click="$set('activeTab', 'blocked')" wire:click="$set('activeTab', '{{ $staleAttentionTab }}')"
> >
Needs follow-up Likely stale
</x-filament::tabs.item>
<x-filament::tabs.item
:active="$this->activeTab === $terminalFollowUpTab"
wire:click="$set('activeTab', '{{ $terminalFollowUpTab }}')"
>
Terminal follow-up
</x-filament::tabs.item> </x-filament::tabs.item>
<x-filament::tabs.item <x-filament::tabs.item
:active="$this->activeTab === 'succeeded'" :active="$this->activeTab === 'succeeded'"
@ -42,8 +50,8 @@
@if (($lifecycleSummary['likely_stale'] ?? 0) > 0 || ($lifecycleSummary['reconciled'] ?? 0) > 0) @if (($lifecycleSummary['likely_stale'] ?? 0) > 0 || ($lifecycleSummary['reconciled'] ?? 0) > 0)
<div class="mb-4 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-4 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">
{{ ($lifecycleSummary['likely_stale'] ?? 0) }} active operation(s) are beyond their lifecycle window. {{ ($lifecycleSummary['likely_stale'] ?? 0) }} active operation(s) are beyond their lifecycle window and belong in the stale-attention view.
{{ ($lifecycleSummary['reconciled'] ?? 0) }} operation(s) have already been automatically reconciled. {{ ($lifecycleSummary['reconciled'] ?? 0) }} operation(s) already carry reconciled stale lineage and belong in terminal follow-up.
</div> </div>
@endif @endif

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

@ -4,20 +4,26 @@
$statusSpec = \App\Support\Badges\BadgeRenderer::spec( $statusSpec = \App\Support\Badges\BadgeRenderer::spec(
\App\Support\Badges\BadgeDomain::OperationRunStatus, \App\Support\Badges\BadgeDomain::OperationRunStatus,
(string) $run->status, [
'status' => (string) $run->status,
'freshness_state' => $run->freshnessState()->value,
],
); );
$outcomeSpec = (string) $run->status === 'completed' $outcomeSpec = \App\Support\Badges\BadgeRenderer::spec(
? \App\Support\Badges\BadgeRenderer::spec(
\App\Support\Badges\BadgeDomain::OperationRunOutcome, \App\Support\Badges\BadgeDomain::OperationRunOutcome,
(string) $run->outcome, [
) 'outcome' => (string) $run->outcome,
: null; 'status' => (string) $run->status,
'freshness_state' => $run->freshnessState()->value,
],
);
$summaryCounts = is_array($run->summary_counts) ? $run->summary_counts : []; $summaryCounts = is_array($run->summary_counts) ? $run->summary_counts : [];
$hasSummary = count($summaryCounts) > 0; $hasSummary = count($summaryCounts) > 0;
$integrityNote = \App\Support\RedactionIntegrity::noteForRun($run); $integrityNote = \App\Support\RedactionIntegrity::noteForRun($run);
$guidance = \App\Support\OpsUx\OperationUxPresenter::surfaceGuidance($run); $guidance = \App\Support\OpsUx\OperationUxPresenter::surfaceGuidance($run);
$decisionTruth = \App\Support\OpsUx\OperationUxPresenter::decisionZoneTruth($run);
@endphp @endphp
<x-filament-panels::page> <x-filament-panels::page>
@ -104,6 +110,47 @@
</dl> </dl>
</x-filament::section> </x-filament::section>
<x-filament::section>
<x-slot name="heading">
Current lifecycle truth
</x-slot>
<div class="grid grid-cols-1 gap-4 sm:grid-cols-3">
<div class="rounded-lg bg-gray-50 px-4 py-3 dark:bg-white/5">
<div class="text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400">Still active</div>
<div class="mt-1 text-sm font-medium text-gray-950 dark:text-white">
{{ ($decisionTruth['isCurrentlyActive'] ?? false) ? 'Yes' : 'No' }}
</div>
</div>
<div class="rounded-lg bg-gray-50 px-4 py-3 dark:bg-white/5">
<div class="text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400">Automatic reconciliation</div>
<div class="mt-1 text-sm font-medium text-gray-950 dark:text-white">
{{ ($decisionTruth['isReconciled'] ?? false) ? 'Yes' : 'No' }}
</div>
</div>
<div class="rounded-lg bg-gray-50 px-4 py-3 dark:bg-white/5">
<div class="text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400">Problem class</div>
<div class="mt-1 text-sm font-medium text-gray-950 dark:text-white">
{{ $decisionTruth['problemClassLabel'] ?? 'None' }}
</div>
</div>
</div>
@if (filled($decisionTruth['attentionNote'] ?? null))
<div class="mt-4 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">
{{ $decisionTruth['attentionNote'] }}
</div>
@endif
@if (filled($decisionTruth['staleLineageNote'] ?? null))
<div class="mt-4 rounded-lg border border-rose-200 bg-rose-50 px-4 py-3 text-sm text-rose-900 dark:border-rose-500/30 dark:bg-rose-500/10 dark:text-rose-100">
{{ $decisionTruth['staleLineageNote'] }}
</div>
@endif
</x-filament::section>
@if ($integrityNote) @if ($integrityNote)
<x-filament::section> <x-filament::section>
<div class="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="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">

View File

@ -17,12 +17,20 @@
@php @php
$statusSpec = \App\Support\Badges\BadgeRenderer::spec( $statusSpec = \App\Support\Badges\BadgeRenderer::spec(
\App\Support\Badges\BadgeDomain::OperationRunStatus, \App\Support\Badges\BadgeDomain::OperationRunStatus,
(string) $run->status, [
'status' => (string) $run->status,
'freshness_state' => $run->freshnessState()->value,
],
); );
$outcomeSpec = \App\Support\Badges\BadgeRenderer::spec( $outcomeSpec = \App\Support\Badges\BadgeRenderer::spec(
\App\Support\Badges\BadgeDomain::OperationRunOutcome, \App\Support\Badges\BadgeDomain::OperationRunOutcome,
(string) $run->outcome, [
'outcome' => (string) $run->outcome,
'status' => (string) $run->status,
'freshness_state' => $run->freshnessState()->value,
],
); );
$lifecycleAttention = \App\Support\OpsUx\OperationUxPresenter::lifecycleAttentionSummary($run);
$guidance = \App\Support\OpsUx\OperationUxPresenter::surfaceGuidance($run); $guidance = \App\Support\OpsUx\OperationUxPresenter::surfaceGuidance($run);
@endphp @endphp
<li class="flex items-center justify-between gap-3 py-2"> <li class="flex items-center justify-between gap-3 py-2">
@ -38,6 +46,11 @@
<x-filament::badge :color="$outcomeSpec->color" size="sm"> <x-filament::badge :color="$outcomeSpec->color" size="sm">
{{ $outcomeSpec->label }} {{ $outcomeSpec->label }}
</x-filament::badge> </x-filament::badge>
@if ($lifecycleAttention)
<span class="inline-flex items-center rounded-full border border-warning-200 bg-warning-50 px-2 py-0.5 text-xs font-medium text-warning-800 dark:border-warning-600/40 dark:bg-warning-500/10 dark:text-warning-100">
{{ $lifecycleAttention }}
</span>
@endif
</div> </div>
<div class="mt-1 text-xs text-gray-500 dark:text-gray-400"> <div class="mt-1 text-xs text-gray-500 dark:text-gray-400">

View File

@ -27,12 +27,12 @@
<x-filament::section <x-filament::section
heading="Verification report" heading="Verification report"
description="Latest verification state for this tenant (DB-only rendering)." description="Latest stored verification result for this tenant. Consent and connection configuration are summarized separately above."
> >
<div class="space-y-4"> <div class="space-y-4">
@if ($run === null) @if ($run === null)
<div class="rounded-lg border border-gray-200 bg-white p-4 text-sm text-gray-600 shadow-sm dark:border-gray-800 dark:bg-gray-900 dark:text-gray-300"> <div class="rounded-lg border border-gray-200 bg-white p-4 text-sm text-gray-600 shadow-sm dark:border-gray-800 dark:bg-gray-900 dark:text-gray-300">
No verification operation has been started yet. No provider verification check has been recorded yet.
</div> </div>
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">

View File

@ -54,6 +54,11 @@
<x-filament::badge :color="$operation['outcome_color']" size="sm"> <x-filament::badge :color="$operation['outcome_color']" size="sm">
{{ $operation['outcome_label'] }} {{ $operation['outcome_label'] }}
</x-filament::badge> </x-filament::badge>
@if (filled($operation['lifecycle_label'] ?? null))
<span class="inline-flex items-center rounded-full border border-warning-200 bg-warning-50 px-2 py-0.5 text-xs font-medium text-warning-800 dark:border-warning-600/40 dark:bg-warning-500/10 dark:text-warning-100">
{{ $operation['lifecycle_label'] }}
</span>
@endif
</div> </div>
@if (filled($operation['guidance'] ?? null)) @if (filled($operation['guidance'] ?? null))

View File

@ -9,6 +9,9 @@
x-data="opsUxProgressWidgetPoller()" x-data="opsUxProgressWidgetPoller()"
x-init="init()" x-init="init()"
wire:key="ops-ux-progress-widget" wire:key="ops-ux-progress-widget"
@if (! $disabled && $hasActiveRuns)
wire:poll.10s="refreshRuns"
@endif
> >
@if($runs->isNotEmpty()) @if($runs->isNotEmpty())
<div class="fixed bottom-4 right-4 z-[999999] w-96 space-y-2" style="pointer-events: auto;"> <div class="fixed bottom-4 right-4 z-[999999] w-96 space-y-2" style="pointer-events: auto;">

View File

@ -0,0 +1,34 @@
# Specification Quality Checklist: Backup Quality Truth Surfaces
**Purpose**: Validate specification completeness and quality before proceeding to planning
**Created**: 2026-04-07
**Feature**: [spec.md](../spec.md)
## Content Quality
- [x] No implementation details (languages, frameworks, APIs)
- [x] Focused on user value and business needs
- [x] Written for non-technical stakeholders
- [x] All mandatory sections completed
## Requirement Completeness
- [x] No [NEEDS CLARIFICATION] markers remain
- [x] Requirements are testable and unambiguous
- [x] Success criteria are measurable
- [x] Success criteria are technology-agnostic (no implementation details)
- [x] All acceptance scenarios are defined
- [x] Edge cases are identified
- [x] Scope is clearly bounded
- [x] Dependencies and assumptions identified
## Feature Readiness
- [x] All functional requirements have clear acceptance criteria
- [x] User scenarios cover primary flows
- [x] Feature meets measurable outcomes defined in Success Criteria
- [x] No implementation details leak into specification
## Notes
- Validated on 2026-04-07. The spec keeps solution details out of the behavior sections; the only structural references are the mandatory surface-identification fields required by this repository's constitution and spec template.

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,35 @@
# Specification Quality Checklist: Spec 177 - Inventory Coverage Truth
**Purpose**: Validate specification completeness and quality before proceeding to planning
**Created**: 2026-04-05
**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 after the draft was rewritten from the template scaffold.
- Follow-up inventory hardening ideas remain listed as future candidates without fixed spec numbers, since this repository already uses `179` for a different accepted spec.

View File

@ -0,0 +1,235 @@
openapi: 3.1.0
info:
title: Inventory Coverage Truth Surfaces
version: 0.1.0
description: |
Logical surface contract for Spec 177. These contracts describe the tenant-scoped
coverage truth that inventory surfaces must render, even when the delivered transport
is server-rendered Filament UI rather than a public JSON API.
paths:
/admin/inventory-items:
get:
summary: Inventory items list with truthful coverage summary
operationId: inventoryItemsCoverageSummary
responses:
'200':
description: Tenant-scoped inventory items list surface
content:
application/json:
schema:
type: object
additionalProperties: false
required:
- tenantId
- coverageSummary
properties:
tenantId:
type: integer
coverageSummary:
$ref: '#/components/schemas/CoverageSummary'
'403':
description: Member lacks capability for a linked follow-up action
'404':
description: User is not entitled to the tenant or workspace scope
/admin/coverage:
get:
summary: Tenant coverage truth report
operationId: inventoryCoverageReport
responses:
'200':
description: Tenant-scoped coverage report surface
content:
application/json:
schema:
type: object
additionalProperties: false
required:
- tenantId
- coverageSummary
- rows
properties:
tenantId:
type: integer
coverageSummary:
$ref: '#/components/schemas/CoverageSummary'
rows:
type: array
items:
$ref: '#/components/schemas/CoverageRow'
'403':
description: Member lacks capability for a linked follow-up action
'404':
description: User is not entitled to the tenant or workspace scope
/admin/operations/{run}:
get:
summary: Canonical inventory-sync run detail with per-type coverage section
operationId: inventorySyncRunDetail
parameters:
- in: path
name: run
required: true
schema:
type: integer
responses:
'200':
description: Canonical operation run detail surface
content:
application/json:
schema:
type: object
additionalProperties: false
required:
- id
- type
- status
- outcome
properties:
id:
type: integer
type:
type: string
enum:
- inventory_sync
status:
type: string
enum:
- queued
- running
- completed
outcome:
type: string
enum:
- pending
- succeeded
- partially_succeeded
- failed
- blocked
inventoryCoverageSection:
oneOf:
- $ref: '#/components/schemas/InventoryCoverageRunSection'
- type: 'null'
'403':
description: In-scope member lacks the run capability required for this operation
'404':
description: User is not entitled to the workspace or tenant scope for this run
components:
schemas:
BasisRun:
type: object
additionalProperties: false
required:
- id
- outcome
- completedAt
properties:
id:
type: integer
outcome:
type: string
enum:
- succeeded
- partially_succeeded
- failed
- blocked
completedAt:
type: string
format: date-time
CoverageSummary:
type: object
additionalProperties: false
required:
- hasCurrentCoverageResult
- supportedTypes
- succeededTypes
- failedTypes
- skippedTypes
- unknownTypes
- followUpTypes
- observedItems
properties:
hasCurrentCoverageResult:
type: boolean
basisRun:
oneOf:
- $ref: '#/components/schemas/BasisRun'
- type: 'null'
supportedTypes:
type: integer
succeededTypes:
type: integer
failedTypes:
type: integer
skippedTypes:
type: integer
unknownTypes:
type: integer
followUpTypes:
type: integer
observedItems:
type: integer
CoverageRow:
type: object
additionalProperties: false
required:
- type
- segment
- label
- category
- coverageState
- followUpRequired
- observedItemCount
- supportsDependencies
properties:
type:
type: string
segment:
type: string
enum:
- policy
- foundation
label:
type: string
category:
type: string
platform:
type:
- string
- 'null'
coverageState:
type: string
enum:
- succeeded
- failed
- skipped
- unknown
followUpRequired:
type: boolean
observedItemCount:
type: integer
basisErrorCode:
type:
- string
- 'null'
restoreMode:
type:
- string
- 'null'
riskLevel:
type:
- string
- 'null'
supportsDependencies:
type: boolean
InventoryCoverageRunSection:
type: object
additionalProperties: false
required:
- basisRun
- rows
properties:
basisRun:
$ref: '#/components/schemas/BasisRun'
rows:
type: array
items:
$ref: '#/components/schemas/CoverageRow'

View File

@ -0,0 +1,187 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://tenantpilot.local/contracts/tenant-coverage-truth.schema.json",
"title": "Tenant Coverage Truth",
"type": "object",
"additionalProperties": false,
"required": [
"tenantId",
"hasCurrentCoverageResult",
"summary",
"rows"
],
"properties": {
"tenantId": {
"type": "integer"
},
"basisRun": {
"oneOf": [
{
"$ref": "#/$defs/basisRun"
},
{
"type": "null"
}
]
},
"hasCurrentCoverageResult": {
"type": "boolean"
},
"summary": {
"$ref": "#/$defs/summary"
},
"rows": {
"type": "array",
"items": {
"$ref": "#/$defs/row"
}
}
},
"$defs": {
"basisRun": {
"type": "object",
"additionalProperties": false,
"required": [
"id",
"outcome",
"completedAt"
],
"properties": {
"id": {
"type": "integer"
},
"outcome": {
"type": "string",
"enum": [
"succeeded",
"partially_succeeded",
"failed",
"blocked"
]
},
"completedAt": {
"type": "string",
"format": "date-time"
}
}
},
"summary": {
"type": "object",
"additionalProperties": false,
"required": [
"supportedTypes",
"succeededTypes",
"failedTypes",
"skippedTypes",
"unknownTypes",
"followUpTypes",
"observedItems"
],
"properties": {
"supportedTypes": {
"type": "integer",
"minimum": 0
},
"succeededTypes": {
"type": "integer",
"minimum": 0
},
"failedTypes": {
"type": "integer",
"minimum": 0
},
"skippedTypes": {
"type": "integer",
"minimum": 0
},
"unknownTypes": {
"type": "integer",
"minimum": 0
},
"followUpTypes": {
"type": "integer",
"minimum": 0
},
"observedItems": {
"type": "integer",
"minimum": 0
}
}
},
"row": {
"type": "object",
"additionalProperties": false,
"required": [
"type",
"segment",
"label",
"category",
"coverageState",
"followUpRequired",
"observedItemCount",
"supportsDependencies"
],
"properties": {
"type": {
"type": "string"
},
"segment": {
"type": "string",
"enum": [
"policy",
"foundation"
]
},
"label": {
"type": "string"
},
"category": {
"type": "string"
},
"platform": {
"type": [
"string",
"null"
]
},
"coverageState": {
"type": "string",
"enum": [
"succeeded",
"failed",
"skipped",
"unknown"
]
},
"followUpRequired": {
"type": "boolean"
},
"observedItemCount": {
"type": "integer",
"minimum": 0
},
"basisErrorCode": {
"type": [
"string",
"null"
]
},
"restoreMode": {
"type": [
"string",
"null"
]
},
"riskLevel": {
"type": [
"string",
"null"
]
},
"supportsDependencies": {
"type": "boolean"
}
}
}
}
}

View File

@ -0,0 +1,163 @@
# Phase 1 Data Model: Inventory Coverage Truth (177)
## Existing Persisted Truth
### `OperationRun` as coverage basis
Represents the canonical execution record for inventory syncs and remains the source of per-type sync truth.
**Relevant existing fields**
- `workspace_id`
- `tenant_id`
- `type`
- `status`
- `outcome`
- `summary_counts`
- `failure_summary`
- `context`
- `started_at`
- `completed_at`
**Relevant existing inventory context shape**
- `context.inventory.coverage.policy_types`
- `context.inventory.coverage.foundation_types`
- each entry stores at minimum `status`
- optional fields already supported by `InventoryCoverage` normalization:
- `item_count`
- `error_code`
**Existing invariant**
- `InventoryCoverage::fromContext()` is the canonical parser for run coverage payload.
### `InventoryItem`
Represents last observed tenant inventory rows and remains the source of current observed-item counts.
**Relevant existing fields**
- `workspace_id`
- `tenant_id`
- `policy_type`
- `external_id`
- `display_name`
- `category`
- `platform`
- `meta_jsonb`
- `last_seen_at`
- `last_seen_operation_run_id`
**Existing invariant**
- Observed rows prove last observation only. They do not by themselves prove current tenant coverage completeness.
### `InventoryPolicyTypeMeta` + capability metadata
Represents product support and capability reference for supported and foundation types.
**Relevant existing fields and derived attributes**
- `type`
- `label`
- `category`
- `platform`
- `restore`
- `risk`
- foundation flag
- dependency support from `CoverageCapabilitiesResolver`
**Existing invariant**
- Capability metadata is product support truth, not tenant coverage truth.
## New Derived Runtime Contract
### `TenantCoverageTruth`
Derived runtime contract that answers the operator question: which supported types are currently covered for this tenant, which types need follow-up, and which run establishes that statement.
**Proposed fields**
- `tenant_id`
- `basis_run_id` nullable
- `basis_run_outcome` nullable
- `basis_completed_at` nullable
- `has_current_coverage_result` boolean
- `supported_type_count`
- `succeeded_type_count`
- `failed_type_count`
- `skipped_type_count`
- `unknown_type_count`
- `follow_up_type_count`
- `observed_item_total`
- `rows` list of `TenantCoverageTypeTruth`
**Derived invariants**
- Exactly one row exists for each supported policy type and foundation type currently in the product support catalog.
- `follow_up_type_count = failed + skipped + unknown`.
- `has_current_coverage_result` is true only when a completed inventory-sync basis run with parseable payload exists.
- The basis run is chosen independently from current item counts.
### `TenantCoverageTypeTruth`
Derived row contract for one supported type.
**Proposed fields**
- `type`
- `segment` (`policy` or `foundation`)
- `label`
- `category`
- `platform` nullable
- `coverage_state` (`succeeded`, `failed`, `skipped`, `unknown`)
- `follow_up_required` boolean
- `observed_item_count`
- `basis_error_code` nullable
- `restore_mode` nullable
- `risk_level` nullable
- `supports_dependencies` boolean
**Derived invariants**
- `follow_up_required` is true for `failed`, `skipped`, and `unknown`; false only for `succeeded`.
- `observed_item_count > 0` does not change `coverage_state`.
- `coverage_state = unknown` when the type is supported but absent from the basis run payload.
- `basis_error_code` is allowed only for non-succeeded payload-backed states.
## Derived State Family
### Coverage state family
This feature introduces one derived state family for tenant coverage rows:
- `Succeeded`
- the basis run reported the type as successfully processed
- `Failed`
- the basis run reported the type as attempted and failed
- `Skipped`
- the basis run reported the type as intentionally skipped or not processed during that run
- `Unknown`
- no current coverage result exists for the supported type in the basis run
**Behavioral consequence**
- `Failed`, `Skipped`, and `Unknown` all suppress calm claims and increment follow-up counts.
- `Unknown` is derived, not persisted.
## Relationships
- One `TenantCoverageTruth` resolves for one tenant at a time.
- One `TenantCoverageTruth` may reference zero or one basis `OperationRun`.
- One `TenantCoverageTruth` contains one row per supported product type from `InventoryPolicyTypeMeta::supported()` and `InventoryPolicyTypeMeta::foundations()`.
- Each `TenantCoverageTypeTruth` joins one supported type to zero or one payload-backed status from the basis run and zero or more `InventoryItem` rows from the current tenant observation set.
## Selection Rules
### Basis run selection
- candidate runs are `OperationRun` rows where:
- `tenant_id` matches the selected tenant
- `type = inventory_sync`
- `status = completed`
- candidates are ordered by:
- `completed_at DESC`
- `id DESC`
- the selected basis run is the first candidate whose `context.inventory.coverage` payload can be parsed by `InventoryCoverage::fromContext()`
- if no candidate qualifies, the tenant has no current coverage basis run
### Unknown derivation
- if a supported type is absent from both `policy_types` and `foundation_types` in the selected basis payload, the type is `Unknown`
- absence from the basis payload is not converted into `Skipped`
- item presence from older runs does not upgrade `Unknown`
## Validation Rules
- No schema migration is required.
- No new persisted state is introduced.
- Coverage rows must remain tenant-scoped.
- Capability metadata must not alter the derived coverage state.
- Surfaces may cite the basis run only when they can do so without violating authorization.

View File

@ -0,0 +1,285 @@
# Implementation Plan: Inventory Coverage Truth
**Branch**: `feat/177-inventory-coverage-truth` | **Date**: 2026-04-05 | **Spec**: `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/specs/177-inventory-coverage-truth/spec.md`
**Input**: Feature specification from `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/specs/177-inventory-coverage-truth/spec.md`
**Note**: This template is filled in by the `/speckit.plan` command. See `.specify/scripts/` for helper scripts.
## Summary
Correct inventory coverage semantics by deriving tenant coverage truth from the latest completed inventory-sync run that contains usable per-type coverage payload, joining that run truth with current inventory-item counts and existing supported-type metadata, replacing the misleading coverage percentage in the inventory KPI header with operator-readable counts, refocusing the coverage page on tenant follow-up, and exposing a human-readable per-type coverage section on inventory-sync run detail. The implementation stays fully derived, introduces no new persistence, keeps capability and support metadata secondary, and preserves existing sync execution, authorization, and Ops-UX behavior.
## Technical Context
**Language/Version**: PHP 8.4.15
**Primary Dependencies**: Laravel 12, Filament v5, Livewire v4, Pest v4, existing `InventoryItem`, `OperationRun`, `InventoryCoverage`, `InventoryPolicyTypeMeta`, `CoverageCapabilitiesResolver`, `InventoryKpiHeader`, `InventoryCoverage` page, and `OperationRunResource` enterprise-detail stack
**Storage**: PostgreSQL; existing `inventory_items` rows and `operation_runs.context` / `operation_runs.summary_counts` JSONB are reused with no schema change
**Testing**: Pest 4 unit and feature tests, including Filament or Livewire page coverage, run through Laravel Sail
**Target Platform**: Laravel monolith web application in Sail locally and containerized Linux deployment for staging and production
**Project Type**: web application
**Performance Goals**: DB-only render path for inventory summary and coverage surfaces; no render-time Graph calls; one tenant-scoped basis-run lookup plus grouped item counts per render; default-visible inventory surfaces expose covered versus follow-up types, basis-run context, and next-step guidance without raw JSON inspection
**Constraints**: Derived-only implementation; no new coverage table; no inventory-sync backend rewrite; no new Graph calls; no new destructive actions; capability metadata stays secondary; unauthorized run drill-through must degrade safely
**Scale/Scope**: One tenant at a time across three primary surfaces: inventory KPI header on the items list, the inventory coverage page, and canonical inventory-sync run detail; dozens of supported and foundation types rather than unbounded row counts
## Constitution Check
*GATE: Passed before Phase 0 research. Re-checked after Phase 1 design and still passing.*
| Principle | Pre-Research | Post-Design | Notes |
|-----------|--------------|-------------|-------|
| Inventory-first / snapshots-second | PASS | PASS | Coverage remains derived from current inventory observation and canonical inventory-sync runs; no snapshot or backup truth is introduced. |
| Read/write separation | PASS | PASS | The feature changes read-time surfaces only. Existing `Run Inventory Sync` semantics remain unchanged. |
| Graph contract path | PASS | PASS | No new Graph call path or contract registry entry is introduced. |
| Deterministic capabilities | PASS | PASS | Capability and support metadata remain derived from existing policy-type meta and capability resolvers. |
| Workspace + tenant isolation | PASS | PASS | All coverage truth stays tenant-scoped; canonical operations drill-through remains tenant-safe and entitlement-checked. |
| RBAC-UX authorization semantics | PASS | PASS | Non-members remain `404`; members lacking the run capability remain `403` on run detail; coverage surfaces must degrade safely instead of exposing broken links. |
| Run observability / Ops-UX | PASS | PASS | Existing `inventory_sync` `OperationRun` remains canonical. No new run type or new feedback surface is introduced. |
| Ops-UX lifecycle / summary counts | PASS | PASS | `OperationRunService` remains the only transition path; `summary_counts` remain canonical and numeric-only. The plan only adds read-time rendering. |
| Data minimization | PASS | PASS | Existing whitelisted inventory metadata and sanitized run context are reused; no new persisted payload surface is added. |
| Proportionality / no premature abstraction | PASS WITH JUSTIFIED DERIVED CONTRACT | PASS WITH JUSTIFIED DERIVED CONTRACT | One narrow runtime contract plus resolver is justified because current truth is split across run context, item counts, and supported-type metadata, and three operator surfaces already need the same synthesis. |
| Persisted truth / behavioral state | PASS | PASS | `Unknown` is derived presentation truth, not persisted domain state. No new tables or durable artifacts are introduced. |
| UI semantics / few layers | PASS | PASS | The design replaces a misleading KPI and capability-first report with one direct tenant-coverage read model instead of adding a wider semantic framework. |
| Badge semantics (BADGE-001) | PASS | PASS | Coverage state badges stay centralized and test-covered. No page-local badge language is introduced. |
| Filament-native UI / Action Surface Contract | PASS | PASS | Existing Filament tables, stats, sections, and enterprise-detail views are reused. The existing derived-row exception on `InventoryCoverage` remains the only UI exception. |
| Filament UX-001 | PASS | PASS | The coverage page remains a searchable, filterable table surface with one clear empty-state CTA; run detail remains a view-style operational page; no create or edit layout changes are needed. |
| List-surface review checklist reference | PASS | PASS | The inventory items list and inventory coverage page are governed by `docs/product/standards/list-surface-review-checklist.md`, which must be applied before sign-off. |
| Filament v5 / Livewire v4 compliance | PASS | PASS | The plan stays inside the existing Filament v5 + Livewire v4 stack. No legacy APIs are introduced. |
| Provider registration location | PASS | PASS | No panel or provider registration change is involved; Laravel 11+ provider registration remains in `bootstrap/providers.php`. |
| Global search hard rule | PASS | PASS | No globally searchable resource is added or modified. |
| Destructive action safety | PASS | PASS | No new destructive action is introduced. Existing sync start action remains non-destructive and capability-gated. |
| Asset strategy | PASS | PASS | No asset registration or `filament:assets` deployment change is required. |
| Testing truth (TEST-TRUTH-001) | PASS | PASS | The design adds focused resolver, surface, and RBAC-safe continuity tests that protect operator-visible business truth. |
## Phase 0 Research
Research outcomes are captured in `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/specs/177-inventory-coverage-truth/research.md`.
Key decisions:
- Define the relevant coverage basis as the latest completed `inventory_sync` run for the tenant that contains a parseable `context.inventory.coverage` payload, regardless of overall run outcome, so `Failed` and `Skipped` remain visible when the run produced per-type truth.
- Keep tenant coverage fully derived from three existing sources: `OperationRun.context.inventory.coverage`, current `InventoryItem` counts, and existing supported-type metadata plus capability metadata.
- Introduce one narrow runtime contract and resolver in `App\Support\Inventory` rather than extending the low-level `InventoryCoverage` parser or adding request-scoped caching infrastructure.
- Replace the KPI percentage with count-based summary facts, since the spec explicitly prioritizes semantically clear counts over percentage language.
- Keep the coverage page as one truth-first report with summary + per-type table, and move capability metadata into secondary columns or reference treatment rather than preserving the current capability-first matrix.
- Add a dedicated human-readable per-type coverage section to the existing enterprise-detail run page instead of creating a new operations screen.
- Defer any first-class `stale` or freshness state family to later health hardening work; this slice surfaces the basis timestamp clearly without adding another semantic layer.
## Phase 1 Design
Design artifacts are created under `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/specs/177-inventory-coverage-truth/`:
- `data-model.md`: existing persisted truths plus the new derived tenant coverage contract
- `contracts/inventory-coverage-truth.openapi.yaml`: logical surface contract for the inventory items summary, coverage page, and run detail continuity
- `contracts/tenant-coverage-truth.schema.json`: schema for the derived tenant coverage truth contract consumed by the UI
- `quickstart.md`: focused implementation and verification workflow
Design decisions:
- The new runtime contract is summary-first and derived; it does not become a new persisted truth source.
- `InventoryCoverage` remains the low-level parser for `OperationRun` context; a sibling resolver assembles tenant coverage truth by joining the parsed payload with item counts and metadata.
- The inventory KPI header will shift from `Coverage %` to count-based coverage signals and explicit basis-run context.
- The `InventoryCoverage` page will reuse its existing table surface but reorder the page around tenant coverage truth and follow-up, with capability metadata kept clearly secondary.
- Inventory-sync run detail will gain one enterprise-detail section backed by a custom Blade view under the existing `filament.infolists.entries.*` convention.
- No new request-scoped caching or cross-surface aggregate infrastructure is introduced in this slice; one resolver is sufficient.
## Project Structure
### Documentation (this feature)
```text
specs/177-inventory-coverage-truth/
├── spec.md
├── plan.md
├── research.md
├── data-model.md
├── quickstart.md
├── contracts/
│ ├── inventory-coverage-truth.openapi.yaml
│ └── tenant-coverage-truth.schema.json
├── checklists/
│ └── requirements.md
└── tasks.md
```
### Source Code (repository root)
```text
app/
├── Filament/
│ ├── Pages/
│ │ └── InventoryCoverage.php
│ ├── Resources/
│ │ ├── InventoryItemResource/
│ │ │ └── Pages/
│ │ │ └── ListInventoryItems.php
│ │ └── OperationRunResource.php
│ ├── Pages/
│ │ └── Operations/
│ │ └── TenantlessOperationRunViewer.php
│ └── Widgets/
│ └── Inventory/
│ └── InventoryKpiHeader.php
├── Models/
│ ├── InventoryItem.php
│ └── OperationRun.php
└── Support/
└── Inventory/
├── CoverageCapabilitiesResolver.php
├── InventoryCoverage.php
├── InventoryPolicyTypeMeta.php
├── TenantCoverageTruth.php
└── TenantCoverageTruthResolver.php
resources/
└── views/
└── filament/
├── pages/
│ └── inventory-coverage.blade.php
└── infolists/
└── entries/
└── inventory-coverage-truth.blade.php
tests/
├── Feature/
│ ├── Filament/
│ │ ├── InventoryCoverageAdminTenantParityTest.php
│ │ ├── InventoryCoverageTableTest.php
│ │ ├── InventoryItemResourceTest.php
│ │ ├── InventoryPagesTest.php
│ │ └── OperationRunEnterpriseDetailPageTest.php
│ ├── Inventory/
│ │ ├── InventorySyncServiceTest.php
│ │ ├── InventorySyncStartSurfaceTest.php
│ │ └── RunInventorySyncJobTest.php
│ ├── Operations/
│ │ └── TenantlessOperationRunViewerTest.php
│ └── Rbac/
│ └── InventoryItemResourceAuthorizationTest.php
└── Unit/
└── Support/
└── Inventory/
└── TenantCoverageTruthResolverTest.php
```
**Structure Decision**: Keep the existing Laravel monolith layout. Add one narrow derived coverage contract plus resolver under `app/Support/Inventory`, update the three existing operator surfaces, add one custom enterprise-detail view, and extend the current feature and unit tests instead of creating new base directories or a broader presentation framework.
## Implementation Strategy
### Phase A — Introduce the Derived Coverage Contract
**Goal**: Add one explicit runtime contract that represents tenant coverage truth without changing persistence.
| Step | File | Change |
|------|------|--------|
| A.1 | `app/Support/Inventory/TenantCoverageTruth.php` | Add a readonly runtime contract representing basis-run metadata, summary counts, and per-type tenant coverage rows |
| A.2 | `app/Support/Inventory/TenantCoverageTruthResolver.php` | Add the resolver that selects the latest completed coverage-bearing inventory-sync run, parses `InventoryCoverage`, joins current item counts, and synthesizes follow-up classification |
| A.3 | `app/Support/Inventory/InventoryCoverage.php` | Keep the low-level parser focused on run payload normalization; extend only if a small helper is needed for row access, not for tenant-level joining |
### Phase B — Refactor the Inventory KPI Header
**Goal**: Remove the misleading percentage and replace it with count-based tenant coverage truth.
| Step | File | Change |
|------|------|--------|
| B.1 | `app/Filament/Widgets/Inventory/InventoryKpiHeader.php` | Replace `Coverage %` with count-based coverage stats such as succeeded types and types needing follow-up, plus explicit basis-run or no-sync context while keeping any restore or compare metadata clearly separate from coverage truth |
| B.2 | `tests/Feature/Filament/InventoryPagesTest.php` and `tests/Feature/Filament/InventoryItemResourceTest.php` | Preserve the existing inventory list inspect and sync-start affordances while asserting the summary surface no longer implies completeness from restorable-item share |
### Phase C — Recenter the Coverage Page Around Tenant Truth
**Goal**: Turn the current capability-first page into a tenant coverage report without removing support metadata entirely.
| Step | File | Change |
|------|------|--------|
| C.1 | `app/Filament/Pages/InventoryCoverage.php` | Replace the current static capability row builder with rows sourced from `TenantCoverageTruthResolver`; lead with coverage-state, basis-run, observed-item, follow-up columns, and deterministic follow-up priority ordering |
| C.2 | `resources/views/filament/pages/inventory-coverage.blade.php` | Add or adjust the summary zone so the page cites the basis run, last sync time, explicit no-sync fallback, provider or permission follow-up guidance, and follow-up summary before the table |
| C.3 | `tests/Feature/Filament/InventoryCoverageTableTest.php` and `tests/Feature/Filament/InventoryCoverageAdminTenantParityTest.php` | Prove the page is tenant-coverage-first, keeps admin and tenant context parity, covers deterministic follow-up priority and no-basis-run messaging, and retains support, restore, and compare metadata only as secondary treatment |
### Phase D — Add Human-Readable Per-Type Results To Run Detail
**Goal**: Make inventory-sync per-type truth readable without raw JSON inspection.
| Step | File | Change |
|------|------|--------|
| D.1 | `app/Filament/Resources/OperationRunResource.php` | For `inventory_sync` runs, add a dedicated enterprise-detail section that renders per-type coverage truth from `InventoryCoverage::fromContext()` |
| D.2 | `resources/views/filament/infolists/entries/inventory-coverage-truth.blade.php` | Add the custom section view used by the enterprise-detail builder for per-type status, item counts, follow-up priority, and provider or permission follow-up cues |
| D.3 | `tests/Feature/Operations/TenantlessOperationRunViewerTest.php` and `tests/Feature/Filament/OperationRunEnterpriseDetailPageTest.php` | Assert that inventory-sync runs render the new coverage section and that execution outcome stays distinct from per-type coverage truth |
### Phase E — Enforce RBAC-Safe Coverage Continuity
**Goal**: Ensure coverage surfaces can cite the backing run without leaking inaccessible operations.
| Step | File | Change |
|------|------|--------|
| E.1 | `app/Support/Inventory/TenantCoverageTruthResolver.php` and calling surfaces | Include basis-run identity and safe continuity metadata without assuming the current user can always open the run |
| E.2 | `app/Filament/Widgets/Inventory/InventoryKpiHeader.php` and `app/Filament/Pages/InventoryCoverage.php` | Show direct links only when the current user can open the run; otherwise show explanatory guidance, explicit no-sync copy, and provider or permission follow-up guidance |
| E.3 | `tests/Feature/Rbac/InventoryItemResourceAuthorizationTest.php` and a new resolver or feature test for run continuity | Assert 404 or 403 behavior remains unchanged and the coverage UI degrades safely for users who cannot open the run |
### Phase F — Regression Protection and Verification
**Goal**: Lock the corrected semantics in place with focused tests and formatting.
| Step | File | Change |
|------|------|--------|
| F.1 | `tests/Unit/Support/Inventory/TenantCoverageTruthResolverTest.php` | Cover basis-run selection, `Unknown` derivation, follow-up classification, and item-count joining |
| F.2 | Existing feature tests across Inventory, Filament, Operations, and RBAC | Cover KPI wording, truth-first coverage page behavior, run-detail readability, and safe continuity |
| F.3 | `vendor/bin/sail bin pint --dirty --format agent` and focused Pest runs | Apply formatting and run the smallest verification pack covering resolver logic, inventory surfaces, run detail, and RBAC continuity |
## Key Design Decisions
### D-001 — The basis run is the latest completed inventory-sync run with usable per-type coverage payload
This feature needs `Failed` and `Skipped` to remain visible when a run produced real per-type truth, so the basis selector must key off payload presence rather than optimistic run outcome alone.
### D-002 — `InventoryCoverage` stays a low-level parser; tenant synthesis lives in a sibling contract and resolver
`InventoryCoverage` already models the canonical `context.inventory.coverage` payload. Extending it to select runs, join item counts, and apply UI-facing follow-up logic would blur responsibilities and make the low-level parser less reusable.
### D-003 — Count-based KPI signals are the narrowest correction
The spec explicitly identifies the unqualified percentage as the misleading element. Replacing it with succeeded and follow-up type counts corrects the operator semantics without inventing a new score.
### D-004 — Capability metadata stays visible but subordinate
The product support matrix is still useful, but it must stop being the primary answer to a tenant coverage question. The plan keeps the metadata in secondary table columns or reference treatment instead of removing it completely.
### D-005 — No new stale state family in this slice
The spec allows stale semantics as optional. Adding them now would create another interpretation layer and broaden the scope beyond the immediate truth correction. This slice uses explicit timestamps and leaves broader freshness posture to later health work.
### D-006 — Follow-up priority is deterministic and severity-first
Coverage surfaces must not invent their own urgency rules. Follow-up ordering is `Failed` before `Unknown` before `Skipped`, then observed item count descending, then type label ascending, so the summary and the table can highlight the same first-review candidates without presentation drift.
## Risk Assessment
| Risk | Impact | Likelihood | Mitigation |
|------|--------|------------|------------|
| The resolver selects a run that should not be the coverage basis | High | Medium | Unit-test the basis selector across succeeded, partial, failed-with-payload, skipped-with-payload, and no-payload scenarios |
| Observed item counts are read as proof of coverage | High | Medium | Separate item counts from state columns and summary language, and add regression tests for types with items but `Unknown` coverage |
| Capability metadata still dominates the coverage page | Medium | Medium | Lead with coverage-state columns and summary facts, demote support metadata to secondary columns, and add page-level assertions |
| Run drill-through leaks inaccessible operations or creates dead links | High | Medium | Compute safe continuity metadata in the resolver or calling surface and test authorized, forbidden, and not-found paths |
| Run-detail coverage rendering conflicts with the existing enterprise-detail hierarchy | Medium | Low | Use one enterprise-detail view section under existing conventions and keep raw context JSON in the technical section |
## Test Strategy
- Add focused unit coverage for `TenantCoverageTruthResolver` so basis-run selection, `Unknown` derivation, and follow-up classification are verified without UI noise.
- Extend `InventoryCoverageTableTest` and `InventoryCoverageAdminTenantParityTest` to prove the page now answers tenant coverage truth first, applies deterministic follow-up priority, handles the no-basis-run case plainly, and keeps support, restore, and compare metadata secondary.
- Extend inventory list and page regressions so the KPI summary no longer exposes a misleading coverage percentage, plainly reports when no basis run exists, and preserves existing sync-start affordances plus canonical run links.
- Extend `TenantlessOperationRunViewerTest` and `OperationRunEnterpriseDetailPageTest` to verify inventory-sync runs render a readable per-type coverage section, provider or permission follow-up guidance, and keep execution outcome separate from coverage truth.
- Add RBAC coverage for safe continuity so users who can see inventory truth but cannot open the run receive non-clickable or explanatory guidance rather than broken drill-throughs.
- Run the smallest focused Sail test pack plus Pint before implementation completion.
## Complexity Tracking
| Violation | Why Needed | Simpler Alternative Rejected Because |
|-----------|------------|-------------------------------------|
| New derived runtime contract plus resolver | Three existing surfaces need the same tenant coverage synthesis from run payload, item counts, and capability metadata | Recomputing the join inside each widget or page would duplicate business truth and make regression drift likely |
## Proportionality Review
- **Current operator problem**: Operators currently read a `Coverage` KPI that actually measures restorable-item share and a coverage page that primarily presents product support metadata, while the true per-type sync result lives in run context and is difficult to read.
- **Existing structure is insufficient because**: No existing object combines the relevant run basis, per-type run status, item counts, and support metadata into one tenant-coverage answer, and the current surfaces each infer their own incomplete version of coverage.
- **Narrowest correct implementation**: Add one derived runtime contract plus one resolver, then refit the existing KPI header, coverage page, and run-detail page around that contract without adding persistence or a broader framework.
- **Ownership cost created**: One new runtime contract, one resolver, one custom enterprise-detail view, and a focused set of unit and feature regressions.
- **Alternative intentionally rejected**: A persisted coverage table, a new percentage score, a request-scoped aggregate framework, or a broader inventory health layer were rejected because they exceed the scope of correcting already-available truth.
- **Release truth**: Current-release truth correction.

View File

@ -0,0 +1,64 @@
# Quickstart: Inventory Coverage Truth (177)
## Goal
Implement the Spec 177 truth correction without changing inventory-sync execution semantics or adding persistence.
The implementation is complete when:
- the inventory KPI header no longer shows a misleading unqualified coverage percentage,
- the coverage page answers tenant coverage truth first,
- inventory-sync run detail shows per-type results in human-readable form,
- and run continuity is RBAC-safe.
## Suggested Implementation Order
1. Add the derived runtime contract and resolver under `app/Support/Inventory`.
2. Add unit tests for basis-run selection, `Unknown` derivation, and follow-up classification.
3. Refactor `InventoryKpiHeader` to consume the new resolver and switch to count-based summary facts.
4. Refactor `InventoryCoverage` to consume the same resolver and move capability metadata into secondary treatment.
5. Add the inventory-sync run detail section and its custom Blade view.
6. Extend RBAC and feature tests for safe continuity and truthful rendering.
7. Run Pint and the focused Sail test pack.
## Verification Workflow
### Unit and focused feature tests
Run the smallest focused set first:
```bash
export PATH="/bin:/usr/bin:/usr/local/bin:$PATH"
cd /Users/ahmeddarrazi/Documents/projects/TenantAtlas
vendor/bin/sail artisan test --compact tests/Unit/Support/Inventory/TenantCoverageTruthResolverTest.php
vendor/bin/sail artisan test --compact tests/Feature/Filament/InventoryCoverageTableTest.php
vendor/bin/sail artisan test --compact tests/Feature/Filament/InventoryCoverageAdminTenantParityTest.php
vendor/bin/sail artisan test --compact tests/Feature/Operations/TenantlessOperationRunViewerTest.php
vendor/bin/sail artisan test --compact tests/Feature/Inventory/RunInventorySyncJobTest.php
vendor/bin/sail bin pint --dirty --format agent
```
### Manual operator walkthrough
1. Open the inventory items list for a tenant with a recent inventory-sync run.
2. Confirm the header shows count-based coverage truth, not `Coverage %`.
3. Open the coverage page and verify the summary cites the basis run and the per-type table leads with coverage state and follow-up.
4. Open the cited inventory-sync run and verify the new per-type coverage section renders without opening raw JSON.
5. Verify a user without access to the run sees safe explanatory guidance instead of a dead-end link.
## Out-of-Scope Guardrails
Do not do any of the following in this slice:
- add a new coverage table or materialized summary artifact
- rewrite `InventorySyncService` or `RunInventorySyncJob`
- introduce restore-readiness or compare-readiness semantics into coverage
- add a first-class stale coverage state family
- add page-local badge mappings or local semantic color logic
## Completion Checklist
- Count-based KPI summary shipped
- Coverage page is tenant-truth-first
- Capability metadata is visibly secondary
- Run detail has human-readable per-type coverage
- Safe continuity works for both authorized and unauthorized viewers
- Focused tests and Pint pass

View File

@ -0,0 +1,66 @@
# Phase 0 Research: Inventory Coverage Truth (177)
## Context
Spec 177 corrects a semantic trust problem on the existing inventory surfaces.
The current inventory KPI widget computes `Coverage %` from restorable-item share inside `InventoryKpiHeader`, while real per-type sync truth is already persisted in canonical `OperationRun.context['inventory']['coverage']` by `InventorySyncService` and `RunInventorySyncJob`. The current `InventoryCoverage` page is config-driven and capability-first, and inventory-sync run detail still leaves the per-type result largely hidden behind generic run outcome and raw JSON.
The feature must stay derived, tenant-scoped, and operator-first.
## Decisions
### Decision: The coverage basis is the latest completed inventory-sync run with parseable per-type coverage payload
- **Rationale**: The spec needs `Succeeded`, `Failed`, `Skipped`, and `Unknown` to be visible as tenant coverage truth. The job currently writes a normalized `context.inventory.coverage` payload before terminalizing the run, including skipped and failed cases that still carry real per-type truth. The narrowest deterministic rule is therefore to select the latest completed `inventory_sync` run for the tenant whose payload can be parsed by `InventoryCoverage::fromContext()`.
- **Alternatives considered**:
- Latest succeeded or partially succeeded run only: rejected because it would hide relevant skipped or failed per-type truth that the spec explicitly wants operators to see.
- Latest attempted run regardless of payload: rejected because a run without parseable coverage payload cannot support per-type coverage truth and would collapse all rows into guesswork.
### Decision: Coverage remains fully derived from existing truth sources
- **Rationale**: The feature can answer the tenant coverage question by combining three already-existing sources: canonical per-type sync truth from `OperationRun.context.inventory.coverage`, observed-item counts from `InventoryItem`, and product capability metadata from `InventoryPolicyTypeMeta` plus `CoverageCapabilitiesResolver`. This satisfies the constitution bias toward deriving before persisting.
- **Alternatives considered**:
- New coverage table or materialized snapshot: rejected because it would duplicate current-release truth and add lifecycle overhead without new operator value.
- Writeback summary JSON to `Tenant`: rejected because the truth already belongs to the latest inventory-sync run and current observed items.
### Decision: Introduce one narrow runtime contract and resolver as siblings to `InventoryCoverage`
- **Rationale**: `InventoryCoverage` is already the canonical parser for the stored run payload. Extending it to perform tenant-scoped run lookup, item-count joins, and follow-up classification would blur responsibilities. A sibling runtime contract such as `TenantCoverageTruth` and a resolver such as `TenantCoverageTruthResolver` keep the low-level parser small while giving the UI one stable read model.
- **Alternatives considered**:
- Add more behavior directly to `InventoryCoverage`: rejected because it would mix raw payload normalization with tenant-level query and presentation concerns.
- Compute the join independently inside each page or widget: rejected because three surfaces would re-own the same truth and regress independently.
- Add request-scoped aggregate caching: rejected as unnecessary complexity for this slice.
### Decision: Replace the KPI percentage with count-based coverage facts
- **Rationale**: The spec explicitly says absolute counts are preferred over a percentage unless the percentage is narrowly qualified. Count-based facts such as succeeded types, types needing follow-up, last sync, and items observed answer the operator question directly and avoid false completeness signals.
- **Alternatives considered**:
- Keep a relabeled percentage such as `Latest sync type coverage`: rejected for the first slice because counts are clearer and avoid another interpretation layer.
- Keep the current restorable-item share with different wording: rejected because it still answers the wrong question.
### Decision: The coverage page becomes one tenant-coverage-first report with capability metadata demoted to secondary treatment
- **Rationale**: The current page already has a searchable and filterable table surface. The narrowest correction is to reuse that surface, rebuild the row model around tenant coverage truth, lead with summary + state + follow-up columns, and keep capability metadata in secondary columns or labeled reference treatment.
- **Alternatives considered**:
- Preserve the current capability-first matrix and add a separate banner: rejected because the primary semantic center would remain wrong.
- Split the page into two separate tables for tenant truth and product support: rejected as broader than needed for the first correction slice.
### Decision: Inventory-sync run detail gets one human-readable per-type coverage section under the existing enterprise-detail stack
- **Rationale**: `OperationRunResource` already uses `EnterpriseDetailBuilder` with custom view sections. Adding one `inventory_sync`-specific section under the same pattern is the narrowest way to expose per-type results without inventing a new operational page.
- **Alternatives considered**:
- Continue relying on raw context JSON: rejected because the spec explicitly forbids leaving this truth buried in JSON.
- Build a standalone inventory-sync detail page: rejected because the canonical run viewer already exists.
### Decision: Do not introduce a first-class stale or freshness coverage state in Spec 177
- **Rationale**: The spec lists stale semantics as optional secondary behavior. The current trust defect is the wrong meaning of coverage, not missing freshness taxonomy. Showing the basis timestamp is enough for this slice and avoids broadening the state family.
- **Alternatives considered**:
- Add `Stale` now as a fifth primary coverage state: rejected because it would expand scope into inventory health and freshness semantics better handled by a later follow-up spec.
### Decision: Run continuity must be RBAC-safe and explanatory when drill-through is unavailable
- **Rationale**: The spec requires that coverage surfaces never emit broken or implicitly inaccessible next actions. The UI must only link to the basis run when the user is entitled to open it; otherwise it must show clear non-clickable guidance.
- **Alternatives considered**:
- Always show the run link and let authorization fail after navigation: rejected because it creates dead-end operator flows and can leak existence.
- Hide all run references unless the user can open them: rejected because the spec still requires clear explanation of what the coverage statement is based on.
## Clarifications Resolved
- **Relevant inventory sync**: The basis run is payload-bearing and completed; outcome alone is not sufficient.
- **Unknown semantics**: `Unknown` means there is no current tenant coverage result for that supported type in the chosen basis run, even if items still exist from older observation.
- **Capability separation**: Restore mode, risk, dependency support, and similar metadata remain visible only as secondary support reference, not as coverage truth.
- **Scope limit**: No new persistence, no backend rewrite, and no freshness-state expansion are included in this slice.

View File

@ -0,0 +1,235 @@
# Feature Specification: Spec 177 - Inventory Coverage Truth
**Feature Branch**: `feat/177-inventory-coverage-truth`
**Created**: 2026-04-05
**Status**: Draft
**Input**: User description: "Spec 177 - Inventory Coverage Truth"
## Spec Scope Fields *(mandatory)*
- **Scope**: tenant + canonical-view
- **Primary Routes**:
- `/admin/inventory-items` and inventory item detail routes while a tenant context is active
- `/admin/coverage` while a tenant context is active
- `/admin/operations` and `/admin/operations/{run}` for canonical inventory-sync drill-through
- **Data Ownership**:
- Tenant-owned `InventoryItem` rows and per-type observed item counts remain the tenant inventory observation truth.
- Workspace-owned `OperationRun` rows with a tenant reference remain the canonical execution truth for `inventory_sync`, including the existing per-type payload stored under `context.inventory.coverage`.
- Product capability and support metadata remain derived from the supported-type catalog and capability metadata already exposed through inventory policy-type meta and related capability resolvers.
- This feature introduces no new persisted coverage table, no materialized coverage snapshot, and no writeback artifact.
- **RBAC**:
- Workspace membership, tenant entitlement, and tenant inventory view capability remain required for the inventory items list and the coverage page.
- Starting an inventory sync from the inventory items list remains gated by the canonical tenant inventory sync capability.
- Inventory-sync run drill-through remains governed by existing operation-run authorization: workspace membership, tenant entitlement when a run is tenant-bound, and any run-specific required capability already attached to that run.
- Coverage surfaces must never expose broken or unauthorized next-action links; when a user can see coverage truth but cannot open the backing run, the UI must degrade safely.
For canonical-view specs, the spec MUST define:
- **Default filter behavior when tenant-context is active**: Coverage-to-operations navigation opens the exact latest relevant inventory-sync run when one is available. If the operator needs the broader operations list instead, the canonical destination opens prefiltered to the active tenant and the inventory-sync operation family.
- **Explicit entitlement checks preventing cross-tenant leakage**: Coverage summary counts, per-type rows, observed-item counts, run references, and follow-up actions must be derived only from the active tenant scope and from operation runs the current user is entitled to inspect. Non-members remain deny-as-not-found, and inaccessible runs must not leak existence through clickable dead ends.
## 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 |
|---|---|---|---|---|---|---|---|---|---|---|---|
| Inventory items list with KPI summary | Read-only Registry / Report Surface | Full-row click to inventory item detail remains the one inspect model for item records | required | `Run Inventory Sync` stays in the header; coverage and run continuity links stay in the KPI summary or adjacent summary content | none | `/admin/inventory-items` | Inventory item detail route for the selected tenant item | Active tenant context, current filters, and the last relevant sync reference anchor the list to one tenant | Inventory Items / Inventory Item | Observed items plus truthful tenant coverage summary, including latest sync reference and follow-up counts rather than a misleading restorable-item percentage | Embedded summary surface inside a read-only resource |
| Inventory coverage page | Read-only Registry / Report Surface | The page itself is the canonical tenant coverage report; diagnostics drill through via explicit summary links to the relevant sync run | forbidden | Summary-level diagnostic and retry actions live above or beside the per-type table; capability reference remains secondary on the page | none | `/admin/coverage` | `/admin/operations/{run}` for the cited relevant inventory-sync run | Active tenant context, cited run timestamp, cited run identity, and visible succeeded or failed or skipped or unknown counts | Inventory Coverage | Per-type tenant coverage truth, follow-up need, and the run the truth is based on, clearly separated from product capability reference | Derived per-type rows have no standalone record detail |
| Inventory sync run detail | Detail-first Operational Surface | Dedicated run detail page | forbidden | Existing back, refresh, related-link, and safe diagnostic actions remain in the detail header | none | `/admin/operations` | `/admin/operations/{run}` | Workspace context, referenced tenant, run timing, run outcome, and inventory-sync identity | Inventory Sync Run / Operation Run | Execution outcome plus human-readable per-type coverage results instead of raw JSON alone | none |
## Operator Surface Contract *(mandatory when operator-facing surfaces are changed)*
| Surface | Primary Persona | Surface Type | Primary Operator Question | Default-visible Information | Diagnostics-only Information | Status Dimensions Used | Mutation Scope | Primary Actions | Dangerous Actions |
|---|---|---|---|---|---|---|---|---|---|
| Inventory items list with KPI summary | Tenant operator | List with embedded summary | What has this tenant inventory observed, and does current coverage need follow-up before I trust that view? | Total observed items, last relevant sync reference, counts of succeeded types and types needing follow-up, and the inventory item list itself | Raw run context, low-level provider failure details, and secondary capability metadata | coverage truth, execution recency, item presence | `Run Inventory Sync` continues to affect the Microsoft tenant and TenantPilot inventory observation state; the list itself is read-only | Open inventory item, Run Inventory Sync, Open coverage truth | none |
| Inventory coverage page | Tenant operator | Derived report | Which supported types are currently covered for this tenant, which are not, and what should I do next? | Coverage summary, follow-up summary, cited relevant sync, and a per-type table showing state, timestamp or run reference context, observed item count, and follow-up need | Secondary support or capability reference, dependency capability, raw reason codes, and deep diagnostics | coverage truth, execution truth, item presence, capability reference as a separate domain | none on the report itself; any sync retry action keeps the existing inventory-sync mutation scope | View latest sync run, Run inventory sync again when authorized, Review follow-up types | none |
| Inventory sync run detail | Tenant operator or workspace operator with tenant entitlement | Detail | What did this inventory sync actually do per type, and how does that explain the tenant's current coverage truth? | Run status and outcome, human-readable per-type results, counts, related tenant context, and next-step guidance | Full context JSON, raw failure payloads, and deeper technical fragments | execution outcome, per-type coverage result, item counts, next-step guidance | none on the detail page itself | Refresh, open related coverage context, open related records | none |
## Proportionality Review *(mandatory when structural complexity is introduced)*
- **New source of truth?**: No.
- **New persisted entity/table/artifact?**: No.
- **New abstraction?**: Yes, one narrow derived coverage-truth assembler or read model may be required to combine supported types, the latest relevant inventory-sync result, observed item counts, and follow-up classification on existing surfaces.
- **New enum/state/reason family?**: Yes, a narrow derived tenant-coverage state family must include `Unknown` or `Not synced yet` alongside the existing successful, failed, and skipped outcomes. It remains derived, not persisted.
- **New cross-domain UI framework/taxonomy?**: No.
- **Current operator problem**: Operators currently see a `Coverage` KPI that is really a restorable-item share, while real per-type sync truth lives in inventory-sync run context and is almost invisible on the operator-facing surfaces that should answer coverage questions.
- **Existing structure is insufficient because**: The KPI widget currently compresses restorable-item share into `Coverage %`, the coverage page renders a capability or support matrix rather than tenant sync truth, and inventory-sync run detail does not expose per-type results in an operator-first format.
- **Narrowest correct implementation**: Derive one tenant coverage view from the existing latest relevant inventory-sync run, the supported-type catalog, and current inventory-item counts; surface it on the existing inventory pages and inventory-sync run detail; keep product capability reference secondary; add no new persistence.
- **Ownership cost**: Focused derived-read-model logic, presentation cleanup on three existing surfaces, and regression coverage across tenant coverage, operations drill-through, and RBAC-safe degradation.
- **Alternative intentionally rejected**: A new coverage table, a restore-readiness score, a compare-readiness layer, a dashboard-wide inventory health program, or a broader content-depth taxonomy were rejected because the immediate defect is misleading operator truth on already-shipped inventory surfaces.
- **Release truth**: Current-release truth correction that prepares later usefulness, missing-item, and dashboard follow-up work.
## User Scenarios & Testing *(mandatory)*
### User Story 1 - Read truthful coverage at a glance (Priority: P1)
As a tenant operator, I can open inventory surfaces and immediately understand which supported inventory types are currently covered for my tenant and which types still need follow-up.
**Why this priority**: The current trust failure starts at first glance. If the leading KPI and coverage page are semantically wrong, every later decision is biased by false calm.
**Independent Test**: Can be fully tested by seeding one tenant with a latest relevant inventory-sync run that contains a mix of succeeded, failed, skipped, and omitted types plus current inventory items, then rendering the inventory items list and the coverage page.
**Acceptance Scenarios**:
1. **Given** a tenant has a latest relevant inventory-sync run with mixed per-type outcomes, **When** the operator opens the coverage page, **Then** every supported type appears with a truthful state of succeeded, failed, skipped, or unknown and the follow-up summary highlights the non-succeeded types.
2. **Given** a supported type has observed items but no current coverage result in the relevant sync basis, **When** the operator opens the coverage surface, **Then** the type appears as unknown or not synced yet rather than implicitly covered.
3. **Given** no relevant inventory-sync run exists for the tenant, **When** the operator opens the inventory summary surfaces, **Then** the UI makes clear that there is no current coverage result and points the operator toward starting inventory sync instead of presenting a positive coverage claim.
---
### User Story 2 - Move cleanly from coverage truth to run truth (Priority: P1)
As a tenant operator, I can see which inventory-sync run the current coverage statement is based on and open that run or a safe equivalent diagnostic path without losing tenant context.
**Why this priority**: Coverage claims must be recoverable. If the UI states that a tenant is covered or not covered but cannot show the exact run that established that claim, the truth is not auditable.
**Independent Test**: Can be fully tested by seeding a tenant with a relevant inventory-sync run and verifying that the coverage page and inventory summary surfaces cite the run, the timestamp, and the correct drill-through behavior for both authorized and unauthorized viewers.
**Acceptance Scenarios**:
1. **Given** current coverage is derived from a specific inventory-sync run, **When** the operator opens the coverage page, **Then** the page shows the cited run and timestamp and provides a direct path to that run.
2. **Given** the current user can see inventory truth but cannot open the backing run, **When** the coverage page renders, **Then** the page shows safe explanatory guidance instead of a broken or unauthorized run link.
3. **Given** the operator needs the broader operations history, **When** the operator follows the diagnostic path from inventory coverage, **Then** the operations destination stays scoped to the originating tenant and the inventory-sync operation family.
---
### User Story 3 - Diagnose per-type inventory-sync results without raw JSON (Priority: P2)
As a tenant operator, I can open an inventory-sync run detail page and read the per-type results in human terms, so I understand how execution outcome and coverage follow-up relate.
**Why this priority**: The backend already knows this truth. The missing value is readable diagnosis, not a new execution system.
**Independent Test**: Can be fully tested by seeding an inventory-sync run whose per-type payload contains mixed outcomes and verifying that the run detail page renders a readable per-type breakdown and next-step guidance without requiring raw JSON inspection.
**Acceptance Scenarios**:
1. **Given** an inventory-sync run has per-type coverage payload data, **When** the operator opens the run detail page, **Then** the page shows a human-readable per-type result section rather than burying the truth only in raw context JSON.
2. **Given** the run outcome is partially successful, **When** the operator views run detail, **Then** the page separates overall execution outcome from the specific types that still need follow-up.
3. **Given** one or more types failed or were skipped, **When** the operator views run detail, **Then** the next-step guidance points to the relevant follow-up path such as reviewing provider or permission problems or running inventory sync again.
### Edge Cases
- The tenant has never completed a relevant inventory-sync run, so all supported types must remain unknown instead of reading as covered by default.
- The latest relevant run omitted one or more supported types entirely, which must result in unknown rather than silent success.
- Observed items exist from an older run, but the latest relevant run did not establish current coverage truth for that type.
- The latest relevant run failed or was blocked before processing most or all selected types.
- A type was intentionally skipped because of selection or foundation toggles, and the UI must show that truth without implying success.
- The current user may view inventory surfaces but may not satisfy the required capability for the cited inventory-sync run.
- The supported-type catalog may change after the latest sync, so the coverage surface must distinguish current support reference from tenant sync truth.
## Requirements *(mandatory)*
**Constitution alignment (required):** This feature introduces no new Microsoft Graph contract, no new change operation, and no new long-running workflow. It reuses the existing `inventory_sync` operation truth already written into canonical `OperationRun` records and corrects how that truth is surfaced on inventory pages.
**Constitution alignment (PROP-001 / ABSTR-001 / PERSIST-001 / STATE-001 / BLOAT-001):** The feature is intentionally narrow. It introduces no new persistence and no generic framework. A single derived coverage-truth assembler and one derived `Unknown` coverage state are justified because the existing surfaces currently misrepresent three different truths as one. The feature follows the default bias of deriving before persisting, replacing misleading semantics before layering new ones, and being explicit instead of generic.
**Constitution alignment (OPS-UX):** The existing `inventory_sync` `OperationRun` remains canonical. This feature does not change queued-toast, progress-surface, or terminal-notification behavior. `OperationRun.status` and `OperationRun.outcome` remain service-owned through `OperationRunService`, `summary_counts` remain numeric-only and canonical, scheduled or system-run behavior is unchanged, and regression coverage must verify run-detail rendering and coverage-to-run continuity without inventing new operation feedback surfaces.
**Constitution alignment (RBAC-UX):** This feature spans tenant inventory surfaces on `/admin` with active tenant context and canonical operations surfaces on `/admin/operations`. Non-members or actors outside the current tenant scope remain `404`. In-scope members missing the required run capability remain `403` on the run detail itself. Server-side authorization remains the source of truth through the existing capability resolver on inventory surfaces and `OperationRunPolicy` plus run-capability resolution for run drill-through. No raw capability strings or role-string checks are introduced. No new destructive action is added.
**Constitution alignment (OPS-EX-AUTH-001):** Not applicable. No authentication handshake behavior is changed.
**Constitution alignment (BADGE-001):** Any added or updated coverage-state badges must stay centralized. `Succeeded`, `Failed`, `Skipped`, and `Unknown` must use shared badge semantics rather than page-local color language, and regression tests must cover the new or revised mappings.
**Constitution alignment (UI-FIL-001):** The feature reuses Filament stats widgets, tables, sections, badges, infolists, and actions already present on the affected inventory and operations surfaces. It avoids local replacement markup for status language and does not require publishing internal Filament views. No exception is expected.
**Constitution alignment (UI-STD-001):** The modified inventory items list and inventory coverage page are governed by `docs/product/standards/list-surface-review-checklist.md`, and implementation must review both surfaces against that checklist before sign-off.
**Constitution alignment (UI-NAMING-001):** Operator-facing vocabulary must distinguish `Coverage`, `Support`, `Capability`, `Restore mode`, `Compare`, `Last sync`, and `Needs follow-up`. `Coverage` means tenant-specific sync coverage for supported types. `Coverage %` is forbidden unless explicitly qualified as the latest sync's type coverage. `Restore ready` and `Compare ready` must not be used as synonyms for coverage.
**Constitution alignment (UI-CONST-001 / UI-SURF-001 / UI-HARD-001 / UI-EX-001 / UI-REVIEW-001):** The inventory items list keeps row click for real inventory records. The inventory coverage page remains a derived report with an approved exception: its per-type rows do not open a standalone detail record. Diagnostics drill through through explicit links to the relevant run. The canonical collection and detail routes are stated above, and critical truth visible by default must be tenant coverage truth rather than support metadata.
**Constitution alignment (OPSURF-001):** Default-visible content must stay operator-first. Coverage truth, execution truth, and item presence must be shown as separate dimensions. Capability and support metadata remain diagnostics or secondary reference. The existing `Run Inventory Sync` action continues to communicate a tenant-affecting sync over the external tenant plus TenantPilot observation state and follows the existing safe execution pattern.
**Constitution alignment (UI-SEM-001 / LAYER-001 / TEST-TRUTH-001):** Direct mapping from one existing source is insufficient because current truth is split across operation-run context, inventory-item counts, and supported-type metadata. This feature may introduce one narrow derived coverage view, but it must replace the misleading KPI and capability-centric interpretation rather than add a new general semantic framework. Tests must focus on operator consequences: truthful summaries, safe follow-up, and auditable continuity.
**Constitution alignment (Filament Action Surfaces):** The Action Surface Contract remains satisfied with one approved exception on `InventoryCoverage`: derived per-type rows do not have a standalone record detail. Redundant `View` actions remain absent, empty action groups remain absent, and no new destructive action is introduced. UI-FIL-001 is satisfied with no approved exception beyond the existing derived-row detail exemption.
**Constitution alignment (UX-001 - Layout & Information Architecture):** The inventory coverage page remains a search and filter driven table surface with a truthful summary above the table and a clear single empty-state CTA. The inventory items list remains a read-only list and detail flow. Inventory-sync run detail remains a view-style operational page. Any new summary panels or diagnostic sections must use shared sections or cards and keep raw JSON secondary.
### Functional Requirements
- **FR-177-001**: Default-visible inventory summary surfaces MUST NOT present a KPI or label that can be read as tenant inventory completeness when it actually represents restorable-item share, capability coverage, or any other secondary truth. The current unqualified `Coverage %` must be removed or replaced with an explicitly qualified type-coverage statement.
- **FR-177-002**: The tenant coverage report MUST show every supported inventory type for the selected tenant with, at minimum, the type, current coverage state, the relevant sync reference or time basis, observed item count, and whether follow-up is needed.
- **FR-177-003**: Coverage state MUST be derived from real inventory-sync truth and MUST include at least `Succeeded`, `Failed`, `Skipped`, and `Unknown` or `Not synced yet`.
- **FR-177-004**: Coverage surfaces MUST make clear which relevant inventory-sync run they are based on, including a visible timestamp and a direct or safely degraded path to the canonical run detail. If no relevant sync exists, the surface must say so plainly.
- **FR-177-005**: Run execution truth and coverage truth MUST remain separate. A partial or failed run outcome must not replace the per-type statement of which supported types are currently covered and which still need follow-up.
- **FR-177-006**: The inventory coverage page MUST primarily answer the tenant question `Which supported types are currently inventoried successfully for this tenant, and where are the gaps?` It must not remain centered on a product support matrix.
- **FR-177-007**: Product support and capability metadata MAY remain available only as a clearly secondary reference layer with labels such as `Support`, `Capability`, or `Restore mode`. It must not be confused with tenant coverage truth.
- **FR-177-008**: When one or more supported types are failed, skipped, or unknown, the coverage surfaces MUST prominently signal that follow-up is required, how many types are affected, and which types are highest priority to review first. Follow-up priority MUST use a deterministic severity-first order of `Failed` before `Unknown` before `Skipped`, then observed item count descending, then inventory type label ascending for stable ties.
- **FR-177-009**: A supported type without a current tenant coverage result MUST appear as `Unknown` or `Not synced yet`. It must not appear positively because items exist, because the type is supported, or because the product has richer capability metadata for it.
- **FR-177-010**: Observed item counts MUST remain clearly separate from coverage truth and MUST NOT be styled or worded as proof of completeness, restore usefulness, or compare usefulness.
- **FR-177-011**: Coverage surfaces MUST provide real next actions such as viewing the latest relevant run, running inventory sync again, reviewing failed or skipped types, or reviewing provider or permission issues. When a next action cannot be opened by the current user, the surface MUST show safe explanatory guidance instead of a dead-end link.
- **FR-177-012**: Inventory-sync run detail MUST expose per-type results in a human-readable section and MUST NOT rely on raw JSON alone to communicate which supported types succeeded, failed, or were skipped.
- **FR-177-013**: All coverage signals, per-type states, counts, and run drill-throughs MUST remain tenant-scoped and RBAC-conformant.
- **FR-177-014**: Coverage MUST NOT imply restore readiness. Any restore metadata shown on the same surface must remain explicitly separate from the coverage statement.
- **FR-177-015**: Coverage MUST NOT imply compare readiness. Any compare-related context shown on the same surface must remain secondary and explicitly distinct from coverage truth.
- **FR-177-016**: The feature MUST be derived from existing inventory items, supported-type metadata, and canonical inventory-sync run truth without adding a new coverage persistence model or rewriting the inventory-sync backend.
## 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 |
|---|---|---|---|---|---|---|---|---|---|---|
| Inventory items list with KPI summary | `app/Filament/Resources/InventoryItemResource.php`, `app/Filament/Resources/InventoryItemResource/Pages/ListInventoryItems.php`, `app/Filament/Widgets/Inventory/InventoryKpiHeader.php` | `Run Inventory Sync` | Existing one-click open path to inventory item detail remains the only inspect model for item rows | none | none | Existing inventory list empty-state CTA remains unchanged | n/a | n/a | existing only for sync dispatch | KPI labels, summary facts, and coverage or run continuity links are changed by this spec. The sync action remains capability-gated and non-destructive. |
| Inventory coverage page | `app/Filament/Pages/InventoryCoverage.php` | none; summary-level diagnostic or retry CTAs may appear above the table | Approved exception: derived per-type rows do not have a standalone detail record | none | none | `Clear filters` | n/a | n/a | no new audit behavior | Action Surface Contract remains satisfied through the approved derived-row exception. Run continuity is provided through explicit summary links rather than row inspect actions. |
| Inventory sync run detail | `app/Filament/Pages/Operations/TenantlessOperationRunViewer.php` and the existing operation-run detail rendering stack | Existing back, refresh, and related-link header actions remain | Direct page | n/a | none | n/a | Existing back, refresh, and related-link header actions remain | n/a | no new audit behavior | This spec adds human-readable per-type inventory coverage results to the existing detail page and does not introduce new mutations. |
### Key Entities *(include if feature involves data)*
- **Tenant coverage truth**: The tenant-specific statement of whether each supported inventory type is currently covered successfully, failed, skipped, or still unknown.
- **Relevant inventory-sync run**: The latest completed `inventory_sync` execution for the active tenant that contains parseable `context.inventory.coverage` truth. Overall run outcome does not disqualify it when usable per-type payload exists, because `Failed` and `Skipped` remain meaningful operator truth.
- **Coverage state**: The per-type result family used for operator decisions: `Succeeded`, `Failed`, `Skipped`, and `Unknown` or `Not synced yet`.
- **Observed item count**: The current number of inventory items observed for one supported type in the selected tenant, kept separate from coverage truth.
- **Capability reference**: Static product support metadata such as support mode, restore mode, dependency capability, risk, or similar type metadata that must remain distinct from tenant coverage truth.
## Success Criteria *(mandatory)*
### Measurable Outcomes
- **SC-177-001**: In seeded regression scenarios, the default-visible inventory items summary, inventory coverage page, and inventory-sync run detail together expose covered versus follow-up types, basis-run context, and next-step guidance without requiring raw JSON inspection.
- **SC-177-002**: In regression coverage, 100% of default-visible inventory summary surfaces stop showing an unqualified `Coverage %` or any equivalent metric that can be read as tenant inventory completeness.
- **SC-177-003**: In regression coverage, every supported type shown on the tenant coverage report resolves to exactly one of `Succeeded`, `Failed`, `Skipped`, or `Unknown`, and the summary follow-up count matches the number of non-succeeded types.
- **SC-177-004**: In tested authorization scenarios, operators can either open the cited relevant inventory-sync run from coverage surfaces or receive a safe non-clickable explanation in 100% of cases.
- **SC-177-005**: The feature ships without a required schema migration, without a new coverage table, and without a rewrite of the inventory-sync backend.
## Assumptions
- Existing inventory-sync runs already persist per-type successful or failed or skipped truth for attempted types inside canonical `OperationRun` context.
- The supported and foundation type catalog remains the authoritative denominator for tenant coverage reporting.
- `Unknown` is a derived tenant state used when current coverage truth for a supported type cannot be established from the relevant sync basis, even if older items still exist.
- Inventory item list and detail pages remain the correct places for item-level observation; this spec does not promote them into restore-readiness or compare-readiness surfaces.
## Non-Goals
- Redesigning the backup or restore domain
- Introducing a restore-readiness algorithm or item-level readiness score
- Introducing a compare-readiness domain for inventory coverage
- Building a missing or vanished item workflow
- Launching a dashboard-wide inventory health program
- Adding a new persistence table or materialized coverage artifact
- Rewriting the inventory-sync backend
## Dependencies
- Spec 039 - Inventory Program
- Spec 040 - Inventory Core
- Spec 041 - Inventory UI
- Spec 042 - Inventory Dependencies Graph
- Existing inventory surfaces, inventory-sync execution, supported-type metadata, and canonical operations drill-through behavior already present in the repo
## Follow-up Spec Candidates
- **Inventory Content Depth & Usefulness**: Separate coverage truth from content depth, restore usefulness, and compare usefulness.
- **Inventory Missing / Vanished Surface**: Add explicit follow-up for missing or vanished items once coverage truth is no longer overloaded.
- **Dashboard Inventory Health**: Propagate truthful coverage and freshness signals onto broader tenant overview surfaces after the core coverage semantics are corrected.
## Definition of Done
Spec 177 is complete when:
- no operator can read a default-visible coverage metric as tenant inventory completeness when it is actually describing capability or restorable-item share,
- the inventory coverage page shows truthful per-type tenant coverage and makes follow-up obvious,
- the inventory items list and its KPI summary cite truthful coverage and last-sync context rather than a misleading percentage,
- inventory-sync run detail exposes per-type results in human-readable form,
- capability and support metadata remain available but secondary and explicitly labeled,
- coverage-to-run continuity is auditable and RBAC-safe,
- and the improvement ships without a new persisted coverage model or an inventory-sync backend rewrite.

View File

@ -0,0 +1,224 @@
# Tasks: Inventory Coverage Truth
**Input**: Design documents from `/specs/177-inventory-coverage-truth/`
**Prerequisites**: `plan.md` (required), `spec.md` (required for user stories), `research.md`, `data-model.md`, `contracts/`, `quickstart.md`
**Tests**: Required. Use Pest coverage in `tests/Unit/Support/Inventory/TenantCoverageTruthResolverTest.php`, `tests/Unit/Badges/BadgeCatalogTest.php`, `tests/Unit/Badges/InventoryCoverageStateBadgesTest.php`, `tests/Feature/Filament/InventoryCoverageTableTest.php`, `tests/Feature/Filament/InventoryCoverageAdminTenantParityTest.php`, `tests/Feature/Filament/InventoryPagesTest.php`, `tests/Feature/Filament/InventoryItemResourceTest.php`, `tests/Feature/Filament/InventoryCoverageRunContinuityTest.php`, `tests/Feature/Filament/OperationRunEnterpriseDetailPageTest.php`, `tests/Feature/Inventory/InventorySyncServiceTest.php`, `tests/Feature/Inventory/InventorySyncStartSurfaceTest.php`, `tests/Feature/Inventory/RunInventorySyncJobTest.php`, `tests/Feature/Operations/TenantlessOperationRunViewerTest.php`, and `tests/Feature/Rbac/InventoryItemResourceAuthorizationTest.php`.
**Operations**: This feature reuses the existing `inventory_sync` `OperationRun` and does not introduce a new run type or change lifecycle ownership. Tasks must keep canonical run links and read-time rendering aligned without changing queued execution semantics.
**RBAC**: Existing workspace membership, tenant entitlement, and 404 vs 403 semantics remain authoritative. Tasks must preserve tenant-safe rendering and ensure basis-run links degrade safely when the current user cannot open the run.
**Operator Surfaces**: The inventory items list KPI header, the inventory coverage page, and canonical inventory-sync run detail must become tenant-coverage-first while keeping low-level diagnostics secondary.
**Filament UI Action Surfaces**: No new destructive actions or new action inventories are added. The existing `Run Inventory Sync` header action remains capability-gated and non-destructive, and the `InventoryCoverage` page keeps its derived-row no-detail exception.
**Filament UI UX-001**: No new create or edit screens are introduced. The inventory coverage page remains a searchable, filterable table with a single clear empty-state CTA, and run detail remains an enterprise-detail view surface.
**Badges**: Coverage-state semantics must stay centralized through `BadgeDomain`, `BadgeCatalog`, and `BadgeRenderer`; no page-local badge mappings are allowed.
**Organization**: Tasks are grouped by user story so each story can be implemented and validated as an independent increment once the shared tenant coverage contract is in place.
## Phase 1: Setup (Shared Coverage Truth Scaffolding)
**Purpose**: Create the narrow runtime and regression entry points required for tenant coverage truth.
- [X] T001 [P] Create the derived tenant coverage truth scaffolding in `app/Support/Inventory/TenantCoverageTruth.php` and `app/Support/Inventory/TenantCoverageTruthResolver.php`
- [X] T002 [P] Create the focused regression scaffolding in `tests/Unit/Support/Inventory/TenantCoverageTruthResolverTest.php`, `tests/Unit/Badges/InventoryCoverageStateBadgesTest.php`, and `tests/Feature/Filament/InventoryCoverageRunContinuityTest.php`
---
## Phase 2: Foundational (Blocking Coverage Contract)
**Purpose**: Build the shared derived coverage contract, basis-run selection, and centralized badge semantics that all user stories depend on.
**⚠️ CRITICAL**: No user story work should begin until this phase is complete.
- [X] T003 [P] Extend basis-run and mixed-outcome payload coverage assertions in `tests/Unit/Support/Inventory/TenantCoverageTruthResolverTest.php` and `tests/Feature/Inventory/RunInventorySyncJobTest.php`
- [X] T004 [P] Add centralized inventory coverage badge mapping assertions in `tests/Unit/Badges/BadgeCatalogTest.php` and `tests/Unit/Badges/InventoryCoverageStateBadgesTest.php`
- [X] T005 [P] Implement the latest completed coverage-bearing inventory-sync selection helper in `app/Models/OperationRun.php`
- [X] T006 [P] Implement the derived runtime fields and row contract in `app/Support/Inventory/TenantCoverageTruth.php`
- [X] T007 Implement tenant coverage resolution, item-count joins, follow-up classification, and basis-run metadata in `app/Support/Inventory/TenantCoverageTruthResolver.php`
- [X] T008 Implement the centralized inventory coverage state badge domain and mapper in `app/Support/Badges/BadgeDomain.php`, `app/Support/Badges/BadgeCatalog.php`, and `app/Support/Badges/Domains/InventoryCoverageStateBadge.php`
**Checkpoint**: A single derived tenant coverage contract now exists for all supported types, with centralized badge semantics and deterministic basis-run selection.
---
## Phase 3: User Story 1 - Read Truthful Coverage At A Glance (Priority: P1) 🎯 MVP
**Goal**: Make the inventory KPI header and coverage page answer the tenant coverage question directly instead of implying completeness from restorable-item share or support metadata.
**Independent Test**: Seed a tenant with a coverage-bearing inventory-sync run that includes succeeded, failed, skipped, and omitted types plus current inventory items, then verify the inventory items list and coverage page show truthful counts, `Unknown` rows, and follow-up emphasis.
### Tests for User Story 1
- [X] T009 [P] [US1] Rewrite summary-surface expectations for truthful count-based coverage on the inventory items list in `tests/Feature/Filament/InventoryPagesTest.php` and `tests/Feature/Filament/InventoryItemResourceTest.php`
- [X] T010 [P] [US1] Add coverage-table assertions for `Succeeded`, `Failed`, `Skipped`, `Unknown`, deterministic follow-up priority, no-basis-run messaging, and secondary support, restore, and compare metadata in `tests/Feature/Filament/InventoryCoverageTableTest.php` and `tests/Feature/Filament/InventoryCoverageAdminTenantParityTest.php`
### Implementation for User Story 1
- [X] T011 [P] [US1] Replace the misleading coverage percentage with count-based tenant coverage facts, explicit no-basis-run messaging, and coverage-only terminology in `app/Filament/Widgets/Inventory/InventoryKpiHeader.php`
- [X] T012 [P] [US1] Rewrite KPI supporting badge copy around follow-up counts, top-priority follow-up types, and observed-item counts without implying restore or compare readiness in `app/Support/Inventory/InventoryKpiBadges.php`
- [X] T013 [US1] Refactor tenant coverage row assembly, deterministic follow-up ranking, and default-visible columns while keeping support, restore, and compare metadata secondary in `app/Filament/Pages/InventoryCoverage.php`
- [X] T014 [US1] Rewrite the coverage page intro and summary copy to tenant-coverage-first language with explicit no-sync fallback guidance in `resources/views/filament/pages/inventory-coverage.blade.php`
- [X] T015 [US1] Run the focused truthful-at-a-glance pack in `tests/Feature/Filament/InventoryCoverageTableTest.php`, `tests/Feature/Filament/InventoryCoverageAdminTenantParityTest.php`, `tests/Feature/Filament/InventoryPagesTest.php`, and `tests/Feature/Filament/InventoryItemResourceTest.php`
**Checkpoint**: The inventory items list and coverage page now show tenant coverage truth first, with capability metadata clearly secondary.
---
## Phase 4: User Story 2 - Move Cleanly From Coverage Truth To Run Truth (Priority: P1)
**Goal**: Make coverage surfaces cite the exact basis run, provide safe drill-through when authorized, and degrade cleanly when the run cannot be opened.
**Independent Test**: Seed a tenant with a coverage-bearing basis run and verify the inventory summary surfaces show the basis timestamp and run continuity, then verify a user without run access receives explanatory guidance instead of a dead-end link.
### Tests for User Story 2
- [X] T016 [P] [US2] Add basis-run continuity assertions for linked coverage summaries, explicit no-basis-run fallback, and tenant-scoped operations fallback in `tests/Feature/Filament/InventoryPagesTest.php` and `tests/Feature/Filament/InventoryCoverageRunContinuityTest.php`
- [X] T017 [P] [US2] Add safe degradation assertions for viewers who can see inventory truth but cannot open the basis run in `tests/Feature/Rbac/InventoryItemResourceAuthorizationTest.php` and `tests/Feature/Filament/InventoryCoverageRunContinuityTest.php`
### Implementation for User Story 2
- [X] T018 [P] [US2] Surface basis-run summary, continuity actions, no-sync fallback, and provider or permission follow-up guidance on the coverage report in `app/Filament/Pages/InventoryCoverage.php` and `resources/views/filament/pages/inventory-coverage.blade.php`
- [X] T019 [P] [US2] Surface basis-run continuity and safe link rendering in the inventory KPI summary using `app/Filament/Widgets/Inventory/InventoryKpiHeader.php` and `app/Support/Inventory/TenantCoverageTruthResolver.php`
- [X] T020 [US2] Run the focused continuity pack in `tests/Feature/Filament/InventoryCoverageRunContinuityTest.php`, `tests/Feature/Filament/InventoryPagesTest.php`, and `tests/Feature/Rbac/InventoryItemResourceAuthorizationTest.php`
**Checkpoint**: Coverage claims are now auditable through the basis run and remain safe when the current user cannot open the run detail.
---
## Phase 5: User Story 3 - Diagnose Per-Type Inventory Sync Results Without Raw JSON (Priority: P2)
**Goal**: Make canonical inventory-sync run detail render human-readable per-type results and keep execution outcome separate from tenant coverage follow-up.
**Independent Test**: Seed an inventory-sync run with mixed per-type outcomes and verify the canonical run viewer shows a readable coverage section without relying on raw JSON.
### Tests for User Story 3
- [X] T021 [P] [US3] Add human-readable inventory-sync section assertions, including provider or permission follow-up guidance, in `tests/Feature/Operations/TenantlessOperationRunViewerTest.php` and `tests/Feature/Filament/OperationRunEnterpriseDetailPageTest.php`
- [X] T022 [P] [US3] Extend mixed-outcome payload expectations for per-type coverage rows in `tests/Feature/Inventory/InventorySyncServiceTest.php` and `tests/Feature/Inventory/RunInventorySyncJobTest.php`
### Implementation for User Story 3
- [X] T023 [US3] Add the inventory-sync per-type coverage enterprise-detail section with explicit next-step guidance in `app/Filament/Resources/OperationRunResource.php`
- [X] T024 [P] [US3] Create the human-readable per-type run coverage view with provider or permission follow-up cues in `resources/views/filament/infolists/entries/inventory-coverage-truth.blade.php`
- [X] T025 [US3] Run the focused inventory-sync run-detail pack in `tests/Feature/Operations/TenantlessOperationRunViewerTest.php`, `tests/Feature/Filament/OperationRunEnterpriseDetailPageTest.php`, `tests/Feature/Inventory/InventorySyncServiceTest.php`, and `tests/Feature/Inventory/RunInventorySyncJobTest.php`
**Checkpoint**: Inventory-sync run detail now explains per-type outcomes directly and no longer forces operators into raw JSON for core coverage truth.
---
## Phase 6: Polish & Cross-Cutting Concerns
**Purpose**: Align copy, run focused verification, and confirm the feature stayed within the intended no-new-persistence boundary.
- [X] T026 [P] Align operator-facing coverage labels, restore or compare separation, and helper copy across `app/Filament/Widgets/Inventory/InventoryKpiHeader.php`, `app/Support/Inventory/InventoryKpiBadges.php`, `app/Filament/Pages/InventoryCoverage.php`, and `app/Filament/Resources/OperationRunResource.php`
- [X] T027 Run `vendor/bin/sail bin pint --dirty --format agent` for touched files under `app/`, `resources/views/`, and `tests/`
- [X] T028 Run the focused Sail verification pack from `specs/177-inventory-coverage-truth/quickstart.md` against `tests/Unit/Support/Inventory/TenantCoverageTruthResolverTest.php`, `tests/Unit/Badges/BadgeCatalogTest.php`, `tests/Unit/Badges/InventoryCoverageStateBadgesTest.php`, `tests/Feature/Filament/InventoryCoverageTableTest.php`, `tests/Feature/Filament/InventoryCoverageAdminTenantParityTest.php`, `tests/Feature/Filament/InventoryPagesTest.php`, `tests/Feature/Filament/InventoryItemResourceTest.php`, `tests/Feature/Filament/InventoryCoverageRunContinuityTest.php`, `tests/Feature/Filament/OperationRunEnterpriseDetailPageTest.php`, `tests/Feature/Inventory/InventorySyncServiceTest.php`, `tests/Feature/Inventory/InventorySyncStartSurfaceTest.php`, `tests/Feature/Inventory/RunInventorySyncJobTest.php`, `tests/Feature/Operations/TenantlessOperationRunViewerTest.php`, and `tests/Feature/Rbac/InventoryItemResourceAuthorizationTest.php`
- [X] T029 Validate that the final implementation introduces no schema migration, no inventory-sync backend rewrite, and no new persisted truth by reviewing `database/migrations/`, `app/Services/Inventory/InventorySyncService.php`, `app/Jobs/RunInventorySyncJob.php`, and `specs/177-inventory-coverage-truth/plan.md` against the final diff
- [X] T030 [P] Review `app/Filament/Resources/InventoryItemResource/Pages/ListInventoryItems.php` and `app/Filament/Pages/InventoryCoverage.php` against `docs/product/standards/list-surface-review-checklist.md` before final sign-off
---
## Dependencies & Execution Order
### Phase Dependencies
- **Setup (Phase 1)**: No dependencies; start immediately.
- **Foundational (Phase 2)**: Depends on Setup completion and blocks all user story work.
- **User Story 1 (Phase 3)**: Depends on Foundational completion and delivers the MVP truth-correction slice.
- **User Story 2 (Phase 4)**: Depends on User Story 1 because it extends the same summary surfaces with basis-run continuity and safe drill-through.
- **User Story 3 (Phase 5)**: Depends on Foundational completion and can proceed in parallel with User Story 2 if staffed, since it focuses on canonical run detail.
- **Polish (Phase 6)**: Depends on all desired user stories being complete.
### User Story Dependencies
- **User Story 1 (P1)**: Depends only on the shared derived coverage contract from Phase 2 and is the recommended MVP.
- **User Story 2 (P1)**: Depends on User Story 1 because the same inventory summary surfaces must first speak truthful coverage before adding run continuity.
- **User Story 3 (P2)**: Depends only on the foundational contract and existing canonical run detail infrastructure; it can be delivered after User Story 1 or in parallel with User Story 2, but is lower priority.
### Within Each User Story
- Story tests should be written before or alongside implementation and should fail for the intended reason before the story is considered complete.
- Shared resolver or badge changes should land before surface refactors that consume them.
- Surface refactors should land before the focused story-level verification run.
### Parallel Opportunities
- `T001` and `T002` can run in parallel during Setup.
- `T003`, `T004`, `T005`, and `T006` can run in parallel during Foundational work.
- `T009` and `T010` can run in parallel for User Story 1.
- `T011` and `T012` can run in parallel for User Story 1 after the foundational resolver is ready.
- `T016` and `T017` can run in parallel for User Story 2.
- `T018` and `T019` can run in parallel for User Story 2 after basis-run continuity metadata is available.
- `T021` and `T022` can run in parallel for User Story 3.
- `T023` and `T024` can run in parallel for User Story 3.
- `T026` and `T030` can run in parallel during Polish.
---
## Parallel Example: User Story 1
```bash
# Launch the inventory truth regressions together before changing summary surfaces:
Task: T009 tests/Feature/Filament/InventoryPagesTest.php and tests/Feature/Filament/InventoryItemResourceTest.php
Task: T010 tests/Feature/Filament/InventoryCoverageTableTest.php and tests/Feature/Filament/InventoryCoverageAdminTenantParityTest.php
# Split KPI and helper refactors once the shared resolver is ready:
Task: T011 app/Filament/Widgets/Inventory/InventoryKpiHeader.php
Task: T012 app/Support/Inventory/InventoryKpiBadges.php
```
## Parallel Example: User Story 2
```bash
# Write the continuity and degradation assertions together before adding links:
Task: T016 tests/Feature/Filament/InventoryCoverageRunContinuityTest.php and tests/Feature/Filament/InventoryPagesTest.php
Task: T017 tests/Feature/Rbac/InventoryItemResourceAuthorizationTest.php and tests/Feature/Filament/InventoryCoverageRunContinuityTest.php
# Split page and widget continuity work after the tests exist:
Task: T018 app/Filament/Pages/InventoryCoverage.php and resources/views/filament/pages/inventory-coverage.blade.php
Task: T019 app/Filament/Widgets/Inventory/InventoryKpiHeader.php and app/Support/Inventory/TenantCoverageTruthResolver.php
```
## Parallel Example: User Story 3
```bash
# Lock the run-detail expectations and payload assertions together:
Task: T021 tests/Feature/Operations/TenantlessOperationRunViewerTest.php and tests/Feature/Filament/OperationRunEnterpriseDetailPageTest.php
Task: T022 tests/Feature/Inventory/InventorySyncServiceTest.php and tests/Feature/Inventory/RunInventorySyncJobTest.php
# Build the section view and enterprise-detail wiring in parallel:
Task: T023 app/Filament/Resources/OperationRunResource.php
Task: T024 resources/views/filament/infolists/entries/inventory-coverage-truth.blade.php
```
---
## Implementation Strategy
### MVP First (User Story 1 Only)
1. Complete Phase 1: Setup.
2. Complete Phase 2: Foundational.
3. Complete Phase 3: User Story 1.
4. Validate the inventory items list and coverage page with the User Story 1-focused subset of `specs/177-inventory-coverage-truth/quickstart.md`.
### Incremental Delivery
1. Finish Setup and Foundational work.
2. Deliver User Story 1 and validate truthful tenant coverage at a glance.
3. Deliver User Story 2 and validate run continuity plus safe degradation.
4. Deliver User Story 3 and validate human-readable inventory-sync run detail.
5. Finish with formatting, the focused Sail pack, and the no-new-persistence review.
### Parallel Team Strategy
1. One developer can complete Phase 1 and the model or resolver side of Phase 2 while another prepares the badge and feature regressions.
2. After Phase 2, one developer can take User Story 1 while another prepares User Story 3 run-detail tests.
3. After User Story 1 stabilizes, one developer can handle User Story 2 continuity while another completes User Story 3 UI wiring.
4. Rejoin for Phase 6 formatting and verification.
---
## Notes
- Every task follows the required checklist format: checkbox, task ID, optional parallel marker, required story label for story phases, and exact file paths.
- The suggested MVP scope is Phase 1 through Phase 3 only.
- No task in this plan introduces new persistence, a new Graph contract, a new Filament panel or provider registration change, or a new destructive action.

View File

@ -0,0 +1,36 @@
# Specification Quality Checklist: Operations Lifecycle Alignment & Cross-Surface Truth Consistency
**Purpose**: Validate specification completeness and quality before proceeding to planning
**Created**: 2026-04-05
**Feature**: [spec.md](../spec.md)
## Content Quality
- [x] No implementation details (languages, frameworks, APIs)
- [x] Focused on user value and business needs
- [x] Written for non-technical stakeholders
- [x] All mandatory sections completed
## Requirement Completeness
- [x] No [NEEDS CLARIFICATION] markers remain
- [x] Requirements are testable and unambiguous
- [x] Success criteria are measurable
- [x] Success criteria are technology-agnostic (no implementation details)
- [x] All acceptance scenarios are defined
- [x] Edge cases are identified
- [x] Scope is clearly bounded
- [x] Dependencies and assumptions identified
## Feature Readiness
- [x] All functional requirements have clear acceptance criteria
- [x] User scenarios cover primary flows
- [x] Feature meets measurable outcomes defined in Success Criteria
- [x] No implementation details leak into specification
## Notes
- Validated after the initial draft. The spec stays focused on operator trust, cross-surface truth alignment, and drill-through continuity while explicitly avoiding new persistence or a lifecycle-model rewrite.
- The only new semantic split is a derived operator-facing distinction between terminal follow-up and active stale or stuck attention, built from existing lifecycle truth rather than new stored state.
- No clarification markers remain. Scope, non-goals, dependencies, and measurable outcomes are explicit enough to proceed to planning.

View File

@ -0,0 +1,279 @@
openapi: 3.1.0
info:
title: Operations Truth Alignment Contract
version: 1.0.0
summary: Route and UI truth contract for Spec 178.
components:
schemas:
FreshnessState:
type: string
enum:
- fresh_active
- likely_stale
- reconciled_failed
- terminal_normal
- unknown
ProblemClass:
type: string
enum:
- none
- active_stale_attention
- terminal_follow_up
OperationsDrillthroughState:
type: object
required:
- workspace_id
- problemClass
properties:
workspace_id:
type: integer
tenant_id:
type:
- integer
- 'null'
problemClass:
$ref: '#/components/schemas/ProblemClass'
activeTab:
type:
- string
- 'null'
navigationContext:
type:
- string
- 'null'
RunDecisionZoneTruth:
type: object
required:
- freshnessState
- problemClass
- isCurrentlyActive
- isReconciled
- primaryNextAction
properties:
freshnessState:
$ref: '#/components/schemas/FreshnessState'
problemClass:
$ref: '#/components/schemas/ProblemClass'
isCurrentlyActive:
type: boolean
isReconciled:
type: boolean
staleLineageNote:
type:
- string
- 'null'
primaryNextAction:
type: string
paths:
/admin:
get:
operationId: viewWorkspaceOverview
summary: Display the workspace overview with aligned operations attention and recency truth.
responses:
'200':
description: Workspace overview rendered successfully.
'404':
description: User is not entitled to the current workspace scope.
x-embedded-surfaces:
workspaceOperationsAttention:
surfaceType: embedded_attention_summary
problemBuckets:
- active_stale_attention
- terminal_follow_up
canonicalCollectionRoute: /admin/operations
destinationContract:
$ref: '#/components/schemas/OperationsDrillthroughState'
workspaceRecentOperations:
surfaceType: diagnostic_recency_table
canonicalCollectionRoute: /admin/operations
canonicalDetailRoute: /admin/operations/{run}
rowTruth:
freshnessState:
$ref: '#/components/schemas/FreshnessState'
problemClass:
$ref: '#/components/schemas/ProblemClass'
/admin/t/{tenant}:
get:
operationId: viewTenantDashboard
summary: Display the tenant dashboard with aligned operations attention, recent activity, and local progress truth.
parameters:
- name: tenant
in: path
required: true
schema:
type: string
responses:
'200':
description: Tenant dashboard rendered successfully.
'404':
description: User is not entitled to the tenant scope.
x-embedded-surfaces:
tenantOperationsAttention:
surfaceType: embedded_attention_summary
problemBuckets:
- active_stale_attention
- terminal_follow_up
canonicalCollectionRoute: /admin/operations
destinationContract:
$ref: '#/components/schemas/OperationsDrillthroughState'
tenantRecentOperations:
surfaceType: diagnostic_recency_table
canonicalCollectionRoute: /admin/operations
canonicalDetailRoute: /admin/operations/{run}
rowTruth:
freshnessState:
$ref: '#/components/schemas/FreshnessState'
problemClass:
$ref: '#/components/schemas/ProblemClass'
bulkOperationProgress:
surfaceType: live_progress_indicator
activeOnly: true
polling:
interval: 10s
activeWhen: active runs exist for the current tenant
inactiveWhen: no relevant active runs remain
/admin/operations:
get:
operationId: listAdminOperations
summary: Display the canonical admin operations hub with problem-class-aware filtering.
parameters:
- name: tenant_id
in: query
required: false
schema:
type: integer
- name: problemClass
in: query
required: false
schema:
$ref: '#/components/schemas/ProblemClass'
- name: activeTab
in: query
required: false
schema:
type: string
responses:
'200':
description: Operations hub rendered successfully.
'404':
description: User is not entitled to the workspace or referenced tenant scope.
x-ui-surface:
surfaceType: read_only_registry_report
displayLabel: Operations
canonicalDetailRoute: /admin/operations/{run}
filters:
active_stale_attention:
definition: queued or running runs whose freshness state is likely_stale
terminal_follow_up:
definition: completed runs whose outcome is blocked, partially_succeeded, or failed; reconciled stale lineage remains visible
rowTruth:
freshnessState:
$ref: '#/components/schemas/FreshnessState'
problemClass:
$ref: '#/components/schemas/ProblemClass'
/admin/operations/{run}:
get:
operationId: viewAdminOperation
summary: Display the canonical admin run detail with decision-zone lifecycle truth.
parameters:
- name: run
in: path
required: true
schema:
type: integer
responses:
'200':
description: Canonical admin run detail rendered successfully.
'404':
description: User is not entitled to the workspace or referenced tenant scope.
x-ui-surface:
surfaceType: detail_first_operational
displayLabel: Operation
decisionZoneTruth:
$ref: '#/components/schemas/RunDecisionZoneTruth'
invariant:
- stale and reconciled lifecycle truth must be visible in the primary decision hierarchy
- problem class on the destination must confirm the problem class of the origin link
/system/ops/runs:
get:
operationId: listSystemOperations
summary: Display the platform-wide operations registry with stale/reconciled lineage visible.
responses:
'200':
description: System operations registry rendered successfully.
'403':
description: Authenticated platform user lacks operations view capability.
'404':
description: Wrong plane or inaccessible system surface.
x-ui-surface:
surfaceType: read_only_registry_report
displayLabel: Operations
canonicalDetailRoute: /system/ops/runs/{run}
rowTruth:
freshnessState:
$ref: '#/components/schemas/FreshnessState'
problemClass:
$ref: '#/components/schemas/ProblemClass'
staleLineageVisible: true
/system/ops/failures:
get:
operationId: listSystemFailures
summary: Display the platform failure registry with reconciled stale lineage visible on failed terminal runs.
responses:
'200':
description: System failures registry rendered successfully.
'403':
description: Authenticated platform user lacks operations view capability.
'404':
description: Wrong plane or inaccessible system surface.
x-ui-surface:
surfaceType: read_only_registry_report
displayLabel: Failed operations
canonicalDetailRoute: /system/ops/runs/{run}
filter:
baseOutcome: failed
invariant:
- failed runs that were auto-reconciled from stale state must visibly preserve that lineage
/system/ops/stuck:
get:
operationId: listSystemStuckOperations
summary: Display active queued/running operations that crossed the lifecycle stuck threshold.
responses:
'200':
description: System stuck registry rendered successfully.
'403':
description: Authenticated platform user lacks operations view capability.
'404':
description: Wrong plane or inaccessible system surface.
x-ui-surface:
surfaceType: read_only_registry_report
displayLabel: Stuck operations
canonicalDetailRoute: /system/ops/runs/{run}
filter:
problemClass: active_stale_attention
invariant:
- the page remains active-only and uses the same lifecycle-policy thresholds as admin stale detection
/system/ops/runs/{run}:
get:
operationId: viewSystemOperation
summary: Display the platform run detail confirming stale/reconciled lineage and next action.
parameters:
- name: run
in: path
required: true
schema:
type: integer
responses:
'200':
description: System run detail rendered successfully.
'403':
description: Authenticated platform user lacks operations view capability.
'404':
description: Wrong plane or inaccessible system surface.
x-ui-surface:
surfaceType: detail_first_operational
displayLabel: Operation
decisionZoneTruth:
$ref: '#/components/schemas/RunDecisionZoneTruth'
invariant:
- stale/reconciled lineage remains visible even when the run is already terminal

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