Compare commits

...

7 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
44898a98ac feat: harden evidence freshness publication trust (#205)
## Summary
- harden governance artifact truth propagation so stale or partial evidence downgrades evidence snapshots, tenant reviews, review packs, the canonical evidence overview, and the canonical review register consistently
- add the full Spec 174 artifact set under `specs/174-evidence-freshness-publication-trust/` including spec, plan, research, data model, contracts, quickstart, checklist, and completed tasks
- add focused fixture helpers plus a new browser smoke test for the touched evidence, review, and review-pack trust surfaces

## Testing
- `vendor/bin/sail artisan test --compact tests/Feature/Evidence/EvidenceSnapshotResourceTest.php tests/Feature/Evidence/EvidenceOverviewPageTest.php tests/Feature/TenantReview/TenantReviewLifecycleTest.php tests/Feature/TenantReview/TenantReviewRegisterTest.php tests/Feature/ReviewPack/ReviewPackResourceTest.php tests/Feature/Monitoring/ArtifactTruthRunDetailTest.php tests/Browser/Spec174EvidenceFreshnessPublicationTrustSmokeTest.php`
- manual integrated-browser smoke pass across Evidence Overview, Review Register, tenant review detail, tenant evidence snapshot detail, and review-packs list

## Notes
- Livewire v4 compliance is preserved and no Filament v3/v4 APIs were introduced
- no panel or provider changes were made; Laravel 11+ provider registration remains in `bootstrap/providers.php`
- no new global-search behavior was introduced; existing resource view pages remain the relevant detail endpoints
- destructive actions were not broadened; existing confirmation and authorization behavior remains in place
- no new assets were added, so the current Filament asset strategy and deploy-time `php artisan filament:assets` behavior stay unchanged
- branch `174-evidence-freshness-publication-trust` is pushed at `7f2c82c26dc83bbc09fbf9e732d5644cdd143113` and targets `dev`

Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de>
Reviewed-on: #205
2026-04-04 11:31:27 +00:00
256 changed files with 23790 additions and 1477 deletions

View File

@ -125,6 +125,20 @@ ## Active Technologies
- PostgreSQL with existing `operation_runs`, `managed_tenant_onboarding_sessions`, tenant records, and workspace records; no schema changes (172-deferred-operator-surfaces-retrofit)
- 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 (173-tenant-dashboard-truth-alignment)
- PostgreSQL unchanged; no new persistence, cache store, or durable dashboard summary artifac (173-tenant-dashboard-truth-alignment)
- 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 (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)
- 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)
@ -144,8 +158,8 @@ ## Code Style
PHP 8.4.15: Follow standard conventions
## Recent Changes
- 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
- 172-deferred-operator-surfaces-retrofit: Added PHP 8.4, Laravel 12, Livewire v4, Filament v5, Tailwind CSS v4 + `laravel/framework`, `filament/filament`, `livewire/livewire`, `pestphp/pest`
- 171-operations-naming-consolidation: Added PHP 8.4, Laravel 12, Livewire v4, Filament v5, Tailwind CSS v4 + `laravel/framework`, `filament/filament`, `livewire/livewire`, `pestphp/pest`
- 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
- 181-restore-safety-integrity: Added PHP 8.4, Laravel 12, Blade, Filament v5, Livewire v4 + Filament v5, Livewire v4, Pest v4, Laravel Sail, existing `RestoreRunResource`, `RestoreService`, `RestoreRiskChecker`, `RestoreDiffGenerator`, `OperationRunResource`, `TenantlessOperationRunViewer`, shared badge infrastructure, and existing RBAC or write-gate helpers
- 178-ops-truth-alignment: Added PHP 8.4.15 + Laravel 12, Filament v5, Livewire v4, Pest v4, existing `OperationRun`, `OperationLifecyclePolicy`, `OperationRunFreshnessState`, `OperationUxPresenter`, `OperationRunLinks`, `ActiveRuns`, `StuckRunClassifier`, `WorkspaceOverviewBuilder`, dashboard widgets, workspace widgets, and system ops pages
<!-- MANUAL ADDITIONS START -->
<!-- MANUAL ADDITIONS END -->

View File

@ -1,32 +1,20 @@
<!--
Sync Impact Report
- Version change: 1.14.0 -> 2.0.0
- Version change: 2.0.0 -> 2.1.0
- Modified principles:
- Filament UI - Action Surface Contract -> Operator-Facing UI/UX Constitution v1 / Filament UI - Action Surface Contract
- Filament UI - Layout & Information Architecture Standards (UX-001) -> Operator-Facing UI/UX Constitution v1 / Filament UI - Layout & Information Architecture Standards (UX-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)
- UX-001 (Layout & IA): header action line strengthened from SHOULD to MUST
with cross-reference to new HDR-001
- Added sections:
- Operator-Facing UI/UX Constitution v1 (UI-CONST-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
- Header Action Discipline & Contextual Navigation (HDR-001)
- Removed sections: None
- Templates requiring updates:
- ✅ .specify/memory/constitution.md
- ✅ .specify/templates/spec-template.md
- ✅ .specify/templates/plan-template.md
- ✅ .specify/templates/tasks-template.md
- ✅ docs/product/principles.md
- ✅ docs/product/standards/README.md
- ✅ docs/HANDOVER.md
- ✅ .specify/templates/plan-template.md (Constitution Check: HDR-001 added)
- ✅ .specify/templates/tasks-template.md (Filament UI section: HDR-001 added)
- ⚠ .specify/templates/spec-template.md (no changes needed; existing
UI/UX Surface Classification and Operator Surface Contract tables already
cover header action placement implicitly)
- Commands checked:
- N/A `.specify/templates/commands/*.md` directory is not present in this repo
- 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.
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.
- 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.
- 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)
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.
- Exceptions are catalogued, justified, and tested.
- 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
@ -690,6 +794,9 @@ #### Appendix B - Feature Review Checklist
- Critical truth is visible.
- Scanability is preserved.
- 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
@ -704,6 +811,9 @@ #### Appendix C - Red Flags for Future PRs
- Queue surfaces throw the operator out of context through row click.
- Critical health or operability truth is hidden by default.
- 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
- Inventory MUST store only metadata + whitelisted `meta_jsonb`.
@ -787,4 +897,4 @@ ### Versioning Policy (SemVer)
- **MINOR**: new principle/section or materially expanded guidance.
- **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): 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 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
### 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 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),
- 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,
- 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),

View File

