Compare commits

...

6 Commits

Author SHA1 Message Date
1f0cc5de56 feat: implement operator explanation layer (#191)
## Summary
- add the shared operator explanation layer with explanation families, trustworthiness semantics, count descriptors, and centralized badge mappings
- adopt explanation-first rendering across baseline compare, governance operation run detail, baseline snapshot presentation, tenant review detail, and review register rows
- extend reason translation, artifact-truth presentation, fallback ops UX messaging, and focused regression coverage for operator explanation semantics

## Testing
- vendor/bin/sail bin pint --dirty --format agent
- vendor/bin/sail artisan test --compact tests/Feature/Monitoring/OperationsTenantScopeTest.php tests/Feature/Operations/OperationRunBlockedExecutionPresentationTest.php
- vendor/bin/sail artisan test --compact

## Notes
- Livewire v4 compatible
- panel provider registration remains in bootstrap/providers.php
- no destructive Filament actions were added or changed in this PR
- no new global-search behavior was introduced in this slice

Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de>
Reviewed-on: #191
2026-03-24 11:24:33 +00:00
845d21db6d feat: harden operation lifecycle monitoring (#190)
## Summary
- harden operation-run lifecycle handling with explicit reconciliation policy, stale-run healing, failed-job bridging, and monitoring visibility
- refactor audit log event inspection into a Filament slide-over and remove the stale inline detail/header-action coupling
- align panel theme asset resolution and supporting Filament UI updates, including the rounded 2xl theme token regression fix

## Testing
- ran focused Pest coverage for the affected audit-log inspection flow and related visibility tests
- ran formatting with `vendor/bin/sail bin pint --dirty --format agent`
- manually verified the updated audit-log slide-over flow in the integrated browser

## Notes
- branch includes the Spec 160 artifacts under `specs/160-operation-lifecycle-guarantees/`
- the full test suite was not rerun as part of this final commit/PR step

Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de>
Reviewed-on: #190
2026-03-23 21:53:19 +00:00
8426741068 feat: add baseline snapshot truth guards (#189)
## Summary
- add explicit BaselineSnapshot lifecycle truth with conservative backfill and a shared truth resolver
- block baseline compare from building, incomplete, or superseded snapshots and align workspace/tenant UI truth surfaces with effective snapshot state
- surface artifact truth separately from operation outcome across baseline profile, snapshot, compare, and operation run pages

## Testing
- integrated browser smoke test on the active feature surfaces
- `vendor/bin/sail artisan test --compact tests/Feature/Filament/BaselineSnapshotTruthSurfaceTest.php tests/Feature/Filament/BaselineProfileCompareStartSurfaceTest.php`
- targeted baseline lifecycle and compare guard coverage added in Pest
- `vendor/bin/sail bin pint --dirty --format agent`

## Notes
- Livewire v4 compliance preserved
- no panel provider registration changes were needed; Laravel 12 providers remain in `bootstrap/providers.php`
- global search remains disabled for the affected baseline resources by design
- destructive actions remain confirmation-gated; capture and compare actions keep their existing authorization and confirmation behavior
- no new panel assets were added; existing deploy flow for `filament:assets` is unchanged

Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de>
Reviewed-on: #189
2026-03-23 11:32:00 +00:00
e7c9b4b853 feat: implement governance artifact truth semantics (#188)
## Summary
- add shared governance artifact truth presentation and badge taxonomy
- integrate artifact-truth messaging across baseline, evidence, tenant review, review pack, and operation run surfaces
- add focused regression coverage and spec artifacts for artifact truth semantics

## Testing
- not run in this step

Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de>
Reviewed-on: #188
2026-03-23 00:13:57 +00:00
92f39d9749 feat: add shared reason translation contract (#187)
## Summary
- introduce a shared reason-translation contract with envelopes, presenter helpers, fallback handling, and provider translation support
- adopt translated operator-facing reason presentation across operation runs, notifications, provider guidance, tenant operability, and RBAC-related surfaces
- add Spec 157 design artifacts and targeted regression coverage for translation quality, diagnostics retention, and authorization-safe guidance

## Validation
- `vendor/bin/sail bin pint --dirty --format agent`
- `vendor/bin/sail artisan test --compact tests/Architecture/ReasonTranslationPrimarySurfaceGuardTest.php tests/Unit/Support/ReasonTranslation/ReasonResolutionEnvelopeTest.php tests/Unit/Support/ReasonTranslation/ExecutionDenialReasonTranslationTest.php tests/Unit/Support/ReasonTranslation/TenantOperabilityReasonTranslationTest.php tests/Unit/Support/ReasonTranslation/RbacReasonTranslationTest.php tests/Unit/Support/ReasonTranslation/ProviderReasonTranslationTest.php tests/Feature/Notifications/OperationRunNotificationTest.php tests/Feature/Operations/OperationRunBlockedExecutionPresentationTest.php tests/Feature/Operations/TenantlessOperationRunViewerTest.php tests/Feature/ReasonTranslation/GovernanceReasonPresentationTest.php tests/Feature/Authorization/ReasonTranslationScopeSafetyTest.php tests/Feature/Monitoring/OperationRunBlockedSpec081Test.php tests/Feature/ProviderConnections/ProviderOperationBlockedGuidanceSpec081Test.php tests/Feature/ProviderConnections/ProviderGatewayRuntimeSmokeSpec081Test.php`

## Notes
- Livewire v4.0+ compliance remains unchanged within the existing Filament v5 stack.
- No new panel was added; provider registration remains in `bootstrap/providers.php`.
- No new globally searchable resource was introduced.
- No new destructive action family was introduced.
- No new assets were added; the existing `filament:assets` deployment behavior remains unchanged.

Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de>
Reviewed-on: #187
2026-03-22 20:19:43 +00:00
3c3daae405 feat: normalize operator outcome taxonomy (#186)
## Summary
- introduce a shared operator outcome taxonomy with semantic axes, severity bands, and next-action policy
- apply the taxonomy to operations, evidence/review completeness, baseline semantics, and restore semantics
- harden badge rendering, tenant-safe filtering/search behavior, and operator-facing summary/notification wording
- add the spec kit artifacts, reference documentation, and regression coverage for diagnostic-vs-primary state handling

## Testing
- focused Pest coverage for taxonomy registry and badge guardrails
- operations presentation and notification tests
- evidence, baseline, restore, and tenant-scope regression tests

## Notes
- Livewire v4.0+ compliance is preserved in the existing Filament v5 stack
- panel provider registration remains unchanged in bootstrap/providers.php
- no new globally searchable resource was added; adopted resources remain tenant-safe and out of global search where required
- no new destructive action family was introduced; existing actions keep their current authorization and confirmation behavior
- no new frontend asset strategy was introduced; existing deploy flow with filament:assets remains unchanged

Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de>
Reviewed-on: #186
2026-03-22 12:13:34 +00:00
327 changed files with 23161 additions and 1232 deletions

View File

@ -96,6 +96,13 @@ ## Active Technologies
- PostgreSQL with new tenant-owned exception tables and JSONB-backed supporting metadata (001-finding-risk-acceptance) - PostgreSQL with new tenant-owned exception tables and JSONB-backed supporting metadata (001-finding-risk-acceptance)
- PHP 8.4, Laravel 12, Livewire 4, Filament 5 + Filament resources/pages/actions, Eloquent models, queued Laravel jobs, existing `EvidenceSnapshotService`, existing `ReviewPackService`, capability registry, `OperationRunService` (155-tenant-review-layer) - PHP 8.4, Laravel 12, Livewire 4, Filament 5 + Filament resources/pages/actions, Eloquent models, queued Laravel jobs, existing `EvidenceSnapshotService`, existing `ReviewPackService`, capability registry, `OperationRunService` (155-tenant-review-layer)
- PostgreSQL with JSONB-backed summary payloads and tenant/workspace ownership columns (155-tenant-review-layer) - PostgreSQL with JSONB-backed summary payloads and tenant/workspace ownership columns (155-tenant-review-layer)
- PostgreSQL-backed existing domain records; no new business-domain table is required for the first slice; shared taxonomy reference will live in repository documentation and code-level metadata (156-operator-outcome-taxonomy)
- PostgreSQL-backed existing records such as `operation_runs`, tenant governance records, onboarding workflow state, and provider connection state; no new business-domain table is required for the first slice (157-reason-code-translation)
- PHP 8.4.15 + Laravel 12, Filament v5, Livewire v4, Pest v4, existing `BadgeCatalog` / `BadgeRenderer` / `OperatorOutcomeTaxonomy`, `ReasonPresenter`, `OperationRunService`, `TenantReviewReadinessGate`, existing baseline/evidence/review/review-pack resources and canonical pages (158-artifact-truth-semantics)
- PostgreSQL with existing JSONB-backed `summary`, `summary_jsonb`, and `context` payloads on baseline snapshots, evidence snapshots, tenant reviews, review packs, and operation runs; no new primary storage required for the first slice (158-artifact-truth-semantics)
- PHP 8.4.15 + Laravel 12, Filament 5, Livewire 4, Pest 4, Laravel queue workers, existing `OperationRunService`, `TrackOperationRun`, `OperationUxPresenter`, `ReasonPresenter`, `BadgeCatalog` domain badges, and current Operations Monitoring pages (160-operation-lifecycle-guarantees)
- PostgreSQL for `operation_runs`, `jobs`, and `failed_jobs`; JSONB-backed `context`, `summary_counts`, and `failure_summary`; configuration in `config/queue.php` and `config/tenantpilot.php` (160-operation-lifecycle-guarantees)
- PostgreSQL (via Sail) plus existing read models persisted in application tables (161-operator-explanation-layer)
- PHP 8.4.15 (feat/005-bulk-operations) - PHP 8.4.15 (feat/005-bulk-operations)
@ -115,8 +122,8 @@ ## Code Style
PHP 8.4.15: Follow standard conventions PHP 8.4.15: Follow standard conventions
## Recent Changes ## Recent Changes
- 155-tenant-review-layer: Added PHP 8.4, Laravel 12, Livewire 4, Filament 5 + Filament resources/pages/actions, Eloquent models, queued Laravel jobs, existing `EvidenceSnapshotService`, existing `ReviewPackService`, capability registry, `OperationRunService` - 161-operator-explanation-layer: Added PHP 8.4 + Laravel 12, Filament v5, Livewire v4
- 001-finding-risk-acceptance: Added PHP 8.4.15 + Laravel 12, Filament v5, Livewire v4, Pest v4, existing Finding, AuditLog, EvidenceSnapshot, CapabilityResolver, WorkspaceCapabilityResolver, and UiEnforcement patterns - 160-operation-lifecycle-guarantees: Added PHP 8.4.15 + Laravel 12, Filament 5, Livewire 4, Pest 4, Laravel queue workers, existing `OperationRunService`, `TrackOperationRun`, `OperationUxPresenter`, `ReasonPresenter`, `BadgeCatalog` domain badges, and current Operations Monitoring pages
- 153-evidence-domain-foundation: Added PHP 8.4.15 + Laravel 12, Filament v5, Livewire v4, Pest v4, existing `StoredReport`, `Finding`, `OperationRun`, and `AuditLog` infrastructure - 159-baseline-snapshot-truth: Added PHP 8.4 + Laravel 12, Filament v5, Livewire v4
<!-- MANUAL ADDITIONS START --> <!-- MANUAL ADDITIONS START -->
<!-- MANUAL ADDITIONS END --> <!-- MANUAL ADDITIONS END -->

View File

@ -3,12 +3,17 @@
- Version change: 1.11.0 → 1.12.0 - Version change: 1.11.0 → 1.12.0
- Modified principles: - Modified principles:
- Scope & Ownership Clarification (SCOPE-001)
- Added sections:
- None - None
- Added sections:
- Operator Surface Principles (OPSURF-001)
- Removed sections: None - Removed sections: None
- Templates requiring updates: - Templates requiring updates:
- None - ✅ .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
- Follow-up TODOs: - Follow-up TODOs:
- None. - None.
--> -->
@ -330,6 +335,65 @@ ### Operator-facing UI Naming Standards (UI-NAMING-001)
- The visible run label for that action MUST be `Policy sync`. - The visible run label for that action MUST be `Policy sync`.
- The audit prose for that action MUST be `{actor} queued policy sync`. - The audit prose for that action MUST be `{actor} queued policy sync`.
### Operator Surface Principles (OPSURF-001)
Goal: operator-facing surfaces MUST optimize for the primary working audience rather than raw implementation visibility.
Operator-first default surfaces
- `/admin` is operator-first.
- Default-visible content MUST use operator-facing language, clear scope, and actionable status communication.
- Raw implementation details MUST NOT be default-visible on primary operator surfaces.
Progressive disclosure for diagnostics
- Diagnostic detail MAY be available where needed, but it MUST be secondary and explicitly revealed.
- JSON payloads, raw IDs, internal field names, provider error details, and low-level technical metadata belong in diagnostics surfaces, drawers, tabs, accordions, or modals rather than primary content.
- A surface MUST NOT require operators to parse raw JSON or provider-specific field names to understand the primary state or next action.
Distinct status dimensions
- Operator-facing surfaces MUST distinguish at least the following dimensions when they all exist in the domain:
- execution outcome
- data completeness
- governance result
- lifecycle or readiness state
- These dimensions MUST NOT be collapsed into a single ambiguous status model.
- If a surface summarizes multiple status dimensions, the default-visible presentation MUST label each dimension explicitly.
Explicit mutation scope
- Every action that changes state MUST communicate before execution whether it affects:
- TenantPilot only
- the Microsoft tenant
- simulation only
- Mutation scope MUST be understandable from the action label, helper text, confirmation copy, preview, or nearby status copy before the operator commits.
- A mutating action MUST NOT rely on hidden implementation knowledge to communicate its blast radius.
Safe execution for dangerous actions
- Dangerous actions MUST follow a consistent safe-execution pattern:
- configuration
- safety checks or simulation
- preview
- hard confirmation where required
- execute
- One-click destructive actions are not acceptable for high-blast-radius operations.
- When a full multi-step flow is not feasible, the spec MUST document the explicit exemption and the replacement safeguards.
Explicit workspace and tenant context
- Workspace and tenant scope MUST remain explicit in navigation, actions, and page semantics.
- Tenant-scoped surfaces MUST NOT silently expose workspace-wide actions.
- Canonical workspace views that reference tenant-owned records MUST make the workspace and tenant context legible before the operator acts.
Page contract requirement
- Every new or materially refactored operator-facing page MUST define:
- primary persona
- surface type
- primary operator question
- default-visible information
- diagnostics-only information
- status dimensions used
- mutation scope
- primary actions
- dangerous actions
- This page contract MUST be recorded in the governing spec and kept in sync when the page semantics materially change.
Spec Scope Fields (SCOPE-002) Spec Scope Fields (SCOPE-002)
- Every feature spec MUST declare: - Every feature spec MUST declare:
@ -387,4 +451,4 @@ ### Versioning Policy (SemVer)
- **MINOR**: new principle/section or materially expanded guidance. - **MINOR**: new principle/section or materially expanded guidance.
- **MAJOR**: removing/redefining principles in a backward-incompatible way. - **MAJOR**: removing/redefining principles in a backward-incompatible way.
**Version**: 1.11.0 | **Ratified**: 2026-01-03 | **Last Amended**: 2026-03-10 **Version**: 1.12.0 | **Ratified**: 2026-01-03 | **Last Amended**: 2026-03-21

View File

@ -50,6 +50,12 @@ ## Constitution Check
- Data minimization: Inventory stores metadata + whitelisted meta; logs contain no secrets/tokens - Data minimization: Inventory stores metadata + whitelisted meta; logs contain no secrets/tokens
- Badge semantics (BADGE-001): status-like badges use `BadgeCatalog` / `BadgeRenderer`; no ad-hoc mappings; new values include tests - Badge semantics (BADGE-001): status-like badges use `BadgeCatalog` / `BadgeRenderer`; no ad-hoc mappings; new values include tests
- UI naming (UI-NAMING-001): operator-facing labels use `Verb + Object`; scope (`Workspace`, `Tenant`) is never the primary action label; source/domain is secondary unless disambiguation is required; runs/toasts/audit prose use the same domain vocabulary; implementation-first terms do not appear in primary operator UI - UI naming (UI-NAMING-001): operator-facing labels use `Verb + Object`; scope (`Workspace`, `Tenant`) is never the primary action label; source/domain is secondary unless disambiguation is required; runs/toasts/audit prose use the same domain vocabulary; implementation-first terms do not appear in primary operator UI
- Operator surfaces (OPSURF-001): `/admin` defaults are operator-first; default-visible content avoids raw implementation detail; diagnostics are explicitly revealed secondarily
- Operator surfaces (OPSURF-001): execution outcome, data completeness, governance result, and lifecycle/readiness are modeled as distinct status dimensions when all apply; they are not collapsed into one ambiguous status
- Operator surfaces (OPSURF-001): every mutating action communicates whether it changes TenantPilot only, the Microsoft tenant, or simulation only before execution
- Operator surfaces (OPSURF-001): dangerous actions follow configuration → safety checks/simulation → preview → hard confirmation where required → execute, unless a spec documents an explicit exemption and replacement safeguards
- 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 record inspection affordance (prefer `recordUrl()` clickable rows; do not render a lone View row action), keep max 2 visible row actions with the rest in “More”, group bulk actions, require confirmations for destructive actions (typed confirmation for large/bulk where applicable), write audit logs for mutations, enforce RBAC via central helpers (non-member 404, member missing capability 403), and ensure CI blocks merges if the contract is violated or not explicitly exempted - Filament UI Action Surface Contract: for any new/modified Filament Resource/RelationManager/Page, define Header/Row/Bulk/Empty-State actions, ensure every List/Table has a record inspection affordance (prefer `recordUrl()` clickable rows; do not render a lone View row action), keep max 2 visible row actions with the rest in “More”, group bulk actions, require confirmations for destructive actions (typed confirmation for large/bulk where applicable), 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; tables provide search/sort/filters for core dimensions; shared layout builders preferred for consistency
## Project Structure ## Project Structure

View File

@ -17,6 +17,14 @@ ## Spec Scope Fields *(mandatory)*
- **Default filter behavior when tenant-context is active**: [e.g., prefilter to current tenant] - **Default filter behavior when tenant-context is active**: [e.g., prefilter to current tenant]
- **Explicit entitlement checks preventing cross-tenant leakage**: [Describe checks] - **Explicit entitlement checks preventing cross-tenant leakage**: [Describe checks]
## Operator Surface Contract *(mandatory when operator-facing surfaces are changed)*
If this feature adds a new operator-facing page or materially refactors one, fill out one row per affected page/surface.
| Surface | Primary Persona | Surface Type | Primary Operator Question | Default-visible Information | Diagnostics-only Information | Status Dimensions Used | Mutation Scope | Primary Actions | Dangerous Actions |
|---|---|---|---|---|---|---|---|---|---|
| e.g. Tenant policies page | Tenant operator | List/detail | What needs action right now? | Policy health, drift, assignment coverage | Raw payloads, provider IDs, low-level API details | lifecycle, data completeness, governance result | TenantPilot only / Microsoft tenant / simulation only | Sync policies, View policy | Restore policy |
## User Scenarios & Testing *(mandatory)* ## User Scenarios & Testing *(mandatory)*
<!-- <!--
@ -127,6 +135,15 @@ ## Requirements *(mandatory)*
- how the same domain vocabulary is preserved across button labels, modal titles, run titles, notifications, and audit prose, - how the same domain vocabulary is preserved across button labels, modal titles, run titles, notifications, and audit prose,
- and how implementation-first terms are kept out of primary operator-facing labels. - and how implementation-first terms are kept out of primary operator-facing labels.
**Constitution alignment (OPSURF-001):** If this feature adds or materially refactors an operator-facing surface, the spec MUST describe:
- how the default-visible content stays operator-first on `/admin` and avoids raw implementation detail,
- which diagnostics are secondary and how they are explicitly revealed,
- which status dimensions are shown separately (execution outcome, data completeness, governance result, lifecycle/readiness) and why,
- how each mutating action communicates its mutation scope before execution (`TenantPilot only`, `Microsoft tenant`, or `simulation only`),
- how dangerous actions follow the safe-execution pattern (configuration, safety checks/simulation, preview, hard confirmation where required, execute),
- how workspace and tenant context remain explicit in navigation, action copy, and page semantics,
- and the page contract for each new or materially refactored operator-facing page.
**Constitution alignment (Filament Action Surfaces):** If this feature adds or modifies any Filament Resource / RelationManager / Page, **Constitution alignment (Filament Action Surfaces):** If this feature adds or modifies any Filament Resource / RelationManager / Page,
the spec MUST include a “UI Action Matrix” (see below) and explicitly state whether the Action Surface Contract is satisfied. the spec MUST include a “UI Action Matrix” (see below) and explicitly state whether the Action Surface Contract is satisfied.
If the contract is not satisfied, the spec MUST include an explicit exemption with rationale. If the contract is not satisfied, the spec MUST include an explicit exemption with rationale.

View File

@ -38,6 +38,13 @@ # Tasks: [FEATURE NAME]
- using source/domain terms only where same-screen disambiguation is required, - using source/domain terms only where same-screen disambiguation is required,
- aligning button labels, modal titles, run titles, notifications, and audit prose to the same domain vocabulary, - aligning button labels, modal titles, run titles, notifications, and audit prose to the same domain vocabulary,
- removing implementation-first wording from primary operator-facing copy. - removing implementation-first wording from primary operator-facing copy.
**Operator Surfaces**: If this feature adds or materially refactors an operator-facing page or flow, tasks MUST include:
- filling the specs Operator Surface Contract for every affected page,
- making default-visible content operator-first and moving JSON payloads, raw IDs, internal field names, provider error details, and low-level metadata into explicitly revealed diagnostics surfaces,
- modeling execution outcome, data completeness, governance result, and lifecycle/readiness as distinct status dimensions when applicable,
- making mutation scope legible before execution for every state-changing action (`TenantPilot only`, `Microsoft tenant`, or `simulation only`),
- implementing the safe-execution flow for dangerous actions (configuration, safety checks/simulation, preview, hard confirmation where required, execute) or documenting an approved exemption,
- keeping workspace and tenant context explicit in navigation, actions, and page semantics so tenant pages do not silently expose workspace-wide actions.
**Filament UI Action Surfaces**: If this feature adds/modifies any Filament Resource / RelationManager / Page, tasks MUST include: **Filament UI Action Surfaces**: If this feature adds/modifies any Filament Resource / RelationManager / Page, tasks MUST include:
- filling the specs “UI Action Matrix” for all changed surfaces, - filling the specs “UI Action Matrix” for all changed surfaces,
- implementing required action surfaces (header/row/bulk/empty-state CTA for lists; header actions for view; consistent save/cancel on create/edit), - implementing required action surfaces (header/row/bulk/empty-state CTA for lists; header actions for view; consistent save/cancel on create/edit),

View File

@ -6,6 +6,7 @@
use App\Models\OperationRun; use App\Models\OperationRun;
use App\Models\Tenant; use App\Models\Tenant;
use App\Services\OperationRunService; use App\Services\OperationRunService;
use App\Services\Operations\OperationLifecycleReconciler;
use App\Support\OperationRunOutcome; use App\Support\OperationRunOutcome;
use Illuminate\Console\Command; use Illuminate\Console\Command;
@ -18,8 +19,10 @@ class TenantpilotReconcileBackupScheduleOperationRuns extends Command
protected $description = 'Reconcile stuck backup schedule OperationRuns without legacy run-table lookups.'; protected $description = 'Reconcile stuck backup schedule OperationRuns without legacy run-table lookups.';
public function handle(OperationRunService $operationRunService): int public function handle(
{ OperationRunService $operationRunService,
OperationLifecycleReconciler $operationLifecycleReconciler,
): int {
$tenantIdentifiers = array_values(array_filter((array) $this->option('tenant'))); $tenantIdentifiers = array_values(array_filter((array) $this->option('tenant')));
$olderThanMinutes = max(0, (int) $this->option('older-than')); $olderThanMinutes = max(0, (int) $this->option('older-than'));
$dryRun = (bool) $this->option('dry-run'); $dryRun = (bool) $this->option('dry-run');
@ -96,31 +99,9 @@ public function handle(OperationRunService $operationRunService): int
continue; continue;
} }
if ($operationRun->status === 'queued' && $operationRunService->isStaleQueuedRun($operationRun, max(1, $olderThanMinutes))) { $change = $operationLifecycleReconciler->reconcileRun($operationRun, $dryRun);
if (! $dryRun) {
$operationRunService->failStaleQueuedRun($operationRun, 'Backup schedule run was queued but never started.');
}
$reconciled++;
continue;
}
if ($operationRun->status === 'running') {
if (! $dryRun) {
$operationRunService->updateRun(
$operationRun,
status: 'completed',
outcome: OperationRunOutcome::Failed->value,
failures: [
[
'code' => 'backup_schedule.stalled',
'message' => 'Backup schedule run exceeded reconciliation timeout and was marked failed.',
],
],
);
}
if ($change !== null) {
$reconciled++; $reconciled++;
continue; continue;

View File

@ -0,0 +1,105 @@
<?php
declare(strict_types=1);
namespace App\Console\Commands;
use App\Models\Tenant;
use App\Services\Operations\OperationLifecycleReconciler;
use App\Support\Operations\OperationLifecyclePolicy;
use Illuminate\Console\Command;
class TenantpilotReconcileOperationRuns extends Command
{
protected $signature = 'tenantpilot:operation-runs:reconcile
{--type=* : Limit reconciliation to one or more covered operation types}
{--tenant=* : Limit reconciliation to tenant_id or tenant external_id}
{--workspace=* : Limit reconciliation to workspace ids}
{--limit=100 : Maximum number of active runs to inspect}
{--dry-run : Report the changes without writing them}';
protected $description = 'Reconcile stale covered operation runs back to deterministic terminal truth.';
public function handle(
OperationLifecycleReconciler $reconciler,
OperationLifecyclePolicy $policy,
): int {
$types = array_values(array_filter(
(array) $this->option('type'),
static fn (mixed $type): bool => is_string($type) && trim($type) !== '',
));
$workspaceIds = array_values(array_filter(
array_map(
static fn (mixed $workspaceId): int => is_numeric($workspaceId) ? (int) $workspaceId : 0,
(array) $this->option('workspace'),
),
static fn (int $workspaceId): bool => $workspaceId > 0,
));
$tenantIds = $this->resolveTenantIds(array_values(array_filter((array) $this->option('tenant'))));
$dryRun = (bool) $this->option('dry-run');
if ($types === []) {
$types = $policy->coveredTypeNames();
}
$result = $reconciler->reconcile([
'types' => $types,
'tenant_ids' => $tenantIds,
'workspace_ids' => $workspaceIds,
'limit' => max(1, (int) $this->option('limit')),
'dry_run' => $dryRun,
]);
$rows = collect($result['changes'] ?? [])
->map(static function (array $change): array {
return [
'Run' => (string) ($change['operation_run_id'] ?? '—'),
'Type' => (string) ($change['type'] ?? '—'),
'Reason' => (string) ($change['reason_code'] ?? '—'),
'Applied' => (($change['applied'] ?? false) === true) ? 'yes' : 'no',
];
})
->values()
->all();
if ($rows !== []) {
$this->table(['Run', 'Type', 'Reason', 'Applied'], $rows);
}
$this->info(sprintf(
'Inspected %d run(s); reconciled %d; skipped %d.',
(int) ($result['candidates'] ?? 0),
(int) ($result['reconciled'] ?? 0),
(int) ($result['skipped'] ?? 0),
));
if ($dryRun) {
$this->comment('Dry-run: no changes written.');
}
return self::SUCCESS;
}
/**
* @param array<int, string> $tenantIdentifiers
* @return array<int, int>
*/
private function resolveTenantIds(array $tenantIdentifiers): array
{
if ($tenantIdentifiers === []) {
return [];
}
$tenantIds = [];
foreach ($tenantIdentifiers as $identifier) {
$tenant = Tenant::query()->forTenant($identifier)->first();
if ($tenant instanceof Tenant) {
$tenantIds[] = (int) $tenant->getKey();
}
}
return array_values(array_unique($tenantIds));
}
}

View File

@ -89,6 +89,9 @@ class BaselineCompareLanding extends Page
/** @var array<string, int>|null */ /** @var array<string, int>|null */
public ?array $rbacRoleDefinitionSummary = null; public ?array $rbacRoleDefinitionSummary = null;
/** @var array<string, mixed>|null */
public ?array $operatorExplanation = null;
public static function canAccess(): bool public static function canAccess(): bool
{ {
$user = auth()->user(); $user = auth()->user();
@ -140,6 +143,7 @@ public function refreshStats(): void
$this->evidenceGapsCount = $stats->evidenceGapsCount; $this->evidenceGapsCount = $stats->evidenceGapsCount;
$this->evidenceGapsTopReasons = $stats->evidenceGapsTopReasons !== [] ? $stats->evidenceGapsTopReasons : null; $this->evidenceGapsTopReasons = $stats->evidenceGapsTopReasons !== [] ? $stats->evidenceGapsTopReasons : null;
$this->rbacRoleDefinitionSummary = $stats->rbacRoleDefinitionSummary !== [] ? $stats->rbacRoleDefinitionSummary : null; $this->rbacRoleDefinitionSummary = $stats->rbacRoleDefinitionSummary !== [] ? $stats->rbacRoleDefinitionSummary : null;
$this->operatorExplanation = $stats->operatorExplanation()->toArray();
} }
/** /**
@ -307,9 +311,22 @@ private function compareNowAction(): Action
$result = $service->startCompare($tenant, $user); $result = $service->startCompare($tenant, $user);
if (! ($result['ok'] ?? false)) { if (! ($result['ok'] ?? false)) {
$reasonCode = is_string($result['reason_code'] ?? null) ? (string) $result['reason_code'] : 'unknown';
$message = match ($reasonCode) {
\App\Support\Baselines\BaselineReasonCodes::COMPARE_ROLLOUT_DISABLED => 'Full-content baseline compare is currently disabled for controlled rollout.',
\App\Support\Baselines\BaselineReasonCodes::COMPARE_PROFILE_NOT_ACTIVE => 'The assigned baseline profile is not active.',
\App\Support\Baselines\BaselineReasonCodes::COMPARE_NO_ACTIVE_SNAPSHOT,
\App\Support\Baselines\BaselineReasonCodes::COMPARE_NO_CONSUMABLE_SNAPSHOT => 'No complete baseline snapshot is currently available for compare.',
\App\Support\Baselines\BaselineReasonCodes::COMPARE_SNAPSHOT_BUILDING => 'The latest baseline capture is still building. Compare will be available after it completes.',
\App\Support\Baselines\BaselineReasonCodes::COMPARE_SNAPSHOT_INCOMPLETE => 'The latest baseline capture is incomplete. Capture a new baseline before comparing.',
\App\Support\Baselines\BaselineReasonCodes::COMPARE_SNAPSHOT_SUPERSEDED => 'A newer complete baseline snapshot is current. Compare uses the latest complete baseline only.',
default => 'Reason: '.$reasonCode,
};
Notification::make() Notification::make()
->title('Cannot start comparison') ->title('Cannot start comparison')
->body('Reason: '.($result['reason_code'] ?? 'unknown')) ->body($message)
->danger() ->danger()
->send(); ->send();

View File

@ -34,6 +34,7 @@
use Filament\Tables\Contracts\HasTable; use Filament\Tables\Contracts\HasTable;
use Filament\Tables\Filters\SelectFilter; use Filament\Tables\Filters\SelectFilter;
use Filament\Tables\Table; use Filament\Tables\Table;
use Illuminate\Contracts\View\View;
use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Builder;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException; use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
use UnitEnum; use UnitEnum;
@ -82,14 +83,16 @@ public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
public function mount(): void public function mount(): void
{ {
$this->authorizePageAccess(); $this->authorizePageAccess();
$this->selectedAuditLogId = is_numeric(request()->query('event')) ? (int) request()->query('event') : null; $requestedEventId = is_numeric(request()->query('event')) ? (int) request()->query('event') : null;
app(CanonicalAdminTenantFilterState::class)->sync($this->getTableFiltersSessionKey(), request: request()); app(CanonicalAdminTenantFilterState::class)->sync($this->getTableFiltersSessionKey(), request: request());
$this->mountInteractsWithTable(); $this->mountInteractsWithTable();
if ($this->selectedAuditLogId !== null) { if ($requestedEventId !== null) {
$this->selectedAuditLog(); $this->resolveAuditLog($requestedEventId);
$this->selectedAuditLogId = $requestedEventId;
$this->mountTableAction('inspect', (string) $requestedEventId);
} }
} }
@ -98,31 +101,10 @@ public function mount(): void
*/ */
protected function getHeaderActions(): array protected function getHeaderActions(): array
{ {
$actions = app(OperateHubShell::class)->headerActions( return app(OperateHubShell::class)->headerActions(
scopeActionName: 'operate_hub_scope_audit_log', scopeActionName: 'operate_hub_scope_audit_log',
returnActionName: 'operate_hub_return_audit_log', returnActionName: 'operate_hub_return_audit_log',
); );
if ($this->selectedAuditLog() instanceof AuditLogModel) {
$actions[] = Action::make('clear_selected_audit_event')
->label('Close details')
->color('gray')
->action(function (): void {
$this->clearSelectedAuditLog();
});
$relatedLink = $this->selectedAuditLink();
if (is_array($relatedLink)) {
$actions[] = Action::make('open_selected_audit_target')
->label($relatedLink['label'])
->icon('heroicon-o-arrow-top-right-on-square')
->color('gray')
->url($relatedLink['url']);
}
}
return $actions;
} }
public function table(Table $table): Table public function table(Table $table): Table
@ -195,9 +177,19 @@ public function table(Table $table): Table
->label('Inspect event') ->label('Inspect event')
->icon('heroicon-o-eye') ->icon('heroicon-o-eye')
->color('gray') ->color('gray')
->action(function (AuditLogModel $record): void { ->before(function (AuditLogModel $record): void {
$this->selectedAuditLogId = (int) $record->getKey(); $this->selectedAuditLogId = (int) $record->getKey();
}), })
->slideOver()
->stickyModalHeader()
->modalSubmitAction(false)
->modalCancelAction(fn (Action $action): Action => $action->label('Close details'))
->modalHeading(fn (AuditLogModel $record): string => $record->summaryText())
->modalDescription(fn (AuditLogModel $record): ?string => $record->recorded_at?->toDayDateTimeString())
->modalContent(fn (AuditLogModel $record): View => view('filament.pages.monitoring.partials.audit-log-inspect-event', [
'selectedAudit' => $record,
'selectedAuditLink' => $this->auditTargetLink($record),
])),
]) ])
->bulkActions([]) ->bulkActions([])
->emptyStateHeading('No audit events match this view') ->emptyStateHeading('No audit events match this view')
@ -209,48 +201,11 @@ public function table(Table $table): Table
->icon('heroicon-o-x-mark') ->icon('heroicon-o-x-mark')
->color('gray') ->color('gray')
->action(function (): void { ->action(function (): void {
$this->selectedAuditLogId = null;
$this->resetTable(); $this->resetTable();
}), }),
]); ]);
} }
public function clearSelectedAuditLog(): void
{
$this->selectedAuditLogId = null;
}
public function selectedAuditLog(): ?AuditLogModel
{
if (! is_numeric($this->selectedAuditLogId)) {
return null;
}
$record = $this->auditBaseQuery()
->whereKey((int) $this->selectedAuditLogId)
->first();
if (! $record instanceof AuditLogModel) {
throw new NotFoundHttpException;
}
return $record;
}
/**
* @return array{label: string, url: string}|null
*/
public function selectedAuditLink(): ?array
{
$record = $this->selectedAuditLog();
if (! $record instanceof AuditLogModel) {
return null;
}
return app(RelatedNavigationResolver::class)->auditTargetLink($record);
}
/** /**
* @return array<int, Tenant> * @return array<int, Tenant>
*/ */
@ -323,6 +278,54 @@ private function auditBaseQuery(): Builder
->latestFirst(); ->latestFirst();
} }
private function resolveAuditLog(int $auditLogId): AuditLogModel
{
$record = $this->auditBaseQuery()
->whereKey($auditLogId)
->first();
if (! $record instanceof AuditLogModel) {
throw new NotFoundHttpException;
}
return $record;
}
public function selectedAuditRecord(): ?AuditLogModel
{
if (! is_int($this->selectedAuditLogId) || $this->selectedAuditLogId <= 0) {
return null;
}
try {
return $this->resolveAuditLog($this->selectedAuditLogId);
} catch (NotFoundHttpException) {
return null;
}
}
/**
* @return array{label: string, url: string}|null
*/
public function selectedAuditTargetLink(): ?array
{
$record = $this->selectedAuditRecord();
if (! $record instanceof AuditLogModel) {
return null;
}
return $this->auditTargetLink($record);
}
/**
* @return array{label: string, url: string}|null
*/
private function auditTargetLink(AuditLogModel $record): ?array
{
return app(RelatedNavigationResolver::class)->auditTargetLink($record);
}
/** /**
* @return array<string, string> * @return array<string, string>
*/ */

View File

@ -8,10 +8,13 @@
use App\Models\EvidenceSnapshot; use App\Models\EvidenceSnapshot;
use App\Models\Tenant; use App\Models\Tenant;
use App\Models\User; use App\Models\User;
use App\Support\Badges\BadgeCatalog;
use App\Support\Badges\BadgeDomain;
use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration; use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration;
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceInspectAffordance; use App\Support\Ui\ActionSurface\Enums\ActionSurfaceInspectAffordance;
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile; use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile;
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceSlot; use App\Support\Ui\ActionSurface\Enums\ActionSurfaceSlot;
use App\Support\Ui\GovernanceArtifactTruth\ArtifactTruthPresenter;
use App\Support\Workspaces\WorkspaceContext; use App\Support\Workspaces\WorkspaceContext;
use BackedEnum; use BackedEnum;
use Filament\Actions\Action; use Filament\Actions\Action;
@ -87,6 +90,9 @@ public function mount(): void
$snapshots = $query->get()->unique('tenant_id')->values(); $snapshots = $query->get()->unique('tenant_id')->values();
$this->rows = $snapshots->map(function (EvidenceSnapshot $snapshot): array { $this->rows = $snapshots->map(function (EvidenceSnapshot $snapshot): array {
$truth = app(ArtifactTruthPresenter::class)->forEvidenceSnapshot($snapshot);
$freshnessSpec = BadgeCatalog::spec(BadgeDomain::GovernanceArtifactFreshness, $truth->freshnessState);
return [ return [
'tenant_name' => $snapshot->tenant?->name ?? 'Unknown tenant', 'tenant_name' => $snapshot->tenant?->name ?? 'Unknown tenant',
'tenant_id' => (int) $snapshot->tenant_id, 'tenant_id' => (int) $snapshot->tenant_id,
@ -95,7 +101,21 @@ public function mount(): void
'generated_at' => $snapshot->generated_at?->toDateTimeString(), 'generated_at' => $snapshot->generated_at?->toDateTimeString(),
'missing_dimensions' => (int) (($snapshot->summary['missing_dimensions'] ?? 0)), 'missing_dimensions' => (int) (($snapshot->summary['missing_dimensions'] ?? 0)),
'stale_dimensions' => (int) (($snapshot->summary['stale_dimensions'] ?? 0)), 'stale_dimensions' => (int) (($snapshot->summary['stale_dimensions'] ?? 0)),
'view_url' => EvidenceSnapshotResource::getUrl('index', tenant: $snapshot->tenant), 'artifact_truth' => [
'label' => $truth->primaryLabel,
'color' => $truth->primaryBadgeSpec()->color,
'icon' => $truth->primaryBadgeSpec()->icon,
'explanation' => $truth->primaryExplanation,
],
'freshness' => [
'label' => $freshnessSpec->label,
'color' => $freshnessSpec->color,
'icon' => $freshnessSpec->icon,
],
'next_step' => $truth->nextStepText(),
'view_url' => $snapshot->tenant
? EvidenceSnapshotResource::getUrl('view', ['record' => $snapshot], tenant: $snapshot->tenant)
: null,
]; ];
})->all(); })->all();
} }

View File

@ -13,6 +13,7 @@
use App\Support\OperateHub\OperateHubShell; use App\Support\OperateHub\OperateHubShell;
use App\Support\OperationRunOutcome; use App\Support\OperationRunOutcome;
use App\Support\OperationRunStatus; use App\Support\OperationRunStatus;
use App\Support\Operations\OperationLifecyclePolicy;
use App\Support\Workspaces\WorkspaceContext; use App\Support\Workspaces\WorkspaceContext;
use BackedEnum; use BackedEnum;
use Filament\Actions\Action; use Filament\Actions\Action;
@ -165,6 +166,68 @@ public function table(Table $table): Table
}); });
} }
/**
* @return array{likely_stale:int,reconciled:int}
*/
public function lifecycleVisibilitySummary(): array
{
$baseQuery = $this->scopedSummaryQuery();
if (! $baseQuery instanceof Builder) {
return [
'likely_stale' => 0,
'reconciled' => 0,
];
}
$reconciled = (clone $baseQuery)
->whereNotNull('context->reconciliation->reconciled_at')
->count();
$policy = app(OperationLifecyclePolicy::class);
$likelyStale = (clone $baseQuery)
->whereIn('status', [
OperationRunStatus::Queued->value,
OperationRunStatus::Running->value,
])
->where(function (Builder $query) use ($policy): void {
foreach ($policy->coveredTypeNames() as $type) {
$query->orWhere(function (Builder $typeQuery) use ($policy, $type): void {
$typeQuery
->where('type', $type)
->where(function (Builder $stateQuery) use ($policy, $type): void {
$stateQuery
->where(function (Builder $queuedQuery) use ($policy, $type): void {
$queuedQuery
->where('status', OperationRunStatus::Queued->value)
->whereNull('started_at')
->where('created_at', '<=', now()->subSeconds($policy->queuedStaleAfterSeconds($type)));
})
->orWhere(function (Builder $runningQuery) use ($policy, $type): void {
$runningQuery
->where('status', OperationRunStatus::Running->value)
->where(function (Builder $startedAtQuery) use ($policy, $type): void {
$startedAtQuery
->where('started_at', '<=', now()->subSeconds($policy->runningStaleAfterSeconds($type)))
->orWhere(function (Builder $fallbackQuery) use ($policy, $type): void {
$fallbackQuery
->whereNull('started_at')
->where('created_at', '<=', now()->subSeconds($policy->runningStaleAfterSeconds($type)));
});
});
});
});
});
}
})
->count();
return [
'likely_stale' => $likelyStale,
'reconciled' => $reconciled,
];
}
private function applyActiveTab(Builder $query): Builder private function applyActiveTab(Builder $query): Builder
{ {
return match ($this->activeTab) { return match ($this->activeTab) {
@ -172,6 +235,9 @@ private function applyActiveTab(Builder $query): Builder
OperationRunStatus::Queued->value, OperationRunStatus::Queued->value,
OperationRunStatus::Running->value, OperationRunStatus::Running->value,
]), ]),
'blocked' => $query
->where('status', OperationRunStatus::Completed->value)
->where('outcome', OperationRunOutcome::Blocked->value),
'succeeded' => $query 'succeeded' => $query
->where('status', OperationRunStatus::Completed->value) ->where('status', OperationRunStatus::Completed->value)
->where('outcome', OperationRunOutcome::Succeeded->value), ->where('outcome', OperationRunOutcome::Succeeded->value),
@ -184,4 +250,26 @@ private function applyActiveTab(Builder $query): Builder
default => $query, default => $query,
}; };
} }
private function scopedSummaryQuery(): ?Builder
{
$workspaceId = app(WorkspaceContext::class)->currentWorkspaceId(request());
if (! $workspaceId) {
return null;
}
$tenantFilter = data_get($this->tableFilters, 'tenant_id.value');
if (! is_numeric($tenantFilter)) {
$tenantFilter = data_get(session()->get($this->getTableFiltersSessionKey(), []), 'tenant_id.value');
}
return OperationRun::query()
->where('workspace_id', (int) $workspaceId)
->when(
is_numeric($tenantFilter),
fn (Builder $query): Builder => $query->where('tenant_id', (int) $tenantFilter),
);
}
} }

View File

@ -19,10 +19,13 @@
use App\Support\OpsUx\OperationUxPresenter; use App\Support\OpsUx\OperationUxPresenter;
use App\Support\OpsUx\OpsUxBrowserEvents; use App\Support\OpsUx\OpsUxBrowserEvents;
use App\Support\OpsUx\RunDetailPolling; use App\Support\OpsUx\RunDetailPolling;
use App\Support\ReasonTranslation\ReasonPresenter;
use App\Support\RedactionIntegrity; use App\Support\RedactionIntegrity;
use App\Support\Tenants\ReferencedTenantLifecyclePresentation; use App\Support\Tenants\ReferencedTenantLifecyclePresentation;
use App\Support\Tenants\TenantInteractionLane; use App\Support\Tenants\TenantInteractionLane;
use App\Support\Tenants\TenantOperabilityQuestion; use App\Support\Tenants\TenantOperabilityQuestion;
use App\Support\Ui\GovernanceArtifactTruth\ArtifactTruthPresenter;
use App\Support\Ui\OperatorExplanation\OperatorExplanationPattern;
use Filament\Actions\Action; use Filament\Actions\Action;
use Filament\Actions\ActionGroup; use Filament\Actions\ActionGroup;
use Filament\Notifications\Notification; use Filament\Notifications\Notification;
@ -169,24 +172,60 @@ public function blockedExecutionBanner(): ?array
return null; return null;
} }
$context = is_array($this->run->context) ? $this->run->context : []; $operatorExplanation = $this->governanceOperatorExplanation();
$reasonCode = data_get($context, 'reason_code'); $reasonEnvelope = app(ReasonPresenter::class)->forOperationRun($this->run, 'run_detail');
$lines = $operatorExplanation instanceof OperatorExplanationPattern
if (! is_string($reasonCode) || trim($reasonCode) === '') { ? array_values(array_filter([
$reasonCode = data_get($context, 'execution_legitimacy.reason_code'); $operatorExplanation->headline,
} $operatorExplanation->dominantCauseExplanation,
OperationUxPresenter::surfaceGuidance($this->run),
$reasonCode = is_string($reasonCode) && trim($reasonCode) !== '' ? trim($reasonCode) : 'unknown_error'; ]))
$message = $this->run->failure_summary[0]['message'] ?? null; : ($reasonEnvelope?->toBodyLines() ?? [
$message = is_string($message) && trim($message) !== '' ? trim($message) : 'The queued run was refused before side effects could begin.'; OperationUxPresenter::surfaceFailureDetail($this->run) ?? 'The queued run was refused before side effects could begin.',
OperationUxPresenter::surfaceGuidance($this->run) ?? 'Review the blocked prerequisite before retrying.',
]);
return [ return [
'tone' => 'amber', 'tone' => 'amber',
'title' => 'Execution blocked', 'title' => 'Blocked by prerequisite',
'body' => sprintf('Reason code: %s. %s', $reasonCode, $message), 'body' => implode(' ', $lines),
]; ];
} }
/**
* @return array{tone: string, title: string, body: string}|null
*/
public function lifecycleBanner(): ?array
{
if (! isset($this->run)) {
return null;
}
$attention = OperationUxPresenter::lifecycleAttentionSummary($this->run);
if ($attention === null) {
return null;
}
$detail = OperationUxPresenter::surfaceFailureDetail($this->run) ?? 'Lifecycle truth needs operator review.';
$guidance = OperationUxPresenter::surfaceGuidance($this->run);
$body = $guidance !== null ? $detail.' '.$guidance : $detail;
return match ($this->run->freshnessState()->value) {
'likely_stale' => [
'tone' => 'amber',
'title' => 'Likely stale run',
'body' => $body,
],
'reconciled_failed' => [
'tone' => 'rose',
'title' => 'Automatically reconciled',
'body' => $body,
],
default => null,
};
}
/** /**
* @return array{tone: string, title: string, body: string}|null * @return array{tone: string, title: string, body: string}|null
*/ */
@ -421,4 +460,13 @@ private function relatedLinksTenant(): ?Tenant
lane: TenantInteractionLane::StandardActiveOperating, lane: TenantInteractionLane::StandardActiveOperating,
)->allowed ? $tenant : null; )->allowed ? $tenant : null;
} }
private function governanceOperatorExplanation(): ?OperatorExplanationPattern
{
if (! isset($this->run) || ! $this->run->supportsOperatorExplanation()) {
return null;
}
return app(ArtifactTruthPresenter::class)->forOperationRun($this->run)?->operatorExplanation;
}
} }

View File

@ -11,15 +11,18 @@
use App\Models\Workspace; use App\Models\Workspace;
use App\Services\TenantReviews\TenantReviewRegisterService; use App\Services\TenantReviews\TenantReviewRegisterService;
use App\Support\Auth\Capabilities; use App\Support\Auth\Capabilities;
use App\Support\Badges\BadgeCatalog;
use App\Support\Badges\BadgeDomain; use App\Support\Badges\BadgeDomain;
use App\Support\Badges\BadgeRenderer; use App\Support\Badges\BadgeRenderer;
use App\Support\Filament\CanonicalAdminTenantFilterState; use App\Support\Filament\CanonicalAdminTenantFilterState;
use App\Support\Filament\FilterPresets; use App\Support\Filament\FilterPresets;
use App\Support\Filament\TablePaginationProfiles; use App\Support\Filament\TablePaginationProfiles;
use App\Support\TenantReviewCompletenessState;
use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration; use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration;
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceInspectAffordance; use App\Support\Ui\ActionSurface\Enums\ActionSurfaceInspectAffordance;
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile; use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile;
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceSlot; use App\Support\Ui\ActionSurface\Enums\ActionSurfaceSlot;
use App\Support\Ui\GovernanceArtifactTruth\ArtifactTruthPresenter;
use App\Support\Workspaces\WorkspaceContext; use App\Support\Workspaces\WorkspaceContext;
use BackedEnum; use BackedEnum;
use Filament\Actions\Action; use Filament\Actions\Action;
@ -112,6 +115,15 @@ public function table(Table $table): Table
->color(BadgeRenderer::color(BadgeDomain::TenantReviewStatus)) ->color(BadgeRenderer::color(BadgeDomain::TenantReviewStatus))
->icon(BadgeRenderer::icon(BadgeDomain::TenantReviewStatus)) ->icon(BadgeRenderer::icon(BadgeDomain::TenantReviewStatus))
->iconColor(BadgeRenderer::iconColor(BadgeDomain::TenantReviewStatus)), ->iconColor(BadgeRenderer::iconColor(BadgeDomain::TenantReviewStatus)),
TextColumn::make('artifact_truth')
->label('Artifact truth')
->badge()
->getStateUsing(fn (TenantReview $record): string => app(ArtifactTruthPresenter::class)->forTenantReview($record)->primaryLabel)
->color(fn (TenantReview $record): string => app(ArtifactTruthPresenter::class)->forTenantReview($record)->primaryBadgeSpec()->color)
->icon(fn (TenantReview $record): ?string => app(ArtifactTruthPresenter::class)->forTenantReview($record)->primaryBadgeSpec()->icon)
->iconColor(fn (TenantReview $record): ?string => app(ArtifactTruthPresenter::class)->forTenantReview($record)->primaryBadgeSpec()->iconColor)
->description(fn (TenantReview $record): ?string => app(ArtifactTruthPresenter::class)->forTenantReview($record)->operatorExplanation?->headline ?? app(ArtifactTruthPresenter::class)->forTenantReview($record)->primaryExplanation)
->wrap(),
TextColumn::make('completeness_state') TextColumn::make('completeness_state')
->label('Completeness') ->label('Completeness')
->badge() ->badge()
@ -121,15 +133,29 @@ public function table(Table $table): Table
->iconColor(BadgeRenderer::iconColor(BadgeDomain::TenantReviewCompleteness)), ->iconColor(BadgeRenderer::iconColor(BadgeDomain::TenantReviewCompleteness)),
TextColumn::make('generated_at')->dateTime()->placeholder('—')->sortable(), TextColumn::make('generated_at')->dateTime()->placeholder('—')->sortable(),
TextColumn::make('published_at')->dateTime()->placeholder('—')->sortable(), TextColumn::make('published_at')->dateTime()->placeholder('—')->sortable(),
TextColumn::make('summary.publish_blockers') TextColumn::make('publication_truth')
->label('Publish blockers') ->label('Publication')
->formatStateUsing(static function (mixed $state): string { ->badge()
if (! is_array($state) || $state === []) { ->getStateUsing(fn (TenantReview $record): string => BadgeCatalog::spec(
return '0'; BadgeDomain::GovernanceArtifactPublicationReadiness,
} app(ArtifactTruthPresenter::class)->forTenantReview($record)->publicationReadiness ?? 'internal_only',
)->label)
return (string) count($state); ->color(fn (TenantReview $record): string => BadgeCatalog::spec(
}), BadgeDomain::GovernanceArtifactPublicationReadiness,
app(ArtifactTruthPresenter::class)->forTenantReview($record)->publicationReadiness ?? 'internal_only',
)->color)
->icon(fn (TenantReview $record): ?string => BadgeCatalog::spec(
BadgeDomain::GovernanceArtifactPublicationReadiness,
app(ArtifactTruthPresenter::class)->forTenantReview($record)->publicationReadiness ?? 'internal_only',
)->icon)
->iconColor(fn (TenantReview $record): ?string => BadgeCatalog::spec(
BadgeDomain::GovernanceArtifactPublicationReadiness,
app(ArtifactTruthPresenter::class)->forTenantReview($record)->publicationReadiness ?? 'internal_only',
)->iconColor),
TextColumn::make('artifact_next_step')
->label('Next step')
->getStateUsing(fn (TenantReview $record): string => app(ArtifactTruthPresenter::class)->forTenantReview($record)->operatorExplanation?->nextActionText ?? app(ArtifactTruthPresenter::class)->forTenantReview($record)->nextStepText())
->wrap(),
]) ])
->filters([ ->filters([
SelectFilter::make('tenant_id') SelectFilter::make('tenant_id')
@ -148,12 +174,7 @@ public function table(Table $table): Table
]), ]),
SelectFilter::make('completeness_state') SelectFilter::make('completeness_state')
->label('Completeness') ->label('Completeness')
->options([ ->options(BadgeCatalog::options(BadgeDomain::TenantReviewCompleteness, TenantReviewCompletenessState::values())),
'complete' => 'Complete',
'partial' => 'Partial',
'missing' => 'Missing',
'stale' => 'Stale',
]),
SelectFilter::make('published_state') SelectFilter::make('published_state')
->label('Published state') ->label('Published state')
->options([ ->options([

View File

@ -2882,9 +2882,12 @@ public function startVerification(): void
break; break;
} }
$reasonEnvelope = app(\App\Support\ReasonTranslation\ReasonPresenter::class)->forOperationRun($result->run, 'notification');
$bodyLines = $reasonEnvelope?->toBodyLines() ?? ['Blocked by provider configuration.'];
Notification::make() Notification::make()
->title('Verification blocked') ->title('Verification blocked')
->body("Blocked by provider configuration ({$reasonCode}).") ->body(implode("\n", $bodyLines))
->warning() ->warning()
->actions($actions) ->actions($actions)
->send(); ->send();

View File

@ -6,19 +6,28 @@
use App\Filament\Resources\BaselineProfileResource\Pages; use App\Filament\Resources\BaselineProfileResource\Pages;
use App\Models\BaselineProfile; use App\Models\BaselineProfile;
use App\Models\BaselineSnapshot;
use App\Models\BaselineTenantAssignment;
use App\Models\Tenant;
use App\Models\User; use App\Models\User;
use App\Models\Workspace; use App\Models\Workspace;
use App\Services\Audit\WorkspaceAuditLogger; use App\Services\Audit\WorkspaceAuditLogger;
use App\Services\Auth\CapabilityResolver;
use App\Services\Baselines\BaselineSnapshotTruthResolver;
use App\Support\Audit\AuditActionId; use App\Support\Audit\AuditActionId;
use App\Support\Auth\Capabilities; use App\Support\Auth\Capabilities;
use App\Support\Badges\BadgeCatalog;
use App\Support\Badges\BadgeDomain; use App\Support\Badges\BadgeDomain;
use App\Support\Badges\BadgeRenderer; use App\Support\Badges\BadgeRenderer;
use App\Support\Baselines\BaselineCaptureMode; use App\Support\Baselines\BaselineCaptureMode;
use App\Support\Baselines\BaselineFullContentRolloutGate; use App\Support\Baselines\BaselineFullContentRolloutGate;
use App\Support\Baselines\BaselineProfileStatus; use App\Support\Baselines\BaselineProfileStatus;
use App\Support\Baselines\BaselineReasonCodes;
use App\Support\Filament\FilterOptionCatalog; use App\Support\Filament\FilterOptionCatalog;
use App\Support\Inventory\InventoryPolicyTypeMeta; use App\Support\Inventory\InventoryPolicyTypeMeta;
use App\Support\Rbac\WorkspaceUiEnforcement; use App\Support\Rbac\WorkspaceUiEnforcement;
use App\Support\ReasonTranslation\ReasonPresenter;
use App\Support\ReasonTranslation\ReasonResolutionEnvelope;
use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration; use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration;
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceInspectAffordance; use App\Support\Ui\ActionSurface\Enums\ActionSurfaceInspectAffordance;
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile; use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile;
@ -288,15 +297,32 @@ public static function infolist(Schema $schema): Schema
->placeholder('None'), ->placeholder('None'),
]) ])
->columnSpanFull(), ->columnSpanFull(),
Section::make('Baseline truth')
->schema([
TextEntry::make('current_snapshot_truth')
->label('Current snapshot')
->state(fn (BaselineProfile $record): string => self::currentSnapshotLabel($record)),
TextEntry::make('latest_attempted_snapshot_truth')
->label('Latest attempt')
->state(fn (BaselineProfile $record): string => self::latestAttemptedSnapshotLabel($record)),
TextEntry::make('compare_readiness')
->label('Compare readiness')
->badge()
->state(fn (BaselineProfile $record): string => self::compareReadinessLabel($record))
->color(fn (BaselineProfile $record): string => self::compareReadinessColor($record))
->icon(fn (BaselineProfile $record): ?string => self::compareReadinessIcon($record)),
TextEntry::make('baseline_next_step')
->label('Next step')
->state(fn (BaselineProfile $record): string => self::profileNextStep($record))
->columnSpanFull(),
])
->columns(2)
->columnSpanFull(),
Section::make('Metadata') Section::make('Metadata')
->schema([ ->schema([
TextEntry::make('createdByUser.name') TextEntry::make('createdByUser.name')
->label('Created by') ->label('Created by')
->placeholder('—'), ->placeholder('—'),
TextEntry::make('activeSnapshot.captured_at')
->label('Last snapshot')
->dateTime()
->placeholder('No snapshot yet'),
TextEntry::make('created_at') TextEntry::make('created_at')
->dateTime(), ->dateTime(),
TextEntry::make('updated_at') TextEntry::make('updated_at')
@ -355,10 +381,27 @@ public static function table(Table $table): Table
TextColumn::make('tenant_assignments_count') TextColumn::make('tenant_assignments_count')
->label('Assigned tenants') ->label('Assigned tenants')
->counts('tenantAssignments'), ->counts('tenantAssignments'),
TextColumn::make('activeSnapshot.captured_at') TextColumn::make('current_snapshot_truth')
->label('Last snapshot') ->label('Current snapshot')
->dateTime() ->getStateUsing(fn (BaselineProfile $record): string => self::currentSnapshotLabel($record))
->placeholder('No snapshot'), ->description(fn (BaselineProfile $record): ?string => self::currentSnapshotDescription($record))
->wrap(),
TextColumn::make('latest_attempted_snapshot_truth')
->label('Latest attempt')
->getStateUsing(fn (BaselineProfile $record): string => self::latestAttemptedSnapshotLabel($record))
->description(fn (BaselineProfile $record): ?string => self::latestAttemptedSnapshotDescription($record))
->wrap(),
TextColumn::make('compare_readiness')
->label('Compare readiness')
->badge()
->getStateUsing(fn (BaselineProfile $record): string => self::compareReadinessLabel($record))
->color(fn (BaselineProfile $record): string => self::compareReadinessColor($record))
->icon(fn (BaselineProfile $record): ?string => self::compareReadinessIcon($record))
->wrap(),
TextColumn::make('baseline_next_step')
->label('Next step')
->getStateUsing(fn (BaselineProfile $record): string => self::profileNextStep($record))
->wrap(),
TextColumn::make('created_at') TextColumn::make('created_at')
->dateTime() ->dateTime()
->sortable() ->sortable()
@ -545,4 +588,167 @@ private static function archiveTableAction(?Workspace $workspace): Action
return $action; return $action;
} }
private static function currentSnapshotLabel(BaselineProfile $profile): string
{
$snapshot = self::effectiveSnapshot($profile);
if (! $snapshot instanceof BaselineSnapshot) {
return 'No complete snapshot';
}
return self::snapshotReference($snapshot);
}
private static function currentSnapshotDescription(BaselineProfile $profile): ?string
{
$snapshot = self::effectiveSnapshot($profile);
if (! $snapshot instanceof BaselineSnapshot) {
return self::compareAvailabilityEnvelope($profile)?->shortExplanation;
}
return $snapshot->captured_at?->toDayDateTimeString();
}
private static function latestAttemptedSnapshotLabel(BaselineProfile $profile): string
{
$latestAttempt = self::latestAttemptedSnapshot($profile);
if (! $latestAttempt instanceof BaselineSnapshot) {
return 'No capture attempts yet';
}
$effectiveSnapshot = self::effectiveSnapshot($profile);
if ($effectiveSnapshot instanceof BaselineSnapshot && (int) $effectiveSnapshot->getKey() === (int) $latestAttempt->getKey()) {
return 'Matches current snapshot';
}
return self::snapshotReference($latestAttempt);
}
private static function latestAttemptedSnapshotDescription(BaselineProfile $profile): ?string
{
$latestAttempt = self::latestAttemptedSnapshot($profile);
if (! $latestAttempt instanceof BaselineSnapshot) {
return null;
}
$effectiveSnapshot = self::effectiveSnapshot($profile);
if ($effectiveSnapshot instanceof BaselineSnapshot && (int) $effectiveSnapshot->getKey() === (int) $latestAttempt->getKey()) {
return 'No newer attempt is pending.';
}
return $latestAttempt->captured_at?->toDayDateTimeString();
}
private static function compareReadinessLabel(BaselineProfile $profile): string
{
return self::compareAvailabilityEnvelope($profile)?->operatorLabel ?? 'Ready';
}
private static function compareReadinessColor(BaselineProfile $profile): string
{
return match (self::compareAvailabilityReason($profile)) {
null => 'success',
BaselineReasonCodes::COMPARE_PROFILE_NOT_ACTIVE => 'gray',
default => 'warning',
};
}
private static function compareReadinessIcon(BaselineProfile $profile): ?string
{
return match (self::compareAvailabilityReason($profile)) {
null => 'heroicon-m-check-badge',
BaselineReasonCodes::COMPARE_PROFILE_NOT_ACTIVE => 'heroicon-m-pause-circle',
default => 'heroicon-m-exclamation-triangle',
};
}
private static function profileNextStep(BaselineProfile $profile): string
{
return self::compareAvailabilityEnvelope($profile)?->guidanceText() ?? 'No action needed.';
}
private static function effectiveSnapshot(BaselineProfile $profile): ?BaselineSnapshot
{
return app(BaselineSnapshotTruthResolver::class)->resolveEffectiveSnapshot($profile);
}
private static function latestAttemptedSnapshot(BaselineProfile $profile): ?BaselineSnapshot
{
return app(BaselineSnapshotTruthResolver::class)->resolveLatestAttemptedSnapshot($profile);
}
private static function compareAvailabilityReason(BaselineProfile $profile): ?string
{
$status = $profile->status instanceof BaselineProfileStatus
? $profile->status
: BaselineProfileStatus::tryFrom((string) $profile->status);
if ($status !== BaselineProfileStatus::Active) {
return BaselineReasonCodes::COMPARE_PROFILE_NOT_ACTIVE;
}
$resolution = app(BaselineSnapshotTruthResolver::class)->resolveCompareSnapshot($profile);
$reasonCode = $resolution['reason_code'] ?? null;
if (is_string($reasonCode) && trim($reasonCode) !== '') {
return trim($reasonCode);
}
if (! self::hasEligibleCompareTarget($profile)) {
return BaselineReasonCodes::COMPARE_NO_ELIGIBLE_TARGET;
}
return null;
}
private static function compareAvailabilityEnvelope(BaselineProfile $profile): ?ReasonResolutionEnvelope
{
$reasonCode = self::compareAvailabilityReason($profile);
if (! is_string($reasonCode)) {
return null;
}
return app(ReasonPresenter::class)->forArtifactTruth($reasonCode, 'artifact_truth');
}
private static function snapshotReference(BaselineSnapshot $snapshot): string
{
$lifecycleLabel = BadgeCatalog::spec(BadgeDomain::BaselineSnapshotLifecycle, $snapshot->lifecycleState()->value)->label;
return sprintf('Snapshot #%d (%s)', (int) $snapshot->getKey(), $lifecycleLabel);
}
private static function hasEligibleCompareTarget(BaselineProfile $profile): bool
{
$user = auth()->user();
if (! $user instanceof User) {
return false;
}
$tenantIds = BaselineTenantAssignment::query()
->where('workspace_id', (int) $profile->workspace_id)
->where('baseline_profile_id', (int) $profile->getKey())
->pluck('tenant_id')
->all();
if ($tenantIds === []) {
return false;
}
$resolver = app(CapabilityResolver::class);
return Tenant::query()
->where('workspace_id', (int) $profile->workspace_id)
->whereIn('id', $tenantIds)
->get(['id'])
->contains(fn (Tenant $tenant): bool => $resolver->can($user, $tenant, Capabilities::TENANT_SYNC));
}
} }

View File

@ -183,7 +183,7 @@ private function compareNowAction(): Action
$modalDescription = $captureMode === BaselineCaptureMode::FullContent $modalDescription = $captureMode === BaselineCaptureMode::FullContent
? 'Select the target tenant. This will refresh content evidence on demand (redacted) before comparing.' ? 'Select the target tenant. This will refresh content evidence on demand (redacted) before comparing.'
: 'Select the target tenant to compare its current inventory against the active baseline snapshot.'; : 'Select the target tenant to compare its current inventory against the effective current baseline snapshot.';
return Action::make('compareNow') return Action::make('compareNow')
->label($label) ->label($label)
@ -198,7 +198,7 @@ private function compareNowAction(): Action
->required() ->required()
->searchable(), ->searchable(),
]) ])
->disabled(fn (): bool => $this->getEligibleCompareTenantOptions() === []) ->disabled(fn (): bool => $this->getEligibleCompareTenantOptions() === [] || ! $this->profileHasConsumableSnapshot())
->action(function (array $data): void { ->action(function (array $data): void {
$user = auth()->user(); $user = auth()->user();
@ -256,7 +256,11 @@ private function compareNowAction(): Action
$message = match ($reasonCode) { $message = match ($reasonCode) {
BaselineReasonCodes::COMPARE_ROLLOUT_DISABLED => 'Full-content baseline compare is currently disabled for controlled rollout.', BaselineReasonCodes::COMPARE_ROLLOUT_DISABLED => 'Full-content baseline compare is currently disabled for controlled rollout.',
BaselineReasonCodes::COMPARE_PROFILE_NOT_ACTIVE => 'This baseline profile is not active.', BaselineReasonCodes::COMPARE_PROFILE_NOT_ACTIVE => 'This baseline profile is not active.',
BaselineReasonCodes::COMPARE_NO_ACTIVE_SNAPSHOT => 'This baseline profile has no active snapshot.', BaselineReasonCodes::COMPARE_NO_ACTIVE_SNAPSHOT,
BaselineReasonCodes::COMPARE_NO_CONSUMABLE_SNAPSHOT => 'This baseline profile has no complete snapshot available for compare yet.',
BaselineReasonCodes::COMPARE_SNAPSHOT_BUILDING => 'The latest baseline capture is still building. Compare will be available after it completes.',
BaselineReasonCodes::COMPARE_SNAPSHOT_INCOMPLETE => 'The latest baseline capture is incomplete. Capture a new baseline before comparing.',
BaselineReasonCodes::COMPARE_SNAPSHOT_SUPERSEDED => 'A newer complete snapshot is current. Compare uses the latest complete baseline only.',
default => 'Reason: '.str_replace('.', ' ', $reasonCode), default => 'Reason: '.str_replace('.', ' ', $reasonCode),
}; };
@ -395,4 +399,12 @@ private function hasManageCapability(): bool
return $resolver->isMember($user, $workspace) return $resolver->isMember($user, $workspace)
&& $resolver->can($user, $workspace, Capabilities::WORKSPACE_BASELINES_MANAGE); && $resolver->can($user, $workspace, Capabilities::WORKSPACE_BASELINES_MANAGE);
} }
private function profileHasConsumableSnapshot(): bool
{
/** @var BaselineProfile $profile */
$profile = $this->getRecord();
return $profile->resolveCurrentConsumableSnapshot() !== null;
}
} }

View File

@ -9,7 +9,12 @@
use App\Models\BaselineSnapshot; use App\Models\BaselineSnapshot;
use App\Models\User; use App\Models\User;
use App\Models\Workspace; use App\Models\Workspace;
use App\Services\Baselines\BaselineSnapshotTruthResolver;
use App\Services\Baselines\SnapshotRendering\FidelityState;
use App\Support\Auth\Capabilities; use App\Support\Auth\Capabilities;
use App\Support\Badges\BadgeCatalog;
use App\Support\Badges\BadgeDomain;
use App\Support\Baselines\BaselineSnapshotLifecycleState;
use App\Support\Filament\FilterPresets; use App\Support\Filament\FilterPresets;
use App\Support\Navigation\CrossResourceNavigationMatrix; use App\Support\Navigation\CrossResourceNavigationMatrix;
use App\Support\Navigation\RelatedContextEntry; use App\Support\Navigation\RelatedContextEntry;
@ -18,6 +23,8 @@
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceInspectAffordance; use App\Support\Ui\ActionSurface\Enums\ActionSurfaceInspectAffordance;
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile; use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile;
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceSlot; use App\Support\Ui\ActionSurface\Enums\ActionSurfaceSlot;
use App\Support\Ui\GovernanceArtifactTruth\ArtifactTruthEnvelope;
use App\Support\Ui\GovernanceArtifactTruth\ArtifactTruthPresenter;
use App\Support\Workspaces\WorkspaceContext; use App\Support\Workspaces\WorkspaceContext;
use BackedEnum; use BackedEnum;
use Filament\Actions\Action; use Filament\Actions\Action;
@ -165,15 +172,39 @@ public static function table(Table $table): Table
->label('Captured') ->label('Captured')
->since() ->since()
->sortable(), ->sortable(),
TextColumn::make('artifact_truth')
->label('Artifact truth')
->badge()
->getStateUsing(static fn (BaselineSnapshot $record): string => self::truthEnvelope($record)->primaryLabel)
->color(static fn (BaselineSnapshot $record): string => self::truthEnvelope($record)->primaryBadgeSpec()->color)
->icon(static fn (BaselineSnapshot $record): ?string => self::truthEnvelope($record)->primaryBadgeSpec()->icon)
->iconColor(static fn (BaselineSnapshot $record): ?string => self::truthEnvelope($record)->primaryBadgeSpec()->iconColor)
->description(static fn (BaselineSnapshot $record): ?string => self::truthEnvelope($record)->operatorExplanation?->headline ?? self::truthEnvelope($record)->primaryExplanation)
->wrap(),
TextColumn::make('lifecycle_state')
->label('Lifecycle')
->badge()
->getStateUsing(static fn (BaselineSnapshot $record): string => self::lifecycleSpec($record)->label)
->color(static fn (BaselineSnapshot $record): string => self::lifecycleSpec($record)->color)
->icon(static fn (BaselineSnapshot $record): ?string => self::lifecycleSpec($record)->icon)
->iconColor(static fn (BaselineSnapshot $record): ?string => self::lifecycleSpec($record)->iconColor)
->sortable(),
TextColumn::make('current_truth')
->label('Current truth')
->badge()
->getStateUsing(static fn (BaselineSnapshot $record): string => self::currentTruthLabel($record))
->color(static fn (BaselineSnapshot $record): string => self::currentTruthColor($record))
->icon(static fn (BaselineSnapshot $record): ?string => self::currentTruthIcon($record))
->description(static fn (BaselineSnapshot $record): ?string => self::currentTruthDescription($record))
->wrap(),
TextColumn::make('fidelity_summary') TextColumn::make('fidelity_summary')
->label('Fidelity') ->label('Fidelity')
->getStateUsing(static fn (BaselineSnapshot $record): string => self::fidelitySummary($record)) ->getStateUsing(static fn (BaselineSnapshot $record): string => self::fidelitySummary($record))
->wrap(), ->wrap(),
TextColumn::make('snapshot_state') TextColumn::make('artifact_next_step')
->label('State') ->label('Next step')
->badge() ->getStateUsing(static fn (BaselineSnapshot $record): string => self::truthEnvelope($record)->operatorExplanation?->nextActionText ?? self::truthEnvelope($record)->nextStepText())
->getStateUsing(static fn (BaselineSnapshot $record): string => self::stateLabel($record)) ->wrap(),
->color(static fn (BaselineSnapshot $record): string => self::hasGaps($record) ? 'warning' : 'success'),
]) ])
->recordUrl(static fn (BaselineSnapshot $record): ?string => static::canView($record) ->recordUrl(static fn (BaselineSnapshot $record): ?string => static::canView($record)
? static::getUrl('view', ['record' => $record]) ? static::getUrl('view', ['record' => $record])
@ -183,10 +214,10 @@ public static function table(Table $table): Table
->label('Baseline') ->label('Baseline')
->options(static::baselineProfileOptions()) ->options(static::baselineProfileOptions())
->searchable(), ->searchable(),
SelectFilter::make('snapshot_state') SelectFilter::make('lifecycle_state')
->label('State') ->label('Lifecycle')
->options(static::snapshotStateOptions()) ->options(static::lifecycleOptions())
->query(fn (Builder $query, array $data): Builder => static::applySnapshotStateFilter($query, $data['value'] ?? null)), ->query(fn (Builder $query, array $data): Builder => static::applyLifecycleFilter($query, $data['value'] ?? null)),
FilterPresets::dateRange('captured_at', 'Captured', 'captured_at'), FilterPresets::dateRange('captured_at', 'Captured', 'captured_at'),
]) ])
->actions([ ->actions([
@ -247,12 +278,9 @@ private static function baselineProfileOptions(): array
/** /**
* @return array<string, string> * @return array<string, string>
*/ */
private static function snapshotStateOptions(): array private static function lifecycleOptions(): array
{ {
return [ return BadgeCatalog::options(BadgeDomain::BaselineSnapshotLifecycle, BaselineSnapshotLifecycleState::values());
'complete' => 'Complete',
'with_gaps' => 'Captured with gaps',
];
} }
public static function resolveWorkspace(): ?Workspace public static function resolveWorkspace(): ?Workspace
@ -290,7 +318,13 @@ private static function fidelitySummary(BaselineSnapshot $snapshot): string
{ {
$counts = self::fidelityCounts($snapshot); $counts = self::fidelityCounts($snapshot);
return sprintf('Content %d, Meta %d', (int) ($counts['content'] ?? 0), (int) ($counts['meta'] ?? 0)); return sprintf(
'%s %d, %s %d',
BadgeCatalog::spec(BadgeDomain::BaselineSnapshotFidelity, FidelityState::Full->value)->label,
(int) ($counts['content'] ?? 0),
BadgeCatalog::spec(BadgeDomain::BaselineSnapshotFidelity, FidelityState::ReferenceOnly->value)->label,
(int) ($counts['meta'] ?? 0),
);
} }
private static function gapsCount(BaselineSnapshot $snapshot): int private static function gapsCount(BaselineSnapshot $snapshot): int
@ -298,6 +332,17 @@ private static function gapsCount(BaselineSnapshot $snapshot): int
$summary = self::summary($snapshot); $summary = self::summary($snapshot);
$gaps = $summary['gaps'] ?? null; $gaps = $summary['gaps'] ?? null;
$gaps = is_array($gaps) ? $gaps : []; $gaps = is_array($gaps) ? $gaps : [];
$byReason = is_array($gaps['by_reason'] ?? null) ? $gaps['by_reason'] : [];
if ($byReason !== []) {
return array_sum(array_map(
static fn (mixed $count, string $reason): int => in_array($reason, ['meta_fallback'], true) || ! is_numeric($count)
? 0
: (int) $count,
$byReason,
array_keys($byReason),
));
}
$count = $gaps['count'] ?? 0; $count = $gaps['count'] ?? 0;
@ -309,32 +354,86 @@ private static function hasGaps(BaselineSnapshot $snapshot): bool
return self::gapsCount($snapshot) > 0; return self::gapsCount($snapshot) > 0;
} }
private static function stateLabel(BaselineSnapshot $snapshot): string private static function lifecycleSpec(BaselineSnapshot $snapshot): \App\Support\Badges\BadgeSpec
{ {
return self::hasGaps($snapshot) ? 'Captured with gaps' : 'Complete'; return BadgeCatalog::spec(BadgeDomain::BaselineSnapshotLifecycle, $snapshot->lifecycleState()->value);
} }
private static function applySnapshotStateFilter(Builder $query, mixed $value): Builder private static function applyLifecycleFilter(Builder $query, mixed $value): Builder
{ {
if (! is_string($value) || trim($value) === '') { if (! is_string($value) || trim($value) === '') {
return $query; return $query;
} }
$gapCountExpression = self::gapCountExpression($query); return $query->where('lifecycle_state', trim($value));
return match ($value) {
'complete' => $query->whereRaw("{$gapCountExpression} = 0"),
'with_gaps' => $query->whereRaw("{$gapCountExpression} > 0"),
default => $query,
};
} }
private static function gapCountExpression(Builder $query): string private static function gapCountExpression(Builder $query): string
{ {
return match ($query->getConnection()->getDriverName()) { return match ($query->getConnection()->getDriverName()) {
'sqlite' => "COALESCE(CAST(json_extract(summary_jsonb, '$.gaps.count') AS INTEGER), 0)", 'sqlite' => "COALESCE(CAST(json_extract(summary_jsonb, '$.gaps.by_reason.missing_evidence') AS INTEGER), 0) + COALESCE(CAST(json_extract(summary_jsonb, '$.gaps.by_reason.missing_role_definition_version_reference') AS INTEGER), 0) + COALESCE(CAST(json_extract(summary_jsonb, '$.gaps.by_reason.invalid_subject') AS INTEGER), 0) + COALESCE(CAST(json_extract(summary_jsonb, '$.gaps.by_reason.duplicate_subject') AS INTEGER), 0) + COALESCE(CAST(json_extract(summary_jsonb, '$.gaps.by_reason.policy_not_found') AS INTEGER), 0) + COALESCE(CAST(json_extract(summary_jsonb, '$.gaps.by_reason.throttled') AS INTEGER), 0) + COALESCE(CAST(json_extract(summary_jsonb, '$.gaps.by_reason.capture_failed') AS INTEGER), 0) + COALESCE(CAST(json_extract(summary_jsonb, '$.gaps.by_reason.budget_exhausted') AS INTEGER), 0)",
'pgsql' => "COALESCE((summary_jsonb #>> '{gaps,count}')::int, 0)", 'pgsql' => "COALESCE((summary_jsonb #>> '{gaps,by_reason,missing_evidence}')::int, 0) + COALESCE((summary_jsonb #>> '{gaps,by_reason,missing_role_definition_version_reference}')::int, 0) + COALESCE((summary_jsonb #>> '{gaps,by_reason,invalid_subject}')::int, 0) + COALESCE((summary_jsonb #>> '{gaps,by_reason,duplicate_subject}')::int, 0) + COALESCE((summary_jsonb #>> '{gaps,by_reason,policy_not_found}')::int, 0) + COALESCE((summary_jsonb #>> '{gaps,by_reason,throttled}')::int, 0) + COALESCE((summary_jsonb #>> '{gaps,by_reason,capture_failed}')::int, 0) + COALESCE((summary_jsonb #>> '{gaps,by_reason,budget_exhausted}')::int, 0)",
default => "COALESCE(CAST(JSON_EXTRACT(summary_jsonb, '$.gaps.count') AS UNSIGNED), 0)", default => "COALESCE(CAST(JSON_EXTRACT(summary_jsonb, '$.gaps.by_reason.missing_evidence') AS UNSIGNED), 0) + COALESCE(CAST(JSON_EXTRACT(summary_jsonb, '$.gaps.by_reason.missing_role_definition_version_reference') AS UNSIGNED), 0) + COALESCE(CAST(JSON_EXTRACT(summary_jsonb, '$.gaps.by_reason.invalid_subject') AS UNSIGNED), 0) + COALESCE(CAST(JSON_EXTRACT(summary_jsonb, '$.gaps.by_reason.duplicate_subject') AS UNSIGNED), 0) + COALESCE(CAST(JSON_EXTRACT(summary_jsonb, '$.gaps.by_reason.policy_not_found') AS UNSIGNED), 0) + COALESCE(CAST(JSON_EXTRACT(summary_jsonb, '$.gaps.by_reason.throttled') AS UNSIGNED), 0) + COALESCE(CAST(JSON_EXTRACT(summary_jsonb, '$.gaps.by_reason.capture_failed') AS UNSIGNED), 0) + COALESCE(CAST(JSON_EXTRACT(summary_jsonb, '$.gaps.by_reason.budget_exhausted') AS UNSIGNED), 0)",
}; };
} }
private static function gapSpec(BaselineSnapshot $snapshot): \App\Support\Badges\BadgeSpec
{
return BadgeCatalog::spec(
BadgeDomain::BaselineSnapshotGapStatus,
self::hasGaps($snapshot) ? 'gaps_present' : 'clear',
);
}
private static function truthEnvelope(BaselineSnapshot $snapshot): ArtifactTruthEnvelope
{
return app(ArtifactTruthPresenter::class)->forBaselineSnapshot($snapshot);
}
private static function currentTruthLabel(BaselineSnapshot $snapshot): string
{
return match (self::currentTruthState($snapshot)) {
'current' => 'Current baseline',
'historical' => 'Historical trace',
default => 'Not compare input',
};
}
private static function currentTruthDescription(BaselineSnapshot $snapshot): ?string
{
return match (self::currentTruthState($snapshot)) {
'current' => 'Compare resolves to this snapshot as the current baseline truth.',
'historical' => 'A newer complete snapshot is now the current baseline truth for this profile.',
default => self::truthEnvelope($snapshot)->primaryExplanation,
};
}
private static function currentTruthColor(BaselineSnapshot $snapshot): string
{
return match (self::currentTruthState($snapshot)) {
'current' => 'success',
'historical' => 'gray',
default => 'warning',
};
}
private static function currentTruthIcon(BaselineSnapshot $snapshot): ?string
{
return match (self::currentTruthState($snapshot)) {
'current' => 'heroicon-m-check-badge',
'historical' => 'heroicon-m-clock',
default => 'heroicon-m-exclamation-triangle',
};
}
private static function currentTruthState(BaselineSnapshot $snapshot): string
{
if (! $snapshot->isConsumable()) {
return 'unusable';
}
return app(BaselineSnapshotTruthResolver::class)->isHistoricallySuperseded($snapshot)
? 'historical'
: 'current';
}
} }

View File

@ -35,6 +35,8 @@ public function mount(int|string $record): void
$snapshot = $this->getRecord(); $snapshot = $this->getRecord();
if ($snapshot instanceof BaselineSnapshot) { if ($snapshot instanceof BaselineSnapshot) {
$snapshot->loadMissing(['baselineProfile', 'items']);
$relatedContext = app(RelatedNavigationResolver::class) $relatedContext = app(RelatedNavigationResolver::class)
->detailEntries(CrossResourceNavigationMatrix::SOURCE_BASELINE_SNAPSHOT, $snapshot); ->detailEntries(CrossResourceNavigationMatrix::SOURCE_BASELINE_SNAPSHOT, $snapshot);

View File

@ -13,8 +13,10 @@
use App\Models\User; use App\Models\User;
use App\Services\Evidence\EvidenceSnapshotService; use App\Services\Evidence\EvidenceSnapshotService;
use App\Support\Auth\Capabilities; use App\Support\Auth\Capabilities;
use App\Support\Badges\BadgeCatalog;
use App\Support\Badges\BadgeDomain; use App\Support\Badges\BadgeDomain;
use App\Support\Badges\BadgeRenderer; use App\Support\Badges\BadgeRenderer;
use App\Support\Evidence\EvidenceCompletenessState;
use App\Support\Evidence\EvidenceSnapshotStatus; use App\Support\Evidence\EvidenceSnapshotStatus;
use App\Support\OperationCatalog; use App\Support\OperationCatalog;
use App\Support\OperationRunLinks; use App\Support\OperationRunLinks;
@ -26,6 +28,8 @@
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceInspectAffordance; use App\Support\Ui\ActionSurface\Enums\ActionSurfaceInspectAffordance;
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile; use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile;
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceSlot; use App\Support\Ui\ActionSurface\Enums\ActionSurfaceSlot;
use App\Support\Ui\GovernanceArtifactTruth\ArtifactTruthEnvelope;
use App\Support\Ui\GovernanceArtifactTruth\ArtifactTruthPresenter;
use BackedEnum; use BackedEnum;
use Filament\Actions; use Filament\Actions;
use Filament\Facades\Filament; use Filament\Facades\Filament;
@ -131,6 +135,15 @@ public static function form(Schema $schema): Schema
public static function infolist(Schema $schema): Schema public static function infolist(Schema $schema): Schema
{ {
return $schema->schema([ return $schema->schema([
Section::make('Artifact truth')
->schema([
ViewEntry::make('artifact_truth')
->hiddenLabel()
->view('filament.infolists.entries.governance-artifact-truth')
->state(fn (EvidenceSnapshot $record): array => static::truthEnvelope($record)->toArray())
->columnSpanFull(),
])
->columnSpanFull(),
Section::make('Snapshot') Section::make('Snapshot')
->schema([ ->schema([
TextEntry::make('status') TextEntry::make('status')
@ -163,8 +176,8 @@ public static function infolist(Schema $schema): Schema
TextEntry::make('summary.finding_count')->label('Findings')->placeholder('—'), TextEntry::make('summary.finding_count')->label('Findings')->placeholder('—'),
TextEntry::make('summary.report_count')->label('Reports')->placeholder('—'), TextEntry::make('summary.report_count')->label('Reports')->placeholder('—'),
TextEntry::make('summary.operation_count')->label('Operations')->placeholder('—'), TextEntry::make('summary.operation_count')->label('Operations')->placeholder('—'),
TextEntry::make('summary.missing_dimensions')->label('Missing dimensions')->placeholder('—'), TextEntry::make('summary.missing_dimensions')->label(static::evidenceCompletenessCountLabel(EvidenceCompletenessState::Missing->value))->placeholder('—'),
TextEntry::make('summary.stale_dimensions')->label('Stale dimensions')->placeholder('—'), TextEntry::make('summary.stale_dimensions')->label(static::evidenceCompletenessCountLabel(EvidenceCompletenessState::Stale->value))->placeholder('—'),
]) ])
->columns(2), ->columns(2),
Section::make('Evidence dimensions') Section::make('Evidence dimensions')
@ -212,6 +225,15 @@ public static function table(Table $table): Table
->icon(BadgeRenderer::icon(BadgeDomain::EvidenceSnapshotStatus)) ->icon(BadgeRenderer::icon(BadgeDomain::EvidenceSnapshotStatus))
->iconColor(BadgeRenderer::iconColor(BadgeDomain::EvidenceSnapshotStatus)) ->iconColor(BadgeRenderer::iconColor(BadgeDomain::EvidenceSnapshotStatus))
->sortable(), ->sortable(),
Tables\Columns\TextColumn::make('artifact_truth')
->label('Artifact truth')
->badge()
->getStateUsing(fn (EvidenceSnapshot $record): string => static::truthEnvelope($record)->primaryLabel)
->color(fn (EvidenceSnapshot $record): string => static::truthEnvelope($record)->primaryBadgeSpec()->color)
->icon(fn (EvidenceSnapshot $record): ?string => static::truthEnvelope($record)->primaryBadgeSpec()->icon)
->iconColor(fn (EvidenceSnapshot $record): ?string => static::truthEnvelope($record)->primaryBadgeSpec()->iconColor)
->description(fn (EvidenceSnapshot $record): ?string => static::truthEnvelope($record)->primaryExplanation)
->wrap(),
Tables\Columns\TextColumn::make('completeness_state') Tables\Columns\TextColumn::make('completeness_state')
->label('Completeness') ->label('Completeness')
->badge() ->badge()
@ -222,25 +244,17 @@ public static function table(Table $table): Table
->sortable(), ->sortable(),
Tables\Columns\TextColumn::make('generated_at')->dateTime()->sortable()->placeholder('—'), Tables\Columns\TextColumn::make('generated_at')->dateTime()->sortable()->placeholder('—'),
Tables\Columns\TextColumn::make('summary.finding_count')->label('Findings'), Tables\Columns\TextColumn::make('summary.finding_count')->label('Findings'),
Tables\Columns\TextColumn::make('summary.missing_dimensions')->label('Missing'), Tables\Columns\TextColumn::make('summary.missing_dimensions')->label(static::evidenceCompletenessCountLabel(EvidenceCompletenessState::Missing->value)),
Tables\Columns\TextColumn::make('artifact_next_step')
->label('Next step')
->getStateUsing(fn (EvidenceSnapshot $record): string => static::truthEnvelope($record)->nextStepText())
->wrap(),
]) ])
->filters([ ->filters([
Tables\Filters\SelectFilter::make('status') Tables\Filters\SelectFilter::make('status')
->options([ ->options(BadgeCatalog::options(BadgeDomain::EvidenceSnapshotStatus, EvidenceSnapshotStatus::values())),
'queued' => 'Queued',
'generating' => 'Generating',
'active' => 'Active',
'superseded' => 'Superseded',
'expired' => 'Expired',
'failed' => 'Failed',
]),
Tables\Filters\SelectFilter::make('completeness_state') Tables\Filters\SelectFilter::make('completeness_state')
->options([ ->options(BadgeCatalog::options(BadgeDomain::EvidenceCompleteness, EvidenceCompletenessState::values())),
'complete' => 'Complete',
'partial' => 'Partial',
'missing' => 'Missing',
'stale' => 'Stale',
]),
]) ])
->actions([ ->actions([
Actions\Action::make('view_snapshot') Actions\Action::make('view_snapshot')
@ -418,13 +432,16 @@ private static function operationsSummaryPresentation(array $payload): array
$failedCount = (int) ($payload['failed_count'] ?? 0); $failedCount = (int) ($payload['failed_count'] ?? 0);
$partialCount = (int) ($payload['partial_count'] ?? 0); $partialCount = (int) ($payload['partial_count'] ?? 0);
$entries = is_array($payload['entries'] ?? null) ? $payload['entries'] : []; $entries = is_array($payload['entries'] ?? null) ? $payload['entries'] : [];
$actionSummary = $failedCount === 0 && $partialCount === 0
? 'No action needed.'
: sprintf('%d execution failures, %d need follow-up.', $failedCount, $partialCount);
return [ return [
'summary' => sprintf('%d operations in the last 30 days, %d failed, %d partial.', $operationCount, $failedCount, $partialCount), 'summary' => sprintf('%d operations in the last 30 days. %s', $operationCount, $actionSummary),
'highlights' => [ 'highlights' => [
['label' => 'Operations', 'value' => (string) $operationCount], ['label' => 'Operations', 'value' => (string) $operationCount],
['label' => 'Failed operations', 'value' => (string) $failedCount], ['label' => 'Execution failures', 'value' => (string) $failedCount],
['label' => 'Partial operations', 'value' => (string) $partialCount], ['label' => 'Needs follow-up', 'value' => (string) $partialCount],
], ],
'items' => collect($entries) 'items' => collect($entries)
->map(fn (mixed $entry): ?string => is_array($entry) ? static::operationEntryLabel($entry) : null) ->map(fn (mixed $entry): ?string => is_array($entry) ? static::operationEntryLabel($entry) : null)
@ -564,20 +581,42 @@ private static function operationEntryStateLabel(array $entry): ?string
$outcome = is_string($entry['outcome'] ?? null) ? trim((string) $entry['outcome']) : null; $outcome = is_string($entry['outcome'] ?? null) ? trim((string) $entry['outcome']) : null;
return match ($status) { return match ($status) {
OperationRunStatus::Queued->value => 'Queued', OperationRunStatus::Queued->value => static::badgeLabel(BadgeDomain::OperationRunStatus, $status),
OperationRunStatus::Running->value => 'Running', OperationRunStatus::Running->value => static::badgeLabel(BadgeDomain::OperationRunStatus, $status),
OperationRunStatus::Completed->value => match ($outcome) { OperationRunStatus::Completed->value => match ($outcome) {
OperationRunOutcome::Succeeded->value => 'Completed', OperationRunOutcome::Pending->value => static::badgeLabel(BadgeDomain::OperationRunStatus, $status),
OperationRunOutcome::PartiallySucceeded->value => OperationRunOutcome::uiLabels(true)[OperationRunOutcome::PartiallySucceeded->value], OperationRunOutcome::Succeeded->value,
OperationRunOutcome::Blocked->value => OperationRunOutcome::uiLabels(true)[OperationRunOutcome::Blocked->value], OperationRunOutcome::PartiallySucceeded->value,
OperationRunOutcome::Failed->value => OperationRunOutcome::uiLabels(true)[OperationRunOutcome::Failed->value], OperationRunOutcome::Blocked->value,
OperationRunOutcome::Cancelled->value => OperationRunOutcome::uiLabels(true)[OperationRunOutcome::Cancelled->value], OperationRunOutcome::Failed->value,
default => 'Completed', OperationRunOutcome::Cancelled->value => static::badgeLabel(BadgeDomain::OperationRunOutcome, $outcome),
default => static::badgeLabel(BadgeDomain::OperationRunStatus, $status),
}, },
default => $outcome !== null ? (OperationRunOutcome::uiLabels(true)[$outcome] ?? null) : null, default => $outcome !== null ? static::badgeLabel(BadgeDomain::OperationRunOutcome, $outcome) : null,
}; };
} }
private static function evidenceCompletenessCountLabel(string $state): string
{
return BadgeCatalog::spec(BadgeDomain::EvidenceCompleteness, $state)->label;
}
private static function badgeLabel(BadgeDomain $domain, ?string $state): ?string
{
if ($state === null || trim($state) === '') {
return null;
}
$label = BadgeCatalog::spec($domain, $state)->label;
return $label === 'Unknown' ? null : $label;
}
private static function truthEnvelope(EvidenceSnapshot $record): ArtifactTruthEnvelope
{
return app(ArtifactTruthPresenter::class)->forEvidenceSnapshot($record);
}
private static function stringifySummaryValue(mixed $value): string private static function stringifySummaryValue(mixed $value): string
{ {
return match (true) { return match (true) {

View File

@ -8,6 +8,7 @@
use App\Models\Tenant; use App\Models\Tenant;
use App\Models\User; use App\Models\User;
use App\Models\VerificationCheckAcknowledgement; use App\Models\VerificationCheckAcknowledgement;
use App\Support\Badges\BadgeCatalog;
use App\Support\Badges\BadgeDomain; use App\Support\Badges\BadgeDomain;
use App\Support\Badges\BadgeRenderer; use App\Support\Badges\BadgeRenderer;
use App\Support\Baselines\BaselineCompareReasonCode; use App\Support\Baselines\BaselineCompareReasonCode;
@ -21,13 +22,17 @@
use App\Support\OperationRunLinks; use App\Support\OperationRunLinks;
use App\Support\OperationRunOutcome; use App\Support\OperationRunOutcome;
use App\Support\OperationRunStatus; use App\Support\OperationRunStatus;
use App\Support\OpsUx\OperationUxPresenter;
use App\Support\OpsUx\RunDurationInsights; use App\Support\OpsUx\RunDurationInsights;
use App\Support\OpsUx\SummaryCountsNormalizer;
use App\Support\ReasonTranslation\ReasonPresenter;
use App\Support\Tenants\ReferencedTenantLifecyclePresentation; use App\Support\Tenants\ReferencedTenantLifecyclePresentation;
use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration; use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration;
use App\Support\Ui\ActionSurface\ActionSurfaceDefaults; use App\Support\Ui\ActionSurface\ActionSurfaceDefaults;
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceInspectAffordance; use App\Support\Ui\ActionSurface\Enums\ActionSurfaceInspectAffordance;
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile; use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile;
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceSlot; use App\Support\Ui\ActionSurface\Enums\ActionSurfaceSlot;
use App\Support\Ui\GovernanceArtifactTruth\ArtifactTruthPresenter;
use App\Support\Workspaces\WorkspaceContext; use App\Support\Workspaces\WorkspaceContext;
use BackedEnum; use BackedEnum;
use Filament\Actions; use Filament\Actions;
@ -123,10 +128,11 @@ public static function table(Table $table): Table
->columns([ ->columns([
Tables\Columns\TextColumn::make('status') Tables\Columns\TextColumn::make('status')
->badge() ->badge()
->formatStateUsing(BadgeRenderer::label(BadgeDomain::OperationRunStatus)) ->formatStateUsing(fn (mixed $state, OperationRun $record): string => BadgeRenderer::spec(BadgeDomain::OperationRunStatus, static::statusBadgeState($record))->label)
->color(BadgeRenderer::color(BadgeDomain::OperationRunStatus)) ->color(fn (mixed $state, OperationRun $record): string => BadgeRenderer::spec(BadgeDomain::OperationRunStatus, static::statusBadgeState($record))->color)
->icon(BadgeRenderer::icon(BadgeDomain::OperationRunStatus)) ->icon(fn (mixed $state, OperationRun $record): ?string => BadgeRenderer::spec(BadgeDomain::OperationRunStatus, static::statusBadgeState($record))->icon)
->iconColor(BadgeRenderer::iconColor(BadgeDomain::OperationRunStatus)), ->iconColor(fn (mixed $state, OperationRun $record): ?string => BadgeRenderer::spec(BadgeDomain::OperationRunStatus, static::statusBadgeState($record))->iconColor)
->description(fn (OperationRun $record): ?string => OperationUxPresenter::lifecycleAttentionSummary($record)),
Tables\Columns\TextColumn::make('type') Tables\Columns\TextColumn::make('type')
->label('Operation') ->label('Operation')
->formatStateUsing(fn (?string $state): string => OperationCatalog::label((string) $state)) ->formatStateUsing(fn (?string $state): string => OperationCatalog::label((string) $state))
@ -149,10 +155,11 @@ public static function table(Table $table): Table
}), }),
Tables\Columns\TextColumn::make('outcome') Tables\Columns\TextColumn::make('outcome')
->badge() ->badge()
->formatStateUsing(BadgeRenderer::label(BadgeDomain::OperationRunOutcome)) ->formatStateUsing(fn (mixed $state, OperationRun $record): string => BadgeRenderer::spec(BadgeDomain::OperationRunOutcome, static::outcomeBadgeState($record))->label)
->color(BadgeRenderer::color(BadgeDomain::OperationRunOutcome)) ->color(fn (mixed $state, OperationRun $record): string => BadgeRenderer::spec(BadgeDomain::OperationRunOutcome, static::outcomeBadgeState($record))->color)
->icon(BadgeRenderer::icon(BadgeDomain::OperationRunOutcome)) ->icon(fn (mixed $state, OperationRun $record): ?string => BadgeRenderer::spec(BadgeDomain::OperationRunOutcome, static::outcomeBadgeState($record))->icon)
->iconColor(BadgeRenderer::iconColor(BadgeDomain::OperationRunOutcome)), ->iconColor(fn (mixed $state, OperationRun $record): ?string => BadgeRenderer::spec(BadgeDomain::OperationRunOutcome, static::outcomeBadgeState($record))->iconColor)
->description(fn (OperationRun $record): ?string => OperationUxPresenter::surfaceGuidance($record)),
]) ])
->filters([ ->filters([
Tables\Filters\SelectFilter::make('tenant_id') Tables\Filters\SelectFilter::make('tenant_id')
@ -205,13 +212,9 @@ public static function table(Table $table): Table
return FilterOptionCatalog::operationTypes(array_keys($types)); return FilterOptionCatalog::operationTypes(array_keys($types));
}), }),
Tables\Filters\SelectFilter::make('status') Tables\Filters\SelectFilter::make('status')
->options([ ->options(BadgeCatalog::options(BadgeDomain::OperationRunStatus, OperationRunStatus::values())),
OperationRunStatus::Queued->value => 'Queued',
OperationRunStatus::Running->value => 'Running',
OperationRunStatus::Completed->value => 'Completed',
]),
Tables\Filters\SelectFilter::make('outcome') Tables\Filters\SelectFilter::make('outcome')
->options(OperationRunOutcome::uiLabels(includeReserved: false)), ->options(BadgeCatalog::options(BadgeDomain::OperationRunOutcome, OperationRunOutcome::values(includeReserved: false))),
Tables\Filters\SelectFilter::make('initiator_name') Tables\Filters\SelectFilter::make('initiator_name')
->label('Initiator') ->label('Initiator')
->options(function (): array { ->options(function (): array {
@ -251,13 +254,25 @@ private static function enterpriseDetailPage(OperationRun $record): \App\Support
{ {
$factory = new \App\Support\Ui\EnterpriseDetail\EnterpriseDetailSectionFactory; $factory = new \App\Support\Ui\EnterpriseDetail\EnterpriseDetailSectionFactory;
$statusSpec = BadgeRenderer::spec(BadgeDomain::OperationRunStatus, $record->status); $statusSpec = BadgeRenderer::spec(BadgeDomain::OperationRunStatus, static::statusBadgeState($record));
$outcomeSpec = BadgeRenderer::spec(BadgeDomain::OperationRunOutcome, $record->outcome); $outcomeSpec = BadgeRenderer::spec(BadgeDomain::OperationRunOutcome, static::outcomeBadgeState($record));
$targetScope = static::targetScopeDisplay($record); $targetScope = static::targetScopeDisplay($record);
$summaryLine = \App\Support\OpsUx\SummaryCountsNormalizer::renderSummaryLine(is_array($record->summary_counts) ? $record->summary_counts : []); $summaryLine = \App\Support\OpsUx\SummaryCountsNormalizer::renderSummaryLine(is_array($record->summary_counts) ? $record->summary_counts : []);
$referencedTenantLifecycle = $record->tenant instanceof Tenant $referencedTenantLifecycle = $record->tenant instanceof Tenant
? ReferencedTenantLifecyclePresentation::forOperationRun($record->tenant) ? ReferencedTenantLifecyclePresentation::forOperationRun($record->tenant)
: null; : null;
$artifactTruth = $record->supportsOperatorExplanation()
? app(ArtifactTruthPresenter::class)->forOperationRun($record)
: null;
$operatorExplanation = $artifactTruth?->operatorExplanation;
$artifactTruthBadge = $artifactTruth !== null
? $factory->statusBadge(
$artifactTruth->primaryBadgeSpec()->label,
$artifactTruth->primaryBadgeSpec()->color,
$artifactTruth->primaryBadgeSpec()->icon,
$artifactTruth->primaryBadgeSpec()->iconColor,
)
: null;
$builder = \App\Support\Ui\EnterpriseDetail\EnterpriseDetailBuilder::make('operation_run', 'workspace-context') $builder = \App\Support\Ui\EnterpriseDetail\EnterpriseDetailBuilder::make('operation_run', 'workspace-context')
->header(new \App\Support\Ui\EnterpriseDetail\SummaryHeaderData( ->header(new \App\Support\Ui\EnterpriseDetail\SummaryHeaderData(
@ -287,6 +302,15 @@ private static function enterpriseDetailPage(OperationRun $record): \App\Support
$factory->keyFact('Expected duration', RunDurationInsights::expectedHuman($record)), $factory->keyFact('Expected duration', RunDurationInsights::expectedHuman($record)),
], ],
), ),
$factory->viewSection(
id: 'artifact_truth',
kind: 'current_status',
title: 'Artifact truth',
view: 'filament.infolists.entries.governance-artifact-truth',
viewData: ['artifactTruthState' => $artifactTruth?->toArray()],
visible: $artifactTruth !== null,
description: 'Run lifecycle stays separate from whether the intended governance artifact was actually produced and usable.',
),
$factory->viewSection( $factory->viewSection(
id: 'related_context', id: 'related_context',
kind: 'related_context', kind: 'related_context',
@ -304,6 +328,15 @@ private static function enterpriseDetailPage(OperationRun $record): \App\Support
items: array_values(array_filter([ items: array_values(array_filter([
$factory->keyFact('Status', $statusSpec->label, badge: $factory->statusBadge($statusSpec->label, $statusSpec->color, $statusSpec->icon, $statusSpec->iconColor)), $factory->keyFact('Status', $statusSpec->label, badge: $factory->statusBadge($statusSpec->label, $statusSpec->color, $statusSpec->icon, $statusSpec->iconColor)),
$factory->keyFact('Outcome', $outcomeSpec->label, badge: $factory->statusBadge($outcomeSpec->label, $outcomeSpec->color, $outcomeSpec->icon, $outcomeSpec->iconColor)), $factory->keyFact('Outcome', $outcomeSpec->label, badge: $factory->statusBadge($outcomeSpec->label, $outcomeSpec->color, $outcomeSpec->icon, $outcomeSpec->iconColor)),
$artifactTruth !== null
? $factory->keyFact('Artifact truth', $artifactTruth->primaryLabel, badge: $artifactTruthBadge)
: null,
$operatorExplanation !== null
? $factory->keyFact('Result meaning', $operatorExplanation->evaluationResultLabel())
: null,
$operatorExplanation !== null
? $factory->keyFact('Result trust', $operatorExplanation->trustworthinessLabel())
: null,
$referencedTenantLifecycle !== null $referencedTenantLifecycle !== null
? $factory->keyFact( ? $factory->keyFact(
'Tenant lifecycle', 'Tenant lifecycle',
@ -322,6 +355,29 @@ private static function enterpriseDetailPage(OperationRun $record): \App\Support
$referencedTenantLifecycle?->contextNote !== null $referencedTenantLifecycle?->contextNote !== null
? $factory->keyFact('Viewer context', $referencedTenantLifecycle->contextNote) ? $factory->keyFact('Viewer context', $referencedTenantLifecycle->contextNote)
: null, : null,
static::freshnessLabel($record) !== null
? $factory->keyFact('Freshness', (string) static::freshnessLabel($record))
: null,
static::reconciliationHeadline($record) !== null
? $factory->keyFact('Lifecycle truth', (string) static::reconciliationHeadline($record))
: null,
static::reconciledAtLabel($record) !== null
? $factory->keyFact('Reconciled at', (string) static::reconciledAtLabel($record))
: null,
static::reconciliationSourceLabel($record) !== null
? $factory->keyFact('Reconciled by', (string) static::reconciliationSourceLabel($record))
: null,
$operatorExplanation !== null
? $factory->keyFact('Artifact next step', $operatorExplanation->nextActionText)
: ($artifactTruth !== null
? $factory->keyFact('Artifact next step', $artifactTruth->nextStepText())
: null),
$operatorExplanation !== null && $operatorExplanation->coverageStatement !== null
? $factory->keyFact('Coverage', $operatorExplanation->coverageStatement)
: null,
OperationUxPresenter::surfaceGuidance($record) !== null
? $factory->keyFact('Next step', OperationUxPresenter::surfaceGuidance($record))
: null,
$summaryLine !== null ? $factory->keyFact('Counts', $summaryLine) : null, $summaryLine !== null ? $factory->keyFact('Counts', $summaryLine) : null,
static::blockedExecutionReasonCode($record) !== null static::blockedExecutionReasonCode($record) !== null
? $factory->keyFact('Blocked reason', static::blockedExecutionReasonCode($record)) ? $factory->keyFact('Blocked reason', static::blockedExecutionReasonCode($record))
@ -385,6 +441,19 @@ private static function enterpriseDetailPage(OperationRun $record): \App\Support
); );
} }
if (static::reconciliationPayload($record) !== []) {
$builder->addSection(
$factory->viewSection(
id: 'reconciliation',
kind: 'operational_context',
title: 'Lifecycle reconciliation',
view: 'filament.infolists.entries.snapshot-json',
viewData: ['payload' => static::reconciliationPayload($record)],
description: 'Lifecycle reconciliation is diagnostic evidence showing when TenantPilot force-resolved the run.',
),
);
}
if ((string) $record->type === 'baseline_compare') { if ((string) $record->type === 'baseline_compare') {
$baselineCompareFacts = static::baselineCompareFacts($record, $factory); $baselineCompareFacts = static::baselineCompareFacts($record, $factory);
$baselineCompareEvidence = static::baselineCompareEvidencePayload($record); $baselineCompareEvidence = static::baselineCompareEvidencePayload($record);
@ -454,7 +523,7 @@ private static function summaryCountFacts(
$counts = \App\Support\OpsUx\SummaryCountsNormalizer::normalize(is_array($record->summary_counts) ? $record->summary_counts : []); $counts = \App\Support\OpsUx\SummaryCountsNormalizer::normalize(is_array($record->summary_counts) ? $record->summary_counts : []);
return array_map( return array_map(
static fn (string $key, int $value): array => $factory->keyFact(ucfirst(str_replace('_', ' ', $key)), $value), static fn (string $key, int $value): array => $factory->keyFact(SummaryCountsNormalizer::label($key), $value),
array_keys($counts), array_keys($counts),
array_values($counts), array_values($counts),
); );
@ -466,8 +535,13 @@ private static function blockedExecutionReasonCode(OperationRun $record): ?strin
return null; return null;
} }
$context = is_array($record->context) ? $record->context : []; $reasonEnvelope = app(ReasonPresenter::class)->forOperationRun($record, 'run_detail');
if ($reasonEnvelope !== null) {
return $reasonEnvelope->operatorLabel;
}
$context = is_array($record->context) ? $record->context : [];
$reasonCode = data_get($context, 'execution_legitimacy.reason_code') $reasonCode = data_get($context, 'execution_legitimacy.reason_code')
?? data_get($context, 'reason_code') ?? data_get($context, 'reason_code')
?? data_get($record->failure_summary, '0.reason_code'); ?? data_get($record->failure_summary, '0.reason_code');
@ -481,6 +555,12 @@ private static function blockedExecutionDetail(OperationRun $record): ?string
return null; return null;
} }
$reasonEnvelope = app(ReasonPresenter::class)->forOperationRun($record, 'run_detail');
if ($reasonEnvelope !== null) {
return $reasonEnvelope->shortExplanation;
}
$message = data_get($record->failure_summary, '0.message'); $message = data_get($record->failure_summary, '0.message');
return is_string($message) && trim($message) !== '' ? trim($message) : 'Execution was refused before work began.'; return is_string($message) && trim($message) !== '' ? trim($message) : 'Execution was refused before work began.';
@ -684,6 +764,82 @@ private static function contextPayload(OperationRun $record): array
return $context; return $context;
} }
/**
* @return array{status:string,freshness_state:string}
*/
private static function statusBadgeState(OperationRun $record): array
{
return [
'status' => (string) $record->status,
'freshness_state' => $record->freshnessState()->value,
];
}
/**
* @return array{outcome:string,status:string,freshness_state:string}
*/
private static function outcomeBadgeState(OperationRun $record): array
{
return [
'outcome' => (string) $record->outcome,
'status' => (string) $record->status,
'freshness_state' => $record->freshnessState()->value,
];
}
private static function freshnessLabel(OperationRun $record): ?string
{
return match ($record->freshnessState()->value) {
'fresh_active' => 'Fresh activity',
'likely_stale' => 'Likely stale',
'reconciled_failed' => 'Automatically reconciled',
'terminal_normal' => 'Terminal truth confirmed',
default => null,
};
}
private static function reconciliationHeadline(OperationRun $record): ?string
{
if (! $record->isLifecycleReconciled()) {
return null;
}
return 'TenantPilot force-resolved this run after normal lifecycle truth was lost.';
}
private static function reconciledAtLabel(OperationRun $record): ?string
{
$reconciledAt = data_get($record->reconciliation(), 'reconciled_at');
return is_string($reconciledAt) && trim($reconciledAt) !== '' ? trim($reconciledAt) : null;
}
private static function reconciliationSourceLabel(OperationRun $record): ?string
{
$source = data_get($record->reconciliation(), 'source');
if (! is_string($source) || trim($source) === '') {
return null;
}
return match (trim($source)) {
'failed_callback' => 'Direct failed() bridge',
'scheduled_reconciler' => 'Scheduled reconciler',
'adapter_reconciler' => 'Adapter reconciler',
default => ucfirst(str_replace('_', ' ', trim($source))),
};
}
/**
* @return array<string, mixed>
*/
private static function reconciliationPayload(OperationRun $record): array
{
$reconciliation = $record->reconciliation();
return $reconciliation;
}
private static function formatDetailTimestamp(mixed $value): string private static function formatDetailTimestamp(mixed $value): string
{ {
if (! $value instanceof \Illuminate\Support\Carbon) { if (! $value instanceof \Illuminate\Support\Carbon) {

View File

@ -824,9 +824,12 @@ public static function table(Table $table): Table
? (string) $result->run->context['reason_code'] ? (string) $result->run->context['reason_code']
: 'unknown_error'; : 'unknown_error';
$reasonEnvelope = app(\App\Support\ReasonTranslation\ReasonPresenter::class)->forOperationRun($result->run, 'notification');
$bodyLines = $reasonEnvelope?->toBodyLines() ?? ['Blocked by provider configuration.'];
Notification::make() Notification::make()
->title('Connection check blocked') ->title('Connection check blocked')
->body("Blocked by provider configuration ({$reasonCode}).") ->body(implode("\n", $bodyLines))
->warning() ->warning()
->actions([ ->actions([
Actions\Action::make('view_run') Actions\Action::make('view_run')
@ -921,9 +924,12 @@ public static function table(Table $table): Table
? (string) $result->run->context['reason_code'] ? (string) $result->run->context['reason_code']
: 'unknown_error'; : 'unknown_error';
$reasonEnvelope = app(\App\Support\ReasonTranslation\ReasonPresenter::class)->forOperationRun($result->run, 'notification');
$bodyLines = $reasonEnvelope?->toBodyLines() ?? ['Blocked by provider configuration.'];
Notification::make() Notification::make()
->title('Inventory sync blocked') ->title('Inventory sync blocked')
->body("Blocked by provider configuration ({$reasonCode}).") ->body(implode("\n", $bodyLines))
->warning() ->warning()
->actions([ ->actions([
Actions\Action::make('view_run') Actions\Action::make('view_run')
@ -1015,9 +1021,12 @@ public static function table(Table $table): Table
? (string) $result->run->context['reason_code'] ? (string) $result->run->context['reason_code']
: 'unknown_error'; : 'unknown_error';
$reasonEnvelope = app(\App\Support\ReasonTranslation\ReasonPresenter::class)->forOperationRun($result->run, 'notification');
$bodyLines = $reasonEnvelope?->toBodyLines() ?? ['Blocked by provider configuration.'];
Notification::make() Notification::make()
->title('Compliance snapshot blocked') ->title('Compliance snapshot blocked')
->body("Blocked by provider configuration ({$reasonCode}).") ->body(implode("\n", $bodyLines))
->warning() ->warning()
->actions([ ->actions([
Actions\Action::make('view_run') Actions\Action::make('view_run')

View File

@ -278,9 +278,12 @@ protected function getHeaderActions(): array
? (string) $result->run->context['reason_code'] ? (string) $result->run->context['reason_code']
: 'unknown_error'; : 'unknown_error';
$reasonEnvelope = app(\App\Support\ReasonTranslation\ReasonPresenter::class)->forOperationRun($result->run, 'notification');
$bodyLines = $reasonEnvelope?->toBodyLines() ?? ['Blocked by provider configuration.'];
Notification::make() Notification::make()
->title('Connection check blocked') ->title('Connection check blocked')
->body("Blocked by provider configuration ({$reasonCode}).") ->body(implode("\n", $bodyLines))
->warning() ->warning()
->actions([ ->actions([
Action::make('view_run') Action::make('view_run')
@ -647,9 +650,12 @@ protected function getHeaderActions(): array
? (string) $result->run->context['reason_code'] ? (string) $result->run->context['reason_code']
: 'unknown_error'; : 'unknown_error';
$reasonEnvelope = app(\App\Support\ReasonTranslation\ReasonPresenter::class)->forOperationRun($result->run, 'notification');
$bodyLines = $reasonEnvelope?->toBodyLines() ?? ['Blocked by provider configuration.'];
Notification::make() Notification::make()
->title('Inventory sync blocked') ->title('Inventory sync blocked')
->body("Blocked by provider configuration ({$reasonCode}).") ->body(implode("\n", $bodyLines))
->warning() ->warning()
->actions([ ->actions([
Action::make('view_run') Action::make('view_run')
@ -758,9 +764,12 @@ protected function getHeaderActions(): array
? (string) $result->run->context['reason_code'] ? (string) $result->run->context['reason_code']
: 'unknown_error'; : 'unknown_error';
$reasonEnvelope = app(\App\Support\ReasonTranslation\ReasonPresenter::class)->forOperationRun($result->run, 'notification');
$bodyLines = $reasonEnvelope?->toBodyLines() ?? ['Blocked by provider configuration.'];
Notification::make() Notification::make()
->title('Compliance snapshot blocked') ->title('Compliance snapshot blocked')
->body("Blocked by provider configuration ({$reasonCode}).") ->body(implode("\n", $bodyLines))
->warning() ->warning()
->actions([ ->actions([
Action::make('view_run') Action::make('view_run')

View File

@ -824,10 +824,10 @@ public static function table(Table $table): Table
->label('Total') ->label('Total')
->state(fn (RestoreRun $record): int => (int) (($record->metadata ?? [])['total'] ?? 0)), ->state(fn (RestoreRun $record): int => (int) (($record->metadata ?? [])['total'] ?? 0)),
Tables\Columns\TextColumn::make('summary_succeeded') Tables\Columns\TextColumn::make('summary_succeeded')
->label('Succeeded') ->label('Applied')
->state(fn (RestoreRun $record): int => (int) (($record->metadata ?? [])['succeeded'] ?? 0)), ->state(fn (RestoreRun $record): int => (int) (($record->metadata ?? [])['succeeded'] ?? 0)),
Tables\Columns\TextColumn::make('summary_failed') Tables\Columns\TextColumn::make('summary_failed')
->label('Failed') ->label('Failed items')
->state(fn (RestoreRun $record): int => (int) (($record->metadata ?? [])['failed'] ?? 0)), ->state(fn (RestoreRun $record): int => (int) (($record->metadata ?? [])['failed'] ?? 0)),
Tables\Columns\TextColumn::make('started_at')->dateTime()->since()->sortable(), Tables\Columns\TextColumn::make('started_at')->dateTime()->since()->sortable(),
Tables\Columns\TextColumn::make('completed_at')->dateTime()->since()->sortable(), Tables\Columns\TextColumn::make('completed_at')->dateTime()->since()->sortable(),
@ -1261,7 +1261,7 @@ public static function infolist(Schema $schema): Schema
$succeeded = (int) ($meta['succeeded'] ?? 0); $succeeded = (int) ($meta['succeeded'] ?? 0);
$failed = (int) ($meta['failed'] ?? 0); $failed = (int) ($meta['failed'] ?? 0);
return sprintf('Total: %d • Succeeded: %d • Failed: %d', $total, $succeeded, $failed); return sprintf('Total: %d • Applied: %d • Failed items: %d', $total, $succeeded, $failed);
}), }),
Infolists\Components\TextEntry::make('is_dry_run') Infolists\Components\TextEntry::make('is_dry_run')
->label('Dry-run') ->label('Dry-run')

View File

@ -10,6 +10,7 @@
use App\Models\User; use App\Models\User;
use App\Services\ReviewPackService; use App\Services\ReviewPackService;
use App\Support\Auth\Capabilities; use App\Support\Auth\Capabilities;
use App\Support\Badges\BadgeCatalog;
use App\Support\Badges\BadgeDomain; use App\Support\Badges\BadgeDomain;
use App\Support\Badges\BadgeRenderer; use App\Support\Badges\BadgeRenderer;
use App\Support\OpsUx\OperationUxPresenter; use App\Support\OpsUx\OperationUxPresenter;
@ -19,11 +20,14 @@
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceInspectAffordance; use App\Support\Ui\ActionSurface\Enums\ActionSurfaceInspectAffordance;
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile; use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile;
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceSlot; use App\Support\Ui\ActionSurface\Enums\ActionSurfaceSlot;
use App\Support\Ui\GovernanceArtifactTruth\ArtifactTruthEnvelope;
use App\Support\Ui\GovernanceArtifactTruth\ArtifactTruthPresenter;
use BackedEnum; use BackedEnum;
use Filament\Actions; use Filament\Actions;
use Filament\Facades\Filament; use Filament\Facades\Filament;
use Filament\Forms\Components\Toggle; use Filament\Forms\Components\Toggle;
use Filament\Infolists\Components\TextEntry; use Filament\Infolists\Components\TextEntry;
use Filament\Infolists\Components\ViewEntry;
use Filament\Notifications\Notification; use Filament\Notifications\Notification;
use Filament\Resources\Resource; use Filament\Resources\Resource;
use Filament\Schemas\Components\Section; use Filament\Schemas\Components\Section;
@ -111,6 +115,15 @@ public static function infolist(Schema $schema): Schema
{ {
return $schema return $schema
->schema([ ->schema([
Section::make('Artifact truth')
->schema([
ViewEntry::make('artifact_truth')
->hiddenLabel()
->view('filament.infolists.entries.governance-artifact-truth')
->state(fn (ReviewPack $record): array => static::truthEnvelope($record)->toArray())
->columnSpanFull(),
])
->columnSpanFull(),
Section::make('Status') Section::make('Status')
->schema([ ->schema([
TextEntry::make('status') TextEntry::make('status')
@ -238,6 +251,15 @@ public static function table(Table $table): Table
->icon(BadgeRenderer::icon(BadgeDomain::ReviewPackStatus)) ->icon(BadgeRenderer::icon(BadgeDomain::ReviewPackStatus))
->iconColor(BadgeRenderer::iconColor(BadgeDomain::ReviewPackStatus)) ->iconColor(BadgeRenderer::iconColor(BadgeDomain::ReviewPackStatus))
->sortable(), ->sortable(),
Tables\Columns\TextColumn::make('artifact_truth')
->label('Artifact truth')
->badge()
->getStateUsing(fn (ReviewPack $record): string => static::truthEnvelope($record)->primaryLabel)
->color(fn (ReviewPack $record): string => static::truthEnvelope($record)->primaryBadgeSpec()->color)
->icon(fn (ReviewPack $record): ?string => static::truthEnvelope($record)->primaryBadgeSpec()->icon)
->iconColor(fn (ReviewPack $record): ?string => static::truthEnvelope($record)->primaryBadgeSpec()->iconColor)
->description(fn (ReviewPack $record): ?string => static::truthEnvelope($record)->primaryExplanation)
->wrap(),
Tables\Columns\TextColumn::make('tenant.name') Tables\Columns\TextColumn::make('tenant.name')
->label('Tenant') ->label('Tenant')
->searchable(), ->searchable(),
@ -257,6 +279,29 @@ public static function table(Table $table): Table
->label('Size') ->label('Size')
->formatStateUsing(fn ($state): string => $state ? Number::fileSize((int) $state) : '—') ->formatStateUsing(fn ($state): string => $state ? Number::fileSize((int) $state) : '—')
->sortable(), ->sortable(),
Tables\Columns\TextColumn::make('publication_truth')
->label('Publication')
->badge()
->getStateUsing(fn (ReviewPack $record): string => BadgeCatalog::spec(
BadgeDomain::GovernanceArtifactPublicationReadiness,
static::truthEnvelope($record)->publicationReadiness ?? 'internal_only',
)->label)
->color(fn (ReviewPack $record): string => BadgeCatalog::spec(
BadgeDomain::GovernanceArtifactPublicationReadiness,
static::truthEnvelope($record)->publicationReadiness ?? 'internal_only',
)->color)
->icon(fn (ReviewPack $record): ?string => BadgeCatalog::spec(
BadgeDomain::GovernanceArtifactPublicationReadiness,
static::truthEnvelope($record)->publicationReadiness ?? 'internal_only',
)->icon)
->iconColor(fn (ReviewPack $record): ?string => BadgeCatalog::spec(
BadgeDomain::GovernanceArtifactPublicationReadiness,
static::truthEnvelope($record)->publicationReadiness ?? 'internal_only',
)->iconColor),
Tables\Columns\TextColumn::make('artifact_next_step')
->label('Next step')
->getStateUsing(fn (ReviewPack $record): string => static::truthEnvelope($record)->nextStepText())
->wrap(),
Tables\Columns\TextColumn::make('created_at') Tables\Columns\TextColumn::make('created_at')
->label('Created') ->label('Created')
->since() ->since()
@ -352,6 +397,11 @@ public static function getPages(): array
]; ];
} }
private static function truthEnvelope(ReviewPack $record): ArtifactTruthEnvelope
{
return app(ArtifactTruthPresenter::class)->forReviewPack($record);
}
/** /**
* @param array<string, mixed> $data * @param array<string, mixed> $data
*/ */

View File

@ -608,9 +608,12 @@ public static function table(Table $table): Table
break; break;
} }
$reasonEnvelope = app(\App\Support\ReasonTranslation\ReasonPresenter::class)->forOperationRun($result->run, 'notification');
$bodyLines = $reasonEnvelope?->toBodyLines() ?? ['Blocked by provider configuration.'];
Notification::make() Notification::make()
->title('Verification blocked') ->title('Verification blocked')
->body("Blocked by provider configuration ({$reasonCode}).") ->body(implode("\n", $bodyLines))
->warning() ->warning()
->actions($actions) ->actions($actions)
->send(); ->send();
@ -908,8 +911,20 @@ public static function infolist(Schema $schema): Schema
->visible(fn (Tenant $record): bool => filled($record->rbac_status)), ->visible(fn (Tenant $record): bool => filled($record->rbac_status)),
Section::make('RBAC Details') Section::make('RBAC Details')
->schema([ ->schema([
Infolists\Components\TextEntry::make('rbac_status_reason_label')
->label('Reason')
->state(fn (Tenant $record): ?string => app(\App\Support\ReasonTranslation\ReasonPresenter::class)
->primaryLabel(app(\App\Support\ReasonTranslation\ReasonPresenter::class)->forRbacReason($record->rbac_status_reason, 'detail')))
->visible(fn (?string $state): bool => filled($state)),
Infolists\Components\TextEntry::make('rbac_status_reason_explanation')
->label('Explanation')
->state(fn (Tenant $record): ?string => app(\App\Support\ReasonTranslation\ReasonPresenter::class)
->shortExplanation(app(\App\Support\ReasonTranslation\ReasonPresenter::class)->forRbacReason($record->rbac_status_reason, 'detail')))
->visible(fn (?string $state): bool => filled($state))
->columnSpanFull(),
Infolists\Components\TextEntry::make('rbac_status_reason') Infolists\Components\TextEntry::make('rbac_status_reason')
->label('Reason'), ->label('Diagnostic code')
->copyable(),
Infolists\Components\TextEntry::make('rbac_role_definition_id') Infolists\Components\TextEntry::make('rbac_role_definition_id')
->label('Role definition ID') ->label('Role definition ID')
->copyable(), ->copyable(),

View File

@ -178,9 +178,12 @@ protected function getHeaderActions(): array
break; break;
} }
$reasonEnvelope = app(\App\Support\ReasonTranslation\ReasonPresenter::class)->forOperationRun($result->run, 'notification');
$bodyLines = $reasonEnvelope?->toBodyLines() ?? ['Blocked by provider configuration.'];
Notification::make() Notification::make()
->title('Verification blocked') ->title('Verification blocked')
->body("Blocked by provider configuration ({$reasonCode}).") ->body(implode("\n", $bodyLines))
->warning() ->warning()
->actions($actions) ->actions($actions)
->send(); ->send();

View File

@ -15,17 +15,21 @@
use App\Services\ReviewPackService; use App\Services\ReviewPackService;
use App\Services\TenantReviews\TenantReviewService; use App\Services\TenantReviews\TenantReviewService;
use App\Support\Auth\Capabilities; use App\Support\Auth\Capabilities;
use App\Support\Badges\BadgeCatalog;
use App\Support\Badges\BadgeDomain; use App\Support\Badges\BadgeDomain;
use App\Support\Badges\BadgeRenderer; use App\Support\Badges\BadgeRenderer;
use App\Support\OperationRunLinks; use App\Support\OperationRunLinks;
use App\Support\OperationRunType; use App\Support\OperationRunType;
use App\Support\OpsUx\OperationUxPresenter; use App\Support\OpsUx\OperationUxPresenter;
use App\Support\Rbac\UiEnforcement; use App\Support\Rbac\UiEnforcement;
use App\Support\TenantReviewCompletenessState;
use App\Support\TenantReviewStatus; use App\Support\TenantReviewStatus;
use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration; use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration;
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceInspectAffordance; use App\Support\Ui\ActionSurface\Enums\ActionSurfaceInspectAffordance;
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile; use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile;
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceSlot; use App\Support\Ui\ActionSurface\Enums\ActionSurfaceSlot;
use App\Support\Ui\GovernanceArtifactTruth\ArtifactTruthEnvelope;
use App\Support\Ui\GovernanceArtifactTruth\ArtifactTruthPresenter;
use BackedEnum; use BackedEnum;
use Filament\Actions; use Filament\Actions;
use Filament\Facades\Filament; use Filament\Facades\Filament;
@ -141,6 +145,15 @@ public static function form(Schema $schema): Schema
public static function infolist(Schema $schema): Schema public static function infolist(Schema $schema): Schema
{ {
return $schema->schema([ return $schema->schema([
Section::make('Artifact truth')
->schema([
ViewEntry::make('artifact_truth')
->hiddenLabel()
->view('filament.infolists.entries.governance-artifact-truth')
->state(fn (TenantReview $record): array => static::truthEnvelope($record)->toArray())
->columnSpanFull(),
])
->columnSpanFull(),
Section::make('Review') Section::make('Review')
->schema([ ->schema([
TextEntry::make('status') TextEntry::make('status')
@ -237,6 +250,15 @@ public static function table(Table $table): Table
->icon(BadgeRenderer::icon(BadgeDomain::TenantReviewStatus)) ->icon(BadgeRenderer::icon(BadgeDomain::TenantReviewStatus))
->iconColor(BadgeRenderer::iconColor(BadgeDomain::TenantReviewStatus)) ->iconColor(BadgeRenderer::iconColor(BadgeDomain::TenantReviewStatus))
->sortable(), ->sortable(),
Tables\Columns\TextColumn::make('artifact_truth')
->label('Artifact truth')
->badge()
->getStateUsing(fn (TenantReview $record): string => static::truthEnvelope($record)->primaryLabel)
->color(fn (TenantReview $record): string => static::truthEnvelope($record)->primaryBadgeSpec()->color)
->icon(fn (TenantReview $record): ?string => static::truthEnvelope($record)->primaryBadgeSpec()->icon)
->iconColor(fn (TenantReview $record): ?string => static::truthEnvelope($record)->primaryBadgeSpec()->iconColor)
->description(fn (TenantReview $record): ?string => static::truthEnvelope($record)->operatorExplanation?->headline ?? static::truthEnvelope($record)->primaryExplanation)
->wrap(),
Tables\Columns\TextColumn::make('completeness_state') Tables\Columns\TextColumn::make('completeness_state')
->label('Completeness') ->label('Completeness')
->badge() ->badge()
@ -248,10 +270,33 @@ public static function table(Table $table): Table
Tables\Columns\TextColumn::make('generated_at')->dateTime()->placeholder('—')->sortable(), Tables\Columns\TextColumn::make('generated_at')->dateTime()->placeholder('—')->sortable(),
Tables\Columns\TextColumn::make('published_at')->dateTime()->placeholder('—')->sortable(), Tables\Columns\TextColumn::make('published_at')->dateTime()->placeholder('—')->sortable(),
Tables\Columns\TextColumn::make('summary.finding_count')->label('Findings'), Tables\Columns\TextColumn::make('summary.finding_count')->label('Findings'),
Tables\Columns\TextColumn::make('summary.section_state_counts.missing')->label('Missing'), Tables\Columns\TextColumn::make('summary.section_state_counts.missing')->label(static::reviewCompletenessCountLabel(TenantReviewCompletenessState::Missing->value)),
Tables\Columns\TextColumn::make('publication_truth')
->label('Publication')
->badge()
->getStateUsing(fn (TenantReview $record): string => BadgeCatalog::spec(
BadgeDomain::GovernanceArtifactPublicationReadiness,
static::truthEnvelope($record)->publicationReadiness ?? 'internal_only',
)->label)
->color(fn (TenantReview $record): string => BadgeCatalog::spec(
BadgeDomain::GovernanceArtifactPublicationReadiness,
static::truthEnvelope($record)->publicationReadiness ?? 'internal_only',
)->color)
->icon(fn (TenantReview $record): ?string => BadgeCatalog::spec(
BadgeDomain::GovernanceArtifactPublicationReadiness,
static::truthEnvelope($record)->publicationReadiness ?? 'internal_only',
)->icon)
->iconColor(fn (TenantReview $record): ?string => BadgeCatalog::spec(
BadgeDomain::GovernanceArtifactPublicationReadiness,
static::truthEnvelope($record)->publicationReadiness ?? 'internal_only',
)->iconColor),
Tables\Columns\IconColumn::make('summary.has_ready_export') Tables\Columns\IconColumn::make('summary.has_ready_export')
->label('Export') ->label('Export')
->boolean(), ->boolean(),
Tables\Columns\TextColumn::make('artifact_next_step')
->label('Next step')
->getStateUsing(fn (TenantReview $record): string => static::truthEnvelope($record)->operatorExplanation?->nextActionText ?? static::truthEnvelope($record)->nextStepText())
->wrap(),
Tables\Columns\TextColumn::make('fingerprint') Tables\Columns\TextColumn::make('fingerprint')
->toggleable(isToggledHiddenByDefault: true) ->toggleable(isToggledHiddenByDefault: true)
->searchable(), ->searchable(),
@ -262,12 +307,7 @@ public static function table(Table $table): Table
->mapWithKeys(fn (TenantReviewStatus $status): array => [$status->value => Str::headline($status->value)]) ->mapWithKeys(fn (TenantReviewStatus $status): array => [$status->value => Str::headline($status->value)])
->all()), ->all()),
Tables\Filters\SelectFilter::make('completeness_state') Tables\Filters\SelectFilter::make('completeness_state')
->options([ ->options(BadgeCatalog::options(BadgeDomain::TenantReviewCompleteness, TenantReviewCompletenessState::values())),
'complete' => 'Complete',
'partial' => 'Partial',
'missing' => 'Missing',
'stale' => 'Stale',
]),
\App\Support\Filament\FilterPresets::dateRange('review_date', 'Review date', 'generated_at'), \App\Support\Filament\FilterPresets::dateRange('review_date', 'Review date', 'generated_at'),
]) ])
->actions([ ->actions([
@ -503,13 +543,18 @@ private static function evidenceSnapshotOptions(): array
(string) $snapshot->getKey() => sprintf( (string) $snapshot->getKey() => sprintf(
'#%d · %s · %s', '#%d · %s · %s',
(int) $snapshot->getKey(), (int) $snapshot->getKey(),
Str::headline((string) $snapshot->completeness_state), BadgeCatalog::spec(BadgeDomain::EvidenceCompleteness, $snapshot->completeness_state)->label,
$snapshot->generated_at?->format('Y-m-d H:i') ?? 'Pending' $snapshot->generated_at?->format('Y-m-d H:i') ?? 'Pending'
), ),
]) ])
->all(); ->all();
} }
private static function reviewCompletenessCountLabel(string $state): string
{
return BadgeCatalog::spec(BadgeDomain::TenantReviewCompleteness, $state)->label;
}
/** /**
* @return array<string, mixed> * @return array<string, mixed>
*/ */
@ -518,6 +563,7 @@ private static function summaryPresentation(TenantReview $record): array
$summary = is_array($record->summary) ? $record->summary : []; $summary = is_array($record->summary) ? $record->summary : [];
return [ return [
'operator_explanation' => static::truthEnvelope($record)->operatorExplanation?->toArray(),
'highlights' => is_array($summary['highlights'] ?? null) ? $summary['highlights'] : [], 'highlights' => is_array($summary['highlights'] ?? null) ? $summary['highlights'] : [],
'next_actions' => is_array($summary['recommended_next_actions'] ?? null) ? $summary['recommended_next_actions'] : [], 'next_actions' => is_array($summary['recommended_next_actions'] ?? null) ? $summary['recommended_next_actions'] : [],
'publish_blockers' => is_array($summary['publish_blockers'] ?? null) ? $summary['publish_blockers'] : [], 'publish_blockers' => is_array($summary['publish_blockers'] ?? null) ? $summary['publish_blockers'] : [],
@ -559,4 +605,9 @@ private static function sectionPresentation(TenantReviewSection $section): array
'links' => [], 'links' => [],
]; ];
} }
private static function truthEnvelope(TenantReview $record): ArtifactTruthEnvelope
{
return app(ArtifactTruthPresenter::class)->forTenantReview($record);
}
} }

View File

@ -22,6 +22,8 @@ protected function getViewData(): array
$empty = [ $empty = [
'hasAssignment' => false, 'hasAssignment' => false,
'state' => 'no_assignment',
'message' => null,
'profileName' => null, 'profileName' => null,
'findingsCount' => 0, 'findingsCount' => 0,
'highCount' => 0, 'highCount' => 0,
@ -43,6 +45,8 @@ protected function getViewData(): array
return [ return [
'hasAssignment' => true, 'hasAssignment' => true,
'state' => $stats->state,
'message' => $stats->message,
'profileName' => $stats->profileName, 'profileName' => $stats->profileName,
'findingsCount' => $stats->findingsCount ?? 0, 'findingsCount' => $stats->findingsCount ?? 0,
'highCount' => $stats->severityCounts['high'] ?? 0, 'highCount' => $stats->severityCounts['high'] ?? 0,

View File

@ -11,6 +11,7 @@
use App\Support\OperationCatalog; use App\Support\OperationCatalog;
use App\Support\OperationRunLinks; use App\Support\OperationRunLinks;
use App\Support\OpsUx\ActiveRuns; use App\Support\OpsUx\ActiveRuns;
use App\Support\OpsUx\OperationUxPresenter;
use Filament\Facades\Filament; use Filament\Facades\Filament;
use Filament\Tables\Columns\TextColumn; use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Table; use Filament\Tables\Table;
@ -57,7 +58,8 @@ public function table(Table $table): Table
->formatStateUsing(BadgeRenderer::label(BadgeDomain::OperationRunOutcome)) ->formatStateUsing(BadgeRenderer::label(BadgeDomain::OperationRunOutcome))
->color(BadgeRenderer::color(BadgeDomain::OperationRunOutcome)) ->color(BadgeRenderer::color(BadgeDomain::OperationRunOutcome))
->icon(BadgeRenderer::icon(BadgeDomain::OperationRunOutcome)) ->icon(BadgeRenderer::icon(BadgeDomain::OperationRunOutcome))
->iconColor(BadgeRenderer::iconColor(BadgeDomain::OperationRunOutcome)), ->iconColor(BadgeRenderer::iconColor(BadgeDomain::OperationRunOutcome))
->description(fn (OperationRun $record): ?string => OperationUxPresenter::surfaceGuidance($record)),
TextColumn::make('created_at') TextColumn::make('created_at')
->label('Started') ->label('Started')
->sortable() ->sortable()

View File

@ -44,8 +44,10 @@ protected function getViewData(): array
} }
return [ return [
'shouldShow' => $hasWarnings && $runUrl !== null, 'shouldShow' => ($hasWarnings && $runUrl !== null) || $stats->state === 'no_snapshot',
'runUrl' => $runUrl, 'runUrl' => $runUrl,
'state' => $stats->state,
'message' => $stats->message,
'coverageStatus' => $coverageStatus, 'coverageStatus' => $coverageStatus,
'fidelity' => $stats->fidelity, 'fidelity' => $stats->fidelity,
'uncoveredTypesCount' => $stats->uncoveredTypesCount ?? count($uncoveredTypes), 'uncoveredTypesCount' => $stats->uncoveredTypesCount ?? count($uncoveredTypes),

View File

@ -134,9 +134,12 @@ public function startVerification(StartVerification $verification): void
break; break;
} }
$reasonEnvelope = app(\App\Support\ReasonTranslation\ReasonPresenter::class)->forOperationRun($result->run, 'notification');
$bodyLines = $reasonEnvelope?->toBodyLines() ?? ['Blocked by provider configuration.'];
Notification::make() Notification::make()
->title('Verification blocked') ->title('Verification blocked')
->body("Blocked by provider configuration ({$reasonCode}).") ->body(implode("\n", $bodyLines))
->warning() ->warning()
->actions($actions) ->actions($actions)
->send(); ->send();

View File

@ -23,6 +23,7 @@ class WorkspaceRecentOperations extends Widget
* status_color: string, * status_color: string,
* outcome_label: string, * outcome_label: string,
* outcome_color: string, * outcome_color: string,
* guidance: ?string,
* started_at: string, * started_at: string,
* url: string * url: string
* }> * }>
@ -48,6 +49,7 @@ class WorkspaceRecentOperations extends Widget
* status_color: string, * status_color: string,
* outcome_label: string, * outcome_label: string,
* outcome_color: string, * outcome_color: string,
* guidance: ?string,
* started_at: string, * started_at: string,
* url: string * url: string
* }> $operations * }> $operations

View File

@ -2,6 +2,7 @@
namespace App\Jobs; namespace App\Jobs;
use App\Jobs\Concerns\BridgesFailedOperationRun;
use App\Jobs\Operations\BackupSetRestoreWorkerJob; use App\Jobs\Operations\BackupSetRestoreWorkerJob;
use App\Models\OperationRun; use App\Models\OperationRun;
use App\Services\OperationRunService; use App\Services\OperationRunService;
@ -11,11 +12,18 @@
use Illuminate\Queue\InteractsWithQueue; use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels; use Illuminate\Queue\SerializesModels;
use RuntimeException; use RuntimeException;
use Throwable;
class BulkBackupSetRestoreJob implements ShouldQueue class BulkBackupSetRestoreJob implements ShouldQueue
{ {
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels; use BridgesFailedOperationRun;
use Dispatchable;
use InteractsWithQueue;
use Queueable;
use SerializesModels;
public int $timeout = 300;
public bool $failOnTimeout = true;
public int $bulkRunId = 0; public int $bulkRunId = 0;
@ -68,32 +76,6 @@ public function handle(OperationRunService $runs): void
} }
} }
public function failed(Throwable $e): void
{
$run = $this->operationRun;
if (! $run instanceof OperationRun && $this->bulkRunId > 0) {
$run = OperationRun::query()->find($this->bulkRunId);
}
if (! $run instanceof OperationRun) {
return;
}
/** @var OperationRunService $runs */
$runs = app(OperationRunService::class);
$runs->updateRun(
$run,
status: 'completed',
outcome: 'failed',
failures: [[
'code' => 'bulk_job.failed',
'message' => $e->getMessage(),
]],
);
}
private function resolveOperationRun(): OperationRun private function resolveOperationRun(): OperationRun
{ {
if ($this->operationRun instanceof OperationRun) { if ($this->operationRun instanceof OperationRun) {

View File

@ -2,6 +2,7 @@
namespace App\Jobs; namespace App\Jobs;
use App\Jobs\Concerns\BridgesFailedOperationRun;
use App\Jobs\Middleware\EnsureQueuedExecutionLegitimate; use App\Jobs\Middleware\EnsureQueuedExecutionLegitimate;
use App\Jobs\Operations\TenantSyncWorkerJob; use App\Jobs\Operations\TenantSyncWorkerJob;
use App\Models\OperationRun; use App\Models\OperationRun;
@ -15,7 +16,15 @@
class BulkTenantSyncJob implements ShouldQueue class BulkTenantSyncJob implements ShouldQueue
{ {
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels; use BridgesFailedOperationRun;
use Dispatchable;
use InteractsWithQueue;
use Queueable;
use SerializesModels;
public int $timeout = 180;
public bool $failOnTimeout = true;
public ?OperationRun $operationRun = null; public ?OperationRun $operationRun = null;

View File

@ -2,6 +2,7 @@
namespace App\Jobs; namespace App\Jobs;
use App\Jobs\Concerns\BridgesFailedOperationRun;
use App\Jobs\Middleware\TrackOperationRun; use App\Jobs\Middleware\TrackOperationRun;
use App\Models\BaselineProfile; use App\Models\BaselineProfile;
use App\Models\BaselineSnapshot; use App\Models\BaselineSnapshot;
@ -12,6 +13,7 @@
use App\Models\User; use App\Models\User;
use App\Services\Baselines\BaselineContentCapturePhase; use App\Services\Baselines\BaselineContentCapturePhase;
use App\Services\Baselines\BaselineSnapshotIdentity; use App\Services\Baselines\BaselineSnapshotIdentity;
use App\Services\Baselines\BaselineSnapshotItemNormalizer;
use App\Services\Baselines\CurrentStateHashResolver; use App\Services\Baselines\CurrentStateHashResolver;
use App\Services\Baselines\Evidence\ResolvedEvidence; use App\Services\Baselines\Evidence\ResolvedEvidence;
use App\Services\Baselines\InventoryMetaContract; use App\Services\Baselines\InventoryMetaContract;
@ -20,7 +22,9 @@
use App\Support\Baselines\BaselineCaptureMode; use App\Support\Baselines\BaselineCaptureMode;
use App\Support\Baselines\BaselineFullContentRolloutGate; use App\Support\Baselines\BaselineFullContentRolloutGate;
use App\Support\Baselines\BaselineProfileStatus; use App\Support\Baselines\BaselineProfileStatus;
use App\Support\Baselines\BaselineReasonCodes;
use App\Support\Baselines\BaselineScope; use App\Support\Baselines\BaselineScope;
use App\Support\Baselines\BaselineSnapshotLifecycleState;
use App\Support\Baselines\PolicyVersionCapturePurpose; use App\Support\Baselines\PolicyVersionCapturePurpose;
use App\Support\Inventory\InventoryPolicyTypeMeta; use App\Support\Inventory\InventoryPolicyTypeMeta;
use App\Support\OperationRunOutcome; use App\Support\OperationRunOutcome;
@ -32,10 +36,19 @@
use Illuminate\Queue\InteractsWithQueue; use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels; use Illuminate\Queue\SerializesModels;
use RuntimeException; use RuntimeException;
use Throwable;
class CaptureBaselineSnapshotJob implements ShouldQueue class CaptureBaselineSnapshotJob implements ShouldQueue
{ {
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels; use BridgesFailedOperationRun;
use Dispatchable;
use InteractsWithQueue;
use Queueable;
use SerializesModels;
public int $timeout = 300;
public bool $failOnTimeout = true;
public ?OperationRun $operationRun = null; public ?OperationRun $operationRun = null;
@ -60,10 +73,12 @@ public function handle(
OperationRunService $operationRunService, OperationRunService $operationRunService,
?CurrentStateHashResolver $hashResolver = null, ?CurrentStateHashResolver $hashResolver = null,
?BaselineContentCapturePhase $contentCapturePhase = null, ?BaselineContentCapturePhase $contentCapturePhase = null,
?BaselineSnapshotItemNormalizer $snapshotItemNormalizer = null,
?BaselineFullContentRolloutGate $rolloutGate = null, ?BaselineFullContentRolloutGate $rolloutGate = null,
): void { ): void {
$hashResolver ??= app(CurrentStateHashResolver::class); $hashResolver ??= app(CurrentStateHashResolver::class);
$contentCapturePhase ??= app(BaselineContentCapturePhase::class); $contentCapturePhase ??= app(BaselineContentCapturePhase::class);
$snapshotItemNormalizer ??= app(BaselineSnapshotItemNormalizer::class);
$rolloutGate ??= app(BaselineFullContentRolloutGate::class); $rolloutGate ??= app(BaselineFullContentRolloutGate::class);
if (! $this->operationRun instanceof OperationRun) { if (! $this->operationRun instanceof OperationRun) {
@ -183,7 +198,12 @@ public function handle(
gaps: $captureGaps, gaps: $captureGaps,
); );
$items = $snapshotItems['items'] ?? []; $normalizedItems = $snapshotItemNormalizer->deduplicate($snapshotItems['items'] ?? []);
$items = $normalizedItems['items'];
if (($normalizedItems['duplicates'] ?? 0) > 0) {
$captureGaps['duplicate_subject_reference'] = ($captureGaps['duplicate_subject_reference'] ?? 0) + (int) $normalizedItems['duplicates'];
}
$identityHash = $identity->computeIdentity($items); $identityHash = $identity->computeIdentity($items);
@ -200,16 +220,17 @@ public function handle(
], ],
]; ];
$snapshot = $this->findOrCreateSnapshot( $snapshotResult = $this->captureSnapshotArtifact(
$profile, $profile,
$identityHash, $identityHash,
$items, $items,
$snapshotSummary, $snapshotSummary,
); );
$wasNewSnapshot = $snapshot->wasRecentlyCreated; $snapshot = $snapshotResult['snapshot'];
$wasNewSnapshot = $snapshotResult['was_new_snapshot'];
if ($profile->status === BaselineProfileStatus::Active) { if ($profile->status === BaselineProfileStatus::Active && $snapshot->isConsumable()) {
$profile->update(['active_snapshot_id' => $snapshot->getKey()]); $profile->update(['active_snapshot_id' => $snapshot->getKey()]);
} }
@ -250,6 +271,7 @@ public function handle(
'snapshot_identity_hash' => $identityHash, 'snapshot_identity_hash' => $identityHash,
'was_new_snapshot' => $wasNewSnapshot, 'was_new_snapshot' => $wasNewSnapshot,
'items_captured' => $snapshotItems['items_count'], 'items_captured' => $snapshotItems['items_count'],
'snapshot_lifecycle' => $snapshot->lifecycleState()->value,
]; ];
$this->operationRun->update(['context' => $updatedContext]); $this->operationRun->update(['context' => $updatedContext]);
@ -500,29 +522,151 @@ private function buildSnapshotItems(
]; ];
} }
private function findOrCreateSnapshot( /**
* @param array<int, array<string, mixed>> $snapshotItems
* @param array<string, mixed> $summaryJsonb
* @return array{snapshot: BaselineSnapshot, was_new_snapshot: bool}
*/
private function captureSnapshotArtifact(
BaselineProfile $profile, BaselineProfile $profile,
string $identityHash, string $identityHash,
array $snapshotItems, array $snapshotItems,
array $summaryJsonb, array $summaryJsonb,
): BaselineSnapshot { ): array {
$existing = $this->findExistingConsumableSnapshot($profile, $identityHash);
if ($existing instanceof BaselineSnapshot) {
$this->rememberSnapshotOnRun(
snapshot: $existing,
identityHash: $identityHash,
wasNewSnapshot: false,
expectedItems: count($snapshotItems),
persistedItems: count($snapshotItems),
);
return [
'snapshot' => $existing,
'was_new_snapshot' => false,
];
}
$expectedItems = count($snapshotItems);
$snapshot = $this->createBuildingSnapshot($profile, $identityHash, $summaryJsonb, $expectedItems);
$this->rememberSnapshotOnRun(
snapshot: $snapshot,
identityHash: $identityHash,
wasNewSnapshot: true,
expectedItems: $expectedItems,
persistedItems: 0,
);
try {
$persistedItems = $this->persistSnapshotItems($snapshot, $snapshotItems);
if ($persistedItems !== $expectedItems) {
throw new RuntimeException('Baseline snapshot completion proof failed.');
}
$snapshot->markComplete($identityHash, [
'expected_identity_hash' => $identityHash,
'expected_items' => $expectedItems,
'persisted_items' => $persistedItems,
'producer_run_id' => (int) $this->operationRun->getKey(),
'was_empty_capture' => $expectedItems === 0,
]);
$snapshot->refresh();
$this->rememberSnapshotOnRun(
snapshot: $snapshot,
identityHash: $identityHash,
wasNewSnapshot: true,
expectedItems: $expectedItems,
persistedItems: $persistedItems,
);
return [
'snapshot' => $snapshot,
'was_new_snapshot' => true,
];
} catch (Throwable $exception) {
$persistedItems = (int) BaselineSnapshotItem::query()
->where('baseline_snapshot_id', (int) $snapshot->getKey())
->count();
$reasonCode = $exception instanceof RuntimeException
&& $exception->getMessage() === 'Baseline snapshot completion proof failed.'
? BaselineReasonCodes::SNAPSHOT_COMPLETION_PROOF_FAILED
: BaselineReasonCodes::SNAPSHOT_CAPTURE_FAILED;
$snapshot->markIncomplete($reasonCode, [
'expected_identity_hash' => $identityHash,
'expected_items' => $expectedItems,
'persisted_items' => $persistedItems,
'producer_run_id' => (int) $this->operationRun->getKey(),
'was_empty_capture' => $expectedItems === 0,
]);
$snapshot->refresh();
$this->rememberSnapshotOnRun(
snapshot: $snapshot,
identityHash: $identityHash,
wasNewSnapshot: true,
expectedItems: $expectedItems,
persistedItems: $persistedItems,
reasonCode: $reasonCode,
);
throw $exception;
}
}
private function findExistingConsumableSnapshot(BaselineProfile $profile, string $identityHash): ?BaselineSnapshot
{
$existing = BaselineSnapshot::query() $existing = BaselineSnapshot::query()
->where('workspace_id', $profile->workspace_id) ->where('workspace_id', $profile->workspace_id)
->where('baseline_profile_id', $profile->getKey()) ->where('baseline_profile_id', $profile->getKey())
->where('snapshot_identity_hash', $identityHash) ->where('snapshot_identity_hash', $identityHash)
->where('lifecycle_state', BaselineSnapshotLifecycleState::Complete->value)
->first(); ->first();
if ($existing instanceof BaselineSnapshot) { return $existing instanceof BaselineSnapshot ? $existing : null;
return $existing;
} }
$snapshot = BaselineSnapshot::create([ /**
* @param array<string, mixed> $summaryJsonb
*/
private function createBuildingSnapshot(
BaselineProfile $profile,
string $identityHash,
array $summaryJsonb,
int $expectedItems,
): BaselineSnapshot {
return BaselineSnapshot::create([
'workspace_id' => (int) $profile->workspace_id, 'workspace_id' => (int) $profile->workspace_id,
'baseline_profile_id' => (int) $profile->getKey(), 'baseline_profile_id' => (int) $profile->getKey(),
'snapshot_identity_hash' => $identityHash, 'snapshot_identity_hash' => $this->temporarySnapshotIdentityHash($profile),
'captured_at' => now(), 'captured_at' => now(),
'lifecycle_state' => BaselineSnapshotLifecycleState::Building->value,
'summary_jsonb' => $summaryJsonb, 'summary_jsonb' => $summaryJsonb,
'completion_meta_jsonb' => [
'expected_identity_hash' => $identityHash,
'expected_items' => $expectedItems,
'persisted_items' => 0,
'producer_run_id' => (int) $this->operationRun->getKey(),
'was_empty_capture' => $expectedItems === 0,
],
]); ]);
}
/**
* @param array<int, array<string, mixed>> $snapshotItems
*/
private function persistSnapshotItems(BaselineSnapshot $snapshot, array $snapshotItems): int
{
$persistedItems = 0;
foreach (array_chunk($snapshotItems, 100) as $chunk) { foreach (array_chunk($snapshotItems, 100) as $chunk) {
$rows = array_map( $rows = array_map(
@ -541,9 +685,56 @@ private function findOrCreateSnapshot(
); );
BaselineSnapshotItem::insert($rows); BaselineSnapshotItem::insert($rows);
$persistedItems += count($rows);
} }
return $snapshot; return $persistedItems;
}
private function temporarySnapshotIdentityHash(BaselineProfile $profile): string
{
return hash(
'sha256',
implode('|', [
'building',
(string) $profile->getKey(),
(string) $this->operationRun->getKey(),
(string) microtime(true),
]),
);
}
private function rememberSnapshotOnRun(
BaselineSnapshot $snapshot,
string $identityHash,
bool $wasNewSnapshot,
int $expectedItems,
int $persistedItems,
?string $reasonCode = null,
): void {
$context = is_array($this->operationRun->context) ? $this->operationRun->context : [];
$context['baseline_snapshot_id'] = (int) $snapshot->getKey();
$context['result'] = array_merge(
is_array($context['result'] ?? null) ? $context['result'] : [],
[
'snapshot_id' => (int) $snapshot->getKey(),
'snapshot_identity_hash' => $identityHash,
'was_new_snapshot' => $wasNewSnapshot,
'items_captured' => $persistedItems,
'snapshot_lifecycle' => $snapshot->lifecycleState()->value,
'expected_items' => $expectedItems,
],
);
if (is_string($reasonCode) && $reasonCode !== '') {
$context['reason_code'] = $reasonCode;
$context['result']['snapshot_reason_code'] = $reasonCode;
} else {
unset($context['reason_code'], $context['result']['snapshot_reason_code']);
}
$this->operationRun->update(['context' => $context]);
$this->operationRun->refresh();
} }
/** /**

View File

@ -4,6 +4,7 @@
namespace App\Jobs; namespace App\Jobs;
use App\Jobs\Concerns\BridgesFailedOperationRun;
use App\Jobs\Middleware\TrackOperationRun; use App\Jobs\Middleware\TrackOperationRun;
use App\Models\BaselineProfile; use App\Models\BaselineProfile;
use App\Models\BaselineSnapshot; use App\Models\BaselineSnapshot;
@ -18,6 +19,7 @@
use App\Services\Baselines\BaselineAutoCloseService; use App\Services\Baselines\BaselineAutoCloseService;
use App\Services\Baselines\BaselineContentCapturePhase; use App\Services\Baselines\BaselineContentCapturePhase;
use App\Services\Baselines\BaselineSnapshotIdentity; use App\Services\Baselines\BaselineSnapshotIdentity;
use App\Services\Baselines\BaselineSnapshotTruthResolver;
use App\Services\Baselines\CurrentStateHashResolver; use App\Services\Baselines\CurrentStateHashResolver;
use App\Services\Baselines\Evidence\BaselinePolicyVersionResolver; use App\Services\Baselines\Evidence\BaselinePolicyVersionResolver;
use App\Services\Baselines\Evidence\ContentEvidenceProvider; use App\Services\Baselines\Evidence\ContentEvidenceProvider;
@ -37,6 +39,7 @@
use App\Support\Baselines\BaselineCaptureMode; use App\Support\Baselines\BaselineCaptureMode;
use App\Support\Baselines\BaselineCompareReasonCode; use App\Support\Baselines\BaselineCompareReasonCode;
use App\Support\Baselines\BaselineFullContentRolloutGate; use App\Support\Baselines\BaselineFullContentRolloutGate;
use App\Support\Baselines\BaselineReasonCodes;
use App\Support\Baselines\BaselineScope; use App\Support\Baselines\BaselineScope;
use App\Support\Baselines\BaselineSubjectKey; use App\Support\Baselines\BaselineSubjectKey;
use App\Support\Baselines\PolicyVersionCapturePurpose; use App\Support\Baselines\PolicyVersionCapturePurpose;
@ -44,6 +47,7 @@
use App\Support\OperationRunOutcome; use App\Support\OperationRunOutcome;
use App\Support\OperationRunStatus; use App\Support\OperationRunStatus;
use App\Support\OperationRunType; use App\Support\OperationRunType;
use App\Support\ReasonTranslation\ReasonPresenter;
use Carbon\CarbonImmutable; use Carbon\CarbonImmutable;
use Illuminate\Bus\Queueable; use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Contracts\Queue\ShouldQueue;
@ -54,7 +58,15 @@
class CompareBaselineToTenantJob implements ShouldQueue class CompareBaselineToTenantJob implements ShouldQueue
{ {
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels; use BridgesFailedOperationRun;
use Dispatchable;
use InteractsWithQueue;
use Queueable;
use SerializesModels;
public int $timeout = 300;
public bool $failOnTimeout = true;
/** /**
* @var array<int, string> * @var array<int, string>
@ -84,6 +96,7 @@ public function handle(
?SettingsResolver $settingsResolver = null, ?SettingsResolver $settingsResolver = null,
?BaselineAutoCloseService $baselineAutoCloseService = null, ?BaselineAutoCloseService $baselineAutoCloseService = null,
?CurrentStateHashResolver $hashResolver = null, ?CurrentStateHashResolver $hashResolver = null,
?BaselineSnapshotTruthResolver $snapshotTruthResolver = null,
?MetaEvidenceProvider $metaEvidenceProvider = null, ?MetaEvidenceProvider $metaEvidenceProvider = null,
?BaselineContentCapturePhase $contentCapturePhase = null, ?BaselineContentCapturePhase $contentCapturePhase = null,
?BaselineFullContentRolloutGate $rolloutGate = null, ?BaselineFullContentRolloutGate $rolloutGate = null,
@ -92,6 +105,7 @@ public function handle(
$settingsResolver ??= app(SettingsResolver::class); $settingsResolver ??= app(SettingsResolver::class);
$baselineAutoCloseService ??= app(BaselineAutoCloseService::class); $baselineAutoCloseService ??= app(BaselineAutoCloseService::class);
$hashResolver ??= app(CurrentStateHashResolver::class); $hashResolver ??= app(CurrentStateHashResolver::class);
$snapshotTruthResolver ??= app(BaselineSnapshotTruthResolver::class);
$metaEvidenceProvider ??= app(MetaEvidenceProvider::class); $metaEvidenceProvider ??= app(MetaEvidenceProvider::class);
$contentCapturePhase ??= app(BaselineContentCapturePhase::class); $contentCapturePhase ??= app(BaselineContentCapturePhase::class);
$rolloutGate ??= app(BaselineFullContentRolloutGate::class); $rolloutGate ??= app(BaselineFullContentRolloutGate::class);
@ -278,12 +292,52 @@ public function handle(
->where('workspace_id', (int) $profile->workspace_id) ->where('workspace_id', (int) $profile->workspace_id)
->where('baseline_profile_id', (int) $profile->getKey()) ->where('baseline_profile_id', (int) $profile->getKey())
->whereKey($snapshotId) ->whereKey($snapshotId)
->first(['id', 'captured_at']); ->first();
if (! $snapshot instanceof BaselineSnapshot) { if (! $snapshot instanceof BaselineSnapshot) {
throw new RuntimeException("BaselineSnapshot #{$snapshotId} not found."); throw new RuntimeException("BaselineSnapshot #{$snapshotId} not found.");
} }
$snapshotResolution = $snapshotTruthResolver->resolveCompareSnapshot($profile, $snapshot);
if (! ($snapshotResolution['ok'] ?? false)) {
$reasonCode = is_string($snapshotResolution['reason_code'] ?? null)
? (string) $snapshotResolution['reason_code']
: BaselineReasonCodes::COMPARE_NO_CONSUMABLE_SNAPSHOT;
$context = is_array($this->operationRun->context) ? $this->operationRun->context : [];
$context['baseline_compare'] = array_merge(
is_array($context['baseline_compare'] ?? null) ? $context['baseline_compare'] : [],
[
'reason_code' => $reasonCode,
'effective_snapshot_id' => $snapshotResolution['effective_snapshot']?->getKey(),
'latest_attempted_snapshot_id' => $snapshotResolution['latest_attempted_snapshot']?->getKey(),
],
);
$context['result'] = array_merge(
is_array($context['result'] ?? null) ? $context['result'] : [],
[
'snapshot_id' => (int) $snapshot->getKey(),
],
);
$context = $this->withCompareReasonTranslation($context, $reasonCode);
$this->operationRun->update(['context' => $context]);
$this->operationRun->refresh();
$operationRunService->finalizeBlockedRun(
run: $this->operationRun,
reasonCode: $reasonCode,
message: $this->snapshotBlockedMessage($reasonCode),
);
return;
}
/** @var BaselineSnapshot $snapshot */
$snapshot = $snapshotResolution['snapshot'];
$snapshotId = (int) $snapshot->getKey();
$since = $snapshot->captured_at instanceof \DateTimeInterface $since = $snapshot->captured_at instanceof \DateTimeInterface
? CarbonImmutable::instance($snapshot->captured_at) ? CarbonImmutable::instance($snapshot->captured_at)
: null; : null;
@ -545,6 +599,10 @@ public function handle(
'findings_resolved' => $resolvedCount, 'findings_resolved' => $resolvedCount,
'severity_breakdown' => $severityBreakdown, 'severity_breakdown' => $severityBreakdown,
]; ];
$updatedContext = $this->withCompareReasonTranslation(
$updatedContext,
$reasonCode?->value,
);
$this->operationRun->update(['context' => $updatedContext]); $this->operationRun->update(['context' => $updatedContext]);
$this->auditCompleted( $this->auditCompleted(
@ -790,6 +848,7 @@ private function completeWithCoverageWarning(
'findings_resolved' => 0, 'findings_resolved' => 0,
'severity_breakdown' => [], 'severity_breakdown' => [],
]; ];
$updatedContext = $this->withCompareReasonTranslation($updatedContext, $reasonCode->value);
$this->operationRun->update(['context' => $updatedContext]); $this->operationRun->update(['context' => $updatedContext]);
@ -896,6 +955,34 @@ private function loadBaselineItems(int $snapshotId, array $policyTypes): array
]; ];
} }
/**
* @param array<string, mixed> $context
* @return array<string, mixed>
*/
private function withCompareReasonTranslation(array $context, ?string $reasonCode): array
{
if (! is_string($reasonCode) || trim($reasonCode) === '') {
unset($context['reason_translation'], $context['next_steps']);
return $context;
}
$translation = app(ReasonPresenter::class)->forArtifactTruth($reasonCode, 'baseline_compare');
if ($translation === null) {
return $context;
}
$context['reason_translation'] = $translation->toArray();
$context['reason_code'] = $reasonCode;
if ($translation->toLegacyNextSteps() !== []) {
$context['next_steps'] = $translation->toLegacyNextSteps();
}
return $context;
}
/** /**
* Load current inventory items keyed by "policy_type|subject_key". * Load current inventory items keyed by "policy_type|subject_key".
* *
@ -1004,6 +1091,17 @@ private function resolveLatestInventorySyncRun(Tenant $tenant): ?OperationRun
return $run instanceof OperationRun ? $run : null; return $run instanceof OperationRun ? $run : null;
} }
private function snapshotBlockedMessage(string $reasonCode): string
{
return match ($reasonCode) {
BaselineReasonCodes::COMPARE_SNAPSHOT_BUILDING => 'The selected baseline snapshot is still building and cannot be used for compare yet.',
BaselineReasonCodes::COMPARE_SNAPSHOT_INCOMPLETE => 'The selected baseline snapshot is incomplete and cannot be used for compare.',
BaselineReasonCodes::COMPARE_SNAPSHOT_SUPERSEDED => 'A newer complete baseline snapshot is now current, so this historical snapshot is blocked from compare.',
BaselineReasonCodes::COMPARE_INVALID_SNAPSHOT => 'The selected baseline snapshot is no longer available.',
default => 'No consumable baseline snapshot is currently available for compare.',
};
}
/** /**
* Compare baseline items vs current inventory and produce drift results. * Compare baseline items vs current inventory and produce drift results.
* *

View File

@ -4,6 +4,7 @@
namespace App\Jobs; namespace App\Jobs;
use App\Jobs\Concerns\BridgesFailedOperationRun;
use App\Models\OperationRun; use App\Models\OperationRun;
use App\Models\TenantReview; use App\Models\TenantReview;
use App\Services\OperationRunService; use App\Services\OperationRunService;
@ -17,8 +18,13 @@
class ComposeTenantReviewJob implements ShouldQueue class ComposeTenantReviewJob implements ShouldQueue
{ {
use BridgesFailedOperationRun;
use Queueable; use Queueable;
public int $timeout = 240;
public bool $failOnTimeout = true;
public function __construct( public function __construct(
public int $tenantReviewId, public int $tenantReviewId,
public int $operationRunId, public int $operationRunId,

View File

@ -0,0 +1,58 @@
<?php
declare(strict_types=1);
namespace App\Jobs\Concerns;
use App\Models\OperationRun;
use App\Services\OperationRunService;
use Throwable;
trait BridgesFailedOperationRun
{
public function failed(Throwable $exception): void
{
$operationRun = $this->failedBridgeOperationRun();
if (! $operationRun instanceof OperationRun) {
return;
}
app(OperationRunService::class)->bridgeFailedJobFailure($operationRun, $exception);
}
protected function failedBridgeOperationRun(): ?OperationRun
{
if (property_exists($this, 'operationRun') && $this->operationRun instanceof OperationRun) {
return $this->operationRun;
}
if (property_exists($this, 'run') && $this->run instanceof OperationRun) {
return $this->run;
}
$candidateIds = [];
foreach (['operationRunId', 'bulkRunId', 'runId'] as $property) {
if (! property_exists($this, $property)) {
continue;
}
$value = $this->{$property};
if (is_numeric($value) && (int) $value > 0) {
$candidateIds[] = (int) $value;
}
}
foreach (array_values(array_unique($candidateIds)) as $candidateId) {
$operationRun = OperationRun::query()->find($candidateId);
if ($operationRun instanceof OperationRun) {
return $operationRun;
}
}
return null;
}
}

View File

@ -20,6 +20,10 @@ class EntraGroupSyncJob implements ShouldQueue
{ {
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels; use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public int $timeout = 240;
public bool $failOnTimeout = true;
public ?OperationRun $operationRun = null; public ?OperationRun $operationRun = null;
public function __construct( public function __construct(

View File

@ -25,6 +25,10 @@ class ExecuteRestoreRunJob implements ShouldQueue
{ {
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels; use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public int $timeout = 420;
public bool $failOnTimeout = true;
public ?OperationRun $operationRun = null; public ?OperationRun $operationRun = null;
public function __construct( public function __construct(

View File

@ -19,6 +19,10 @@ class GenerateEvidenceSnapshotJob implements ShouldQueue
{ {
use Queueable; use Queueable;
public int $timeout = 240;
public bool $failOnTimeout = true;
public function __construct( public function __construct(
public int $snapshotId, public int $snapshotId,
public int $operationRunId, public int $operationRunId,

View File

@ -28,6 +28,10 @@ class GenerateReviewPackJob implements ShouldQueue
{ {
use Queueable; use Queueable;
public int $timeout = 240;
public bool $failOnTimeout = true;
public function __construct( public function __construct(
public int $reviewPackId, public int $reviewPackId,
public int $operationRunId, public int $operationRunId,

View File

@ -40,6 +40,10 @@ class RunBackupScheduleJob implements ShouldQueue
public int $tries = 3; public int $tries = 3;
public int $timeout = 300;
public bool $failOnTimeout = true;
/** /**
* Compatibility-only legacy field. * Compatibility-only legacy field.
* *

View File

@ -2,6 +2,7 @@
namespace App\Jobs; namespace App\Jobs;
use App\Jobs\Concerns\BridgesFailedOperationRun;
use App\Jobs\Middleware\EnsureQueuedExecutionLegitimate; use App\Jobs\Middleware\EnsureQueuedExecutionLegitimate;
use App\Jobs\Middleware\TrackOperationRun; use App\Jobs\Middleware\TrackOperationRun;
use App\Models\OperationRun; use App\Models\OperationRun;
@ -24,7 +25,15 @@
class RunInventorySyncJob implements ShouldQueue class RunInventorySyncJob implements ShouldQueue
{ {
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels; use BridgesFailedOperationRun;
use Dispatchable;
use InteractsWithQueue;
use Queueable;
use SerializesModels;
public int $timeout = 240;
public bool $failOnTimeout = true;
public ?OperationRun $operationRun = null; public ?OperationRun $operationRun = null;

View File

@ -2,6 +2,7 @@
namespace App\Jobs; namespace App\Jobs;
use App\Jobs\Concerns\BridgesFailedOperationRun;
use App\Jobs\Middleware\EnsureQueuedExecutionLegitimate; use App\Jobs\Middleware\EnsureQueuedExecutionLegitimate;
use App\Jobs\Middleware\TrackOperationRun; use App\Jobs\Middleware\TrackOperationRun;
use App\Models\OperationRun; use App\Models\OperationRun;
@ -21,7 +22,15 @@
class SyncPoliciesJob implements ShouldQueue class SyncPoliciesJob implements ShouldQueue
{ {
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels; use BridgesFailedOperationRun;
use Dispatchable;
use InteractsWithQueue;
use Queueable;
use SerializesModels;
public int $timeout = 180;
public bool $failOnTimeout = true;
public ?OperationRun $operationRun = null; public ?OperationRun $operationRun = null;

View File

@ -20,6 +20,10 @@ class SyncRoleDefinitionsJob implements ShouldQueue
{ {
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels; use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public int $timeout = 240;
public bool $failOnTimeout = true;
public ?OperationRun $operationRun = null; public ?OperationRun $operationRun = null;
/** /**

View File

@ -7,6 +7,7 @@
use App\Support\Baselines\BaselineCaptureMode; use App\Support\Baselines\BaselineCaptureMode;
use App\Support\Baselines\BaselineProfileStatus; use App\Support\Baselines\BaselineProfileStatus;
use App\Support\Baselines\BaselineScope; use App\Support\Baselines\BaselineScope;
use App\Support\Baselines\BaselineSnapshotLifecycleState;
use Illuminate\Database\Eloquent\Casts\Attribute; use Illuminate\Database\Eloquent\Casts\Attribute;
use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
@ -121,6 +122,37 @@ public function snapshots(): HasMany
return $this->hasMany(BaselineSnapshot::class); return $this->hasMany(BaselineSnapshot::class);
} }
public function resolveCurrentConsumableSnapshot(): ?BaselineSnapshot
{
$activeSnapshot = $this->relationLoaded('activeSnapshot')
? $this->getRelation('activeSnapshot')
: $this->activeSnapshot()->first();
if ($activeSnapshot instanceof BaselineSnapshot && $activeSnapshot->isConsumable()) {
return $activeSnapshot;
}
return $this->snapshots()
->where('lifecycle_state', BaselineSnapshotLifecycleState::Complete->value)
->orderByDesc('completed_at')
->orderByDesc('captured_at')
->orderByDesc('id')
->first();
}
public function resolveLatestAttemptedSnapshot(): ?BaselineSnapshot
{
return $this->snapshots()
->orderByDesc('captured_at')
->orderByDesc('id')
->first();
}
public function hasConsumableSnapshot(): bool
{
return $this->resolveCurrentConsumableSnapshot() instanceof BaselineSnapshot;
}
public function tenantAssignments(): HasMany public function tenantAssignments(): HasMany
{ {
return $this->hasMany(BaselineTenantAssignment::class); return $this->hasMany(BaselineTenantAssignment::class);

View File

@ -1,11 +1,17 @@
<?php <?php
declare(strict_types=1);
namespace App\Models; namespace App\Models;
use App\Support\Baselines\BaselineReasonCodes;
use App\Support\Baselines\BaselineSnapshotLifecycleState;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Database\Eloquent\Relations\HasMany;
use RuntimeException;
class BaselineSnapshot extends Model class BaselineSnapshot extends Model
{ {
@ -13,10 +19,20 @@ class BaselineSnapshot extends Model
protected $guarded = []; protected $guarded = [];
protected $casts = [ /**
* @return array<string, string>
*/
protected function casts(): array
{
return [
'lifecycle_state' => BaselineSnapshotLifecycleState::class,
'summary_jsonb' => 'array', 'summary_jsonb' => 'array',
'completion_meta_jsonb' => 'array',
'captured_at' => 'datetime', 'captured_at' => 'datetime',
'completed_at' => 'datetime',
'failed_at' => 'datetime',
]; ];
}
public function workspace(): BelongsTo public function workspace(): BelongsTo
{ {
@ -32,4 +48,100 @@ public function items(): HasMany
{ {
return $this->hasMany(BaselineSnapshotItem::class); return $this->hasMany(BaselineSnapshotItem::class);
} }
public function scopeConsumable(Builder $query): Builder
{
return $query->where('lifecycle_state', BaselineSnapshotLifecycleState::Complete->value);
}
public function scopeLatestConsumable(Builder $query): Builder
{
return $query
->consumable()
->orderByDesc('completed_at')
->orderByDesc('captured_at')
->orderByDesc('id');
}
public function isConsumable(): bool
{
return $this->lifecycleState() === BaselineSnapshotLifecycleState::Complete;
}
public function isBuilding(): bool
{
return $this->lifecycleState() === BaselineSnapshotLifecycleState::Building;
}
public function isComplete(): bool
{
return $this->lifecycleState() === BaselineSnapshotLifecycleState::Complete;
}
public function isIncomplete(): bool
{
return $this->lifecycleState() === BaselineSnapshotLifecycleState::Incomplete;
}
public function markBuilding(array $completionMeta = []): void
{
$this->forceFill([
'lifecycle_state' => BaselineSnapshotLifecycleState::Building,
'completed_at' => null,
'failed_at' => null,
'completion_meta_jsonb' => $this->mergedCompletionMeta($completionMeta),
])->save();
}
public function markComplete(string $identityHash, array $completionMeta = []): void
{
if ($this->isIncomplete()) {
throw new RuntimeException('Incomplete baseline snapshots cannot transition back to complete.');
}
$this->forceFill([
'snapshot_identity_hash' => $identityHash,
'lifecycle_state' => BaselineSnapshotLifecycleState::Complete,
'completed_at' => now(),
'failed_at' => null,
'completion_meta_jsonb' => $this->mergedCompletionMeta($completionMeta),
])->save();
}
public function markIncomplete(?string $reasonCode = null, array $completionMeta = []): void
{
$this->forceFill([
'lifecycle_state' => BaselineSnapshotLifecycleState::Incomplete,
'completed_at' => null,
'failed_at' => now(),
'completion_meta_jsonb' => $this->mergedCompletionMeta(array_filter([
'finalization_reason_code' => $reasonCode ?? BaselineReasonCodes::SNAPSHOT_INCOMPLETE,
...$completionMeta,
], static fn (mixed $value): bool => $value !== null)),
])->save();
}
public function lifecycleState(): BaselineSnapshotLifecycleState
{
if ($this->lifecycle_state instanceof BaselineSnapshotLifecycleState) {
return $this->lifecycle_state;
}
if (is_string($this->lifecycle_state) && BaselineSnapshotLifecycleState::tryFrom($this->lifecycle_state) instanceof BaselineSnapshotLifecycleState) {
return BaselineSnapshotLifecycleState::from($this->lifecycle_state);
}
return BaselineSnapshotLifecycleState::Incomplete;
}
/**
* @param array<string, mixed> $completionMeta
* @return array<string, mixed>
*/
private function mergedCompletionMeta(array $completionMeta): array
{
$existing = is_array($this->completion_meta_jsonb) ? $this->completion_meta_jsonb : [];
return array_replace($existing, $completionMeta);
}
} }

View File

@ -2,6 +2,8 @@
namespace App\Models; namespace App\Models;
use App\Support\OperationCatalog;
use App\Support\Operations\OperationRunFreshnessState;
use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
@ -127,4 +129,68 @@ public function setFinishedAtAttribute(mixed $value): void
{ {
$this->completed_at = $value; $this->completed_at = $value;
} }
public function isGovernanceArtifactOperation(): bool
{
return OperationCatalog::isGovernanceArtifactOperation((string) $this->type);
}
public function supportsOperatorExplanation(): bool
{
return OperationCatalog::supportsOperatorExplanation((string) $this->type);
}
public function governanceArtifactFamily(): ?string
{
return OperationCatalog::governanceArtifactFamily((string) $this->type);
}
/**
* @return array<string, mixed>
*/
public function artifactResultContext(): array
{
$context = is_array($this->context) ? $this->context : [];
$result = is_array($context['result'] ?? null) ? $context['result'] : [];
return array_merge($context, ['result' => $result]);
}
public function relatedArtifactId(): ?int
{
return match ($this->governanceArtifactFamily()) {
'baseline_snapshot' => is_numeric(data_get($this->context, 'result.snapshot_id'))
? (int) data_get($this->context, 'result.snapshot_id')
: null,
default => null,
};
}
/**
* @return array<string, mixed>
*/
public function reconciliation(): array
{
$context = is_array($this->context) ? $this->context : [];
$reconciliation = $context['reconciliation'] ?? null;
return is_array($reconciliation) ? $reconciliation : [];
}
public function isLifecycleReconciled(): bool
{
return $this->reconciliation() !== [];
}
public function lifecycleReconciliationReasonCode(): ?string
{
$reasonCode = $this->reconciliation()['reason_code'] ?? null;
return is_string($reasonCode) && trim($reasonCode) !== '' ? trim($reasonCode) : null;
}
public function freshnessState(): OperationRunFreshnessState
{
return OperationRunFreshnessState::forRun($this);
}
} }

View File

@ -7,6 +7,7 @@
use App\Models\Tenant; use App\Models\Tenant;
use App\Support\OperationRunLinks; use App\Support\OperationRunLinks;
use App\Support\OpsUx\OperationUxPresenter; use App\Support\OpsUx\OperationUxPresenter;
use App\Support\ReasonTranslation\ReasonPresenter;
use App\Support\System\SystemOperationRunLinks; use App\Support\System\SystemOperationRunLinks;
use Illuminate\Bus\Queueable; use Illuminate\Bus\Queueable;
use Illuminate\Notifications\Notification; use Illuminate\Notifications\Notification;
@ -44,6 +45,14 @@ public function toDatabase(object $notifiable): array
->url($runUrl), ->url($runUrl),
]); ]);
return $notification->getDatabaseMessage(); $message = $notification->getDatabaseMessage();
$reasonEnvelope = app(ReasonPresenter::class)->forOperationRun($this->run, 'notification');
if ($reasonEnvelope !== null) {
$message['reason_translation'] = $reasonEnvelope->toArray();
$message['diagnostic_reason_code'] = $reasonEnvelope->diagnosticCode();
}
return $message;
} }
} }

View File

@ -49,8 +49,8 @@ public function toDatabase(object $notifiable): array
return FilamentNotification::make() return FilamentNotification::make()
->title("{$operationLabel} queued") ->title("{$operationLabel} queued")
->body('Queued. Monitor progress in Monitoring → Operations.') ->body('Queued for execution. Open the run for progress and next steps.')
->warning() ->info()
->actions([ ->actions([
\Filament\Actions\Action::make('view_run') \Filament\Actions\Action::make('view_run')
->label('View run') ->label('View run')

View File

@ -61,7 +61,7 @@ public function view(User $user, OperationRun $run): Response|bool
} }
$requiredCapability = app(OperationRunCapabilityResolver::class) $requiredCapability = app(OperationRunCapabilityResolver::class)
->requiredCapabilityForType((string) $run->type); ->requiredCapabilityForRun($run);
if (! is_string($requiredCapability) || $requiredCapability === '') { if (! is_string($requiredCapability) || $requiredCapability === '') {
return true; return true;

View File

@ -28,6 +28,7 @@
use App\Services\Auth\WorkspaceCapabilityResolver; use App\Services\Auth\WorkspaceCapabilityResolver;
use App\Services\Auth\WorkspaceRoleCapabilityMap; use App\Services\Auth\WorkspaceRoleCapabilityMap;
use App\Support\Auth\Capabilities; use App\Support\Auth\Capabilities;
use App\Support\Filament\PanelThemeAsset;
use App\Support\Workspaces\WorkspaceContext; use App\Support\Workspaces\WorkspaceContext;
use Filament\Http\Middleware\Authenticate; use Filament\Http\Middleware\Authenticate;
use Filament\Http\Middleware\AuthenticateSession; use Filament\Http\Middleware\AuthenticateSession;
@ -202,7 +203,7 @@ public function panel(Panel $panel): Panel
]); ]);
if (! app()->runningUnitTests()) { if (! app()->runningUnitTests()) {
$panel->viteTheme('resources/css/filament/admin/theme.css'); $panel->theme(PanelThemeAsset::resolve('resources/css/filament/admin/theme.css'));
} }
return $panel; return $panel;

View File

@ -6,6 +6,7 @@
use App\Filament\System\Pages\Dashboard; use App\Filament\System\Pages\Dashboard;
use App\Http\Middleware\UseSystemSessionCookie; use App\Http\Middleware\UseSystemSessionCookie;
use App\Support\Auth\PlatformCapabilities; use App\Support\Auth\PlatformCapabilities;
use App\Support\Filament\PanelThemeAsset;
use Filament\Http\Middleware\Authenticate; use Filament\Http\Middleware\Authenticate;
use Filament\Http\Middleware\AuthenticateSession; use Filament\Http\Middleware\AuthenticateSession;
use Filament\Http\Middleware\DisableBladeIconComponents; use Filament\Http\Middleware\DisableBladeIconComponents;
@ -60,6 +61,6 @@ public function panel(Panel $panel): Panel
Authenticate::class, Authenticate::class,
'ensure-platform-capability:'.PlatformCapabilities::ACCESS_SYSTEM_PANEL, 'ensure-platform-capability:'.PlatformCapabilities::ACCESS_SYSTEM_PANEL,
]) ])
->viteTheme('resources/css/filament/system/theme.css'); ->theme(PanelThemeAsset::resolve('resources/css/filament/system/theme.css'));
} }
} }

View File

@ -6,6 +6,7 @@
use App\Filament\Pages\TenantDashboard; use App\Filament\Pages\TenantDashboard;
use App\Filament\Resources\TenantReviewResource; use App\Filament\Resources\TenantReviewResource;
use App\Models\Tenant; use App\Models\Tenant;
use App\Support\Filament\PanelThemeAsset;
use App\Support\Middleware\DenyNonMemberTenantAccess; use App\Support\Middleware\DenyNonMemberTenantAccess;
use Filament\Facades\Filament; use Filament\Facades\Filament;
use Filament\Http\Middleware\Authenticate; use Filament\Http\Middleware\Authenticate;
@ -112,7 +113,7 @@ public function panel(Panel $panel): Panel
]); ]);
if (! app()->runningUnitTests()) { if (! app()->runningUnitTests()) {
$panel->viteTheme('resources/css/filament/admin/theme.css'); $panel->theme(PanelThemeAsset::resolve('resources/css/filament/admin/theme.css'));
} }
return $panel; return $panel;

View File

@ -8,6 +8,7 @@
use App\Models\RestoreRun; use App\Models\RestoreRun;
use App\Support\OperationRunOutcome; use App\Support\OperationRunOutcome;
use App\Support\OperationRunStatus; use App\Support\OperationRunStatus;
use App\Support\Operations\LifecycleReconciliationReason;
use App\Support\RestoreRunStatus; use App\Support\RestoreRunStatus;
use Carbon\CarbonImmutable; use Carbon\CarbonImmutable;
use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Builder;
@ -151,25 +152,23 @@ private function reconcileOne(OperationRun $run, bool $dryRun): ?array
/** @var OperationRunService $runs */ /** @var OperationRunService $runs */
$runs = app(OperationRunService::class); $runs = app(OperationRunService::class);
$runs->updateRun( $runs->updateRunWithReconciliation(
$run, run: $run,
status: $opStatus, status: $opStatus,
outcome: $opOutcome, outcome: $opOutcome,
summaryCounts: $summaryCounts, summaryCounts: $summaryCounts,
failures: $failures, failures: $failures,
reasonCode: LifecycleReconciliationReason::AdapterOutOfSync->value,
reasonMessage: LifecycleReconciliationReason::AdapterOutOfSync->defaultMessage(),
source: 'adapter_reconciler',
evidence: [
'restore_run_id' => (int) $restoreRun->getKey(),
'restore_status' => $restoreStatus?->value,
],
); );
$run->refresh(); $run->refresh();
$updatedContext = is_array($run->context) ? $run->context : [];
$reconciliation = is_array($updatedContext['reconciliation'] ?? null) ? $updatedContext['reconciliation'] : [];
$reconciliation['reconciled_at'] = CarbonImmutable::now()->toIso8601String();
$reconciliation['reason'] = 'adapter_out_of_sync';
$updatedContext['reconciliation'] = $reconciliation;
$run->context = $updatedContext;
if ($run->started_at === null && $restoreRun->started_at !== null) { if ($run->started_at === null && $restoreRun->started_at !== null) {
$run->started_at = $restoreRun->started_at; $run->started_at = $restoreRun->started_at;
} }

View File

@ -18,16 +18,18 @@
use App\Support\Baselines\BaselineReasonCodes; use App\Support\Baselines\BaselineReasonCodes;
use App\Support\Baselines\BaselineScope; use App\Support\Baselines\BaselineScope;
use App\Support\OperationRunType; use App\Support\OperationRunType;
use App\Support\ReasonTranslation\ReasonPresenter;
final class BaselineCompareService final class BaselineCompareService
{ {
public function __construct( public function __construct(
private readonly OperationRunService $runs, private readonly OperationRunService $runs,
private readonly BaselineFullContentRolloutGate $rolloutGate, private readonly BaselineFullContentRolloutGate $rolloutGate,
private readonly BaselineSnapshotTruthResolver $snapshotTruthResolver,
) {} ) {}
/** /**
* @return array{ok: bool, run?: OperationRun, reason_code?: string} * @return array{ok: bool, run?: OperationRun, reason_code?: string, reason_translation?: array<string, mixed>}
*/ */
public function startCompare( public function startCompare(
Tenant $tenant, Tenant $tenant,
@ -40,38 +42,45 @@ public function startCompare(
->first(); ->first();
if (! $assignment instanceof BaselineTenantAssignment) { if (! $assignment instanceof BaselineTenantAssignment) {
return ['ok' => false, 'reason_code' => BaselineReasonCodes::COMPARE_NO_ASSIGNMENT]; return $this->failedStart(BaselineReasonCodes::COMPARE_NO_ASSIGNMENT);
} }
$profile = BaselineProfile::query()->find($assignment->baseline_profile_id); $profile = BaselineProfile::query()->find($assignment->baseline_profile_id);
if (! $profile instanceof BaselineProfile) { if (! $profile instanceof BaselineProfile) {
return ['ok' => false, 'reason_code' => BaselineReasonCodes::COMPARE_PROFILE_NOT_ACTIVE]; return $this->failedStart(BaselineReasonCodes::COMPARE_PROFILE_NOT_ACTIVE);
} }
$hasExplicitSnapshotSelection = is_int($baselineSnapshotId) && $baselineSnapshotId > 0; $precondition = $this->validatePreconditions($profile);
$precondition = $this->validatePreconditions($profile, hasExplicitSnapshotSelection: $hasExplicitSnapshotSelection);
if ($precondition !== null) { if ($precondition !== null) {
return ['ok' => false, 'reason_code' => $precondition]; return $this->failedStart($precondition);
} }
$snapshotId = $baselineSnapshotId !== null ? (int) $baselineSnapshotId : 0; $selectedSnapshot = null;
if ($snapshotId > 0) { if (is_int($baselineSnapshotId) && $baselineSnapshotId > 0) {
$snapshot = BaselineSnapshot::query() $selectedSnapshot = BaselineSnapshot::query()
->where('workspace_id', (int) $profile->workspace_id) ->where('workspace_id', (int) $profile->workspace_id)
->where('baseline_profile_id', (int) $profile->getKey()) ->where('baseline_profile_id', (int) $profile->getKey())
->whereKey($snapshotId) ->whereKey((int) $baselineSnapshotId)
->first(['id']); ->first();
if (! $snapshot instanceof BaselineSnapshot) { if (! $selectedSnapshot instanceof BaselineSnapshot) {
return ['ok' => false, 'reason_code' => BaselineReasonCodes::COMPARE_INVALID_SNAPSHOT]; return $this->failedStart(BaselineReasonCodes::COMPARE_INVALID_SNAPSHOT);
} }
} else {
$snapshotId = (int) $profile->active_snapshot_id;
} }
$snapshotResolution = $this->snapshotTruthResolver->resolveCompareSnapshot($profile, $selectedSnapshot);
if (! ($snapshotResolution['ok'] ?? false)) {
return $this->failedStart($snapshotResolution['reason_code'] ?? BaselineReasonCodes::COMPARE_NO_CONSUMABLE_SNAPSHOT);
}
/** @var BaselineSnapshot $snapshot */
$snapshot = $snapshotResolution['snapshot'];
$snapshotId = (int) $snapshot->getKey();
$profileScope = BaselineScope::fromJsonb( $profileScope = BaselineScope::fromJsonb(
is_array($profile->scope_jsonb) ? $profile->scope_jsonb : null, is_array($profile->scope_jsonb) ? $profile->scope_jsonb : null,
); );
@ -113,7 +122,7 @@ public function startCompare(
return ['ok' => true, 'run' => $run]; return ['ok' => true, 'run' => $run];
} }
private function validatePreconditions(BaselineProfile $profile, bool $hasExplicitSnapshotSelection = false): ?string private function validatePreconditions(BaselineProfile $profile): ?string
{ {
if ($profile->status !== BaselineProfileStatus::Active) { if ($profile->status !== BaselineProfileStatus::Active) {
return BaselineReasonCodes::COMPARE_PROFILE_NOT_ACTIVE; return BaselineReasonCodes::COMPARE_PROFILE_NOT_ACTIVE;
@ -123,10 +132,20 @@ private function validatePreconditions(BaselineProfile $profile, bool $hasExplic
return BaselineReasonCodes::COMPARE_ROLLOUT_DISABLED; return BaselineReasonCodes::COMPARE_ROLLOUT_DISABLED;
} }
if (! $hasExplicitSnapshotSelection && $profile->active_snapshot_id === null) {
return BaselineReasonCodes::COMPARE_NO_ACTIVE_SNAPSHOT;
}
return null; return null;
} }
/**
* @return array{ok: false, reason_code: string, reason_translation?: array<string, mixed>}
*/
private function failedStart(string $reasonCode): array
{
$translation = app(ReasonPresenter::class)->forArtifactTruth($reasonCode, 'baseline_compare');
return array_filter([
'ok' => false,
'reason_code' => $reasonCode,
'reason_translation' => $translation?->toArray(),
], static fn (mixed $value): bool => $value !== null);
}
} }

View File

@ -0,0 +1,90 @@
<?php
declare(strict_types=1);
namespace App\Services\Baselines;
final class BaselineSnapshotItemNormalizer
{
/**
* @param list<array{subject_type: string, subject_external_id: string, subject_key: string, policy_type: string, baseline_hash: string, meta_jsonb: array<string, mixed>}> $items
* @return array{items: list<array{subject_type: string, subject_external_id: string, subject_key: string, policy_type: string, baseline_hash: string, meta_jsonb: array<string, mixed>}>, duplicates: int}
*/
public function deduplicate(array $items): array
{
$uniqueItems = [];
$duplicates = 0;
foreach ($items as $item) {
$key = trim((string) ($item['subject_type'] ?? '')).'|'.trim((string) ($item['subject_external_id'] ?? ''));
if ($key === '|') {
continue;
}
if (! array_key_exists($key, $uniqueItems)) {
$uniqueItems[$key] = $item;
continue;
}
$duplicates++;
if ($this->shouldReplace($uniqueItems[$key], $item)) {
$uniqueItems[$key] = $item;
}
}
return [
'items' => array_values($uniqueItems),
'duplicates' => $duplicates,
];
}
/**
* @param array{meta_jsonb?: array<string, mixed>, baseline_hash?: string} $current
* @param array{meta_jsonb?: array<string, mixed>, baseline_hash?: string} $candidate
*/
private function shouldReplace(array $current, array $candidate): bool
{
$currentFidelity = $this->fidelityRank($current);
$candidateFidelity = $this->fidelityRank($candidate);
if ($candidateFidelity !== $currentFidelity) {
return $candidateFidelity > $currentFidelity;
}
$currentObservedAt = $this->observedAt($current);
$candidateObservedAt = $this->observedAt($candidate);
if ($candidateObservedAt !== $currentObservedAt) {
return $candidateObservedAt > $currentObservedAt;
}
return strcmp((string) ($candidate['baseline_hash'] ?? ''), (string) ($current['baseline_hash'] ?? '')) > 0;
}
/**
* @param array{meta_jsonb?: array<string, mixed>} $item
*/
private function fidelityRank(array $item): int
{
$fidelity = data_get($item, 'meta_jsonb.evidence.fidelity');
return match ($fidelity) {
'content' => 2,
'meta' => 1,
default => 0,
};
}
/**
* @param array{meta_jsonb?: array<string, mixed>} $item
*/
private function observedAt(array $item): string
{
$observedAt = data_get($item, 'meta_jsonb.evidence.observed_at');
return is_string($observedAt) ? $observedAt : '';
}
}

View File

@ -0,0 +1,175 @@
<?php
declare(strict_types=1);
namespace App\Services\Baselines;
use App\Models\BaselineProfile;
use App\Models\BaselineSnapshot;
use App\Support\Baselines\BaselineReasonCodes;
use App\Support\Baselines\BaselineSnapshotLifecycleState;
final class BaselineSnapshotTruthResolver
{
public function resolveEffectiveSnapshot(BaselineProfile $profile): ?BaselineSnapshot
{
return BaselineSnapshot::query()
->where('workspace_id', (int) $profile->workspace_id)
->where('baseline_profile_id', (int) $profile->getKey())
->where('lifecycle_state', BaselineSnapshotLifecycleState::Complete->value)
->orderByDesc('completed_at')
->orderByDesc('captured_at')
->orderByDesc('id')
->first();
}
public function resolveLatestAttemptedSnapshot(BaselineProfile $profile): ?BaselineSnapshot
{
return BaselineSnapshot::query()
->where('workspace_id', (int) $profile->workspace_id)
->where('baseline_profile_id', (int) $profile->getKey())
->orderByDesc('captured_at')
->orderByDesc('id')
->first();
}
/**
* @return array{
* ok: bool,
* snapshot: ?BaselineSnapshot,
* effective_snapshot: ?BaselineSnapshot,
* latest_attempted_snapshot: ?BaselineSnapshot,
* reason_code: ?string
* }
*/
public function resolveCompareSnapshot(BaselineProfile $profile, ?BaselineSnapshot $explicitSnapshot = null): array
{
$effectiveSnapshot = $this->resolveEffectiveSnapshot($profile);
$latestAttemptedSnapshot = $this->resolveLatestAttemptedSnapshot($profile);
if ($explicitSnapshot instanceof BaselineSnapshot) {
if ((int) $explicitSnapshot->workspace_id !== (int) $profile->workspace_id
|| (int) $explicitSnapshot->baseline_profile_id !== (int) $profile->getKey()) {
return [
'ok' => false,
'snapshot' => null,
'effective_snapshot' => $effectiveSnapshot,
'latest_attempted_snapshot' => $latestAttemptedSnapshot,
'reason_code' => BaselineReasonCodes::COMPARE_INVALID_SNAPSHOT,
];
}
$reasonCode = $this->compareBlockedReasonForSnapshot($explicitSnapshot, $effectiveSnapshot, explicitSelection: true);
return [
'ok' => $reasonCode === null,
'snapshot' => $reasonCode === null ? $explicitSnapshot : null,
'effective_snapshot' => $effectiveSnapshot,
'latest_attempted_snapshot' => $latestAttemptedSnapshot,
'reason_code' => $reasonCode,
];
}
if ($effectiveSnapshot instanceof BaselineSnapshot) {
return [
'ok' => true,
'snapshot' => $effectiveSnapshot,
'effective_snapshot' => $effectiveSnapshot,
'latest_attempted_snapshot' => $latestAttemptedSnapshot,
'reason_code' => null,
];
}
return [
'ok' => false,
'snapshot' => null,
'effective_snapshot' => null,
'latest_attempted_snapshot' => $latestAttemptedSnapshot,
'reason_code' => $this->profileBlockedReason($latestAttemptedSnapshot),
];
}
public function isHistoricallySuperseded(BaselineSnapshot $snapshot, ?BaselineSnapshot $effectiveSnapshot = null): bool
{
$effectiveSnapshot ??= BaselineSnapshot::query()
->where('workspace_id', (int) $snapshot->workspace_id)
->where('baseline_profile_id', (int) $snapshot->baseline_profile_id)
->where('lifecycle_state', BaselineSnapshotLifecycleState::Complete->value)
->orderByDesc('completed_at')
->orderByDesc('captured_at')
->orderByDesc('id')
->first();
if (! $effectiveSnapshot instanceof BaselineSnapshot) {
return false;
}
return $snapshot->isConsumable()
&& (int) $effectiveSnapshot->getKey() !== (int) $snapshot->getKey();
}
public function artifactReasonCode(BaselineSnapshot $snapshot, ?BaselineSnapshot $effectiveSnapshot = null): ?string
{
if (! $effectiveSnapshot instanceof BaselineSnapshot) {
$snapshot->loadMissing('baselineProfile');
$profile = $snapshot->baselineProfile;
if ($profile instanceof BaselineProfile) {
$effectiveSnapshot = $this->resolveEffectiveSnapshot($profile);
}
}
if ($snapshot->isBuilding()) {
return BaselineReasonCodes::SNAPSHOT_BUILDING;
}
if ($snapshot->isIncomplete()) {
$completionMeta = is_array($snapshot->completion_meta_jsonb) ? $snapshot->completion_meta_jsonb : [];
$reasonCode = $completionMeta['finalization_reason_code'] ?? null;
return is_string($reasonCode) && trim($reasonCode) !== ''
? trim($reasonCode)
: BaselineReasonCodes::SNAPSHOT_INCOMPLETE;
}
if ($this->isHistoricallySuperseded($snapshot, $effectiveSnapshot)) {
return BaselineReasonCodes::SNAPSHOT_SUPERSEDED;
}
return null;
}
private function compareBlockedReasonForSnapshot(
BaselineSnapshot $snapshot,
?BaselineSnapshot $effectiveSnapshot,
bool $explicitSelection,
): ?string {
if ($snapshot->isBuilding()) {
return BaselineReasonCodes::COMPARE_SNAPSHOT_BUILDING;
}
if ($snapshot->isIncomplete()) {
return BaselineReasonCodes::COMPARE_SNAPSHOT_INCOMPLETE;
}
if (! $snapshot->isConsumable()) {
return BaselineReasonCodes::COMPARE_NO_CONSUMABLE_SNAPSHOT;
}
if ($explicitSelection && $effectiveSnapshot instanceof BaselineSnapshot && (int) $effectiveSnapshot->getKey() !== (int) $snapshot->getKey()) {
return BaselineReasonCodes::COMPARE_SNAPSHOT_SUPERSEDED;
}
return null;
}
private function profileBlockedReason(?BaselineSnapshot $latestAttemptedSnapshot): string
{
return match (true) {
$latestAttemptedSnapshot?->isBuilding() === true => BaselineReasonCodes::COMPARE_SNAPSHOT_BUILDING,
$latestAttemptedSnapshot?->isIncomplete() === true => BaselineReasonCodes::COMPARE_SNAPSHOT_INCOMPLETE,
default => BaselineReasonCodes::COMPARE_NO_CONSUMABLE_SNAPSHOT,
};
}
}

View File

@ -6,6 +6,7 @@
use App\Models\BaselineSnapshot; use App\Models\BaselineSnapshot;
use App\Models\BaselineSnapshotItem; use App\Models\BaselineSnapshotItem;
use App\Support\Badges\BadgeCatalog;
use App\Support\Badges\BadgeDomain; use App\Support\Badges\BadgeDomain;
use App\Support\Badges\BadgeRenderer; use App\Support\Badges\BadgeRenderer;
use App\Support\Inventory\InventoryPolicyTypeMeta; use App\Support\Inventory\InventoryPolicyTypeMeta;
@ -13,6 +14,8 @@
use App\Support\Ui\EnterpriseDetail\EnterpriseDetailPageData; use App\Support\Ui\EnterpriseDetail\EnterpriseDetailPageData;
use App\Support\Ui\EnterpriseDetail\EnterpriseDetailSectionFactory; use App\Support\Ui\EnterpriseDetail\EnterpriseDetailSectionFactory;
use App\Support\Ui\EnterpriseDetail\SummaryHeaderData; use App\Support\Ui\EnterpriseDetail\SummaryHeaderData;
use App\Support\Ui\GovernanceArtifactTruth\ArtifactTruthEnvelope;
use App\Support\Ui\GovernanceArtifactTruth\ArtifactTruthPresenter;
use Illuminate\Database\Eloquent\Collection as EloquentCollection; use Illuminate\Database\Eloquent\Collection as EloquentCollection;
use Illuminate\Support\Carbon; use Illuminate\Support\Carbon;
use Illuminate\Support\Collection; use Illuminate\Support\Collection;
@ -57,7 +60,8 @@ public function present(BaselineSnapshot $snapshot): RenderedSnapshot
$groups, $groups,
); );
$overallGapCount = $this->summaryGapCount($summary); $overallGapSummary = $this->summaryGapSummary($summary);
$overallGapCount = $overallGapSummary->count;
$overallFidelity = FidelityState::fromSummary($summary, $items->isNotEmpty()); $overallFidelity = FidelityState::fromSummary($summary, $items->isNotEmpty());
return new RenderedSnapshot( return new RenderedSnapshot(
@ -67,7 +71,7 @@ public function present(BaselineSnapshot $snapshot): RenderedSnapshot
snapshotIdentityHash: is_string($snapshot->snapshot_identity_hash) && trim($snapshot->snapshot_identity_hash) !== '' snapshotIdentityHash: is_string($snapshot->snapshot_identity_hash) && trim($snapshot->snapshot_identity_hash) !== ''
? trim($snapshot->snapshot_identity_hash) ? trim($snapshot->snapshot_identity_hash)
: null, : null,
stateLabel: $overallGapCount > 0 ? 'Captured with gaps' : 'Complete', stateLabel: $this->gapStatusSpec($overallGapCount)->label,
fidelitySummary: $this->fidelitySummary($summary), fidelitySummary: $this->fidelitySummary($summary),
overallFidelity: $overallFidelity, overallFidelity: $overallFidelity,
overallGapCount: $overallGapCount, overallGapCount: $overallGapCount,
@ -96,10 +100,21 @@ public function presentEnterpriseDetail(BaselineSnapshot $snapshot, array $relat
{ {
$rendered = $this->present($snapshot); $rendered = $this->present($snapshot);
$factory = new EnterpriseDetailSectionFactory; $factory = new EnterpriseDetailSectionFactory;
$truth = app(ArtifactTruthPresenter::class)->forBaselineSnapshot($snapshot);
$stateBadge = $factory->statusBadge( $truthBadge = $factory->statusBadge(
$rendered->stateLabel, $truth->primaryBadgeSpec()->label,
$rendered->overallGapCount > 0 ? 'warning' : 'success', $truth->primaryBadgeSpec()->color,
$truth->primaryBadgeSpec()->icon,
$truth->primaryBadgeSpec()->iconColor,
);
$lifecycleSpec = BadgeRenderer::spec(BadgeDomain::BaselineSnapshotLifecycle, $snapshot->lifecycleState()->value);
$lifecycleBadge = $factory->statusBadge(
$lifecycleSpec->label,
$lifecycleSpec->color,
$lifecycleSpec->icon,
$lifecycleSpec->iconColor,
); );
$fidelitySpec = BadgeRenderer::spec(BadgeDomain::BaselineSnapshotFidelity, $rendered->overallFidelity->value); $fidelitySpec = BadgeRenderer::spec(BadgeDomain::BaselineSnapshotFidelity, $rendered->overallFidelity->value);
@ -114,21 +129,37 @@ public function presentEnterpriseDetail(BaselineSnapshot $snapshot, array $relat
static fn (array $row): int => (int) ($row['itemCount'] ?? 0), static fn (array $row): int => (int) ($row['itemCount'] ?? 0),
$rendered->summaryRows, $rendered->summaryRows,
)); ));
$currentTruth = $this->currentTruthPresentation($truth);
$currentTruthBadge = $factory->statusBadge(
$currentTruth['label'],
$currentTruth['color'],
$currentTruth['icon'],
$currentTruth['iconColor'],
);
$operatorExplanation = $truth->operatorExplanation;
return EnterpriseDetailBuilder::make('baseline_snapshot', 'workspace') return EnterpriseDetailBuilder::make('baseline_snapshot', 'workspace')
->header(new SummaryHeaderData( ->header(new SummaryHeaderData(
title: $rendered->baselineProfileName ?? 'Baseline snapshot', title: $rendered->baselineProfileName ?? 'Baseline snapshot',
subtitle: 'Snapshot #'.$rendered->snapshotId, subtitle: 'Snapshot #'.$rendered->snapshotId,
statusBadges: [$stateBadge, $fidelityBadge], statusBadges: [$truthBadge, $lifecycleBadge, $fidelityBadge],
keyFacts: [ keyFacts: [
$factory->keyFact('Captured', $this->formatTimestamp($rendered->capturedAt)), $factory->keyFact('Captured', $this->formatTimestamp($rendered->capturedAt)),
$factory->keyFact('Current truth', $currentTruth['label'], badge: $currentTruthBadge),
$factory->keyFact('Evidence mix', $rendered->fidelitySummary), $factory->keyFact('Evidence mix', $rendered->fidelitySummary),
$factory->keyFact('Evidence gaps', $rendered->overallGapCount),
$factory->keyFact('Captured items', $capturedItemCount), $factory->keyFact('Captured items', $capturedItemCount),
], ],
descriptionHint: 'Capture context, coverage, and governance links stay ahead of technical payload detail.', descriptionHint: 'Current baseline truth, lifecycle proof, and coverage stay ahead of technical payload detail.',
)) ))
->addSection( ->addSection(
$factory->viewSection(
id: 'artifact_truth',
kind: 'current_status',
title: 'Artifact truth',
view: 'filament.infolists.entries.governance-artifact-truth',
viewData: ['state' => $truth->toArray()],
description: 'Trustworthy artifact state stays separate from historical trace and support diagnostics.',
),
$factory->viewSection( $factory->viewSection(
id: 'coverage_summary', id: 'coverage_summary',
kind: 'current_status', kind: 'current_status',
@ -160,11 +191,30 @@ public function presentEnterpriseDetail(BaselineSnapshot $snapshot, array $relat
->addSupportingCard( ->addSupportingCard(
$factory->supportingFactsCard( $factory->supportingFactsCard(
kind: 'status', kind: 'status',
title: 'Snapshot status', title: 'Snapshot truth',
items: array_values(array_filter([
$factory->keyFact('Artifact truth', $truth->primaryLabel, badge: $truthBadge),
$operatorExplanation !== null
? $factory->keyFact('Result meaning', $operatorExplanation->evaluationResultLabel())
: null,
$operatorExplanation !== null
? $factory->keyFact('Result trust', $operatorExplanation->trustworthinessLabel())
: null,
$factory->keyFact('Lifecycle', $lifecycleSpec->label, badge: $lifecycleBadge),
$factory->keyFact('Current truth', $currentTruth['label'], badge: $currentTruthBadge),
$operatorExplanation !== null && $operatorExplanation->coverageStatement !== null
? $factory->keyFact('Coverage', $operatorExplanation->coverageStatement)
: null,
$factory->keyFact('Next step', $operatorExplanation?->nextActionText ?? $truth->nextStepText()),
])),
),
$factory->supportingFactsCard(
kind: 'coverage',
title: 'Coverage',
items: [ items: [
$factory->keyFact('State', $rendered->stateLabel, badge: $stateBadge),
$factory->keyFact('Overall fidelity', $fidelitySpec->label, badge: $fidelityBadge), $factory->keyFact('Overall fidelity', $fidelitySpec->label, badge: $fidelityBadge),
$factory->keyFact('Evidence gaps', $rendered->overallGapCount), $factory->keyFact('Evidence gaps', $rendered->overallGapCount),
$factory->keyFact('Captured items', $capturedItemCount),
], ],
), ),
$factory->supportingFactsCard( $factory->supportingFactsCard(
@ -172,6 +222,8 @@ public function presentEnterpriseDetail(BaselineSnapshot $snapshot, array $relat
title: 'Capture timing', title: 'Capture timing',
items: [ items: [
$factory->keyFact('Captured', $this->formatTimestamp($rendered->capturedAt)), $factory->keyFact('Captured', $this->formatTimestamp($rendered->capturedAt)),
$factory->keyFact('Completed', $this->formatTimestamp($snapshot->completed_at?->toIso8601String())),
$factory->keyFact('Failed', $this->formatTimestamp($snapshot->failed_at?->toIso8601String())),
$factory->keyFact('Identity hash', $rendered->snapshotIdentityHash), $factory->keyFact('Identity hash', $rendered->snapshotIdentityHash),
], ],
), ),
@ -223,10 +275,6 @@ private function presentGroup(string $policyType, Collection $items): RenderedSn
$renderedItems, $renderedItems,
)); ));
if ($renderingError !== null) {
$gapSummary = $gapSummary->withMessage($renderingError);
}
$capturedAt = collect($renderedItems) $capturedAt = collect($renderedItems)
->pluck('observedAt') ->pluck('observedAt')
->filter(static fn (mixed $value): bool => is_string($value) && trim($value) !== '') ->filter(static fn (mixed $value): bool => is_string($value) && trim($value) !== '')
@ -276,10 +324,7 @@ private function technicalPayload(Collection $items): array
*/ */
private function summaryGapCount(array $summary): int private function summaryGapCount(array $summary): int
{ {
$gaps = is_array($summary['gaps'] ?? null) ? $summary['gaps'] : []; return $this->summaryGapSummary($summary)->count;
$count = $gaps['count'] ?? 0;
return is_numeric($count) ? (int) $count : 0;
} }
/** /**
@ -294,7 +339,67 @@ private function fidelitySummary(array $summary): string
$content = is_numeric($counts['content'] ?? null) ? (int) $counts['content'] : 0; $content = is_numeric($counts['content'] ?? null) ? (int) $counts['content'] : 0;
$meta = is_numeric($counts['meta'] ?? null) ? (int) $counts['meta'] : 0; $meta = is_numeric($counts['meta'] ?? null) ? (int) $counts['meta'] : 0;
return sprintf('Content %d, Meta %d', $content, $meta); return sprintf(
'%s %d, %s %d',
BadgeCatalog::spec(BadgeDomain::BaselineSnapshotFidelity, FidelityState::Full->value)->label,
$content,
BadgeCatalog::spec(BadgeDomain::BaselineSnapshotFidelity, FidelityState::ReferenceOnly->value)->label,
$meta,
);
}
/**
* @param array<string, mixed> $summary
*/
private function summaryGapSummary(array $summary): GapSummary
{
$gaps = is_array($summary['gaps'] ?? null) ? $summary['gaps'] : [];
$byReason = is_array($gaps['by_reason'] ?? null) ? $gaps['by_reason'] : [];
$gapSummary = GapSummary::fromReasonMap($byReason);
if ($byReason !== [] || ! is_numeric($gaps['count'] ?? null) || (int) $gaps['count'] <= 0) {
return $gapSummary;
}
return new GapSummary(
count: (int) $gaps['count'],
messages: ['Coverage gaps need review.'],
);
}
private function gapStatusSpec(int $gapCount): \App\Support\Badges\BadgeSpec
{
return BadgeRenderer::spec(
BadgeDomain::BaselineSnapshotGapStatus,
$gapCount > 0 ? 'gaps_present' : 'clear',
);
}
/**
* @return array{label: string, color: string, icon: string, iconColor: string}
*/
private function currentTruthPresentation(ArtifactTruthEnvelope $truth): array
{
return match ($truth->artifactExistence) {
'historical_only' => [
'label' => 'Historical trace',
'color' => 'gray',
'icon' => 'heroicon-m-clock',
'iconColor' => 'gray',
],
'created_but_not_usable' => [
'label' => 'Not compare input',
'color' => 'warning',
'icon' => 'heroicon-m-exclamation-triangle',
'iconColor' => 'warning',
],
default => [
'label' => 'Current baseline',
'color' => 'success',
'icon' => 'heroicon-m-check-badge',
'iconColor' => 'success',
],
};
} }
private function typeLabel(string $policyType): string private function typeLabel(string $policyType): string

View File

@ -95,9 +95,9 @@ public function coverageHint(): ?string
{ {
return match ($this) { return match ($this) {
self::Full => null, self::Full => null,
self::Partial => 'Mixed evidence fidelity across this group.', self::Partial => 'Mixed evidence detail is available for this group.',
self::ReferenceOnly => 'Metadata-only evidence is available.', self::ReferenceOnly => 'Metadata-only evidence is available for this group.',
self::Unsupported => 'Fallback metadata rendering is being used.', self::Unsupported => 'Support is limited for this policy type. Fallback rendering is being used.',
}; };
} }

View File

@ -49,7 +49,7 @@ public static function fromItemMeta(array $meta, FidelityState $fidelity, bool $
$messages = self::uniqueMessages($messages); $messages = self::uniqueMessages($messages);
return new self( return new self(
count: count($messages), count: 0,
messages: $messages, messages: $messages,
); );
} }
@ -60,17 +60,25 @@ public static function fromItemMeta(array $meta, FidelityState $fidelity, bool $
public static function fromReasonMap(array $reasons): self public static function fromReasonMap(array $reasons): self
{ {
$messages = []; $messages = [];
$primaryCount = 0;
foreach ($reasons as $reason => $count) { foreach ($reasons as $reason => $reasonCount) {
if (! is_string($reason) || ! is_numeric($count) || (int) $count <= 0) { if (! is_string($reason) || ! is_numeric($reasonCount) || (int) $reasonCount <= 0) {
continue; continue;
} }
$messages[] = sprintf('%s (%d)', self::humanizeReason($reason), (int) $count); if (self::isDiagnosticReason($reason)) {
$messages[] = sprintf('%s (%d)', self::diagnosticMessageForReason($reason), (int) $reasonCount);
continue;
}
$messages[] = sprintf('%s (%d)', self::humanizeReason($reason), (int) $reasonCount);
$primaryCount += (int) $reasonCount;
} }
return new self( return new self(
count: array_sum(array_map(static fn (mixed $value): int => is_numeric($value) ? (int) $value : 0, $reasons)), count: $primaryCount,
messages: self::uniqueMessages($messages), messages: self::uniqueMessages($messages),
); );
} }
@ -90,10 +98,6 @@ public static function merge(array $summaries): self
$messages = self::uniqueMessages($messages); $messages = self::uniqueMessages($messages);
if ($count === 0) {
$count = count($messages);
}
return new self( return new self(
count: $count, count: $count,
messages: $messages, messages: $messages,
@ -111,14 +115,14 @@ public function withMessage(string $message): self
$messages = self::uniqueMessages([...$this->messages, $message]); $messages = self::uniqueMessages([...$this->messages, $message]);
return new self( return new self(
count: max($this->count, count($messages)), count: $this->count,
messages: $messages, messages: $messages,
); );
} }
public function hasGaps(): bool public function hasGaps(): bool
{ {
return $this->count > 0 || $this->messages !== []; return $this->count > 0;
} }
public function badgeState(): string public function badgeState(): string
@ -158,4 +162,17 @@ private static function humanizeReason(string $reason): string
->headline() ->headline()
->toString(); ->toString();
} }
private static function isDiagnosticReason(string $reason): bool
{
return in_array($reason, ['meta_fallback'], true);
}
private static function diagnosticMessageForReason(string $reason): string
{
return match ($reason) {
'meta_fallback' => 'Metadata-only evidence was used for some items.',
default => self::humanizeReason($reason),
};
}
} }

View File

@ -23,7 +23,12 @@ public function __construct(
) {} ) {}
/** /**
* @return array{status:string,reason:?string,used_artifacts:bool} * @return array{
* status: string,
* reason: ?string,
* used_artifacts: bool,
* reason_translation: array<string, mixed>|null
* }
*/ */
public function check(Tenant $tenant): array public function check(Tenant $tenant): array
{ {
@ -105,10 +110,19 @@ public function check(Tenant $tenant): array
} }
/** /**
* @return array{status:string,reason:?string,used_artifacts:bool} * @return array{
* status: string,
* reason: ?string,
* used_artifacts: bool,
* reason_translation: array<string, mixed>|null
* }
*/ */
private function record(Tenant $tenant, string $status, ?string $reason, bool $usedArtifacts): array private function record(Tenant $tenant, string $status, ?string $reason, bool $usedArtifacts): array
{ {
$reasonTranslation = is_string($reason) && $reason !== ''
? RbacReason::tryFrom($reason)?->toReasonResolutionEnvelope('detail')->toArray()
: null;
$tenant->update([ $tenant->update([
'rbac_status' => $status, 'rbac_status' => $status,
'rbac_status_reason' => $reason, 'rbac_status_reason' => $reason,
@ -119,6 +133,7 @@ private function record(Tenant $tenant, string $status, ?string $reason, bool $u
'status' => $status, 'status' => $status,
'reason' => $reason, 'reason' => $reason,
'used_artifacts' => $usedArtifacts, 'used_artifacts' => $usedArtifacts,
'reason_translation' => $reasonTranslation,
]; ];
} }

View File

@ -17,11 +17,18 @@
use App\Support\OperationRunStatus; use App\Support\OperationRunStatus;
use App\Support\Operations\ExecutionAuthorityMode; use App\Support\Operations\ExecutionAuthorityMode;
use App\Support\Operations\ExecutionDenialReasonCode; use App\Support\Operations\ExecutionDenialReasonCode;
use App\Support\Operations\LifecycleReconciliationReason;
use App\Support\Operations\OperationRunCapabilityResolver; use App\Support\Operations\OperationRunCapabilityResolver;
use App\Support\Operations\QueuedExecutionLegitimacyDecision; use App\Support\Operations\QueuedExecutionLegitimacyDecision;
use App\Support\OpsUx\BulkRunContext; use App\Support\OpsUx\BulkRunContext;
use App\Support\OpsUx\RunFailureSanitizer; use App\Support\OpsUx\RunFailureSanitizer;
use App\Support\OpsUx\SummaryCountsNormalizer; use App\Support\OpsUx\SummaryCountsNormalizer;
use App\Support\Providers\ProviderReasonCodes;
use App\Support\RbacReason;
use App\Support\ReasonTranslation\NextStepOption;
use App\Support\ReasonTranslation\ReasonResolutionEnvelope;
use App\Support\ReasonTranslation\ReasonTranslator;
use App\Support\Tenants\TenantOperabilityReasonCode;
use Illuminate\Database\QueryException; use Illuminate\Database\QueryException;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
use InvalidArgumentException; use InvalidArgumentException;
@ -34,6 +41,7 @@ class OperationRunService
public function __construct( public function __construct(
private readonly AuditRecorder $auditRecorder, private readonly AuditRecorder $auditRecorder,
private readonly OperationRunCapabilityResolver $operationRunCapabilityResolver, private readonly OperationRunCapabilityResolver $operationRunCapabilityResolver,
private readonly ReasonTranslator $reasonTranslator,
) {} ) {}
public function isStaleQueuedRun(OperationRun $run, int $thresholdMinutes = 5): bool public function isStaleQueuedRun(OperationRun $run, int $thresholdMinutes = 5): bool
@ -55,15 +63,45 @@ public function isStaleQueuedRun(OperationRun $run, int $thresholdMinutes = 5):
public function failStaleQueuedRun(OperationRun $run, string $message = 'Run was queued but never started.'): OperationRun public function failStaleQueuedRun(OperationRun $run, string $message = 'Run was queued but never started.'): OperationRun
{ {
return $this->updateRun( return $this->forceFailNonTerminalRun(
$run, $run,
status: OperationRunStatus::Completed->value, reasonCode: LifecycleReconciliationReason::StaleQueued->value,
outcome: OperationRunOutcome::Failed->value, message: $message,
failures: [ source: 'scheduled_reconciler',
[ evidence: [
'code' => 'run.stale_queued', 'status' => OperationRunStatus::Queued->value,
'message' => $message, 'created_at' => $run->created_at?->toIso8601String(),
], ],
);
}
public function isStaleRunningRun(OperationRun $run, int $thresholdMinutes = 15): bool
{
if ($run->status !== OperationRunStatus::Running->value) {
return false;
}
$startedAt = $run->started_at ?? $run->created_at;
if ($startedAt === null) {
return false;
}
return $startedAt->lte(now()->subMinutes(max(1, $thresholdMinutes)));
}
public function failStaleRunningRun(
OperationRun $run,
string $message = 'Run stopped reporting progress and was marked failed.',
): OperationRun {
return $this->forceFailNonTerminalRun(
$run,
reasonCode: LifecycleReconciliationReason::StaleRunning->value,
message: $message,
source: 'scheduled_reconciler',
evidence: [
'status' => OperationRunStatus::Running->value,
'started_at' => ($run->started_at ?? $run->created_at)?->toIso8601String(),
], ],
); );
} }
@ -487,6 +525,16 @@ public function updateRun(
$updateData['failure_summary'] = $this->sanitizeFailures($failures); $updateData['failure_summary'] = $this->sanitizeFailures($failures);
} }
$updatedContext = $this->withReasonTranslationContext(
run: $run,
context: is_array($run->context) ? $run->context : [],
failures: is_array($updateData['failure_summary'] ?? null) ? $updateData['failure_summary'] : [],
);
if ($updatedContext !== null) {
$updateData['context'] = $updatedContext;
}
if ($status === OperationRunStatus::Running->value && is_null($run->started_at)) { if ($status === OperationRunStatus::Running->value && is_null($run->started_at)) {
$updateData['started_at'] = now(); $updateData['started_at'] = now();
} }
@ -704,6 +752,136 @@ public function failRun(OperationRun $run, Throwable $e): OperationRun
); );
} }
/**
* @param array<string, mixed> $evidence
* @param array<string, mixed> $summaryCounts
*/
public function forceFailNonTerminalRun(
OperationRun $run,
string $reasonCode,
string $message,
string $source = 'scheduled_reconciler',
array $evidence = [],
array $summaryCounts = [],
): OperationRun {
return $this->updateRunWithReconciliation(
run: $run,
status: OperationRunStatus::Completed->value,
outcome: OperationRunOutcome::Failed->value,
summaryCounts: $summaryCounts,
failures: [[
'code' => $reasonCode,
'reason_code' => $reasonCode,
'message' => $message,
]],
reasonCode: $reasonCode,
reasonMessage: $message,
source: $source,
evidence: $evidence,
);
}
public function bridgeFailedJobFailure(
OperationRun $run,
Throwable $exception,
string $source = 'failed_callback',
): OperationRun {
$reason = $this->bridgeReasonForThrowable($exception);
$message = $reason->defaultMessage();
$exceptionMessage = $this->sanitizeMessage($exception->getMessage());
if ($exceptionMessage !== '') {
$message = $exceptionMessage;
}
return $this->forceFailNonTerminalRun(
$run,
reasonCode: $reason->value,
message: $message,
source: $source,
evidence: [
'exception_class' => $exception::class,
'bridge_source' => $source,
],
);
}
/**
* @param array<string, mixed> $summaryCounts
* @param array<int, array{code?: mixed, reason_code?: mixed, message?: mixed}> $failures
* @param array<string, mixed> $evidence
*/
public function updateRunWithReconciliation(
OperationRun $run,
string $status,
string $outcome,
array $summaryCounts,
array $failures,
string $reasonCode,
string $reasonMessage,
string $source = 'scheduled_reconciler',
array $evidence = [],
): OperationRun {
/** @var OperationRun $updated */
$updated = DB::transaction(function () use (
$run,
$status,
$outcome,
$summaryCounts,
$failures,
$reasonCode,
$reasonMessage,
$source,
$evidence,
): OperationRun {
$locked = OperationRun::query()
->whereKey($run->getKey())
->lockForUpdate()
->first();
if (! $locked instanceof OperationRun) {
return $run;
}
if ((string) $locked->status === OperationRunStatus::Completed->value) {
return $locked;
}
$context = is_array($locked->context) ? $locked->context : [];
$context['reason_code'] = RunFailureSanitizer::normalizeReasonCode($reasonCode);
$context['reconciliation'] = $this->reconciliationMetadata(
reasonCode: $reasonCode,
reasonMessage: $reasonMessage,
source: $source,
evidence: $evidence,
);
$translatedContext = $this->withReasonTranslationContext(
run: $locked,
context: $context,
failures: $failures,
);
$locked->update([
'context' => $translatedContext ?? $context,
]);
$locked->refresh();
return $this->updateRun(
$locked,
status: $status,
outcome: $outcome,
summaryCounts: $summaryCounts,
failures: $failures,
);
});
$updated->refresh();
return $updated;
}
/** /**
* Finalize a run as blocked with deterministic reason_code + link-only next steps. * Finalize a run as blocked with deterministic reason_code + link-only next steps.
* *
@ -721,6 +899,13 @@ public function finalizeBlockedRun(
$context = is_array($run->context) ? $run->context : []; $context = is_array($run->context) ? $run->context : [];
$context['reason_code'] = $reasonCode; $context['reason_code'] = $reasonCode;
$context['next_steps'] = $nextSteps; $context['next_steps'] = $nextSteps;
$context = $this->withReasonTranslationContext(
run: $run,
context: $context,
failures: [[
'reason_code' => $reasonCode,
]],
) ?? $context;
$summaryCounts = $this->sanitizeSummaryCounts(is_array($run->summary_counts ?? null) ? $run->summary_counts : []); $summaryCounts = $this->sanitizeSummaryCounts(is_array($run->summary_counts ?? null) ? $run->summary_counts : []);
$run->update([ $run->update([
@ -943,12 +1128,115 @@ protected function sanitizeNextSteps(array $nextSteps): array
return $sanitized; return $sanitized;
} }
/**
* @param array<string, mixed> $context
* @param array<int, array{code?: string, reason_code?: string, message?: string}> $failures
* @return array<string, mixed>|null
*/
private function withReasonTranslationContext(OperationRun $run, array $context, array $failures): ?array
{
$reasonCode = $this->resolveReasonCode($context, $failures);
if ($reasonCode === null) {
return null;
}
$hasExplicitContextReason = is_string(data_get($context, 'execution_legitimacy.reason_code'))
|| is_string(data_get($context, 'reason_code'));
if (! $hasExplicitContextReason && ! $this->isDirectlyTranslatableReason($reasonCode)) {
return null;
}
$translation = $this->reasonTranslator->translate($reasonCode, surface: 'notification', context: $context);
if (! $translation instanceof ReasonResolutionEnvelope) {
return null;
}
$legacyNextSteps = is_array($context['next_steps'] ?? null) ? NextStepOption::collect($context['next_steps']) : [];
if ($translation->nextSteps === [] && $legacyNextSteps !== []) {
$translation = $translation->withNextSteps($legacyNextSteps);
}
$context['reason_translation'] = $translation->toArray();
if ($translation->toLegacyNextSteps() !== [] && empty($context['next_steps'])) {
$context['next_steps'] = $translation->toLegacyNextSteps();
}
return $context;
}
/**
* @param array<string, mixed> $context
* @param array<int, array{code?: string, reason_code?: string, message?: string}> $failures
*/
private function resolveReasonCode(array $context, array $failures): ?string
{
$reasonCode = data_get($context, 'execution_legitimacy.reason_code')
?? data_get($context, 'reason_code')
?? data_get($failures, '0.reason_code');
if (! is_string($reasonCode) || trim($reasonCode) === '') {
return null;
}
return trim($reasonCode);
}
private function isDirectlyTranslatableReason(string $reasonCode): bool
{
if ($reasonCode === ProviderReasonCodes::UnknownError) {
return false;
}
return ProviderReasonCodes::isKnown($reasonCode)
|| ExecutionDenialReasonCode::tryFrom($reasonCode) instanceof ExecutionDenialReasonCode
|| LifecycleReconciliationReason::tryFrom($reasonCode) instanceof LifecycleReconciliationReason
|| TenantOperabilityReasonCode::tryFrom($reasonCode) instanceof TenantOperabilityReasonCode
|| RbacReason::tryFrom($reasonCode) instanceof RbacReason;
}
/**
* @param array<string, mixed> $evidence
* @return array<string, mixed>
*/
private function reconciliationMetadata(
string $reasonCode,
string $reasonMessage,
string $source,
array $evidence,
): array {
return [
'reconciled_at' => now()->toIso8601String(),
'reason' => RunFailureSanitizer::normalizeReasonCode($reasonCode),
'reason_code' => RunFailureSanitizer::normalizeReasonCode($reasonCode),
'reason_message' => $this->sanitizeMessage($reasonMessage),
'source' => $this->sanitizeFailureCode($source),
'evidence' => $evidence,
];
}
private function bridgeReasonForThrowable(Throwable $exception): LifecycleReconciliationReason
{
$className = strtolower(class_basename($exception));
if (str_contains($className, 'timeout') || str_contains($className, 'attempts')) {
return LifecycleReconciliationReason::InfrastructureTimeoutOrAbandonment;
}
return LifecycleReconciliationReason::QueueFailureBridge;
}
private function writeTerminalAudit(OperationRun $run): void private function writeTerminalAudit(OperationRun $run): void
{ {
$tenant = $run->tenant; $tenant = $run->tenant;
$workspace = $run->workspace; $workspace = $run->workspace;
$context = is_array($run->context) ? $run->context : []; $context = is_array($run->context) ? $run->context : [];
$executionLegitimacy = is_array($context['execution_legitimacy'] ?? null) ? $context['execution_legitimacy'] : []; $executionLegitimacy = is_array($context['execution_legitimacy'] ?? null) ? $context['execution_legitimacy'] : [];
$reconciliation = is_array($context['reconciliation'] ?? null) ? $context['reconciliation'] : [];
$operationLabel = OperationCatalog::label((string) $run->type); $operationLabel = OperationCatalog::label((string) $run->type);
$action = match ($run->outcome) { $action = match ($run->outcome) {
@ -978,6 +1266,7 @@ private function writeTerminalAudit(OperationRun $run): void
'authority_mode' => $executionLegitimacy['authority_mode'] ?? ($context['execution_authority_mode'] ?? null), 'authority_mode' => $executionLegitimacy['authority_mode'] ?? ($context['execution_authority_mode'] ?? null),
'acting_identity_type' => $executionLegitimacy['initiator']['identity_type'] ?? ($run->user instanceof User ? 'user' : 'system'), 'acting_identity_type' => $executionLegitimacy['initiator']['identity_type'] ?? ($run->user instanceof User ? 'user' : 'system'),
'blocked_by' => $context['blocked_by'] ?? null, 'blocked_by' => $context['blocked_by'] ?? null,
'reconciliation' => $reconciliation !== [] ? $reconciliation : null,
], ],
], ],
workspace: $workspace, workspace: $workspace,

View File

@ -0,0 +1,139 @@
<?php
declare(strict_types=1);
namespace App\Services\Operations;
use App\Jobs\Concerns\BridgesFailedOperationRun;
use App\Support\Operations\OperationLifecyclePolicy;
use RuntimeException;
final class OperationLifecyclePolicyValidator
{
public function __construct(
private readonly OperationLifecyclePolicy $policy,
) {}
/**
* @return array{
* valid:bool,
* errors:array<int, string>,
* definitions:array<string, array<string, mixed>>
* }
*/
public function validate(): array
{
$errors = [];
$definitions = [];
foreach ($this->policy->coveredTypeNames() as $operationType) {
$definition = $this->policy->definition($operationType);
if ($definition === null) {
$errors[] = sprintf('Missing lifecycle policy definition for [%s].', $operationType);
continue;
}
$definitions[$operationType] = $definition;
$jobClass = $this->policy->jobClass($operationType);
if ($jobClass === null || ! class_exists($jobClass)) {
$errors[] = sprintf('Lifecycle policy [%s] points to a missing job class.', $operationType);
continue;
}
$timeout = $this->jobTimeoutSeconds($operationType);
if (! is_int($timeout) || $timeout <= 0) {
$errors[] = sprintf('Lifecycle policy [%s] requires an explicit positive job timeout.', $operationType);
}
if (! $this->jobFailsOnTimeout($operationType)) {
$errors[] = sprintf('Lifecycle policy [%s] requires failOnTimeout=true.', $operationType);
}
if ($this->policy->requiresDirectFailedBridge($operationType) && ! $this->jobUsesDirectFailedBridge($operationType)) {
$errors[] = sprintf('Lifecycle policy [%s] requires a direct failed-job bridge.', $operationType);
}
$retryAfter = $this->policy->queueRetryAfterSeconds($this->policy->queueConnection($operationType));
$safetyMargin = $this->policy->retryAfterSafetyMarginSeconds();
if (is_int($timeout) && is_int($retryAfter) && $timeout >= ($retryAfter - $safetyMargin)) {
$errors[] = sprintf(
'Lifecycle policy [%s] has timeout %d which is not safely below retry_after %d (margin %d).',
$operationType,
$timeout,
$retryAfter,
$safetyMargin,
);
}
$expectedMaxRuntime = $this->policy->expectedMaxRuntimeSeconds($operationType);
if (is_int($expectedMaxRuntime) && is_int($retryAfter) && $expectedMaxRuntime >= ($retryAfter - $safetyMargin)) {
$errors[] = sprintf(
'Lifecycle policy [%s] expected runtime %d is not safely below retry_after %d (margin %d).',
$operationType,
$expectedMaxRuntime,
$retryAfter,
$safetyMargin,
);
}
}
return [
'valid' => $errors === [],
'errors' => $errors,
'definitions' => $definitions,
];
}
public function assertValid(): void
{
$result = $this->validate();
if (($result['valid'] ?? false) === true) {
return;
}
throw new RuntimeException(implode(' ', $result['errors'] ?? ['Lifecycle policy validation failed.']));
}
public function jobTimeoutSeconds(string $operationType): ?int
{
$jobClass = $this->policy->jobClass($operationType);
if ($jobClass === null || ! class_exists($jobClass)) {
return null;
}
$timeout = get_class_vars($jobClass)['timeout'] ?? null;
return is_numeric($timeout) ? (int) $timeout : null;
}
public function jobFailsOnTimeout(string $operationType): bool
{
$jobClass = $this->policy->jobClass($operationType);
if ($jobClass === null || ! class_exists($jobClass)) {
return false;
}
return (bool) (get_class_vars($jobClass)['failOnTimeout'] ?? false);
}
public function jobUsesDirectFailedBridge(string $operationType): bool
{
$jobClass = $this->policy->jobClass($operationType);
if ($jobClass === null || ! class_exists($jobClass)) {
return false;
}
return in_array(BridgesFailedOperationRun::class, class_uses_recursive($jobClass), true);
}
}

View File

@ -0,0 +1,200 @@
<?php
declare(strict_types=1);
namespace App\Services\Operations;
use App\Models\OperationRun;
use App\Services\OperationRunService;
use App\Support\OperationRunOutcome;
use App\Support\OperationRunStatus;
use App\Support\Operations\LifecycleReconciliationReason;
use App\Support\Operations\OperationLifecyclePolicy;
use App\Support\Operations\OperationRunFreshnessState;
use Illuminate\Database\Eloquent\Builder;
final class OperationLifecycleReconciler
{
public function __construct(
private readonly OperationLifecyclePolicy $policy,
private readonly OperationRunService $operationRunService,
private readonly QueuedExecutionLegitimacyGate $queuedExecutionLegitimacyGate,
) {}
/**
* @param array{
* types?: array<int, string>,
* tenant_ids?: array<int, int>,
* workspace_ids?: array<int, int>,
* limit?: int,
* dry_run?: bool
* } $options
* @return array{candidates:int,reconciled:int,skipped:int,changes:array<int, array<string, mixed>>}
*/
public function reconcile(array $options = []): array
{
$types = array_values(array_filter(
$options['types'] ?? $this->policy->coveredTypeNames(),
static fn (mixed $type): bool => is_string($type) && trim($type) !== '',
));
$tenantIds = array_values(array_filter(
$options['tenant_ids'] ?? [],
static fn (mixed $tenantId): bool => is_int($tenantId) && $tenantId > 0,
));
$workspaceIds = array_values(array_filter(
$options['workspace_ids'] ?? [],
static fn (mixed $workspaceId): bool => is_int($workspaceId) && $workspaceId > 0,
));
$limit = min(max(1, (int) ($options['limit'] ?? $this->policy->reconciliationBatchLimit())), 500);
$dryRun = (bool) ($options['dry_run'] ?? false);
$runs = OperationRun::query()
->with(['tenant', 'user'])
->whereIn('type', $types)
->whereIn('status', [
OperationRunStatus::Queued->value,
OperationRunStatus::Running->value,
])
->when(
$tenantIds !== [],
fn (Builder $query): Builder => $query->whereIn('tenant_id', $tenantIds),
)
->when(
$workspaceIds !== [],
fn (Builder $query): Builder => $query->whereIn('workspace_id', $workspaceIds),
)
->orderBy('id')
->limit($limit)
->get();
$changes = [];
$reconciled = 0;
$skipped = 0;
foreach ($runs as $run) {
$change = $this->reconcileRun($run, $dryRun);
if ($change === null) {
$skipped++;
continue;
}
$changes[] = $change;
if (($change['applied'] ?? false) === true) {
$reconciled++;
}
}
return [
'candidates' => $runs->count(),
'reconciled' => $reconciled,
'skipped' => $skipped,
'changes' => $changes,
];
}
/**
* @return array<string, mixed>|null
*/
public function reconcileRun(OperationRun $run, bool $dryRun = false): ?array
{
$assessment = $this->assessment($run);
if ($assessment === null || ($assessment['should_reconcile'] ?? false) !== true) {
return null;
}
$before = [
'status' => (string) $run->status,
'outcome' => (string) $run->outcome,
'freshness_state' => OperationRunFreshnessState::forRun($run, $this->policy)->value,
];
$after = [
'status' => OperationRunStatus::Completed->value,
'outcome' => OperationRunOutcome::Failed->value,
'freshness_state' => OperationRunFreshnessState::ReconciledFailed->value,
];
if ($dryRun) {
return [
'applied' => false,
'operation_run_id' => (int) $run->getKey(),
'type' => (string) $run->type,
'before' => $before,
'after' => $after,
'reason_code' => $assessment['reason_code'],
'reason_message' => $assessment['reason_message'],
'evidence' => $assessment['evidence'],
];
}
$updated = $this->operationRunService->forceFailNonTerminalRun(
run: $run,
reasonCode: (string) $assessment['reason_code'],
message: (string) $assessment['reason_message'],
source: 'scheduled_reconciler',
evidence: is_array($assessment['evidence'] ?? null) ? $assessment['evidence'] : [],
);
return [
'applied' => true,
'operation_run_id' => (int) $updated->getKey(),
'type' => (string) $updated->type,
'before' => $before,
'after' => $after,
'reason_code' => $assessment['reason_code'],
'reason_message' => $assessment['reason_message'],
'evidence' => $assessment['evidence'],
];
}
/**
* @return array{should_reconcile:bool,reason_code:string,reason_message:string,evidence:array<string, mixed>}|null
*/
public function assessment(OperationRun $run): ?array
{
if ((string) $run->status === OperationRunStatus::Completed->value) {
return null;
}
if (! $this->policy->supports((string) $run->type) || ! $this->policy->supportsScheduledReconciliation((string) $run->type)) {
return null;
}
$freshnessState = OperationRunFreshnessState::forRun($run, $this->policy);
if (! $freshnessState->isLikelyStale()) {
return null;
}
$reason = (string) $run->status === OperationRunStatus::Queued->value
? LifecycleReconciliationReason::StaleQueued
: LifecycleReconciliationReason::StaleRunning;
$referenceTime = (string) $run->status === OperationRunStatus::Queued->value
? $run->created_at
: ($run->started_at ?? $run->created_at);
$thresholdSeconds = (string) $run->status === OperationRunStatus::Queued->value
? $this->policy->queuedStaleAfterSeconds((string) $run->type)
: $this->policy->runningStaleAfterSeconds((string) $run->type);
$legitimacy = $this->queuedExecutionLegitimacyGate->evaluate($run)->toArray();
return [
'should_reconcile' => true,
'reason_code' => $reason->value,
'reason_message' => $reason->defaultMessage(),
'evidence' => [
'evaluated_at' => now()->toIso8601String(),
'freshness_state' => $freshnessState->value,
'threshold_seconds' => $thresholdSeconds,
'reference_time' => $referenceTime?->toIso8601String(),
'status' => (string) $run->status,
'execution_legitimacy' => $legitimacy,
'terminal_truth_path' => $this->policy->requiresDirectFailedBridge((string) $run->type)
? 'direct_and_scheduled'
: 'scheduled_only',
],
];
}
}

View File

@ -8,6 +8,7 @@
use App\Models\TenantOnboardingSession; use App\Models\TenantOnboardingSession;
use App\Models\User; use App\Models\User;
use App\Services\Auth\CapabilityResolver; use App\Services\Auth\CapabilityResolver;
use App\Support\ReasonTranslation\ReasonResolutionEnvelope;
use App\Support\Tenants\TenantInteractionLane; use App\Support\Tenants\TenantInteractionLane;
use App\Support\Tenants\TenantLifecycle; use App\Support\Tenants\TenantLifecycle;
use App\Support\Tenants\TenantOperabilityContext; use App\Support\Tenants\TenantOperabilityContext;
@ -217,6 +218,11 @@ public function canReferenceInWorkspaceMonitoring(Tenant $tenant): bool
)->allowed; )->allowed;
} }
public function presentReason(TenantOperabilityOutcome $outcome): ?ReasonResolutionEnvelope
{
return $outcome->reasonCode?->toReasonResolutionEnvelope('detail');
}
/** /**
* @param Collection<int, Tenant> $tenants * @param Collection<int, Tenant> $tenants
* @return Collection<int, Tenant> * @return Collection<int, Tenant>

View File

@ -15,6 +15,14 @@ final class BadgeCatalog
private const DOMAIN_MAPPERS = [ private const DOMAIN_MAPPERS = [
BadgeDomain::AuditOutcome->value => Domains\AuditOutcomeBadge::class, BadgeDomain::AuditOutcome->value => Domains\AuditOutcomeBadge::class,
BadgeDomain::AuditActorType->value => Domains\AuditActorTypeBadge::class, BadgeDomain::AuditActorType->value => Domains\AuditActorTypeBadge::class,
BadgeDomain::GovernanceArtifactExistence->value => Domains\GovernanceArtifactExistenceBadge::class,
BadgeDomain::GovernanceArtifactContent->value => Domains\GovernanceArtifactContentBadge::class,
BadgeDomain::GovernanceArtifactFreshness->value => Domains\GovernanceArtifactFreshnessBadge::class,
BadgeDomain::GovernanceArtifactPublicationReadiness->value => Domains\GovernanceArtifactPublicationReadinessBadge::class,
BadgeDomain::GovernanceArtifactActionability->value => Domains\GovernanceArtifactActionabilityBadge::class,
BadgeDomain::OperatorExplanationEvaluationResult->value => Domains\OperatorExplanationEvaluationResultBadge::class,
BadgeDomain::OperatorExplanationTrustworthiness->value => Domains\OperatorExplanationTrustworthinessBadge::class,
BadgeDomain::BaselineSnapshotLifecycle->value => Domains\BaselineSnapshotLifecycleBadge::class,
BadgeDomain::BaselineSnapshotFidelity->value => Domains\BaselineSnapshotFidelityBadge::class, BadgeDomain::BaselineSnapshotFidelity->value => Domains\BaselineSnapshotFidelityBadge::class,
BadgeDomain::BaselineSnapshotGapStatus->value => Domains\BaselineSnapshotGapStatusBadge::class, BadgeDomain::BaselineSnapshotGapStatus->value => Domains\BaselineSnapshotGapStatusBadge::class,
BadgeDomain::OperationRunStatus->value => Domains\OperationRunStatusBadge::class, BadgeDomain::OperationRunStatus->value => Domains\OperationRunStatusBadge::class,
@ -93,6 +101,27 @@ public static function mapper(BadgeDomain $domain): ?BadgeMapper
return $mapper; return $mapper;
} }
/**
* @param iterable<mixed> $values
* @return array<string, string>
*/
public static function options(BadgeDomain $domain, iterable $values): array
{
$options = [];
foreach ($values as $value) {
$normalized = self::normalizeState($value);
if ($normalized === null) {
continue;
}
$options[$normalized] = self::spec($domain, $value)->label;
}
return $options;
}
public static function normalizeState(mixed $value): ?string public static function normalizeState(mixed $value): ?string
{ {
if ($value === null) { if ($value === null) {

View File

@ -6,6 +6,14 @@ enum BadgeDomain: string
{ {
case AuditOutcome = 'audit_outcome'; case AuditOutcome = 'audit_outcome';
case AuditActorType = 'audit_actor_type'; case AuditActorType = 'audit_actor_type';
case GovernanceArtifactExistence = 'governance_artifact_existence';
case GovernanceArtifactContent = 'governance_artifact_content';
case GovernanceArtifactFreshness = 'governance_artifact_freshness';
case GovernanceArtifactPublicationReadiness = 'governance_artifact_publication_readiness';
case GovernanceArtifactActionability = 'governance_artifact_actionability';
case OperatorExplanationEvaluationResult = 'operator_explanation_evaluation_result';
case OperatorExplanationTrustworthiness = 'operator_explanation_trustworthiness';
case BaselineSnapshotLifecycle = 'baseline_snapshot_lifecycle';
case BaselineSnapshotFidelity = 'baseline_snapshot_fidelity'; case BaselineSnapshotFidelity = 'baseline_snapshot_fidelity';
case BaselineSnapshotGapStatus = 'baseline_snapshot_gap_status'; case BaselineSnapshotGapStatus = 'baseline_snapshot_gap_status';
case OperationRunStatus = 'operation_run_status'; case OperationRunStatus = 'operation_run_status';

View File

@ -23,6 +23,15 @@ public function __construct(
public readonly string $color, public readonly string $color,
public readonly ?string $icon = null, public readonly ?string $icon = null,
public readonly ?string $iconColor = null, public readonly ?string $iconColor = null,
public readonly ?OperatorSemanticAxis $semanticAxis = null,
public readonly ?OperatorStateClassification $classification = null,
public readonly ?OperatorNextActionPolicy $nextActionPolicy = null,
public readonly ?string $diagnosticLabel = null,
/**
* @var list<string>
*/
public readonly array $legacyAliases = [],
public readonly ?string $notes = null,
) { ) {
if (trim($this->label) === '') { if (trim($this->label) === '') {
throw new InvalidArgumentException('BadgeSpec label must be a non-empty string.'); throw new InvalidArgumentException('BadgeSpec label must be a non-empty string.');
@ -39,6 +48,41 @@ public function __construct(
if ($this->iconColor !== null && ! in_array($this->iconColor, self::ALLOWED_COLORS, true)) { if ($this->iconColor !== null && ! in_array($this->iconColor, self::ALLOWED_COLORS, true)) {
throw new InvalidArgumentException('BadgeSpec iconColor must be null or one of: '.implode(', ', self::ALLOWED_COLORS)); throw new InvalidArgumentException('BadgeSpec iconColor must be null or one of: '.implode(', ', self::ALLOWED_COLORS));
} }
$hasTaxonomyMetadata = $this->semanticAxis !== null
|| $this->classification !== null
|| $this->nextActionPolicy !== null
|| $this->diagnosticLabel !== null
|| $this->legacyAliases !== []
|| $this->notes !== null;
if ($hasTaxonomyMetadata && ($this->semanticAxis === null || $this->classification === null || $this->nextActionPolicy === null)) {
throw new InvalidArgumentException('BadgeSpec taxonomy metadata requires semanticAxis, classification, and nextActionPolicy together.');
}
if ($this->diagnosticLabel !== null && trim($this->diagnosticLabel) === '') {
throw new InvalidArgumentException('BadgeSpec diagnosticLabel must be null or a non-empty string.');
}
foreach ($this->legacyAliases as $legacyAlias) {
if (! is_string($legacyAlias) || trim($legacyAlias) === '') {
throw new InvalidArgumentException('BadgeSpec legacyAliases must contain only non-empty strings.');
}
}
if ($this->notes !== null && trim($this->notes) === '') {
throw new InvalidArgumentException('BadgeSpec notes must be null or a non-empty string.');
}
if ($this->classification === OperatorStateClassification::Diagnostic && in_array($this->color, ['warning', 'danger'], true)) {
throw new InvalidArgumentException('Diagnostic badge specs cannot use warning or danger colors.');
}
if ($this->classification === OperatorStateClassification::Primary
&& in_array($this->color, ['warning', 'danger'], true)
&& $this->nextActionPolicy === OperatorNextActionPolicy::None) {
throw new InvalidArgumentException('Primary warning or danger badge specs must declare an operator next-action policy.');
}
} }
/** /**

View File

@ -6,8 +6,10 @@
use App\Services\Baselines\SnapshotRendering\FidelityState; use App\Services\Baselines\SnapshotRendering\FidelityState;
use App\Support\Badges\BadgeCatalog; use App\Support\Badges\BadgeCatalog;
use App\Support\Badges\BadgeDomain;
use App\Support\Badges\BadgeMapper; use App\Support\Badges\BadgeMapper;
use App\Support\Badges\BadgeSpec; use App\Support\Badges\BadgeSpec;
use App\Support\Badges\OperatorOutcomeTaxonomy;
final class BaselineSnapshotFidelityBadge implements BadgeMapper final class BaselineSnapshotFidelityBadge implements BadgeMapper
{ {
@ -16,11 +18,11 @@ public function spec(mixed $value): BadgeSpec
$state = BadgeCatalog::normalizeState($value); $state = BadgeCatalog::normalizeState($value);
return match ($state) { return match ($state) {
FidelityState::Full->value => new BadgeSpec('Full', 'success', 'heroicon-m-check-circle'), FidelityState::Full->value => OperatorOutcomeTaxonomy::spec(BadgeDomain::BaselineSnapshotFidelity, $state, 'heroicon-m-check-circle'),
FidelityState::Partial->value => new BadgeSpec('Partial', 'warning', 'heroicon-m-exclamation-triangle'), FidelityState::Partial->value => OperatorOutcomeTaxonomy::spec(BadgeDomain::BaselineSnapshotFidelity, $state, 'heroicon-m-document-magnifying-glass'),
FidelityState::ReferenceOnly->value => new BadgeSpec('Reference only', 'info', 'heroicon-m-document-text'), FidelityState::ReferenceOnly->value => OperatorOutcomeTaxonomy::spec(BadgeDomain::BaselineSnapshotFidelity, $state, 'heroicon-m-document-text'),
FidelityState::Unsupported->value => new BadgeSpec('Unsupported', 'gray', 'heroicon-m-question-mark-circle'), FidelityState::Unsupported->value => OperatorOutcomeTaxonomy::spec(BadgeDomain::BaselineSnapshotFidelity, $state, 'heroicon-m-question-mark-circle'),
default => BadgeSpec::unknown(), default => BadgeSpec::unknown(),
}; } ?? BadgeSpec::unknown();
} }
} }

View File

@ -5,8 +5,10 @@
namespace App\Support\Badges\Domains; namespace App\Support\Badges\Domains;
use App\Support\Badges\BadgeCatalog; use App\Support\Badges\BadgeCatalog;
use App\Support\Badges\BadgeDomain;
use App\Support\Badges\BadgeMapper; use App\Support\Badges\BadgeMapper;
use App\Support\Badges\BadgeSpec; use App\Support\Badges\BadgeSpec;
use App\Support\Badges\OperatorOutcomeTaxonomy;
final class BaselineSnapshotGapStatusBadge implements BadgeMapper final class BaselineSnapshotGapStatusBadge implements BadgeMapper
{ {
@ -15,9 +17,9 @@ public function spec(mixed $value): BadgeSpec
$state = BadgeCatalog::normalizeState($value); $state = BadgeCatalog::normalizeState($value);
return match ($state) { return match ($state) {
'clear' => new BadgeSpec('No gaps', 'success', 'heroicon-m-check-circle'), 'clear' => OperatorOutcomeTaxonomy::spec(BadgeDomain::BaselineSnapshotGapStatus, $state, 'heroicon-m-check-circle'),
'gaps_present' => new BadgeSpec('Gaps present', 'warning', 'heroicon-m-exclamation-triangle'), 'gaps_present' => OperatorOutcomeTaxonomy::spec(BadgeDomain::BaselineSnapshotGapStatus, $state, 'heroicon-m-exclamation-triangle'),
default => BadgeSpec::unknown(), default => BadgeSpec::unknown(),
}; } ?? BadgeSpec::unknown();
} }
} }

View File

@ -0,0 +1,28 @@
<?php
declare(strict_types=1);
namespace App\Support\Badges\Domains;
use App\Support\Badges\BadgeCatalog;
use App\Support\Badges\BadgeDomain;
use App\Support\Badges\BadgeMapper;
use App\Support\Badges\BadgeSpec;
use App\Support\Badges\OperatorOutcomeTaxonomy;
use App\Support\Baselines\BaselineSnapshotLifecycleState;
final class BaselineSnapshotLifecycleBadge implements BadgeMapper
{
public function spec(mixed $value): BadgeSpec
{
$state = BadgeCatalog::normalizeState($value);
return match ($state) {
BaselineSnapshotLifecycleState::Building->value => OperatorOutcomeTaxonomy::spec(BadgeDomain::BaselineSnapshotLifecycle, $state, 'heroicon-m-arrow-path'),
BaselineSnapshotLifecycleState::Complete->value => OperatorOutcomeTaxonomy::spec(BadgeDomain::BaselineSnapshotLifecycle, $state, 'heroicon-m-check-circle'),
BaselineSnapshotLifecycleState::Incomplete->value => OperatorOutcomeTaxonomy::spec(BadgeDomain::BaselineSnapshotLifecycle, $state, 'heroicon-m-x-circle'),
'superseded' => OperatorOutcomeTaxonomy::spec(BadgeDomain::BaselineSnapshotLifecycle, $state, 'heroicon-m-clock'),
default => BadgeSpec::unknown(),
} ?? BadgeSpec::unknown();
}
}

View File

@ -5,8 +5,10 @@
namespace App\Support\Badges\Domains; namespace App\Support\Badges\Domains;
use App\Support\Badges\BadgeCatalog; use App\Support\Badges\BadgeCatalog;
use App\Support\Badges\BadgeDomain;
use App\Support\Badges\BadgeMapper; use App\Support\Badges\BadgeMapper;
use App\Support\Badges\BadgeSpec; use App\Support\Badges\BadgeSpec;
use App\Support\Badges\OperatorOutcomeTaxonomy;
use App\Support\Evidence\EvidenceCompletenessState; use App\Support\Evidence\EvidenceCompletenessState;
final class EvidenceCompletenessBadge implements BadgeMapper final class EvidenceCompletenessBadge implements BadgeMapper
@ -16,11 +18,11 @@ public function spec(mixed $value): BadgeSpec
$state = BadgeCatalog::normalizeState($value); $state = BadgeCatalog::normalizeState($value);
return match ($state) { return match ($state) {
EvidenceCompletenessState::Complete->value => new BadgeSpec('Complete', 'success', 'heroicon-m-check-badge'), EvidenceCompletenessState::Complete->value => OperatorOutcomeTaxonomy::spec(BadgeDomain::EvidenceCompleteness, $state, 'heroicon-m-check-badge'),
EvidenceCompletenessState::Partial->value => new BadgeSpec('Partial', 'warning', 'heroicon-m-exclamation-triangle'), EvidenceCompletenessState::Partial->value => OperatorOutcomeTaxonomy::spec(BadgeDomain::EvidenceCompleteness, $state, 'heroicon-m-exclamation-triangle'),
EvidenceCompletenessState::Missing->value => new BadgeSpec('Missing', 'danger', 'heroicon-m-x-circle'), EvidenceCompletenessState::Missing->value => OperatorOutcomeTaxonomy::spec(BadgeDomain::EvidenceCompleteness, $state, 'heroicon-m-clock'),
EvidenceCompletenessState::Stale->value => new BadgeSpec('Stale', 'gray', 'heroicon-m-clock'), EvidenceCompletenessState::Stale->value => OperatorOutcomeTaxonomy::spec(BadgeDomain::EvidenceCompleteness, $state, 'heroicon-m-arrow-path'),
default => BadgeSpec::unknown(), default => BadgeSpec::unknown(),
}; } ?? BadgeSpec::unknown();
} }
} }

View File

@ -0,0 +1,26 @@
<?php
declare(strict_types=1);
namespace App\Support\Badges\Domains;
use App\Support\Badges\BadgeCatalog;
use App\Support\Badges\BadgeDomain;
use App\Support\Badges\BadgeMapper;
use App\Support\Badges\BadgeSpec;
use App\Support\Badges\OperatorOutcomeTaxonomy;
final class GovernanceArtifactActionabilityBadge implements BadgeMapper
{
public function spec(mixed $value): BadgeSpec
{
$state = BadgeCatalog::normalizeState($value);
return match ($state) {
'none' => OperatorOutcomeTaxonomy::spec(BadgeDomain::GovernanceArtifactActionability, $state, 'heroicon-m-check'),
'optional' => OperatorOutcomeTaxonomy::spec(BadgeDomain::GovernanceArtifactActionability, $state, 'heroicon-m-information-circle'),
'required' => OperatorOutcomeTaxonomy::spec(BadgeDomain::GovernanceArtifactActionability, $state, 'heroicon-m-exclamation-triangle'),
default => BadgeSpec::unknown(),
} ?? BadgeSpec::unknown();
}
}

View File

@ -0,0 +1,30 @@
<?php
declare(strict_types=1);
namespace App\Support\Badges\Domains;
use App\Support\Badges\BadgeCatalog;
use App\Support\Badges\BadgeDomain;
use App\Support\Badges\BadgeMapper;
use App\Support\Badges\BadgeSpec;
use App\Support\Badges\OperatorOutcomeTaxonomy;
final class GovernanceArtifactContentBadge implements BadgeMapper
{
public function spec(mixed $value): BadgeSpec
{
$state = BadgeCatalog::normalizeState($value);
return match ($state) {
'trusted' => OperatorOutcomeTaxonomy::spec(BadgeDomain::GovernanceArtifactContent, $state, 'heroicon-m-check-badge'),
'partial' => OperatorOutcomeTaxonomy::spec(BadgeDomain::GovernanceArtifactContent, $state, 'heroicon-m-exclamation-triangle'),
'missing_input' => OperatorOutcomeTaxonomy::spec(BadgeDomain::GovernanceArtifactContent, $state, 'heroicon-m-exclamation-circle'),
'metadata_only' => OperatorOutcomeTaxonomy::spec(BadgeDomain::GovernanceArtifactContent, $state, 'heroicon-m-document-text'),
'reference_only' => OperatorOutcomeTaxonomy::spec(BadgeDomain::GovernanceArtifactContent, $state, 'heroicon-m-link'),
'empty' => OperatorOutcomeTaxonomy::spec(BadgeDomain::GovernanceArtifactContent, $state, 'heroicon-m-no-symbol'),
'unsupported' => OperatorOutcomeTaxonomy::spec(BadgeDomain::GovernanceArtifactContent, $state, 'heroicon-m-question-mark-circle'),
default => BadgeSpec::unknown(),
} ?? BadgeSpec::unknown();
}
}

View File

@ -0,0 +1,27 @@
<?php
declare(strict_types=1);
namespace App\Support\Badges\Domains;
use App\Support\Badges\BadgeCatalog;
use App\Support\Badges\BadgeDomain;
use App\Support\Badges\BadgeMapper;
use App\Support\Badges\BadgeSpec;
use App\Support\Badges\OperatorOutcomeTaxonomy;
final class GovernanceArtifactExistenceBadge implements BadgeMapper
{
public function spec(mixed $value): BadgeSpec
{
$state = BadgeCatalog::normalizeState($value);
return match ($state) {
'not_created' => OperatorOutcomeTaxonomy::spec(BadgeDomain::GovernanceArtifactExistence, $state, 'heroicon-m-clock'),
'historical_only' => OperatorOutcomeTaxonomy::spec(BadgeDomain::GovernanceArtifactExistence, $state, 'heroicon-m-archive-box'),
'created' => OperatorOutcomeTaxonomy::spec(BadgeDomain::GovernanceArtifactExistence, $state, 'heroicon-m-check-circle'),
'created_but_not_usable' => OperatorOutcomeTaxonomy::spec(BadgeDomain::GovernanceArtifactExistence, $state, 'heroicon-m-exclamation-triangle'),
default => BadgeSpec::unknown(),
} ?? BadgeSpec::unknown();
}
}

View File

@ -0,0 +1,26 @@
<?php
declare(strict_types=1);
namespace App\Support\Badges\Domains;
use App\Support\Badges\BadgeCatalog;
use App\Support\Badges\BadgeDomain;
use App\Support\Badges\BadgeMapper;
use App\Support\Badges\BadgeSpec;
use App\Support\Badges\OperatorOutcomeTaxonomy;
final class GovernanceArtifactFreshnessBadge implements BadgeMapper
{
public function spec(mixed $value): BadgeSpec
{
$state = BadgeCatalog::normalizeState($value);
return match ($state) {
'current' => OperatorOutcomeTaxonomy::spec(BadgeDomain::GovernanceArtifactFreshness, $state, 'heroicon-m-check-circle'),
'stale' => OperatorOutcomeTaxonomy::spec(BadgeDomain::GovernanceArtifactFreshness, $state, 'heroicon-m-arrow-path'),
'unknown' => OperatorOutcomeTaxonomy::spec(BadgeDomain::GovernanceArtifactFreshness, $state, 'heroicon-m-question-mark-circle'),
default => BadgeSpec::unknown(),
} ?? BadgeSpec::unknown();
}
}

View File

@ -0,0 +1,27 @@
<?php
declare(strict_types=1);
namespace App\Support\Badges\Domains;
use App\Support\Badges\BadgeCatalog;
use App\Support\Badges\BadgeDomain;
use App\Support\Badges\BadgeMapper;
use App\Support\Badges\BadgeSpec;
use App\Support\Badges\OperatorOutcomeTaxonomy;
final class GovernanceArtifactPublicationReadinessBadge implements BadgeMapper
{
public function spec(mixed $value): BadgeSpec
{
$state = BadgeCatalog::normalizeState($value);
return match ($state) {
'not_applicable' => OperatorOutcomeTaxonomy::spec(BadgeDomain::GovernanceArtifactPublicationReadiness, $state, 'heroicon-m-minus-circle'),
'internal_only' => OperatorOutcomeTaxonomy::spec(BadgeDomain::GovernanceArtifactPublicationReadiness, $state, 'heroicon-m-document-duplicate'),
'publishable' => OperatorOutcomeTaxonomy::spec(BadgeDomain::GovernanceArtifactPublicationReadiness, $state, 'heroicon-m-check-badge'),
'blocked' => OperatorOutcomeTaxonomy::spec(BadgeDomain::GovernanceArtifactPublicationReadiness, $state, 'heroicon-m-no-symbol'),
default => BadgeSpec::unknown(),
} ?? BadgeSpec::unknown();
}
}

View File

@ -3,24 +3,60 @@
namespace App\Support\Badges\Domains; namespace App\Support\Badges\Domains;
use App\Support\Badges\BadgeCatalog; use App\Support\Badges\BadgeCatalog;
use App\Support\Badges\BadgeDomain;
use App\Support\Badges\BadgeMapper; use App\Support\Badges\BadgeMapper;
use App\Support\Badges\BadgeSpec; use App\Support\Badges\BadgeSpec;
use App\Support\Badges\OperatorOutcomeTaxonomy;
use App\Support\OperationRunOutcome; use App\Support\OperationRunOutcome;
use App\Support\OperationRunStatus;
use App\Support\Operations\OperationRunFreshnessState;
final class OperationRunOutcomeBadge implements BadgeMapper final class OperationRunOutcomeBadge implements BadgeMapper
{ {
public function spec(mixed $value): BadgeSpec public function spec(mixed $value): BadgeSpec
{ {
$state = BadgeCatalog::normalizeState($value); $state = null;
if (is_array($value)) {
$outcome = BadgeCatalog::normalizeState($value['outcome'] ?? null);
$status = BadgeCatalog::normalizeState($value['status'] ?? null);
$freshnessState = BadgeCatalog::normalizeState($value['freshness_state'] ?? null);
if ($outcome === null) {
if ($freshnessState === OperationRunFreshnessState::ReconciledFailed->value) {
$outcome = OperationRunOutcome::Failed->value;
} elseif (
$freshnessState === OperationRunFreshnessState::LikelyStale->value
|| in_array($status, [OperationRunStatus::Queued->value, OperationRunStatus::Running->value], true)
) {
$outcome = OperationRunOutcome::Pending->value;
}
}
if ($outcome === OperationRunOutcome::Failed->value
&& $freshnessState === OperationRunFreshnessState::ReconciledFailed->value
) {
return new BadgeSpec(
label: 'Reconciled failed',
color: 'danger',
icon: 'heroicon-m-arrow-path-rounded-square',
iconColor: 'danger',
);
}
$state = $outcome;
}
$state ??= BadgeCatalog::normalizeState($value);
return match ($state) { return match ($state) {
OperationRunOutcome::Pending->value => new BadgeSpec('Pending', 'gray', 'heroicon-m-clock'), OperationRunOutcome::Pending->value => OperatorOutcomeTaxonomy::spec(BadgeDomain::OperationRunOutcome, $state, 'heroicon-m-clock'),
OperationRunOutcome::Succeeded->value => new BadgeSpec('Succeeded', 'success', 'heroicon-m-check-circle'), OperationRunOutcome::Succeeded->value => OperatorOutcomeTaxonomy::spec(BadgeDomain::OperationRunOutcome, $state, 'heroicon-m-check-circle'),
OperationRunOutcome::PartiallySucceeded->value => new BadgeSpec('Partially succeeded', 'warning', 'heroicon-m-exclamation-triangle'), OperationRunOutcome::PartiallySucceeded->value => OperatorOutcomeTaxonomy::spec(BadgeDomain::OperationRunOutcome, $state, 'heroicon-m-exclamation-triangle'),
OperationRunOutcome::Blocked->value, 'operation.blocked' => new BadgeSpec('Blocked', 'warning', 'heroicon-m-no-symbol'), OperationRunOutcome::Blocked->value, 'operation.blocked' => OperatorOutcomeTaxonomy::spec(BadgeDomain::OperationRunOutcome, $state, 'heroicon-m-no-symbol'),
OperationRunOutcome::Failed->value => new BadgeSpec('Failed', 'danger', 'heroicon-m-x-circle'), OperationRunOutcome::Failed->value => OperatorOutcomeTaxonomy::spec(BadgeDomain::OperationRunOutcome, $state, 'heroicon-m-x-circle'),
OperationRunOutcome::Cancelled->value => new BadgeSpec('Cancelled', 'gray', 'heroicon-m-minus-circle'), OperationRunOutcome::Cancelled->value => OperatorOutcomeTaxonomy::spec(BadgeDomain::OperationRunOutcome, $state, 'heroicon-m-minus-circle'),
default => BadgeSpec::unknown(), default => BadgeSpec::unknown(),
}; } ?? BadgeSpec::unknown();
} }
} }

View File

@ -3,20 +3,43 @@
namespace App\Support\Badges\Domains; namespace App\Support\Badges\Domains;
use App\Support\Badges\BadgeCatalog; use App\Support\Badges\BadgeCatalog;
use App\Support\Badges\BadgeDomain;
use App\Support\Badges\BadgeMapper; use App\Support\Badges\BadgeMapper;
use App\Support\Badges\BadgeSpec; use App\Support\Badges\BadgeSpec;
use App\Support\Badges\OperatorOutcomeTaxonomy;
use App\Support\OperationRunStatus; use App\Support\OperationRunStatus;
use App\Support\Operations\OperationRunFreshnessState;
final class OperationRunStatusBadge implements BadgeMapper final class OperationRunStatusBadge implements BadgeMapper
{ {
public function spec(mixed $value): BadgeSpec public function spec(mixed $value): BadgeSpec
{ {
$state = BadgeCatalog::normalizeState($value); $state = null;
if (is_array($value)) {
$status = BadgeCatalog::normalizeState($value['status'] ?? null);
$freshnessState = BadgeCatalog::normalizeState($value['freshness_state'] ?? null);
if (in_array($status, [OperationRunStatus::Queued->value, OperationRunStatus::Running->value], true)
&& $freshnessState === OperationRunFreshnessState::LikelyStale->value
) {
return new BadgeSpec(
label: 'Likely stale',
color: 'warning',
icon: 'heroicon-m-exclamation-triangle',
iconColor: 'warning',
);
}
$state = $status;
}
$state ??= BadgeCatalog::normalizeState($value);
return match ($state) { return match ($state) {
OperationRunStatus::Queued->value => new BadgeSpec('Queued', 'warning', 'heroicon-m-clock'), OperationRunStatus::Queued->value => OperatorOutcomeTaxonomy::spec(BadgeDomain::OperationRunStatus, $state, 'heroicon-m-clock'),
OperationRunStatus::Running->value => new BadgeSpec('Running', 'info', 'heroicon-m-arrow-path'), OperationRunStatus::Running->value => OperatorOutcomeTaxonomy::spec(BadgeDomain::OperationRunStatus, $state, 'heroicon-m-arrow-path'),
OperationRunStatus::Completed->value => new BadgeSpec('Completed', 'gray', 'heroicon-m-check-circle'), OperationRunStatus::Completed->value => OperatorOutcomeTaxonomy::spec(BadgeDomain::OperationRunStatus, $state, 'heroicon-m-check-circle'),
default => BadgeSpec::unknown(), default => BadgeSpec::unknown(),
}; };
} }

View File

@ -0,0 +1,28 @@
<?php
declare(strict_types=1);
namespace App\Support\Badges\Domains;
use App\Support\Badges\BadgeCatalog;
use App\Support\Badges\BadgeDomain;
use App\Support\Badges\BadgeMapper;
use App\Support\Badges\BadgeSpec;
use App\Support\Badges\OperatorOutcomeTaxonomy;
final class OperatorExplanationEvaluationResultBadge implements BadgeMapper
{
public function spec(mixed $value): BadgeSpec
{
$state = BadgeCatalog::normalizeState($value);
return match ($state) {
'full_result' => OperatorOutcomeTaxonomy::spec(BadgeDomain::OperatorExplanationEvaluationResult, $state, 'heroicon-m-check-badge'),
'incomplete_result' => OperatorOutcomeTaxonomy::spec(BadgeDomain::OperatorExplanationEvaluationResult, $state, 'heroicon-m-exclamation-triangle'),
'suppressed_result' => OperatorOutcomeTaxonomy::spec(BadgeDomain::OperatorExplanationEvaluationResult, $state, 'heroicon-m-eye-slash'),
'no_result' => OperatorOutcomeTaxonomy::spec(BadgeDomain::OperatorExplanationEvaluationResult, $state, 'heroicon-m-check'),
'unavailable' => OperatorOutcomeTaxonomy::spec(BadgeDomain::OperatorExplanationEvaluationResult, $state, 'heroicon-m-no-symbol'),
default => BadgeSpec::unknown(),
} ?? BadgeSpec::unknown();
}
}

View File

@ -0,0 +1,27 @@
<?php
declare(strict_types=1);
namespace App\Support\Badges\Domains;
use App\Support\Badges\BadgeCatalog;
use App\Support\Badges\BadgeDomain;
use App\Support\Badges\BadgeMapper;
use App\Support\Badges\BadgeSpec;
use App\Support\Badges\OperatorOutcomeTaxonomy;
final class OperatorExplanationTrustworthinessBadge implements BadgeMapper
{
public function spec(mixed $value): BadgeSpec
{
$state = BadgeCatalog::normalizeState($value);
return match ($state) {
'trustworthy' => OperatorOutcomeTaxonomy::spec(BadgeDomain::OperatorExplanationTrustworthiness, $state, 'heroicon-m-check-badge'),
'limited_confidence' => OperatorOutcomeTaxonomy::spec(BadgeDomain::OperatorExplanationTrustworthiness, $state, 'heroicon-m-exclamation-triangle'),
'diagnostic_only' => OperatorOutcomeTaxonomy::spec(BadgeDomain::OperatorExplanationTrustworthiness, $state, 'heroicon-m-beaker'),
'unusable' => OperatorOutcomeTaxonomy::spec(BadgeDomain::OperatorExplanationTrustworthiness, $state, 'heroicon-m-x-circle'),
default => BadgeSpec::unknown(),
} ?? BadgeSpec::unknown();
}
}

View File

@ -3,8 +3,10 @@
namespace App\Support\Badges\Domains; namespace App\Support\Badges\Domains;
use App\Support\Badges\BadgeCatalog; use App\Support\Badges\BadgeCatalog;
use App\Support\Badges\BadgeDomain;
use App\Support\Badges\BadgeMapper; use App\Support\Badges\BadgeMapper;
use App\Support\Badges\BadgeSpec; use App\Support\Badges\BadgeSpec;
use App\Support\Badges\OperatorOutcomeTaxonomy;
final class RestoreCheckSeverityBadge implements BadgeMapper final class RestoreCheckSeverityBadge implements BadgeMapper
{ {
@ -13,10 +15,10 @@ public function spec(mixed $value): BadgeSpec
$state = BadgeCatalog::normalizeState($value); $state = BadgeCatalog::normalizeState($value);
return match ($state) { return match ($state) {
'blocking' => new BadgeSpec('Blocking', 'danger', 'heroicon-m-x-circle'), 'blocking' => OperatorOutcomeTaxonomy::spec(BadgeDomain::RestoreCheckSeverity, $state, 'heroicon-m-x-circle'),
'warning' => new BadgeSpec('Warning', 'warning', 'heroicon-m-exclamation-triangle'), 'warning' => OperatorOutcomeTaxonomy::spec(BadgeDomain::RestoreCheckSeverity, $state, 'heroicon-m-exclamation-triangle'),
'safe' => new BadgeSpec('Safe', 'success', 'heroicon-m-check-circle'), 'safe' => OperatorOutcomeTaxonomy::spec(BadgeDomain::RestoreCheckSeverity, $state, 'heroicon-m-check-circle'),
default => BadgeSpec::unknown(), default => BadgeSpec::unknown(),
}; } ?? BadgeSpec::unknown();
} }
} }

View File

@ -3,8 +3,10 @@
namespace App\Support\Badges\Domains; namespace App\Support\Badges\Domains;
use App\Support\Badges\BadgeCatalog; use App\Support\Badges\BadgeCatalog;
use App\Support\Badges\BadgeDomain;
use App\Support\Badges\BadgeMapper; use App\Support\Badges\BadgeMapper;
use App\Support\Badges\BadgeSpec; use App\Support\Badges\BadgeSpec;
use App\Support\Badges\OperatorOutcomeTaxonomy;
final class RestorePreviewDecisionBadge implements BadgeMapper final class RestorePreviewDecisionBadge implements BadgeMapper
{ {
@ -13,12 +15,12 @@ public function spec(mixed $value): BadgeSpec
$state = BadgeCatalog::normalizeState($value); $state = BadgeCatalog::normalizeState($value);
return match ($state) { return match ($state) {
'created' => new BadgeSpec('Created', 'success', 'heroicon-m-check-circle'), 'created' => OperatorOutcomeTaxonomy::spec(BadgeDomain::RestorePreviewDecision, $state, 'heroicon-m-plus-circle'),
'created_copy' => new BadgeSpec('Created copy', 'warning', 'heroicon-m-exclamation-triangle'), 'created_copy' => OperatorOutcomeTaxonomy::spec(BadgeDomain::RestorePreviewDecision, $state, 'heroicon-m-document-duplicate'),
'mapped_existing' => new BadgeSpec('Mapped existing', 'info', 'heroicon-m-arrow-path'), 'mapped_existing' => OperatorOutcomeTaxonomy::spec(BadgeDomain::RestorePreviewDecision, $state, 'heroicon-m-arrow-right-circle'),
'skipped' => new BadgeSpec('Skipped', 'warning', 'heroicon-m-minus-circle'), 'skipped' => OperatorOutcomeTaxonomy::spec(BadgeDomain::RestorePreviewDecision, $state, 'heroicon-m-minus-circle'),
'failed' => new BadgeSpec('Failed', 'danger', 'heroicon-m-x-circle'), 'failed' => OperatorOutcomeTaxonomy::spec(BadgeDomain::RestorePreviewDecision, $state, 'heroicon-m-x-circle'),
default => BadgeSpec::unknown(), default => BadgeSpec::unknown(),
}; } ?? BadgeSpec::unknown();
} }
} }

View File

@ -3,8 +3,10 @@
namespace App\Support\Badges\Domains; namespace App\Support\Badges\Domains;
use App\Support\Badges\BadgeCatalog; use App\Support\Badges\BadgeCatalog;
use App\Support\Badges\BadgeDomain;
use App\Support\Badges\BadgeMapper; use App\Support\Badges\BadgeMapper;
use App\Support\Badges\BadgeSpec; use App\Support\Badges\BadgeSpec;
use App\Support\Badges\OperatorOutcomeTaxonomy;
final class RestoreResultStatusBadge implements BadgeMapper final class RestoreResultStatusBadge implements BadgeMapper
{ {
@ -13,14 +15,14 @@ public function spec(mixed $value): BadgeSpec
$state = BadgeCatalog::normalizeState($value); $state = BadgeCatalog::normalizeState($value);
return match ($state) { return match ($state) {
'applied' => new BadgeSpec('Applied', 'success', 'heroicon-m-check-circle'), 'applied' => OperatorOutcomeTaxonomy::spec(BadgeDomain::RestoreResultStatus, $state, 'heroicon-m-check-circle'),
'dry_run' => new BadgeSpec('Dry run', 'info', 'heroicon-m-arrow-path'), 'dry_run' => OperatorOutcomeTaxonomy::spec(BadgeDomain::RestoreResultStatus, $state, 'heroicon-m-eye'),
'mapped' => new BadgeSpec('Mapped', 'success', 'heroicon-m-check-circle'), 'mapped' => OperatorOutcomeTaxonomy::spec(BadgeDomain::RestoreResultStatus, $state, 'heroicon-m-arrow-right-circle'),
'skipped' => new BadgeSpec('Skipped', 'warning', 'heroicon-m-minus-circle'), 'skipped' => OperatorOutcomeTaxonomy::spec(BadgeDomain::RestoreResultStatus, $state, 'heroicon-m-minus-circle'),
'partial' => new BadgeSpec('Partial', 'warning', 'heroicon-m-exclamation-triangle'), 'partial' => OperatorOutcomeTaxonomy::spec(BadgeDomain::RestoreResultStatus, $state, 'heroicon-m-exclamation-triangle'),
'manual_required' => new BadgeSpec('Manual required', 'warning', 'heroicon-m-exclamation-triangle'), 'manual_required' => OperatorOutcomeTaxonomy::spec(BadgeDomain::RestoreResultStatus, $state, 'heroicon-m-wrench-screwdriver'),
'failed' => new BadgeSpec('Failed', 'danger', 'heroicon-m-x-circle'), 'failed' => OperatorOutcomeTaxonomy::spec(BadgeDomain::RestoreResultStatus, $state, 'heroicon-m-x-circle'),
default => BadgeSpec::unknown(), default => BadgeSpec::unknown(),
}; } ?? BadgeSpec::unknown();
} }
} }

View File

@ -3,8 +3,10 @@
namespace App\Support\Badges\Domains; namespace App\Support\Badges\Domains;
use App\Support\Badges\BadgeCatalog; use App\Support\Badges\BadgeCatalog;
use App\Support\Badges\BadgeDomain;
use App\Support\Badges\BadgeMapper; use App\Support\Badges\BadgeMapper;
use App\Support\Badges\BadgeSpec; use App\Support\Badges\BadgeSpec;
use App\Support\Badges\OperatorOutcomeTaxonomy;
use App\Support\RestoreRunStatus; use App\Support\RestoreRunStatus;
final class RestoreRunStatusBadge implements BadgeMapper final class RestoreRunStatusBadge implements BadgeMapper
@ -14,20 +16,20 @@ public function spec(mixed $value): BadgeSpec
$state = BadgeCatalog::normalizeState($value); $state = BadgeCatalog::normalizeState($value);
return match ($state) { return match ($state) {
RestoreRunStatus::Draft->value => new BadgeSpec('Draft', 'gray', 'heroicon-m-minus-circle'), RestoreRunStatus::Draft->value => OperatorOutcomeTaxonomy::spec(BadgeDomain::RestoreRunStatus, $state, 'heroicon-m-minus-circle'),
RestoreRunStatus::Scoped->value => new BadgeSpec('Scoped', 'gray', 'heroicon-m-minus-circle'), RestoreRunStatus::Scoped->value => OperatorOutcomeTaxonomy::spec(BadgeDomain::RestoreRunStatus, $state, 'heroicon-m-funnel'),
RestoreRunStatus::Checked->value => new BadgeSpec('Checked', 'gray', 'heroicon-m-minus-circle'), RestoreRunStatus::Checked->value => OperatorOutcomeTaxonomy::spec(BadgeDomain::RestoreRunStatus, $state, 'heroicon-m-shield-check'),
RestoreRunStatus::Previewed->value => new BadgeSpec('Previewed', 'gray', 'heroicon-m-minus-circle'), RestoreRunStatus::Previewed->value => OperatorOutcomeTaxonomy::spec(BadgeDomain::RestoreRunStatus, $state, 'heroicon-m-eye'),
RestoreRunStatus::Pending->value => new BadgeSpec('Pending', 'warning', 'heroicon-m-clock'), RestoreRunStatus::Pending->value => OperatorOutcomeTaxonomy::spec(BadgeDomain::RestoreRunStatus, $state, 'heroicon-m-clock'),
RestoreRunStatus::Queued->value => new BadgeSpec('Queued', 'warning', 'heroicon-m-clock'), RestoreRunStatus::Queued->value => OperatorOutcomeTaxonomy::spec(BadgeDomain::RestoreRunStatus, $state, 'heroicon-m-queue-list'),
RestoreRunStatus::Running->value => new BadgeSpec('Running', 'info', 'heroicon-m-arrow-path'), RestoreRunStatus::Running->value => OperatorOutcomeTaxonomy::spec(BadgeDomain::RestoreRunStatus, $state, 'heroicon-m-arrow-path'),
RestoreRunStatus::Completed->value => new BadgeSpec('Completed', 'success', 'heroicon-m-check-circle'), RestoreRunStatus::Completed->value => OperatorOutcomeTaxonomy::spec(BadgeDomain::RestoreRunStatus, $state, 'heroicon-m-check-circle'),
RestoreRunStatus::Partial->value => new BadgeSpec('Partial', 'warning', 'heroicon-m-exclamation-triangle'), RestoreRunStatus::Partial->value => OperatorOutcomeTaxonomy::spec(BadgeDomain::RestoreRunStatus, $state, 'heroicon-m-exclamation-triangle'),
RestoreRunStatus::Failed->value => new BadgeSpec('Failed', 'danger', 'heroicon-m-x-circle'), RestoreRunStatus::Failed->value => OperatorOutcomeTaxonomy::spec(BadgeDomain::RestoreRunStatus, $state, 'heroicon-m-x-circle'),
RestoreRunStatus::Cancelled->value => new BadgeSpec('Cancelled', 'gray', 'heroicon-m-minus-circle'), RestoreRunStatus::Cancelled->value => OperatorOutcomeTaxonomy::spec(BadgeDomain::RestoreRunStatus, $state, 'heroicon-m-minus-circle'),
RestoreRunStatus::Aborted->value => new BadgeSpec('Aborted', 'gray', 'heroicon-m-minus-circle'), RestoreRunStatus::Aborted->value => OperatorOutcomeTaxonomy::spec(BadgeDomain::RestoreRunStatus, $state, 'heroicon-m-stop-circle'),
RestoreRunStatus::CompletedWithErrors->value => new BadgeSpec('Completed with errors', 'warning', 'heroicon-m-exclamation-triangle'), RestoreRunStatus::CompletedWithErrors->value => OperatorOutcomeTaxonomy::spec(BadgeDomain::RestoreRunStatus, $state, 'heroicon-m-exclamation-triangle'),
default => BadgeSpec::unknown(), default => BadgeSpec::unknown(),
}; } ?? BadgeSpec::unknown();
} }
} }

View File

@ -5,8 +5,10 @@
namespace App\Support\Badges\Domains; namespace App\Support\Badges\Domains;
use App\Support\Badges\BadgeCatalog; use App\Support\Badges\BadgeCatalog;
use App\Support\Badges\BadgeDomain;
use App\Support\Badges\BadgeMapper; use App\Support\Badges\BadgeMapper;
use App\Support\Badges\BadgeSpec; use App\Support\Badges\BadgeSpec;
use App\Support\Badges\OperatorOutcomeTaxonomy;
use App\Support\TenantReviewCompletenessState; use App\Support\TenantReviewCompletenessState;
final class TenantReviewCompletenessStateBadge implements BadgeMapper final class TenantReviewCompletenessStateBadge implements BadgeMapper
@ -16,11 +18,11 @@ public function spec(mixed $value): BadgeSpec
$state = BadgeCatalog::normalizeState($value); $state = BadgeCatalog::normalizeState($value);
return match ($state) { return match ($state) {
TenantReviewCompletenessState::Complete->value => new BadgeSpec('Complete', 'success', 'heroicon-m-check-circle'), TenantReviewCompletenessState::Complete->value => OperatorOutcomeTaxonomy::spec(BadgeDomain::TenantReviewCompleteness, $state, 'heroicon-m-check-circle'),
TenantReviewCompletenessState::Partial->value => new BadgeSpec('Partial', 'warning', 'heroicon-m-exclamation-triangle'), TenantReviewCompletenessState::Partial->value => OperatorOutcomeTaxonomy::spec(BadgeDomain::TenantReviewCompleteness, $state, 'heroicon-m-exclamation-triangle'),
TenantReviewCompletenessState::Missing->value => new BadgeSpec('Missing', 'danger', 'heroicon-m-x-circle'), TenantReviewCompletenessState::Missing->value => OperatorOutcomeTaxonomy::spec(BadgeDomain::TenantReviewCompleteness, $state, 'heroicon-m-clock'),
TenantReviewCompletenessState::Stale->value => new BadgeSpec('Stale', 'gray', 'heroicon-m-clock'), TenantReviewCompletenessState::Stale->value => OperatorOutcomeTaxonomy::spec(BadgeDomain::TenantReviewCompleteness, $state, 'heroicon-m-arrow-path'),
default => BadgeSpec::unknown(), default => BadgeSpec::unknown(),
}; } ?? BadgeSpec::unknown();
} }
} }

View File

@ -0,0 +1,17 @@
<?php
declare(strict_types=1);
namespace App\Support\Badges;
enum OperatorNextActionPolicy: string
{
case Required = 'required';
case Optional = 'optional';
case None = 'none';
public function requiresExplanation(): bool
{
return $this !== self::None;
}
}

View File

@ -0,0 +1,973 @@
<?php
declare(strict_types=1);
namespace App\Support\Badges;
use InvalidArgumentException;
final class OperatorOutcomeTaxonomy
{
/**
* @var array<string, array<string, array{
* axis: string,
* label: string,
* color: string,
* classification: string,
* next_action_policy: string,
* legacy_aliases: list<string>,
* diagnostic_label?: string|null,
* notes: string
* }>>
*/
private const ENTRIES = [
'governance_artifact_existence' => [
'not_created' => [
'axis' => 'artifact_existence',
'label' => 'Not created yet',
'color' => 'info',
'classification' => 'primary',
'next_action_policy' => 'optional',
'legacy_aliases' => ['No artifact'],
'notes' => 'The intended artifact has not been produced yet.',
],
'historical_only' => [
'axis' => 'artifact_existence',
'label' => 'Historical artifact',
'color' => 'gray',
'classification' => 'primary',
'next_action_policy' => 'none',
'legacy_aliases' => ['Historical only'],
'notes' => 'The artifact remains readable for history but is no longer the current working artifact.',
],
'created' => [
'axis' => 'artifact_existence',
'label' => 'Artifact available',
'color' => 'success',
'classification' => 'primary',
'next_action_policy' => 'none',
'legacy_aliases' => ['Created'],
'notes' => 'The intended artifact exists and can be inspected.',
],
'created_but_not_usable' => [
'axis' => 'artifact_existence',
'label' => 'Artifact not usable',
'color' => 'warning',
'classification' => 'primary',
'next_action_policy' => 'required',
'legacy_aliases' => ['Created but not usable'],
'notes' => 'The artifact record exists, but the operator cannot safely rely on it for the primary task.',
],
],
'governance_artifact_content' => [
'trusted' => [
'axis' => 'data_coverage',
'label' => 'Trustworthy artifact',
'color' => 'success',
'classification' => 'primary',
'next_action_policy' => 'none',
'legacy_aliases' => ['Trusted'],
'notes' => 'The artifact content is fit for the primary operator workflow.',
],
'partial' => [
'axis' => 'data_coverage',
'label' => 'Partially complete',
'color' => 'warning',
'classification' => 'primary',
'next_action_policy' => 'required',
'legacy_aliases' => ['Partially complete'],
'notes' => 'The artifact exists but key content is incomplete.',
],
'missing_input' => [
'axis' => 'data_coverage',
'label' => 'Missing input',
'color' => 'warning',
'classification' => 'primary',
'next_action_policy' => 'required',
'legacy_aliases' => ['Missing'],
'notes' => 'The artifact is blocked by missing upstream inputs.',
],
'metadata_only' => [
'axis' => 'evidence_depth',
'label' => 'Metadata only',
'color' => 'info',
'classification' => 'diagnostic',
'next_action_policy' => 'none',
'legacy_aliases' => ['Metadata-only'],
'notes' => 'Only metadata is available. This is diagnostic context and should not replace the primary truth state.',
],
'reference_only' => [
'axis' => 'evidence_depth',
'label' => 'Reference only',
'color' => 'info',
'classification' => 'diagnostic',
'next_action_policy' => 'none',
'legacy_aliases' => ['Reference-only'],
'notes' => 'Only reference placeholders are available. This is diagnostic context and should not replace the primary truth state.',
],
'empty' => [
'axis' => 'data_coverage',
'label' => 'Empty snapshot',
'color' => 'warning',
'classification' => 'primary',
'next_action_policy' => 'required',
'legacy_aliases' => ['Empty'],
'notes' => 'The artifact exists but captured no usable content.',
],
'unsupported' => [
'axis' => 'product_support_maturity',
'label' => 'Support limited',
'color' => 'gray',
'classification' => 'diagnostic',
'next_action_policy' => 'none',
'legacy_aliases' => ['Unsupported'],
'notes' => 'The product is representing the source with limited fidelity. This remains diagnostic unless a stronger truth dimension applies.',
],
],
'governance_artifact_freshness' => [
'current' => [
'axis' => 'data_freshness',
'label' => 'Current',
'color' => 'success',
'classification' => 'primary',
'next_action_policy' => 'none',
'legacy_aliases' => ['Fresh'],
'notes' => 'The available artifact is current enough for the primary task.',
],
'stale' => [
'axis' => 'data_freshness',
'label' => 'Refresh recommended',
'color' => 'warning',
'classification' => 'primary',
'next_action_policy' => 'optional',
'legacy_aliases' => ['Refresh recommended'],
'notes' => 'The artifact exists but should be refreshed before relying on it.',
],
'unknown' => [
'axis' => 'data_freshness',
'label' => 'Freshness unknown',
'color' => 'gray',
'classification' => 'diagnostic',
'next_action_policy' => 'none',
'legacy_aliases' => ['Unknown'],
'notes' => 'The system cannot determine freshness from the available payload.',
],
],
'governance_artifact_publication_readiness' => [
'not_applicable' => [
'axis' => 'publication_readiness',
'label' => 'Not applicable',
'color' => 'gray',
'classification' => 'diagnostic',
'next_action_policy' => 'none',
'legacy_aliases' => ['N/A'],
'notes' => 'Publication readiness does not apply to this artifact family.',
],
'internal_only' => [
'axis' => 'publication_readiness',
'label' => 'Internal only',
'color' => 'info',
'classification' => 'primary',
'next_action_policy' => 'optional',
'legacy_aliases' => ['Draft'],
'notes' => 'The artifact is useful internally but not ready for stakeholder delivery.',
],
'publishable' => [
'axis' => 'publication_readiness',
'label' => 'Publishable',
'color' => 'success',
'classification' => 'primary',
'next_action_policy' => 'none',
'legacy_aliases' => ['Ready'],
'notes' => 'The artifact is ready for stakeholder publication or export.',
],
'blocked' => [
'axis' => 'publication_readiness',
'label' => 'Publication blocked',
'color' => 'warning',
'classification' => 'primary',
'next_action_policy' => 'required',
'legacy_aliases' => ['Not publishable'],
'notes' => 'The artifact exists but is blocked from publication or export.',
],
],
'governance_artifact_actionability' => [
'none' => [
'axis' => 'operator_actionability',
'label' => 'No action needed',
'color' => 'gray',
'classification' => 'primary',
'next_action_policy' => 'none',
'legacy_aliases' => ['No follow-up'],
'notes' => 'The current non-green state is informational only and does not require action.',
],
'optional' => [
'axis' => 'operator_actionability',
'label' => 'Review recommended',
'color' => 'info',
'classification' => 'primary',
'next_action_policy' => 'optional',
'legacy_aliases' => ['Optional follow-up'],
'notes' => 'The artifact can be used, but the operator should review the follow-up guidance.',
],
'required' => [
'axis' => 'operator_actionability',
'label' => 'Action required',
'color' => 'danger',
'classification' => 'primary',
'next_action_policy' => 'required',
'legacy_aliases' => ['Required follow-up'],
'notes' => 'The artifact cannot be trusted for the primary task until an operator addresses the issue.',
],
],
'operator_explanation_evaluation_result' => [
'full_result' => [
'axis' => 'execution_outcome',
'label' => 'Complete result',
'color' => 'success',
'classification' => 'primary',
'next_action_policy' => 'none',
'legacy_aliases' => ['Full result'],
'notes' => 'The result can be read as complete for the intended operator decision.',
],
'incomplete_result' => [
'axis' => 'data_coverage',
'label' => 'Incomplete result',
'color' => 'warning',
'classification' => 'primary',
'next_action_policy' => 'required',
'legacy_aliases' => ['Partial result'],
'notes' => 'A result exists, but missing or partial coverage limits what it means.',
],
'suppressed_result' => [
'axis' => 'data_coverage',
'label' => 'Suppressed result',
'color' => 'warning',
'classification' => 'primary',
'next_action_policy' => 'required',
'legacy_aliases' => ['Suppressed'],
'notes' => 'Normal output was intentionally suppressed because the system could not safely present it as final.',
],
'no_result' => [
'axis' => 'execution_outcome',
'label' => 'No issues detected',
'color' => 'success',
'classification' => 'primary',
'next_action_policy' => 'none',
'legacy_aliases' => ['No result'],
'notes' => 'The workflow produced no decision-relevant follow-up for the operator.',
],
'unavailable' => [
'axis' => 'execution_outcome',
'label' => 'Result unavailable',
'color' => 'warning',
'classification' => 'primary',
'next_action_policy' => 'required',
'legacy_aliases' => ['Unavailable'],
'notes' => 'A usable result is not currently available for this surface.',
],
],
'operator_explanation_trustworthiness' => [
'trustworthy' => [
'axis' => 'data_coverage',
'label' => 'Trustworthy',
'color' => 'success',
'classification' => 'primary',
'next_action_policy' => 'none',
'legacy_aliases' => ['Decision grade'],
'notes' => 'The operator can rely on this result for the intended task.',
],
'limited_confidence' => [
'axis' => 'data_coverage',
'label' => 'Limited confidence',
'color' => 'warning',
'classification' => 'primary',
'next_action_policy' => 'optional',
'legacy_aliases' => ['Use with caution'],
'notes' => 'The result is still useful, but the operator should account for documented limitations.',
],
'diagnostic_only' => [
'axis' => 'evidence_depth',
'label' => 'Diagnostic only',
'color' => 'info',
'classification' => 'diagnostic',
'next_action_policy' => 'none',
'legacy_aliases' => ['Diagnostics only'],
'notes' => 'The result is suitable for diagnostics only, not for a final decision.',
],
'unusable' => [
'axis' => 'operator_actionability',
'label' => 'Not usable yet',
'color' => 'danger',
'classification' => 'primary',
'next_action_policy' => 'required',
'legacy_aliases' => ['Unusable'],
'notes' => 'The operator should not rely on this result until the blocking issue is resolved.',
],
],
'baseline_snapshot_lifecycle' => [
'building' => [
'axis' => 'execution_lifecycle',
'label' => 'Building',
'color' => 'info',
'classification' => 'primary',
'next_action_policy' => 'none',
'legacy_aliases' => ['In progress'],
'notes' => 'The snapshot row exists, but completion proof has not finished yet.',
],
'complete' => [
'axis' => 'data_coverage',
'label' => 'Complete',
'color' => 'success',
'classification' => 'primary',
'next_action_policy' => 'none',
'legacy_aliases' => ['Ready'],
'notes' => 'The snapshot passed completion proof and is eligible for compare.',
],
'incomplete' => [
'axis' => 'data_coverage',
'label' => 'Incomplete',
'color' => 'warning',
'classification' => 'primary',
'next_action_policy' => 'required',
'legacy_aliases' => ['Partial'],
'notes' => 'The snapshot exists but did not finish cleanly and is not usable for compare.',
],
'superseded' => [
'axis' => 'data_freshness',
'label' => 'Superseded',
'color' => 'gray',
'classification' => 'primary',
'next_action_policy' => 'none',
'legacy_aliases' => ['Historical'],
'notes' => 'A newer complete snapshot is the effective current baseline truth.',
],
],
'operation_run_status' => [
'queued' => [
'axis' => 'execution_lifecycle',
'label' => 'Queued for execution',
'color' => 'info',
'classification' => 'primary',
'next_action_policy' => 'none',
'legacy_aliases' => ['Queued'],
'notes' => 'Execution is waiting for a worker to start the run.',
],
'running' => [
'axis' => 'execution_lifecycle',
'label' => 'In progress',
'color' => 'info',
'classification' => 'primary',
'next_action_policy' => 'none',
'legacy_aliases' => ['Running'],
'notes' => 'Execution is currently running.',
],
'completed' => [
'axis' => 'execution_lifecycle',
'label' => 'Run finished',
'color' => 'gray',
'classification' => 'primary',
'next_action_policy' => 'none',
'legacy_aliases' => ['Completed'],
'notes' => 'Execution has reached a terminal state and the outcome badge carries the primary meaning.',
],
],
'operation_run_outcome' => [
'pending' => [
'axis' => 'execution_outcome',
'label' => 'Awaiting result',
'color' => 'gray',
'classification' => 'primary',
'next_action_policy' => 'none',
'legacy_aliases' => ['Pending'],
'notes' => 'Execution has not produced a terminal outcome yet.',
],
'succeeded' => [
'axis' => 'execution_outcome',
'label' => 'Completed successfully',
'color' => 'success',
'classification' => 'primary',
'next_action_policy' => 'none',
'legacy_aliases' => ['Succeeded'],
'notes' => 'The run finished without operator follow-up.',
],
'partially_succeeded' => [
'axis' => 'execution_outcome',
'label' => 'Completed with follow-up',
'color' => 'warning',
'classification' => 'primary',
'next_action_policy' => 'optional',
'legacy_aliases' => ['Partially succeeded', 'Partial'],
'notes' => 'The run finished but needs operator review or cleanup.',
],
'blocked' => [
'axis' => 'execution_outcome',
'label' => 'Blocked by prerequisite',
'color' => 'warning',
'classification' => 'primary',
'next_action_policy' => 'required',
'legacy_aliases' => ['Blocked'],
'notes' => 'Execution could not start or continue until a prerequisite is fixed.',
],
'failed' => [
'axis' => 'execution_outcome',
'label' => 'Execution failed',
'color' => 'danger',
'classification' => 'primary',
'next_action_policy' => 'required',
'legacy_aliases' => ['Failed'],
'notes' => 'Execution ended unsuccessfully and needs operator attention.',
],
'cancelled' => [
'axis' => 'execution_outcome',
'label' => 'Cancelled',
'color' => 'gray',
'classification' => 'primary',
'next_action_policy' => 'none',
'legacy_aliases' => ['Cancelled'],
'notes' => 'Execution was intentionally stopped.',
],
],
'evidence_completeness' => [
'complete' => [
'axis' => 'data_coverage',
'label' => 'Coverage ready',
'color' => 'success',
'classification' => 'primary',
'next_action_policy' => 'none',
'legacy_aliases' => ['Complete'],
'notes' => 'Required evidence is present.',
],
'partial' => [
'axis' => 'data_coverage',
'label' => 'Coverage incomplete',
'color' => 'warning',
'classification' => 'primary',
'next_action_policy' => 'required',
'legacy_aliases' => ['Partial'],
'notes' => 'Some required evidence dimensions are still missing.',
],
'missing' => [
'axis' => 'data_coverage',
'label' => 'Not collected yet',
'color' => 'info',
'classification' => 'primary',
'next_action_policy' => 'optional',
'legacy_aliases' => ['Missing'],
'notes' => 'No evidence has been captured for this slice yet. This is not a failure by itself.',
],
'stale' => [
'axis' => 'data_freshness',
'label' => 'Refresh recommended',
'color' => 'warning',
'classification' => 'primary',
'next_action_policy' => 'optional',
'legacy_aliases' => ['Stale'],
'notes' => 'Evidence exists but is old enough that the operator should refresh it before relying on it.',
],
],
'tenant_review_completeness' => [
'complete' => [
'axis' => 'data_coverage',
'label' => 'Review inputs ready',
'color' => 'success',
'classification' => 'primary',
'next_action_policy' => 'none',
'legacy_aliases' => ['Complete'],
'notes' => 'The review has the evidence inputs it needs.',
],
'partial' => [
'axis' => 'data_coverage',
'label' => 'Review inputs incomplete',
'color' => 'warning',
'classification' => 'primary',
'next_action_policy' => 'required',
'legacy_aliases' => ['Partial'],
'notes' => 'Some review sections still need inputs.',
],
'missing' => [
'axis' => 'data_coverage',
'label' => 'Review input pending',
'color' => 'info',
'classification' => 'primary',
'next_action_policy' => 'optional',
'legacy_aliases' => ['Missing'],
'notes' => 'The review has not been anchored to usable evidence yet.',
],
'stale' => [
'axis' => 'data_freshness',
'label' => 'Refresh review inputs',
'color' => 'warning',
'classification' => 'primary',
'next_action_policy' => 'optional',
'legacy_aliases' => ['Stale'],
'notes' => 'The review input exists but should be refreshed before stakeholder use.',
],
],
'baseline_snapshot_fidelity' => [
'full' => [
'axis' => 'evidence_depth',
'label' => 'Detailed evidence',
'color' => 'success',
'classification' => 'diagnostic',
'next_action_policy' => 'none',
'legacy_aliases' => ['Full'],
'notes' => 'Full structured evidence detail is available.',
],
'partial' => [
'axis' => 'evidence_depth',
'label' => 'Mixed evidence detail',
'color' => 'info',
'classification' => 'diagnostic',
'next_action_policy' => 'none',
'legacy_aliases' => ['Partial'],
'notes' => 'Some items have full detail while others are metadata-only.',
],
'reference_only' => [
'axis' => 'evidence_depth',
'label' => 'Metadata only',
'color' => 'info',
'classification' => 'diagnostic',
'next_action_policy' => 'none',
'legacy_aliases' => ['Reference only'],
'notes' => 'Only reference metadata is available for this capture.',
],
'unsupported' => [
'axis' => 'product_support_maturity',
'label' => 'Support limited',
'color' => 'gray',
'classification' => 'diagnostic',
'next_action_policy' => 'none',
'legacy_aliases' => ['Unsupported'],
'diagnostic_label' => 'Fallback renderer',
'notes' => 'The renderer fell back to a lower-fidelity representation. This is diagnostic context, not a governance gap.',
],
],
'baseline_snapshot_gap_status' => [
'clear' => [
'axis' => 'data_coverage',
'label' => 'No follow-up needed',
'color' => 'success',
'classification' => 'primary',
'next_action_policy' => 'none',
'legacy_aliases' => ['No gaps'],
'notes' => 'The captured group does not contain unresolved coverage gaps.',
],
'gaps_present' => [
'axis' => 'data_coverage',
'label' => 'Coverage gaps need review',
'color' => 'warning',
'classification' => 'primary',
'next_action_policy' => 'required',
'legacy_aliases' => ['Gaps present'],
'notes' => 'The captured group has unresolved gaps that should be reviewed.',
],
],
'restore_run_status' => [
'draft' => [
'axis' => 'execution_lifecycle',
'label' => 'Draft',
'color' => 'gray',
'classification' => 'primary',
'next_action_policy' => 'none',
'legacy_aliases' => ['Draft'],
'notes' => 'The restore run has not been prepared yet.',
],
'scoped' => [
'axis' => 'execution_lifecycle',
'label' => 'Scope selected',
'color' => 'gray',
'classification' => 'primary',
'next_action_policy' => 'none',
'legacy_aliases' => ['Scoped'],
'notes' => 'Items were selected for restore.',
],
'checked' => [
'axis' => 'execution_lifecycle',
'label' => 'Checks complete',
'color' => 'gray',
'classification' => 'primary',
'next_action_policy' => 'none',
'legacy_aliases' => ['Checked'],
'notes' => 'Safety checks were completed for this run.',
],
'previewed' => [
'axis' => 'execution_lifecycle',
'label' => 'Preview ready',
'color' => 'gray',
'classification' => 'primary',
'next_action_policy' => 'none',
'legacy_aliases' => ['Previewed'],
'notes' => 'A dry-run preview is available for review.',
],
'pending' => [
'axis' => 'execution_lifecycle',
'label' => 'Pending execution',
'color' => 'gray',
'classification' => 'primary',
'next_action_policy' => 'none',
'legacy_aliases' => ['Pending'],
'notes' => 'Execution has not been queued yet.',
],
'queued' => [
'axis' => 'execution_lifecycle',
'label' => 'Queued for execution',
'color' => 'info',
'classification' => 'primary',
'next_action_policy' => 'none',
'legacy_aliases' => ['Queued'],
'notes' => 'Execution is queued and waiting for a worker.',
],
'running' => [
'axis' => 'execution_lifecycle',
'label' => 'Applying restore',
'color' => 'info',
'classification' => 'primary',
'next_action_policy' => 'none',
'legacy_aliases' => ['Running'],
'notes' => 'Execution is currently applying restore work.',
],
'completed' => [
'axis' => 'execution_outcome',
'label' => 'Applied successfully',
'color' => 'success',
'classification' => 'primary',
'next_action_policy' => 'none',
'legacy_aliases' => ['Completed'],
'notes' => 'The restore run finished successfully.',
],
'partial' => [
'axis' => 'execution_outcome',
'label' => 'Applied with follow-up',
'color' => 'warning',
'classification' => 'primary',
'next_action_policy' => 'optional',
'legacy_aliases' => ['Partial'],
'notes' => 'The restore run finished but needs follow-up on a subset of items.',
],
'failed' => [
'axis' => 'execution_outcome',
'label' => 'Restore failed',
'color' => 'danger',
'classification' => 'primary',
'next_action_policy' => 'required',
'legacy_aliases' => ['Failed'],
'notes' => 'The restore run did not complete successfully.',
],
'cancelled' => [
'axis' => 'execution_outcome',
'label' => 'Cancelled',
'color' => 'gray',
'classification' => 'primary',
'next_action_policy' => 'none',
'legacy_aliases' => ['Cancelled'],
'notes' => 'Execution was intentionally cancelled.',
],
'aborted' => [
'axis' => 'execution_outcome',
'label' => 'Stopped early',
'color' => 'gray',
'classification' => 'primary',
'next_action_policy' => 'none',
'legacy_aliases' => ['Aborted'],
'notes' => 'Execution stopped before the normal terminal path completed.',
],
'completed_with_errors' => [
'axis' => 'execution_outcome',
'label' => 'Applied with follow-up',
'color' => 'warning',
'classification' => 'primary',
'next_action_policy' => 'optional',
'legacy_aliases' => ['Completed with errors'],
'notes' => 'Execution completed but still needs follow-up on failed items.',
],
],
'restore_result_status' => [
'applied' => [
'axis' => 'item_result',
'label' => 'Applied',
'color' => 'success',
'classification' => 'primary',
'next_action_policy' => 'none',
'legacy_aliases' => ['Applied'],
'notes' => 'The item was applied successfully.',
],
'dry_run' => [
'axis' => 'item_result',
'label' => 'Preview only',
'color' => 'info',
'classification' => 'primary',
'next_action_policy' => 'none',
'legacy_aliases' => ['Dry run'],
'notes' => 'The item was only simulated and not applied.',
],
'mapped' => [
'axis' => 'item_result',
'label' => 'Mapped to existing item',
'color' => 'info',
'classification' => 'primary',
'next_action_policy' => 'none',
'legacy_aliases' => ['Mapped'],
'notes' => 'The source item mapped to an existing target.',
],
'skipped' => [
'axis' => 'item_result',
'label' => 'Not applied',
'color' => 'gray',
'classification' => 'primary',
'next_action_policy' => 'optional',
'legacy_aliases' => ['Skipped'],
'notes' => 'The item was intentionally not applied.',
],
'partial' => [
'axis' => 'item_result',
'label' => 'Partially applied',
'color' => 'warning',
'classification' => 'primary',
'next_action_policy' => 'optional',
'legacy_aliases' => ['Partial'],
'notes' => 'The item only applied in part and needs review.',
],
'manual_required' => [
'axis' => 'operator_actionability',
'label' => 'Manual follow-up needed',
'color' => 'warning',
'classification' => 'primary',
'next_action_policy' => 'required',
'legacy_aliases' => ['Manual required'],
'notes' => 'The operator must handle this item manually.',
],
'failed' => [
'axis' => 'item_result',
'label' => 'Apply failed',
'color' => 'danger',
'classification' => 'primary',
'next_action_policy' => 'required',
'legacy_aliases' => ['Failed'],
'notes' => 'The item failed to apply.',
],
],
'restore_preview_decision' => [
'created' => [
'axis' => 'item_result',
'label' => 'Will create',
'color' => 'success',
'classification' => 'primary',
'next_action_policy' => 'none',
'legacy_aliases' => ['Created'],
'notes' => 'The preview plans to create a new target item.',
],
'created_copy' => [
'axis' => 'item_result',
'label' => 'Will create copy',
'color' => 'warning',
'classification' => 'primary',
'next_action_policy' => 'optional',
'legacy_aliases' => ['Created copy'],
'notes' => 'The preview plans to create a copy and should be reviewed before execution.',
],
'mapped_existing' => [
'axis' => 'item_result',
'label' => 'Will map existing',
'color' => 'info',
'classification' => 'primary',
'next_action_policy' => 'none',
'legacy_aliases' => ['Mapped existing'],
'notes' => 'The preview plans to map this item to an existing target.',
],
'skipped' => [
'axis' => 'item_result',
'label' => 'Will skip',
'color' => 'gray',
'classification' => 'primary',
'next_action_policy' => 'optional',
'legacy_aliases' => ['Skipped'],
'notes' => 'The preview plans to skip this item.',
],
'failed' => [
'axis' => 'item_result',
'label' => 'Cannot apply',
'color' => 'danger',
'classification' => 'primary',
'next_action_policy' => 'required',
'legacy_aliases' => ['Failed'],
'notes' => 'The preview could not produce a viable action for this item.',
],
],
'restore_check_severity' => [
'blocking' => [
'axis' => 'operator_actionability',
'label' => 'Fix before running',
'color' => 'danger',
'classification' => 'primary',
'next_action_policy' => 'required',
'legacy_aliases' => ['Blocking'],
'notes' => 'Execution should not proceed until this check is fixed.',
],
'warning' => [
'axis' => 'operator_actionability',
'label' => 'Review before running',
'color' => 'warning',
'classification' => 'primary',
'next_action_policy' => 'optional',
'legacy_aliases' => ['Warning'],
'notes' => 'Execution may proceed, but the operator should review the warning first.',
],
'safe' => [
'axis' => 'operator_actionability',
'label' => 'Ready to continue',
'color' => 'success',
'classification' => 'primary',
'next_action_policy' => 'none',
'legacy_aliases' => ['Safe'],
'notes' => 'No blocking issue was found for this check.',
],
],
];
/**
* @return array{
* axis: OperatorSemanticAxis,
* label: string,
* color: string,
* classification: OperatorStateClassification,
* next_action_policy: OperatorNextActionPolicy,
* legacy_aliases: list<string>,
* diagnostic_label: ?string,
* notes: string
* }|null
*/
public static function entry(BadgeDomain $domain, mixed $value): ?array
{
$state = BadgeCatalog::normalizeState($value);
if ($state === null) {
return null;
}
if ($domain === BadgeDomain::OperationRunOutcome && $state === 'operation.blocked') {
$state = 'blocked';
}
$entry = self::ENTRIES[$domain->value][$state] ?? null;
if (! is_array($entry)) {
return null;
}
return [
'axis' => self::axisFrom($entry['axis']),
'label' => $entry['label'],
'color' => $entry['color'],
'classification' => self::classificationFrom($entry['classification']),
'next_action_policy' => self::nextActionPolicyFrom($entry['next_action_policy']),
'legacy_aliases' => $entry['legacy_aliases'],
'diagnostic_label' => $entry['diagnostic_label'] ?? null,
'notes' => $entry['notes'],
];
}
/**
* @return array<string, array<string, array{
* axis: OperatorSemanticAxis,
* label: string,
* color: string,
* classification: OperatorStateClassification,
* next_action_policy: OperatorNextActionPolicy,
* legacy_aliases: list<string>,
* diagnostic_label: ?string,
* notes: string
* }>>
*/
public static function all(): array
{
$entries = [];
foreach (self::ENTRIES as $domain => $mappings) {
foreach ($mappings as $state => $entry) {
$entries[$domain][$state] = [
'axis' => self::axisFrom($entry['axis']),
'label' => $entry['label'],
'color' => $entry['color'],
'classification' => self::classificationFrom($entry['classification']),
'next_action_policy' => self::nextActionPolicyFrom($entry['next_action_policy']),
'legacy_aliases' => $entry['legacy_aliases'],
'diagnostic_label' => $entry['diagnostic_label'] ?? null,
'notes' => $entry['notes'],
];
}
}
return $entries;
}
/**
* @return list<array{name: string, domain: BadgeDomain, raw_value: string}>
*/
public static function curatedExamples(): array
{
return [
['name' => 'Operation blocked by missing prerequisite', 'domain' => BadgeDomain::OperationRunOutcome, 'raw_value' => 'blocked'],
['name' => 'Artifact exists but is not usable', 'domain' => BadgeDomain::GovernanceArtifactExistence, 'raw_value' => 'created_but_not_usable'],
['name' => 'Artifact is trustworthy', 'domain' => BadgeDomain::GovernanceArtifactContent, 'raw_value' => 'trusted'],
['name' => 'Artifact is stale', 'domain' => BadgeDomain::GovernanceArtifactFreshness, 'raw_value' => 'stale'],
['name' => 'Artifact is publishable', 'domain' => BadgeDomain::GovernanceArtifactPublicationReadiness, 'raw_value' => 'publishable'],
['name' => 'Artifact requires action', 'domain' => BadgeDomain::GovernanceArtifactActionability, 'raw_value' => 'required'],
['name' => 'Operation completed with follow-up', 'domain' => BadgeDomain::OperationRunOutcome, 'raw_value' => 'partially_succeeded'],
['name' => 'Operation completed successfully', 'domain' => BadgeDomain::OperationRunOutcome, 'raw_value' => 'succeeded'],
['name' => 'Evidence not collected yet', 'domain' => BadgeDomain::EvidenceCompleteness, 'raw_value' => 'missing'],
['name' => 'Evidence refresh recommended', 'domain' => BadgeDomain::EvidenceCompleteness, 'raw_value' => 'stale'],
['name' => 'Review input pending', 'domain' => BadgeDomain::TenantReviewCompleteness, 'raw_value' => 'missing'],
['name' => 'Mixed evidence detail stays diagnostic', 'domain' => BadgeDomain::BaselineSnapshotFidelity, 'raw_value' => 'partial'],
['name' => 'Support limited stays diagnostic', 'domain' => BadgeDomain::BaselineSnapshotFidelity, 'raw_value' => 'unsupported'],
['name' => 'Coverage gaps need review', 'domain' => BadgeDomain::BaselineSnapshotGapStatus, 'raw_value' => 'gaps_present'],
['name' => 'Restore preview blocked by a check', 'domain' => BadgeDomain::RestoreCheckSeverity, 'raw_value' => 'blocking'],
['name' => 'Restore run applied with follow-up', 'domain' => BadgeDomain::RestoreRunStatus, 'raw_value' => 'completed_with_errors'],
['name' => 'Restore item requires manual follow-up', 'domain' => BadgeDomain::RestoreResultStatus, 'raw_value' => 'manual_required'],
];
}
public static function spec(
BadgeDomain $domain,
mixed $value,
?string $icon = null,
?string $iconColor = null,
): ?BadgeSpec {
$entry = self::entry($domain, $value);
if ($entry === null) {
return null;
}
return new BadgeSpec(
label: $entry['label'],
color: $entry['color'],
icon: $icon,
iconColor: $iconColor,
semanticAxis: $entry['axis'],
classification: $entry['classification'],
nextActionPolicy: $entry['next_action_policy'],
diagnosticLabel: $entry['diagnostic_label'],
legacyAliases: $entry['legacy_aliases'],
notes: $entry['notes'],
);
}
private static function axisFrom(string $value): OperatorSemanticAxis
{
return OperatorSemanticAxis::tryFrom($value)
?? throw new InvalidArgumentException("Unknown operator semantic axis [{$value}].");
}
private static function classificationFrom(string $value): OperatorStateClassification
{
return OperatorStateClassification::tryFrom($value)
?? throw new InvalidArgumentException("Unknown operator state classification [{$value}].");
}
private static function nextActionPolicyFrom(string $value): OperatorNextActionPolicy
{
return OperatorNextActionPolicy::tryFrom($value)
?? throw new InvalidArgumentException("Unknown operator next-action policy [{$value}].");
}
}

View File

@ -0,0 +1,54 @@
<?php
declare(strict_types=1);
namespace App\Support\Badges;
enum OperatorSemanticAxis: string
{
case ArtifactExistence = 'artifact_existence';
case ExecutionLifecycle = 'execution_lifecycle';
case ExecutionOutcome = 'execution_outcome';
case ItemResult = 'item_result';
case DataCoverage = 'data_coverage';
case EvidenceDepth = 'evidence_depth';
case ProductSupportMaturity = 'product_support_maturity';
case DataFreshness = 'data_freshness';
case OperatorActionability = 'operator_actionability';
case PublicationReadiness = 'publication_readiness';
case GovernanceDeviation = 'governance_deviation';
public function label(): string
{
return match ($this) {
self::ArtifactExistence => 'Artifact existence',
self::ExecutionLifecycle => 'Execution lifecycle',
self::ExecutionOutcome => 'Execution outcome',
self::ItemResult => 'Item result',
self::DataCoverage => 'Data coverage',
self::EvidenceDepth => 'Evidence depth',
self::ProductSupportMaturity => 'Product support maturity',
self::DataFreshness => 'Data freshness',
self::OperatorActionability => 'Operator actionability',
self::PublicationReadiness => 'Publication readiness',
self::GovernanceDeviation => 'Governance deviation',
};
}
public function definition(): string
{
return match ($this) {
self::ArtifactExistence => 'Whether the intended governance artifact actually exists and can be located.',
self::ExecutionLifecycle => 'Where a run sits in its execution flow.',
self::ExecutionOutcome => 'What happened when execution finished or stopped.',
self::ItemResult => 'How one restore or preview item resolved.',
self::DataCoverage => 'Whether the expected data or sections are present.',
self::EvidenceDepth => 'How much structured evidence detail is available.',
self::ProductSupportMaturity => 'Whether the product can represent the source faithfully.',
self::DataFreshness => 'Whether the available data is still current enough to trust.',
self::OperatorActionability => 'Whether an operator needs to do anything next.',
self::PublicationReadiness => 'Whether the current record is ready for stakeholder delivery.',
self::GovernanceDeviation => 'Whether the record represents a real governance problem.',
};
}
}

View File

@ -0,0 +1,16 @@
<?php
declare(strict_types=1);
namespace App\Support\Badges;
enum OperatorStateClassification: string
{
case Primary = 'primary';
case Diagnostic = 'diagnostic';
public function isDiagnostic(): bool
{
return $this === self::Diagnostic;
}
}

View File

@ -0,0 +1,203 @@
<?php
declare(strict_types=1);
namespace App\Support\Baselines;
use App\Support\ReasonTranslation\ReasonPresenter;
use App\Support\Ui\OperatorExplanation\CountDescriptor;
use App\Support\Ui\OperatorExplanation\ExplanationFamily;
use App\Support\Ui\OperatorExplanation\OperatorExplanationBuilder;
use App\Support\Ui\OperatorExplanation\OperatorExplanationPattern;
use App\Support\Ui\OperatorExplanation\TrustworthinessLevel;
final class BaselineCompareExplanationRegistry
{
public function __construct(
private readonly OperatorExplanationBuilder $builder,
private readonly ReasonPresenter $reasonPresenter,
) {}
public function forStats(BaselineCompareStats $stats): OperatorExplanationPattern
{
$reason = $stats->reasonCode !== null
? $this->reasonPresenter->forArtifactTruth($stats->reasonCode, 'baseline_compare')
: null;
$hasCoverageWarnings = in_array($stats->coverageStatus, ['warning', 'unproven'], true);
$hasEvidenceGaps = (int) ($stats->evidenceGapsCount ?? 0) > 0;
$hasWarnings = $hasCoverageWarnings || $hasEvidenceGaps;
$findingsCount = (int) ($stats->findingsCount ?? 0);
$executionOutcome = match ($stats->state) {
'comparing' => 'in_progress',
'failed' => 'failed',
default => $hasWarnings ? 'completed_with_follow_up' : 'completed',
};
$executionOutcomeLabel = match ($executionOutcome) {
'in_progress' => 'In progress',
'failed' => 'Execution failed',
'completed_with_follow_up' => 'Completed with follow-up',
default => 'Completed successfully',
};
$family = $reason?->absencePattern !== null
? ExplanationFamily::tryFrom(str_replace('true_no_result', 'no_issues_detected', $reason->absencePattern)) ?? null
: null;
$family ??= match (true) {
$stats->state === 'comparing' => ExplanationFamily::InProgress,
$stats->state === 'failed' => ExplanationFamily::BlockedPrerequisite,
$stats->state === 'no_tenant',
$stats->state === 'no_assignment',
$stats->state === 'no_snapshot',
$stats->state === 'idle' => ExplanationFamily::Unavailable,
$findingsCount === 0 && ! $hasWarnings => ExplanationFamily::NoIssuesDetected,
$hasWarnings => ExplanationFamily::CompletedButLimited,
default => ExplanationFamily::TrustworthyResult,
};
$trustworthiness = $reason?->trustImpact !== null
? TrustworthinessLevel::tryFrom($reason->trustImpact)
: null;
$trustworthiness ??= match (true) {
$family === ExplanationFamily::NoIssuesDetected,
$family === ExplanationFamily::TrustworthyResult => TrustworthinessLevel::Trustworthy,
$family === ExplanationFamily::CompletedButLimited => TrustworthinessLevel::LimitedConfidence,
$family === ExplanationFamily::InProgress => TrustworthinessLevel::DiagnosticOnly,
default => TrustworthinessLevel::Unusable,
};
$evaluationResult = match ($family) {
ExplanationFamily::TrustworthyResult => 'full_result',
ExplanationFamily::NoIssuesDetected => 'no_result',
ExplanationFamily::SuppressedOutput => 'suppressed_result',
ExplanationFamily::MissingInput,
ExplanationFamily::BlockedPrerequisite,
ExplanationFamily::Unavailable,
ExplanationFamily::InProgress => 'unavailable',
ExplanationFamily::CompletedButLimited => $reason?->absencePattern === 'suppressed_output'
? 'suppressed_result'
: 'incomplete_result',
};
$headline = match ($family) {
ExplanationFamily::NoIssuesDetected => 'The comparison found no actionable drift.',
ExplanationFamily::TrustworthyResult => 'The comparison produced a usable drift result.',
ExplanationFamily::CompletedButLimited => $findingsCount > 0
? 'The comparison found drift, but the result needs caution.'
: 'The comparison finished, but the current result is not an all-clear.',
ExplanationFamily::SuppressedOutput => 'The comparison finished, but normal result output was suppressed.',
ExplanationFamily::MissingInput => 'The comparison could not produce a usable result because required inputs were missing.',
ExplanationFamily::BlockedPrerequisite => 'The comparison could not produce a usable result because a prerequisite blocked it.',
ExplanationFamily::InProgress => 'The comparison is still running.',
ExplanationFamily::Unavailable => 'A usable comparison result is not currently available.',
};
$coverageStatement = match (true) {
$stats->state === 'idle' => 'No compare run has produced a result yet for this tenant.',
$stats->coverageStatus === 'unproven' => 'Coverage proof was unavailable, so suppressed output is possible even when the findings count is zero.',
$stats->uncoveredTypes !== [] => 'One or more in-scope policy types were not fully covered in this compare run.',
$hasEvidenceGaps => 'Evidence gaps limited the quality of the visible result for some subjects.',
$stats->state === 'comparing' => 'Counts will become decision-grade after the compare run finishes.',
in_array($family, [ExplanationFamily::MissingInput, ExplanationFamily::BlockedPrerequisite, ExplanationFamily::Unavailable], true) => $reason?->shortExplanation ?? 'The compare inputs were not complete enough to produce a normal result.',
default => 'Coverage matched the in-scope compare input for this run.',
};
$reliabilityStatement = match ($trustworthiness) {
TrustworthinessLevel::Trustworthy => $findingsCount > 0
? 'The compare completed with enough coverage to treat the recorded drift as decision-grade.'
: 'The compare completed with enough coverage to treat the absence of findings as trustworthy.',
TrustworthinessLevel::LimitedConfidence => 'The compare completed, but coverage or evidence limitations mean the visible result should be treated as partial.',
TrustworthinessLevel::DiagnosticOnly => 'The compare is still in progress, so current counts are only diagnostic.',
TrustworthinessLevel::Unusable => 'The compare did not produce a result that should be used for the intended decision yet.',
};
$nextActionText = $reason?->firstNextStep()?->label ?? match ($family) {
ExplanationFamily::NoIssuesDetected => 'No action needed',
ExplanationFamily::TrustworthyResult => 'Review the detected drift findings',
ExplanationFamily::SuppressedOutput => 'Review the missing coverage or evidence before treating this compare as complete',
ExplanationFamily::CompletedButLimited => 'Review coverage limits before acting on this compare',
ExplanationFamily::InProgress => 'Wait for the compare to finish',
ExplanationFamily::MissingInput,
ExplanationFamily::BlockedPrerequisite,
ExplanationFamily::Unavailable => $stats->state === 'idle'
? 'Run the baseline compare to generate a result'
: 'Review the blocking baseline or scope prerequisite',
};
return $this->builder->build(
family: $family,
headline: $headline,
executionOutcome: $executionOutcome,
executionOutcomeLabel: $executionOutcomeLabel,
evaluationResult: $evaluationResult,
trustworthinessLevel: $trustworthiness,
reliabilityStatement: $reliabilityStatement,
coverageStatement: $coverageStatement,
dominantCauseCode: $reason?->internalCode ?? $stats->reasonCode,
dominantCauseLabel: $reason?->operatorLabel,
dominantCauseExplanation: $reason?->shortExplanation ?? $stats->message ?? $stats->failureReason,
nextActionCategory: $family === ExplanationFamily::NoIssuesDetected
? 'none'
: match ($family) {
ExplanationFamily::TrustworthyResult => 'manual_validate',
ExplanationFamily::MissingInput,
ExplanationFamily::BlockedPrerequisite,
ExplanationFamily::Unavailable => 'fix_prerequisite',
default => 'review_evidence_gaps',
},
nextActionText: $nextActionText,
countDescriptors: $this->countDescriptors($stats, $hasCoverageWarnings, $hasEvidenceGaps),
diagnosticsAvailable: $stats->operationRunId !== null || $reason !== null,
diagnosticsSummary: 'Run evidence and low-level compare diagnostics remain available below the primary explanation.',
);
}
/**
* @return array<int, CountDescriptor>
*/
private function countDescriptors(
BaselineCompareStats $stats,
bool $hasCoverageWarnings,
bool $hasEvidenceGaps,
): array {
$descriptors = [];
if ($stats->findingsCount !== null) {
$descriptors[] = new CountDescriptor(
label: 'Findings shown',
value: (int) $stats->findingsCount,
role: CountDescriptor::ROLE_EVALUATION_OUTPUT,
qualifier: $hasCoverageWarnings || $hasEvidenceGaps ? 'not complete' : null,
);
}
if ($stats->uncoveredTypesCount !== null) {
$descriptors[] = new CountDescriptor(
label: 'Uncovered types',
value: (int) $stats->uncoveredTypesCount,
role: CountDescriptor::ROLE_COVERAGE,
qualifier: (int) $stats->uncoveredTypesCount > 0 ? 'coverage gap' : null,
);
}
if ($stats->evidenceGapsCount !== null) {
$descriptors[] = new CountDescriptor(
label: 'Evidence gaps',
value: (int) $stats->evidenceGapsCount,
role: CountDescriptor::ROLE_RELIABILITY_SIGNAL,
qualifier: (int) $stats->evidenceGapsCount > 0 ? 'review needed' : null,
);
}
if ($stats->severityCounts !== []) {
foreach (['high' => 'High severity', 'medium' => 'Medium severity', 'low' => 'Low severity'] as $key => $label) {
$value = (int) ($stats->severityCounts[$key] ?? 0);
if ($value === 0) {
continue;
}
$descriptors[] = new CountDescriptor(
label: $label,
value: $value,
role: CountDescriptor::ROLE_EVALUATION_OUTPUT,
visibilityTier: CountDescriptor::VISIBILITY_DIAGNOSTIC,
);
}
}
return $descriptors;
}
}

View File

@ -4,6 +4,9 @@
namespace App\Support\Baselines; namespace App\Support\Baselines;
use App\Support\Ui\OperatorExplanation\ExplanationFamily;
use App\Support\Ui\OperatorExplanation\TrustworthinessLevel;
enum BaselineCompareReasonCode: string enum BaselineCompareReasonCode: string
{ {
case NoSubjectsInScope = 'no_subjects_in_scope'; case NoSubjectsInScope = 'no_subjects_in_scope';
@ -22,4 +25,37 @@ public function message(): string
self::NoDriftDetected => 'No drift was detected for in-scope subjects.', self::NoDriftDetected => 'No drift was detected for in-scope subjects.',
}; };
} }
public function explanationFamily(): ExplanationFamily
{
return match ($this) {
self::NoDriftDetected => ExplanationFamily::NoIssuesDetected,
self::CoverageUnproven,
self::EvidenceCaptureIncomplete,
self::RolloutDisabled => ExplanationFamily::CompletedButLimited,
self::NoSubjectsInScope => ExplanationFamily::MissingInput,
};
}
public function trustworthinessLevel(): TrustworthinessLevel
{
return match ($this) {
self::NoDriftDetected => TrustworthinessLevel::Trustworthy,
self::CoverageUnproven,
self::EvidenceCaptureIncomplete => TrustworthinessLevel::LimitedConfidence,
self::RolloutDisabled,
self::NoSubjectsInScope => TrustworthinessLevel::Unusable,
};
}
public function absencePattern(): ?string
{
return match ($this) {
self::NoDriftDetected => 'true_no_result',
self::CoverageUnproven,
self::EvidenceCaptureIncomplete => 'suppressed_output',
self::RolloutDisabled => 'blocked_prerequisite',
self::NoSubjectsInScope => 'missing_input',
};
}
} }

View File

@ -5,13 +5,17 @@
namespace App\Support\Baselines; namespace App\Support\Baselines;
use App\Models\BaselineProfile; use App\Models\BaselineProfile;
use App\Models\BaselineSnapshot;
use App\Models\BaselineTenantAssignment; use App\Models\BaselineTenantAssignment;
use App\Models\Finding; use App\Models\Finding;
use App\Models\InventoryItem; use App\Models\InventoryItem;
use App\Models\OperationRun; use App\Models\OperationRun;
use App\Models\Tenant; use App\Models\Tenant;
use App\Services\Baselines\BaselineSnapshotTruthResolver;
use App\Support\OperationRunStatus; use App\Support\OperationRunStatus;
use App\Support\OperationRunType; use App\Support\OperationRunType;
use App\Support\Ui\OperatorExplanation\CountDescriptor;
use App\Support\Ui\OperatorExplanation\OperatorExplanationPattern;
use Illuminate\Support\Facades\Cache; use Illuminate\Support\Facades\Cache;
final class BaselineCompareStats final class BaselineCompareStats
@ -73,7 +77,11 @@ public static function forTenant(?Tenant $tenant): self
$profileName = (string) $profile->name; $profileName = (string) $profile->name;
$profileId = (int) $profile->getKey(); $profileId = (int) $profile->getKey();
$snapshotId = $profile->active_snapshot_id !== null ? (int) $profile->active_snapshot_id : null; $truthResolution = app(BaselineSnapshotTruthResolver::class)->resolveCompareSnapshot($profile);
$effectiveSnapshot = $truthResolution['effective_snapshot'] ?? null;
$snapshotId = $effectiveSnapshot instanceof BaselineSnapshot ? (int) $effectiveSnapshot->getKey() : null;
$snapshotReasonCode = is_string($truthResolution['reason_code'] ?? null) ? (string) $truthResolution['reason_code'] : null;
$snapshotReasonMessage = self::missingSnapshotMessage($snapshotReasonCode);
$profileScope = BaselineScope::fromJsonb( $profileScope = BaselineScope::fromJsonb(
is_array($profile->scope_jsonb) ? $profile->scope_jsonb : null, is_array($profile->scope_jsonb) ? $profile->scope_jsonb : null,
@ -86,12 +94,21 @@ public static function forTenant(?Tenant $tenant): self
$duplicateNamePoliciesCount = self::duplicateNamePoliciesCount($tenant, $effectiveScope); $duplicateNamePoliciesCount = self::duplicateNamePoliciesCount($tenant, $effectiveScope);
if ($snapshotId === null) { if ($snapshotId === null) {
return self::empty( return new self(
'no_snapshot', state: 'no_snapshot',
'The baseline profile has no active snapshot yet. A workspace manager needs to capture a snapshot first.', message: $snapshotReasonMessage ?? 'The baseline profile has no complete snapshot yet. A workspace manager needs to capture a baseline first.',
profileName: $profileName, profileName: $profileName,
profileId: $profileId, profileId: $profileId,
snapshotId: null,
duplicateNamePoliciesCount: $duplicateNamePoliciesCount, duplicateNamePoliciesCount: $duplicateNamePoliciesCount,
operationRunId: null,
findingsCount: null,
severityCounts: [],
lastComparedHuman: null,
lastComparedIso: null,
failureReason: null,
reasonCode: $snapshotReasonCode,
reasonMessage: $snapshotReasonMessage,
); );
} }
@ -291,6 +308,11 @@ public static function forWidget(?Tenant $tenant): self
} }
$profile = $assignment->baselineProfile; $profile = $assignment->baselineProfile;
$truthResolution = app(BaselineSnapshotTruthResolver::class)->resolveCompareSnapshot($profile);
$effectiveSnapshot = $truthResolution['effective_snapshot'] ?? null;
$snapshotId = $effectiveSnapshot instanceof BaselineSnapshot ? (int) $effectiveSnapshot->getKey() : null;
$snapshotReasonCode = is_string($truthResolution['reason_code'] ?? null) ? (string) $truthResolution['reason_code'] : null;
$snapshotReasonMessage = self::missingSnapshotMessage($snapshotReasonCode);
$scopeKey = 'baseline_profile:'.$profile->getKey(); $scopeKey = 'baseline_profile:'.$profile->getKey();
$severityRows = Finding::query() $severityRows = Finding::query()
@ -314,11 +336,11 @@ public static function forWidget(?Tenant $tenant): self
->first(); ->first();
return new self( return new self(
state: $totalFindings > 0 ? 'ready' : 'idle', state: $snapshotId === null ? 'no_snapshot' : ($totalFindings > 0 ? 'ready' : 'idle'),
message: null, message: $snapshotId === null ? $snapshotReasonMessage : null,
profileName: (string) $profile->name, profileName: (string) $profile->name,
profileId: (int) $profile->getKey(), profileId: (int) $profile->getKey(),
snapshotId: $profile->active_snapshot_id !== null ? (int) $profile->active_snapshot_id : null, snapshotId: $snapshotId,
duplicateNamePoliciesCount: null, duplicateNamePoliciesCount: null,
operationRunId: $latestRun instanceof OperationRun ? (int) $latestRun->getKey() : null, operationRunId: $latestRun instanceof OperationRun ? (int) $latestRun->getKey() : null,
findingsCount: $totalFindings, findingsCount: $totalFindings,
@ -330,6 +352,8 @@ public static function forWidget(?Tenant $tenant): self
lastComparedHuman: $latestRun?->finished_at?->diffForHumans(), lastComparedHuman: $latestRun?->finished_at?->diffForHumans(),
lastComparedIso: $latestRun?->finished_at?->toIso8601String(), lastComparedIso: $latestRun?->finished_at?->toIso8601String(),
failureReason: null, failureReason: null,
reasonCode: $snapshotReasonCode,
reasonMessage: $snapshotReasonMessage,
); );
} }
@ -561,6 +585,31 @@ private static function rbacRoleDefinitionSummaryForRun(?OperationRun $run): ?ar
]; ];
} }
public function operatorExplanation(): OperatorExplanationPattern
{
/** @var BaselineCompareExplanationRegistry $registry */
$registry = app(BaselineCompareExplanationRegistry::class);
return $registry->forStats($this);
}
/**
* @return array<int, array{
* label: string,
* value: int,
* role: string,
* qualifier: ?string,
* visibilityTier: string
* }>
*/
public function explanationCountDescriptors(): array
{
return array_map(
static fn (CountDescriptor $descriptor): array => $descriptor->toArray(),
$this->operatorExplanation()->countDescriptors,
);
}
private static function empty( private static function empty(
string $state, string $state,
?string $message, ?string $message,
@ -583,4 +632,15 @@ private static function empty(
failureReason: null, failureReason: null,
); );
} }
private static function missingSnapshotMessage(?string $reasonCode): ?string
{
return match ($reasonCode) {
BaselineReasonCodes::COMPARE_SNAPSHOT_BUILDING => 'The latest baseline capture is still building. Compare becomes available after a complete snapshot is finalized.',
BaselineReasonCodes::COMPARE_SNAPSHOT_INCOMPLETE => 'The latest baseline capture is incomplete and there is no current complete snapshot to compare against.',
BaselineReasonCodes::COMPARE_NO_CONSUMABLE_SNAPSHOT,
BaselineReasonCodes::COMPARE_NO_ACTIVE_SNAPSHOT => 'The baseline profile has no complete snapshot yet. A workspace manager needs to capture a baseline first.',
default => null,
};
}
} }

View File

@ -18,13 +18,125 @@ final class BaselineReasonCodes
public const string CAPTURE_ROLLOUT_DISABLED = 'baseline.capture.rollout_disabled'; public const string CAPTURE_ROLLOUT_DISABLED = 'baseline.capture.rollout_disabled';
public const string SNAPSHOT_BUILDING = 'baseline.snapshot.building';
public const string SNAPSHOT_INCOMPLETE = 'baseline.snapshot.incomplete';
public const string SNAPSHOT_SUPERSEDED = 'baseline.snapshot.superseded';
public const string SNAPSHOT_CAPTURE_FAILED = 'baseline.snapshot.capture_failed';
public const string SNAPSHOT_COMPLETION_PROOF_FAILED = 'baseline.snapshot.completion_proof_failed';
public const string SNAPSHOT_LEGACY_NO_PROOF = 'baseline.snapshot.legacy_no_proof';
public const string SNAPSHOT_LEGACY_CONTRADICTORY = 'baseline.snapshot.legacy_contradictory';
public const string COMPARE_NO_ASSIGNMENT = 'baseline.compare.no_assignment'; public const string COMPARE_NO_ASSIGNMENT = 'baseline.compare.no_assignment';
public const string COMPARE_PROFILE_NOT_ACTIVE = 'baseline.compare.profile_not_active'; public const string COMPARE_PROFILE_NOT_ACTIVE = 'baseline.compare.profile_not_active';
public const string COMPARE_NO_ACTIVE_SNAPSHOT = 'baseline.compare.no_active_snapshot'; public const string COMPARE_NO_ACTIVE_SNAPSHOT = 'baseline.compare.no_active_snapshot';
public const string COMPARE_NO_CONSUMABLE_SNAPSHOT = 'baseline.compare.no_consumable_snapshot';
public const string COMPARE_NO_ELIGIBLE_TARGET = 'baseline.compare.no_eligible_target';
public const string COMPARE_INVALID_SNAPSHOT = 'baseline.compare.invalid_snapshot'; public const string COMPARE_INVALID_SNAPSHOT = 'baseline.compare.invalid_snapshot';
public const string COMPARE_ROLLOUT_DISABLED = 'baseline.compare.rollout_disabled'; public const string COMPARE_ROLLOUT_DISABLED = 'baseline.compare.rollout_disabled';
public const string COMPARE_SNAPSHOT_BUILDING = 'baseline.compare.snapshot_building';
public const string COMPARE_SNAPSHOT_INCOMPLETE = 'baseline.compare.snapshot_incomplete';
public const string COMPARE_SNAPSHOT_SUPERSEDED = 'baseline.compare.snapshot_superseded';
/**
* @return array<int, string>
*/
public static function all(): array
{
return [
self::CAPTURE_MISSING_SOURCE_TENANT,
self::CAPTURE_PROFILE_NOT_ACTIVE,
self::CAPTURE_ROLLOUT_DISABLED,
self::SNAPSHOT_BUILDING,
self::SNAPSHOT_INCOMPLETE,
self::SNAPSHOT_SUPERSEDED,
self::SNAPSHOT_CAPTURE_FAILED,
self::SNAPSHOT_COMPLETION_PROOF_FAILED,
self::SNAPSHOT_LEGACY_NO_PROOF,
self::SNAPSHOT_LEGACY_CONTRADICTORY,
self::COMPARE_NO_ASSIGNMENT,
self::COMPARE_PROFILE_NOT_ACTIVE,
self::COMPARE_NO_ACTIVE_SNAPSHOT,
self::COMPARE_NO_CONSUMABLE_SNAPSHOT,
self::COMPARE_NO_ELIGIBLE_TARGET,
self::COMPARE_INVALID_SNAPSHOT,
self::COMPARE_ROLLOUT_DISABLED,
self::COMPARE_SNAPSHOT_BUILDING,
self::COMPARE_SNAPSHOT_INCOMPLETE,
self::COMPARE_SNAPSHOT_SUPERSEDED,
];
}
public static function isKnown(?string $reasonCode): bool
{
return is_string($reasonCode) && in_array(trim($reasonCode), self::all(), true);
}
public static function trustImpact(?string $reasonCode): ?string
{
return match (trim((string) $reasonCode)) {
self::SNAPSHOT_CAPTURE_FAILED => 'limited_confidence',
self::COMPARE_ROLLOUT_DISABLED,
self::CAPTURE_ROLLOUT_DISABLED,
self::SNAPSHOT_BUILDING,
self::SNAPSHOT_INCOMPLETE,
self::SNAPSHOT_SUPERSEDED,
self::SNAPSHOT_COMPLETION_PROOF_FAILED,
self::SNAPSHOT_LEGACY_NO_PROOF,
self::SNAPSHOT_LEGACY_CONTRADICTORY,
self::COMPARE_NO_ASSIGNMENT,
self::COMPARE_PROFILE_NOT_ACTIVE,
self::COMPARE_NO_ACTIVE_SNAPSHOT,
self::COMPARE_NO_CONSUMABLE_SNAPSHOT,
self::COMPARE_NO_ELIGIBLE_TARGET,
self::COMPARE_INVALID_SNAPSHOT,
self::COMPARE_SNAPSHOT_BUILDING,
self::COMPARE_SNAPSHOT_INCOMPLETE,
self::COMPARE_SNAPSHOT_SUPERSEDED,
self::CAPTURE_MISSING_SOURCE_TENANT,
self::CAPTURE_PROFILE_NOT_ACTIVE => 'unusable',
default => null,
};
}
public static function absencePattern(?string $reasonCode): ?string
{
return match (trim((string) $reasonCode)) {
self::SNAPSHOT_BUILDING,
self::SNAPSHOT_INCOMPLETE,
self::SNAPSHOT_COMPLETION_PROOF_FAILED,
self::SNAPSHOT_LEGACY_NO_PROOF,
self::SNAPSHOT_LEGACY_CONTRADICTORY,
self::COMPARE_NO_ACTIVE_SNAPSHOT,
self::COMPARE_NO_CONSUMABLE_SNAPSHOT,
self::COMPARE_SNAPSHOT_BUILDING,
self::COMPARE_SNAPSHOT_INCOMPLETE => 'missing_input',
self::CAPTURE_MISSING_SOURCE_TENANT,
self::CAPTURE_PROFILE_NOT_ACTIVE,
self::CAPTURE_ROLLOUT_DISABLED,
self::COMPARE_NO_ASSIGNMENT,
self::COMPARE_PROFILE_NOT_ACTIVE,
self::COMPARE_NO_ELIGIBLE_TARGET,
self::COMPARE_INVALID_SNAPSHOT,
self::COMPARE_ROLLOUT_DISABLED,
self::SNAPSHOT_SUPERSEDED,
self::COMPARE_SNAPSHOT_SUPERSEDED => 'blocked_prerequisite',
self::SNAPSHOT_CAPTURE_FAILED => 'unavailable',
default => null,
};
}
} }

View File

@ -0,0 +1,42 @@
<?php
declare(strict_types=1);
namespace App\Support\Baselines;
enum BaselineSnapshotLifecycleState: string
{
case Building = 'building';
case Complete = 'complete';
case Incomplete = 'incomplete';
public function label(): string
{
return match ($this) {
self::Building => 'Building',
self::Complete => 'Complete',
self::Incomplete => 'Incomplete',
};
}
public function isConsumable(): bool
{
return $this === self::Complete;
}
public function isTerminal(): bool
{
return in_array($this, [self::Complete, self::Incomplete], true);
}
/**
* @return array<int, string>
*/
public static function values(): array
{
return array_map(
static fn (self $state): string => $state->value,
self::cases(),
);
}
}

View File

@ -230,9 +230,9 @@ public static function platforms(?iterable $platforms = null): array
public static function restoreRunOutcomes(): array public static function restoreRunOutcomes(): array
{ {
return [ return [
'succeeded' => 'Succeeded', 'succeeded' => BadgeCatalog::spec(BadgeDomain::RestoreRunStatus, RestoreRunStatus::Completed->value)->label,
'partial' => 'Partial', 'partial' => BadgeCatalog::spec(BadgeDomain::RestoreRunStatus, RestoreRunStatus::Partial->value)->label,
'failed' => 'Failed', 'failed' => BadgeCatalog::spec(BadgeDomain::RestoreRunStatus, RestoreRunStatus::Failed->value)->label,
]; ];
} }

View File

@ -0,0 +1,27 @@
<?php
namespace App\Support\Filament;
use Illuminate\Support\Facades\Vite;
class PanelThemeAsset
{
public static function resolve(string $entry): string
{
$manifest = public_path('build/manifest.json');
if (! is_file($manifest)) {
return Vite::asset($entry);
}
/** @var array<string, array{file?: string}>|null $decoded */
$decoded = json_decode((string) file_get_contents($manifest), true);
$file = $decoded[$entry]['file'] ?? null;
if (! is_string($file) || $file === '') {
return Vite::asset($entry);
}
return asset('build/'.$file);
}
}

View File

@ -378,7 +378,7 @@ private function resolveBaselineProfileRule(NavigationMatrixRule $rule, Baseline
return match ($rule->relationKey) { return match ($rule->relationKey) {
'baseline_snapshot' => $this->baselineSnapshotEntry( 'baseline_snapshot' => $this->baselineSnapshotEntry(
rule: $rule, rule: $rule,
snapshotId: is_numeric($profile->active_snapshot_id ?? null) ? (int) $profile->active_snapshot_id : null, snapshotId: $profile->resolveCurrentConsumableSnapshot()?->getKey(),
workspaceId: (int) $profile->workspace_id, workspaceId: (int) $profile->workspace_id,
), ),
default => null, default => null,

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