@ -6,19 +6,20 @@
use App\Filament\Clusters\Inventory\InventoryCluster;
use App\Filament\Concerns\ResolvesPanelTenantContext;
use App\Filament\Resources\InventoryItemResource;
use App\Filament\Widgets\Inventory\InventoryKpiHeader;
use App\Models\OperationRun;
use App\Models\Tenant;
use App\Models\User;
use App\Services\Auth\CapabilityResolver;
use App\Services\Inventory\CoverageCapabilitiesResolver;
use App\Support\Auth\Capabilities;
use App\Support\Badges\BadgeCatalog;
use App\Support\Badges\BadgeDomain;
use App\Support\Badges\BadgeRenderer;
use App\Support\Badges\TagBadgeCatalog;
use App\Support\Badges\TagBadgeDomain;
use App\Support\Badges\TagBadgeRenderer;
use App\Support\Inventory\InventoryPolicyTypeMeta;
use App\Support\Inventory\TenantCoverageTruth;
use App\Support\Inventory\TenantCoverageTruthResolver;
use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration;
use App\Support\Ui\ActionSurface\ActionSurfaceDefaults;
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 ?TenantCoverageTruth $cachedCoverageTruth = null;
public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
{
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::ListRowMoreMenu, 'Derived coverage rows do not expose row 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
@ -110,9 +113,12 @@ protected function getHeaderWidgets(): array
public function table(Table $table): Table
{
return $table
->persistFiltersInSession()
->persistSearchInSession()
->persistSortInSession()
->searchable()
->searchPlaceholder('Search by policy type or label')
->defaultSort('label')
->searchPlaceholder('Search by type or label')
->defaultSort('follow_up_priority')
->defaultPaginationPageOption(50)
->paginated(\App\Support\Filament\TablePaginationProfiles::customPage())
->records(function (
@ -142,14 +148,16 @@ public function table(Table $table): Table
);
})
->columns([
TextColumn::make('type')
->label('Type')
->sortable()
->fontFamily(FontFamily::Mono)
->copyable()
->wrap(),
TextColumn::make('coverage_state')
->label('Coverage state')
->badge()
->formatStateUsing(BadgeRenderer::label(BadgeDomain::InventoryCoverageState))
->color(BadgeRenderer::color(BadgeDomain::InventoryCoverageState))
->icon(BadgeRenderer::icon(BadgeDomain::InventoryCoverageState))
->iconColor(BadgeRenderer::iconColor(BadgeDomain::InventoryCoverageState))
->sortable(),
TextColumn::make('label')
->label('Label')
->label('Type')
->sortable()
->badge()
->formatStateUsing(function (?string $state, array $record): string {
@ -179,17 +187,29 @@ public function table(Table $table): Table
return $spec->iconColor ?? $spec->color;
})
->wrap(),
TextColumn::make('risk')
->label('Risk')
TextColumn::make('follow_up_guidance')
->label('Follow-up guidance')
->wrap()
->toggleable(),
TextColumn::make('observed_item_count')
->label('Observed items')
->numeric()
->sortable(),
TextColumn::make('category')
->badge()
->formatStateUsing(BadgeRenderer::label(BadgeDomain::PolicyRisk))
->color(BadgeRenderer::color(BadgeDomain::PolicyRisk))
->icon(BadgeRenderer::icon(BadgeDomain::PolicyRisk))
->iconColor(BadgeRenderer::iconColor(BadgeDomain::PolicyRisk)),
->formatStateUsing(fn (?string $state): string => TagBadgeCatalog::spec(TagBadgeDomain::PolicyCategory, $state)->label)
->color(fn (?string $state): string => TagBadgeCatalog::spec(TagBadgeDomain::PolicyCategory, $state)->color)
->icon(fn (?string $state): ?string => TagBadgeCatalog::spec(TagBadgeDomain::PolicyCategory, $state)->icon)
->iconColor(function (?string $state): ?string {
$spec = TagBadgeCatalog::spec(TagBadgeDomain::PolicyCategory, $state);
return $spec->iconColor ?? $spec->color;
})
->toggleable()
->wrap(),
TextColumn::make('restore')
->label('Restore')
->badge()
->state(fn (array $record): ?string => $record['restore'])
->formatStateUsing(function (?string $state): string {
return filled($state)
? BadgeCatalog::spec(BadgeDomain::PolicyRestoreMode, $state)->label
@ -213,20 +233,7 @@ public function table(Table $table): Table
$spec = BadgeCatalog::spec(BadgeDomain::PolicyRestoreMode, $state);
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(),
IconColumn::make('dependencies')
->label('Dependencies')
@ -237,10 +244,31 @@ public function table(Table $table): Table
->falseColor('gray')
->alignCenter()
->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())
->emptyStateHeading('No coverage entries match this view')
->emptyStateDescription('Clear the current search or filters to return to the full coverage matrix.')
->emptyStateHeading('No coverage rows match this report')
->emptyStateDescription('Clear the current search or filters to return to the full tenant coverage report.')
->emptyStateIcon('heroicon-o-funnel')
->emptyStateActions([
Action::make('clear_filters')
@ -261,6 +289,14 @@ public function table(Table $table): Table
protected function tableFilters(): array
{
$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')
->label('Category')
->options($this->categoryFilterOptions()),
@ -279,84 +315,36 @@ protected function tableFilters(): array
* @return Collection<string, array{
* __key: string,
* key: string,
* segment: string,
* type: string,
* segment: string,
* label: 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,
* risk: string,
* source_order: int
* risk: ?string,
* dependencies: bool,
* is_basis_payload_backed: bool
* }>
*/
protected function coverageRows(): Collection
{
$resolver = app(CoverageCapabilitiesResolver::class);
$truth = $this->coverageTruth();
$supported = $this->mapCoverageRows(
rows: InventoryPolicyTypeMeta::supported(),
segment: 'policy',
sourceOrderOffset: 0,
resolver: $resolver,
);
if (! $truth instanceof TenantCoverageTruth) {
return collect();
}
return $supported->merge($this->mapCoverageRows(
rows: InventoryPolicyTypeMeta::foundations(),
segment: 'foundation',
sourceOrderOffset: $supported->count(),
resolver: $resolver,
));
}
/**
* @param array<int, array<string, mixed>> $rows
* @return Collection<string, array{
* __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,
],
];
});
return collect($truth->rows)
->mapWithKeys(static fn ($row): array => [
$row->key => $row->toArray(),
]);
}
/**
@ -367,6 +355,7 @@ protected function mapCoverageRows(
protected function filterRows(Collection $rows, ?string $search, array $filters): Collection
{
$normalizedSearch = Str::lower(trim((string) $search));
$coverageState = $filters['coverage_state']['value'] ?? null;
$category = $filters['category']['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(
filled($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
{
$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) {
return $rows->sortBy('source_order');
return $rows;
}
$records = $rows->all();
uasort($records, function (array $left, array $right) use ($sortColumn, $sortDirection): int {
$comparison = strnatcasecmp(
(string) ($left[$sortColumn] ?? ''),
(string) ($right[$sortColumn] ?? ''),
);
$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) ($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) {
$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;
@ -468,4 +474,99 @@ protected function restoreFilterOptions(): array
})
->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

@ -10,6 +10,7 @@
use App\Models\Workspace;
use App\Services\Auth\WorkspaceCapabilityResolver;
use App\Support\Auth\Capabilities;
use App\Support\Navigation\CanonicalNavigationContext;
use App\Support\OperateHub\OperateHubShell;
use App\Support\Workspaces\WorkspaceContext;
use BackedEnum;
@ -79,9 +80,23 @@ protected function getHeaderWidgets(): array
*/
protected function getHeaderActions(): array
{
return app(OperateHubShell::class)->headerActions(
$actions = app(OperateHubShell::class)->headerActions(
scopeActionName: 'operate_hub_scope_alerts',
returnActionName: 'operate_hub_return_alerts',
);
$navigationContext = CanonicalNavigationContext::fromRequest(request());
if ($navigationContext?->backLinkLabel !== null && $navigationContext->backLinkUrl !== null) {
array_splice($actions, 1, 0, [
Action::make('operate_hub_back_to_origin_alerts')
->label($navigationContext->backLinkLabel)
->icon('heroicon-o-arrow-left')
->color('gray')
->url($navigationContext->backLinkUrl),
]);
}
return $actions;
}
}

View File

@ -7,9 +7,11 @@
use App\Filament\Resources\EvidenceSnapshotResource;
use App\Models\EvidenceSnapshot;
use App\Models\Tenant;
use App\Models\TenantReview;
use App\Models\User;
use App\Support\Badges\BadgeCatalog;
use App\Support\Badges\BadgeDomain;
use App\Support\TenantReviewStatus;
use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration;
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceInspectAffordance;
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile;
@ -90,14 +92,30 @@ public function mount(): void
}
$snapshots = $query->get()->unique('tenant_id')->values();
$currentReviewTenantIds = TenantReview::query()
->where('workspace_id', $workspaceId)
->whereIn('tenant_id', $snapshots->pluck('tenant_id')->map(static fn (mixed $tenantId): int => (int) $tenantId)->all())
->whereIn('status', [
TenantReviewStatus::Draft->value,
TenantReviewStatus::Ready->value,
TenantReviewStatus::Published->value,
])
->pluck('tenant_id')
->mapWithKeys(static fn (mixed $tenantId): array => [(int) $tenantId => true])
->all();
$this->rows = $snapshots->map(function (EvidenceSnapshot $snapshot): array {
$this->rows = $snapshots->map(function (EvidenceSnapshot $snapshot) use ($currentReviewTenantIds): array {
$truth = $this->snapshotTruth($snapshot);
$freshnessSpec = BadgeCatalog::spec(BadgeDomain::GovernanceArtifactFreshness, $truth->freshnessState);
$tenantId = (int) $snapshot->tenant_id;
$hasCurrentReview = $currentReviewTenantIds[$tenantId] ?? false;
$nextStep = ! $hasCurrentReview && $truth->contentState === 'trusted' && $truth->freshnessState === 'current'
? 'Create a current review from this evidence snapshot'
: $truth->nextStepText();
return [
'tenant_name' => $snapshot->tenant?->name ?? 'Unknown tenant',
'tenant_id' => (int) $snapshot->tenant_id,
'tenant_id' => $tenantId,
'snapshot_id' => (int) $snapshot->getKey(),
'completeness_state' => (string) $snapshot->completeness_state,
'generated_at' => $snapshot->generated_at?->toDateTimeString(),
@ -114,7 +132,7 @@ public function mount(): void
'color' => $freshnessSpec->color,
'icon' => $freshnessSpec->icon,
],
'next_step' => $truth->nextStepText(),
'next_step' => $nextStep,
'view_url' => $snapshot->tenant
? EvidenceSnapshotResource::getUrl('view', ['record' => $snapshot], tenant: $snapshot->tenant)
: null,

View File

@ -38,6 +38,7 @@
use Filament\Tables\Contracts\HasTable;
use Filament\Tables\Filters\SelectFilter;
use Filament\Tables\Table;
use Illuminate\Contracts\View\View;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Collection;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
@ -49,6 +50,8 @@ class FindingExceptionsQueue extends Page implements HasTable
public ?int $selectedFindingExceptionId = null;
public bool $showSelectedExceptionSummary = false;
protected static bool $isDiscovered = false;
protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-shield-exclamation';
@ -116,11 +119,12 @@ public static function canAccess(): bool
public function mount(): void
{
$this->selectedFindingExceptionId = is_numeric(request()->query('exception')) ? (int) request()->query('exception') : null;
$this->showSelectedExceptionSummary = $this->selectedFindingExceptionId !== null;
$this->mountInteractsWithTable();
$this->applyRequestedTenantPrefilter();
if ($this->selectedFindingExceptionId !== null) {
$this->selectedFindingException();
$this->resolveSelectedFindingException($this->selectedFindingExceptionId);
}
}
@ -141,6 +145,7 @@ protected function getHeaderActions(): array
$this->removeTableFilter('status');
$this->removeTableFilter('current_validity_state');
$this->selectedFindingExceptionId = null;
$this->showSelectedExceptionSummary = false;
$this->resetTable();
});
@ -165,6 +170,7 @@ protected function getHeaderActions(): array
->visible(fn (): bool => $this->selectedFindingExceptionId !== null)
->action(function (): void {
$this->selectedFindingExceptionId = null;
$this->showSelectedExceptionSummary = false;
});
$actions[] = Action::make('open_selected_exception')
@ -325,8 +331,31 @@ public function table(Table $table): Table
->label('Inspect exception')
->icon('heroicon-o-eye')
->color('gray')
->action(function (FindingException $record): void {
->before(function (FindingException $record): void {
$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([])
@ -343,6 +372,7 @@ public function table(Table $table): Table
$this->removeTableFilter('status');
$this->removeTableFilter('current_validity_state');
$this->selectedFindingExceptionId = null;
$this->showSelectedExceptionSummary = false;
$this->resetTable();
}),
]);
@ -354,15 +384,7 @@ public function selectedFindingException(): ?FindingException
return null;
}
$record = $this->queueBaseQuery()
->whereKey($this->selectedFindingExceptionId)
->first();
if (! $record instanceof FindingException) {
throw new NotFoundHttpException;
}
return $record;
return $this->resolveSelectedFindingException($this->selectedFindingExceptionId);
}
public function selectedExceptionUrl(): ?string
@ -508,6 +530,30 @@ private function hasActiveQueueFilters(): bool
|| 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
{
$finding = $record->relationLoaded('finding')

View File

@ -74,6 +74,8 @@ public function mount(): void
{
$this->navigationContextPayload = is_array(request()->query('nav')) ? request()->query('nav') : null;
$this->applyRequestedTenantScope();
app(CanonicalAdminTenantFilterState::class)->sync(
$this->getTableFiltersSessionKey(),
['type', 'initiator_name'],
@ -187,6 +189,17 @@ public function table(Table $table): Table
});
}
private function applyRequestedTenantScope(): void
{
if (! $this->shouldForceWorkspaceWideTenantScope()) {
return;
}
Filament::setTenant(null, true);
app(WorkspaceContext::class)->clearLastTenantId(request());
}
/**
* @return array{likely_stale:int,reconciled:int}
*/
@ -220,6 +233,8 @@ private function applyActiveTab(Builder $query): Builder
{
return match ($this->activeTab) {
'active' => $query->healthyActive(),
OperationRun::PROBLEM_CLASS_ACTIVE_STALE_ATTENTION => $query->activeStaleAttention(),
OperationRun::PROBLEM_CLASS_TERMINAL_FOLLOW_UP => $query->terminalFollowUp(),
'blocked' => $query->dashboardNeedsFollowUp(),
'succeeded' => $query
->where('status', OperationRunStatus::Completed->value)
@ -258,18 +273,45 @@ private function scopedSummaryQuery(): ?Builder
private function applyRequestedDashboardPrefilter(): void
{
$requestedTenantId = request()->query('tenant_id');
if (! $this->shouldForceWorkspaceWideTenantScope()) {
$requestedTenantId = request()->query('tenant_id');
if (is_numeric($requestedTenantId)) {
$tenantId = (string) $requestedTenantId;
$this->tableFilters['tenant_id']['value'] = $tenantId;
$this->tableDeferredFilters['tenant_id']['value'] = $tenantId;
if (is_numeric($requestedTenantId)) {
$tenantId = (string) $requestedTenantId;
$this->tableFilters['tenant_id']['value'] = $tenantId;
$this->tableDeferredFilters['tenant_id']['value'] = $tenantId;
}
}
$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');
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;
}
}
private function shouldForceWorkspaceWideTenantScope(): bool
{
return request()->query('tenant_scope') === 'all';
}
}

View File

@ -22,6 +22,7 @@
use App\Support\OpsUx\RunDetailPolling;
use App\Support\ReasonTranslation\ReasonPresenter;
use App\Support\RedactionIntegrity;
use App\Support\RestoreSafety\RestoreSafetyCopy;
use App\Support\Tenants\ReferencedTenantLifecyclePresentation;
use App\Support\Tenants\TenantInteractionLane;
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
*/

View File

@ -87,7 +87,6 @@
use Illuminate\Database\QueryException;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Gate;
use Illuminate\Validation\ValidationException;
use InvalidArgumentException;
use Livewire\Attributes\Locked;
@ -3334,7 +3333,7 @@ private function canInspectOperationRun(OperationRun $run): bool
return false;
}
return Gate::forUser($user)->allows('view', $run);
return $user->can('view', $run);
}
public function verificationSucceeded(): bool

View File

@ -18,6 +18,7 @@
use App\Services\OperationRunService;
use App\Services\Operations\BulkSelectionIdentity;
use App\Support\Auth\Capabilities;
use App\Support\BackupQuality\BackupQualityResolver;
use App\Support\Badges\BadgeDomain;
use App\Support\Badges\BadgeRenderer;
use App\Support\Filament\TablePaginationProfiles;
@ -161,6 +162,15 @@ public static function table(Table $table): Table
->persistFiltersInSession()
->persistSearchInSession()
->persistSortInSession()
->modifyQueryUsing(fn (Builder $query): Builder => $query->with([
'items' => fn ($itemQuery) => $itemQuery->select([
'id',
'backup_set_id',
'payload',
'metadata',
'assignments',
]),
]))
->columns([
Tables\Columns\TextColumn::make('name')
->searchable()
@ -172,6 +182,11 @@ public static function table(Table $table): Table
->icon(BadgeRenderer::icon(BadgeDomain::BackupSetStatus))
->iconColor(BadgeRenderer::iconColor(BadgeDomain::BackupSetStatus)),
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('completed_at')->label('Completed')->dateTime()->since()->sortable(),
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);
$relatedContext = static::relatedContextEntries($record);
$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')
->header(new SummaryHeaderData(
@ -667,14 +688,37 @@ private static function enterpriseDetailPage(BackupSet $record): EnterpriseDetai
statusBadges: [
$factory->statusBadge($statusSpec->label, $statusSpec->color, $statusSpec->icon, $statusSpec->iconColor),
$factory->statusBadge($isArchived ? 'Archived' : 'Active', $isArchived ? 'warning' : 'success'),
$qualityBadge,
],
keyFacts: [
$factory->keyFact('Items', $record->item_count),
$factory->keyFact('Backup quality', $qualitySummary->compactSummary),
$factory->keyFact('Created by', $record->created_by),
$factory->keyFact('Completed', static::formatDetailTimestamp($record->completed_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(
$factory->factsSection(
@ -700,11 +744,12 @@ private static function enterpriseDetailPage(BackupSet $record): EnterpriseDetai
->addSupportingCard(
$factory->supportingFactsCard(
kind: 'status',
title: 'Recovery readiness',
title: 'Backup quality counts',
items: [
$factory->keyFact('Backup state', $statusSpec->label, badge: $factory->statusBadge($statusSpec->label, $statusSpec->color, $statusSpec->icon, $statusSpec->iconColor)),
$factory->keyFact('Archived', $isArchived),
$factory->keyFact('Metadata keys', $metadataKeyCount),
$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->supportingFactsCard(
@ -740,4 +785,29 @@ private static function formatDetailTimestamp(mixed $value): string
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\Services\OperationRunService;
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;
@ -279,11 +280,32 @@ public function table(Table $table): Table
->sortable()
->searchable()
->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')
->label('Version')
->badge()
->default('—')
->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')
->label('Type')
->badge()
@ -480,6 +502,11 @@ private function backupItemInspectUrl(BackupItem $record): ?string
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
{
$recordId = $this->normalizeBackupItemKey($record);

View File

@ -5,6 +5,7 @@
use App\Filament\Support\VerificationReportChangeIndicator;
use App\Filament\Support\VerificationReportViewer;
use App\Models\OperationRun;
use App\Models\RestoreRun;
use App\Models\Tenant;
use App\Models\User;
use App\Models\VerificationCheckAcknowledgement;
@ -16,6 +17,9 @@
use App\Support\Filament\FilterOptionCatalog;
use App\Support\Filament\FilterPresets;
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\RelatedNavigationResolver;
use App\Support\OperateHub\OperateHubShell;
@ -27,6 +31,7 @@
use App\Support\OpsUx\RunDurationInsights;
use App\Support\OpsUx\SummaryCountsNormalizer;
use App\Support\ReasonTranslation\ReasonPresenter;
use App\Support\RestoreSafety\RestoreSafetyResolver;
use App\Support\Tenants\ReferencedTenantLifecyclePresentation;
use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration;
use App\Support\Ui\ActionSurface\ActionSurfaceDefaults;
@ -264,6 +269,7 @@ private static function enterpriseDetailPage(OperationRun $record): \App\Support
$artifactTruth = static::artifactTruthEnvelope($record);
$operatorExplanation = $artifactTruth?->operatorExplanation;
$primaryNextStep = static::resolvePrimaryNextStep($record, $artifactTruth, $operatorExplanation);
$restoreContinuation = static::restoreContinuation($record);
$supportingGroups = static::supportingGroups(
record: $record,
factory: $factory,
@ -316,6 +322,13 @@ private static function enterpriseDetailPage(OperationRun $record): \App\Support
),
)
: 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['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)) {
$builder->addSection(
$factory->viewSection(
@ -766,7 +794,7 @@ private static function artifactTruthFact(
private static function decisionAttentionNote(OperationRun $record): ?string
{
return null;
return OperationUxPresenter::decisionAttentionNote($record);
}
private static function detailHintUnlessDuplicate(?string $hint, ?string $duplicateOf): ?string
@ -1169,6 +1197,106 @@ private static function reconciliationPayload(OperationRun $record): array
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
{
if (! $value instanceof \Illuminate\Support\Carbon) {
@ -1210,6 +1338,58 @@ private static function surfaceGuidance(OperationRun $record, bool $fresh = fals
: 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{
* key: string,

View File

@ -21,6 +21,9 @@
use App\Services\OperationRunService;
use App\Services\Operations\BulkSelectionIdentity;
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\TagBadgeRenderer;
use App\Support\Baselines\PolicyVersionCapturePurpose;
@ -107,7 +110,7 @@ public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
->satisfy(ActionSurfaceSlot::InspectAffordance, ActionSurfaceInspectAffordance::ClickableRow->value)
->satisfy(ActionSurfaceSlot::ListRowMoreMenu, 'Secondary row 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.');
}
@ -129,6 +132,37 @@ public static function infolist(Schema $schema): Schema
->color(TagBadgeRenderer::color(TagBadgeDomain::Platform)),
Infolists\Components\TextEntry::make('created_by')->label('Actor'),
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')
->schema([
Infolists\Components\ViewEntry::make('related_context')
@ -528,6 +562,19 @@ public static function table(Table $table): Table
->searchable()
->getStateUsing(fn (PolicyVersion $record): string => static::resolvedDisplayName($record)),
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')
->badge()
->formatStateUsing(TagBadgeRenderer::label(TagBadgeDomain::PolicyType))
@ -536,7 +583,7 @@ public static function table(Table $table): Table
->badge()
->formatStateUsing(TagBadgeRenderer::label(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(),
])
->filters([
@ -578,7 +625,7 @@ public static function table(Table $table): Table
return $resolver->isMember($user, $tenant);
})
->disabled(function (PolicyVersion $record): bool {
if (($record->metadata['source'] ?? null) === 'metadata_only') {
if (static::policyVersionQualitySummary($record)->snapshotMode === 'metadata_only') {
return true;
}
@ -617,7 +664,7 @@ public static function table(Table $table): Table
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).';
}
@ -642,7 +689,7 @@ public static function table(Table $table): Table
abort(403);
}
if (($record->metadata['source'] ?? null) === 'metadata_only') {
if (static::policyVersionQualitySummary($record)->snapshotMode === 'metadata_only') {
Notification::make()
->title('Restore disabled for metadata-only snapshot')
->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 = [
'source' => 'policy_version',
'snapshot_source' => $record->snapshotSource(),
'display_name' => $policy->display_name,
'policy_version_id' => $record->id,
'policy_version_number' => $record->version_number,
'version_captured_at' => $record->captured_at?->toIso8601String(),
'redaction_version' => $record->redaction_version,
'warnings' => $record->warningMessages(),
'assignments_fetch_failed' => $record->assignmentsFetchFailed(),
'has_orphaned_assignments' => $record->hasOrphanedAssignments(),
];
$integrityWarning = RedactionIntegrity::noteForPolicyVersion($record);
@ -891,7 +942,13 @@ public static function table(Table $table): Table
])
->emptyStateHeading('No policy versions')
->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
@ -980,6 +1037,23 @@ private static function primaryRelatedAction(): Actions\Action
->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
{
return app(RelatedNavigationResolver::class)

View File

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

View File

@ -27,6 +27,7 @@
use App\Services\OperationRunService;
use App\Services\Operations\BulkSelectionIdentity;
use App\Support\Auth\Capabilities;
use App\Support\BackupQuality\BackupQualityResolver;
use App\Support\Badges\BadgeDomain;
use App\Support\Badges\BadgeRenderer;
use App\Support\Filament\FilterOptionCatalog;
@ -37,6 +38,10 @@
use App\Support\Rbac\UiEnforcement;
use App\Support\RestoreRunIdempotency;
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\Enums\ActionSurfaceInspectAffordance;
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile;
@ -124,24 +129,8 @@ public static function form(Schema $schema): Schema
->schema([
Forms\Components\Select::make('backup_set_id')
->label('Backup set')
->options(function () {
$tenantId = static::resolveTenantContextForCurrentPanelOrFail()->getKey();
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];
});
})
->options(fn () => static::restoreBackupSetOptions())
->helperText(fn (Get $get): string => static::restoreBackupSetHelperText($get('backup_set_id')))
->reactive()
->afterStateUpdated(function (Set $set): void {
$set('scope_mode', 'all');
@ -159,7 +148,7 @@ public static function form(Schema $schema): Schema
->bulkToggleable()
->reactive()
->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')
->description('Some source groups do not exist in the target tenant. Map them or choose Skip.')
->schema(function (Get $get): array {
@ -187,7 +176,7 @@ public static function form(Schema $schema): Schema
$cacheNotice = match (true) {
! $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,
};
@ -306,52 +295,43 @@ public static function getWizardSteps(): array
{
return [
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([
Forms\Components\Select::make('backup_set_id')
->label('Backup set')
->options(function () {
$tenantId = static::resolveTenantContextForCurrentPanelOrFail()->getKey();
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];
});
})
->options(fn () => static::restoreBackupSetOptions())
->helperText(fn (Get $get): string => static::restoreBackupSetHelperText($get('backup_set_id')))
->reactive()
->afterStateUpdated(function (Set $set, Get $get): void {
$set('scope_mode', 'all');
$set('backup_item_ids', null);
$set('group_mapping', static::groupMappingPlaceholders(
$groupMapping = static::groupMappingPlaceholders(
backupSetId: $get('backup_set_id'),
scopeMode: 'all',
selectedItemIds: null,
tenant: static::resolveTenantContextForCurrentPanel(),
));
);
$set('scope_mode', 'all');
$set('backup_item_ids', null);
$set('group_mapping', $groupMapping);
$set('is_dry_run', true);
$set('acknowledged_impact', false);
$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);
$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']);
})
->required(),
]),
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([
Forms\Components\Radio::make('scope_mode')
->label('Scope')
@ -367,27 +347,45 @@ public static function getWizardSteps(): array
$set('is_dry_run', true);
$set('acknowledged_impact', false);
$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') {
$set('backup_item_ids', null);
$set('group_mapping', static::groupMappingPlaceholders(
$groupMapping = static::groupMappingPlaceholders(
backupSetId: $backupSetId,
scopeMode: 'all',
selectedItemIds: null,
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;
}
$set('group_mapping', []);
$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(),
Forms\Components\Select::make('backup_item_ids')
@ -414,12 +412,21 @@ public static function getWizardSteps(): array
$set('is_dry_run', true);
$set('acknowledged_impact', false);
$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);
$draft = static::synchronizeRestoreSafetyDraft([
...static::draftDataSnapshot($get),
'backup_item_ids' => $selectedItemIds ?? [],
'group_mapping' => static::groupMappingPlaceholders(
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')
->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')
->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')
->description('Some source groups do not exist in the target tenant. Map them or choose Skip.')
->schema(function (Get $get): array {
@ -482,7 +489,7 @@ public static function getWizardSteps(): array
$cacheNotice = match (true) {
! $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,
};
@ -495,13 +502,16 @@ public static function getWizardSteps(): array
->placeholder('SKIP or target group Object ID (GUID)')
->rules([new SkipOrUuidRule])
->reactive()
->afterStateUpdated(function (Set $set): void {
$set('check_summary', null);
$set('check_results', []);
$set('checks_ran_at', null);
$set('preview_summary', null);
$set('preview_diffs', []);
$set('preview_ran_at', null);
->afterStateUpdated(function (Set $set, Get $get): void {
$set('is_dry_run', true);
$set('acknowledged_impact', false);
$set('tenant_confirm', null);
$draft = static::synchronizeRestoreSafetyDraft(static::draftDataSnapshot($get));
$set('scope_basis', $draft['scope_basis']);
$set('check_invalidation_reasons', $draft['check_invalidation_reasons']);
$set('preview_invalidation_reasons', $draft['preview_invalidation_reasons']);
})
->required()
->suffixAction(
@ -554,10 +564,16 @@ public static function getWizardSteps(): array
Step::make('Safety & Conflict Checks')
->description('Is this dangerous?')
->schema([
Forms\Components\Hidden::make('scope_basis')
->default(null),
Forms\Components\Hidden::make('check_summary')
->default(null),
Forms\Components\Hidden::make('checks_ran_at')
->default(null),
Forms\Components\Hidden::make('check_basis')
->default(null),
Forms\Components\Hidden::make('check_invalidation_reasons')
->default([]),
Forms\Components\ViewField::make('check_results')
->label('Checks')
->default([])
@ -565,6 +581,7 @@ public static function getWizardSteps(): array
->viewData(fn (Get $get): array => [
'summary' => $get('check_summary'),
'ranAt' => $get('checks_ran_at'),
...static::wizardSafetyState(static::draftDataSnapshot($get)),
])
->hintActions([
Actions\Action::make('run_restore_checks')
@ -614,9 +631,23 @@ public static function getWizardSteps(): array
groupMapping: $groupMapping,
);
$set('check_summary', $outcome['summary'] ?? [], shouldCallUpdatedHooks: true);
$set('check_results', $outcome['results'] ?? [], shouldCallUpdatedHooks: true);
$set('checks_ran_at', now()->toIso8601String(), shouldCallUpdatedHooks: true);
$ranAt = now('UTC')->toIso8601String();
$draft = [
...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'] ?? [];
$blockers = (int) ($summary['blocking'] ?? 0);
@ -644,6 +675,8 @@ public static function getWizardSteps(): array
$set('check_summary', null, shouldCallUpdatedHooks: true);
$set('check_results', [], 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.'),
@ -656,6 +689,10 @@ public static function getWizardSteps(): array
Forms\Components\Hidden::make('preview_ran_at')
->default(null)
->required(),
Forms\Components\Hidden::make('preview_basis')
->default(null),
Forms\Components\Hidden::make('preview_invalidation_reasons')
->default([]),
Forms\Components\ViewField::make('preview_diffs')
->label('Preview')
->default([])
@ -663,6 +700,7 @@ public static function getWizardSteps(): array
->viewData(fn (Get $get): array => [
'summary' => $get('preview_summary'),
'ranAt' => $get('preview_ran_at'),
...static::wizardSafetyState(static::draftDataSnapshot($get)),
])
->hintActions([
Actions\Action::make('run_restore_preview')
@ -711,10 +749,23 @@ public static function getWizardSteps(): array
$summary = $outcome['summary'] ?? [];
$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_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);
$policiesTotal = (int) ($summary['policies_total'] ?? 0);
@ -737,6 +788,8 @@ public static function getWizardSteps(): array
$set('preview_summary', null, shouldCallUpdatedHooks: true);
$set('preview_diffs', [], 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.'),
@ -760,24 +813,66 @@ public static function getWizardSteps(): array
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')
->label('Preview only (dry-run)')
->default(true)
->reactive()
->disabled(function (Get $get): bool {
if (! filled($get('checks_ran_at'))) {
return true;
}
$state = static::wizardSafetyState(static::draftDataSnapshot($get));
$readiness = $state['executionReadiness'];
$summary = $get('check_summary');
if (! is_array($summary)) {
return false;
}
return (int) ($summary['blocking'] ?? 0) > 0;
return ! is_array($readiness) || ! (bool) ($readiness['allowed'] ?? false);
})
->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')
->label('I reviewed the impact (checks + preview)')
->accepted()
@ -1290,11 +1385,11 @@ public static function infolist(Schema $schema): Schema
Infolists\Components\ViewEntry::make('preview')
->label('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')
->label('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) {
$meta = static::typeMeta($item->policy_type);
$qualitySummary = static::backupItemQualitySummary($item);
$typeLabel = $meta['label'] ?? $item->policy_type;
$category = $meta['category'] ?? 'Policies';
$restore = $meta['restore'] ?? 'enabled';
@ -1380,6 +1476,7 @@ private static function restoreItemOptionData(?int $backupSetId): array
$category,
$typeLabel,
$platform,
'quality: '.$qualitySummary->compactSummary,
"restore: {$restore}",
$versionNumber ? "version: {$versionNumber}" : null,
$item->hasAssignments() ? "assignments: {$item->assignment_count}" : null,
@ -1445,12 +1542,111 @@ private static function restoreItemGroupedOptions(?int $backupSetId): array
]));
$groups[$groupLabel] ??= [];
$groups[$groupLabel][$item->id] = $item->resolvedDisplayName();
$groups[$groupLabel][$item->id] = static::restoreItemSelectionLabel($item);
}
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
{
$tenant = static::resolveTenantContextForCurrentPanel();
@ -1471,37 +1667,6 @@ public static function createRestoreRun(array $data): RestoreRun
abort(403);
}
try {
app(WriteGateInterface::class)->evaluate($tenant, 'restore.execute');
} catch (ProviderAccessHardeningRequired $e) {
app(\App\Services\Intune\AuditLogger::class)->log(
tenant: $tenant,
action: 'intune_rbac.write_blocked',
status: 'blocked',
actorId: (int) $user->getKey(),
actorEmail: $user->email,
actorName: $user->name,
resourceType: 'restore_run',
context: [
'metadata' => [
'operation_type' => 'restore.execute',
'reason_code' => $e->reasonCode,
'backup_set_id' => $data['backup_set_id'] ?? null,
],
],
);
Notification::make()
->title('Write operation blocked')
->body($e->reasonMessage)
->danger()
->send();
throw ValidationException::withMessages([
'backup_set_id' => $e->reasonMessage,
]);
}
/** @var BackupSet $backupSet */
$backupSet = BackupSet::findOrFail($data['backup_set_id']);
@ -1520,6 +1685,26 @@ public static function createRestoreRun(array $data): RestoreRun
$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;
@ -1532,27 +1717,71 @@ public static function createRestoreRun(array $data): RestoreRun
$highlanderLabel = (string) ($tenant->name ?? $tenantIdentifier ?? $tenant->getKey());
if (! $isDryRun) {
if (! is_array($checkSummary) || ! filled($checksRanAt)) {
try {
app(WriteGateInterface::class)->evaluate($tenant, 'restore.execute');
} catch (ProviderAccessHardeningRequired $e) {
app(\App\Services\Intune\AuditLogger::class)->log(
tenant: $tenant,
action: 'intune_rbac.write_blocked',
status: 'blocked',
actorId: (int) $user->getKey(),
actorEmail: $user->email,
actorName: $user->name,
resourceType: 'restore_run',
context: [
'metadata' => [
'operation_type' => 'restore.execute',
'reason_code' => $e->reasonCode,
'backup_set_id' => $data['backup_set_id'] ?? null,
],
],
);
Notification::make()
->title('Write operation blocked')
->body($e->reasonMessage)
->danger()
->send();
throw ValidationException::withMessages([
'backup_set_id' => $e->reasonMessage,
]);
}
$previewIntegrity = $restoreSafetyResolver->previewIntegrityFromData($data);
$checksIntegrity = $restoreSafetyResolver->checksIntegrityFromData($data);
$assessment = $restoreSafetyResolver->safetyAssessment($tenant, $user, $data);
if ($checksIntegrity->state === ChecksIntegrityState::STATE_NOT_RUN) {
throw ValidationException::withMessages([
'check_summary' => 'Run safety checks before executing.',
]);
}
$blocking = (int) ($checkSummary['blocking'] ?? 0);
$hasBlockers = (bool) ($checkSummary['has_blockers'] ?? ($blocking > 0));
if ($checksIntegrity->state !== ChecksIntegrityState::STATE_CURRENT) {
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([
'check_summary' => 'Blocking checks must be resolved before executing.',
]);
}
if (! filled($previewRanAt)) {
if ($previewIntegrity->state === PreviewIntegrityState::STATE_NOT_GENERATED) {
throw ValidationException::withMessages([
'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)) {
throw ValidationException::withMessages([
'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['scope_basis'] = $scopeBasis;
if (is_array($checkBasis)) {
$metadata['check_basis'] = $checkBasis;
}
if (is_array($previewBasis)) {
$metadata['preview_basis'] = $previewBasis;
}
$restoreRun->update(['metadata' => $metadata]);
return $restoreRun->refresh();
@ -1619,6 +1858,7 @@ public static function createRestoreRun(array $data): RestoreRun
'confirmed_at' => now()->toIso8601String(),
'confirmed_by' => $actorEmail,
'confirmed_by_name' => $actorName,
'scope_basis' => $scopeBasis,
];
if (is_array($checkSummary)) {
@ -1645,6 +1885,18 @@ public static function createRestoreRun(array $data): RestoreRun
$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(
tenantId: (int) $tenant->getKey(),
backupSetId: (int) $backupSet->getKey(),
@ -1768,6 +2020,145 @@ public static function createRestoreRun(array $data): RestoreRun
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
* @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->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
{
data_set($this->data, "group_mapping.{$sourceGroupId}", $entraId);
$this->data['check_summary'] = null;
$this->data['check_results'] = [];
$this->data['checks_ran_at'] = null;
$this->data['preview_summary'] = null;
$this->data['preview_diffs'] = [];
$this->data['preview_ran_at'] = null;
$this->data['is_dry_run'] = true;
$this->data['acknowledged_impact'] = false;
$this->data['tenant_confirm'] = null;
$this->data = RestoreRunResource::synchronizeRestoreSafetyDraft($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))
->description(fn (Tenant $record): string => static::tenantLifecyclePresentation($record)->shortDescription)
->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')
->dateTime()
->since()
@ -310,13 +303,6 @@ public static function table(Table $table): Table
'staging' => 'STAGING',
'other' => 'Other',
]),
Tables\Filters\SelectFilter::make('app_status')
->options([
'ok' => 'OK',
'consent_required' => 'Consent required',
'error' => 'Error',
'unknown' => 'Unknown',
]),
])
->actions([
Actions\Action::make('related_onboarding')
@ -842,12 +828,6 @@ public static function infolist(Schema $schema): Schema
->label('Lifecycle summary')
->state(fn (Tenant $record): string => static::tenantLifecyclePresentation($record)->longDescription)
->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)
->columnSpanFull(),
@ -1492,19 +1472,30 @@ private static function providerConnectionState(Tenant $tenant): array
{
$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')
->orderByDesc('is_default')
->where('is_default', true)
->orderBy('id')
->first();
$connection = $defaultConnection instanceof ProviderConnection
? $defaultConnection
: ProviderConnection::query()
->where('tenant_id', (int) $tenant->getKey())
->where('provider', 'microsoft')
->orderBy('id')
->first();
if (! $connection instanceof ProviderConnection) {
return [
'state' => 'needs_action',
'state' => 'missing',
'cta_url' => $ctaUrl,
'needs_default_connection' => false,
'display_name' => null,
'provider' => null,
'consent_status' => null,
'verification_status' => null,
'status' => null,
'health_status' => null,
'last_health_check_at' => null,
@ -1515,8 +1506,15 @@ private static function providerConnectionState(Tenant $tenant): array
return [
'state' => $connection->is_default ? 'default_configured' : 'configured',
'cta_url' => $ctaUrl,
'needs_default_connection' => ! $connection->is_default,
'display_name' => (string) $connection->display_name,
'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,
'health_status' => is_string($connection->health_status) ? $connection->health_status : null,
'last_health_check_at' => optional($connection->last_health_check_at)->toDateTimeString(),

View File

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

View File

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

View File

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

View File

@ -23,13 +23,7 @@ class DashboardKpis extends StatsOverviewWidget
protected function getPollingInterval(): ?string
{
$tenant = Filament::getTenant();
if (! $tenant instanceof Tenant) {
return null;
}
return ActiveRuns::existForTenant($tenant) ? '10s' : null;
return ActiveRuns::pollingIntervalForTenant(Filament::getTenant());
}
/**
@ -60,9 +54,14 @@ protected function getStats(): array
->healthyActive()
->count();
$followUpRuns = (int) OperationRun::query()
$staleActiveRuns = (int) OperationRun::query()
->where('tenant_id', $tenantId)
->dashboardNeedsFollowUp()
->activeStaleAttention()
->count();
$terminalFollowUpRuns = (int) OperationRun::query()
->where('tenant_id', $tenantId)
->terminalFollowUp()
->count();
$openDriftUrl = $openDriftFindings > 0
@ -96,10 +95,26 @@ protected function getStats(): array
->description('healthy queued or running tenant work')
->color($activeRuns > 0 ? 'info' : 'gray')
->url($activeRuns > 0 ? OperationRunLinks::index($tenant, activeTab: 'active') : null),
Stat::make('Operations needing follow-up', $followUpRuns)
->description('failed, warning, or stalled runs')
->color($followUpRuns > 0 ? 'danger' : 'gray')
->url($followUpRuns > 0 ? OperationRunLinks::index($tenant, activeTab: 'blocked') : null),
Stat::make('Likely stale operations', $staleActiveRuns)
->description('queued or running past the lifecycle window')
->color($staleActiveRuns > 0 ? 'warning' : 'gray')
->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('High severity active findings', 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;
$expiringGovernanceCount = $aggregate->expiringGovernanceCount;
$highSeverityCount = $aggregate->highSeverityActiveFindingsCount;
$operationsFollowUpCount = (int) OperationRun::query()
$staleActiveOperationsCount = (int) OperationRun::query()
->where('tenant_id', $tenantId)
->dashboardNeedsFollowUp()
->activeStaleAttention()
->count();
$terminalFollowUpOperationsCount = (int) OperationRun::query()
->where('tenant_id', $tenantId)
->terminalFollowUp()
->count();
$activeRuns = (int) OperationRun::query()
->where('tenant_id', $tenantId)
@ -139,15 +143,35 @@ protected function getViewData(): array
];
}
if ($operationsFollowUpCount > 0) {
if ($staleActiveOperationsCount > 0) {
$items[] = [
'key' => 'operations_follow_up',
'title' => 'Operations need follow-up',
'body' => "{$operationsFollowUpCount} run(s) failed, completed with warnings, or look stalled.",
'key' => 'operations_stale_attention',
'title' => 'Active operations look stale',
'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',
'badgeColor' => 'danger',
'actionLabel' => 'Open operations',
'actionUrl' => OperationRunLinks::index($tenant, activeTab: 'blocked'),
'actionLabel' => 'Open terminal follow-up',
'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 [
'pollingInterval' => ActiveRuns::existForTenant($tenant) ? '10s' : null,
'pollingInterval' => ActiveRuns::pollingIntervalForTenant($tenant),
'items' => $items,
'healthyChecks' => $healthyChecks,
];

View File

@ -29,7 +29,7 @@ public function table(Table $table): Table
return $table
->heading('Recent Operations')
->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')
->paginated(\App\Support\Filament\TablePaginationProfiles::widget())
->columns([
@ -43,22 +43,52 @@ public function table(Table $table): Table
->sortable()
->formatStateUsing(fn (?string $state): string => OperationCatalog::label((string) $state))
->limit(40)
->description(fn (OperationRun $record): ?string => OperationUxPresenter::lifecycleAttentionSummary($record))
->tooltip(fn (OperationRun $record): string => OperationCatalog::label((string) $record->type)),
TextColumn::make('status')
->badge()
->sortable()
->toggleable(isToggledHiddenByDefault: true)
->formatStateUsing(BadgeRenderer::label(BadgeDomain::OperationRunStatus))
->color(BadgeRenderer::color(BadgeDomain::OperationRunStatus))
->icon(BadgeRenderer::icon(BadgeDomain::OperationRunStatus))
->iconColor(BadgeRenderer::iconColor(BadgeDomain::OperationRunStatus)),
->formatStateUsing(fn (mixed $state, OperationRun $record): string => BadgeRenderer::spec(BadgeDomain::OperationRunStatus, [
'status' => (string) $record->status,
'freshness_state' => $record->freshnessState()->value,
])->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')
->badge()
->sortable()
->formatStateUsing(BadgeRenderer::label(BadgeDomain::OperationRunOutcome))
->color(BadgeRenderer::color(BadgeDomain::OperationRunOutcome))
->icon(BadgeRenderer::icon(BadgeDomain::OperationRunOutcome))
->iconColor(BadgeRenderer::iconColor(BadgeDomain::OperationRunOutcome))
->formatStateUsing(fn (mixed $state, OperationRun $record): string => BadgeRenderer::spec(BadgeDomain::OperationRunOutcome, [
'outcome' => (string) $record->outcome,
'status' => (string) $record->status,
'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('created_at')
->label('Started')

View File

@ -5,16 +5,16 @@
namespace App\Filament\Widgets\Inventory;
use App\Filament\Concerns\ResolvesPanelTenantContext;
use App\Models\InventoryItem;
use App\Models\OperationRun;
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\BadgeRenderer;
use App\Support\Inventory\InventoryKpiBadges;
use App\Support\Inventory\InventoryPolicyTypeMeta;
use App\Support\OperationRunOutcome;
use App\Support\OperationRunStatus;
use App\Support\Inventory\TenantCoverageTruth;
use App\Support\Inventory\TenantCoverageTruthResolver;
use App\Support\OperationRunLinks;
use Filament\Widgets\StatsOverviewWidget;
use Filament\Widgets\StatsOverviewWidget\Stat;
use Illuminate\Support\Facades\Blade;
@ -28,12 +28,9 @@ class InventoryKpiHeader extends StatsOverviewWidget
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>
*/
protected function getStats(): array
@ -43,126 +40,85 @@ protected function getStats(): array
if (! $tenant instanceof Tenant) {
return [
Stat::make('Total items', 0),
Stat::make('Coverage', '0%')->description('Select a tenant to load coverage.'),
Stat::make('Last inventory sync', '—')->description('Select a tenant to see the latest sync.'),
Stat::make('Covered types', '—')->description('Select a tenant to load coverage truth.'),
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('Inventory ops', 0)->description('Select a tenant to load dependency and risk counts.'),
];
}
$tenantId = (int) $tenant->getKey();
/** @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,
]);
$truth = app(TenantCoverageTruthResolver::class)->resolve($tenant);
$activeOps = (int) OperationRun::query()
->where('tenant_id', $tenantId)
->where('tenant_id', (int) $tenant->getKey())
->active()
->count();
$inventoryOps = (int) OperationRun::query()
->where('tenant_id', $tenantId)
->where('tenant_id', (int) $tenant->getKey())
->where('type', 'inventory_sync')
->active()
->count();
$resolver = app(CoverageCapabilitiesResolver::class);
$dependenciesItems = 0;
foreach ($countsByPolicyType as $policyType => $count) {
if ($policyType !== '' && $resolver->supportsDependencies($policyType)) {
$dependenciesItems += $count;
}
}
return [
Stat::make('Total items', $totalItems),
Stat::make('Coverage', $coveragePercent.'%')
->description(new HtmlString(InventoryKpiBadges::coverage($restorableItems, $partialItems))),
Stat::make('Last inventory sync', $lastInventorySyncTimeLabel)
->description(new HtmlString($lastInventorySyncDescription)),
Stat::make('Active ops', $activeOps),
Stat::make('Inventory ops', $inventoryOps)
->description(new HtmlString(InventoryKpiBadges::inventoryOps($dependenciesItems, $riskItems))),
Stat::make('Total items', $truth->observedItemTotal)
->description(sprintf('Observed across %d supported types.', $truth->observedTypeCount())),
Stat::make('Covered types', sprintf('%d / %d', $truth->succeededTypeCount, $truth->supportedTypeCount))
->description(new HtmlString(InventoryKpiBadges::coverageBreakdown(
$truth->failedTypeCount,
$truth->skippedTypeCount,
$truth->unknownTypeCount,
))),
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',
'status',
'outcome',
'context',
'failure_summary',
'created_at',
'started_at',
'completed_at',

View File

@ -201,7 +201,7 @@ protected function getViewData(): array
&& $user->can(Capabilities::PROVIDER_RUN, $tenant);
$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;
$runData = null;

View File

@ -16,11 +16,21 @@ class WorkspaceNeedsAttention extends Widget
/**
* @var array<int, array{
* key: string,
* tenant_id: int,
* tenant_label: string,
* tenant_route_key: string,
* family: string,
* urgency: string,
* title: string,
* body: string,
* url: string,
* supporting_message: ?string,
* badge: string,
* badge_color: string
* badge_color: string,
* destination: array<string, mixed>,
* action_disabled: bool,
* helper_text: ?string,
* url: ?string
* }>
*/
public array $items = [];
@ -37,11 +47,21 @@ class WorkspaceNeedsAttention extends Widget
/**
* @param array<int, array{
* key: string,
* tenant_id: int,
* tenant_label: string,
* tenant_route_key: string,
* family: string,
* urgency: string,
* title: string,
* body: string,
* url: string,
* supporting_message: ?string,
* badge: string,
* badge_color: string
* badge_color: string,
* destination: array<string, mixed>,
* action_disabled: bool,
* helper_text: ?string,
* url: ?string
* }> $items
* @param array{
* title: string,

View File

@ -23,8 +23,10 @@ class WorkspaceRecentOperations extends Widget
* status_color: string,
* outcome_label: string,
* outcome_color: string,
* lifecycle_label: ?string,
* guidance: ?string,
* started_at: string,
* destination: array<string, mixed>,
* url: string
* }>
*/
@ -49,8 +51,10 @@ class WorkspaceRecentOperations extends Widget
* status_color: string,
* outcome_label: string,
* outcome_color: string,
* lifecycle_label: ?string,
* guidance: ?string,
* started_at: string,
* destination: array<string, mixed>,
* url: string
* }> $operations
* @param array{

View File

@ -20,9 +20,11 @@ class WorkspaceSummaryStats extends StatsOverviewWidget
* key: string,
* label: string,
* value: int,
* category: string,
* description: string,
* destination: ?array<string, mixed>,
* destination_url: ?string,
* color: string
* color: string,
* }>
*/
public array $metrics = [];
@ -32,9 +34,11 @@ class WorkspaceSummaryStats extends StatsOverviewWidget
* key: string,
* label: string,
* value: int,
* category: string,
* description: string,
* destination: ?array<string, mixed>,
* destination_url: ?string,
* color: string
* color: string,
* }> $metrics
*/
public function mount(array $metrics = []): void
@ -53,8 +57,13 @@ protected function getStats(): array
->description($metric['description'])
->color($metric['color']);
if ($metric['destination_url'] !== null) {
$stat->url($metric['destination_url']);
$destination = $metric['destination'] ?? null;
$destinationUrl = is_array($destination) && ($destination['disabled'] ?? false) === false
? ($destination['url'] ?? null)
: ($metric['destination_url'] ?? null);
if (is_string($destinationUrl) && $destinationUrl !== '') {
$stat->url($destinationUrl);
}
return $stat;

View File

@ -103,11 +103,15 @@ public function handle(InventorySyncService $inventorySyncService, AuditLogger $
$this->operationRun,
$tenant,
$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;
$coverageStatusByType[$policyType] = $success
? InventoryCoverage::StatusSucceeded
: InventoryCoverage::StatusFailed;
$coverageStatusByType[$policyType] = array_filter([
'status' => $success
? InventoryCoverage::StatusSucceeded
: InventoryCoverage::StatusFailed,
'item_count' => $itemCount,
'error_code' => $success ? null : $errorCode,
], static fn (mixed $value): bool => $value !== null);
if ($success) {
$successCount++;
@ -126,7 +130,10 @@ function (string $policyType, bool $success, ?string $errorCode) use (&$processe
continue;
}
$statusByType[$type] = InventoryCoverage::StatusSkipped;
$statusByType[$type] = [
'status' => InventoryCoverage::StatusSkipped,
'item_count' => 0,
];
}
foreach ($coverageStatusByType as $type => $status) {
@ -138,8 +145,16 @@ function (string $policyType, bool $success, ?string $errorCode) use (&$processe
}
if ((string) ($result['status'] ?? '') === 'skipped') {
$skippedErrorCode = is_string($result['error_codes'][0] ?? null)
? (string) $result['error_codes'][0]
: null;
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\Tenant;
use App\Support\OpsUx\ActiveRuns;
use App\Support\OpsUx\OpsUxBrowserEvents;
use Filament\Facades\Filament;
use Illuminate\Support\Collection;
@ -85,13 +86,13 @@ public function refreshRuns(): void
$query = OperationRun::query()
->where('tenant_id', $tenantId)
->active()
->healthyActive()
->orderByDesc('created_at');
$activeCount = (clone $query)->count();
$this->runs = (clone $query)->limit(6)->get();
$this->overflowCount = max(0, $activeCount - 5);
$this->hasActiveRuns = $this->runs->isNotEmpty();
$this->hasActiveRuns = ActiveRuns::existForTenantId($tenantId);
}
public function render(): \Illuminate\Contracts\View\View

View File

@ -86,6 +86,63 @@ public function assignmentsFetchFailed(): bool
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
{
$types = array_column(config('tenantpilot.foundation_types', []), 'type');

View File

@ -2,6 +2,7 @@
namespace App\Models;
use App\Support\Inventory\InventoryCoverage;
use App\Support\OperationCatalog;
use App\Support\OperationRunOutcome;
use App\Support\OperationRunStatus;
@ -17,6 +18,12 @@ class OperationRun extends Model
{
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 $casts = [
@ -178,20 +185,34 @@ public function scopeDashboardNeedsFollowUp(Builder $query): Builder
return $query->where(function (Builder $query): void {
$query
->where(function (Builder $terminalQuery): void {
$terminalQuery
->where('status', OperationRunStatus::Completed->value)
->whereIn('outcome', [
OperationRunOutcome::Blocked->value,
OperationRunOutcome::PartiallySucceeded->value,
OperationRunOutcome::Failed->value,
]);
$terminalQuery->terminalFollowUp();
})
->orWhere(function (Builder $activeQuery): void {
$activeQuery->likelyStale();
$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(function (Builder $query): void {
$query
->whereIn('outcome', [
OperationRunOutcome::Blocked->value,
OperationRunOutcome::PartiallySucceeded->value,
OperationRunOutcome::Failed->value,
])
->orWhereNotNull('context->reconciliation->reconciled_at');
});
}
public function getSelectionHashAttribute(): ?string
{
$context = is_array($this->context) ? $this->context : [];
@ -253,11 +274,33 @@ public function setFinishedAtAttribute(mixed $value): void
$this->completed_at = $value;
}
public function inventoryCoverage(): ?InventoryCoverage
{
return InventoryCoverage::fromContext($this->context);
}
public function isGovernanceArtifactOperation(): bool
{
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
{
return OperationCatalog::supportsOperatorExplanation((string) $this->type);
@ -317,17 +360,64 @@ public function freshnessState(): OperationRunFreshnessState
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) {
return in_array((string) $this->outcome, [
OperationRunOutcome::Blocked->value,
OperationRunOutcome::PartiallySucceeded->value,
OperationRunOutcome::Failed->value,
], true);
], true)
? self::PROBLEM_CLASS_TERMINAL_FOLLOW_UP
: self::PROBLEM_CLASS_NONE;
}
return $this->freshnessState()->isLikelyStale();
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);
}
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\Concerns\DerivesWorkspaceIdFromTenant;
use App\Support\RedactionIntegrity;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
@ -59,6 +60,55 @@ public function baselineProfile(): BelongsTo
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)
{
return $query

View File

@ -156,4 +156,46 @@ public function getSkippedAssignmentsCount(): int
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;
}
$statusByType[$type] = InventoryCoverage::StatusSkipped;
$statusByType[$type] = [
'status' => InventoryCoverage::StatusSkipped,
'item_count' => 0,
];
}
$result = $this->executeSelection(
$operationRun,
$tenant,
$normalizedSelection,
function (string $policyType, bool $success, ?string $errorCode) use (&$statusByType): void {
$statusByType[$policyType] = $success
? InventoryCoverage::StatusSucceeded
: InventoryCoverage::StatusFailed;
function (string $policyType, bool $success, ?string $errorCode, int $itemCount) use (&$statusByType): void {
$statusByType[$policyType] = array_filter([
'status' => $success
? InventoryCoverage::StatusSucceeded
: 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 : [];
$coverageStatusByType = $statusByType;
$skippedErrorCode = is_string($errorCodes[0] ?? null) ? (string) $errorCodes[0] : null;
if ($status === 'skipped') {
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.
*
* @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}
*/
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 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}
*/
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 = [];
$hadErrors = false;
$warnings = [];
$observedByType = [];
try {
$connection = $this->resolveProviderConnection($tenant);
@ -277,7 +290,7 @@ private function executeSelectionUnderLock(OperationRun $operationRun, Tenant $t
$hadErrors = true;
$errors++;
$errorCodes[] = 'unsupported_type';
$onPolicyTypeProcessed && $onPolicyTypeProcessed($policyType, false, 'unsupported_type');
$onPolicyTypeProcessed && $onPolicyTypeProcessed($policyType, false, 'unsupported_type', 0);
continue;
}
@ -293,7 +306,7 @@ private function executeSelectionUnderLock(OperationRun $operationRun, Tenant $t
$errors++;
$errorCode = $this->mapGraphFailureToErrorCode($response);
$errorCodes[] = $errorCode;
$onPolicyTypeProcessed && $onPolicyTypeProcessed($policyType, false, $errorCode);
$onPolicyTypeProcessed && $onPolicyTypeProcessed($policyType, false, $errorCode, 0);
continue;
}
@ -313,6 +326,7 @@ private function executeSelectionUnderLock(OperationRun $operationRun, Tenant $t
}
$observed++;
$observedByType[$policyType] = (int) ($observedByType[$policyType] ?? 0) + 1;
$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 [

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::OperationRunStatus->value => Domains\OperationRunStatusBadge::class,
BadgeDomain::OperationRunOutcome->value => Domains\OperationRunOutcomeBadge::class,
BadgeDomain::InventoryCoverageState->value => Domains\InventoryCoverageStateBadge::class,
BadgeDomain::BackupSetStatus->value => Domains\BackupSetStatusBadge::class,
BadgeDomain::RestoreRunStatus->value => Domains\RestoreRunStatusBadge::class,
BadgeDomain::RestoreCheckSeverity->value => Domains\RestoreCheckSeverityBadge::class,
@ -46,6 +47,8 @@ final class BadgeCatalog
BadgeDomain::IgnoredAt->value => Domains\IgnoredAtBadge::class,
BadgeDomain::RestorePreviewDecision->value => Domains\RestorePreviewDecisionBadge::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::ProviderConnectionHealth->value => Domains\ProviderConnectionHealthBadge::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
{
$state = self::normalizeState($value);

View File

@ -18,6 +18,7 @@ enum BadgeDomain: string
case BaselineSnapshotGapStatus = 'baseline_snapshot_gap_status';
case OperationRunStatus = 'operation_run_status';
case OperationRunOutcome = 'operation_run_outcome';
case InventoryCoverageState = 'inventory_coverage_state';
case BackupSetStatus = 'backup_set_status';
case RestoreRunStatus = 'restore_run_status';
case RestoreCheckSeverity = 'restore_check_severity';
@ -37,6 +38,8 @@ enum BadgeDomain: string
case IgnoredAt = 'ignored_at';
case RestorePreviewDecision = 'restore_preview_decision';
case RestoreResultStatus = 'restore_result_status';
case ProviderConsentStatus = 'provider_connection.consent_status';
case ProviderVerificationStatus = 'provider_connection.verification_status';
case ProviderConnectionStatus = 'provider_connection.status';
case ProviderConnectionHealth = 'provider_connection.health';
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'),
'warning' => OperatorOutcomeTaxonomy::spec(BadgeDomain::RestoreCheckSeverity, $state, 'heroicon-m-exclamation-triangle'),
'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(),
} ?? 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_copy' => OperatorOutcomeTaxonomy::spec(BadgeDomain::RestorePreviewDecision, $state, 'heroicon-m-document-duplicate'),
'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'),
'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(),
} ?? BadgeSpec::unknown();
}

View File

@ -22,6 +22,9 @@ public function spec(mixed $value): BadgeSpec
'partial' => OperatorOutcomeTaxonomy::spec(BadgeDomain::RestoreResultStatus, $state, 'heroicon-m-exclamation-triangle'),
'manual_required' => OperatorOutcomeTaxonomy::spec(BadgeDomain::RestoreResultStatus, $state, 'heroicon-m-wrench-screwdriver'),
'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(),
} ?? BadgeSpec::unknown();
}

View File

@ -68,10 +68,56 @@ public function coveredTypes(): array
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.
*
* @param array<string, string> $statusByType
* @param array<string, string|array{status: string, item_count?: int, error_code?: string|null}> $statusByType
* @param list<string> $foundationTypes
* @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;
}
$normalizedStatus = self::normalizeStatus($status);
$row = self::normalizeBuildRow($status);
if ($normalizedStatus === null) {
if ($row === null) {
continue;
}
$row = ['status' => $normalizedStatus];
if (array_key_exists($type, $foundationLookup)) {
$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
{
if (! is_string($status)) {

View File

@ -8,39 +8,75 @@
class InventoryKpiBadges
{
public static function coverage(int $restorableCount, int $partialCount): string
public static function coverageBreakdown(int $failedCount, int $skippedCount, int $unknownCount): string
{
return Blade::render(<<<'BLADE'
<div class="flex items-center gap-2">
<x-filament::badge color="success" size="sm">
Restorable {{ $restorableCount }}
</x-filament::badge>
if ($failedCount === 0 && $skippedCount === 0 && $unknownCount === 0) {
return Blade::render(<<<'BLADE'
<div class="flex items-center gap-2">
<x-filament::badge color="success" size="sm">
No follow-up
</x-filament::badge>
</div>
BLADE);
}
<x-filament::badge color="warning" size="sm">
Partial {{ $partialCount }}
</x-filament::badge>
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, [
'restorableCount' => $restorableCount,
'partialCount' => $partialCount,
'failedCount' => $failedCount,
'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'
<div class="flex items-center gap-2">
<x-filament::badge color="success" size="sm">
All covered
</x-filament::badge>
</div>
BLADE);
}
return Blade::render(<<<'BLADE'
<div class="flex items-center gap-2">
<div class="flex flex-wrap items-center gap-2">
<x-filament::badge color="gray" size="sm">
Dependencies {{ $dependenciesCount }}
{{ $topPriorityLabel }}
</x-filament::badge>
<x-filament::badge color="danger" size="sm">
Risk {{ $riskCount }}
<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, [
'dependenciesCount' => $dependenciesCount,
'riskCount' => $riskCount,
'topPriorityLabel' => $topPriorityRow->label,
'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;
}
$tenantKeyColumn = (new Tenant)->getQualifiedKeyName();
return Tenant::query()
->withTrashed()
->where(static function ($query) use ($routeTenant): void {
->where(static function ($query) use ($routeTenant, $tenantKeyColumn): void {
$query->where('external_id', $routeTenant);
if (ctype_digit($routeTenant)) {
$query->orWhereKey((int) $routeTenant);
$query->orWhere($tenantKeyColumn, (int) $routeTenant);
}
})
->first();

View File

@ -79,17 +79,33 @@ public static function index(
?Tenant $tenant = null,
?CanonicalNavigationContext $context = null,
?string $activeTab = null,
bool $allTenants = false,
?string $problemClass = null,
): string {
$parameters = $context?->toQuery() ?? [];
if ($tenant instanceof Tenant) {
$parameters['tenant_id'] = (int) $tenant->getKey();
} elseif ($allTenants) {
$parameters['tenant_scope'] = 'all';
}
if (is_string($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);
}

View File

@ -11,9 +11,30 @@ final class ActiveRuns
{
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()
->where('tenant_id', $tenant->getKey())
->active()
->where('tenant_id', $tenantId)
->healthyActive()
->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();
}
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
{
return self::memoizeExplanation(
@ -247,7 +311,9 @@ private static function buildLifecycleAttentionSummary(OperationRun $run): ?stri
return match (self::freshnessState($run)) {
OperationRunFreshnessState::LikelyStale => 'Likely stale',
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

@ -17,6 +17,7 @@
use App\Services\Baselines\SnapshotRendering\FidelityState;
use App\Support\Badges\BadgeCatalog;
use App\Support\Badges\BadgeDomain;
use App\Support\Evidence\EvidenceCompletenessState;
use App\Support\OperationCatalog;
use App\Support\OperationRunLinks;
use App\Support\OperationRunOutcome;
@ -32,6 +33,7 @@
use App\Support\Ui\DerivedState\RequestScopedDerivedStateStore;
use App\Support\Ui\OperatorExplanation\CountDescriptor;
use App\Support\Ui\OperatorExplanation\OperatorExplanationBuilder;
use Filament\Facades\Filament;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Arr;
@ -349,14 +351,16 @@ private function buildEvidenceSnapshotEnvelope(EvidenceSnapshot $snapshot): Arti
'required' => 'Refresh evidence before using this snapshot',
'optional' => in_array($status, ['queued', 'generating'], true)
? 'Wait for evidence generation to finish'
: 'Review the evidence freshness before relying on this snapshot',
: ($freshnessState === 'stale'
? 'Refresh the stale evidence before relying on this snapshot'
: 'Review the evidence freshness before relying on this snapshot'),
default => null,
},
),
nextActionUrl: $nextActionUrl,
relatedRunId: $snapshot->operation_run_id !== null ? (int) $snapshot->operation_run_id : 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,
includePublicationDimension: false,
countDescriptors: [
@ -402,7 +406,7 @@ public function forTenantReviewFresh(TenantReview $review): ArtifactTruthEnvelop
private function buildTenantReviewEnvelope(TenantReview $review): ArtifactTruthEnvelope
{
$review->loadMissing(['tenant', 'currentExportReviewPack']);
$review->loadMissing(['tenant', 'currentExportReviewPack', 'evidenceSnapshot']);
$summary = is_array($review->summary) ? $review->summary : [];
$publishBlockers = $review->publishBlockers();
@ -410,6 +414,7 @@ private function buildTenantReviewEnvelope(TenantReview $review): ArtifactTruthE
$completeness = $review->completenessEnum()->value;
$sectionCounts = is_array($summary['section_state_counts'] ?? null) ? $summary['section_state_counts'] : [];
$staleSections = (int) ($sectionCounts['stale'] ?? 0);
$sourceEvidence = $this->evidenceTrustBurden($review->evidenceSnapshot);
$artifactExistence = match ($status) {
TenantReviewStatus::Archived, TenantReviewStatus::Superseded => 'historical_only',
@ -417,24 +422,27 @@ private function buildTenantReviewEnvelope(TenantReview $review): ArtifactTruthE
default => 'created',
};
$contentState = match ($completeness) {
TenantReviewCompletenessState::Complete->value => 'trusted',
TenantReviewCompletenessState::Partial->value => 'partial',
TenantReviewCompletenessState::Missing->value => 'missing_input',
TenantReviewCompletenessState::Stale->value => 'trusted',
$contentState = match (true) {
$completeness === TenantReviewCompletenessState::Missing->value => 'missing_input',
$completeness === TenantReviewCompletenessState::Partial->value || $sourceEvidence['isPartial'] => 'partial',
$sourceEvidence['isMissing'] => 'missing_input',
$completeness === TenantReviewCompletenessState::Complete->value => 'trusted',
$completeness === TenantReviewCompletenessState::Stale->value => 'trusted',
default => 'partial',
};
$freshnessState = match (true) {
$artifactExistence === 'historical_only' => 'stale',
$completeness === TenantReviewCompletenessState::Stale->value || $staleSections > 0 => 'stale',
$completeness === TenantReviewCompletenessState::Stale->value || $staleSections > 0 || $sourceEvidence['isStale'] => 'stale',
default => 'current',
};
$publicationReadiness = match (true) {
$artifactExistence === 'historical_only' => 'internal_only',
$status === TenantReviewStatus::Published => 'publishable',
$publishBlockers !== [] => 'blocked',
$contentState === 'missing_input' => 'blocked',
$freshnessState === 'stale' || $contentState === 'partial' => 'internal_only',
$status === TenantReviewStatus::Published => 'publishable',
$status === TenantReviewStatus::Ready => 'publishable',
default => 'internal_only',
};
@ -442,16 +450,16 @@ private function buildTenantReviewEnvelope(TenantReview $review): ArtifactTruthE
$actionability = match (true) {
$artifactExistence === 'historical_only' => 'none',
$publicationReadiness === 'publishable' && $freshnessState === 'current' => 'none',
$publicationReadiness === 'internal_only' && $contentState === 'trusted' => 'optional',
$freshnessState === 'stale' && $publishBlockers === [] => 'optional',
$publicationReadiness === 'blocked' => 'required',
$publicationReadiness === 'internal_only' => 'optional',
default => 'required',
};
$reasonCode = match (true) {
$publishBlockers !== [] => 'review_publish_blocked',
$status === TenantReviewStatus::Failed => 'review_generation_failed',
$completeness === TenantReviewCompletenessState::Missing->value => 'review_missing_sections',
$completeness === TenantReviewCompletenessState::Stale->value => 'review_stale_sections',
$contentState === 'missing_input' => 'review_missing_sections',
$freshnessState === 'stale' => 'review_stale_sections',
default => null,
};
$reason = $this->reasonPresenter->forArtifactTruth($reasonCode, 'artifact_truth');
@ -470,7 +478,11 @@ private function buildTenantReviewEnvelope(TenantReview $review): ArtifactTruthE
$publicationReadiness === 'internal_only' => [
BadgeDomain::GovernanceArtifactPublicationReadiness,
'internal_only',
'This review exists and is useful internally, but it is not yet ready for stakeholder publication.',
match (true) {
$freshnessState === 'stale' => 'This review is useful internally, but stale evidence should be refreshed before stakeholder publication.',
$contentState === 'partial' => 'This review is useful internally, but the evidence basis is partial and should be completed before stakeholder publication.',
default => 'This review exists and is useful internally, but it is not yet ready for stakeholder publication.',
},
],
$freshnessState === 'stale' => [
BadgeDomain::GovernanceArtifactFreshness,
@ -489,7 +501,13 @@ private function buildTenantReviewEnvelope(TenantReview $review): ArtifactTruthE
: 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) {
$nextActionUrl = $this->panelSafeTenantArtifactUrl(
fn (): string => EvidenceSnapshotResource::getUrl('view', ['record' => $review->evidenceSnapshot], tenant: $review->tenant)
);
}
return $this->makeEnvelope(
@ -514,16 +532,20 @@ private function buildTenantReviewEnvelope(TenantReview $review): ArtifactTruthE
nextActionLabel: $this->nextActionLabel(
$actionability,
$reason,
match ($actionability) {
'required' => 'Resolve the review blockers before publication',
'optional' => 'Complete the remaining review work before publication',
match (true) {
$publicationReadiness === 'blocked' => 'Resolve the review blockers before publication',
$freshnessState === 'stale' => 'Refresh the evidence basis before publishing this review',
$contentState === 'partial' => 'Complete the evidence basis before publishing this review',
$actionability === 'optional' => 'Complete the remaining review work before publication',
default => null,
},
),
nextActionUrl: $nextActionUrl,
relatedRunId: $review->operation_run_id !== null ? (int) $review->operation_run_id : null,
relatedArtifactUrl: $review->tenant !== null
? TenantReviewResource::tenantScopedUrl('view', ['record' => $review], $review->tenant)
? $this->panelSafeTenantArtifactUrl(
fn (): string => TenantReviewResource::tenantScopedUrl('view', ['record' => $review], $review->tenant)
)
: null,
includePublicationDimension: true,
countDescriptors: [
@ -568,14 +590,18 @@ public function forReviewPackFresh(ReviewPack $pack): ArtifactTruthEnvelope
private function buildReviewPackEnvelope(ReviewPack $pack): ArtifactTruthEnvelope
{
$pack->loadMissing(['tenant', 'tenantReview']);
$pack->loadMissing(['tenant', 'tenantReview', 'evidenceSnapshot']);
$summary = is_array($pack->summary) ? $pack->summary : [];
$status = (string) $pack->status;
$evidenceResolution = is_array($summary['evidence_resolution'] ?? null) ? $summary['evidence_resolution'] : [];
$sourceReview = $pack->tenantReview;
$sourceReviewTruth = $sourceReview instanceof TenantReview
? $this->forTenantReviewFresh($sourceReview)
: null;
$sourceBlockers = $sourceReview instanceof TenantReview ? $sourceReview->publishBlockers() : [];
$sourceReviewStatus = $sourceReview instanceof TenantReview ? $sourceReview->statusEnum() : null;
$sourceEvidence = $this->evidenceTrustBurden($pack->evidenceSnapshot);
$artifactExistence = match ($status) {
ReviewPackStatus::Queued->value, ReviewPackStatus::Generating->value => 'not_created',
@ -588,23 +614,31 @@ private function buildReviewPackEnvelope(ReviewPack $pack): ArtifactTruthEnvelop
$artifactExistence === 'not_created' => 'missing_input',
$status === ReviewPackStatus::Failed->value => 'missing_input',
($evidenceResolution['outcome'] ?? null) !== 'resolved' => 'missing_input',
$sourceReview instanceof TenantReview && $sourceBlockers !== [] => 'partial',
$sourceReviewTruth?->contentState === 'missing_input' => 'missing_input',
$sourceReviewTruth?->contentState === 'partial' || $sourceEvidence['isPartial'] => 'partial',
default => 'trusted',
};
$freshnessState = $artifactExistence === 'historical_only' ? 'stale' : 'current';
$freshnessState = match (true) {
$artifactExistence === 'historical_only' => 'stale',
$sourceReviewTruth?->freshnessState === 'stale' || $sourceEvidence['isStale'] => 'stale',
default => 'current',
};
$publicationReadiness = match (true) {
$artifactExistence === 'historical_only' => 'internal_only',
$artifactExistence === 'not_created' => 'blocked',
$status === ReviewPackStatus::Failed->value => 'blocked',
$sourceReview instanceof TenantReview && $sourceBlockers !== [] => 'blocked',
$sourceReviewTruth?->publicationReadiness === 'blocked' => 'blocked',
$sourceReviewTruth?->publicationReadiness === 'internal_only' => 'internal_only',
$freshnessState === 'stale' || $contentState === 'partial' => 'internal_only',
$sourceReviewStatus === TenantReviewStatus::Draft || $sourceReviewStatus === TenantReviewStatus::Failed => 'internal_only',
default => $status === ReviewPackStatus::Ready->value ? 'publishable' : 'blocked',
};
$actionability = match (true) {
$artifactExistence === 'historical_only' => 'none',
$publicationReadiness === 'publishable' => 'none',
$publicationReadiness === 'publishable' && $freshnessState === 'current' => 'none',
$publicationReadiness === 'internal_only' => 'optional',
default => 'required',
};
@ -612,7 +646,7 @@ private function buildReviewPackEnvelope(ReviewPack $pack): ArtifactTruthEnvelop
$reasonCode = match (true) {
$status === ReviewPackStatus::Failed->value => 'review_pack_generation_failed',
($evidenceResolution['outcome'] ?? null) !== 'resolved' => 'review_pack_missing_snapshot',
$sourceReview instanceof TenantReview && $sourceBlockers !== [] => 'review_pack_source_not_publishable',
$sourceReviewTruth?->publicationReadiness === 'blocked' || $sourceReviewTruth?->publicationReadiness === 'internal_only' => 'review_pack_source_not_publishable',
$artifactExistence === 'historical_only' => 'review_pack_expired',
default => null,
};
@ -632,7 +666,11 @@ private function buildReviewPackEnvelope(ReviewPack $pack): ArtifactTruthEnvelop
$publicationReadiness === 'internal_only' => [
BadgeDomain::GovernanceArtifactPublicationReadiness,
'internal_only',
'This pack can be reviewed internally, but the source review is not currently publishable.',
match (true) {
$freshnessState === 'stale' => 'This pack is downloadable, but the source review relies on stale evidence and should stay internal until refreshed.',
$contentState === 'partial' => 'This pack is downloadable, but the source review relies on partial evidence and should stay internal until the evidence basis is completed.',
default => 'This pack can be reviewed internally, but the source review is not currently publishable.',
},
],
default => [
BadgeDomain::GovernanceArtifactPublicationReadiness,
@ -644,7 +682,13 @@ private function buildReviewPackEnvelope(ReviewPack $pack): ArtifactTruthEnvelop
$nextActionUrl = 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) {
$nextActionUrl = $this->panelSafeTenantArtifactUrl(
fn (): string => EvidenceSnapshotResource::getUrl('view', ['record' => $pack->evidenceSnapshot], tenant: $pack->tenant)
);
} elseif ($pack->operation_run_id !== null) {
$nextActionUrl = OperationRunLinks::tenantlessView((int) $pack->operation_run_id);
}
@ -671,16 +715,20 @@ private function buildReviewPackEnvelope(ReviewPack $pack): ArtifactTruthEnvelop
nextActionLabel: $this->nextActionLabel(
$actionability,
$reason,
match ($actionability) {
'required' => 'Open the source review before sharing this pack',
'optional' => 'Review the source review before sharing this pack',
match (true) {
$publicationReadiness === 'blocked' => 'Open the source review before sharing this pack',
$freshnessState === 'stale' => 'Refresh the source review before sharing this pack',
$contentState === 'partial' => 'Complete the source review before sharing this pack',
$actionability === 'optional' => 'Review the source review before sharing this pack',
default => null,
},
),
nextActionUrl: $nextActionUrl,
relatedRunId: $pack->operation_run_id !== null ? (int) $pack->operation_run_id : 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,
includePublicationDimension: true,
countDescriptors: [
@ -704,6 +752,30 @@ private function buildReviewPackEnvelope(ReviewPack $pack): ArtifactTruthEnvelop
);
}
/**
* @return array{isMissing: bool, isPartial: bool, isStale: bool}
*/
private function evidenceTrustBurden(?EvidenceSnapshot $snapshot): array
{
if (! $snapshot instanceof EvidenceSnapshot) {
return [
'isMissing' => false,
'isPartial' => false,
'isStale' => false,
];
}
$summary = is_array($snapshot->summary) ? $snapshot->summary : [];
$completeness = $snapshot->completenessState();
$staleDimensions = (int) ($summary['stale_dimensions'] ?? 0);
return [
'isMissing' => $completeness === EvidenceCompletenessState::Missing,
'isPartial' => $completeness === EvidenceCompletenessState::Partial,
'isStale' => $completeness === EvidenceCompletenessState::Stale || $staleDimensions > 0,
];
}
public function forOperationRun(OperationRun $run): ArtifactTruthEnvelope
{
return $this->resolveEnvelope(
@ -808,7 +880,7 @@ private function buildOperationRunEnvelope(OperationRun $run): ArtifactTruthEnve
reason: ArtifactTruthCause::fromReasonResolutionEnvelope($reason, ReasonPresenter::GOVERNANCE_ARTIFACT_TRUTH_ARTIFACT),
nextActionLabel: $reason?->firstNextStep()?->label
?? ($actionability === 'required'
? 'Inspect the blocked operation details before retrying'
? 'Inspect the blocked run details before retrying'
: 'Wait for the artifact-producing operation to finish'),
nextActionUrl: null,
relatedRunId: (int) $run->getKey(),
@ -1025,6 +1097,13 @@ classification: $classification,
);
}
private function panelSafeTenantArtifactUrl(callable $resolver): ?string
{
return Filament::getCurrentPanel()?->getId() === 'system'
? null
: $resolver();
}
/**
* @return array<int, CountDescriptor>
*/

File diff suppressed because it is too large Load Diff

View File

@ -5,7 +5,7 @@ # Spec Candidates
>
> **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.
- **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

View File

@ -7,11 +7,20 @@
$summary = $summary ?? [];
$summary = is_array($summary) ? $summary : [];
$blocking = (int) ($summary['blocking'] ?? 0);
$warning = (int) ($summary['warning'] ?? 0);
$checksIntegrity = $checksIntegrity ?? [];
$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);
$ranAt = $ranAt ?? null;
$ranAt = $ranAt ?? ($checksIntegrity['ran_at'] ?? null);
$ranAtLabel = null;
if (is_string($ranAt) && $ranAt !== '') {
@ -26,6 +35,12 @@
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 {
if (count($items) <= $limit) {
return $items;
@ -39,25 +54,65 @@
<div class="space-y-4">
<x-filament::section
heading="Safety checks"
:description="$ranAtLabel ? ('Last run: ' . $ranAtLabel) : 'Run checks to evaluate risk before previewing.'"
:description="$ranAtLabel ? ('Last run: ' . $ranAtLabel) : 'Checks tell you whether the current scope can be defended, not just whether it can start.'"
>
<div class="flex flex-wrap gap-2">
<x-filament::badge :color="$blocking > 0 ? $severitySpec('blocking')->color : 'gray'">
{{ $blocking }} {{ \Illuminate\Support\Str::lower($severitySpec('blocking')->label) }}
</x-filament::badge>
<x-filament::badge :color="$warning > 0 ? $severitySpec('warning')->color : 'gray'">
{{ $warning }} {{ \Illuminate\Support\Str::lower($severitySpec('warning')->label) }}
</x-filament::badge>
<x-filament::badge :color="$safe > 0 ? $severitySpec('safe')->color : 'gray'">
{{ $safe }} {{ \Illuminate\Support\Str::lower($severitySpec('safe')->label) }}
</x-filament::badge>
<div 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">
<x-filament::badge :color="$blocking > 0 ? $severitySpec('blocking')->color : 'gray'">
{{ $blocking }} {{ \Illuminate\Support\Str::lower($severitySpec('blocking')->label) }}
</x-filament::badge>
<x-filament::badge :color="$warning > 0 ? $severitySpec('warning')->color : 'gray'">
{{ $warning }} {{ \Illuminate\Support\Str::lower($severitySpec('warning')->label) }}
</x-filament::badge>
<x-filament::badge :color="$safe > 0 ? $severitySpec('safe')->color : 'gray'">
{{ $safe }} {{ \Illuminate\Support\Str::lower($severitySpec('safe')->label) }}
</x-filament::badge>
</div>
@if (($checksIntegrity['invalidation_reasons'] ?? []) !== [])
<div class="text-xs text-amber-800 dark:text-amber-200">
Invalidated by: {{ implode(', ', array_map(static fn (string $reason): string => \Illuminate\Support\Str::replace('_', ' ', $reason), $checksIntegrity['invalidation_reasons'])) }}
</div>
@endif
</div>
</x-filament::section>
@if ($results === [])
<x-filament::section>
<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>
</x-filament::section>
@else
@ -69,9 +124,9 @@
$message = is_array($result) ? ($result['message'] ?? null) : null;
$meta = is_array($result) ? ($result['meta'] ?? []) : [];
$meta = is_array($meta) ? $meta : [];
$unmappedGroups = $meta['unmapped'] ?? [];
$unmappedGroups = is_array($unmappedGroups) ? $limitedList($unmappedGroups) : [];
$spec = $severitySpec($severity);
@endphp
<x-filament::section>
@ -87,10 +142,6 @@
@endif
</div>
@php
$spec = $severitySpec($severity);
@endphp
<x-filament::badge :color="$spec->color" :icon="$spec->icon" size="sm">
{{ $spec->label }}
</x-filament::badge>

View File

@ -7,7 +7,16 @@
$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;
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);
$policiesChanged = (int) ($summary['policies_changed'] ?? 0);
$assignmentsChanged = (int) ($summary['assignments_changed'] ?? 0);
$scopeTagsChanged = (int) ($summary['scope_tags_changed'] ?? 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 {
$keys = array_keys($items);
@ -39,22 +55,57 @@
<div class="space-y-4">
<x-filament::section
heading="Preview"
:description="$ranAtLabel ? ('Generated: ' . $ranAtLabel) : 'Generate a preview to see what would change.'"
:description="$ranAtLabel ? ('Generated: ' . $ranAtLabel) : 'Preview answers what would change for the current scope.'"
>
<div class="flex flex-wrap gap-2">
<x-filament::badge :color="$policiesChanged > 0 ? 'warning' : 'success'">
{{ $policiesChanged }}/{{ $policiesTotal }} policies changed
</x-filament::badge>
<x-filament::badge :color="$assignmentsChanged > 0 ? 'warning' : 'gray'">
{{ $assignmentsChanged }} assignments changed
</x-filament::badge>
<x-filament::badge :color="$scopeTagsChanged > 0 ? 'warning' : 'gray'">
{{ $scopeTagsChanged }} scope tags changed
</x-filament::badge>
@if ($diffsOmitted > 0)
<x-filament::badge color="gray">
{{ $diffsOmitted }} diffs omitted (limit)
<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">
<x-filament::badge :color="$policiesChanged > 0 ? 'warning' : 'success'">
{{ $policiesChanged }}/{{ $policiesTotal }} policies changed
</x-filament::badge>
<x-filament::badge :color="$assignmentsChanged > 0 ? 'warning' : 'gray'">
{{ $assignmentsChanged }} assignments changed
</x-filament::badge>
<x-filament::badge :color="$scopeTagsChanged > 0 ? 'warning' : 'gray'">
{{ $scopeTagsChanged }} scope tags changed
</x-filament::badge>
@if ($diffsOmitted > 0)
<x-filament::badge color="gray">
{{ $diffsOmitted }} diffs omitted (limit)
</x-filament::badge>
@endif
</div>
@if (($previewIntegrity['invalidation_reasons'] ?? []) !== [])
<div class="text-xs text-amber-800 dark:text-amber-200">
Invalidated by: {{ implode(', ', array_map(static fn (string $reason): string => \Illuminate\Support\Str::replace('_', ' ', $reason), $previewIntegrity['invalidation_reasons'])) }}
</div>
@endif
</div>
</x-filament::section>
@ -62,7 +113,7 @@
@if ($diffs === [])
<x-filament::section>
<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>
</x-filament::section>
@else
@ -76,16 +127,13 @@
$action = $entry['action'] ?? 'update';
$diff = is_array($entry['diff'] ?? null) ? $entry['diff'] : [];
$diffSummary = is_array($diff['summary'] ?? null) ? $diff['summary'] : [];
$added = (int) ($diffSummary['added'] ?? 0);
$removed = (int) ($diffSummary['removed'] ?? 0);
$changed = (int) ($diffSummary['changed'] ?? 0);
$assignmentsDelta = (bool) ($entry['assignments_changed'] ?? false);
$scopeTagsDelta = (bool) ($entry['scope_tags_changed'] ?? false);
$diffOmitted = (bool) ($entry['diff_omitted'] ?? false);
$diffTruncated = (bool) ($entry['diff_truncated'] ?? false);
$changedKeys = $limitedKeys(is_array($diff['changed'] ?? null) ? $diff['changed'] : []);
$addedKeys = $limitedKeys(is_array($diff['added'] ?? null) ? $diff['added'] : []);
$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 = 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'] : '#';
$needsDefaultConnection = (bool) ($state['needs_default_connection'] ?? false);
$displayName = is_string($state['display_name'] ?? null) ? (string) $state['display_name'] : 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;
$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;
$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
<div class="space-y-3 rounded-md border border-gray-200 bg-white p-4 shadow-sm">
@ -20,7 +27,9 @@
<div>
<div class="text-sm font-semibold text-gray-800">Provider connection</div>
@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
<div class="mt-1 text-sm text-gray-700">{{ $displayName ?? 'Unnamed connection' }}</div>
@endif
@ -32,18 +41,32 @@
</div>
@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">
<div>
<dt class="text-xs uppercase tracking-wide text-gray-500">Provider</dt>
<dd>{{ $provider ?? 'n/a' }}</dd>
</div>
<div>
<dt class="text-xs uppercase tracking-wide text-gray-500">Status</dt>
<dd>{{ $status ?? 'n/a' }}</dd>
<dt class="text-xs uppercase tracking-wide text-gray-500">Consent</dt>
<dd>
<x-filament::badge :color="$consentSpec->color" :icon="$consentSpec->icon" size="sm">
{{ $consentSpec->label }}
</x-filament::badge>
</dd>
</div>
<div>
<dt class="text-xs uppercase tracking-wide text-gray-500">Health</dt>
<dd>{{ $healthStatus ?? 'n/a' }}</dd>
<dt class="text-xs uppercase tracking-wide text-gray-500">Verification</dt>
<dd>
<x-filament::badge :color="$verificationSpec->color" :icon="$verificationSpec->icon" size="sm">
{{ $verificationSpec->label }}
</x-filament::badge>
</dd>
</div>
<div>
<dt class="text-xs uppercase tracking-wide text-gray-500">Last check</dt>
@ -51,10 +74,32 @@
</div>
</dl>
@if ($lastErrorReason)
<div class="rounded-md border border-amber-300 bg-amber-50 p-2 text-xs text-amber-800">
Last error reason: {{ $lastErrorReason }}
</div>
@endif
<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)
<div class="rounded-md border border-amber-300 bg-amber-50 p-2 text-xs text-amber-800">
Last error reason: {{ $lastErrorReason }}
</div>
@endif
</div>
@endunless
</div>

View File

@ -1,5 +1,28 @@
@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 {
$action = is_string($item['action'] ?? null) ? $item['action'] : null;
@ -9,6 +32,7 @@
default => ['label' => \Illuminate\Support\Str::headline((string) ($action ?? 'action')), 'color' => 'gray'],
};
};
$foundationItems = collect($preview)->filter(function ($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>
@else
<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())
<div class="space-y-2">
<div class="text-xs font-semibold uppercase tracking-wide text-gray-500">Foundations</div>
@foreach ($foundationItems as $item)
@php
$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
<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">
@ -44,9 +109,9 @@
Target: {{ $item['targetName'] }}
</div>
@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">
{{ $item['reason'] }}
{{ $foundationReason }}
</div>
@endif
</div>
@ -64,16 +129,16 @@
@endphp
<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">
<span class="font-semibold">{{ $item['policy_identifier'] ?? 'Policy' }}</span>
<div class="flex items-center gap-2">
@if ($restoreMode === 'preview-only')
@php
$restoreModeSpec = \App\Support\Badges\BadgeRenderer::spec(\App\Support\Badges\BadgeDomain::PolicyRestoreMode, $restoreMode);
@endphp
<x-filament::badge :color="$restoreModeSpec->color" :icon="$restoreModeSpec->icon" size="sm">
{{ $restoreModeSpec->label }}
</x-filament::badge>
@endif
<span class="font-semibold">{{ $item['policy_identifier'] ?? 'Policy' }}</span>
<div class="flex items-center gap-2">
@if ($restoreMode === 'preview-only')
@php
$restoreModeSpec = \App\Support\Badges\BadgeRenderer::spec(\App\Support\Badges\BadgeDomain::PolicyRestoreMode, $restoreMode);
@endphp
<x-filament::badge :color="$restoreModeSpec->color" :icon="$restoreModeSpec->icon" size="sm">
{{ $restoreModeSpec->label }}
</x-filament::badge>
@endif
<span class="rounded bg-gray-100 px-2 py-0.5 text-xs font-medium text-gray-700">
{{ $actionState['label'] }}
</span>

View File

@ -1,5 +1,9 @@
@php
$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) {
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>
@else
@php
$needsAttention = $policyItems->contains(function ($item) {
$status = $item['status'] ?? null;
$needsAttention = (bool) ($resultAttention['follow_up_required'] ?? false)
|| $policyItems->contains(function ($item) {
$status = $item['status'] ?? null;
return in_array($status, ['partial', 'manual_required'], true);
});
return in_array($status, ['partial', 'manual_required'], true);
});
$attentionSpec = \App\Support\Badges\BadgeRenderer::spec(
\App\Support\Badges\BadgeDomain::RestoreResultStatus,
$resultAttention['state'] ?? ($needsAttention ? 'completed_with_follow_up' : 'completed')
);
$executionBasisLabel = \App\Support\RestoreSafety\RestoreSafetyCopy::safetyStateLabel(
is_string($executionSafetySnapshot['safety_state'] ?? null) ? $executionSafetySnapshot['safety_state'] : null
);
$primaryNextAction = \App\Support\RestoreSafety\RestoreSafetyCopy::primaryNextAction(
is_string($resultAttention['primary_next_action'] ?? null) ? $resultAttention['primary_next_action'] : 'review_result'
);
$primaryCauseFamily = \App\Support\RestoreSafety\RestoreSafetyCopy::primaryCauseFamily(
is_string($resultAttention['primary_cause_family'] ?? null) ? $resultAttention['primary_cause_family'] : 'none'
);
$recoveryBoundary = \App\Support\RestoreSafety\RestoreSafetyCopy::recoveryBoundary(
is_string($resultAttention['recovery_claim_boundary'] ?? null)
? $resultAttention['recovery_claim_boundary']
: 'run_completed_not_recovery_proven'
);
@endphp
<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())
<div class="space-y-2">
<div class="text-xs font-semibold uppercase tracking-wide text-gray-500">Foundations</div>
@foreach ($foundationItems as $item)
@php
$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
<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">
@ -70,9 +138,9 @@
Target: {{ $item['targetName'] }}
</div>
@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">
{{ $item['reason'] }}
{{ $foundationReason }}
</div>
@endif
</div>
@ -82,7 +150,7 @@
@if ($needsAttention)
<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>
@endif

View File

@ -1,16 +1,121 @@
<x-filament-panels::page>
@php
$summary = $this->coverageSummary();
$basis = $this->basisRunSummary();
@endphp
<x-filament::section>
<div class="flex flex-col gap-3">
<div class="text-lg font-semibold text-gray-900 dark:text-gray-100">
Searchable support matrix
<div class="grid gap-4 xl:grid-cols-[minmax(0,1.8fr)_minmax(0,1fr)]">
<div class="space-y-4">
<div class="space-y-2">
<div class="text-lg font-semibold text-gray-900 dark:text-white">
Tenant coverage truth
</div>
<div class="text-sm text-gray-600 dark:text-gray-300">
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="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.
</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-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.
<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 class="text-sm text-gray-600 dark:text-gray-300">
{{ $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>
</x-filament::section>

View File

@ -1,4 +1,6 @@
<x-filament-panels::page>
@php($selectedException = $this->selectedFindingException())
<x-filament::section>
<div class="flex flex-col gap-3">
<div class="text-lg font-semibold text-gray-900 dark:text-gray-100">
@ -11,129 +13,13 @@
</div>
</x-filament::section>
{{ $this->table }}
@php
$selectedException = $this->selectedFindingException();
@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>
@if ($this->showSelectedExceptionSummary && $selectedException)
<x-filament::section>
@include('filament.pages.monitoring.partials.finding-exception-queue-sidebar', [
'selectedException' => $selectedException,
])
</x-filament::section>
@endif
{{ $this->table }}
</x-filament-panels::page>

View File

@ -1,5 +1,7 @@
<x-filament-panels::page>
@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.item
@ -15,10 +17,16 @@
Active
</x-filament::tabs.item>
<x-filament::tabs.item
:active="$this->activeTab === 'blocked'"
wire:click="$set('activeTab', 'blocked')"
:active="$this->activeTab === $staleAttentionTab"
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
:active="$this->activeTab === 'succeeded'"
@ -42,8 +50,8 @@
@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">
{{ ($lifecycleSummary['likely_stale'] ?? 0) }} active operation(s) are beyond their lifecycle window.
{{ ($lifecycleSummary['reconciled'] ?? 0) }} operation(s) have already been automatically reconciled.
{{ ($lifecycleSummary['likely_stale'] ?? 0) }} active operation(s) are beyond their lifecycle window and belong in the stale-attention view.
{{ ($lifecycleSummary['reconciled'] ?? 0) }} operation(s) already carry reconciled stale lineage and belong in terminal follow-up.
</div>
@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();
$blockedBanner = $this->blockedExecutionBanner();
$lifecycleBanner = $this->lifecycleBanner();
$restoreContinuationBanner = $this->restoreContinuationBanner();
$pollInterval = $this->pollInterval();
@endphp
@ -49,6 +50,27 @@
</div>
@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())
<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() }}

View File

@ -26,7 +26,7 @@
</h1>
<p class="max-w-2xl text-sm leading-6 text-gray-600 dark:text-gray-300">
This home stays workspace-scoped even when you were previously working in a tenant. Tenant drill-down remains explicit so the overview never silently narrows itself.
This home stays workspace-scoped even when you were previously working in a tenant. Governance risk is ranked ahead of execution noise, and calm wording only appears when the checked workspace domains are genuinely quiet.
</p>
</div>
</x-filament::section>
@ -82,6 +82,18 @@ class="rounded-xl border border-gray-200 bg-white px-4 py-3 text-left transition
@endif
<div class="space-y-6">
<div class="flex flex-wrap gap-2 text-xs text-gray-600 dark:text-gray-300">
<span class="inline-flex items-center rounded-full border border-danger-200 bg-danger-50 px-3 py-1 font-medium text-danger-700 dark:border-danger-700/50 dark:bg-danger-950/30 dark:text-danger-200">
Governance risk counts affected tenants
</span>
<span class="inline-flex items-center rounded-full border border-warning-200 bg-warning-50 px-3 py-1 font-medium text-warning-700 dark:border-warning-700/50 dark:bg-warning-950/30 dark:text-warning-200">
Activity counts execution load only
</span>
<span class="inline-flex items-center rounded-full border border-gray-200 bg-white px-3 py-1 font-medium text-gray-600 dark:border-white/10 dark:bg-white/5 dark:text-gray-300">
Recent operations stay diagnostic
</span>
</div>
@livewire(\App\Filament\Widgets\Workspace\WorkspaceSummaryStats::class, [
'metrics' => $overview['summary_metrics'] ?? [],
], key('workspace-overview-summary-' . ($workspace['id'] ?? 'none')))

View File

@ -4,20 +4,26 @@
$statusSpec = \App\Support\Badges\BadgeRenderer::spec(
\App\Support\Badges\BadgeDomain::OperationRunStatus,
(string) $run->status,
[
'status' => (string) $run->status,
'freshness_state' => $run->freshnessState()->value,
],
);
$outcomeSpec = (string) $run->status === 'completed'
? \App\Support\Badges\BadgeRenderer::spec(
\App\Support\Badges\BadgeDomain::OperationRunOutcome,
(string) $run->outcome,
)
: null;
$outcomeSpec = \App\Support\Badges\BadgeRenderer::spec(
\App\Support\Badges\BadgeDomain::OperationRunOutcome,
[
'outcome' => (string) $run->outcome,
'status' => (string) $run->status,
'freshness_state' => $run->freshnessState()->value,
],
);
$summaryCounts = is_array($run->summary_counts) ? $run->summary_counts : [];
$hasSummary = count($summaryCounts) > 0;
$integrityNote = \App\Support\RedactionIntegrity::noteForRun($run);
$guidance = \App\Support\OpsUx\OperationUxPresenter::surfaceGuidance($run);
$decisionTruth = \App\Support\OpsUx\OperationUxPresenter::decisionZoneTruth($run);
@endphp
<x-filament-panels::page>
@ -104,6 +110,47 @@
</dl>
</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)
<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">

View File

@ -17,12 +17,20 @@
@php
$statusSpec = \App\Support\Badges\BadgeRenderer::spec(
\App\Support\Badges\BadgeDomain::OperationRunStatus,
(string) $run->status,
[
'status' => (string) $run->status,
'freshness_state' => $run->freshnessState()->value,
],
);
$outcomeSpec = \App\Support\Badges\BadgeRenderer::spec(
\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);
@endphp
<li class="flex items-center justify-between gap-3 py-2">
@ -38,6 +46,11 @@
<x-filament::badge :color="$outcomeSpec->color" size="sm">
{{ $outcomeSpec->label }}
</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 class="mt-1 text-xs text-gray-500 dark:text-gray-400">

View File

@ -27,12 +27,12 @@
<x-filament::section
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">
@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">
No verification operation has been started yet.
No provider verification check has been recorded yet.
</div>
<div class="flex items-center gap-2">

View File

@ -22,25 +22,66 @@
@else
<div class="space-y-3">
@foreach ($items as $item)
<a
href="{{ $item['url'] }}"
class="block rounded-xl border border-gray-200 bg-gray-50 p-4 transition hover:border-gray-300 hover:bg-white dark:border-white/10 dark:bg-white/5 dark:hover:border-white/20 dark:hover:bg-white/10"
>
<div class="flex items-start justify-between gap-3">
<div class="space-y-1">
<div class="text-sm font-semibold text-gray-950 dark:text-white">
{{ $item['title'] }}
</div>
<div class="text-sm leading-6 text-gray-600 dark:text-gray-300">
{{ $item['body'] }}
@php
$destination = $item['destination'] ?? null;
$actionUrl = is_array($destination) && ($destination['disabled'] ?? false) === false
? ($destination['url'] ?? null)
: null;
@endphp
<div class="rounded-xl border border-gray-200 bg-gray-50 p-4 dark:border-white/10 dark:bg-white/5">
<div class="space-y-3">
<div class="flex flex-wrap items-start justify-between gap-3">
<div class="space-y-2">
<div class="flex flex-wrap items-center gap-2">
<span class="inline-flex items-center rounded-full border border-gray-200 bg-white px-2.5 py-0.5 text-xs font-medium text-gray-700 dark:border-white/10 dark:bg-white/10 dark:text-gray-200">
{{ $item['tenant_label'] }}
</span>
<span class="inline-flex items-center rounded-full border border-gray-200 bg-white px-2.5 py-0.5 text-xs font-medium uppercase tracking-wide text-gray-500 dark:border-white/10 dark:bg-white/10 dark:text-gray-300">
{{ str_replace('_', ' ', $item['urgency']) }}
</span>
<x-filament::badge :color="$item['badge_color']" size="sm">
{{ $item['badge'] }}
</x-filament::badge>
</div>
<div class="text-sm font-semibold text-gray-950 dark:text-white">
{{ $item['title'] }}
</div>
<div class="text-sm leading-6 text-gray-600 dark:text-gray-300">
{{ $item['body'] }}
</div>
@if (filled($item['supporting_message'] ?? null))
<p class="text-xs leading-5 text-gray-500 dark:text-gray-400">
{{ $item['supporting_message'] }}
</p>
@endif
</div>
</div>
<x-filament::badge :color="$item['badge_color']" size="sm">
{{ $item['badge'] }}
</x-filament::badge>
<div class="flex flex-wrap items-center gap-3 text-sm">
@if (is_string($actionUrl) && $actionUrl !== '')
<x-filament::link :href="$actionUrl" size="sm">
{{ $destination['label'] ?? 'Open' }}
</x-filament::link>
@else
<span class="text-gray-500 dark:text-gray-400">
{{ $destination['label'] ?? 'Unavailable' }}
</span>
@endif
@if (filled($item['helper_text'] ?? null))
<span class="text-xs text-gray-500 dark:text-gray-400">
{{ $item['helper_text'] }}
</span>
@endif
</div>
</div>
</a>
</div>
@endforeach
</div>
@endif

View File

@ -1,4 +1,8 @@
<x-filament::section heading="Recent operations">
<p class="mb-4 text-xs leading-5 text-gray-500 dark:text-gray-400">
Diagnostic recency across your visible workspace slice. This does not define governance health on its own.
</p>
@if ($operations === [])
<div class="flex h-full flex-col justify-between gap-4">
<div class="space-y-2">
@ -22,45 +26,62 @@
@else
<div class="space-y-3">
@foreach ($operations as $operation)
<a
href="{{ $operation['url'] }}"
class="block rounded-xl border border-gray-200 bg-gray-50 p-4 transition hover:border-gray-300 hover:bg-white dark:border-white/10 dark:bg-white/5 dark:hover:border-white/20 dark:hover:bg-white/10"
>
<div class="flex items-start justify-between gap-3">
<div class="min-w-0 space-y-2">
<div class="flex flex-wrap items-center gap-2">
<div class="text-sm font-semibold text-gray-950 dark:text-white">
{{ $operation['title'] }}
@php
$destination = $operation['destination'] ?? null;
$actionUrl = is_array($destination) ? ($destination['url'] ?? null) : ($operation['url'] ?? null);
@endphp
<div class="rounded-xl border border-gray-200 bg-gray-50 p-4 dark:border-white/10 dark:bg-white/5">
<div class="space-y-3">
<div class="flex items-start justify-between gap-3">
<div class="min-w-0 space-y-2">
<div class="flex flex-wrap items-center gap-2">
<div class="text-sm font-semibold text-gray-950 dark:text-white">
{{ $operation['title'] }}
</div>
@if (filled($operation['tenant_label'] ?? null))
<span class="inline-flex items-center rounded-full border border-gray-200 bg-white px-2 py-0.5 text-xs font-medium text-gray-600 dark:border-white/10 dark:bg-white/10 dark:text-gray-300">
{{ $operation['tenant_label'] }}
</span>
@endif
</div>
@if (filled($operation['tenant_label'] ?? null))
<span class="inline-flex items-center rounded-full border border-gray-200 bg-white px-2 py-0.5 text-xs font-medium text-gray-600 dark:border-white/10 dark:bg-white/10 dark:text-gray-300">
{{ $operation['tenant_label'] }}
</span>
<div class="flex flex-wrap items-center gap-2">
<x-filament::badge :color="$operation['status_color']" size="sm">
{{ $operation['status_label'] }}
</x-filament::badge>
<x-filament::badge :color="$operation['outcome_color']" size="sm">
{{ $operation['outcome_label'] }}
</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>
@if (filled($operation['guidance'] ?? null))
<p class="text-xs leading-5 text-gray-600 dark:text-gray-300">
{{ $operation['guidance'] }}
</p>
@endif
</div>
<div class="flex flex-wrap items-center gap-2">
<x-filament::badge :color="$operation['status_color']" size="sm">
{{ $operation['status_label'] }}
</x-filament::badge>
<x-filament::badge :color="$operation['outcome_color']" size="sm">
{{ $operation['outcome_label'] }}
</x-filament::badge>
<div class="shrink-0 text-xs text-gray-500 dark:text-gray-400">
{{ $operation['started_at'] }}
</div>
@if (filled($operation['guidance'] ?? null))
<p class="text-xs leading-5 text-gray-600 dark:text-gray-300">
{{ $operation['guidance'] }}
</p>
@endif
</div>
<div class="shrink-0 text-xs text-gray-500 dark:text-gray-400">
{{ $operation['started_at'] }}
</div>
@if (is_string($actionUrl) && $actionUrl !== '')
<div>
<x-filament::link :href="$actionUrl" size="sm">
{{ $destination['label'] ?? 'Open operation' }}
</x-filament::link>
</div>
@endif
</div>
</a>
</div>
@endforeach
</div>
@endif

View File

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

View File

@ -0,0 +1,35 @@
# Specification Quality Checklist: Evidence Temporal Freshness & Review Publication Trust
**Purpose**: Validate specification completeness and quality before proceeding to planning
**Created**: 2026-04-04
**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
- Required repo contract references such as routes, capabilities, and affected operator surfaces are included because this repository's spec format requires them; the requirements and outcomes themselves remain user-facing and do not prescribe code structure.
- No clarification markers remain. The feature is ready for `/speckit.plan`.

View File

@ -0,0 +1,396 @@
openapi: 3.1.0
info:
title: Evidence Review Trust Surfaces Contract
version: 1.0.0
description: >-
Internal reference contract for the rendered HTML surfaces affected by Spec 174.
These routes continue to return HTML through Filament and Livewire. The vendor
media types below document the structured truth payloads that must be derivable
before rendering. This is not a public API commitment.
paths:
/admin/evidence/overview:
get:
summary: Canonical evidence overview
description: >-
Returns the rendered evidence overview for entitled tenants in the current workspace.
The vendor media type documents the derived row contract used to communicate
artifact truth, freshness, and next steps.
responses:
'200':
description: Rendered evidence overview page
content:
text/html:
schema:
type: string
application/vnd.tenantpilot.evidence-overview+json:
schema:
$ref: '#/components/schemas/EvidenceOverviewPage'
'404':
description: Workspace context is missing or the viewer is not entitled to the relevant scope
/admin/reviews:
get:
summary: Canonical review register
description: >-
Returns the rendered review register for entitled tenants in the current workspace.
The vendor media type documents the row-level trust and publication contract.
responses:
'200':
description: Rendered review register page
content:
text/html:
schema:
type: string
application/vnd.tenantpilot.review-register+json:
schema:
$ref: '#/components/schemas/ReviewRegisterPage'
'404':
description: Workspace context is missing or the viewer is not entitled to the relevant scope
/admin/t/{tenant}/evidence/{snapshot}:
get:
summary: Tenant-scoped evidence snapshot detail
parameters:
- name: tenant
in: path
required: true
schema:
type: string
- name: snapshot
in: path
required: true
schema:
type: integer
responses:
'200':
description: Rendered evidence snapshot detail page
content:
text/html:
schema:
type: string
application/vnd.tenantpilot.evidence-snapshot-detail+json:
schema:
$ref: '#/components/schemas/EvidenceSnapshotDetailPage'
'403':
description: Viewer is in tenant scope but lacks the required manage capability for actions
'404':
description: Snapshot is not visible because it does not exist or tenant entitlement is missing
/admin/t/{tenant}/reviews/{review}:
get:
summary: Tenant-scoped review detail
parameters:
- name: tenant
in: path
required: true
schema:
type: string
- name: review
in: path
required: true
schema:
type: integer
responses:
'200':
description: Rendered tenant review detail page
content:
text/html:
schema:
type: string
application/vnd.tenantpilot.tenant-review-detail+json:
schema:
$ref: '#/components/schemas/TenantReviewDetailPage'
'403':
description: Viewer is in tenant scope but lacks the required manage capability for actions
'404':
description: Review is not visible because it does not exist or tenant entitlement is missing
/admin/t/{tenant}/review-packs/{pack}:
get:
summary: Tenant-scoped review pack detail
parameters:
- name: tenant
in: path
required: true
schema:
type: string
- name: pack
in: path
required: true
schema:
type: integer
responses:
'200':
description: Rendered review pack detail page
content:
text/html:
schema:
type: string
application/vnd.tenantpilot.review-pack-detail+json:
schema:
$ref: '#/components/schemas/ReviewPackDetailPage'
'403':
description: Viewer is in tenant scope but lacks the required manage capability for actions
'404':
description: Review pack is not visible because it does not exist or tenant entitlement is missing
components:
schemas:
ArtifactTruthSummary:
type: object
required:
- primaryLabel
- contentState
- freshnessState
- actionability
properties:
primaryLabel:
type: string
primaryExplanation:
type:
- string
- 'null'
contentState:
type: string
freshnessState:
type: string
enum:
- current
- stale
- unknown
publicationReadiness:
type:
- string
- 'null'
enum:
- publishable
- internal_only
- blocked
actionability:
type: string
enum:
- none
- optional
- required
nextActionLabel:
type:
- string
- 'null'
nextActionUrl:
type:
- string
- 'null'
diagnosticLabel:
type:
- string
- 'null'
Badge:
type: object
required:
- label
properties:
label:
type: string
color:
type:
- string
- 'null'
icon:
type:
- string
- 'null'
EvidenceOverviewRow:
type: object
required:
- tenantName
- tenantId
- snapshotId
- completenessState
- artifactTruth
- freshness
- nextStep
properties:
tenantName:
type: string
tenantId:
type: integer
snapshotId:
type: integer
completenessState:
type: string
generatedAt:
type:
- string
- 'null'
format: date-time
missingDimensions:
type: integer
staleDimensions:
type: integer
artifactTruth:
$ref: '#/components/schemas/ArtifactTruthSummary'
freshness:
$ref: '#/components/schemas/Badge'
nextStep:
type: string
viewUrl:
type:
- string
- 'null'
ReviewRegisterRow:
type: object
required:
- tenantName
- tenantId
- reviewId
- status
- completenessState
- artifactTruth
- publication
- nextStep
properties:
tenantName:
type: string
tenantId:
type: integer
reviewId:
type: integer
status:
type: string
completenessState:
type: string
generatedAt:
type:
- string
- 'null'
format: date-time
publishedAt:
type:
- string
- 'null'
format: date-time
artifactTruth:
$ref: '#/components/schemas/ArtifactTruthSummary'
publication:
$ref: '#/components/schemas/Badge'
nextStep:
type: string
viewUrl:
type:
- string
- 'null'
EvidenceOverviewPage:
type: object
required:
- rows
properties:
rows:
type: array
items:
$ref: '#/components/schemas/EvidenceOverviewRow'
ReviewRegisterPage:
type: object
required:
- rows
properties:
rows:
type: array
items:
$ref: '#/components/schemas/ReviewRegisterRow'
EvidenceSnapshotDetailPage:
type: object
required:
- recordId
- tenantId
- completenessState
- artifactTruth
properties:
recordId:
type: integer
tenantId:
type: integer
status:
type: string
completenessState:
type: string
generatedAt:
type:
- string
- 'null'
format: date-time
artifactTruth:
$ref: '#/components/schemas/ArtifactTruthSummary'
linkedReviewUrl:
type:
- string
- 'null'
linkedRunUrl:
type:
- string
- 'null'
TenantReviewDetailPage:
type: object
required:
- recordId
- tenantId
- status
- completenessState
- artifactTruth
properties:
recordId:
type: integer
tenantId:
type: integer
status:
type: string
completenessState:
type: string
generatedAt:
type:
- string
- 'null'
format: date-time
publishedAt:
type:
- string
- 'null'
format: date-time
artifactTruth:
$ref: '#/components/schemas/ArtifactTruthSummary'
linkedEvidenceUrl:
type:
- string
- 'null'
linkedPackUrl:
type:
- string
- 'null'
ReviewPackDetailPage:
type: object
required:
- recordId
- tenantId
- status
- artifactTruth
properties:
recordId:
type: integer
tenantId:
type: integer
status:
type: string
generatedAt:
type:
- string
- 'null'
format: date-time
expiresAt:
type:
- string
- 'null'
format: date-time
artifactTruth:
$ref: '#/components/schemas/ArtifactTruthSummary'
linkedReviewUrl:
type:
- string
- 'null'
linkedEvidenceUrl:
type:
- string
- 'null'

View File

@ -0,0 +1,268 @@
# Data Model: Evidence Temporal Freshness & Review Publication Trust
## Overview
This feature does not add or modify persisted domain entities. It strengthens the derived trust-propagation model that transforms existing evidence, review, and pack records into operator-facing truth across tenant-scoped detail pages and canonical summary pages.
The key design constraint is that freshness and publication trust remain derived from existing fields and relationships:
- evidence-source freshness signals
- evidence snapshot completeness and summary state
- tenant review completeness and publish blockers
- review pack linkage back to a source review and source evidence snapshot
- the existing `ArtifactTruthEnvelope` dimensions
## Existing Persistent Entities
### 1. EvidenceSnapshot
- Purpose: Immutable tenant-scoped evidence basis assembled from multiple evidence dimensions.
- Key persisted fields used by this feature:
- `id`
- `workspace_id`
- `tenant_id`
- `status`
- `completeness_state`
- `summary`
- `generated_at`
- `expires_at`
- `operation_run_id`
- `fingerprint`
- Key summary fields used by this feature:
- `dimension_count`
- `missing_dimensions`
- `stale_dimensions`
- `dimensions[]`
- `finding_count`
- `report_count`
- `operation_count`
- Relationships used by this feature:
- `items`
- `tenantReviews`
- `reviewPacks`
- `operationRun`
- `tenant`
### 2. EvidenceSnapshotItem
- Purpose: Dimension-level evidence record inside one snapshot.
- Key persisted fields used by this feature:
- `evidence_snapshot_id`
- `tenant_id`
- `dimension_key`
- `state`
- `required`
- `measured_at`
- `freshness_at`
- `source_kind`
- `source_record_type`
- `source_record_id`
- `summary_payload`
- `sort_order`
- Relationships used by this feature:
- `snapshot`
- `tenant`
### 3. TenantReview
- Purpose: Tenant-scoped review artifact anchored to one evidence snapshot.
- Key persisted fields used by this feature:
- `id`
- `workspace_id`
- `tenant_id`
- `evidence_snapshot_id`
- `status`
- `completeness_state`
- `summary`
- `generated_at`
- `published_at`
- `archived_at`
- `current_export_review_pack_id`
- `operation_run_id`
- `fingerprint`
- Key summary fields used by this feature:
- `publish_blockers[]`
- `section_state_counts.complete`
- `section_state_counts.partial`
- `section_state_counts.missing`
- `section_state_counts.stale`
- `section_count`
- `finding_count`
- `report_count`
- `operation_count`
- Relationships used by this feature:
- `evidenceSnapshot`
- `sections`
- `currentExportReviewPack`
- `reviewPacks`
- `operationRun`
- `tenant`
### 4. ReviewPack
- Purpose: Tenant-scoped export artifact derived from a review and evidence basis.
- Key persisted fields used by this feature:
- `id`
- `workspace_id`
- `tenant_id`
- `tenant_review_id`
- `evidence_snapshot_id`
- `status`
- `summary`
- `generated_at`
- `expires_at`
- `file_disk`
- `file_path`
- `file_size`
- `operation_run_id`
- `fingerprint`
- Key summary fields used by this feature:
- `review_status`
- `review_completeness_state`
- `evidence_resolution.outcome`
- `evidence_resolution.snapshot_id`
- `evidence_resolution.snapshot_fingerprint`
- `evidence_resolution.completeness_state`
- `finding_count`
- `report_count`
- `operation_count`
- Relationships used by this feature:
- `tenantReview`
- `evidenceSnapshot`
- `operationRun`
- `tenant`
## Existing Derived State Chain
### A. Evidence Freshness Derivation
Evidence freshness is already determined in the evidence domain before any UI surface is rendered.
1. Each evidence source provider returns a dimension payload with:
- `state`
- `required`
- `measured_at`
- `freshness_at`
2. `EvidenceCompletenessEvaluator` rolls required dimension states into snapshot completeness:
- `missing` wins first
- then `stale`
- then `partial`
- otherwise `complete`
3. `EvidenceSnapshotService` persists:
- `completeness_state`
- `summary.missing_dimensions`
- `summary.stale_dimensions`
- per-dimension summary state
This feature must reuse that chain instead of replacing it.
### B. Review Readiness Derivation
Tenant review readiness is already derived from section completeness and blockers.
1. `TenantReviewSectionFactory` and related composition logic produce section completeness states.
2. `TenantReviewReadinessGate` derives:
- `blockersForSections()`
- `completenessForSections()`
- `statusForSections()`
3. `TenantReview.summary` persists:
- `publish_blockers[]`
- `section_state_counts.*`
This feature must reuse those publish blockers and section-state counts rather than adding a parallel publish-readiness source.
## Derived View Models
### 1. ArtifactTruthPropagationModel
This is the conceptual chain, not a new class.
| Stage | Inputs | Derived outputs |
|---|---|---|
| Evidence source item | source-specific timestamps and source-record state | per-dimension `state`, `freshness_at`, `required` |
| Evidence snapshot | item states, snapshot status, summary counts | `artifactExistence`, `contentState`, `freshnessState`, `actionability` |
| Tenant review | review status, review completeness, section-state counts, publish blockers, linked snapshot | `artifactExistence`, `contentState`, `freshnessState`, `publicationReadiness`, `actionability` |
| Review pack | pack status, evidence resolution, linked review trust burden, linked snapshot burden | `artifactExistence`, `contentState`, `freshnessState`, `publicationReadiness`, `actionability` |
| Canonical summary row | tenant label, record timestamps, derived artifact truth envelope | row-level artifact truth, publication signal, freshness signal, next step |
### 2. SnapshotTruthModel
Derived from `EvidenceSnapshot` plus `ArtifactTruthPresenter::buildEvidenceSnapshotEnvelope()`.
| Field | Meaning | Source |
|---|---|---|
| `artifactExistence` | whether a current or historical snapshot exists | snapshot `status` |
| `contentState` | whether the evidence basis is trusted, partial, missing, or empty | snapshot `completeness_state`, `summary.dimension_count` |
| `freshnessState` | whether the evidence basis is current or stale | snapshot `completeness_state`, `summary.stale_dimensions`, historical status |
| `actionability` | whether the operator must act, may optionally act, or can do nothing | derived from freshness and completeness |
| `nextStepText` | operator-facing refresh or wait guidance | existing presenter logic |
### 3. ReviewTruthModel
Derived from `TenantReview` plus `ArtifactTruthPresenter::buildTenantReviewEnvelope()`.
| Field | Meaning | Source |
|---|---|---|
| `artifactExistence` | whether the review is current, failed, or historical | review status |
| `contentState` | whether the review body is complete enough or partial | review completeness |
| `freshnessState` | whether the review evidence basis is stale | review completeness and `summary.section_state_counts.stale` |
| `publicationReadiness` | whether the review is blocked, internal-only, or publishable | review status, publish blockers, and this feature's stricter freshness/partiality propagation |
| `actionability` | whether the operator must resolve blockers, should refresh, or can leave the review alone | derived from publication and freshness burden |
| `nextStepText` | resolve blockers, complete work, refresh evidence, or no action needed | existing presenter logic tightened by this feature |
### 4. PackTruthModel
Derived from `ReviewPack` plus `ArtifactTruthPresenter::buildReviewPackEnvelope()`.
| Field | Meaning | Source |
|---|---|---|
| `artifactExistence` | whether a current, failed, or historical pack exists | pack status |
| `contentState` | whether the pack has a trustworthy source basis | evidence resolution and source review burden |
| `freshnessState` | whether the pack should be treated as current or stale for operator trust | pack expiration plus propagated source review/evidence staleness |
| `publicationReadiness` | whether the pack is blocked, internal-only, or publishable | pack status plus propagated review and evidence burden |
| `actionability` | whether the operator must revisit the review or simply note caution | derived from the trust posture |
| `nextStepText` | open source review, refresh evidence, or no action needed | existing presenter logic tightened by this feature |
### 5. CanonicalSummaryRowModel
Used conceptually by both `EvidenceOverview` and `ReviewRegister`.
| Field | Type | Purpose |
|---|---|---|
| `tenantName` | string | Maintain tenant context in canonical pages |
| `recordId` | int | Link the summary row back to the tenant-scoped detail page |
| `artifactTruth` | label + badge spec + explanation | Primary trust summary shown in the row |
| `freshness` | badge spec | Evidence overview freshness summary |
| `publication` | badge spec | Review register publication summary |
| `nextStep` | string | Operator-facing follow-up guidance that matches detail surfaces |
| `viewUrl` | string | Drill-through to the tenant-scoped detail page |
Rules:
- Canonical rows must never appear calmer than the corresponding detail pages.
- `nextStep` may be shorter than detail guidance, but not contradictory.
- Canonical rows must stay derived from the same truth envelope rather than page-local heuristics.
## Validation Rules
- A snapshot can be structurally complete and still stale; the derived truth must preserve that distinction.
- A review can be internally useful while not being publishable; the derived truth must preserve that distinction.
- A pack must not be calmer than its source review or source evidence basis.
- Canonical overview and register rows must not contradict the tenant-scoped detail view for the same artifact.
- Freshness, completeness, and publication readiness remain separate dimensions even when they jointly influence the primary label.
## State Notes
This feature introduces no new persisted state.
Existing state and badge families that remain canonical:
- `EvidenceCompletenessState`
- `EvidenceSnapshotStatus`
- `TenantReviewCompletenessState`
- `TenantReviewStatus`
- `ReviewPackStatus`
- `BadgeDomain::GovernanceArtifactFreshness`
- `BadgeDomain::GovernanceArtifactPublicationReadiness`
The feature only changes how these existing states combine into operator-facing trust across multiple related surfaces.

View File

@ -0,0 +1,294 @@
# Implementation Plan: Evidence Temporal Freshness & Review Publication Trust
**Branch**: `174-evidence-freshness-publication-trust` | **Date**: 2026-04-04 | **Spec**: `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/specs/174-evidence-freshness-publication-trust/spec.md`
**Input**: Feature specification from `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/specs/174-evidence-freshness-publication-trust/spec.md`
## Summary
Harden evidence freshness and publication trust across the existing evidence snapshot, tenant review, review pack, evidence overview, and canonical review register surfaces without adding new persistence, a new trust layer, or a new reporting subsystem. The implementation will reuse the source-derived stale semantics and their existing source-defined freshness thresholds, tighten propagation in `ArtifactTruthPresenter`, keep review readiness and publication readiness distinct, preserve the current tenant and canonical routes and action inventory, and close the existing cross-surface gap where stale or partial evidence can still look publishable.
Key approach: keep the work inside the current `EvidenceSnapshotService` freshness semantics, `TenantReviewReadinessGate`, `ArtifactTruthPresenter`, tenant-scoped Filament resources, and canonical summary pages. The slice is primarily about better derivation and consistent display, not about new models or new workflows.
## Technical Context
**Language/Version**: PHP 8.4, Laravel 12, Filament v5, Livewire v4, Blade
**Primary Dependencies**: Filament v5, Livewire v4, Pest v4, Laravel Sail, existing `ArtifactTruthPresenter`, `ArtifactTruthEnvelope`, `TenantReviewReadinessGate`, `EvidenceSnapshotService`, `TenantReviewRegisterService`, and current evidence/review/review-pack resources and pages
**Storage**: PostgreSQL with existing `evidence_snapshots`, `evidence_snapshot_items`, `tenant_reviews`, and `review_packs` tables using current summary JSON and timestamps; no schema change planned
**Testing**: Pest feature tests and Livewire page tests run via Sail, plus existing governance-artifact fixture helpers
**Target Platform**: Laravel web application in Sail locally and containerized Linux deployment in staging and production
**Project Type**: Laravel monolith web application
**Performance Goals**: Preserve DB-only render behavior on detail and canonical surfaces, avoid any render-time external calls, keep list-row truth derivation lightweight enough for canonical table scans, and keep operator trust signals readable within a 5-10 second scan on summary surfaces
**Constraints**: No new tables, no new enum families, no new presenter or resolver subsystem, no route changes, no RBAC drift, no destructive-action placement drift, no global asset changes, and no new global freshness engine if existing source-derived stale semantics are sufficient
**Scale/Scope**: Five operator-facing surfaces (`/admin/evidence/overview`, `/admin/reviews`, `/admin/t/{tenant}/evidence/{snapshot}`, `/admin/t/{tenant}/reviews/{review}`, `/admin/t/{tenant}/review-packs/{pack}`), one central truth presenter, existing readiness helpers, and focused regression coverage across fresh, stale, partial, blocked, internal-only, and publishable scenarios
## Constitution Check
*GATE: Passed before Phase 0 research. Re-checked after Phase 1 design and still passing.*
| Principle | Status | Notes |
|-----------|--------|-------|
| Inventory-first | Pass | Evidence freshness remains derived from the existing snapshot-item and source-evaluation chain; no new snapshot ownership semantics |
| Read/write separation | Pass | This slice primarily changes truth derivation and display; existing mutate actions remain unchanged |
| Graph contract path | Pass | No new Graph calls or contract-registry changes are introduced |
| Deterministic capabilities | Pass | No new capability derivation or role mapping is added |
| RBAC-UX planes and 404 vs 403 | Pass | Tenant-scoped resources remain tenant-scoped; canonical `/admin` pages stay workspace- and tenant-entitlement-safe |
| Workspace isolation | Pass | Canonical summary pages continue to derive rows only from entitled tenants in the current workspace |
| Tenant isolation | Pass | Drill-through links remain tenant-scoped and non-entitled users remain deny-as-not-found |
| Destructive confirmation | Pass | Existing destructive actions (`Expire snapshot`, `Archive review`, `Expire pack`) already require confirmation and remain unchanged |
| Global search safety | Pass | No global-search behavior is added or broadened; `EvidenceSnapshotResource`, `TenantReviewResource`, and `ReviewPackResource` already have view pages |
| Run observability | Pass | Existing evidence, review, and pack generation flows keep their current `OperationRun` types and ownership; no new run type is introduced |
| Ops-UX 3-surface feedback | Pass | No toast, progress, or terminal-notification behavior changes |
| Ops-UX lifecycle ownership | Pass | No `OperationRun.status` or `outcome` transition logic is touched |
| Ops-UX summary counts | Pass | No changes to `summary_counts` contracts are required |
| Ops-UX guards | Pass | Existing operation lifecycle guards stay in place; this feature adds truth-surface regression tests instead |
| Data minimization | Pass | No new payload exposure or raw-report surface is introduced |
| Proportionality (PROP-001) | Pass | The implementation stays inside existing freshness, readiness, and truth layers rather than adding persistence or abstraction |
| No premature abstraction (ABSTR-001) | Pass | No new resolver, gate, presenter family, registry, or taxonomy is planned |
| Persisted truth (PERSIST-001) | Pass | All new semantics remain derived from existing timestamps, summary state, and linked records |
| Behavioral state (STATE-001) | Pass | No new persisted states are introduced; existing stale/partial/publishable/internal-only semantics become stricter |
| UI semantics (UI-SEM-001) | Pass | Existing badge and truth primitives are reused; no second interpretation framework is introduced |
| V1 explicitness / few layers (V1-EXP-001, LAYER-001) | Pass | One central presenter and existing pages remain the implementation seam |
| Badge semantics (BADGE-001) | Pass | Existing freshness, completeness, publication-readiness, and artifact-truth badge domains stay canonical |
| Filament-native UI (UI-FIL-001) | Pass | Existing Filament resources, pages, badges, tables, and infolists are reused; no page-local status language is added |
| UI/UX surface taxonomy (UI-CONST-001 / UI-SURF-001) | Pass | Evidence snapshot, tenant review, and review pack remain `CRUD / List-first Resource` surfaces with dedicated detail pages, while evidence overview and review register remain `Read-only Registry / Report Surface` surfaces |
| UI/UX inspect model (UI-HARD-001) | Pass | Clickable-row inspect remains primary on the affected lists; no redundant view action is introduced |
| UI/UX action hierarchy (UI-HARD-001 / UI-EX-001) | Pass | Existing one-inline-safe-shortcut patterns remain; no new row-level destructive actions are added |
| UI/UX scope, truth, and naming (UI-HARD-001 / UI-NAMING-001 / OPSURF-001) | Pass | The feature strengthens default-visible trust truth while preserving canonical nouns and existing verbs |
| List-surface review checklist (UI-STD-001) | Pass | The affected list and report surfaces will be reviewed against `docs/product/standards/list-surface-review-checklist.md` during implementation and final verification |
| Filament Action Surface Contract | Pass | Current surface declarations already match the required list/detail behavior; the feature changes truth semantics, not action topology |
| Filament UX-001 | Pass | Existing Infolist and table layouts remain; this slice strengthens the truth surfaces inside them |
| Filament v5 / Livewire v4 compliance | Pass | The work remains inside the current Filament v5 + Livewire v4 stack |
| Provider registration location | Pass | No panel/provider changes; Laravel 11+ provider registration remains in `bootstrap/providers.php` |
| Asset strategy | Pass | No new panel or shared assets are required; deployment `filament:assets` behavior remains unchanged |
## Phase 0 Research
Research outcomes are captured in `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/specs/174-evidence-freshness-publication-trust/research.md`.
Key decisions:
- Reuse the existing source-derived stale semantics from evidence providers and `EvidenceCompletenessEvaluator` instead of creating a new global freshness engine.
- Tighten `ArtifactTruthPresenter` rather than introducing a new publication-trust resolver or freshness-trust gate.
- Degrade tenant review and review pack publication trust when freshness is stale and downgrade partial evidence to `internal_only` unless stronger existing blockers already make the artifact `blocked`.
- Make review packs inherit stale and partial burden from their linked review and evidence basis instead of treating pack freshness as `current` whenever the file itself is not expired.
- Keep canonical register and overview surfaces aligned by reusing the same truth envelope and next-step language rather than adding page-local taxonomies or ad-hoc columns.
- Expand existing Pest coverage and fixture builders rather than creating a new UI test harness.
## Phase 1 Design
Design artifacts are created under `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/specs/174-evidence-freshness-publication-trust/`:
- `data-model.md`: derived trust-propagation model for evidence snapshots, tenant reviews, review packs, and canonical summary rows
- `contracts/evidence-review-trust-surfaces.openapi.yaml`: internal page-contract schema for the affected rendered HTML surfaces and their structured truth payloads
- `quickstart.md`: focused verification workflow for manual and automated validation
Design decisions:
- No schema migration is required; freshness remains derived from existing evidence-source timestamps and summary state.
- The primary implementation seam is `ArtifactTruthPresenter`, supported by the current evidence and review readiness services and existing page row builders.
- `TenantReviewReadinessGate` remains the publish-blocker authority for required stale or missing sections; this feature tightens how that burden is translated into operator-facing trust and publication surfaces.
- `EvidenceOverview` and `ReviewRegister` continue to render read-only summary rows, but must no longer sound calmer than the corresponding tenant-scoped detail surfaces.
- Existing destructive actions and capabilities remain unchanged; only truth presentation, next-step guidance, and consistency rules are hardened.
## Project Structure
### Documentation (this feature)
```text
specs/174-evidence-freshness-publication-trust/
├── spec.md
├── plan.md
├── research.md
├── data-model.md
├── quickstart.md
├── contracts/
│ └── evidence-review-trust-surfaces.openapi.yaml
├── checklists/
│ └── requirements.md
└── tasks.md
```
### Source Code (repository root)
```text
app/
├── Filament/
│ ├── Pages/
│ │ ├── Monitoring/
│ │ │ └── EvidenceOverview.php
│ │ └── Reviews/
│ │ └── ReviewRegister.php
│ └── Resources/
│ ├── EvidenceSnapshotResource.php
│ ├── ReviewPackResource.php
│ ├── TenantReviewResource.php
│ ├── EvidenceSnapshotResource/
│ │ └── Pages/
│ │ └── ViewEvidenceSnapshot.php
│ ├── ReviewPackResource/
│ │ └── Pages/
│ │ └── ViewReviewPack.php
│ └── TenantReviewResource/
│ └── Pages/
│ └── ViewTenantReview.php
├── Models/
│ ├── EvidenceSnapshot.php
│ ├── EvidenceSnapshotItem.php
│ ├── ReviewPack.php
│ └── TenantReview.php
├── Services/
│ ├── Evidence/
│ │ ├── EvidenceCompletenessEvaluator.php
│ │ ├── EvidenceSnapshotService.php
│ │ └── Sources/
│ │ ├── BaselineDriftPostureSource.php
│ │ ├── EntraAdminRolesSource.php
│ │ ├── FindingsSummarySource.php
│ │ ├── OperationsSummarySource.php
│ │ └── PermissionPostureSource.php
│ └── TenantReviews/
│ ├── TenantReviewReadinessGate.php
│ └── TenantReviewRegisterService.php
└── Support/
└── Ui/
└── GovernanceArtifactTruth/
├── ArtifactTruthEnvelope.php
└── ArtifactTruthPresenter.php
resources/
└── views/
└── filament/
├── infolists/
│ └── entries/
│ └── governance-artifact-truth.blade.php
└── pages/
├── monitoring/
│ └── evidence-overview.blade.php
└── reviews/
└── review-register.blade.php
routes/
└── web.php
tests/
├── Feature/
│ ├── Concerns/
│ │ └── BuildsGovernanceArtifactTruthFixtures.php
│ ├── Evidence/
│ │ ├── EvidenceOverviewPageTest.php
│ │ └── EvidenceSnapshotResourceTest.php
│ ├── Monitoring/
│ │ └── ArtifactTruthRunDetailTest.php
│ ├── ReviewPack/
│ │ └── ReviewPackResourceTest.php
│ └── TenantReview/
│ ├── TenantReviewLifecycleTest.php
│ └── TenantReviewRegisterTest.php
└── Pest.php
```
**Structure Decision**: Standard Laravel monolith. The change is concentrated in one existing truth-presenter seam, current readiness helpers, a small set of existing Filament resources/pages, and focused Pest coverage. No new base directories, services, or presentation frameworks are required.
## Implementation Strategy
### Phase A — Preserve Source-Derived Freshness As The Canonical Input
**Goal**: Keep existing provider-level stale semantics as the source of truth and document them clearly in the implementation so the feature does not accidentally invent a second freshness system.
| Step | File | Change |
|------|------|--------|
| A.1 | `app/Services/Evidence/EvidenceCompletenessEvaluator.php` | Confirm that source-level `stale` evaluation and snapshot completeness remain the canonical freshness input used by the UI truth layer |
| A.2 | `app/Support/Ui/GovernanceArtifactTruth/ArtifactTruthPresenter.php` | Keep evidence-snapshot truth derived from snapshot status, completeness, and `summary.stale_dimensions` rather than introducing new page-local age logic |
| A.3 | `specs/174-evidence-freshness-publication-trust/research.md` and `data-model.md` | Record the source-derived freshness chain so implementation does not drift into a new threshold engine |
### Phase B — Downgrade Tenant Review Publication Trust When Freshness Or Completeness Weakens Confidence
**Goal**: Ensure tenant reviews can remain useful internally while no longer appearing publishable when their evidence basis is stale or partial.
| Step | File | Change |
|------|------|--------|
| B.1 | `app/Support/Ui/GovernanceArtifactTruth/ArtifactTruthPresenter.php` | Tighten `buildTenantReviewEnvelope()` so stale freshness and partial evidence degrade publication readiness and next-step guidance appropriately |
| B.2 | `app/Services/TenantReviews/TenantReviewReadinessGate.php` | Reuse existing required-section stale and missing blocker semantics; do not create a second publish-blocker system |
| B.3 | `app/Filament/Resources/TenantReviewResource.php` and `app/Filament/Resources/TenantReviewResource/Pages/ViewTenantReview.php` | Verify the tightened truth envelope is visible on list and detail surfaces without changing action topology or copy vocabulary |
### Phase C — Propagate Source Review And Evidence Burden Into Review Pack Trust
**Goal**: Prevent review packs from appearing calmer than the review or evidence basis they were generated from.
| Step | File | Change |
|------|------|--------|
| C.1 | `app/Support/Ui/GovernanceArtifactTruth/ArtifactTruthPresenter.php` | Tighten `buildReviewPackEnvelope()` so pack freshness and publication readiness inherit stale or partial burden from the source review and evidence resolution |
| C.2 | `app/Filament/Resources/ReviewPackResource.php` and `app/Filament/Resources/ReviewPackResource/Pages/ViewReviewPack.php` | Ensure pack list/detail surfaces expose internal-only or cautionary trust and next-step caveats before download or sharing |
| C.3 | `resources/views/filament/infolists/entries/governance-artifact-truth.blade.php` | Reuse the existing truth partial to surface freshness and publication dimensions with stronger consistency rather than new local markup |
### Phase D — Align Canonical Overview And Register Rows With Tenant-Scoped Detail Truth
**Goal**: Ensure `/admin/evidence/overview` and `/admin/reviews` summarize the same truth direction as the tenant-scoped detail pages.
| Step | File | Change |
|------|------|--------|
| D.1 | `app/Filament/Pages/Monitoring/EvidenceOverview.php` | Keep row truth and next-step guidance sourced from the same envelope semantics used by snapshot detail |
| D.2 | `app/Filament/Pages/Reviews/ReviewRegister.php` | Keep artifact truth, publication readiness, and next-step rows aligned with tenant review detail without adding new ad-hoc row taxonomy |
| D.3 | `routes/web.php` and current navigation helpers | Preserve existing canonical route shape and tenant-prefilter continuity; no routing change |
### Phase E — Regression Protection And Focused Validation
**Goal**: Add the smallest useful test set that protects stale propagation, partial-evidence trust, review/pack consistency, and canonical summary alignment.
| Step | File | Change |
|------|------|--------|
| E.1 | `tests/Feature/Concerns/BuildsGovernanceArtifactTruthFixtures.php` | Add fixture helpers for stale and partial evidence scenarios so tests do not duplicate setup logic |
| E.2 | `tests/Feature/Evidence/EvidenceSnapshotResourceTest.php` | Add fresh-vs-stale snapshot truth assertions, including stale-dimension next-step behavior |
| E.3 | `tests/Feature/TenantReview/TenantReviewLifecycleTest.php` | Add stale and partial review publication-trust assertions without changing lifecycle behavior |
| E.4 | `tests/Feature/ReviewPack/ReviewPackResourceTest.php` | Add pack trust propagation assertions for stale or partial source review/evidence combinations |
| E.5 | `tests/Feature/TenantReview/TenantReviewRegisterTest.php` and `tests/Feature/Evidence/EvidenceOverviewPageTest.php` | Add canonical row-alignment assertions so summary pages do not sound calmer than detail |
| E.6 | `vendor/bin/sail bin pint --dirty --format agent` and focused Pest runs | Required formatting and targeted verification before task completion |
## Key Design Decisions
### D-001 — Source-specific stale evaluation stays canonical
Evidence sources already produce `stale` item states using their own domain-appropriate recency logic, and `EvidenceCompletenessEvaluator` already rolls those into snapshot completeness. The plan reuses that chain instead of inventing a separate global age-policy system.
### D-002 — `ArtifactTruthPresenter` remains the single trust-propagation seam
The current code already centralizes evidence snapshot, tenant review, and review pack trust in one presenter. Tightening that presenter is narrower and safer than introducing a new publication-trust resolver or freshness framework.
### D-003 — Publication readiness remains distinct from freshness and completeness
The feature must not collapse all trust dimensions into one vague `ready` state. Freshness, completeness, and publication readiness remain separate dimensions, but stale evidence downgrades share safety and partial evidence downgrades publication readiness to `internal_only` unless existing blockers already make the artifact `blocked`.
### D-004 — Canonical summary pages must reuse existing truth, not invent row-local semantics
`EvidenceOverview` and `ReviewRegister` already surface artifact truth, publication, and next-step information. The right fix is to align their inputs with the tightened truth envelope, not to add page-specific badges or prose.
### D-005 — No new persistence or reporting subsystem is justified
This is a current-release operator-trust problem, but it is still a derived-truth problem. Existing timestamps, summary state, and linked artifacts are enough to solve it without a StoredReport viewer, new export-governance model, or separate publication-trust entity.
## Risk Assessment
| Risk | Impact | Likelihood | Mitigation |
|------|--------|------------|------------|
| Review publication trust becomes too strict and downgrades healthy reviews | Medium | Medium | Base downgrades on existing stale/partial semantics already produced by evidence and review services rather than a new heuristic |
| Review pack truth diverges from review truth because pack code and review code evolve separately | High | Medium | Centralize propagation in `ArtifactTruthPresenter` and add explicit review-vs-pack consistency tests |
| Canonical summary pages become denser or harder to scan | Medium | Low | Reuse existing columns and next-step fields instead of adding more row furniture |
| `fresh` versus non-`fresh` envelope variants diverge across surfaces | Medium | Low | Keep one truth-building path and only use `fresh` variants when cache bypass is genuinely needed |
| Implementation accidentally introduces a new freshness policy or new abstraction | Medium | Low | Lock the design to current source-derived stale semantics and reject new persistence or resolver additions in review |
## Test Strategy
- Extend the current evidence, tenant review, review pack, and canonical summary Pest tests instead of adding a new testing layer.
- Use `BuildsGovernanceArtifactTruthFixtures` and existing `composeTenantReviewForTest()` helpers to build fresh, stale, and partial scenarios consistently.
- Add explicit assertions for fresh versus stale evidence snapshots, partial versus complete review evidence, review-pack versus review trust alignment, and canonical row truth alignment.
- Preserve existing authorization semantics: non-entitled users remain `404`, in-scope users without manage capability remain `403` for actions, and summary truth remains visible only within entitled scope.
- Keep destructive actions and operation-launch semantics unchanged; test additions should focus on trust consequences, not on unrelated lifecycle behavior.
- Focused verification targets: `EvidenceSnapshotResourceTest`, `TenantReviewLifecycleTest`, `ReviewPackResourceTest`, `TenantReviewRegisterTest`, `EvidenceOverviewPageTest`, and fixture helpers.
## Complexity Tracking
No constitution violations or justified complexity exceptions were identified.
## Proportionality Review
Not triggered beyond the already-passed spec review. The plan introduces no new enum/status family, DTO/presenter family, persisted entity, registry, resolver, taxonomy, or cross-domain UI framework.

View File

@ -0,0 +1,141 @@
# Quickstart: Evidence Temporal Freshness & Review Publication Trust
## Goal
Validate that stale and partial evidence now degrade review and pack trust consistently across tenant-scoped detail pages and canonical summary pages, without changing routes, authorization semantics, or the current action inventory.
## Prerequisites
1. Start Sail if it is not already running.
2. Ensure the acting user is a valid workspace member and entitled to the target tenant or tenants.
3. Prepare representative fixtures for these cases:
- fresh and complete evidence snapshot
- stale evidence snapshot
- partial evidence snapshot or review with partial sections
- review with publication blockers
- ready review pack derived from a fresh review
- ready review pack derived from a stale or partial review
## Focused Automated Verification
Run the smallest existing test set that guards the affected surfaces first:
```bash
vendor/bin/sail artisan test --compact tests/Feature/Evidence/EvidenceSnapshotResourceTest.php
vendor/bin/sail artisan test --compact tests/Feature/TenantReview/TenantReviewLifecycleTest.php
vendor/bin/sail artisan test --compact tests/Feature/ReviewPack/ReviewPackResourceTest.php
vendor/bin/sail artisan test --compact tests/Feature/TenantReview/TenantReviewRegisterTest.php
vendor/bin/sail artisan test --compact tests/Feature/Evidence/EvidenceOverviewPageTest.php
```
If implementation extends shared truth helpers or run-detail truth copy, also run:
```bash
vendor/bin/sail artisan test --compact tests/Feature/Monitoring/ArtifactTruthRunDetailTest.php
```
## Manual Validation Pass
Use one fresh tenant and one stale or partial tenant, or equivalent seeded records in the same tenant.
### 1. Evidence snapshot detail trust
Open the tenant-scoped evidence snapshot detail page for a fresh snapshot and a stale snapshot.
Confirm that:
- both snapshots can still exist structurally,
- the stale snapshot is clearly shown as stale or cautionary,
- the fresh snapshot remains current,
- next-step guidance differs appropriately,
- and no raw JSON is required to understand why the stale snapshot is less trustworthy.
### 2. Review detail trust
Open tenant reviews anchored to:
- fresh complete evidence,
- stale evidence,
- partial evidence.
Confirm that:
- review freshness and publication readiness remain separate concepts,
- stale or partial reviews can still be useful internally,
- but they do not present the same calm publishable posture as fresh complete reviews,
- and next-step guidance points toward refresh or completion work when needed.
### 3. Review pack trust
Open the review pack list and review packs linked to the reviews above.
Confirm that:
- the review-pack list row already surfaces the same internal-only or publishable caveat before the operator opens detail or clicks `Download`,
- a pack generated from stale or partial evidence no longer looks calmer than the source review,
- any internal-only or cautionary posture is visible before download,
- and the pack points back to the source review when corrective action is needed.
### 4. Canonical summary alignment
Open:
- `/admin/evidence/overview`
- `/admin/reviews`
Confirm that:
- stale or partial artifacts are visible as such in the row summaries,
- the next-step language is directionally the same as on detail pages,
- a tenant with fresh evidence but no current review shows a next step that points toward review creation rather than implying review readiness already exists,
- and drill-through links preserve tenant context and do not reveal non-entitled tenants.
### 5. Ten-second scan validation
Timebox the first visible scan of one snapshot detail page, one tenant review detail page, and one review pack detail page to 10 seconds each.
Confirm that within that time an operator can tell:
- whether the artifact is fresh enough,
- whether it is only internally useful or publishable,
- and what the next action is.
### 6. Authorization and action non-regression
Confirm that:
- view-only users can still inspect truth but not execute manage actions,
- non-entitled users still receive deny-as-not-found behavior,
- existing destructive actions still require confirmation,
- touched refresh, publish, export, regenerate, or create-next-review handlers still dispatch the existing services and current `OperationRun` types where applicable,
- and no new actions or route changes were introduced as part of the hardening.
### 7. Shared list-surface review checklist
Review `docs/product/standards/list-surface-review-checklist.md` against the touched list and report surfaces.
Confirm that:
- `/admin/evidence/overview` and `/admin/reviews` still use row click as the primary inspect model,
- the tenant-scoped review-pack list keeps its row-level trust caveat visible before drill-through or download,
- existing inline safe shortcuts and header actions remain in their established positions,
- empty and filtered states still read clearly without hiding trust truth,
- and default-visible row summaries still surface freshness, publishable posture, and next step without requiring drill-through.
### 8. Performance and render guardrails
Confirm from the implementation diff and final surface behavior that:
- the touched detail and canonical surfaces still render from existing database-backed truth inputs,
- no new render-time external calls, background dispatches, or route changes were introduced,
- and canonical row truth remains lightweight enough for a normal operator scan without adding a new per-row derivation layer.
## Formatting And Final Verification
Before finalizing implementation work:
```bash
vendor/bin/sail bin pint --dirty --format agent
```
Then rerun the smallest affected test set. If the user wants broader confidence afterward, offer the full suite.

View File

@ -0,0 +1,60 @@
# Research: Evidence Temporal Freshness & Review Publication Trust
## Decision 1: Reuse the existing source-derived stale semantics instead of introducing a global freshness engine
- Decision: Treat provider-level `stale` evaluation and the existing `EvidenceCompletenessEvaluator` as the canonical source of temporal freshness for this feature.
- Rationale: Evidence freshness is already derived upstream in the evidence domain. `EvidenceSnapshotService` collects per-dimension payloads, providers such as `BaselineDriftPostureSource` already mark data stale using domain-appropriate recency rules, and `EvidenceCompletenessEvaluator` already escalates any required stale dimension into snapshot completeness. A second global threshold engine would duplicate truth and risk disagreement with the evidence domain.
- Alternatives considered:
- Add a new global `freshness_threshold_hours` configuration and recompute staleness on the page layer. Rejected because the existing evidence sources already encode domain-aware freshness and the page layer should not create its own recency semantics.
- Derive staleness purely from `generated_at` on snapshots and reviews. Rejected because the meaningful freshness signal already exists at the dimension level, and a snapshot can be freshly generated from stale inputs.
## Decision 2: Tighten `ArtifactTruthPresenter` instead of adding a new trust or publication resolver
- Decision: Keep `ArtifactTruthPresenter` as the single seam for evidence snapshot, tenant review, and review pack trust propagation.
- Rationale: The presenter already builds envelopes for `EvidenceSnapshot`, `TenantReview`, and `ReviewPack`, including freshness, content, publication, actionability, and next-step guidance. The current bug is not the absence of a trust layer, but that stale and partial semantics are not fully propagated through the existing one.
- Alternatives considered:
- Create a dedicated `PublicationReadinessResolver` or `FreshnessTrustGate`. Rejected because the current architecture already centralizes the needed truth and a second layer would violate the repo's proportionality and few-layers rules.
- Push the logic into page classes. Rejected because it would fragment truth across summary pages and detail resources.
## Decision 3: Tenant review publication readiness must degrade when freshness is stale
- Decision: Make stale review freshness capable of downgrading a review from publishable to an internal-only or cautionary posture even when the review status is `ready` or `published`.
- Rationale: `TenantReviewReadinessGate` already treats stale required sections as publication blockers at composition time, and `buildTenantReviewEnvelope()` already calculates a `freshnessState` of `stale`. The current contradiction is that publication readiness still becomes `publishable` from status alone, which lets stale reviews sound calmer than the evidence basis justifies.
- Rule: A stale review may remain internally useful, but it must not remain `publishable` solely because its lifecycle status is `ready` or `published`. If stronger blockers already exist, `blocked` still takes precedence.
- Alternatives considered:
- Leave publication readiness based only on status and blocker arrays. Rejected because it allows a stale review to look publishable even when the same presenter calls it stale.
- Introduce a new persisted review trust field. Rejected because the stale condition is already derivable from review completeness and section counts.
## Decision 4: Partial evidence must lower publication confidence even when it does not fully block review use
- Decision: Distinguish between reviews that are usable internally and reviews that are truly publishable when the evidence basis is partial.
- Rationale: The spec requires a meaningful difference between internal-use artifacts and publishable artifacts. The current truth model already supports separate `contentState`, `freshnessState`, `publicationReadiness`, and `actionability` dimensions, so the implementation can express this nuance without new state families.
- Rule: Partial evidence downgrades publication readiness to `internal_only` unless stronger existing blockers already make the artifact `blocked`.
- Alternatives considered:
- Treat partial evidence exactly like complete evidence whenever hard blockers are absent. Rejected because that is the current trust problem.
- Convert all partial evidence into hard publish blockers. Rejected because the spec wants internal-use versus publishable to remain distinct, not collapsed into blocked versus not blocked.
## Decision 5: Review packs must inherit stale and partial burden from the linked review and evidence basis
- Decision: Make `ReviewPack` truth inherit source review and evidence burden rather than treating a ready, non-expired file as inherently current and publishable.
- Rationale: `buildReviewPackEnvelope()` currently treats pack freshness as `current` whenever the file is not expired, even if the source review or source evidence is stale. That allows packs to appear calmer than the review they were generated from, which is exactly the contradiction this spec is meant to close.
- Rule: A pack is only `publishable` when its source review remains current and strong enough to publish. Stale or partial source burden must downgrade the pack to `internal_only`, unless stronger blockers already make it `blocked`.
- Alternatives considered:
- Keep pack freshness tied only to file expiration. Rejected because a current file can still be generated from stale governance evidence.
- Persist a separate pack trust state. Rejected because the necessary source review and evidence inputs already exist.
## Decision 6: Canonical summary pages should reuse the same truth envelope and next-step semantics
- Decision: Keep `EvidenceOverview` and `ReviewRegister` aligned by reusing the same envelope semantics that power the tenant-scoped detail pages.
- Rationale: Both canonical pages already display artifact truth and next-step or publication surfaces. The current risk is not missing UI slots, but summary rows sounding calmer than the detail pages because stale and partial burden are not fully represented in the underlying truth.
- Alternatives considered:
- Add new page-specific columns, banners, or taxonomy labels. Rejected because the repo already has canonical truth and badge primitives.
- Leave canonical summary rows untouched and rely on drill-through. Rejected because the spec explicitly targets trust propagation on summary surfaces.
## Decision 7: Expand the current Pest test surfaces and fixture helpers instead of creating a new test harness
- Decision: Extend the current evidence, review, pack, review-register, and evidence-overview tests, and strengthen shared governance-artifact fixtures.
- Rationale: The current suite already covers resource routing, basic artifact truth visibility, review publication blockers, and canonical row scoping. The missing coverage is specific: fresh versus stale propagation, partial-evidence publication trust, and review-versus-pack consistency.
- Alternatives considered:
- Rely on manual browser validation only. Rejected because this feature is about preventing semantic drift across multiple related surfaces.
- Add a separate browser suite as the primary guard. Rejected because the existing Pest feature surfaces are already well aligned with the affected code paths and will be faster and cheaper to maintain.

View File

@ -0,0 +1,237 @@
# Feature Specification: Evidence Temporal Freshness & Review Publication Trust
**Feature Branch**: `174-evidence-freshness-publication-trust`
**Created**: 2026-04-04
**Status**: Draft
**Input**: User description: "Spec 174 — Evidence Temporal Freshness & Review Publication Trust"
## Spec Scope Fields *(mandatory)*
- **Scope**: tenant + canonical-view
- **Primary Routes**:
- `/admin/t/{tenant}/evidence` and `/admin/t/{tenant}/evidence/{snapshot}` for tenant-scoped evidence snapshot inspection
- `/admin/t/{tenant}/reviews` and `/admin/t/{tenant}/reviews/{review}` for tenant-scoped review lifecycle and publication decisions
- `/admin/t/{tenant}/review-packs` and `/admin/t/{tenant}/review-packs/{pack}` for tenant-scoped executive pack generation, download, and trust communication
- `/admin/reviews` for the canonical review register across entitled tenants
- `route('admin.evidence.overview')` for the canonical evidence overview across entitled tenants
- **Data Ownership**:
- Tenant-owned: stored reports, evidence snapshots, evidence snapshot items, tenant reviews, and review packs remain tenant-scoped artifacts anchored to one tenant's governance history
- Workspace-owned but tenant-filtered: canonical register and overview pages aggregate only within the operator's entitled workspace and tenant set, without changing ownership of the underlying artifacts
- This feature introduces no new persisted trust record, no new freshness record, and no new export-tracking entity; truth remains derived from existing timestamps, completeness states, and artifact links
- **RBAC**:
- Workspace membership and tenant entitlement remain required for all tenant-scoped evidence, review, and pack routes
- Existing capabilities remain authoritative: `evidence.view` / `evidence.manage`, `tenant_review.view` / `tenant_review.manage`, and `review_pack.view` / `review_pack.manage`
- Canonical review and evidence surfaces must preserve deny-as-not-found semantics for non-members and non-entitled users, and must not expose cross-tenant artifact existence, trust posture, or readiness hints outside the authorized tenant set
For canonical-view specs, the spec MUST define:
- **Default filter behavior when tenant-context is active**: When an operator opens the canonical review register or canonical evidence overview from a tenant-scoped surface, the destination opens prefiltered to that tenant through the existing tenant-prefilter mechanisms so the operator stays in the same tenant world they clicked from.
- **Explicit entitlement checks preventing cross-tenant leakage**: Canonical review rows, evidence rows, artifact-truth badges, publication-readiness badges, next-step text, filters, and drill-through links must only be built after workspace membership and tenant-entitlement checks. Non-entitled users must not learn whether another tenant has a current review, a stale evidence snapshot, or an exportable pack.
## UI/UX Surface Classification *(mandatory when operator-facing surfaces are changed)*
Affected list and report surfaces in this spec MUST also be reviewed against `docs/product/standards/list-surface-review-checklist.md` before implementation sign-off so row-click behavior, inline action discipline, empty states, and summary truth remain aligned with the shared operator-surface standard.
| 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 |
|---|---|---|---|---|---|---|---|---|---|---|---|
| Evidence snapshot resource | CRUD / List-first Resource | Full-row click from list to snapshot detail | required | One `More` menu for non-primary list actions | `Expire snapshot` in `More` or detail header | `/admin/t/{tenant}/evidence` | `/admin/t/{tenant}/evidence/{snapshot}` | Tenant context and evidence completeness badges scope every row and action | Evidence / Evidence snapshot | Artifact truth, completeness, freshness pressure, and next step | none |
| Tenant review resource | CRUD / List-first Resource | Full-row click from list to tenant review detail | required | One inline safe shortcut (`Export executive pack`) plus detail-header support actions | `Archive review` in detail header `More` group | `/admin/t/{tenant}/reviews` | `/admin/t/{tenant}/reviews/{review}` | Tenant context, review status, completeness, publication readiness, and evidence basis stay explicit | Reviews / Review | Artifact truth, completeness, publication readiness, and next step | none |
| Canonical review register | Read-only Registry / Report Surface | Clickable row to the tenant-scoped review detail that matches the row | required | One inline safe shortcut (`Export executive pack`) plus header clear-filters action | none | `/admin/reviews` | Tenant-scoped review detail for the selected row | Tenant labels, publication badges, completeness badges, and tenant-prefilter state | Reviews / Review | Cross-tenant review truth, publication readiness, and next step | canonical-view registry |
| Review pack resource | CRUD / List-first Resource | Full-row click from list to pack detail | required | One inline safe shortcut (`Download`) plus detail-header support actions | `Expire` in overflow or detail header | `/admin/t/{tenant}/review-packs` | `/admin/t/{tenant}/review-packs/{pack}` | Tenant context, linked review, review status, pack status, and artifact truth remain visible | Review Packs / Review pack | Publication readiness, freshness burden, and whether export is internal-only or publishable | none |
| Evidence overview | Read-only Registry / Report Surface | Clickable row to the tenant-scoped evidence snapshot detail for the selected tenant | required | Header clear-filters action only | none | `route('admin.evidence.overview')` | `/admin/t/{tenant}/evidence/{snapshot}` for the selected row | Tenant labels, artifact truth, freshness badges, and next-step text keep the overview scoped | Evidence / Evidence snapshot | Freshness and completeness truth across entitled tenants | canonical-view registry |
## 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 |
|---|---|---|---|---|---|---|---|---|---|
| Evidence snapshot detail | Tenant operator | Detail-first Operational Surface | Is this evidence basis complete enough, fresh enough, and trustworthy enough to use right now? | Artifact truth, completeness state, freshness pressure, missing or stale dimensions, and next-step guidance | Raw summary JSON, deep dimension payloads, fingerprints, source record IDs | artifact existence, content completeness, temporal freshness, actionability | TenantPilot evidence lifecycle only | Refresh evidence, view linked operation, inspect dimensions | Expire snapshot |
| Tenant review detail | Tenant operator | Detail-first Operational Surface | Is this review merely stored, ready for internal use, or strong enough to publish externally? | Artifact truth, completeness, publication readiness, review blockers or warnings, evidence basis, and next-step guidance | Full section payloads, raw summary JSON, historical audit context, operation metadata | artifact existence, completeness, temporal freshness, publication readiness, actionability | TenantPilot review lifecycle and export initiation | Refresh review, publish review, export executive pack, create next review, open evidence | Archive review |
| Canonical review register | Workspace auditor or operator | Read-only Registry / Report Surface | Which tenants currently have review artifacts that are fresh enough, publication-ready enough, or in need of follow-up? | Review status, artifact truth, completeness, publication readiness, and next-step text per tenant | Deep section content, raw evidence payloads, audit internals | review lifecycle, artifact truth, completeness, publication readiness | Read-only registry plus export initiation where already allowed | Open review, export executive pack | none |
| Review pack detail | Tenant operator | Detail-first Operational Surface | Is this pack merely downloadable, or is it publishable enough to treat as a stakeholder-facing export? | Artifact truth, pack status, linked review, evidence basis summary, publication readiness, and download caveat when needed | Fingerprints, previous fingerprint, low-level generation metadata | artifact existence, freshness pressure, publication readiness, expiration state | TenantPilot export lifecycle only | Download, regenerate pack, open linked review | Expire pack |
| Evidence overview | Workspace auditor or operator | Read-only Registry / Report Surface | Which entitled tenants currently have fresh, complete evidence and which require follow-up before review or publication? | Tenant, artifact truth, freshness state, missing or stale dimensions, and next step | Per-dimension detail payloads and raw summary JSON remain on snapshot detail | artifact truth, freshness, completeness | none | Open evidence snapshot | none |
## Proportionality Review *(mandatory when structural complexity is introduced)*
- **New source of truth?**: No. Evidence snapshots, review records, review packs, and the existing artifact-truth layer remain the authoritative truth sources.
- **New persisted entity/table/artifact?**: No. This feature explicitly avoids new persistence and must derive freshness and publication trust from existing timestamps, completeness states, and links.
- **New abstraction?**: No. The narrowest correct implementation is to harden the existing `ArtifactTruthPresenter`, readiness gates, and surface mappings rather than adding a second trust framework.
- **New enum/state/reason family?**: No. Existing freshness and publication-readiness dimensions should be tightened, not replaced with a new persisted status family.
- **New cross-domain UI framework/taxonomy?**: No. This is a surface-truth hardening slice inside the existing evidence/review/export chain.
- **Current operator problem**: Structurally complete evidence can currently read as current enough and publishable enough even when it is too old or only partial, which risks operators exporting or publishing artifacts that are not decision-grade.
- **Existing structure is insufficient because**: Completeness and publication surfaces already exist, but temporal freshness and partial-evidence burden are not strong enough in the artifact-truth and review-readiness chain to keep surfaces from sounding calmer than the underlying evidence basis.
- **Narrowest correct implementation**: Tighten the existing artifact-truth and readiness evaluation to degrade stale or partial artifacts visibly on evidence, review, register, and pack surfaces, while preserving current resources and actions.
- **Ownership cost**: The repo takes on a small amount of additional cross-surface regression coverage and ongoing maintenance for freshness-threshold and publication-caveat rules.
- **Alternative intentionally rejected**: A new StoredReport resource, a new export governance table, or a separate portfolio-trust layer was rejected because the immediate product risk can be solved by tightening the current evidence/review/export truth chain.
- **Release truth**: Current-release truth. Operators can already act on these surfaces today, so trust hardening cannot wait for a later reporting overhaul.
## User Scenarios & Testing *(mandatory)*
### User Story 1 - Detect Stale Evidence Before Reusing It (Priority: P1)
As a tenant operator, I want evidence, reviews, and packs to tell me when their underlying evidence basis is too old to rely on confidently, so that I do not make governance decisions on structurally complete but outdated artifacts.
**Why this priority**: Silent evidence aging is the highest trust risk in this domain because it can make calm-looking artifacts unsafe for real decisions.
**Independent Test**: Can be fully tested by seeding fresh and old evidence snapshots with the same structural completeness and verifying that only the fresh artifacts read as current enough for confident reuse.
**Acceptance Scenarios**:
1. **Given** an evidence snapshot is structurally complete but one or more required evidence dimensions have aged past their canonical source-defined freshness thresholds, **When** an operator opens the snapshot, linked review, or linked pack surface, **Then** the artifact is shown as stale or cautionary rather than quietly current.
2. **Given** an evidence snapshot is structurally complete and still within the freshness threshold, **When** an operator opens the snapshot, linked review, or linked pack surface, **Then** the freshness dimension allows the artifact to remain in a current or fresher state when no other trust limiter applies.
3. **Given** a canonical register or overview lists both fresh and stale artifacts, **When** the operator scans the table, **Then** freshness burden is visible without opening every row.
---
### User Story 2 - Distinguish Internal-Use Reviews From Publishable Reviews (Priority: P1)
As a tenant operator, I want review and pack surfaces to distinguish between internally useful artifacts and truly publishable artifacts, so that I do not treat downloadability or technical readiness as proof that a report is publishable.
**Why this priority**: Export and publication are the moments where a misleadingly calm artifact becomes an external trust problem.
**Independent Test**: Can be fully tested by rendering reviews and packs built from complete evidence, partial evidence, and stale evidence, then verifying that their readiness and publication signals diverge appropriately.
**Acceptance Scenarios**:
1. **Given** a tenant review is structurally ready but built on partial evidence, **When** the operator views the review, **Then** the surface shows a cautionary or internal-only publication posture rather than the same calm signal used for publishable reviews.
2. **Given** a review pack is downloadable but derived from stale or partial evidence, **When** the operator opens or downloads the pack, **Then** the surface discloses that the pack is better suited to internal use or review refresh rather than quiet external sharing.
3. **Given** a tenant review and its current export pack share the same stale or partial evidence burden, **When** the operator compares their surfaces, **Then** both surfaces communicate consistent trust and publication signals.
---
### User Story 3 - Recover The Evidence Basis Across Review Surfaces (Priority: P2)
As a workspace operator reviewing multiple tenants, I want canonical review and evidence surfaces to preserve the link between review status, evidence freshness, and next action, so that I can tell which tenants are review-ready and which need evidence refresh before publication.
**Why this priority**: Portfolio-level review work depends on summary surfaces carrying the same truth as the detail surfaces they summarize.
**Independent Test**: Can be fully tested by seeding tenants with mixed evidence freshness and review readiness and verifying that review-register and evidence-overview rows present the same trust direction as the underlying detail pages.
**Acceptance Scenarios**:
1. **Given** two tenants have reviews with different evidence freshness burdens, **When** the operator scans the review register, **Then** the table communicates which review is fresher and which needs follow-up.
2. **Given** a tenant has fresh evidence but no current review, **When** the operator scans canonical surfaces, **Then** the surfaces show a next step that points toward review creation rather than implying review readiness already exists.
3. **Given** a tenant has stale evidence and a previously generated pack, **When** the operator follows drill-through links between overview and detail pages, **Then** the trust explanation remains coherent across the journey.
### Edge Cases
- A run can complete and create an evidence snapshot, but the snapshot can later age past the acceptable freshness window without any structural completeness change.
- A review can have all sections present yet still rely on partial sections whose evidence basis should reduce publication confidence.
- A pack can remain downloadable after its linked review or evidence basis becomes stale; the surface must warn rather than silently remain calm.
- A stored report referenced by an evidence item can be pruned by retention while the evidence snapshot still exists; this feature must not require a new raw-report viewer to remain truthful.
- A user may be entitled to a tenant review but not to export or manage it; readiness and trust truth may still be visible, while actions remain capability-gated.
- Canonical register and overview pages may be prefiltered from a tenant context; they must remain semantically consistent without leaking artifacts from non-entitled tenants.
## Requirements *(mandatory)*
**Constitution alignment (required):** This feature introduces no new Microsoft Graph calls and no new long-running flow. It hardens how existing evidence, review, and pack artifacts communicate trust. Existing review and pack actions continue to use their current services and current `OperationRun` types. No new contract registry entry or queued process is added.
**Constitution alignment (PROP-001 / ABSTR-001 / PERSIST-001 / STATE-001 / BLOAT-001):** This slice adds no persistence, no new abstraction layer, and no new persisted status family. The current operator problem is that structurally complete artifacts can look newer and safer than they are. Existing structure is insufficient because completeness and publication surfaces are not yet hard enough against age and partial evidence. The narrowest correct implementation is to strengthen the existing artifact-truth and readiness logic. Ownership cost is limited to regression coverage and threshold maintenance. A separate reporting or StoredReport framework is intentionally rejected in this slice.
**Constitution alignment (OPS-UX):** No new `OperationRun` type is added. Existing evidence snapshot generation, review composition, review refresh, and review-pack generation continue to own execution lifecycle through current services and current run types. This feature only changes how resulting artifacts communicate trust and next action after those runs complete.
**Constitution alignment (RBAC-UX):** The feature affects the tenant/admin plane for tenant-scoped evidence, reviews, and review packs, plus canonical admin pages for the review register and evidence overview. Non-members and non-entitled users remain `404`. In-scope members lacking `evidence.manage`, `tenant_review.manage`, or `review_pack.manage` remain `403` for the corresponding actions. Export and publication affordances must stay capability-aware while still allowing truth signals to appear for view-only users.
**Constitution alignment (OPS-EX-AUTH-001):** Not applicable. No authentication handshake behavior is changed.
**Constitution alignment (BADGE-001):** Existing centralized badge domains for tenant review status, tenant review completeness, evidence completeness, review pack status, governance artifact freshness, and related artifact-truth labels remain the semantic source. This feature may change when those badge families show caution or downgrade states, but must not introduce local page-only badge mappings.
**Constitution alignment (UI-FIL-001):** The feature reuses native Filament resources, infolists, tables, actions, badges, sections, and existing governance artifact-truth entry views. Local replacement markup for core truth surfaces should be avoided. Any stronger stale or internal-only emphasis should still be expressed through Filament props and central badge or truth primitives rather than page-local color languages.
**Constitution alignment (UI-NAMING-001):** The target objects are evidence snapshots, reviews, and review packs. Primary operator-facing vocabulary remains `Create snapshot`, `Refresh evidence`, `Create review`, `Refresh review`, `Publish review`, `Export executive pack`, `Generate first pack`, `Download`, and `Expire`. New or revised copy must preserve the difference between `current`, `stale`, `partial`, `internal use`, `publication ready`, and `blocked`, and must avoid implementation-first wording such as `snapshot age rule`, `trust envelope`, or `derived state` in primary operator labels.
**Constitution alignment (UI-CONST-001 / UI-SURF-001 / UI-HARD-001 / UI-EX-001 / UI-REVIEW-001):** The affected surfaces consist of `CRUD / List-first Resource` lists for snapshots, reviews, and review packs, `Read-only Registry / Report Surface` pages for the canonical overview and register, and dedicated `Detail-first Operational Surface` pages for artifact inspection and action context. Each keeps one primary inspect model: row-click for snapshot, review, register, and pack tables; detail-header actions for lifecycle mutations; and diagnostic subsections for raw summaries. Critical truth visible by default must include artifact truth, freshness burden, completeness burden, publication readiness, and next step where relevant. This feature does not introduce redundant `View` affordances or move destructive actions out of their current safe placements.
**Constitution alignment (OPSURF-001):** Operator-first content on evidence, review, and pack surfaces must separate execution truth, artifact existence, completeness, freshness, and publication readiness rather than flattening them into one vague `ready` state. Diagnostics such as raw summary JSON, fingerprints, and low-level IDs remain secondary. Mutating actions continue to act on TenantPilot-managed artifacts only.
**Constitution alignment (UI-SEM-001 / LAYER-001 / TEST-TRUTH-001):** Direct mapping from persisted artifact status alone is insufficient because artifact trust depends on cross-cutting freshness and completeness context. This feature must strengthen the existing artifact-truth layer rather than adding a parallel presenter family. Tests must verify business consequences such as stale artifacts not appearing publishable, partial evidence downgrading publication trust, and review/pack surfaces staying consistent.
**Constitution alignment (Filament Action Surfaces):** The Action Surface Contract remains satisfied. Evidence, review, and pack resources keep one primary inspect model per list, no redundant row-level view actions, and confirmation-gated destructive actions where applicable. Review register and evidence overview remain read-only registry surfaces. UI-FIL-001 remains satisfied with no new exception required.
**Constitution alignment (UX-001 — Layout & Information Architecture):** Existing detail screens remain sectioned Infolist-style pages. This feature strengthens the top-level truth and caveat zones on those screens rather than converting them into forms or custom layouts. Tables remain searchable, sortable, and filterable where already supported.
### Functional Requirements
- **FR-174-001**: Evidence snapshots and every artifact derived from them MUST distinguish structural completeness from temporal freshness. A structurally complete artifact MUST NOT automatically read as current enough solely because all expected dimensions are present.
- **FR-174-002**: Temporal freshness MUST be evaluated from existing evidence timestamps using a clearly defined freshness policy for each evidence dimension. Where source-specific freshness thresholds are already canonical in the evidence domain, this feature MUST reuse them rather than inventing a second global threshold.
- **FR-174-003**: When one or more required evidence dimensions exceed their canonical freshness policy, the snapshot, any review anchored to it, and any review pack anchored to that review or snapshot MUST visibly degrade into a stale or cautionary trust state.
- **FR-174-004**: Stale evidence MUST influence the artifact-truth summary that operators see on evidence snapshot detail, tenant review detail, review register rows, review pack detail, review pack list rows, and evidence overview rows.
- **FR-174-005**: A tenant review or review pack based on stale evidence MUST NOT present the same calm `current`, `ready`, or publishable impression as a fresh artifact.
- **FR-174-006**: Partial evidence MUST materially affect review and pack trust. If a review or review pack is structurally complete but its evidence basis is partial, the surface MUST downgrade publication readiness to `internal_only`, unless existing stronger publish blockers already make it `blocked`, instead of using the same publishable posture used for stronger evidence.
- **FR-174-007**: Review readiness and publication readiness MUST remain visibly distinct. An artifact may be ready for internal inspection while still requiring caution or restriction for external sharing.
- **FR-174-008**: Download or export affordances for review packs MUST surface when a pack is safer for internal use than for external sharing. A technically downloadable pack MUST NOT silently imply publishable trust.
- **FR-174-009**: Review and pack surfaces MUST explain the principal reason for trust reduction when freshness or evidence quality lowers confidence, including stale evidence, partial sections, or missing or stale dimensions where relevant.
- **FR-174-010**: When an artifact is stale, partial, internal-only, or otherwise not publishable, the surface MUST provide a clear next step such as refreshing evidence, refreshing the review, resolving missing evidence, or generating a new pack.
- **FR-174-011**: Canonical review and evidence registry surfaces MUST stay semantically aligned with their tenant-scoped detail surfaces. A row that reads stale, partial, or internal-only on a detail page MUST not read calm or publishable in its register row.
- **FR-174-012**: Tenant review detail and review pack detail MUST not send contradictory publication or trust signals for artifacts that share the same stale or partial evidence burden.
- **FR-174-013**: This feature MUST preserve existing tenant-scoped and canonical prefilter navigation semantics so operators can follow truth from snapshot to review to pack without losing tenant context.
- **FR-174-014**: The feature MUST not require a new StoredReport surface, a new persistence model, or a new export-governance artifact in order to become truthful about stale or partial evidence.
- **FR-174-015**: Existing evidence, review, and pack actions MUST keep their current capability checks, confirmation behavior, and run-launch semantics while the surrounding truth language becomes stricter.
- **FR-174-016**: Regression coverage MUST verify fresh vs stale evidence behavior, complete vs partial evidence behavior, review vs pack truth consistency, and download or export caveat behavior without relying on a new schema artifact.
## 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 |
|---|---|---|---|---|---|---|---|---|---|---|
| Evidence snapshot resource | `app/Filament/Resources/EvidenceSnapshotResource.php` | `Create snapshot` on list header | `recordUrl()` clickable row to snapshot detail | `More` group currently carries `Expire snapshot` | none | `Create first snapshot` | `Refresh evidence`, `Expire snapshot`, existing related links | n/a | existing evidence lifecycle audit remains | This spec changes truth presentation and next-step guidance, not the action inventory |
| Tenant review resource | `app/Filament/Resources/TenantReviewResource.php` and `Pages/ViewTenantReview.php` | `Create review` on list header | `recordUrl()` clickable row to review detail | `Export executive pack` inline shortcut on list | none | `Create first review` | `Open operation`, `View executive pack`, `View evidence snapshot`, `Refresh review`, `Publish review`, `Export executive pack`, `Create next review`, `Archive review` | n/a | existing review lifecycle audit remains | Publication-trust hardening must apply consistently across list and detail |
| Canonical review register | `app/Filament/Pages/Reviews/ReviewRegister.php` | `Clear filters` | `recordUrl()` clickable row to tenant-scoped review detail | `Export executive pack` safe row action where currently allowed | none | `Clear filters` | n/a | n/a | no new audit behavior | Canonical registry remains read-only apart from the existing export shortcut |
| Review pack resource | `app/Filament/Resources/ReviewPackResource.php` | `Generate pack` on list header | `recordUrl()` clickable row to pack detail | `Download` | none | `Generate first pack` | `Download`, `Regenerate`, `Expire`, and existing related links | n/a | existing pack lifecycle audit remains | Download remains allowed where currently allowed, but its trust caveat must become explicit |
| Evidence overview | `app/Filament/Pages/Monitoring/EvidenceOverview.php` | `Clear filters` when tenant-prefilter is active | Clickable row to tenant-scoped evidence snapshot detail | none | none | `Clear filters` in the empty state | n/a | n/a | no new audit behavior | Read-only canonical overview; this spec makes freshness truth stronger here |
### Key Entities *(include if feature involves data)*
- **StoredReport**: Raw tenant-scoped point-in-time source data such as permission posture or Entra admin roles captures. It is an input to evidence, not itself a review-grade artifact.
- **EvidenceSnapshot**: Immutable tenant-scoped evidence basis composed from multiple dimensions and evaluated for completeness and freshness.
- **EvidenceSnapshotItem**: A dimension-level evidence record linking an evidence snapshot to its source kind, source record, fingerprint, measured time, and completeness state.
- **TenantReview**: A review artifact anchored to one evidence snapshot and evaluated for completeness, publication readiness, and next action.
- **ReviewPack**: A tenant-scoped export artifact derived from a review or evidence basis, intended for download and potential sharing, with integrity and lifecycle metadata.
- **Artifact truth**: The operator-facing evaluation of artifact existence, completeness, freshness, publication readiness, and actionability that determines whether an artifact is merely stored, internally useful, or safe enough to publish.
## Success Criteria *(mandatory)*
### Measurable Outcomes
- **SC-174-001**: In seeded review exercises, operators can determine within 10 seconds whether an evidence snapshot, tenant review, or review pack is fresh enough, stale, partial, or publishable without opening raw JSON or source records.
- **SC-174-002**: In regression coverage, every scenario built on stale evidence or partial evidence shows a more cautious trust or publication state than the equivalent fresh-and-complete scenario across snapshot, review, and pack surfaces.
- **SC-174-003**: In regression coverage, review-register rows, evidence-overview rows, tenant review detail, and review-pack detail agree on the trust direction of the same underlying artifact in 100% of covered stale and partial scenarios.
- **SC-174-004**: In operator validation, downloadable but internal-only or cautionary packs are recognizably different from publishable packs before the operator clicks `Download`.
- **SC-174-005**: The feature ships without a required schema migration, a new persisted trust artifact, or a new raw StoredReport resource.
## Assumptions
- Existing evidence snapshot timestamps and item timestamps are sufficient to derive temporal freshness without creating a new persistence model.
- Existing artifact-truth and review-readiness logic are the right places to strengthen stale and partial-evidence semantics.
- Existing review-pack download behavior can remain technically available while its trust and sharing guidance becomes more explicit.
- StoredReport observability and source-liveness follow-up work may still be useful later, but are not prerequisites for this truth-hardening slice.
## Non-Goals
- Creating a dedicated StoredReport resource or raw report viewer
- Building a live-state-versus-historical-state diff engine for published reviews
- Adding stakeholder-delivery tracking or external-share audit semantics
- Rebuilding Evidence Overview or Review Register into a new portfolio application
- Introducing new tables, new status enums, or a second artifact-truth framework
## Dependencies
- Existing `EvidenceSnapshotResource`, `TenantReviewResource`, `ReviewPackResource`, `ReviewRegister`, and `EvidenceOverview` surfaces
- Existing `ArtifactTruthPresenter`, `ArtifactTruthEnvelope`, review readiness logic, and review-pack generation services
- Existing capability and tenant-entitlement enforcement for evidence, reviews, and packs
- Existing evidence-domain, tenant-review, and review-pack foundational specs already implemented in the repo
## Follow-up Spec Candidates
- **StoredReport Observability & Source Liveness** for explicit raw-report diagnostics and pruned-source visibility
- **Evidence / Review Portfolio Cross-Linking** for tighter canonical navigation between evidence overview and review register
- **Published Review Drift & Superseded Evidence Signals** for signaling when a published review no longer reflects the current evidence basis
## Definition of Done
Spec 174 is complete when:
- structurally complete artifacts no longer read as current enough when their evidence basis is too old,
- partial evidence produces a visibly more cautious review and pack trust posture,
- review and pack surfaces stay consistent about the same stale or partial burden,
- export and download actions no longer imply stronger trust than the underlying artifact supports,
- canonical register and overview surfaces reflect the same truth direction as tenant-scoped detail surfaces,
- and the hardening is achieved without new persistence or a new reporting subsystem.

View File

@ -0,0 +1,226 @@
# Tasks: Evidence Temporal Freshness & Review Publication Trust
**Input**: Design documents from `/specs/174-evidence-freshness-publication-trust/`
**Prerequisites**: `plan.md` (required), `spec.md` (required for user stories), `research.md`, `data-model.md`, `contracts/`
**Tests**: Tests are REQUIRED for this feature. Use focused Pest coverage in `tests/Feature/Evidence/EvidenceSnapshotResourceTest.php`, `tests/Feature/Evidence/EvidenceOverviewPageTest.php`, `tests/Feature/TenantReview/TenantReviewLifecycleTest.php`, `tests/Feature/TenantReview/TenantReviewRegisterTest.php`, `tests/Feature/ReviewPack/ReviewPackResourceTest.php`, and existing truth-support fixtures.
**Operations**: This feature reuses existing evidence snapshot, tenant review, and review pack generation flows and their current `OperationRun` types. No new `OperationRun` creation, lifecycle transition, notification, or `summary_counts` producer work is introduced.
**RBAC**: Existing tenant entitlement, workspace entitlement, and capability gating must remain unchanged. Tests must preserve deny-as-not-found behavior for non-entitled users and manage-action capability gating for view-only users.
**Operator Surfaces**: Evidence snapshot detail, tenant review detail, review pack detail, evidence overview, and the canonical review register must stay operator-first, with freshness, completeness, publication readiness, and next step visible by default where relevant.
**Filament UI Action Surfaces**: No new actions are introduced. Existing list inspect affordances, inline safe shortcuts, destructive action placement, and confirmation behavior must remain intact while truth semantics become stricter.
**Filament UI UX-001**: Existing Infolist and table layouts remain; this feature hardens top-level trust and caveat semantics rather than creating new screens.
**Badges**: Freshness, completeness, publication-readiness, and artifact-truth semantics must continue to use centralized badge domains and the existing governance truth partial.
**Organization**: Tasks are grouped by user story so each story can be implemented and tested as an independent increment after the shared truth scaffolding is in place.
## Phase 1: Setup (Shared Test Scaffolding)
**Purpose**: Prepare reusable stale and partial evidence fixtures that all stories can build on.
- [X] T001 Extend shared stale and partial governance artifact fixture builders in `tests/Feature/Concerns/BuildsGovernanceArtifactTruthFixtures.php`
- [X] T002 [P] Add or refine stale evidence seeding helpers used by evidence and review tests in `tests/Pest.php`
---
## Phase 2: Foundational (Blocking Truth Propagation Prerequisite)
**Purpose**: Tighten the shared trust-propagation seam before any surface-specific story work begins.
**⚠️ CRITICAL**: No user story work should begin until this phase is complete.
- [X] T003 Refactor shared freshness-source and evidence-burden inputs used by evidence, review, and pack truth envelopes in `app/Support/Ui/GovernanceArtifactTruth/ArtifactTruthPresenter.php` without introducing a new resolver layer
- [X] T004 [P] Preserve source-derived stale semantics and required-section blocker behavior without adding a new freshness engine in `app/Services/Evidence/EvidenceCompletenessEvaluator.php` and `app/Services/TenantReviews/TenantReviewReadinessGate.php`
**Checkpoint**: The central truth seam is ready for story-specific stale and publication hardening without introducing a second freshness framework.
---
## Phase 3: User Story 1 - Detect Stale Evidence Before Reusing It (Priority: P1) 🎯 MVP
**Goal**: Make stale evidence visible on snapshot, review, pack, and evidence-overview surfaces before operators reuse the artifact.
**Independent Test**: Seed fresh and stale evidence snapshots with the same structural completeness and verify that snapshot, linked review, linked pack, and evidence-overview rows downgrade only for the stale case.
### Tests for User Story 1
- [X] T005 [P] [US1] Add fresh-versus-stale snapshot detail assertions in `tests/Feature/Evidence/EvidenceSnapshotResourceTest.php`
- [X] T006 [P] [US1] Add stale-evidence overview row assertions in `tests/Feature/Evidence/EvidenceOverviewPageTest.php`
- [X] T007 [P] [US1] Add stale-evidence trust assertions for linked reviews and packs in `tests/Feature/TenantReview/TenantReviewLifecycleTest.php` and `tests/Feature/ReviewPack/ReviewPackResourceTest.php`
### Implementation for User Story 1
- [X] T008 [US1] Surface stale snapshot truth and next-step guidance on tenant evidence detail in `app/Filament/Resources/EvidenceSnapshotResource.php` and `app/Filament/Resources/EvidenceSnapshotResource/Pages/ViewEvidenceSnapshot.php`
- [X] T009 [US1] Surface stale-evidence burden on tenant review detail without changing action topology in `app/Filament/Resources/TenantReviewResource.php` and `app/Filament/Resources/TenantReviewResource/Pages/ViewTenantReview.php`
- [X] T010 [US1] Surface stale-evidence burden on review pack list and detail before download in `app/Filament/Resources/ReviewPackResource.php` and `app/Filament/Resources/ReviewPackResource/Pages/ViewReviewPack.php`
- [X] T011 [US1] Keep canonical evidence freshness rows aligned with snapshot detail in `app/Filament/Pages/Monitoring/EvidenceOverview.php` and `resources/views/filament/pages/monitoring/evidence-overview.blade.php`
- [X] T012 [US1] Run the focused stale-evidence verification pack in `tests/Feature/Evidence/EvidenceSnapshotResourceTest.php`, `tests/Feature/Evidence/EvidenceOverviewPageTest.php`, `tests/Feature/TenantReview/TenantReviewLifecycleTest.php`, and `tests/Feature/ReviewPack/ReviewPackResourceTest.php`
**Checkpoint**: Operators can now tell when evidence is stale before relying on snapshots, reviews, packs, or evidence-overview rows.
---
## Phase 4: User Story 2 - Distinguish Internal-Use Reviews From Publishable Reviews (Priority: P1)
**Goal**: Make partial or stale review evidence degrade review and pack publication posture into internal-only or cautionary states instead of looking publishable.
**Independent Test**: Render reviews and packs built from complete evidence, stale evidence, and partial evidence and verify that only the strongest case appears publishable.
### Tests for User Story 2
- [X] T013 [P] [US2] Add partial-evidence and stale-publication-readiness assertions for tenant reviews in `tests/Feature/TenantReview/TenantReviewLifecycleTest.php`
- [X] T014 [P] [US2] Add internal-only versus publishable pack assertions and download caveat coverage in `tests/Feature/ReviewPack/ReviewPackResourceTest.php`
### Implementation for User Story 2
- [X] T015 [US2] Tighten tenant review publication-readiness, next-step, and explanatory trust semantics for stale or partial evidence in `app/Support/Ui/GovernanceArtifactTruth/ArtifactTruthPresenter.php` and `app/Filament/Resources/TenantReviewResource.php`
- [X] T016 [US2] Tighten review pack publication-readiness and source-review caveat semantics for stale or partial evidence in `app/Support/Ui/GovernanceArtifactTruth/ArtifactTruthPresenter.php` and `app/Filament/Resources/ReviewPackResource.php`
- [X] T017 [US2] Keep the shared governance truth partial explicit about freshness versus publication readiness without adding page-local status language in `resources/views/filament/infolists/entries/governance-artifact-truth.blade.php`
- [X] T018 [US2] Verify review and pack detail surfaces stay consistent about internal-only versus publishable trust in `tests/Feature/TenantReview/TenantReviewLifecycleTest.php` and `tests/Feature/ReviewPack/ReviewPackResourceTest.php`
**Checkpoint**: Review and pack surfaces now distinguish internal-use artifacts from publishable artifacts and no longer imply publication safety from mere downloadability or status alone.
---
## Phase 5: User Story 3 - Recover The Evidence Basis Across Review Surfaces (Priority: P2)
**Goal**: Keep canonical review and evidence summary surfaces aligned with the tenant-scoped truth they summarize.
**Independent Test**: Seed tenants with mixed freshness and review readiness, then verify that the review register and evidence overview rows match the corresponding detail-page trust direction and preserve tenant-safe drill-through.
### Tests for User Story 3
- [X] T019 [P] [US3] Add stale and partial canonical review-register alignment assertions in `tests/Feature/TenantReview/TenantReviewRegisterTest.php`
- [X] T020 [P] [US3] Add cross-surface drill-through, no-current-review next-step, and tenant-safe evidence-overview alignment assertions in `tests/Feature/Evidence/EvidenceOverviewPageTest.php` and `tests/Feature/TenantReview/TenantReviewRegisterTest.php`
### Implementation for User Story 3
- [X] T021 [US3] Align canonical review-register artifact truth, publication, and next-step rows with tenant review detail in `app/Filament/Pages/Reviews/ReviewRegister.php`
- [X] T022 [US3] Preserve tenant-prefilter continuity, truthful drill-through, and the fresh-evidence-with-no-current-review next step between overview, register, and tenant detail surfaces in `app/Filament/Pages/Monitoring/EvidenceOverview.php` and `app/Filament/Pages/Reviews/ReviewRegister.php`
- [X] T023 [US3] Keep tenant-scoped snapshot, review, and pack resources consistent with canonical summary language in `app/Filament/Resources/EvidenceSnapshotResource.php`, `app/Filament/Resources/TenantReviewResource.php`, and `app/Filament/Resources/ReviewPackResource.php`
- [X] T024 [US3] Run the focused canonical-summary alignment verification pack in `tests/Feature/TenantReview/TenantReviewRegisterTest.php` and `tests/Feature/Evidence/EvidenceOverviewPageTest.php`
**Checkpoint**: Canonical summary surfaces now carry the same trust direction as the tenant-scoped detail pages they summarize.
---
## Phase 6: Polish & Cross-Cutting Concerns
**Purpose**: Final consistency, authorization non-regression, formatting, and focused verification across all stories.
- [X] T025 [P] Review and align operator-facing truth copy across the shared presenter and truth partial in `app/Support/Ui/GovernanceArtifactTruth/ArtifactTruthPresenter.php` and `resources/views/filament/infolists/entries/governance-artifact-truth.blade.php`
- [X] T026 [P] Add manage-action and tenant-entitlement non-regression coverage for touched evidence, review, and pack surfaces in `tests/Feature/Evidence/EvidenceSnapshotResourceTest.php`, `tests/Feature/TenantReview/TenantReviewLifecycleTest.php`, and `tests/Feature/ReviewPack/ReviewPackResourceTest.php`
- [X] T027 Run formatting for touched files using `vendor/bin/sail bin pint --dirty --format agent`
- [X] T028 Run the final focused verification pack from `specs/174-evidence-freshness-publication-trust/quickstart.md`, including the SC-174-001 10-second manual scan validation, the shared list-surface review checklist in `docs/product/standards/list-surface-review-checklist.md`, the DB-only render and lightweight canonical-row-derivation guardrails in `specs/174-evidence-freshness-publication-trust/plan.md`, and the existing action run-launch semantics in `app/Filament/Resources/EvidenceSnapshotResource.php`, `app/Filament/Resources/TenantReviewResource.php`, and `app/Filament/Resources/ReviewPackResource.php`, against `tests/Feature/Evidence/EvidenceSnapshotResourceTest.php`, `tests/Feature/Evidence/EvidenceOverviewPageTest.php`, `tests/Feature/TenantReview/TenantReviewLifecycleTest.php`, `tests/Feature/TenantReview/TenantReviewRegisterTest.php`, and `tests/Feature/ReviewPack/ReviewPackResourceTest.php`
---
## Dependencies & Execution Order
### Phase Dependencies
- **Setup (Phase 1)**: Starts immediately and prepares shared stale and partial fixtures.
- **Foundational (Phase 2)**: Depends on Setup and blocks all story work until the central truth propagation seam is tightened.
- **User Story 1 (Phase 3)**: Starts after Foundational and delivers the MVP stale-evidence visibility across key surfaces.
- **User Story 2 (Phase 4)**: Starts after Foundational; it can build on User Story 1 or proceed immediately after the presenter seam is stable, but should follow US1 for the cleanest rollout.
- **User Story 3 (Phase 5)**: Starts after Foundational and depends on tightened truth semantics from earlier phases to align canonical rows.
- **Polish (Phase 6)**: Starts after the desired user stories are complete.
### User Story Dependencies
- **User Story 1 (P1)**: Depends only on the shared truth propagation seam from Phase 2.
- **User Story 2 (P1)**: Depends on the shared truth propagation seam from Phase 2 and benefits from the stale-surface work in US1.
- **User Story 3 (P2)**: Depends on the shared truth propagation seam from Phase 2 and should consume the tightened truth semantics delivered by US1 and US2.
### Within Each User Story
- Shared fixture work must land before story-specific tests rely on new stale or partial scenarios.
- Tests should be updated before or alongside the related implementation tasks and must fail before the behavior change is considered complete.
- `ArtifactTruthPresenter` changes should land before downstream Filament resource and page copy cleanup for the same story.
- Focused story-level test runs should complete before moving to the next story.
### Parallel Opportunities
- `T001` and `T002` can run in parallel once the shared stale and partial scenario matrix is agreed.
- `T005`, `T006`, and `T007` can run in parallel for User Story 1.
- `T013` and `T014` can run in parallel for User Story 2.
- `T019` and `T020` can run in parallel for User Story 3.
- `T025` and `T026` can run in parallel once implementation work is complete.
---
## Parallel Example: User Story 1
```bash
# Story 1 tests in parallel:
Task: T005 tests/Feature/Evidence/EvidenceSnapshotResourceTest.php
Task: T006 tests/Feature/Evidence/EvidenceOverviewPageTest.php
Task: T007 tests/Feature/TenantReview/TenantReviewLifecycleTest.php and tests/Feature/ReviewPack/ReviewPackResourceTest.php
# Story 1 implementation split after truth propagation is stable:
Task: T008 app/Filament/Resources/EvidenceSnapshotResource.php and app/Filament/Resources/EvidenceSnapshotResource/Pages/ViewEvidenceSnapshot.php
Task: T009 app/Filament/Resources/TenantReviewResource.php and app/Filament/Resources/TenantReviewResource/Pages/ViewTenantReview.php
Task: T010 app/Filament/Resources/ReviewPackResource.php and app/Filament/Resources/ReviewPackResource/Pages/ViewReviewPack.php
```
## Parallel Example: User Story 2
```bash
# Story 2 tests in parallel:
Task: T013 tests/Feature/TenantReview/TenantReviewLifecycleTest.php
Task: T014 tests/Feature/ReviewPack/ReviewPackResourceTest.php
# Story 2 implementation split after failing assertions are in place:
Task: T015 app/Support/Ui/GovernanceArtifactTruth/ArtifactTruthPresenter.php and app/Filament/Resources/TenantReviewResource.php
Task: T016 app/Support/Ui/GovernanceArtifactTruth/ArtifactTruthPresenter.php and app/Filament/Resources/ReviewPackResource.php
```
## Parallel Example: User Story 3
```bash
# Story 3 tests in parallel:
Task: T019 tests/Feature/TenantReview/TenantReviewRegisterTest.php
Task: T020 tests/Feature/Evidence/EvidenceOverviewPageTest.php and tests/Feature/TenantReview/TenantReviewRegisterTest.php
# Story 3 implementation split after row-level truth expectations are clear:
Task: T021 app/Filament/Pages/Reviews/ReviewRegister.php
Task: T022 app/Filament/Pages/Monitoring/EvidenceOverview.php and app/Filament/Pages/Reviews/ReviewRegister.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. **STOP and VALIDATE**: Verify stale evidence is visible across snapshot, linked review, linked pack, and evidence overview surfaces.
5. Deploy or demo if the MVP confidence level is sufficient.
### Incremental Delivery
1. Complete Setup + Foundational so the central truth seam is stable.
2. Add User Story 1 and validate stale-evidence visibility.
3. Add User Story 2 and validate internal-only versus publishable trust.
4. Add User Story 3 and validate canonical summary alignment.
5. Finish with cross-cutting copy, authorization non-regression, formatting, and focused verification.
### Parallel Team Strategy
With multiple developers:
1. Complete Setup + Foundational together.
2. Once Foundational is done:
- Developer A: User Story 1
- Developer B: User Story 2
- Developer C: User Story 3
3. Reconcile in Phase 6 with shared copy, authorization non-regression, formatting, and final focused tests.
---
## Notes
- [P] tasks indicate different files and no dependency on incomplete predecessor tasks.
- The same touched file may appear in multiple stories because each story hardens a different user-visible outcome on the same existing surface.
- No task introduces a new persistence model, a new abstraction layer, or a new reporting subsystem.

View File

@ -0,0 +1,36 @@
# Specification Quality Checklist: Spec 175 - Workspace Governance Attention Foundation
**Purpose**: Validate specification completeness and quality before proceeding to planning
**Created**: 2026-04-04
**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 against the written spec on 2026-04-04.
- No clarification markers were needed because the supplied feature description already defined scope, priorities, constraints, and desired outcomes precisely.
- The spec stays intentionally narrow: it hardens existing workspace surfaces using already-available tenant truth and explicitly rejects new persistence, scores, or matrix-style redesign in this slice.

View File

@ -0,0 +1,541 @@
openapi: 3.1.0
info:
title: Workspace Governance Attention Internal Surface Contract
version: 0.1.0
summary: Internal logical contract for governance-aware workspace overview semantics
description: |
This contract is an internal planning artifact for Spec 175. It documents how
the existing workspace overview must derive governance-aware summary metrics,
attention items, calmness claims, and drill-through destinations from visible
tenant truth. The rendered routes still return HTML. The structured schemas
below describe the internal page and widget models that must be derivable
before rendering. This does not add a public HTTP API.
servers:
- url: /internal
x-overview-consumers:
- surface: workspace.overview.summary_stats
summarySource:
- workspace_overview_builder
- tenant_governance_aggregate
- operation_run_activity
- alert_delivery_activity
guardScope:
- app/Support/Workspaces/WorkspaceOverviewBuilder.php
- app/Filament/Widgets/Workspace/WorkspaceSummaryStats.php
expectedContract:
- governance_metrics_count_visible_tenants_not_raw_issue_totals
- governance_metrics_are_distinct_from_activity_metrics
- surface: workspace.overview.needs_attention
summarySource:
- workspace_overview_builder
- tenant_governance_aggregate
- existing_evidence_review_truth
- operation_run_activity
- alert_delivery_activity
guardScope:
- app/Support/Workspaces/WorkspaceOverviewBuilder.php
- app/Filament/Widgets/Workspace/WorkspaceNeedsAttention.php
- resources/views/filament/widgets/workspace/workspace-needs-attention.blade.php
expectedContract:
- each_item_identifies_the_visible_tenant_when_tenant_bound
- all_attention_items_are_tenant_bound
- governance_issues_rank_above_activity_only_items
- compare_attention_only_uses_stale_or_compare_specific_action_required_signals
- each_item_has_one_matching_destination_or_a_safe_disabled_state
- surface: workspace.overview.calmness
summarySource:
- workspace_overview_builder
- tenant_governance_aggregate
- operation_run_activity
- alert_delivery_activity
guardScope:
- app/Support/Workspaces/WorkspaceOverviewBuilder.php
- resources/views/filament/pages/workspace-overview.blade.php
expectedContract:
- operations_quiet_alone_is_not_enough_for_calmness
- zero_tenant_and_low_permission_states_do_not_masquerade_as_healthy_calm
- zero_tenant_recovery_uses_switch_workspace_and_low_permission_recovery_uses_operations_index
- surface: workspace.overview.recent_operations
summarySource:
- workspace_overview_builder
- operation_run_recency_query
guardScope:
- app/Filament/Widgets/Workspace/WorkspaceRecentOperations.php
- resources/views/filament/widgets/workspace/workspace-recent-operations.blade.php
expectedContract:
- surface_role_is_diagnostic_recency_not_primary_governance_summary
- recent_operations_remain_filtered_to_visible_tenant_scope
- recent_operations_are_bounded_to_five
paths:
/admin:
get:
summary: Render the governance-aware workspace overview bundle
operationId: viewWorkspaceGovernanceAttentionOverview
responses:
'200':
description: Workspace overview rendered with governance-aware summary, attention, calmness, and recency semantics
content:
text/html:
schema:
type: string
application/vnd.tenantpilot.workspace-governance-attention+json:
schema:
$ref: '#/components/schemas/WorkspaceGovernanceOverviewBundle'
'302':
description: No workspace context is active yet, so the request is redirected to `/admin/choose-workspace`
'404':
description: Workspace is outside entitlement scope
/admin/choose-tenant:
get:
summary: Explicit tenant-entry destination used by workspace drill-down when the operator wants to pick a tenant deliberately
operationId: openChooseTenantFromWorkspaceOverview
responses:
'200':
description: Choose-tenant page opened inside the authenticated admin panel and may bootstrap workspace context from the selected tenant if no workspace is currently active
/admin/choose-workspace:
get:
summary: Default workspace-switch recovery destination for zero-tenant or wrong-workspace states
operationId: openChooseWorkspaceFromWorkspaceOverview
responses:
'200':
description: Choose-workspace page opened so the operator can recover to another entitled workspace context even when no workspace is currently active
/admin/t/{tenant}:
get:
summary: Tenant dashboard fallback or broad tenant drill-through from workspace attention
operationId: openTenantDashboardFromWorkspaceAttention
parameters:
- name: tenant
in: path
required: true
schema:
type: string
responses:
'200':
description: Tenant dashboard opened for the visible tenant named by the workspace item when the dashboard is the allowed fallback or primary tenant landing
'404':
description: Tenant is outside entitlement scope
/admin/t/{tenant}/findings:
get:
summary: Tenant findings destination used by workspace governance and findings attention
operationId: openTenantFindingsFromWorkspaceAttention
parameters:
- name: tenant
in: path
required: true
schema:
type: string
- name: tab
in: query
required: false
schema:
$ref: '#/components/schemas/FindingsTab'
- name: high_severity
in: query
required: false
schema:
type: boolean
- name: governance_validity
in: query
required: false
schema:
$ref: '#/components/schemas/GovernanceValidityFilter'
description: Reproduces directly filterable governance-validity subsets such as `missing_support` or `expiring`; aggregate lapsed-governance attention falls back to the tenant dashboard when the full invalid-governance family cannot be reproduced without narrowing.
responses:
'200':
description: Tenant findings list opened with a subset matching the originating workspace attention item
'403':
description: Actor is in scope but lacks findings inspection capability
'404':
description: Tenant is outside entitlement scope
/admin/t/{tenant}/baseline-compare-landing:
get:
summary: Tenant baseline compare landing used by workspace compare attention
operationId: openTenantBaselineCompareFromWorkspaceAttention
parameters:
- name: tenant
in: path
required: true
schema:
type: string
responses:
'200':
description: Tenant baseline compare landing opened with the same compare posture family summarized by the workspace item
'403':
description: Actor is in scope but lacks the current tenant-view capability required by the compare landing
'404':
description: Tenant is outside entitlement scope
/admin/t/{tenant}/evidence:
get:
summary: Tenant evidence destination used only when existing evidence truth is the most precise workspace next jump
operationId: openTenantEvidenceFromWorkspaceAttention
parameters:
- name: tenant
in: path
required: true
schema:
type: string
responses:
'200':
description: Tenant evidence surface opened for the visible tenant named by the workspace item
'403':
description: Actor is in scope but lacks evidence inspection capability
'404':
description: Tenant is outside entitlement scope
/admin/t/{tenant}/reviews:
get:
summary: Tenant reviews destination used only when existing review truth is the most precise workspace next jump
operationId: openTenantReviewsFromWorkspaceAttention
parameters:
- name: tenant
in: path
required: true
schema:
type: string
responses:
'200':
description: Tenant reviews surface opened for the visible tenant named by the workspace item
'403':
description: Actor is in scope but lacks review inspection capability
'404':
description: Tenant is outside entitlement scope
/admin/operations:
get:
summary: Canonical operations index used by workspace activity and operations-follow-up signals
operationId: openOperationsFromWorkspaceOverview
parameters:
- name: tenant_id
in: query
required: false
schema:
type:
- integer
- string
description: Optional tenant filter when the workspace item points to one tenant's operations follow-up.
- name: activeTab
in: query
required: false
schema:
$ref: '#/components/schemas/OperationsTab'
responses:
'200':
description: Canonical operations index opened with any tenant and tab continuity required by the workspace signal; this remains the workspace-member-safe fallback for low-permission workspace states
'404':
description: Requested tenant context is outside entitlement scope
/admin/operations/{run}:
get:
summary: Canonical operation detail opened from workspace recent operations or operations attention
operationId: openOperationDetailFromWorkspaceOverview
parameters:
- name: run
in: path
required: true
schema:
type:
- integer
- string
responses:
'200':
description: Canonical operation detail opened for the visible run
'403':
description: Actor is in scope but lacks operation detail capability
'404':
description: Operation run is outside entitlement scope
/admin/alerts:
get:
summary: Canonical alerts overview used for alert-only follow-up from the workspace home
operationId: openAlertsOverviewFromWorkspaceOverview
responses:
'200':
description: Alerts overview opened for the current workspace, including delivery follow-up and alert-health context
'403':
description: Actor is in scope but lacks alerts inspection capability
'404':
description: Workspace is outside entitlement scope
components:
schemas:
MetricCategory:
type: string
enum:
- scope
- governance_risk
- activity
- alerts
AttentionFamily:
type: string
enum:
- governance
- findings
- compare
- evidence
- review
- operations
- alerts
AttentionUrgency:
type: string
enum:
- critical
- high
- medium
- supporting
DestinationKind:
type: string
enum:
- choose_tenant
- tenant_dashboard
- tenant_findings
- baseline_compare_landing
- tenant_evidence
- tenant_reviews
- operations_index
- operation_detail
- alerts_overview
- switch_workspace
- none
CheckedDomain:
type: string
enum:
- governance
- findings
- compare
- evidence
- review
- operations
- alerts
- tenant_access
FindingsTab:
type: string
enum:
- all
- needs_action
- overdue
- risk_accepted
- resolved
GovernanceValidityFilter:
type: string
enum:
- missing_support
- expiring
- valid
OperationsTab:
type: string
enum:
- all
- active
- blocked
- failed
- partial
- succeeded
DrillthroughTarget:
type: object
additionalProperties: false
required:
- kind
- disabled
properties:
kind:
$ref: '#/components/schemas/DestinationKind'
url:
type:
- string
- 'null'
tenantRouteKey:
type:
- string
- 'null'
label:
type:
- string
- 'null'
disabled:
type: boolean
helperText:
type:
- string
- 'null'
filters:
type:
- object
- 'null'
additionalProperties: true
WorkspaceSummaryMetric:
type: object
additionalProperties: false
required:
- key
- label
- value
- category
- description
- color
properties:
key:
type: string
label:
type: string
value:
type: integer
minimum: 0
category:
$ref: '#/components/schemas/MetricCategory'
description:
type: string
color:
type: string
destination:
oneOf:
- $ref: '#/components/schemas/DrillthroughTarget'
- type: 'null'
WorkspaceAttentionItem:
type: object
additionalProperties: false
required:
- key
- tenantId
- tenantLabel
- family
- urgency
- title
- body
- badge
- badgeColor
anyOf:
- required:
- destination
properties:
destination:
$ref: '#/components/schemas/DrillthroughTarget'
- required:
- actionDisabled
- helperText
properties:
actionDisabled:
const: true
helperText:
type: string
properties:
key:
type: string
tenantId:
type: integer
tenantLabel:
type: string
family:
$ref: '#/components/schemas/AttentionFamily'
urgency:
$ref: '#/components/schemas/AttentionUrgency'
title:
type: string
body:
type: string
supportingMessage:
type:
- string
- 'null'
badge:
type: string
badgeColor:
type: string
destination:
oneOf:
- $ref: '#/components/schemas/DrillthroughTarget'
- type: 'null'
actionDisabled:
type:
- boolean
- 'null'
helperText:
type:
- string
- 'null'
WorkspaceRecentOperation:
type: object
additionalProperties: false
required:
- id
- title
- statusLabel
- statusColor
- outcomeLabel
- outcomeColor
- startedAt
- destination
properties:
id:
type: integer
title:
type: string
tenantLabel:
type:
- string
- 'null'
statusLabel:
type: string
statusColor:
type: string
outcomeLabel:
type: string
outcomeColor:
type: string
guidance:
type:
- string
- 'null'
startedAt:
type: string
destination:
$ref: '#/components/schemas/DrillthroughTarget'
WorkspaceCalmnessState:
type: object
additionalProperties: false
required:
- isCalm
- checkedDomains
- title
- body
- nextAction
properties:
isCalm:
type: boolean
checkedDomains:
type: array
items:
$ref: '#/components/schemas/CheckedDomain'
title:
type: string
body:
type: string
nextAction:
description: Defaults to `switch_workspace` for zero-tenant recovery and `operations_index` for low-permission workspace-state recovery unless a more specific allowed action exists.
$ref: '#/components/schemas/DrillthroughTarget'
WorkspaceGovernanceOverviewBundle:
type: object
additionalProperties: false
required:
- workspaceId
- workspaceName
- summaryMetrics
- attentionItems
- recentOperations
- calmness
properties:
workspaceId:
type: integer
workspaceName:
type: string
summaryMetrics:
type: array
maxItems: 8
items:
$ref: '#/components/schemas/WorkspaceSummaryMetric'
attentionItems:
type: array
maxItems: 5
items:
$ref: '#/components/schemas/WorkspaceAttentionItem'
recentOperations:
type: array
maxItems: 5
items:
$ref: '#/components/schemas/WorkspaceRecentOperation'
calmness:
$ref: '#/components/schemas/WorkspaceCalmnessState'

View File

@ -0,0 +1,313 @@
# Phase 1 Data Model: Workspace Governance Attention Foundation
## Overview
This feature does not add a table, persisted workspace summary, or new cross-domain runtime subsystem. It aligns the existing workspace overview surface with already-available tenant truth so the workspace home can answer which visible tenants need governance attention, why they need it, and where the operator should jump next.
## Persistent Source Truths
### Workspace
**Purpose**: Scope boundary for the workspace home and all workspace-safe aggregates.
**Key fields**:
- `id`
- `name`
- `slug`
**Validation rules**:
- Workspace overview aggregation must always resolve for one explicit workspace.
- Non-members must receive deny-as-not-found behavior before any workspace truth is rendered.
### Tenant
**Purpose**: Scope boundary and identity anchor for every governance-aware workspace attention item.
**Key fields**:
- `id`
- `workspace_id`
- `external_id`
- `name`
- `status`
**Validation rules**:
- Only active tenants inside the current workspace and inside the current user's entitled tenant slice may contribute to workspace attention or governance-risk metrics.
- Every workspace attention item must identify one visible tenant explicitly.
### Finding
**Purpose**: Source of overdue findings, high-severity active findings, and other governance workflow pressure promoted into workspace attention.
**Key fields**:
- `tenant_id`
- `workspace_id`
- `finding_type`
- `status`
- `severity`
- `due_at`
- `scope_key`
**Validation rules**:
- Canonical open and active semantics remain sourced from existing finding query helpers.
- Workspace promotion must not invent a second finding status universe.
### FindingException / Governance Validity
**Purpose**: Source of lapsed governance and expiring governance truth for risk-accepted findings.
**Key fields**:
- `tenant_id`
- `workspace_id`
- `finding_id`
- `status`
- `current_validity_state`
- `review_due_at`
- `expires_at`
**Validation rules**:
- Lapsed and expiring governance remain derived from existing validity-state rules.
- Workspace promotion must not replace existing governance-validity semantics with a new workspace-specific status family.
### OperationRun
**Purpose**: Source of workspace activity, operation failures, and canonical operation drill-through.
**Key fields**:
- `id`
- `workspace_id`
- `tenant_id`
- `type`
- `status`
- `outcome`
- `created_at`
- `completed_at`
**Validation rules**:
- Active operations and failed or warning operations remain activity truths, not governance truths.
- Workspace attention may still include operations follow-up, but those items must remain semantically distinct from governance items.
### AlertDelivery
**Purpose**: Source of workspace alert-delivery failures that remain supporting attention but no longer define workspace calmness by themselves.
**Key fields**:
- `workspace_id`
- `tenant_id`
- `status`
- `created_at`
**Validation rules**:
- Alert-delivery failures remain lower-priority supporting signals once governance-critical tenant states exist.
### EvidenceSnapshot and TenantReview
**Purpose**: Existing tenant-level evidence and review truth that may serve as a precise workspace drill-through target when already-available truth makes them the best next jump.
**Key fields**:
- `tenant_id`
- `workspace_id`
- `status` or completeness fields on the owning model
- associated detail identifiers needed by tenant evidence or review resources
**Validation rules**:
- This slice does not add a new workspace evidence or review aggregate.
- Evidence or review destinations may only be used when existing tenant truth already makes them the most precise action target.
## Existing Runtime Source Objects
### TenantGovernanceAggregate
**Purpose**: Existing derived tenant summary that already combines compare posture with overdue, expiring, lapsed, and high-severity counts.
**Key consumed fields**:
- `tenantId`
- `workspaceId`
- `compareState`
- `stateFamily`
- `tone`
- `headline`
- `supportingMessage`
- `overdueOpenFindingsCount`
- `expiringGovernanceCount`
- `lapsedGovernanceCount`
- `highSeverityActiveFindingsCount`
- `nextActionLabel`
- `nextActionTarget`
- `positiveClaimAllowed`
- `summaryAssessment`
**Validation rules**:
- Workspace promotion should consume this existing summary contract before considering lower-level recomputation.
- Any workspace calmness or ranking rule based on compare or governance must remain consistent with this aggregate.
### BaselineCompareStats
**Purpose**: Existing compare-backed statistics object underlying the tenant governance aggregate.
**Key consumed fields**:
- `overdueOpenFindingsCount`
- `expiringGovernanceCount`
- `lapsedGovernanceCount`
- `highSeverityActiveFindingsCount`
- compare posture state and evidence-gap fields needed by `summaryAssessment`
**Validation rules**:
- Workspace code should not fork a second compare-summary model while this object already provides the necessary facts.
### BaselineCompareSummaryAssessment
**Purpose**: Existing compare posture contract that maps compare stats into a state family, tone, headline, and next-action intent.
**Key consumed fields**:
- `stateFamily`
- `tone`
- `headline`
- `supportingMessage`
- `reasonCode`
- `nextActionTarget()` semantics as reflected into the aggregate
**Validation rules**:
- Workspace compare attention and calmness suppression should reuse this assessment path rather than inventing a workspace-only tone system.
### WorkspaceOverviewBuilder Payload
**Purpose**: Existing page-level payload that already carries summary metrics, attention items, recent operations, quick actions, and empty states for `/admin`.
**Validation rules**:
- Governance hardening extends this payload shape rather than replacing the page with a new surface framework.
- Existing visible-tenant filtering remains the workspace aggregation guardrail.
## Derived Workspace View Contracts
### Workspace Summary Metric
**Purpose**: Compact workspace stat that answers one scope, governance-risk, or activity question and optionally opens one matching destination.
#### Fields
| Field | Type | Required | Description |
|------|------|----------|-------------|
| `key` | string | yes | Stable metric identity such as `accessible_tenants`, `governance_attention_tenants`, `overdue_findings_tenants`, or `active_operations` |
| `label` | string | yes | Operator-facing label that must accurately describe the counted universe |
| `value` | integer | yes | Metric value |
| `category` | enum | yes | `scope`, `governance_risk`, `activity`, or `alerts` |
| `description` | string | yes | Short explanation of what the metric means |
| `color` | string | yes | Existing tone family used by the widget |
| `destination` | object nullable | no | Shared drill-through contract when the metric is actionable |
#### Validation rules
- Governance-risk metrics count affected visible tenants, not raw issue totals.
- Activity metrics remain activity-only and must not imply governance health.
- The stat strip must make the difference between `governance_risk` and `activity` categories obvious.
### Workspace Attention Item
**Purpose**: One prioritized workspace triage item that names a visible tenant problem and one next jump.
#### Fields
| Field | Type | Required | Description |
|------|------|----------|-------------|
| `key` | string | yes | Stable attention identity such as `tenant_overdue_findings`, `tenant_lapsed_governance`, `tenant_compare_attention`, or `tenant_failed_operation` |
| `tenantId` | integer | yes | Visible tenant identifier |
| `tenantLabel` | string | yes | Tenant name shown to the operator |
| `family` | enum | yes | `governance`, `findings`, `compare`, `evidence`, `review`, `operations`, or `alerts` |
| `urgency` | enum | yes | `critical`, `high`, `medium`, or `supporting` |
| `title` | string | yes | Primary operator-facing summary |
| `body` | string | yes | Short explanation of why this needs attention |
| `badge` | string | yes | Existing family label shown in the UI |
| `badgeColor` | string | yes | Existing tone family used for the item |
| `supportingMessage` | string nullable | no | Secondary explanatory text when needed |
| `destination` | object nullable | no | Shared drill-through contract |
| `actionDisabled` | boolean nullable | no | Whether the visible next step is intentionally disabled |
| `helperText` | string nullable | no | Explanation when the visible next step is disabled |
#### Validation rules
- Every workspace attention item is tenant-bound and must include both `tenantId` and `tenantLabel`.
- Governance, findings, compare, evidence or review, and operations items must remain semantically distinct.
- Compare attention promotes `BaselineCompareSummaryAssessment::STATE_STALE` directly and only treats `BaselineCompareSummaryAssessment::STATE_ACTION_REQUIRED` as materially degraded compare posture when the aggregate's next action remains compare-specific rather than findings-driven.
- `BaselineCompareSummaryAssessment::STATE_CAUTION` stays below the workspace-attention threshold unless another governance signal independently warrants promotion.
- Workspace-wide operations or alert totals remain in summary metrics or recent operations until they can be attributed to one visible tenant.
- Each item may expose one primary destination only.
- Every item must either expose a non-null destination or set `actionDisabled=true` with explanatory `helperText`; null destination without an explicit disabled state is invalid.
- If the most precise destination is not available to the current in-scope user, the item must either use an allowed fallback or expose a disabled explanatory state instead of a clickable dead end.
### Workspace Recent Operation
**Purpose**: One bounded recent-operations entry shown on the workspace overview as diagnostic recency, not governance posture.
#### Fields
| Field | Type | Required | Description |
|------|------|----------|-------------|
| `id` | integer | yes | Operation run identifier |
| `title` | string | yes | Operator-facing operation label |
| `tenantLabel` | string nullable | no | Tenant name when the run is tenant-bound |
| `statusLabel` | string | yes | Human-readable run status |
| `statusColor` | string | yes | Existing tone family for the run status |
| `outcomeLabel` | string | yes | Human-readable run outcome |
| `outcomeColor` | string | yes | Existing tone family for the run outcome |
| `guidance` | string nullable | no | Short follow-up guidance when helpful |
| `startedAt` | string | yes | Render-ready recency label |
| `destination` | object | yes | Canonical operation-detail drill-through target |
#### Validation rules
- The recent-operations collection is bounded to the five most recent visible runs.
- Recent operations remain diagnostic context and do not define calmness on their own.
- `tenantLabel` may be null only when the run is genuinely workspace-wide rather than tenant-bound.
### Workspace Drill-Through Target
**Purpose**: Shared navigation contract used by summary metrics and attention items.
#### Fields
| Field | Type | Required | Description |
|------|------|----------|-------------|
| `kind` | enum | yes | `choose_tenant`, `tenant_dashboard`, `tenant_findings`, `baseline_compare_landing`, `tenant_evidence`, `tenant_reviews`, `operations_index`, `operation_detail`, `alerts_overview`, `switch_workspace`, or `none` |
| `url` | string nullable | no | Destination URL when the target is actionable |
| `tenantRouteKey` | string nullable | no | Tenant route scope when the destination is tenant-bound |
| `filters` | object nullable | no | Query or state needed to reproduce the same subset on the destination |
| `label` | string nullable | no | Primary action label |
| `disabled` | boolean | yes | Whether the target is intentionally non-clickable |
| `helperText` | string nullable | no | Explanation shown when the target is disabled |
#### Validation rules
- `kind=none` may only be used for intentionally passive reassurance states.
- `tenant_findings` must carry the filter state needed to reproduce overdue, high-severity, expiring-governance, or other directly filterable findings subsets when applicable.
- Aggregate lapsed-governance attention uses `tenant_dashboard` when the current tenant findings filters would otherwise narrow the full invalid-governance family to a smaller subset such as `missing_support`.
- `operations_index` may carry tenant and tab filters but must remain the canonical admin operations route and the workspace-member-safe fallback for low-permission workspace states.
- `alerts_overview` targets the existing alerts overview at `/admin/alerts`, which remains the canonical alert-delivery follow-up surface for this slice.
- `switch_workspace` targets `/admin/choose-workspace` and is the default zero-tenant recovery action.
### Workspace Calmness State
**Purpose**: The derived claim that the workspace is currently calm enough for an empty or reassurance state.
#### Fields
| Field | Type | Required | Description |
|------|------|----------|-------------|
| `isCalm` | boolean | yes | Whether the workspace may currently make a calmness claim |
| `checkedDomains` | array<enum> | yes | Domains actually checked before the claim was made: `governance`, `findings`, `compare`, `evidence`, `review`, `operations`, `alerts`, `tenant_access` |
| `title` | string | yes | Empty-state or reassurance title |
| `body` | string | yes | Supporting explanation constrained to the checked domains |
| `nextAction` | object | yes | One bounded next action |
#### Validation rules
- `isCalm=true` is invalid whenever any visible tenant has governance-critical conditions inside the checked domains.
- Zero-tenant states and low-permission states must not masquerade as healthy calmness states.
- Zero-tenant states default `nextAction.kind` to `switch_workspace`, while low-permission states default `nextAction.kind` to `operations_index` unless a more specific allowed in-scope recovery action exists.
- Calm wording must not imply portfolio health beyond the `checkedDomains` list.
## Ranking Rules
1. Governance-critical tenant conditions outrank activity-only and alert-only items.
2. A single tenant may contribute multiple raw issues, but workspace attention should surface a bounded prioritized subset.
3. The stat strip answers portfolio counts by tenant, while the attention list answers which tenant to open next.
4. Recent operations remain supporting recency context and do not participate in calmness unless explicitly modeled as an operations-follow-up issue.

View File

@ -0,0 +1,332 @@
# Implementation Plan: Workspace Governance Attention Foundation
**Branch**: `175-workspace-governance-attention` | **Date**: 2026-04-04 | **Spec**: `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/specs/175-workspace-governance-attention/spec.md`
**Input**: Feature specification from `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/specs/175-workspace-governance-attention/spec.md`
**Note**: This plan follows the existing TenantPilot workspace and tenant-truth architecture. It hardens the current workspace overview instead of introducing a new workspace posture subsystem.
## Summary
Promote existing tenant governance truth into the existing workspace overview so `/admin` becomes a trustworthy multi-tenant governance attention surface instead of an operations-first calm surface. The implementation will keep `WorkspaceOverviewBuilder` as the orchestration point, reuse `TenantGovernanceAggregateResolver`, `BaselineCompareStats`, existing findings and compare destinations, and optionally already-available evidence or review surfaces where they provide a more precise next jump. The first slice will harden summary metrics, attention ranking, tenant identification, compare breadth across stale, failed, and degraded states, and calmness semantics; the second slice will preserve activity versus governance separation and protect the new contract with focused workspace overview regression tests.
## Technical Context
**Language/Version**: PHP 8.4.15
**Primary Dependencies**: Laravel 12, Filament v5, Livewire v4, Pest v4, existing `WorkspaceOverviewBuilder`, `TenantGovernanceAggregateResolver`, `BaselineCompareStats`, `BaselineCompareSummaryAssessor`, `WorkspaceSummaryStats`, `WorkspaceNeedsAttention`, `WorkspaceRecentOperations`, `WorkspaceCapabilityResolver`, `CapabilityResolver`, `FindingResource`, `BaselineCompareLanding`, `EvidenceSnapshotResource`, `TenantReviewResource`, and canonical admin Operations routes
**Storage**: PostgreSQL unchanged; no new persistence, cache table, or materialized aggregate is introduced
**Testing**: Pest 4 feature and Livewire-style widget tests through Laravel Sail using existing workspace overview tests plus new governance attention and drill-through coverage
**Target Platform**: Laravel monolith web application in Sail locally and containerized Linux deployment in staging and production
**Project Type**: web application
**Performance Goals**: Keep `/admin` DB-only at render time, keep workspace attention bounded, reuse request-scoped derived-state caching for tenant governance aggregates, and avoid uncontrolled polling or unbounded cross-tenant queries
**Constraints**: No new table, no new workspace posture score, no full portfolio matrix, no new panel/provider, no cross-tenant leakage, no dead-end drill-throughs for visible states, no ad-hoc status taxonomy, and no broad workspace IA redesign
**Scale/Scope**: One workspace landing page, three existing workspace widgets, one builder, one accessible-tenant slice per workspace, six central governance signal families, and focused regression coverage for workspace calmness, ranking, drill-through continuity, and RBAC-safe omission or fallback behavior
## Constitution Check
*GATE: Passed before Phase 0 research. Re-check after Phase 1 design.*
| Principle | Pre-Research | Post-Design | Notes |
|-----------|--------------|-------------|-------|
| Inventory-first / snapshots-second | PASS | PASS | The feature adds no new inventory or snapshot truth. It only changes read-time workspace aggregation over existing records. |
| Read/write separation | PASS | PASS | The slice is read-only workspace overview hardening. No new write path, preview flow, or destructive action is introduced. |
| Graph contract path | N/A | N/A | No Graph call or `config/graph_contracts.php` change is required. |
| Deterministic capabilities | PASS | PASS | Existing workspace membership and tenant capability checks remain authoritative for aggregation and drill-through behavior. |
| RBAC-UX authorization semantics | PASS | PASS | `/admin` remains workspace-scoped, tenant destinations remain tenant-scoped, non-members stay `404`, and members missing downstream capability must not receive dead-end links. |
| Workspace and tenant isolation | PASS | PASS | Aggregation stays limited to visible tenants in the active workspace and uses existing `scopeToAuthorizedTenants()` style filtering. |
| Run observability / Ops-UX | PASS | PASS | No new `OperationRun` type, feedback surface, or lifecycle change is added. Existing operations destinations remain canonical. |
| Data minimization | PASS | PASS | The plan adds no new persistence and no broader route exposure. Only already-visible tenant truth is promoted into the workspace home. |
| Proportionality / no premature abstraction | PASS | PASS | The plan reuses `WorkspaceOverviewBuilder` and `TenantGovernanceAggregateResolver` instead of adding a new workspace posture framework or aggregate layer. |
| Persisted truth / behavioral state | PASS | PASS | No new table, enum, status family, or persisted summary artifact is planned. |
| UI semantics / few layers | PASS | PASS | The feature aligns existing widget semantics rather than introducing a new presenter or badge taxonomy. |
| Badge semantics (BADGE-001) | PASS | PASS | Existing badge and tone domains remain the source for operations and compare posture meaning. New workspace items reuse those semantics rather than inventing new colors or labels. |
| Filament-native UI / Action Surface Contract | PASS | PASS | `WorkspaceOverview` and its widgets remain navigation and inspection surfaces only. No destructive or redundant action model is added. |
| Filament UX-001 | PASS | PASS | No create or edit page is touched. The design keeps governance attention above recent operations and uses bounded empty-state wording. |
| Filament v5 / Livewire v4 compliance | PASS | PASS | The design stays within the current Filament v5 + Livewire v4 stack. |
| Provider registration location | PASS | PASS | No panel/provider registration change is required. Laravel 11+ provider registration remains in `bootstrap/providers.php`. |
| Global search hard rule | PASS | PASS | No global-searchable resource behavior is changed in this slice. |
| Destructive action safety | PASS | PASS | The feature introduces no destructive action. |
| Asset strategy | PASS | PASS | No new assets or `filament:assets` deployment change is required. |
| Testing truth (TEST-TRUTH-001) | PASS | PASS | The plan adds tests around business consequences: false calmness, tenant identification, ordering, continuity, and RBAC-safe navigation behavior. |
## Phase 0 Research
Research outcomes are captured in `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/specs/175-workspace-governance-attention/research.md`.
Key decisions:
- Reuse `WorkspaceOverviewBuilder` as the single orchestration point instead of creating a new workspace posture service.
- Reuse `TenantGovernanceAggregateResolver` and `BaselineCompareStats` per visible tenant as the primary source for lapsed governance, overdue findings, expiring governance, high-severity active findings, and stale, failed, or materially degraded compare posture.
- Count governance risk at the tenant level for workspace summary metrics so the workspace answer is “how many tenants need attention,” not “how many raw issues exist.”
- Rank governance-critical tenant states above activity-only or alert-delivery-only items, while keeping operations and alerts available as lower-priority supporting attention.
- Make every workspace attention item tenant-identifiable and map it to exactly one matching destination, with an RBAC-safe fallback or disabled/non-clickable explanatory state when the exact destination is not allowed.
- Default zero-tenant recovery to the existing choose-workspace route and keep alert-only follow-up on the existing alerts overview route.
- Keep `WorkspaceRecentOperations` as a diagnostic activity surface, not a governance surface.
- Promote evidence or review truth only when an existing tenant-level evidence or review surface already carries a clearer next jump than the tenant dashboard fallback.
- Lean on request-scoped derived-state caching to keep per-tenant aggregate resolution viable for normal workspace sizes without new persistence.
## Phase 1 Design
Design artifacts are created under `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/specs/175-workspace-governance-attention/`:
- `data-model.md`: persistent source truths and derived workspace attention contracts for this slice
- `contracts/workspace-governance-attention.openapi.yaml`: internal surface contract for workspace governance-aware overview semantics and drill-through continuity
- `quickstart.md`: focused implementation and verification workflow
Design highlights:
- `WorkspaceOverviewBuilder` remains the builder boundary and becomes responsible for deriving governance-aware summary metrics, attention candidates, and calmness claims from visible tenants only.
- `TenantGovernanceAggregateResolver` is the primary source for governance-related tenant promotion, mirroring the mature tenant dashboard semantics rather than reinterpreting the same truth a second time.
- Workspace summary metrics are split into scope, governance-risk, and activity categories so the stat strip can no longer blur portfolio activity with portfolio risk.
- Workspace attention items become structured tenant-bound records carrying tenant label, problem family, urgency, and one primary drill-through target.
- `WorkspaceNeedsAttention` remains bounded and prioritized, favoring the highest-value tenant signal per item instead of dumping every raw issue into the workspace surface.
- Compare-driven workspace attention must preserve stale, failed, and materially degraded posture families rather than collapsing them into a single degraded-only bucket.
- Compare attention promotes `STATE_STALE` directly and only treats `STATE_ACTION_REQUIRED` as materially degraded compare posture when the aggregate's next action remains compare-specific rather than findings-driven; `STATE_CAUTION` remains below the workspace-attention threshold unless another governance signal independently warrants promotion.
- Existing evidence and review surfaces stay optional targets in this slice: they are only used when already-available truth makes them the most precise next jump.
- Zero-tenant recovery defaults to `ChooseWorkspace`, and alert-only follow-up reuses the existing alerts overview at `/admin/alerts`.
- `WorkspaceRecentOperations` remains a recency surface and is intentionally prevented from defining calmness on its own.
## Phase 1 — Agent Context Update
Run after artifact generation:
- `.specify/scripts/bash/update-agent-context.sh copilot`
## Project Structure
### Documentation (this feature)
```text
specs/175-workspace-governance-attention/
├── spec.md
├── plan.md
├── research.md
├── data-model.md
├── quickstart.md
├── contracts/
│ └── workspace-governance-attention.openapi.yaml
├── checklists/
│ └── requirements.md
└── tasks.md
```
### Source Code (repository root)
```text
app/
├── Filament/
│ ├── Pages/
│ │ ├── WorkspaceOverview.php
│ │ ├── ChooseTenant.php
│ │ ├── TenantDashboard.php
│ │ └── BaselineCompareLanding.php
│ ├── Resources/
│ │ ├── FindingResource.php
│ │ ├── EvidenceSnapshotResource.php
│ │ └── TenantReviewResource.php
│ └── Widgets/
│ ├── Dashboard/
│ │ └── NeedsAttention.php
│ └── Workspace/
│ ├── WorkspaceSummaryStats.php
│ ├── WorkspaceNeedsAttention.php
│ └── WorkspaceRecentOperations.php
├── Models/
│ ├── Finding.php
│ ├── AlertDelivery.php
│ ├── OperationRun.php
│ ├── EvidenceSnapshot.php
│ └── TenantReview.php
├── Services/
│ └── Auth/
│ ├── WorkspaceCapabilityResolver.php
│ └── CapabilityResolver.php
└── Support/
├── Auth/
│ └── Capabilities.php
├── Baselines/
│ ├── BaselineCompareStats.php
│ ├── BaselineCompareSummaryAssessment.php
│ ├── BaselineCompareSummaryAssessor.php
│ ├── TenantGovernanceAggregate.php
│ └── TenantGovernanceAggregateResolver.php
├── OperationRunLinks.php
└── Workspaces/
└── WorkspaceOverviewBuilder.php
resources/
└── views/
└── filament/
├── pages/
│ └── workspace-overview.blade.php
└── widgets/
└── workspace/
├── workspace-needs-attention.blade.php
└── workspace-recent-operations.blade.php
tests/
└── Feature/
└── Filament/
├── WorkspaceOverviewAccessTest.php
├── WorkspaceOverviewAuthorizationTest.php
├── WorkspaceOverviewContentTest.php
├── WorkspaceOverviewEmptyStatesTest.php
├── WorkspaceOverviewLandingTest.php
├── WorkspaceOverviewNavigationTest.php
├── WorkspaceOverviewOperationsTest.php
├── WorkspaceOverviewPermissionVisibilityTest.php
├── WorkspaceOverviewGovernanceAttentionTest.php
├── WorkspaceOverviewDbOnlyTest.php
├── WorkspaceOverviewDrilldownContinuityTest.php
└── WorkspaceOverviewSummaryMetricsTest.php
```
**Structure Decision**: Keep the feature entirely inside the existing Laravel/Filament monolith. Extend the current workspace overview builder, workspace widgets, tenant truth helpers, and existing destination resources or pages instead of creating a new workspace domain layer.
## Complexity Tracking
> No Constitution Check violations are planned. No exceptions are currently justified.
| Violation | Why Needed | Simpler Alternative Rejected Because |
|-----------|------------|-------------------------------------|
| — | — | — |
## Proportionality Review
> No new enum/status family, persisted entity, abstraction layer, taxonomy, or cross-domain UI framework is planned in this slice.
- **Current operator problem**: The workspace home can look calm even when visible tenants already carry governance-critical problems.
- **Existing structure is insufficient because**: Workspace aggregation currently stops at operations and alerts and fails to propagate already-mature tenant governance truth.
- **Narrowest correct implementation**: Extend the existing workspace overview builder and widgets to consume existing tenant aggregates and destination contracts.
- **Ownership cost created**: Modest additional workspace-level aggregation logic and focused regression coverage.
- **Alternative intentionally rejected**: A new workspace posture subsystem, new persistence, or a matrix-style redesign.
- **Release truth**: Current-release truth.
## Implementation Strategy
### Phase A — Reuse Existing Tenant Truth In The Workspace Builder
**Goal**: Make `WorkspaceOverviewBuilder` governance-aware without creating a parallel truth layer.
| Step | File | Change |
|------|------|--------|
| A.1 | `app/Support/Workspaces/WorkspaceOverviewBuilder.php` | Add a visible-tenant governance promotion pass that resolves `TenantGovernanceAggregate` for accessible tenants and builds a bounded set of governance candidates from overdue findings, lapsed governance, expiring governance, high-severity active findings, stale, failed, or materially degraded compare posture, and optionally already-available evidence or review attention. |
| A.2 | `app/Support/Baselines/TenantGovernanceAggregateResolver.php` and existing tenant truth classes | Reuse existing aggregate outputs as-is; only add plan-level consumption, not a second interpretation layer. |
| A.3 | `tests/Feature/Filament/WorkspaceOverviewGovernanceAttentionTest.php` plus existing overview tests | Prove that visible governance-critical tenants now surface on the workspace home even when operations are quiet. |
### Phase B — Separate Governance Metrics From Activity Metrics
**Goal**: Make the stat strip clearly answer risk versus activity instead of flattening them into one line of numbers.
| Step | File | Change |
|------|------|--------|
| B.1 | `app/Support/Workspaces/WorkspaceOverviewBuilder.php` | Replace or augment the current `needs_attention` stat with one or more tenant-level governance-risk metrics such as tenants needing governance attention, tenants with overdue findings, tenants with lapsed governance, or tenants with stale, failed, or materially degraded compare posture. |
| B.2 | `app/Filament/Widgets/Workspace/WorkspaceSummaryStats.php` and `resources/views/filament/pages/workspace-overview.blade.php` | Preserve the existing stat strip but ensure risk metrics and activity metrics are semantically and visually distinguishable through grouping, wording, and destination meaning. |
| B.3 | `tests/Feature/Filament/WorkspaceOverviewSummaryMetricsTest.php` and existing content tests | Prove that governance metrics count affected visible tenants, activity metrics remain activity-only, and the two meanings are not mixed. |
### Phase C — Make Workspace Attention Tenant-Addressable And Actionable
**Goal**: Turn workspace attention into a real start surface with tenant identity, reason, priority, and next jump.
| Step | File | Change |
|------|------|--------|
| C.1 | `app/Support/Workspaces/WorkspaceOverviewBuilder.php` | Expand the attention item payload to include tenant label, tenant route key or id, problem family, urgency, and one primary target kind. Keep every attention item tenant-bound, leave workspace-wide operations or alert totals in metrics or recency surfaces, and rank governance issues above activity-only items while keeping the list bounded. |
| C.2 | `resources/views/filament/widgets/workspace/workspace-needs-attention.blade.php` | Render tenant identity, family, urgency, and one explicit primary action or a disabled explanatory state when the exact destination is not allowed. |
| C.3 | `tests/Feature/Filament/WorkspaceOverviewDrilldownContinuityTest.php` and `tests/Feature/Filament/WorkspaceOverviewGovernanceAttentionTest.php` | Prove that each central attention family leads to the correct tenant dashboard, findings, compare, evidence, review, or operation destination and that dead-end links are not exposed. |
### Phase D — Fix Workspace Calmness And Empty-State Semantics
**Goal**: Stop the workspace home from claiming calmness when only operations are quiet.
| Step | File | Change |
|------|------|--------|
| D.1 | `app/Support/Workspaces/WorkspaceOverviewBuilder.php` | Change empty-state and calmness logic so calm claims are suppressed whenever visible tenant governance or compare issues exist, even if operations and alerts are healthy. Keep zero-tenant states distinct from healthy states and default their recovery action to the existing choose-workspace route. |
| D.2 | `app/Filament/Widgets/Workspace/WorkspaceNeedsAttention.php`, `resources/views/filament/pages/workspace-overview.blade.php`, and `resources/views/filament/widgets/workspace/workspace-needs-attention.blade.php` | Tighten copy so the workspace can say “nothing urgent” only for the domains actually checked and only when no visible governance-critical tenant state exists. |
| D.3 | `tests/Feature/Filament/WorkspaceOverviewEmptyStatesTest.php` and new governance attention coverage | Prove false calmness is suppressed and low-permission or zero-tenant scenarios remain clearly distinct from healthy calm. |
### Phase E — Preserve Operations As Diagnostic Recency, Not Portfolio Posture
**Goal**: Keep `WorkspaceRecentOperations` useful without letting it dominate workspace risk semantics.
| Step | File | Change |
|------|------|--------|
| E.1 | `app/Filament/Widgets/Workspace/WorkspaceRecentOperations.php` and `resources/views/filament/widgets/workspace/workspace-recent-operations.blade.php` | Preserve existing recency behavior and row-open model, but ensure surrounding copy and page hierarchy keep it clearly subordinate to governance attention and summary metrics. |
| E.2 | `tests/Feature/Filament/WorkspaceOverviewOperationsTest.php` and content tests | Prove operations remain filtered to the visible tenant slice, remain non-polling by default, and no longer define calmness on their own. |
### Phase F — Tighten RBAC-Safe Destination Selection
**Goal**: Ensure visible truth never creates a broken or misleading navigation path.
| Step | File | Change |
|------|------|--------|
| F.1 | `app/Support/Workspaces/WorkspaceOverviewBuilder.php`, `app/Services/Auth/WorkspaceCapabilityResolver.php`, `app/Services/Auth/CapabilityResolver.php`, and tenant resource URL helpers | For each attention family, choose the most precise destination the current in-scope user may actually open; otherwise fall back to an allowed tenant dashboard or disabled explanatory state. Aggregate lapsed-governance attention stays tenant-bound but falls back to the tenant dashboard when the current findings filters would narrow the invalid-governance family. Alert-only follow-up reuses `/admin/alerts`, and zero-tenant recovery reuses `/admin/choose-workspace`. |
| F.2 | `tests/Feature/Filament/WorkspaceOverviewPermissionVisibilityTest.php`, `WorkspaceOverviewAuthorizationTest.php`, and new drilldown tests | Prove non-members still receive `404`, hidden tenants do not affect visible output, and members missing downstream capability do not receive clickable dead-end links. |
### Phase G — Verification And Formatting
**Goal**: Lock the new workspace truth and performance contract in place.
| Step | File | Change |
|------|------|--------|
| G.1 | Workspace overview focused test pack | Add or extend tests for governance promotion, stale, failed, and materially degraded compare breadth, ordering, tenant identity, summary metric separation, calmness suppression, zero-tenant next-step recovery, low-permission operations fallback, drill-through continuity, DB-only query-bounded rendering, and permission-safe fallbacks. |
| G.2 | `vendor/bin/sail bin pint --dirty --format agent` and focused Pest runs | Apply formatting and run the smallest verification pack that covers the builder, workspace view rendering, and navigation behavior. |
## Key Design Decisions
### D-001 — Keep `WorkspaceOverviewBuilder` as the single orchestration boundary
The repo already has one place that constructs the workspace overview payload. Extending that builder is narrower than inventing a separate workspace governance service or presenter layer.
### D-002 — Promote tenant truth by tenant, not by raw issue count
The workspace operator needs to know which tenants need attention first. A tenant-level risk count is a better workspace answer than raw issue totals because it preserves the MSP triage shape and avoids making one noisy tenant dominate the stat strip.
### D-003 — Reuse `TenantGovernanceAggregate` before touching lower-level logic
The tenant dashboard already uses a mature governance aggregate. Workspace attention should consume that same truth path rather than rebuilding overdue, lapsed, or compare logic with new ad-hoc queries.
### D-004 — Keep workspace attention bounded and prioritized
The spec hardens the workspace entry point, not a portfolio matrix. The workspace home should surface the top visible reasons to act, not every raw tenant issue. One high-value item per visible problem family is preferable to a noisy stream.
### D-005 — Treat evidence and review as opportunistic precision targets
Evidence and reviews are in scope only when they already represent the best existing tenant-level next jump. They are not a reason to create a new workspace evidence or review aggregate in this slice.
### D-006 — Calmness is a checked-domain claim, not a decorative empty state
The workspace may only look calm when both governance and activity signals in visible scope are calm. Operations-only quietness is never enough.
### D-007 — Disabled or fallback navigation is preferable to dead-end clicks
If the current in-scope user can see that a tenant has a problem but cannot open the most precise destination, the UI must not offer a clickable link that fails later. The surface must either choose an allowed fallback or render a disabled explanatory state.
## Risk Assessment
| Risk | Impact | Likelihood | Mitigation |
|------|--------|------------|------------|
| Workspace attention becomes noisy by promoting too many tenant signals | High | Medium | Bound the list, rank governance-critical families first, and cap per-page attention items. |
| Per-tenant aggregate resolution introduces avoidable N+1 behavior | High | Medium | Reuse `TenantGovernanceAggregateResolver` request-scoped caching and keep the visible tenant slice bounded. |
| Workspace and tenant truth diverge because workspace logic starts reinterpreting the same facts | High | Medium | Consume `TenantGovernanceAggregate` and existing destinations instead of rebuilding semantics with ad-hoc query logic. |
| Capability gaps create misleading or broken drill-throughs | High | Medium | Implement explicit destination selection with fallback or disabled states and cover it with focused tests. |
| Calmness copy still overstates health after the change | Medium | Medium | Add explicit calmness-suppression tests covering quiet-ops but risky-governance scenarios. |
## Test Strategy
- Extend existing workspace overview tests to preserve landing, navigation, authorization, permission visibility, and operations-slice behavior.
- Add focused governance attention coverage for overdue findings, lapsed governance, expiring governance, high-severity active findings, stale, failed, or materially degraded compare posture, and quiet-ops-but-risky-governance scenarios.
- Add summary-metric tests proving governance-risk metrics count affected visible tenants rather than raw issue counts and remain distinct from activity metrics.
- Add drill-through continuity tests covering tenant dashboard fallback, findings filters, baseline compare landing, evidence or review targets where applicable, and canonical operation detail or index routes.
- Add permission-sensitive tests ensuring non-members remain `404`, invisible tenants do not affect visible output, members missing a downstream capability get a safe fallback or disabled state rather than a clickable dead end, and zero-tenant members receive the choose-workspace recovery action instead of healthy calm messaging.
- Add DB-only and query-bounding verification so render-time aggregation stays inside the plan's performance constraints and benefits from existing request-scoped caching.
- Keep the verification pack Sail-first and run `vendor/bin/sail bin pint --dirty --format agent` before closing implementation.
## Constitution Check (Post-Design)
Re-check result: PASS.
- Livewire v4.0+ compliance: preserved because the design stays inside existing Filament v5 widgets and pages.
- Provider registration location: unchanged; no panel/provider registration work is needed beyond the existing `bootstrap/providers.php` setup.
- Global-searchable resources: unchanged; no resource search behavior is altered.
- Destructive actions: unchanged; the feature adds no destructive action and therefore no new confirmation flow.
- Asset strategy: unchanged; no new asset bundle or deploy-time asset step is introduced.
- Testing plan: focused Pest coverage will be added or extended for workspace overview rendering, governance promotion, calmness suppression, summary metric separation, drill-through continuity, and RBAC-safe behavior.

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