Compare commits

..

8 Commits

Author SHA1 Message Date
02e75e1cda feat: harden baseline compare summary trust surfaces (#196)
## Summary
- add a shared baseline compare summary assessment and assessor for compact trust propagation
- harden dashboard, landing, and banner baseline compare surfaces against false all-clear claims
- add focused Pest coverage for dashboard, landing, banner, reason translation, and canonical detail parity

## Validation
- vendor/bin/sail bin pint --dirty --format agent
- vendor/bin/sail artisan test --compact tests/Feature/Baselines/BaselineCompareSummaryAssessmentTest.php tests/Feature/Baselines/BaselineCompareExplanationFallbackTest.php tests/Feature/Filament/BaselineCompareNowWidgetTest.php tests/Feature/Filament/NeedsAttentionWidgetTest.php tests/Feature/Filament/BaselineCompareExplanationSurfaceTest.php tests/Feature/Filament/BaselineCompareLandingWhyNoFindingsTest.php tests/Feature/Filament/BaselineCompareCoverageBannerTest.php tests/Feature/Filament/BaselineCompareSummaryConsistencyTest.php tests/Feature/Filament/OperationRunBaselineTruthSurfaceTest.php tests/Feature/ReasonTranslation/ReasonTranslationExplanationTest.php

## Notes
- Livewire compliance: Filament v5 / Livewire v4 stack unchanged
- Provider registration: unchanged, Laravel 12 providers remain in bootstrap/providers.php
- Global search: no searchable resource behavior changed
- Destructive actions: none introduced by this change
- Assets: no new assets registered; existing deploy process remains unchanged

Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de>
Reviewed-on: #196
2026-03-27 00:19:53 +00:00
20b6aa6a32 refactor: reduce operation run detail density (#194)
## Summary
- collapse secondary and diagnostic operation-run sections by default to reduce page density
- visually emphasize the primary next step while keeping counts readable but secondary
- keep failures and other actionable detail available without dominating the default reading path

## Testing
- vendor/bin/sail artisan test --compact tests/Feature/Filament/OperationRunBaselineTruthSurfaceTest.php tests/Feature/Filament/OperationRunEnterpriseDetailPageTest.php tests/Feature/Filament/EnterpriseDetailTemplateRegressionTest.php tests/Feature/Operations/TenantlessOperationRunViewerTest.php
- vendor/bin/sail bin pint --dirty --format agent

Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de>
Reviewed-on: #194
2026-03-26 13:23:52 +00:00
c17255f854 feat: implement baseline subject resolution semantics (#193)
## Summary
- add the structured subject-resolution foundation for baseline compare and baseline capture, including capability guards, subject descriptors, resolution outcomes, and operator action categories
- persist structured evidence-gap subject records and update compare/capture surfaces, landing projections, and cleanup tooling to use the new contract
- add Spec 163 artifacts and focused Pest coverage for classification, determinism, cleanup, and DB-only rendering

## Validation
- `vendor/bin/sail bin pint --dirty --format agent`
- `vendor/bin/sail artisan test --compact tests/Unit/Support/Baselines tests/Feature/Baselines tests/Feature/Filament/OperationRunEnterpriseDetailPageTest.php`

## Notes
- verified locally that a fresh post-restart baseline compare run now writes structured `baseline_compare.evidence_gaps.subjects` records instead of the legacy broad payload shape
- excluded the separate `docs/product/spec-candidates.md` worktree change from this branch commit and PR

Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de>
Reviewed-on: #193
2026-03-25 12:40:45 +00:00
7d4d607475 feat: add baseline gap details surfaces (#192)
## Summary
- add baseline compare evidence gap detail modeling and a dedicated Livewire table surface
- extend baseline compare landing and operation run detail surfaces to expose evidence gap details and stats
- add spec artifacts for feature 162 and expand feature coverage with focused Filament and baseline tests

## Notes
- branch: `162-baseline-gap-details`
- commit: `a92dd812`
- working tree was clean after push

## Validation
- tests were not run in this step

Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de>
Reviewed-on: #192
2026-03-24 19:05:23 +00:00
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
294 changed files with 24790 additions and 1437 deletions

View File

@ -100,6 +100,16 @@ ## Active Technologies
- 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 / Laravel 12, Blade, Alpine via Filament, Tailwind CSS v4 + Filament v5, Livewire v4, Pest v4, Laravel Sail, existing `OperationRun` and baseline compare services (162-baseline-gap-details)
- PostgreSQL with JSONB-backed `operation_runs.context`; no new tables required (162-baseline-gap-details)
- PostgreSQL via existing application tables, especially `operation_runs.context` and baseline snapshot summary JSON (163-baseline-subject-resolution)
- PHP 8.4, Laravel 12, Blade views, Alpine via Filament v5 / Livewire v4 + Filament v5, Livewire v4, Pest v4, Laravel Sail, existing `OperationRunResource`, `TenantlessOperationRunViewer`, `EnterpriseDetailBuilder`, `ArtifactTruthPresenter`, `OperationUxPresenter`, and `SummaryCountsNormalizer` (164-run-detail-hardening)
- PostgreSQL with existing `operation_runs` JSONB-backed `context`, `summary_counts`, and `failure_summary`; no schema change planned (164-run-detail-hardening)
- PHP 8.4, Laravel 12, Blade, Filament v5, Livewire v4 + Filament v5, Livewire v4, Pest v4, Laravel Sail, existing `BaselineCompareStats`, `BaselineCompareExplanationRegistry`, `ReasonPresenter`, `BadgeCatalog` or `BadgeRenderer`, `UiEnforcement`, and `OperationRunLinks` (165-baseline-summary-trust)
- PostgreSQL with existing baseline, findings, and `operation_runs` tables plus JSONB-backed compare context; no schema change planned (165-baseline-summary-trust)
- PHP 8.4.15 (feat/005-bulk-operations)
@ -119,8 +129,8 @@ ## Code Style
PHP 8.4.15: Follow standard conventions
## Recent Changes
- 158-artifact-truth-semantics: Added 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
- 157-reason-code-translation: Added PHP 8.4.15 + Laravel 12, Filament v5, Livewire v4, PostgreSQL, Laravel Sail, Pest v4
- 156-operator-outcome-taxonomy: Added PHP 8.4.15 + Laravel 12, Filament v5, Livewire v4, PostgreSQL, Laravel Sail, Pest v4
- 165-baseline-summary-trust: Added PHP 8.4, Laravel 12, Blade, Filament v5, Livewire v4 + Filament v5, Livewire v4, Pest v4, Laravel Sail, existing `BaselineCompareStats`, `BaselineCompareExplanationRegistry`, `ReasonPresenter`, `BadgeCatalog` or `BadgeRenderer`, `UiEnforcement`, and `OperationRunLinks`
- 164-run-detail-hardening: Added PHP 8.4, Laravel 12, Blade views, Alpine via Filament v5 / Livewire v4 + Filament v5, Livewire v4, Pest v4, Laravel Sail, existing `OperationRunResource`, `TenantlessOperationRunViewer`, `EnterpriseDetailBuilder`, `ArtifactTruthPresenter`, `OperationUxPresenter`, and `SummaryCountsNormalizer`
- 163-baseline-subject-resolution-session-1774398153: Added PHP 8.4 + Laravel 12, Filament v5, Livewire v4
<!-- MANUAL ADDITIONS START -->
<!-- MANUAL ADDITIONS END -->

View File

@ -1,19 +1,22 @@
<!--
Sync Impact Report
- Version change: 1.11.0 → 1.12.0
- Version change: 1.12.0 → 1.13.0
- Modified principles:
- None
- Added sections:
- Operator Surface Principles (OPSURF-001)
- Filament Native First / No Ad-hoc Styling (UI-FIL-001)
- Removed sections: None
- Templates requiring updates:
- ✅ .specify/memory/constitution.md
- ✅ .specify/templates/spec-template.md
- ✅ .specify/templates/plan-template.md
- ✅ .specify/templates/tasks-template.md
- ✅ docs/product/principles.md
- ✅ docs/product/standards/README.md
- ✅ docs/HANDOVER.md
- Commands checked:
- N/A `.specify/templates/commands/*.md` directory is not present in this repo
- Follow-up TODOs:
- None.
-->
@ -416,6 +419,39 @@ ### Badge Semantics Are Centralized (BADGE-001)
- Introducing or changing a status-like value MUST include updating the relevant badge mapper and adding/updating tests for the mapping.
- Tag/category chips (e.g., type/platform/environment) are not status-like and are not governed by BADGE-001.
### Filament Native First / No Ad-hoc Styling (UI-FIL-001)
- Admin and operator-facing surfaces MUST use native Filament components, existing shared UI primitives, and centralized design patterns first.
- If Filament already provides the required semantic element, feature code MUST use the Filament-native component instead of a locally assembled replacement.
- Preferred native elements include `x-filament::badge`, `x-filament::button`, `x-filament::icon`, and Filament Forms, Infolists, Tables, Sections, Tabs, Grids, and Actions.
Forbidden local replacements
- Feature code MUST NOT hand-build badges, pills, status chips, alert cards, or action buttons from raw `<span>`, `<div>`, or `<button>` markup plus Tailwind classes when Filament-native or shared project primitives can express the same meaning.
- Feature code MUST NOT introduce page-local visual status languages for status, risk, outcome, drift, trust, importance, or severity.
- Feature code MUST NOT make local color, border, rounding, or emphasis decisions for semantic UI states using ad-hoc classes such as `bg-danger-100`, `text-warning-900`, `border-dashed`, or `rounded-full` when the same state can be expressed through Filament props or shared primitives.
Shared primitive before local override
- If the same UI pattern can recur, it MUST use an existing shared primitive or introduce a new central primitive instead of reassembling the pattern inside a Blade view.
- Central badge and status catalogs remain the canonical source for status semantics; local views MUST consume them rather than re-map them.
Upgrade-safe preference
- Update-safe, framework-native implementations take priority over page-local styling shortcuts.
- Filament props such as `color`, `icon`, and standard component composition are the default mechanism for semantic emphasis.
- Publishing or emulating Filament internals for cosmetic speed is not acceptable when native composition or shared primitives are sufficient.
Exception rule
- Ad-hoc markup or styling is allowed only when all of the following are true:
- native Filament components cannot express the required semantics,
- no suitable shared primitive exists,
- and the deviation is justified briefly in code and in the governing spec or PR.
- Approved exceptions MUST stay layout-neutral, use the minimum local classes necessary, and MUST NOT invent a new page-local status language.
Review and enforcement
- Every UI review MUST answer:
- which native Filament element or shared primitive was used,
- why an existing component was insufficient if an exception was taken,
- and whether any ad-hoc status or emphasis styling was introduced.
- UI work is not Done if it introduces ad-hoc status styling or framework-foreign replacement components where a native Filament or shared UI solution was viable.
### Incremental UI Standards Enforcement (UI-STD-001)
- UI consistency is enforced incrementally, not by recurring cleanup passes.
- New tables, filters, and list surfaces MUST follow established Filament-native standards from the first implementation.
@ -451,4 +487,4 @@ ### Versioning Policy (SemVer)
- **MINOR**: new principle/section or materially expanded guidance.
- **MAJOR**: removing/redefining principles in a backward-incompatible way.
**Version**: 1.12.0 | **Ratified**: 2026-01-03 | **Last Amended**: 2026-03-21
**Version**: 1.13.0 | **Ratified**: 2026-01-03 | **Last Amended**: 2026-03-26

View File

@ -49,6 +49,7 @@ ## Constitution Check
- Automation: queued/scheduled ops use locks + idempotency; handle 429/503 with backoff+jitter
- 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
- Filament-native UI (UI-FIL-001): admin/operator surfaces use native Filament components or shared primitives first; no ad-hoc status UI, local semantic color/border decisions, or hand-built replacements when native/shared semantics exist; any exception is explicitly justified
- 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

View File

@ -127,6 +127,12 @@ ## Requirements *(mandatory)*
**Constitution alignment (BADGE-001):** If this feature changes status-like badges (status/outcome/severity/risk/availability/boolean),
the spec MUST describe how badge semantics stay centralized (no ad-hoc mappings) and which tests cover any new/changed values.
**Constitution alignment (UI-FIL-001):** If this feature adds or changes Filament or Blade UI for admin/operator surfaces, the spec MUST describe:
- which native Filament components or shared UI primitives are used,
- whether any local replacement markup was avoided for badges, alerts, buttons, or status surfaces,
- how semantic emphasis is expressed through Filament props or central primitives rather than page-local color/border classes,
- and any exception where Filament or a shared primitive was insufficient, including why the exception is necessary and how it avoids introducing a new local status language.
**Constitution alignment (UI-NAMING-001):** If this feature adds or changes operator-facing buttons, header actions, run titles,
notifications, audit prose, or related helper copy, the spec MUST describe:
- the target object,
@ -147,6 +153,7 @@ ## Requirements *(mandatory)*
**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.
If the contract is not satisfied, the spec MUST include an explicit exemption with rationale.
The same section MUST also state whether UI-FIL-001 is satisfied and identify any approved exception.
**Constitution alignment (UX-001 — Layout & Information Architecture):** If this feature adds or modifies any Filament screen,
the spec MUST describe compliance with UX-001: Create/Edit uses Main/Aside layout (3-col grid), all fields inside Sections/Cards
(no naked inputs), View pages use Infolists (not disabled edit forms), status badges use BADGE-001, empty states have a specific

View File

@ -53,6 +53,9 @@ # Tasks: [FEATURE NAME]
- grouping bulk actions via BulkActionGroup,
- adding confirmations for destructive actions (and typed confirmation where required by scale),
- adding `AuditLog` entries for relevant mutations,
- using native Filament components or shared UI primitives before any local Blade/Tailwind assembly for badges, alerts, buttons, and semantic status surfaces,
- avoiding page-local semantic color, border, rounding, or highlight styling when Filament props or shared primitives can express the same state,
- documenting any UI-FIL-001 exception with rationale in the spec/PR,
- adding/updated tests that enforce the contract and block merge on violations, OR documenting an explicit exemption with rationale.
**Filament UI UX-001 (Layout & IA)**: If this feature adds/modifies any Filament screen, tasks MUST include:
- ensuring Create/Edit pages use Main/Aside layout (3-col grid, Main=columnSpan(2), Aside=columnSpan(1)),

View File

@ -0,0 +1,153 @@
<?php
declare(strict_types=1);
namespace App\Console\Commands;
use App\Models\OperationRun;
use App\Models\Tenant;
use Illuminate\Console\Command;
class PurgeLegacyBaselineGapRuns extends Command
{
protected $signature = 'tenantpilot:baselines:purge-legacy-gap-runs
{--type=* : Limit cleanup to baseline_compare and/or baseline_capture runs}
{--tenant=* : Limit cleanup to tenant ids or tenant external ids}
{--workspace=* : Limit cleanup to workspace ids}
{--limit=500 : Maximum candidate runs to inspect}
{--force : Actually delete matched legacy runs}';
protected $description = 'Purge development-only baseline compare/capture runs that still use legacy broad gap payloads.';
public function handle(): int
{
if (! app()->environment(['local', 'testing'])) {
$this->error('This cleanup command is limited to local and testing environments.');
return self::FAILURE;
}
$types = $this->normalizedTypes();
$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'))));
$limit = max(1, (int) $this->option('limit'));
$dryRun = ! (bool) $this->option('force');
$query = OperationRun::query()
->whereIn('type', $types)
->orderBy('id')
->limit($limit);
if ($workspaceIds !== []) {
$query->whereIn('workspace_id', $workspaceIds);
}
if ($tenantIds !== []) {
$query->whereIn('tenant_id', $tenantIds);
}
$candidates = $query->get();
$matched = $candidates
->filter(static fn (OperationRun $run): bool => $run->hasLegacyBaselineGapPayload())
->values();
if ($matched->isEmpty()) {
$this->info('No legacy baseline gap runs matched the current filters.');
return self::SUCCESS;
}
$this->table(
['Run', 'Type', 'Tenant', 'Workspace', 'Legacy signal'],
$matched
->map(fn (OperationRun $run): array => [
'Run' => (string) $run->getKey(),
'Type' => (string) $run->type,
'Tenant' => $run->tenant_id !== null ? (string) $run->tenant_id : '—',
'Workspace' => $run->workspace_id !== null ? (string) $run->workspace_id : '—',
'Legacy signal' => $this->legacySignal($run),
])
->all(),
);
if ($dryRun) {
$this->warn(sprintf(
'Dry run: matched %d legacy baseline run(s). Re-run with --force to delete them.',
$matched->count(),
));
return self::SUCCESS;
}
OperationRun::query()
->whereKey($matched->modelKeys())
->delete();
$this->info(sprintf('Deleted %d legacy baseline run(s).', $matched->count()));
return self::SUCCESS;
}
/**
* @return array<int, string>
*/
private function normalizedTypes(): array
{
$types = array_values(array_unique(array_filter(
array_map(
static fn (mixed $type): ?string => is_string($type) && trim($type) !== '' ? trim($type) : null,
(array) $this->option('type'),
),
)));
if ($types === []) {
return ['baseline_compare', 'baseline_capture'];
}
return array_values(array_filter(
$types,
static fn (string $type): bool => in_array($type, ['baseline_compare', 'baseline_capture'], true),
));
}
/**
* @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));
}
private function legacySignal(OperationRun $run): string
{
$byReason = $run->baselineGapEnvelope()['by_reason'] ?? null;
$byReason = is_array($byReason) ? $byReason : [];
if (array_key_exists('policy_not_found', $byReason)) {
return 'legacy_reason_code';
}
return 'legacy_subject_shape';
}
}

View File

@ -6,6 +6,7 @@
use App\Models\OperationRun;
use App\Models\Tenant;
use App\Services\OperationRunService;
use App\Services\Operations\OperationLifecycleReconciler;
use App\Support\OperationRunOutcome;
use Illuminate\Console\Command;
@ -18,8 +19,10 @@ class TenantpilotReconcileBackupScheduleOperationRuns extends Command
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')));
$olderThanMinutes = max(0, (int) $this->option('older-than'));
$dryRun = (bool) $this->option('dry-run');
@ -96,31 +99,9 @@ public function handle(OperationRunService $operationRunService): int
continue;
}
if ($operationRun->status === 'queued' && $operationRunService->isStaleQueuedRun($operationRun, max(1, $olderThanMinutes))) {
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.',
],
],
);
}
$change = $operationLifecycleReconciler->reconcileRun($operationRun, $dryRun);
if ($change !== null) {
$reconciled++;
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

@ -13,6 +13,7 @@
use App\Services\Baselines\BaselineCompareService;
use App\Support\Auth\Capabilities;
use App\Support\Baselines\BaselineCaptureMode;
use App\Support\Baselines\BaselineCompareEvidenceGapDetails;
use App\Support\Baselines\BaselineCompareStats;
use App\Support\OperationRunLinks;
use App\Support\OpsUx\OperationUxPresenter;
@ -59,6 +60,8 @@ class BaselineCompareLanding extends Page
public ?int $duplicateNamePoliciesCount = null;
public ?int $duplicateNameSubjectsCount = null;
public ?int $operationRunId = null;
public ?int $findingsCount = null;
@ -86,9 +89,24 @@ class BaselineCompareLanding extends Page
/** @var array<string, int>|null */
public ?array $evidenceGapsTopReasons = null;
/** @var array<string, mixed>|null */
public ?array $evidenceGapSummary = null;
/** @var list<array<string, mixed>>|null */
public ?array $evidenceGapBuckets = null;
/** @var array<string, mixed>|null */
public ?array $baselineCompareDiagnostics = null;
/** @var array<string, int>|null */
public ?array $rbacRoleDefinitionSummary = null;
/** @var array<string, mixed>|null */
public ?array $operatorExplanation = null;
/** @var array<string, mixed>|null */
public ?array $summaryAssessment = null;
public static function canAccess(): bool
{
$user = auth()->user();
@ -123,6 +141,7 @@ public function refreshStats(): void
$this->profileId = $stats->profileId;
$this->snapshotId = $stats->snapshotId;
$this->duplicateNamePoliciesCount = $stats->duplicateNamePoliciesCount;
$this->duplicateNameSubjectsCount = $stats->duplicateNameSubjectsCount;
$this->operationRunId = $stats->operationRunId;
$this->findingsCount = $stats->findingsCount;
$this->severityCounts = $stats->severityCounts !== [] ? $stats->severityCounts : null;
@ -139,7 +158,18 @@ public function refreshStats(): void
$this->evidenceGapsCount = $stats->evidenceGapsCount;
$this->evidenceGapsTopReasons = $stats->evidenceGapsTopReasons !== [] ? $stats->evidenceGapsTopReasons : null;
$this->evidenceGapSummary = is_array($stats->evidenceGapDetails['summary'] ?? null)
? $stats->evidenceGapDetails['summary']
: null;
$this->evidenceGapBuckets = is_array($stats->evidenceGapDetails['buckets'] ?? null) && $stats->evidenceGapDetails['buckets'] !== []
? $stats->evidenceGapDetails['buckets']
: null;
$this->baselineCompareDiagnostics = $stats->baselineCompareDiagnostics !== []
? $stats->baselineCompareDiagnostics
: null;
$this->rbacRoleDefinitionSummary = $stats->rbacRoleDefinitionSummary !== [] ? $stats->rbacRoleDefinitionSummary : null;
$this->operatorExplanation = $stats->operatorExplanation()->toArray();
$this->summaryAssessment = $stats->summaryAssessment()->toArray();
}
/**
@ -152,26 +182,32 @@ public function refreshStats(): void
*/
protected function getViewData(): array
{
$evidenceGapSummary = is_array($this->evidenceGapSummary) ? $this->evidenceGapSummary : [];
$hasCoverageWarnings = in_array($this->coverageStatus, ['warning', 'unproven'], true);
$evidenceGapsCountValue = (int) ($this->evidenceGapsCount ?? 0);
$evidenceGapsCountValue = is_numeric($evidenceGapSummary['count'] ?? null)
? (int) $evidenceGapSummary['count']
: (int) ($this->evidenceGapsCount ?? 0);
$hasEvidenceGaps = $evidenceGapsCountValue > 0;
$hasWarnings = $hasCoverageWarnings || $hasEvidenceGaps;
$hasRbacRoleDefinitionSummary = is_array($this->rbacRoleDefinitionSummary)
&& array_sum($this->rbacRoleDefinitionSummary) > 0;
$evidenceGapDetailState = is_string($evidenceGapSummary['detail_state'] ?? null)
? (string) $evidenceGapSummary['detail_state']
: 'no_gaps';
$hasEvidenceGapDetailSection = $evidenceGapDetailState !== 'no_gaps';
$hasEvidenceGapDiagnostics = is_array($this->baselineCompareDiagnostics) && $this->baselineCompareDiagnostics !== [];
$evidenceGapsSummary = null;
$evidenceGapsTooltip = null;
if ($hasEvidenceGaps && is_array($this->evidenceGapsTopReasons) && $this->evidenceGapsTopReasons !== []) {
$parts = [];
foreach (array_slice($this->evidenceGapsTopReasons, 0, 5, true) as $reason => $count) {
if (! is_string($reason) || $reason === '' || ! is_numeric($count)) {
continue;
}
$parts[] = $reason.' ('.((int) $count).')';
}
if ($hasEvidenceGaps) {
$parts = array_map(
static fn (array $reason): string => $reason['reason_label'].' ('.$reason['count'].')',
BaselineCompareEvidenceGapDetails::topReasons(
is_array($evidenceGapSummary['by_reason'] ?? null) ? $evidenceGapSummary['by_reason'] : [],
5,
),
);
if ($parts !== []) {
$evidenceGapsSummary = implode(', ', $parts);
@ -207,12 +243,16 @@ protected function getViewData(): array
'hasEvidenceGaps' => $hasEvidenceGaps,
'hasWarnings' => $hasWarnings,
'hasRbacRoleDefinitionSummary' => $hasRbacRoleDefinitionSummary,
'evidenceGapDetailState' => $evidenceGapDetailState,
'hasEvidenceGapDetailSection' => $hasEvidenceGapDetailSection,
'hasEvidenceGapDiagnostics' => $hasEvidenceGapDiagnostics,
'evidenceGapsSummary' => $evidenceGapsSummary,
'evidenceGapsTooltip' => $evidenceGapsTooltip,
'findingsColorClass' => $findingsColorClass,
'whyNoFindingsMessage' => $whyNoFindingsMessage,
'whyNoFindingsFallback' => $whyNoFindingsFallback,
'whyNoFindingsColor' => $whyNoFindingsColor,
'summaryAssessment' => is_array($this->summaryAssessment) ? $this->summaryAssessment : null,
];
}
@ -307,9 +347,22 @@ private function compareNowAction(): Action
$result = $service->startCompare($tenant, $user);
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()
->title('Cannot start comparison')
->body('Reason: '.($result['reason_code'] ?? 'unknown'))
->body($message)
->danger()
->send();

View File

@ -34,6 +34,7 @@
use Filament\Tables\Contracts\HasTable;
use Filament\Tables\Filters\SelectFilter;
use Filament\Tables\Table;
use Illuminate\Contracts\View\View;
use Illuminate\Database\Eloquent\Builder;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
use UnitEnum;
@ -82,14 +83,16 @@ public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
public function mount(): void
{
$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());
$this->mountInteractsWithTable();
if ($this->selectedAuditLogId !== null) {
$this->selectedAuditLog();
if ($requestedEventId !== null) {
$this->resolveAuditLog($requestedEventId);
$this->selectedAuditLogId = $requestedEventId;
$this->mountTableAction('inspect', (string) $requestedEventId);
}
}
@ -98,31 +101,10 @@ public function mount(): void
*/
protected function getHeaderActions(): array
{
$actions = app(OperateHubShell::class)->headerActions(
return app(OperateHubShell::class)->headerActions(
scopeActionName: 'operate_hub_scope_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
@ -195,9 +177,19 @@ public function table(Table $table): Table
->label('Inspect event')
->icon('heroicon-o-eye')
->color('gray')
->action(function (AuditLogModel $record): void {
->before(function (AuditLogModel $record): void {
$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([])
->emptyStateHeading('No audit events match this view')
@ -209,48 +201,11 @@ public function table(Table $table): Table
->icon('heroicon-o-x-mark')
->color('gray')
->action(function (): void {
$this->selectedAuditLogId = null;
$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>
*/
@ -323,6 +278,54 @@ private function auditBaseQuery(): Builder
->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>
*/

View File

@ -13,6 +13,7 @@
use App\Support\OperateHub\OperateHubShell;
use App\Support\OperationRunOutcome;
use App\Support\OperationRunStatus;
use App\Support\Operations\OperationLifecyclePolicy;
use App\Support\Workspaces\WorkspaceContext;
use BackedEnum;
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
{
return match ($this->activeTab) {
@ -187,4 +250,26 @@ private function applyActiveTab(Builder $query): Builder
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

@ -24,6 +24,8 @@
use App\Support\Tenants\ReferencedTenantLifecyclePresentation;
use App\Support\Tenants\TenantInteractionLane;
use App\Support\Tenants\TenantOperabilityQuestion;
use App\Support\Ui\GovernanceArtifactTruth\ArtifactTruthPresenter;
use App\Support\Ui\OperatorExplanation\OperatorExplanationPattern;
use Filament\Actions\Action;
use Filament\Actions\ActionGroup;
use Filament\Notifications\Notification;
@ -170,19 +172,56 @@ public function blockedExecutionBanner(): ?array
return null;
}
$operatorExplanation = $this->governanceOperatorExplanation();
$reasonEnvelope = app(ReasonPresenter::class)->forOperationRun($this->run, 'run_detail');
$lines = $reasonEnvelope?->toBodyLines() ?? [
OperationUxPresenter::surfaceFailureDetail($this->run) ?? 'The queued run was refused before side effects could begin.',
OperationUxPresenter::surfaceGuidance($this->run) ?? 'Review the blocked prerequisite before retrying.',
];
$lines = $operatorExplanation instanceof OperatorExplanationPattern
? array_values(array_filter([
$operatorExplanation->headline,
$operatorExplanation->dominantCauseExplanation,
]))
: ($reasonEnvelope?->toBodyLines(false) ?? [
OperationUxPresenter::surfaceFailureDetail($this->run) ?? 'The queued run was refused before side effects could begin.',
]);
return [
'tone' => 'amber',
'title' => 'Blocked by prerequisite',
'body' => implode(' ', $lines),
'body' => implode(' ', array_values(array_unique($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.';
return match ($this->run->freshnessState()->value) {
'likely_stale' => [
'tone' => 'amber',
'title' => 'Likely stale run',
'body' => $detail,
],
'reconciled_failed' => [
'tone' => 'rose',
'title' => 'Automatically reconciled',
'body' => $detail,
],
default => null,
};
}
/**
* @return array{tone: string, title: string, body: string}|null
*/
@ -417,4 +456,13 @@ private function relatedLinksTenant(): ?Tenant
lane: TenantInteractionLane::StandardActiveOperating,
)->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

@ -122,7 +122,7 @@ public function table(Table $table): Table
->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)->primaryExplanation)
->description(fn (TenantReview $record): ?string => app(ArtifactTruthPresenter::class)->forTenantReview($record)->operatorExplanation?->headline ?? app(ArtifactTruthPresenter::class)->forTenantReview($record)->primaryExplanation)
->wrap(),
TextColumn::make('completeness_state')
->label('Completeness')
@ -154,7 +154,7 @@ public function table(Table $table): Table
)->iconColor),
TextColumn::make('artifact_next_step')
->label('Next step')
->getStateUsing(fn (TenantReview $record): string => app(ArtifactTruthPresenter::class)->forTenantReview($record)->nextStepText())
->getStateUsing(fn (TenantReview $record): string => app(ArtifactTruthPresenter::class)->forTenantReview($record)->operatorExplanation?->nextActionText ?? app(ArtifactTruthPresenter::class)->forTenantReview($record)->nextStepText())
->wrap(),
])
->filters([

View File

@ -6,19 +6,28 @@
use App\Filament\Resources\BaselineProfileResource\Pages;
use App\Models\BaselineProfile;
use App\Models\BaselineSnapshot;
use App\Models\BaselineTenantAssignment;
use App\Models\Tenant;
use App\Models\User;
use App\Models\Workspace;
use App\Services\Audit\WorkspaceAuditLogger;
use App\Services\Auth\CapabilityResolver;
use App\Services\Baselines\BaselineSnapshotTruthResolver;
use App\Support\Audit\AuditActionId;
use App\Support\Auth\Capabilities;
use App\Support\Badges\BadgeCatalog;
use App\Support\Badges\BadgeDomain;
use App\Support\Badges\BadgeRenderer;
use App\Support\Baselines\BaselineCaptureMode;
use App\Support\Baselines\BaselineFullContentRolloutGate;
use App\Support\Baselines\BaselineProfileStatus;
use App\Support\Baselines\BaselineReasonCodes;
use App\Support\Filament\FilterOptionCatalog;
use App\Support\Inventory\InventoryPolicyTypeMeta;
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\Enums\ActionSurfaceInspectAffordance;
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile;
@ -288,15 +297,32 @@ public static function infolist(Schema $schema): Schema
->placeholder('None'),
])
->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')
->schema([
TextEntry::make('createdByUser.name')
->label('Created by')
->placeholder('—'),
TextEntry::make('activeSnapshot.captured_at')
->label('Last snapshot')
->dateTime()
->placeholder('No snapshot yet'),
TextEntry::make('created_at')
->dateTime(),
TextEntry::make('updated_at')
@ -355,10 +381,27 @@ public static function table(Table $table): Table
TextColumn::make('tenant_assignments_count')
->label('Assigned tenants')
->counts('tenantAssignments'),
TextColumn::make('activeSnapshot.captured_at')
->label('Last snapshot')
->dateTime()
->placeholder('No snapshot'),
TextColumn::make('current_snapshot_truth')
->label('Current snapshot')
->getStateUsing(fn (BaselineProfile $record): string => self::currentSnapshotLabel($record))
->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')
->dateTime()
->sortable()
@ -545,4 +588,167 @@ private static function archiveTableAction(?Workspace $workspace): 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
? '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')
->label($label)
@ -198,7 +198,7 @@ private function compareNowAction(): Action
->required()
->searchable(),
])
->disabled(fn (): bool => $this->getEligibleCompareTenantOptions() === [])
->disabled(fn (): bool => $this->getEligibleCompareTenantOptions() === [] || ! $this->profileHasConsumableSnapshot())
->action(function (array $data): void {
$user = auth()->user();
@ -256,7 +256,11 @@ private function compareNowAction(): Action
$message = match ($reasonCode) {
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_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),
};
@ -395,4 +399,12 @@ private function hasManageCapability(): bool
return $resolver->isMember($user, $workspace)
&& $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,10 +9,12 @@
use App\Models\BaselineSnapshot;
use App\Models\User;
use App\Models\Workspace;
use App\Services\Baselines\BaselineSnapshotTruthResolver;
use App\Services\Baselines\SnapshotRendering\FidelityState;
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\Navigation\CrossResourceNavigationMatrix;
use App\Support\Navigation\RelatedContextEntry;
@ -177,7 +179,23 @@ public static function table(Table $table): Table
->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)->primaryExplanation)
->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')
->label('Fidelity')
@ -185,15 +203,8 @@ public static function table(Table $table): Table
->wrap(),
TextColumn::make('artifact_next_step')
->label('Next step')
->getStateUsing(static fn (BaselineSnapshot $record): string => self::truthEnvelope($record)->nextStepText())
->getStateUsing(static fn (BaselineSnapshot $record): string => self::truthEnvelope($record)->operatorExplanation?->nextActionText ?? self::truthEnvelope($record)->nextStepText())
->wrap(),
TextColumn::make('snapshot_state')
->label('State')
->badge()
->getStateUsing(static fn (BaselineSnapshot $record): string => self::stateLabel($record))
->color(static fn (BaselineSnapshot $record): string => self::gapSpec($record)->color)
->icon(static fn (BaselineSnapshot $record): ?string => self::gapSpec($record)->icon)
->iconColor(static fn (BaselineSnapshot $record): ?string => self::gapSpec($record)->iconColor),
])
->recordUrl(static fn (BaselineSnapshot $record): ?string => static::canView($record)
? static::getUrl('view', ['record' => $record])
@ -203,10 +214,10 @@ public static function table(Table $table): Table
->label('Baseline')
->options(static::baselineProfileOptions())
->searchable(),
SelectFilter::make('snapshot_state')
->label('State')
->options(static::snapshotStateOptions())
->query(fn (Builder $query, array $data): Builder => static::applySnapshotStateFilter($query, $data['value'] ?? null)),
SelectFilter::make('lifecycle_state')
->label('Lifecycle')
->options(static::lifecycleOptions())
->query(fn (Builder $query, array $data): Builder => static::applyLifecycleFilter($query, $data['value'] ?? null)),
FilterPresets::dateRange('captured_at', 'Captured', 'captured_at'),
])
->actions([
@ -267,9 +278,9 @@ private static function baselineProfileOptions(): array
/**
* @return array<string, string>
*/
private static function snapshotStateOptions(): array
private static function lifecycleOptions(): array
{
return BadgeCatalog::options(BadgeDomain::BaselineSnapshotGapStatus, ['clear', 'gaps_present']);
return BadgeCatalog::options(BadgeDomain::BaselineSnapshotLifecycle, BaselineSnapshotLifecycleState::values());
}
public static function resolveWorkspace(): ?Workspace
@ -343,32 +354,26 @@ private static function hasGaps(BaselineSnapshot $snapshot): bool
return self::gapsCount($snapshot) > 0;
}
private static function stateLabel(BaselineSnapshot $snapshot): string
private static function lifecycleSpec(BaselineSnapshot $snapshot): \App\Support\Badges\BadgeSpec
{
return self::gapSpec($snapshot)->label;
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) === '') {
return $query;
}
$gapCountExpression = self::gapCountExpression($query);
return match ($value) {
'clear' => $query->whereRaw("{$gapCountExpression} = 0"),
'gaps_present' => $query->whereRaw("{$gapCountExpression} > 0"),
default => $query,
};
return $query->where('lifecycle_state', trim($value));
}
private static function gapCountExpression(Builder $query): string
{
return match ($query->getConnection()->getDriverName()) {
'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,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.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)",
'sqlite' => "MAX(0, COALESCE(CAST(json_extract(summary_jsonb, '$.gaps.count') AS INTEGER), 0) - COALESCE(CAST(json_extract(summary_jsonb, '$.gaps.by_reason.meta_fallback') AS INTEGER), 0))",
'pgsql' => "GREATEST(0, COALESCE((summary_jsonb #>> '{gaps,count}')::int, 0) - COALESCE((summary_jsonb #>> '{gaps,by_reason,meta_fallback}')::int, 0))",
default => "GREATEST(0, COALESCE(CAST(JSON_EXTRACT(summary_jsonb, '$.gaps.count') AS SIGNED), 0) - COALESCE(CAST(JSON_EXTRACT(summary_jsonb, '$.gaps.by_reason.meta_fallback') AS SIGNED), 0))",
};
}
@ -384,4 +389,51 @@ private static function truthEnvelope(BaselineSnapshot $snapshot): ArtifactTruth
{
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();
if ($snapshot instanceof BaselineSnapshot) {
$snapshot->loadMissing(['baselineProfile', 'items']);
$relatedContext = app(RelatedNavigationResolver::class)
->detailEntries(CrossResourceNavigationMatrix::SOURCE_BASELINE_SNAPSHOT, $snapshot);

View File

@ -11,6 +11,7 @@
use App\Support\Badges\BadgeCatalog;
use App\Support\Badges\BadgeDomain;
use App\Support\Badges\BadgeRenderer;
use App\Support\Baselines\BaselineCompareEvidenceGapDetails;
use App\Support\Baselines\BaselineCompareReasonCode;
use App\Support\Filament\FilterOptionCatalog;
use App\Support\Filament\FilterPresets;
@ -32,7 +33,9 @@
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceInspectAffordance;
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile;
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceSlot;
use App\Support\Ui\GovernanceArtifactTruth\ArtifactTruthEnvelope;
use App\Support\Ui\GovernanceArtifactTruth\ArtifactTruthPresenter;
use App\Support\Ui\OperatorExplanation\OperatorExplanationPattern;
use App\Support\Workspaces\WorkspaceContext;
use BackedEnum;
use Filament\Actions;
@ -128,10 +131,11 @@ public static function table(Table $table): Table
->columns([
Tables\Columns\TextColumn::make('status')
->badge()
->formatStateUsing(BadgeRenderer::label(BadgeDomain::OperationRunStatus))
->color(BadgeRenderer::color(BadgeDomain::OperationRunStatus))
->icon(BadgeRenderer::icon(BadgeDomain::OperationRunStatus))
->iconColor(BadgeRenderer::iconColor(BadgeDomain::OperationRunStatus)),
->formatStateUsing(fn (mixed $state, OperationRun $record): string => BadgeRenderer::spec(BadgeDomain::OperationRunStatus, static::statusBadgeState($record))->label)
->color(fn (mixed $state, OperationRun $record): string => BadgeRenderer::spec(BadgeDomain::OperationRunStatus, static::statusBadgeState($record))->color)
->icon(fn (mixed $state, OperationRun $record): ?string => BadgeRenderer::spec(BadgeDomain::OperationRunStatus, static::statusBadgeState($record))->icon)
->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')
->label('Operation')
->formatStateUsing(fn (?string $state): string => OperationCatalog::label((string) $state))
@ -154,10 +158,10 @@ public static function table(Table $table): Table
}),
Tables\Columns\TextColumn::make('outcome')
->badge()
->formatStateUsing(BadgeRenderer::label(BadgeDomain::OperationRunOutcome))
->color(BadgeRenderer::color(BadgeDomain::OperationRunOutcome))
->icon(BadgeRenderer::icon(BadgeDomain::OperationRunOutcome))
->iconColor(BadgeRenderer::iconColor(BadgeDomain::OperationRunOutcome))
->formatStateUsing(fn (mixed $state, OperationRun $record): string => BadgeRenderer::spec(BadgeDomain::OperationRunOutcome, static::outcomeBadgeState($record))->label)
->color(fn (mixed $state, OperationRun $record): string => BadgeRenderer::spec(BadgeDomain::OperationRunOutcome, static::outcomeBadgeState($record))->color)
->icon(fn (mixed $state, OperationRun $record): ?string => BadgeRenderer::spec(BadgeDomain::OperationRunOutcome, static::outcomeBadgeState($record))->icon)
->iconColor(fn (mixed $state, OperationRun $record): ?string => BadgeRenderer::spec(BadgeDomain::OperationRunOutcome, static::outcomeBadgeState($record))->iconColor)
->description(fn (OperationRun $record): ?string => OperationUxPresenter::surfaceGuidance($record)),
])
->filters([
@ -253,13 +257,25 @@ private static function enterpriseDetailPage(OperationRun $record): \App\Support
{
$factory = new \App\Support\Ui\EnterpriseDetail\EnterpriseDetailSectionFactory;
$statusSpec = BadgeRenderer::spec(BadgeDomain::OperationRunStatus, $record->status);
$outcomeSpec = BadgeRenderer::spec(BadgeDomain::OperationRunOutcome, $record->outcome);
$targetScope = static::targetScopeDisplay($record);
$statusSpec = BadgeRenderer::spec(BadgeDomain::OperationRunStatus, static::statusBadgeState($record));
$outcomeSpec = BadgeRenderer::spec(BadgeDomain::OperationRunOutcome, static::outcomeBadgeState($record));
$targetScope = static::targetScopeDisplay($record) ?? 'No target scope details were recorded for this run.';
$summaryLine = \App\Support\OpsUx\SummaryCountsNormalizer::renderSummaryLine(is_array($record->summary_counts) ? $record->summary_counts : []);
$referencedTenantLifecycle = $record->tenant instanceof Tenant
? ReferencedTenantLifecyclePresentation::forOperationRun($record->tenant)
: null;
$artifactTruth = $record->supportsOperatorExplanation()
? app(ArtifactTruthPresenter::class)->forOperationRun($record)
: null;
$operatorExplanation = $artifactTruth?->operatorExplanation;
$primaryNextStep = static::resolvePrimaryNextStep($record, $artifactTruth, $operatorExplanation);
$supportingGroups = static::supportingGroups(
record: $record,
factory: $factory,
referencedTenantLifecycle: $referencedTenantLifecycle,
operatorExplanation: $operatorExplanation,
primaryNextStep: $primaryNextStep,
);
$builder = \App\Support\Ui\EnterpriseDetail\EnterpriseDetailBuilder::make('operation_run', 'workspace-context')
->header(new \App\Support\Ui\EnterpriseDetail\SummaryHeaderData(
@ -270,131 +286,121 @@ private static function enterpriseDetailPage(OperationRun $record): \App\Support
$factory->statusBadge($outcomeSpec->label, $outcomeSpec->color, $outcomeSpec->icon, $outcomeSpec->iconColor),
],
keyFacts: [
$factory->keyFact('Target', $targetScope ?? 'No target scope details were recorded for this run.'),
$factory->keyFact('Initiator', $record->initiator_name),
$factory->keyFact('Target', $targetScope),
$factory->keyFact('Elapsed', RunDurationInsights::elapsedHuman($record)),
$factory->keyFact('Expected', RunDurationInsights::expectedHuman($record)),
],
descriptionHint: 'Run identity, outcome, scope, and related next steps stay ahead of stored payloads and diagnostic fragments.',
descriptionHint: 'Decision guidance and high-signal context stay ahead of diagnostic payloads and raw JSON.',
))
->addSection(
$factory->factsSection(
id: 'run_summary',
kind: 'core_details',
title: 'Run summary',
items: [
$factory->keyFact('Operation', OperationCatalog::label((string) $record->type)),
$factory->keyFact('Initiator', $record->initiator_name),
$factory->keyFact('Target scope', $targetScope ?? 'No target scope details were recorded for this run.'),
$factory->keyFact('Expected duration', RunDurationInsights::expectedHuman($record)),
],
->decisionZone($factory->decisionZone(
facts: array_values(array_filter([
$factory->keyFact(
'Execution state',
$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),
),
static::artifactTruthFact($factory, $artifactTruth),
$operatorExplanation instanceof OperatorExplanationPattern
? $factory->keyFact(
'Result meaning',
$operatorExplanation->evaluationResultLabel(),
$operatorExplanation->headline,
)
: null,
$operatorExplanation instanceof OperatorExplanationPattern
? $factory->keyFact(
'Result trust',
$operatorExplanation->trustworthinessLabel(),
static::detailHintUnlessDuplicate(
$operatorExplanation->reliabilityStatement,
$artifactTruth?->primaryExplanation,
),
)
: null,
])),
primaryNextStep: $factory->primaryNextStep(
$primaryNextStep['text'],
$primaryNextStep['source'],
$primaryNextStep['secondaryGuidance'],
),
$factory->viewSection(
id: 'artifact_truth',
kind: 'current_status',
title: 'Artifact truth',
view: 'filament.infolists.entries.governance-artifact-truth',
viewData: ['artifactTruthState' => app(ArtifactTruthPresenter::class)->forOperationRun($record)->toArray()],
visible: $record->isGovernanceArtifactOperation(),
description: 'Run lifecycle stays separate from whether the intended governance artifact was actually produced and usable.',
),
$factory->viewSection(
id: 'related_context',
kind: 'related_context',
title: 'Related context',
view: 'filament.infolists.entries.related-context',
viewData: ['entries' => app(RelatedNavigationResolver::class)
->detailEntries(CrossResourceNavigationMatrix::SOURCE_OPERATION_RUN, $record)],
emptyState: $factory->emptyState('No related context is available for this record.'),
),
)
->addSupportingCard(
$factory->supportingFactsCard(
kind: 'status',
title: 'Current state',
items: array_values(array_filter([
$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)),
$referencedTenantLifecycle !== null
? $factory->keyFact(
'Tenant lifecycle',
$referencedTenantLifecycle->presentation->label,
badge: $factory->statusBadge(
$referencedTenantLifecycle->presentation->label,
$referencedTenantLifecycle->presentation->badgeColor,
$referencedTenantLifecycle->presentation->badgeIcon,
$referencedTenantLifecycle->presentation->badgeIconColor,
),
)
: null,
$referencedTenantLifecycle?->selectorAvailabilityMessage() !== null
? $factory->keyFact('Tenant selector context', $referencedTenantLifecycle->selectorAvailabilityMessage())
: null,
$referencedTenantLifecycle?->contextNote !== null
? $factory->keyFact('Viewer context', $referencedTenantLifecycle->contextNote)
: null,
OperationUxPresenter::surfaceGuidance($record) !== null
? $factory->keyFact('Next step', OperationUxPresenter::surfaceGuidance($record))
: null,
$summaryLine !== null ? $factory->keyFact('Counts', $summaryLine) : null,
static::blockedExecutionReasonCode($record) !== null
? $factory->keyFact('Blocked reason', static::blockedExecutionReasonCode($record))
: null,
static::blockedExecutionDetail($record) !== null
? $factory->keyFact('Blocked detail', static::blockedExecutionDetail($record))
: null,
static::blockedExecutionSource($record) !== null
? $factory->keyFact('Blocked by', static::blockedExecutionSource($record))
: null,
RunDurationInsights::stuckGuidance($record) !== null ? $factory->keyFact('Guidance', RunDurationInsights::stuckGuidance($record)) : null,
])),
),
$factory->supportingFactsCard(
kind: 'timestamps',
title: 'Timing',
items: [
$factory->keyFact('Created', static::formatDetailTimestamp($record->created_at)),
$factory->keyFact('Started', static::formatDetailTimestamp($record->started_at)),
$factory->keyFact('Completed', static::formatDetailTimestamp($record->completed_at)),
$factory->keyFact('Elapsed', RunDurationInsights::elapsedHuman($record)),
],
),
)
->addTechnicalSection(
$factory->technicalDetail(
title: 'Context',
entries: [
$factory->keyFact('Identity hash', $record->run_identity_hash),
$factory->keyFact('Workspace scope', $record->workspace_id),
$factory->keyFact('Tenant scope', $record->tenant_id),
],
description: 'Stored run context stays available for debugging without dominating the default reading path.',
view: 'filament.infolists.entries.snapshot-json',
viewData: ['payload' => static::contextPayload($record)],
),
);
description: 'Start here to see how the run ended, whether the result is trustworthy enough to use, and the one primary next step.',
compactCounts: $summaryLine !== null
? $factory->countPresentation(summaryLine: $summaryLine)
: null,
attentionNote: static::decisionAttentionNote($record),
));
if ($supportingGroups !== []) {
$builder->addSupportingGroup(...$supportingGroups);
}
$builder->addSection(
$factory->viewSection(
id: 'related_context',
kind: 'related_context',
title: 'Related context',
view: 'filament.infolists.entries.related-context',
viewData: ['entries' => app(RelatedNavigationResolver::class)
->detailEntries(CrossResourceNavigationMatrix::SOURCE_OPERATION_RUN, $record)],
emptyState: $factory->emptyState('No related context is available for this record.'),
),
$factory->viewSection(
id: 'artifact_truth',
kind: 'supporting_detail',
title: 'Artifact truth details',
view: 'filament.infolists.entries.governance-artifact-truth',
viewData: [
'artifactTruthState' => $artifactTruth?->toArray(),
'surface' => 'expanded',
],
visible: $artifactTruth !== null,
description: 'Detailed artifact-truth context explains evidence quality and caveats without repeating the top decision summary.',
collapsible: true,
collapsed: true,
),
);
$counts = static::summaryCountFacts($record, $factory);
if ($counts !== []) {
$builder->addSection(
$factory->factsSection(
id: 'counts',
kind: 'current_status',
title: 'Counts',
items: $counts,
$builder->addTechnicalSection(
$factory->technicalDetail(
title: 'Count diagnostics',
entries: $counts,
description: 'Normalized run counters remain available for deeper inspection without competing with the primary decision.',
collapsible: true,
collapsed: true,
variant: 'diagnostic',
),
);
}
if (! empty($record->failure_summary)) {
$builder->addSection(
$factory->viewSection(
id: 'failures',
kind: 'operational_context',
$builder->addTechnicalSection(
$factory->technicalDetail(
title: (string) $record->outcome === OperationRunOutcome::Blocked->value ? 'Blocked execution details' : 'Failures',
description: 'Detailed failure evidence stays available for investigation after the decision and supporting context.',
view: 'filament.infolists.entries.snapshot-json',
viewData: ['payload' => $record->failure_summary ?? []],
collapsible: true,
collapsed: false,
),
);
}
if (static::reconciliationPayload($record) !== []) {
$builder->addTechnicalSection(
$factory->technicalDetail(
title: 'Lifecycle reconciliation',
description: 'Lifecycle reconciliation is diagnostic evidence showing when TenantPilot force-resolved the run.',
view: 'filament.infolists.entries.snapshot-json',
viewData: ['payload' => static::reconciliationPayload($record)],
collapsible: true,
collapsed: true,
),
);
}
@ -402,14 +408,39 @@ private static function enterpriseDetailPage(OperationRun $record): \App\Support
if ((string) $record->type === 'baseline_compare') {
$baselineCompareFacts = static::baselineCompareFacts($record, $factory);
$baselineCompareEvidence = static::baselineCompareEvidencePayload($record);
$gapDetails = BaselineCompareEvidenceGapDetails::fromOperationRun($record);
$gapSummary = is_array($gapDetails['summary'] ?? null) ? $gapDetails['summary'] : [];
$gapBuckets = is_array($gapDetails['buckets'] ?? null) ? $gapDetails['buckets'] : [];
if ($baselineCompareFacts !== []) {
$builder->addSection(
$factory->factsSection(
id: 'baseline_compare',
kind: 'operational_context',
kind: 'type_specific_detail',
title: 'Baseline compare',
items: $baselineCompareFacts,
description: 'Type-specific comparison detail stays below the canonical decision and supporting layers.',
collapsible: true,
collapsed: true,
),
);
}
if (($gapSummary['detail_state'] ?? 'no_gaps') !== 'no_gaps') {
$builder->addSection(
$factory->viewSection(
id: 'baseline_compare_gap_details',
kind: 'type_specific_detail',
title: 'Evidence gap details',
description: 'Policies affected by evidence gaps, grouped by reason and searchable by reason, policy type, or subject key.',
view: 'filament.infolists.entries.evidence-gap-subjects',
viewData: [
'summary' => $gapSummary,
'buckets' => $gapBuckets,
'searchId' => 'baseline-compare-gap-search-'.$record->getKey(),
],
collapsible: true,
collapsed: true,
),
);
}
@ -418,10 +449,12 @@ private static function enterpriseDetailPage(OperationRun $record): \App\Support
$builder->addSection(
$factory->viewSection(
id: 'baseline_compare_evidence',
kind: 'operational_context',
kind: 'type_specific_detail',
title: 'Baseline compare evidence',
view: 'filament.infolists.entries.snapshot-json',
viewData: ['payload' => $baselineCompareEvidence],
collapsible: true,
collapsed: true,
),
);
}
@ -434,10 +467,12 @@ private static function enterpriseDetailPage(OperationRun $record): \App\Support
$builder->addSection(
$factory->viewSection(
id: 'baseline_capture_evidence',
kind: 'operational_context',
kind: 'type_specific_detail',
title: 'Baseline capture evidence',
view: 'filament.infolists.entries.snapshot-json',
viewData: ['payload' => $baselineCaptureEvidence],
collapsible: true,
collapsed: true,
),
);
}
@ -447,7 +482,7 @@ private static function enterpriseDetailPage(OperationRun $record): \App\Support
$builder->addSection(
$factory->viewSection(
id: 'verification_report',
kind: 'operational_context',
kind: 'type_specific_detail',
title: 'Verification report',
view: 'filament.components.verification-report-viewer',
viewData: static::verificationReportViewData($record),
@ -455,9 +490,321 @@ private static function enterpriseDetailPage(OperationRun $record): \App\Support
);
}
$builder->addTechnicalSection(
$factory->technicalDetail(
title: 'Context',
entries: [
$factory->keyFact('Identity hash', $record->run_identity_hash, mono: true),
$factory->keyFact('Workspace scope', $record->workspace_id),
$factory->keyFact('Tenant scope', $record->tenant_id),
],
description: 'Stored run context stays available for debugging without dominating the default reading path.',
view: 'filament.infolists.entries.snapshot-json',
viewData: ['payload' => static::contextPayload($record)],
),
);
return $builder->build();
}
/**
* @return list<\App\Support\Ui\EnterpriseDetail\SupportingCardData>
*/
private static function supportingGroups(
OperationRun $record,
\App\Support\Ui\EnterpriseDetail\EnterpriseDetailSectionFactory $factory,
?ReferencedTenantLifecyclePresentation $referencedTenantLifecycle,
?OperatorExplanationPattern $operatorExplanation,
array $primaryNextStep,
): array {
$groups = [];
$hasElevatedLifecycleState = OperationUxPresenter::lifecycleAttentionSummary($record) !== null;
$guidanceItems = array_values(array_filter([
$operatorExplanation instanceof OperatorExplanationPattern && $operatorExplanation->coverageStatement !== null
? $factory->keyFact('Coverage', $operatorExplanation->coverageStatement)
: null,
$operatorExplanation instanceof OperatorExplanationPattern && $operatorExplanation->diagnosticsSummary !== null
? $factory->keyFact('Diagnostics summary', $operatorExplanation->diagnosticsSummary)
: null,
...array_map(
static fn (array $guidance): array => $factory->keyFact($guidance['label'], $guidance['text']),
array_values(array_filter(
$primaryNextStep['secondaryGuidance'] ?? [],
static fn (mixed $guidance): bool => is_array($guidance),
)),
),
static::blockedExecutionReasonCode($record) !== null
? $factory->keyFact('Blocked reason', static::blockedExecutionReasonCode($record))
: null,
static::blockedExecutionDetail($record) !== null
? $factory->keyFact('Blocked detail', static::blockedExecutionDetail($record))
: null,
static::blockedExecutionSource($record) !== null
? $factory->keyFact('Blocked by', static::blockedExecutionSource($record))
: null,
RunDurationInsights::stuckGuidance($record) !== null
? $factory->keyFact('Queue guidance', RunDurationInsights::stuckGuidance($record))
: null,
]));
if ($guidanceItems !== []) {
$groups[] = $factory->supportingFactsCard(
kind: 'guidance',
title: 'Guidance',
items: $guidanceItems,
description: 'Secondary guidance explains caveats and context without competing with the primary next step.',
);
}
$lifecycleItems = array_values(array_filter([
$referencedTenantLifecycle !== null
? $factory->keyFact(
'Tenant lifecycle',
$referencedTenantLifecycle->presentation->label,
badge: $factory->statusBadge(
$referencedTenantLifecycle->presentation->label,
$referencedTenantLifecycle->presentation->badgeColor,
$referencedTenantLifecycle->presentation->badgeIcon,
$referencedTenantLifecycle->presentation->badgeIconColor,
),
)
: null,
$referencedTenantLifecycle?->selectorAvailabilityMessage() !== null
? $factory->keyFact('Tenant selector context', $referencedTenantLifecycle->selectorAvailabilityMessage())
: null,
$referencedTenantLifecycle?->contextNote !== null
? $factory->keyFact('Viewer context', $referencedTenantLifecycle->contextNote)
: null,
! $hasElevatedLifecycleState && static::freshnessLabel($record) !== null
? $factory->keyFact('Freshness', (string) static::freshnessLabel($record))
: null,
! $hasElevatedLifecycleState && 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,
]));
if ($lifecycleItems !== []) {
$groups[] = $factory->supportingFactsCard(
kind: 'lifecycle',
title: 'Lifecycle',
items: $lifecycleItems,
description: 'Lifecycle context explains freshness, reconciliation, and tenant-scoped caveats.',
);
}
$timingItems = [
$factory->keyFact('Created', static::formatDetailTimestamp($record->created_at)),
$factory->keyFact('Started', static::formatDetailTimestamp($record->started_at)),
$factory->keyFact('Completed', static::formatDetailTimestamp($record->completed_at)),
$factory->keyFact('Elapsed', RunDurationInsights::elapsedHuman($record)),
];
$groups[] = $factory->supportingFactsCard(
kind: 'timing',
title: 'Timing',
items: $timingItems,
);
$metadataItems = array_values(array_filter([
$factory->keyFact('Initiator', $record->initiator_name),
RunDurationInsights::expectedHuman($record) !== null
? $factory->keyFact('Expected duration', RunDurationInsights::expectedHuman($record))
: null,
]));
if ($metadataItems !== []) {
$groups[] = $factory->supportingFactsCard(
kind: 'metadata',
title: 'Metadata',
items: $metadataItems,
description: 'Secondary metadata remains visible without crowding the top decision surface.',
);
}
return $groups;
}
/**
* @return array{
* text: string,
* source: string,
* secondaryGuidance: list<array{label: string, text: string, source: string}>
* }
*/
private static function resolvePrimaryNextStep(
OperationRun $record,
?ArtifactTruthEnvelope $artifactTruth,
?OperatorExplanationPattern $operatorExplanation,
): array {
$candidates = [];
static::pushNextStepCandidate($candidates, $operatorExplanation?->nextActionText, 'operator_explanation');
static::pushNextStepCandidate($candidates, $artifactTruth?->nextStepText(), 'artifact_truth');
$opsUxSource = match (true) {
(string) $record->outcome === OperationRunOutcome::Blocked->value => 'blocked_reason',
OperationUxPresenter::lifecycleAttentionSummary($record) !== null => 'lifecycle_attention',
default => 'ops_ux',
};
static::pushNextStepCandidate($candidates, OperationUxPresenter::surfaceGuidance($record), $opsUxSource);
if ($candidates === []) {
return [
'text' => 'No action needed.',
'source' => 'none_required',
'secondaryGuidance' => [],
];
}
$primary = $candidates[0];
$primarySource = static::normalizeGuidance($primary['text']) === 'no action needed'
? 'none_required'
: $primary['source'];
$secondaryGuidance = array_map(
static fn (array $candidate): array => [
'label' => static::guidanceLabel($candidate['source']),
'text' => $candidate['text'],
'source' => $candidate['source'],
],
array_slice($candidates, 1),
);
return [
'text' => $primary['text'],
'source' => $primarySource,
'secondaryGuidance' => $secondaryGuidance,
];
}
/**
* @param array<int, array{text: string, source: string, normalized: string}> $candidates
*/
private static function pushNextStepCandidate(array &$candidates, ?string $text, string $source): void
{
$formattedText = static::formatGuidanceText($text);
if ($formattedText === null) {
return;
}
$normalized = static::normalizeGuidance($formattedText);
foreach ($candidates as $candidate) {
if (($candidate['normalized'] ?? null) === $normalized) {
return;
}
}
$candidates[] = [
'text' => $formattedText,
'source' => $source,
'normalized' => $normalized,
];
}
private static function formatGuidanceText(?string $text): ?string
{
if (! is_string($text)) {
return null;
}
$text = trim($text);
if ($text === '') {
return null;
}
if (preg_match('/[.!?]$/', $text) === 1) {
return $text;
}
return $text.'.';
}
private static function normalizeGuidance(string $text): string
{
$normalized = mb_strtolower(trim($text));
$normalized = preg_replace('/^next step:\s*/', '', $normalized) ?? $normalized;
return trim($normalized, " \t\n\r\0\x0B.!?");
}
private static function guidanceLabel(string $source): string
{
return match ($source) {
'operator_explanation' => 'Operator guidance',
'artifact_truth' => 'Artifact guidance',
'blocked_reason' => 'Blocked prerequisite',
'lifecycle_attention' => 'Lifecycle guidance',
default => 'General guidance',
};
}
/**
* @return array<string, mixed>|null
*/
private static function artifactTruthFact(
\App\Support\Ui\EnterpriseDetail\EnterpriseDetailSectionFactory $factory,
?ArtifactTruthEnvelope $artifactTruth,
): ?array {
if (! $artifactTruth instanceof ArtifactTruthEnvelope) {
return null;
}
$badge = $artifactTruth->primaryBadgeSpec();
return $factory->keyFact(
'Artifact truth',
$artifactTruth->primaryLabel,
$artifactTruth->primaryExplanation,
$factory->statusBadge($badge->label, $badge->color, $badge->icon, $badge->iconColor),
);
}
private static function decisionAttentionNote(OperationRun $record): ?string
{
return null;
}
private static function detailHintUnlessDuplicate(?string $hint, ?string $duplicateOf): ?string
{
$normalizedHint = static::normalizeDetailText($hint);
if ($normalizedHint === null) {
return null;
}
if ($normalizedHint === static::normalizeDetailText($duplicateOf)) {
return null;
}
return trim($hint ?? '');
}
private static function normalizeDetailText(?string $value): ?string
{
if (! is_string($value)) {
return null;
}
$normalized = trim((string) preg_replace('/\s+/', ' ', $value));
if ($normalized === '') {
return null;
}
return mb_strtolower($normalized);
}
/**
* @return list<array<string, mixed>>
*/
@ -468,12 +815,29 @@ private static function summaryCountFacts(
$counts = \App\Support\OpsUx\SummaryCountsNormalizer::normalize(is_array($record->summary_counts) ? $record->summary_counts : []);
return array_map(
static fn (string $key, int $value): array => $factory->keyFact(SummaryCountsNormalizer::label($key), $value),
static fn (string $key, int $value): array => $factory->keyFact(
SummaryCountsNormalizer::label($key),
$value,
tone: self::countTone($key, $value),
),
array_keys($counts),
array_values($counts),
);
}
private static function countTone(string $key, int $value): ?string
{
if (in_array($key, ['failed', 'errors_recorded', 'findings_reopened'], true)) {
return $value > 0 ? 'danger' : 'success';
}
if ($key === 'succeeded' && $value > 0) {
return 'success';
}
return null;
}
private static function blockedExecutionReasonCode(OperationRun $record): ?string
{
if ((string) $record->outcome !== OperationRunOutcome::Blocked->value) {
@ -538,6 +902,8 @@ private static function baselineCompareFacts(
\App\Support\Ui\EnterpriseDetail\EnterpriseDetailSectionFactory $factory,
): array {
$context = is_array($record->context) ? $record->context : [];
$gapDetails = BaselineCompareEvidenceGapDetails::fromOperationRun($record);
$gapSummary = is_array($gapDetails['summary'] ?? null) ? $gapDetails['summary'] : [];
$facts = [];
$fidelity = data_get($context, 'baseline_compare.fidelity');
@ -569,6 +935,30 @@ private static function baselineCompareFacts(
);
}
if ((int) ($gapSummary['count'] ?? 0) > 0) {
$facts[] = $factory->keyFact(
'Evidence gap detail',
match ($gapSummary['detail_state'] ?? 'no_gaps') {
'structured_details_recorded' => 'Structured subject details available',
'details_not_recorded' => 'Detailed rows were not recorded',
'legacy_broad_reason' => 'Legacy development payload should be regenerated',
default => 'No evidence gaps recorded',
},
);
}
if ((int) ($gapSummary['structural_count'] ?? 0) > 0) {
$facts[] = $factory->keyFact('Structural gaps', (string) (int) $gapSummary['structural_count']);
}
if ((int) ($gapSummary['operational_count'] ?? 0) > 0) {
$facts[] = $factory->keyFact('Operational gaps', (string) (int) $gapSummary['operational_count']);
}
if ((int) ($gapSummary['transient_count'] ?? 0) > 0) {
$facts[] = $factory->keyFact('Transient gaps', (string) (int) $gapSummary['transient_count']);
}
if ($uncoveredTypes !== []) {
sort($uncoveredTypes, SORT_STRING);
$facts[] = $factory->keyFact('Uncovered types', implode(', ', array_slice($uncoveredTypes, 0, 12)).(count($uncoveredTypes) > 12 ? '…' : ''));
@ -709,6 +1099,82 @@ private static function contextPayload(OperationRun $record): array
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
{
if (! $value instanceof \Illuminate\Support\Carbon) {

View File

@ -257,7 +257,7 @@ public static function table(Table $table): Table
->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)->primaryExplanation)
->description(fn (TenantReview $record): ?string => static::truthEnvelope($record)->operatorExplanation?->headline ?? static::truthEnvelope($record)->primaryExplanation)
->wrap(),
Tables\Columns\TextColumn::make('completeness_state')
->label('Completeness')
@ -295,7 +295,7 @@ public static function table(Table $table): Table
->boolean(),
Tables\Columns\TextColumn::make('artifact_next_step')
->label('Next step')
->getStateUsing(fn (TenantReview $record): string => static::truthEnvelope($record)->nextStepText())
->getStateUsing(fn (TenantReview $record): string => static::truthEnvelope($record)->operatorExplanation?->nextActionText ?? static::truthEnvelope($record)->nextStepText())
->wrap(),
Tables\Columns\TextColumn::make('fingerprint')
->toggleable(isToggledHiddenByDefault: true)
@ -563,6 +563,7 @@ private static function summaryPresentation(TenantReview $record): array
$summary = is_array($record->summary) ? $record->summary : [];
return [
'operator_explanation' => static::truthEnvelope($record)->operatorExplanation?->toArray(),
'highlights' => is_array($summary['highlights'] ?? null) ? $summary['highlights'] : [],
'next_actions' => is_array($summary['recommended_next_actions'] ?? null) ? $summary['recommended_next_actions'] : [],
'publish_blockers' => is_array($summary['publish_blockers'] ?? null) ? $summary['publish_blockers'] : [],

View File

@ -4,8 +4,11 @@
namespace App\Filament\Widgets\Dashboard;
use App\Filament\Pages\BaselineCompareLanding;
use App\Filament\Resources\FindingResource;
use App\Models\Tenant;
use App\Support\Baselines\BaselineCompareStats;
use App\Support\OperationRunLinks;
use Filament\Facades\Filament;
use Filament\Widgets\Widget;
@ -23,33 +26,46 @@ protected function getViewData(): array
$empty = [
'hasAssignment' => false,
'profileName' => null,
'findingsCount' => 0,
'highCount' => 0,
'mediumCount' => 0,
'lowCount' => 0,
'lastComparedAt' => null,
'landingUrl' => null,
'runUrl' => null,
'findingsUrl' => null,
'nextActionUrl' => null,
'summaryAssessment' => null,
];
if (! $tenant instanceof Tenant) {
return $empty;
}
$stats = BaselineCompareStats::forWidget($tenant);
$stats = BaselineCompareStats::forTenant($tenant);
if (in_array($stats->state, ['no_tenant', 'no_assignment'], true)) {
return $empty;
}
$landingUrl = BaselineCompareLanding::getUrl(tenant: $tenant);
$runUrl = $stats->operationRunId !== null
? OperationRunLinks::view($stats->operationRunId, $tenant)
: null;
$findingsUrl = FindingResource::getUrl('index', tenant: $tenant);
$summaryAssessment = $stats->summaryAssessment();
$nextActionUrl = match ($summaryAssessment->nextActionTarget()) {
'run' => $runUrl,
'findings' => $findingsUrl,
'landing' => $landingUrl,
default => null,
};
return [
'hasAssignment' => true,
'profileName' => $stats->profileName,
'findingsCount' => $stats->findingsCount ?? 0,
'highCount' => $stats->severityCounts['high'] ?? 0,
'mediumCount' => $stats->severityCounts['medium'] ?? 0,
'lowCount' => $stats->severityCounts['low'] ?? 0,
'lastComparedAt' => $stats->lastComparedHuman,
'landingUrl' => \App\Filament\Pages\BaselineCompareLanding::getUrl(tenant: $tenant),
'landingUrl' => $landingUrl,
'runUrl' => $runUrl,
'findingsUrl' => $findingsUrl,
'nextActionUrl' => $nextActionUrl,
'summaryAssessment' => $summaryAssessment->toArray(),
];
}
}

View File

@ -4,12 +4,9 @@
namespace App\Filament\Widgets\Dashboard;
use App\Filament\Pages\BaselineCompareLanding;
use App\Filament\Resources\FindingResource;
use App\Models\Finding;
use App\Models\OperationRun;
use App\Models\Tenant;
use App\Support\OperationRunLinks;
use App\Support\Baselines\BaselineCompareStats;
use App\Support\OpsUx\ActiveRuns;
use Filament\Facades\Filament;
use Filament\Widgets\Widget;
@ -34,6 +31,8 @@ protected function getViewData(): array
}
$tenantId = (int) $tenant->getKey();
$compareStats = BaselineCompareStats::forTenant($tenant);
$compareAssessment = $compareStats->summaryAssessment();
$items = [];
@ -48,71 +47,30 @@ protected function getViewData(): array
$items[] = [
'title' => 'High severity drift findings',
'body' => "{$highSeverityCount} finding(s) need review.",
'url' => FindingResource::getUrl('index', tenant: $tenant),
'badge' => 'Drift',
'badgeColor' => 'danger',
];
}
$latestBaselineCompareSuccess = OperationRun::query()
->where('tenant_id', $tenantId)
->where('type', 'baseline_compare')
->where('status', 'completed')
->where('outcome', 'succeeded')
->whereNotNull('completed_at')
->latest('completed_at')
->first();
if (! $latestBaselineCompareSuccess) {
if ($compareAssessment->stateFamily !== 'positive') {
$items[] = [
'title' => 'No baseline compare yet',
'body' => 'Run a baseline compare after your tenant has an assigned baseline snapshot.',
'url' => BaselineCompareLanding::getUrl(tenant: $tenant),
'badge' => 'Drift',
'badgeColor' => 'warning',
];
} else {
$isStale = $latestBaselineCompareSuccess->completed_at?->lt(now()->subDays(7)) ?? true;
if ($isStale) {
$items[] = [
'title' => 'Baseline compare stale',
'body' => 'Last baseline compare is older than 7 days.',
'url' => BaselineCompareLanding::getUrl(tenant: $tenant),
'badge' => 'Drift',
'badgeColor' => 'warning',
];
}
}
$latestBaselineCompareFailure = OperationRun::query()
->where('tenant_id', $tenantId)
->where('type', 'baseline_compare')
->where('status', 'completed')
->where('outcome', 'failed')
->latest('id')
->first();
if ($latestBaselineCompareFailure instanceof OperationRun) {
$items[] = [
'title' => 'Baseline compare failed',
'body' => 'Investigate the latest failed run.',
'url' => OperationRunLinks::view($latestBaselineCompareFailure, $tenant),
'badge' => 'Operations',
'badgeColor' => 'danger',
'title' => 'Baseline compare posture',
'body' => $compareAssessment->headline,
'supportingMessage' => $compareAssessment->supportingMessage,
'badge' => 'Baseline',
'badgeColor' => $compareAssessment->tone,
'nextStep' => $compareAssessment->nextActionLabel(),
];
}
$activeRuns = (int) OperationRun::query()
->where('tenant_id', $tenantId)
->active()
->count();
$activeRuns = ActiveRuns::existForTenant($tenant)
? (int) \App\Models\OperationRun::query()->where('tenant_id', $tenantId)->active()->count()
: 0;
if ($activeRuns > 0) {
$items[] = [
'title' => 'Operations in progress',
'body' => "{$activeRuns} run(s) are active.",
'url' => OperationRunLinks::index($tenant),
'badge' => 'Operations',
'badgeColor' => 'warning',
];
@ -125,24 +83,16 @@ protected function getViewData(): array
if ($items === []) {
$healthyChecks = [
[
'title' => 'Drift findings look healthy',
'body' => 'No high severity drift findings are open.',
'url' => FindingResource::getUrl('index', tenant: $tenant),
'linkLabel' => 'View findings',
'title' => 'Baseline compare looks trustworthy',
'body' => $compareAssessment->headline,
],
[
'title' => 'Baseline compares are up to date',
'body' => $latestBaselineCompareSuccess?->completed_at
? 'Last baseline compare: '.$latestBaselineCompareSuccess->completed_at->diffForHumans(['short' => true]).'.'
: 'Baseline compare history is available in Baseline Compare.',
'url' => BaselineCompareLanding::getUrl(tenant: $tenant),
'linkLabel' => 'Open Baseline Compare',
'title' => 'No high severity drift is open',
'body' => 'No high severity drift findings are currently open for this tenant.',
],
[
'title' => 'No active operations',
'body' => 'Nothing is currently running for this tenant.',
'url' => OperationRunLinks::index($tenant),
'linkLabel' => 'View operations',
],
];
}

View File

@ -4,6 +4,7 @@
namespace App\Filament\Widgets\Tenant;
use App\Filament\Pages\BaselineCompareLanding;
use App\Models\Tenant;
use App\Support\Baselines\BaselineCompareStats;
use App\Support\OperationRunLinks;
@ -30,26 +31,29 @@ protected function getViewData(): array
}
$stats = BaselineCompareStats::forTenant($tenant);
$uncoveredTypes = $stats->uncoveredTypes ?? [];
$uncoveredTypes = is_array($uncoveredTypes) ? $uncoveredTypes : [];
$coverageStatus = $stats->coverageStatus;
$hasWarnings = in_array($coverageStatus, ['warning', 'unproven'], true) && $uncoveredTypes !== [];
$summaryAssessment = $stats->summaryAssessment();
$runUrl = null;
if ($stats->operationRunId !== null) {
$runUrl = OperationRunLinks::view($stats->operationRunId, $tenant);
}
$landingUrl = BaselineCompareLanding::getUrl(tenant: $tenant);
$nextActionUrl = match ($summaryAssessment->nextActionTarget()) {
'run' => $runUrl,
'landing' => $landingUrl,
default => null,
};
$shouldShow = in_array($summaryAssessment->stateFamily, ['caution', 'stale', 'unavailable', 'in_progress'], true)
|| ($summaryAssessment->stateFamily === 'action_required' && $summaryAssessment->evaluationResult === 'failed_result');
return [
'shouldShow' => $hasWarnings && $runUrl !== null,
'shouldShow' => $shouldShow,
'landingUrl' => $landingUrl,
'runUrl' => $runUrl,
'coverageStatus' => $coverageStatus,
'fidelity' => $stats->fidelity,
'uncoveredTypesCount' => $stats->uncoveredTypesCount ?? count($uncoveredTypes),
'uncoveredTypes' => $uncoveredTypes,
'nextActionUrl' => $nextActionUrl,
'summaryAssessment' => $summaryAssessment->toArray(),
'state' => $stats->state,
];
}
}

View File

@ -2,6 +2,7 @@
namespace App\Jobs;
use App\Jobs\Concerns\BridgesFailedOperationRun;
use App\Jobs\Operations\BackupSetRestoreWorkerJob;
use App\Models\OperationRun;
use App\Services\OperationRunService;
@ -11,11 +12,18 @@
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use RuntimeException;
use Throwable;
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;
@ -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
{
if ($this->operationRun instanceof OperationRun) {

View File

@ -2,6 +2,7 @@
namespace App\Jobs;
use App\Jobs\Concerns\BridgesFailedOperationRun;
use App\Jobs\Middleware\EnsureQueuedExecutionLegitimate;
use App\Jobs\Operations\TenantSyncWorkerJob;
use App\Models\OperationRun;
@ -15,7 +16,15 @@
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;

View File

@ -2,6 +2,7 @@
namespace App\Jobs;
use App\Jobs\Concerns\BridgesFailedOperationRun;
use App\Jobs\Middleware\TrackOperationRun;
use App\Models\BaselineProfile;
use App\Models\BaselineSnapshot;
@ -21,7 +22,9 @@
use App\Support\Baselines\BaselineCaptureMode;
use App\Support\Baselines\BaselineFullContentRolloutGate;
use App\Support\Baselines\BaselineProfileStatus;
use App\Support\Baselines\BaselineReasonCodes;
use App\Support\Baselines\BaselineScope;
use App\Support\Baselines\BaselineSnapshotLifecycleState;
use App\Support\Baselines\PolicyVersionCapturePurpose;
use App\Support\Inventory\InventoryPolicyTypeMeta;
use App\Support\OperationRunOutcome;
@ -33,10 +36,19 @@
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use RuntimeException;
use Throwable;
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;
@ -60,13 +72,13 @@ public function handle(
AuditLogger $auditLogger,
OperationRunService $operationRunService,
?CurrentStateHashResolver $hashResolver = null,
?BaselineSnapshotItemNormalizer $snapshotItemNormalizer = null,
?BaselineContentCapturePhase $contentCapturePhase = null,
?BaselineSnapshotItemNormalizer $snapshotItemNormalizer = null,
?BaselineFullContentRolloutGate $rolloutGate = null,
): void {
$hashResolver ??= app(CurrentStateHashResolver::class);
$snapshotItemNormalizer ??= app(BaselineSnapshotItemNormalizer::class);
$contentCapturePhase ??= app(BaselineContentCapturePhase::class);
$snapshotItemNormalizer ??= app(BaselineSnapshotItemNormalizer::class);
$rolloutGate ??= app(BaselineFullContentRolloutGate::class);
if (! $this->operationRun instanceof OperationRun) {
@ -96,6 +108,7 @@ public function handle(
: null;
$effectiveScope = BaselineScope::fromJsonb($context['effective_scope'] ?? null);
$truthfulTypes = $this->truthfulTypesFromContext($context, $effectiveScope);
$captureMode = $profile->capture_mode instanceof BaselineCaptureMode
? $profile->capture_mode
@ -115,6 +128,7 @@ public function handle(
scope: $effectiveScope,
identity: $identity,
latestInventorySyncRunId: $latestInventorySyncRunId,
policyTypes: $truthfulTypes,
);
$subjects = $inventoryResult['subjects'];
@ -208,16 +222,17 @@ public function handle(
],
];
$snapshot = $this->findOrCreateSnapshot(
$snapshotResult = $this->captureSnapshotArtifact(
$profile,
$identityHash,
$items,
$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()]);
}
@ -249,6 +264,9 @@ public function handle(
'gaps' => [
'count' => $gapsCount,
'by_reason' => $gapsByReason,
'subjects' => is_array($phaseResult['gap_subjects'] ?? null) && $phaseResult['gap_subjects'] !== []
? array_values($phaseResult['gap_subjects'])
: null,
],
'resume_token' => $resumeToken,
],
@ -258,6 +276,7 @@ public function handle(
'snapshot_identity_hash' => $identityHash,
'was_new_snapshot' => $wasNewSnapshot,
'items_captured' => $snapshotItems['items_count'],
'snapshot_lifecycle' => $snapshot->lifecycleState()->value,
];
$this->operationRun->update(['context' => $updatedContext]);
@ -282,7 +301,7 @@ public function handle(
/**
* @return array{
* subjects_total: int,
* subjects: list<array{policy_type: string, subject_external_id: string}>,
* subjects: list<array{policy_type: string, subject_external_id: string, subject_key: string}>,
* inventory_by_key: array<string, array{
* tenant_subject_external_id: string,
* workspace_subject_external_id: string,
@ -303,6 +322,7 @@ private function collectInventorySubjects(
BaselineScope $scope,
BaselineSnapshotIdentity $identity,
?int $latestInventorySyncRunId = null,
?array $policyTypes = null,
): array {
$query = InventoryItem::query()
->where('tenant_id', $sourceTenant->getKey());
@ -311,7 +331,7 @@ private function collectInventorySubjects(
$query->where('last_seen_operation_run_id', $latestInventorySyncRunId);
}
$query->whereIn('policy_type', $scope->allTypes());
$query->whereIn('policy_type', is_array($policyTypes) && $policyTypes !== [] ? $policyTypes : $scope->allTypes());
/** @var array<string, array{tenant_subject_external_id: string, workspace_subject_external_id: string, subject_key: string, policy_type: string, identity_strategy: string, display_name: ?string, category: ?string, platform: ?string, is_built_in: ?bool, role_permission_count: ?int}> $inventoryByKey */
$inventoryByKey = [];
@ -399,6 +419,7 @@ private function collectInventorySubjects(
static fn (array $item): array => [
'policy_type' => (string) $item['policy_type'],
'subject_external_id' => (string) $item['tenant_subject_external_id'],
'subject_key' => (string) $item['subject_key'],
],
$inventoryByKey,
));
@ -411,6 +432,27 @@ private function collectInventorySubjects(
];
}
/**
* @param array<string, mixed> $context
* @return list<string>
*/
private function truthfulTypesFromContext(array $context, BaselineScope $effectiveScope): array
{
$truthfulTypes = data_get($context, 'effective_scope.truthful_types');
if (is_array($truthfulTypes)) {
$truthfulTypes = array_values(array_filter($truthfulTypes, 'is_string'));
if ($truthfulTypes !== []) {
sort($truthfulTypes, SORT_STRING);
return $truthfulTypes;
}
}
return $effectiveScope->allTypes();
}
/**
* @param array<string, array{
* tenant_subject_external_id: string,
@ -508,29 +550,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,
string $identityHash,
array $snapshotItems,
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()
->where('workspace_id', $profile->workspace_id)
->where('baseline_profile_id', $profile->getKey())
->where('snapshot_identity_hash', $identityHash)
->where('lifecycle_state', BaselineSnapshotLifecycleState::Complete->value)
->first();
if ($existing instanceof BaselineSnapshot) {
return $existing;
}
return $existing instanceof BaselineSnapshot ? $existing : null;
}
$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,
'baseline_profile_id' => (int) $profile->getKey(),
'snapshot_identity_hash' => $identityHash,
'snapshot_identity_hash' => $this->temporarySnapshotIdentityHash($profile),
'captured_at' => now(),
'lifecycle_state' => BaselineSnapshotLifecycleState::Building->value,
'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) {
$rows = array_map(
@ -549,9 +713,56 @@ private function findOrCreateSnapshot(
);
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;
use App\Jobs\Concerns\BridgesFailedOperationRun;
use App\Jobs\Middleware\TrackOperationRun;
use App\Models\BaselineProfile;
use App\Models\BaselineSnapshot;
@ -18,6 +19,7 @@
use App\Services\Baselines\BaselineAutoCloseService;
use App\Services\Baselines\BaselineContentCapturePhase;
use App\Services\Baselines\BaselineSnapshotIdentity;
use App\Services\Baselines\BaselineSnapshotTruthResolver;
use App\Services\Baselines\CurrentStateHashResolver;
use App\Services\Baselines\Evidence\BaselinePolicyVersionResolver;
use App\Services\Baselines\Evidence\ContentEvidenceProvider;
@ -37,13 +39,16 @@
use App\Support\Baselines\BaselineCaptureMode;
use App\Support\Baselines\BaselineCompareReasonCode;
use App\Support\Baselines\BaselineFullContentRolloutGate;
use App\Support\Baselines\BaselineReasonCodes;
use App\Support\Baselines\BaselineScope;
use App\Support\Baselines\BaselineSubjectKey;
use App\Support\Baselines\PolicyVersionCapturePurpose;
use App\Support\Baselines\SubjectResolver;
use App\Support\Inventory\InventoryCoverage;
use App\Support\OperationRunOutcome;
use App\Support\OperationRunStatus;
use App\Support\OperationRunType;
use App\Support\ReasonTranslation\ReasonPresenter;
use Carbon\CarbonImmutable;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
@ -54,7 +59,15 @@
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>
@ -84,6 +97,7 @@ public function handle(
?SettingsResolver $settingsResolver = null,
?BaselineAutoCloseService $baselineAutoCloseService = null,
?CurrentStateHashResolver $hashResolver = null,
?BaselineSnapshotTruthResolver $snapshotTruthResolver = null,
?MetaEvidenceProvider $metaEvidenceProvider = null,
?BaselineContentCapturePhase $contentCapturePhase = null,
?BaselineFullContentRolloutGate $rolloutGate = null,
@ -92,6 +106,7 @@ public function handle(
$settingsResolver ??= app(SettingsResolver::class);
$baselineAutoCloseService ??= app(BaselineAutoCloseService::class);
$hashResolver ??= app(CurrentStateHashResolver::class);
$snapshotTruthResolver ??= app(BaselineSnapshotTruthResolver::class);
$metaEvidenceProvider ??= app(MetaEvidenceProvider::class);
$contentCapturePhase ??= app(BaselineContentCapturePhase::class);
$rolloutGate ??= app(BaselineFullContentRolloutGate::class);
@ -130,7 +145,7 @@ public function handle(
: null;
$effectiveScope = BaselineScope::fromJsonb($context['effective_scope'] ?? null);
$effectiveTypes = $effectiveScope->allTypes();
$effectiveTypes = $this->truthfulTypesFromContext($context, $effectiveScope);
$scopeKey = 'baseline_profile:'.$profile->getKey();
$captureMode = $profile->capture_mode instanceof BaselineCaptureMode
@ -278,12 +293,52 @@ public function handle(
->where('workspace_id', (int) $profile->workspace_id)
->where('baseline_profile_id', (int) $profile->getKey())
->whereKey($snapshotId)
->first(['id', 'captured_at']);
->first();
if (! $snapshot instanceof BaselineSnapshot) {
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
? CarbonImmutable::instance($snapshot->captured_at)
: null;
@ -309,6 +364,7 @@ public function handle(
static fn (array $item): array => [
'policy_type' => (string) $item['policy_type'],
'subject_external_id' => (string) $item['subject_external_id'],
'subject_key' => (string) $item['subject_key'],
],
$currentItems,
));
@ -334,6 +390,7 @@ public function handle(
];
$phaseResult = [];
$phaseGaps = [];
$phaseGapSubjects = [];
$resumeToken = null;
if ($captureMode === BaselineCaptureMode::FullContent) {
@ -362,6 +419,7 @@ public function handle(
$phaseStats = is_array($phaseResult['stats'] ?? null) ? $phaseResult['stats'] : $phaseStats;
$phaseGaps = is_array($phaseResult['gaps'] ?? null) ? $phaseResult['gaps'] : [];
$phaseGapSubjects = is_array($phaseResult['gap_subjects'] ?? null) ? $phaseResult['gap_subjects'] : [];
$resumeToken = is_string($phaseResult['resume_token'] ?? null) ? $phaseResult['resume_token'] : null;
}
@ -441,6 +499,12 @@ public function handle(
$gapsByReason = $this->mergeGapCounts($baselineGaps, $currentGaps, $phaseGaps, $driftGaps);
$gapsCount = array_sum($gapsByReason);
$gapSubjects = $this->collectGapSubjects(
ambiguousKeys: $ambiguousKeys,
phaseGapSubjects: $phaseGapSubjects ?? [],
driftGapSubjects: $computeResult['evidence_gap_subjects'] ?? [],
);
$summaryCounts = [
'total' => count($driftResults),
'processed' => count($driftResults),
@ -518,6 +582,7 @@ public function handle(
'count' => $gapsCount,
'by_reason' => $gapsByReason,
...$gapsByReason,
'subjects' => $gapSubjects !== [] ? $gapSubjects : null,
],
'resume_token' => $resumeToken,
'coverage' => [
@ -545,6 +610,10 @@ public function handle(
'findings_resolved' => $resolvedCount,
'severity_breakdown' => $severityBreakdown,
];
$updatedContext = $this->withCompareReasonTranslation(
$updatedContext,
$reasonCode?->value,
);
$this->operationRun->update(['context' => $updatedContext]);
$this->auditCompleted(
@ -790,6 +859,7 @@ private function completeWithCoverageWarning(
'findings_resolved' => 0,
'severity_breakdown' => [],
];
$updatedContext = $this->withCompareReasonTranslation($updatedContext, $reasonCode->value);
$this->operationRun->update(['context' => $updatedContext]);
@ -896,6 +966,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".
*
@ -1004,6 +1102,38 @@ private function resolveLatestInventorySyncRun(Tenant $tenant): ?OperationRun
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.',
};
}
/**
* @param array<string, mixed> $context
* @return list<string>
*/
private function truthfulTypesFromContext(array $context, BaselineScope $effectiveScope): array
{
$truthfulTypes = data_get($context, 'effective_scope.truthful_types');
if (is_array($truthfulTypes)) {
$truthfulTypes = array_values(array_filter($truthfulTypes, 'is_string'));
if ($truthfulTypes !== []) {
sort($truthfulTypes, SORT_STRING);
return $truthfulTypes;
}
}
return $effectiveScope->allTypes();
}
/**
* Compare baseline items vs current inventory and produce drift results.
*
@ -1036,6 +1166,7 @@ private function computeDrift(
): array {
$drift = [];
$evidenceGaps = [];
$evidenceGapSubjects = [];
$rbacRoleDefinitionSummary = $this->emptyRbacRoleDefinitionSummary();
$roleDefinitionNormalizer = app(IntuneRoleDefinitionNormalizer::class);
@ -1077,6 +1208,7 @@ private function computeDrift(
if (! is_array($currentItem)) {
if ($isRbacRoleDefinition && $baselinePolicyVersionId === null) {
$evidenceGaps['missing_role_definition_baseline_version_reference'] = ($evidenceGaps['missing_role_definition_baseline_version_reference'] ?? 0) + 1;
$evidenceGapSubjects['missing_role_definition_baseline_version_reference'][] = $key;
continue;
}
@ -1141,6 +1273,7 @@ private function computeDrift(
if (! $currentEvidence instanceof ResolvedEvidence) {
$evidenceGaps['missing_current'] = ($evidenceGaps['missing_current'] ?? 0) + 1;
$evidenceGapSubjects['missing_current'][] = $key;
continue;
}
@ -1157,12 +1290,14 @@ private function computeDrift(
if ($isRbacRoleDefinition) {
if ($baselinePolicyVersionId === null) {
$evidenceGaps['missing_role_definition_baseline_version_reference'] = ($evidenceGaps['missing_role_definition_baseline_version_reference'] ?? 0) + 1;
$evidenceGapSubjects['missing_role_definition_baseline_version_reference'][] = $key;
continue;
}
if ($currentPolicyVersionId === null) {
$evidenceGaps['missing_role_definition_current_version_reference'] = ($evidenceGaps['missing_role_definition_current_version_reference'] ?? 0) + 1;
$evidenceGapSubjects['missing_role_definition_current_version_reference'][] = $key;
continue;
}
@ -1176,6 +1311,7 @@ private function computeDrift(
if ($roleDefinitionDiff === null) {
$evidenceGaps['missing_role_definition_compare_surface'] = ($evidenceGaps['missing_role_definition_compare_surface'] ?? 0) + 1;
$evidenceGapSubjects['missing_role_definition_compare_surface'][] = $key;
continue;
}
@ -1256,6 +1392,7 @@ private function computeDrift(
if (! $currentEvidence instanceof ResolvedEvidence) {
$evidenceGaps['missing_current'] = ($evidenceGaps['missing_current'] ?? 0) + 1;
$evidenceGapSubjects['missing_current'][] = $key;
continue;
}
@ -1271,6 +1408,7 @@ private function computeDrift(
if ($isRbacRoleDefinition && $currentPolicyVersionId === null) {
$evidenceGaps['missing_role_definition_current_version_reference'] = ($evidenceGaps['missing_role_definition_current_version_reference'] ?? 0) + 1;
$evidenceGapSubjects['missing_role_definition_current_version_reference'][] = $key;
continue;
}
@ -1330,6 +1468,7 @@ private function computeDrift(
return [
'drift' => $drift,
'evidence_gaps' => $evidenceGaps,
'evidence_gap_subjects' => $evidenceGapSubjects,
'rbac_role_definitions' => $rbacRoleDefinitionSummary,
];
}
@ -1841,6 +1980,163 @@ private function mergeGapCounts(array ...$gaps): array
return $merged;
}
private const GAP_SUBJECTS_LIMIT = 50;
/**
* @param list<string> $ambiguousKeys
* @return list<array<string, mixed>>
*/
private function collectGapSubjects(array $ambiguousKeys, mixed $phaseGapSubjects, mixed $driftGapSubjects): array
{
$subjects = [];
$seen = [];
if ($ambiguousKeys !== []) {
foreach (array_slice($ambiguousKeys, 0, self::GAP_SUBJECTS_LIMIT) as $ambiguousKey) {
if (! is_string($ambiguousKey) || $ambiguousKey === '') {
continue;
}
[$policyType, $subjectKey] = $this->splitGapSubjectKey($ambiguousKey);
if ($policyType === null || $subjectKey === null) {
continue;
}
$descriptor = $this->subjectResolver()->describeForCompare($policyType, subjectKey: $subjectKey);
$record = array_merge($descriptor->toArray(), $this->subjectResolver()->ambiguousMatch($descriptor)->toArray());
$fingerprint = md5(json_encode([$record['policy_type'], $record['subject_key'], $record['reason_code']]));
if (isset($seen[$fingerprint])) {
continue;
}
$seen[$fingerprint] = true;
$subjects[] = $record;
}
}
foreach ($this->normalizeStructuredGapSubjects($phaseGapSubjects) as $record) {
$fingerprint = md5(json_encode([$record['policy_type'] ?? null, $record['subject_key'] ?? null, $record['reason_code'] ?? null]));
if (isset($seen[$fingerprint])) {
continue;
}
$seen[$fingerprint] = true;
$subjects[] = $record;
}
foreach ($this->normalizeLegacyGapSubjects($driftGapSubjects) as $record) {
$fingerprint = md5(json_encode([$record['policy_type'] ?? null, $record['subject_key'] ?? null, $record['reason_code'] ?? null]));
if (isset($seen[$fingerprint])) {
continue;
}
$seen[$fingerprint] = true;
$subjects[] = $record;
}
return array_slice($subjects, 0, self::GAP_SUBJECTS_LIMIT);
}
/**
* @return list<array<string, mixed>>
*/
private function normalizeStructuredGapSubjects(mixed $value): array
{
if (! is_array($value)) {
return [];
}
$subjects = [];
foreach ($value as $record) {
if (! is_array($record)) {
continue;
}
if (! is_string($record['policy_type'] ?? null) || ! is_string($record['subject_key'] ?? null) || ! is_string($record['reason_code'] ?? null)) {
continue;
}
$subjects[] = $record;
}
return $subjects;
}
/**
* @return list<array<string, mixed>>
*/
private function normalizeLegacyGapSubjects(mixed $value): array
{
if (! is_array($value)) {
return [];
}
$subjects = [];
foreach ($value as $reasonCode => $keys) {
if (! is_string($reasonCode) || ! is_array($keys)) {
continue;
}
foreach ($keys as $key) {
if (! is_string($key) || $key === '') {
continue;
}
[$policyType, $subjectKey] = $this->splitGapSubjectKey($key);
if ($policyType === null || $subjectKey === null) {
continue;
}
$descriptor = $this->subjectResolver()->describeForCompare($policyType, subjectKey: $subjectKey);
$outcome = match ($reasonCode) {
'missing_current' => $this->subjectResolver()->missingExpectedRecord($descriptor),
'ambiguous_match' => $this->subjectResolver()->ambiguousMatch($descriptor),
default => $this->subjectResolver()->captureFailed($descriptor),
};
$record = array_merge($descriptor->toArray(), $outcome->toArray());
$record['reason_code'] = $reasonCode;
$subjects[] = $record;
}
}
return $subjects;
}
/**
* @return array{0: ?string, 1: ?string}
*/
private function splitGapSubjectKey(string $value): array
{
$parts = explode('|', $value, 2);
if (count($parts) !== 2) {
return [null, null];
}
[$policyType, $subjectKey] = $parts;
$policyType = trim($policyType);
$subjectKey = trim($subjectKey);
if ($policyType === '' || $subjectKey === '') {
return [null, null];
}
return [$policyType, $subjectKey];
}
private function subjectResolver(): SubjectResolver
{
return app(SubjectResolver::class);
}
/**
* @param array<string, array{subject_external_id: string, policy_type: string, meta_jsonb: array<string, mixed>}> $currentItems
* @param array<string, ResolvedEvidence|null> $resolvedCurrentEvidence

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -2,6 +2,7 @@
namespace App\Jobs;
use App\Jobs\Concerns\BridgesFailedOperationRun;
use App\Jobs\Middleware\EnsureQueuedExecutionLegitimate;
use App\Jobs\Middleware\TrackOperationRun;
use App\Models\OperationRun;
@ -24,7 +25,15 @@
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;

View File

@ -2,6 +2,7 @@
namespace App\Jobs;
use App\Jobs\Concerns\BridgesFailedOperationRun;
use App\Jobs\Middleware\EnsureQueuedExecutionLegitimate;
use App\Jobs\Middleware\TrackOperationRun;
use App\Models\OperationRun;
@ -21,7 +22,15 @@
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;

View File

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

View File

@ -0,0 +1,254 @@
<?php
declare(strict_types=1);
namespace App\Livewire;
use App\Support\Badges\TagBadgeDomain;
use App\Support\Badges\TagBadgeRenderer;
use App\Support\Baselines\BaselineCompareEvidenceGapDetails;
use App\Support\Filament\TablePaginationProfiles;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Filters\SelectFilter;
use Filament\Tables\Table;
use Filament\Tables\TableComponent;
use Illuminate\Contracts\View\View;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Pagination\LengthAwarePaginator;
use Illuminate\Support\Collection;
use Illuminate\Support\Str;
class BaselineCompareEvidenceGapTable extends TableComponent
{
/**
* @var list<array<string, mixed>>
*/
public array $gapRows = [];
public string $context = 'default';
/**
* @param list<array<string, mixed>> $buckets
*/
public function mount(array $buckets = [], string $context = 'default'): void
{
$this->gapRows = BaselineCompareEvidenceGapDetails::tableRows($buckets);
$this->context = $context;
}
public function table(Table $table): Table
{
return $table
->queryStringIdentifier('baselineCompareEvidenceGap'.Str::studly($this->context))
->defaultSort('reason_label')
->defaultPaginationPageOption(10)
->paginated(TablePaginationProfiles::picker())
->searchable()
->searchPlaceholder(__('baseline-compare.evidence_gap_search_placeholder'))
->records(function (
?string $sortColumn,
?string $sortDirection,
?string $search,
array $filters,
int $page,
int $recordsPerPage
): LengthAwarePaginator {
$rows = $this->filterRows(
rows: collect($this->gapRows),
search: $search,
filters: $filters,
);
$rows = $this->sortRows(
rows: $rows,
sortColumn: $sortColumn,
sortDirection: $sortDirection,
);
return $this->paginateRows(
rows: $rows,
page: $page,
recordsPerPage: $recordsPerPage,
);
})
->filters([
SelectFilter::make('reason_code')
->label(__('baseline-compare.evidence_gap_reason'))
->options(BaselineCompareEvidenceGapDetails::reasonFilterOptions($this->gapRows)),
SelectFilter::make('policy_type')
->label(__('baseline-compare.evidence_gap_policy_type'))
->options(BaselineCompareEvidenceGapDetails::policyTypeFilterOptions($this->gapRows)),
SelectFilter::make('subject_class')
->label(__('baseline-compare.evidence_gap_subject_class'))
->options(BaselineCompareEvidenceGapDetails::subjectClassFilterOptions($this->gapRows)),
SelectFilter::make('operator_action_category')
->label(__('baseline-compare.evidence_gap_next_action'))
->options(BaselineCompareEvidenceGapDetails::actionCategoryFilterOptions($this->gapRows)),
])
->striped()
->deferLoading(! app()->runningUnitTests())
->columns([
TextColumn::make('reason_label')
->label(__('baseline-compare.evidence_gap_reason'))
->searchable()
->sortable()
->wrap()
->toggleable(isToggledHiddenByDefault: true),
TextColumn::make('policy_type')
->label(__('baseline-compare.evidence_gap_policy_type'))
->badge()
->formatStateUsing(TagBadgeRenderer::label(TagBadgeDomain::PolicyType))
->color(TagBadgeRenderer::color(TagBadgeDomain::PolicyType))
->icon(TagBadgeRenderer::icon(TagBadgeDomain::PolicyType))
->iconColor(TagBadgeRenderer::iconColor(TagBadgeDomain::PolicyType))
->searchable()
->sortable()
->wrap(),
TextColumn::make('subject_class_label')
->label(__('baseline-compare.evidence_gap_subject_class'))
->badge()
->searchable()
->sortable()
->wrap(),
TextColumn::make('resolution_outcome_label')
->label(__('baseline-compare.evidence_gap_outcome'))
->searchable()
->sortable()
->wrap(),
TextColumn::make('operator_action_category_label')
->label(__('baseline-compare.evidence_gap_next_action'))
->searchable()
->sortable()
->wrap(),
TextColumn::make('subject_key')
->label(__('baseline-compare.evidence_gap_subject_key'))
->searchable()
->sortable()
->wrap(),
])
->actions([])
->bulkActions([])
->emptyStateHeading(__('baseline-compare.evidence_gap_table_empty_heading'))
->emptyStateDescription(__('baseline-compare.evidence_gap_table_empty_description'));
}
public function render(): View
{
return view('livewire.baseline-compare-evidence-gap-table');
}
/**
* @param Collection<int, array<string, mixed>> $rows
* @param array<string, mixed> $filters
* @return Collection<int, array<string, mixed>>
*/
private function filterRows(Collection $rows, ?string $search, array $filters): Collection
{
$normalizedSearch = Str::lower(trim((string) $search));
$reasonCode = $filters['reason_code']['value'] ?? null;
$policyType = $filters['policy_type']['value'] ?? null;
$subjectClass = $filters['subject_class']['value'] ?? null;
$operatorActionCategory = $filters['operator_action_category']['value'] ?? null;
return $rows
->when(
$normalizedSearch !== '',
function (Collection $rows) use ($normalizedSearch): Collection {
return $rows->filter(function (array $row) use ($normalizedSearch): bool {
return str_contains(Str::lower((string) ($row['search_text'] ?? '')), $normalizedSearch);
});
}
)
->when(
filled($reasonCode),
fn (Collection $rows): Collection => $rows->where('reason_code', (string) $reasonCode)
)
->when(
filled($policyType),
fn (Collection $rows): Collection => $rows->where('policy_type', (string) $policyType)
)
->when(
filled($subjectClass),
fn (Collection $rows): Collection => $rows->where('subject_class', (string) $subjectClass)
)
->when(
filled($operatorActionCategory),
fn (Collection $rows): Collection => $rows->where('operator_action_category', (string) $operatorActionCategory)
)
->values();
}
/**
* @param Collection<int, array<string, mixed>> $rows
* @return Collection<int, array<string, mixed>>
*/
private function sortRows(Collection $rows, ?string $sortColumn, ?string $sortDirection): Collection
{
if (! filled($sortColumn)) {
return $rows;
}
$direction = Str::lower((string) ($sortDirection ?? 'asc')) === 'desc' ? 'desc' : 'asc';
return $rows->sortBy(
fn (array $row): string => (string) ($row[$sortColumn] ?? ''),
SORT_NATURAL | SORT_FLAG_CASE,
$direction === 'desc'
)->values();
}
/**
* @param Collection<int, array<string, mixed>> $rows
*/
private function paginateRows(Collection $rows, int $page, int $recordsPerPage): LengthAwarePaginator
{
$perPage = max(1, $recordsPerPage);
$currentPage = max(1, $page);
$total = $rows->count();
$items = $rows->forPage($currentPage, $perPage)
->values()
->map(fn (array $row, int $index): Model => $this->toTableRecord(
row: $row,
index: (($currentPage - 1) * $perPage) + $index,
));
return new LengthAwarePaginator(
$items,
$total,
$perPage,
$currentPage,
);
}
/**
* @param array<string, mixed> $row
*/
private function toTableRecord(array $row, int $index): Model
{
$record = new class extends Model
{
public $timestamps = false;
public $incrementing = false;
protected $keyType = 'string';
protected $guarded = [];
protected $table = 'baseline_compare_evidence_gap_rows';
};
$record->forceFill([
'id' => implode(':', array_filter([
(string) ($row['reason_code'] ?? 'reason'),
(string) ($row['policy_type'] ?? 'policy'),
(string) ($row['subject_key'] ?? 'subject'),
(string) $index,
])),
...$row,
]);
$record->exists = true;
return $record;
}
}

View File

@ -7,6 +7,7 @@
use App\Support\Baselines\BaselineCaptureMode;
use App\Support\Baselines\BaselineProfileStatus;
use App\Support\Baselines\BaselineScope;
use App\Support\Baselines\BaselineSnapshotLifecycleState;
use Illuminate\Database\Eloquent\Casts\Attribute;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
@ -121,6 +122,37 @@ public function snapshots(): HasMany
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
{
return $this->hasMany(BaselineTenantAssignment::class);

View File

@ -1,11 +1,17 @@
<?php
declare(strict_types=1);
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\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
use RuntimeException;
class BaselineSnapshot extends Model
{
@ -13,10 +19,20 @@ class BaselineSnapshot extends Model
protected $guarded = [];
protected $casts = [
'summary_jsonb' => 'array',
'captured_at' => 'datetime',
];
/**
* @return array<string, string>
*/
protected function casts(): array
{
return [
'lifecycle_state' => BaselineSnapshotLifecycleState::class,
'summary_jsonb' => 'array',
'completion_meta_jsonb' => 'array',
'captured_at' => 'datetime',
'completed_at' => 'datetime',
'failed_at' => 'datetime',
];
}
public function workspace(): BelongsTo
{
@ -32,4 +48,100 @@ public function items(): HasMany
{
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

@ -3,6 +3,7 @@
namespace App\Models;
use App\Support\OperationCatalog;
use App\Support\Operations\OperationRunFreshnessState;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
@ -134,6 +135,11 @@ 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);
@ -159,4 +165,109 @@ public function relatedArtifactId(): ?int
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);
}
/**
* @return array<string, mixed>
*/
public function baselineGapEnvelope(): array
{
$context = is_array($this->context) ? $this->context : [];
return match ((string) $this->type) {
'baseline_compare' => is_array(data_get($context, 'baseline_compare.evidence_gaps'))
? data_get($context, 'baseline_compare.evidence_gaps')
: [],
'baseline_capture' => is_array(data_get($context, 'baseline_capture.gaps'))
? data_get($context, 'baseline_capture.gaps')
: [],
default => [],
};
}
public function hasStructuredBaselineGapPayload(): bool
{
$subjects = $this->baselineGapEnvelope()['subjects'] ?? null;
if (! is_array($subjects) || ! array_is_list($subjects) || $subjects === []) {
return false;
}
foreach ($subjects as $subject) {
if (! is_array($subject)) {
return false;
}
foreach ([
'policy_type',
'subject_key',
'subject_class',
'resolution_path',
'resolution_outcome',
'reason_code',
'operator_action_category',
'structural',
'retryable',
] as $key) {
if (! array_key_exists($key, $subject)) {
return false;
}
}
}
return true;
}
public function hasLegacyBaselineGapPayload(): bool
{
$envelope = $this->baselineGapEnvelope();
$byReason = is_array($envelope['by_reason'] ?? null) ? $envelope['by_reason'] : [];
if (array_key_exists('policy_not_found', $byReason)) {
return true;
}
$subjects = $envelope['subjects'] ?? null;
if (! is_array($subjects)) {
return false;
}
if (! array_is_list($subjects)) {
return $subjects !== [];
}
if ($subjects === []) {
return false;
}
return ! $this->hasStructuredBaselineGapPayload();
}
}

View File

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

View File

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

View File

@ -6,6 +6,7 @@
use App\Filament\System\Pages\Dashboard;
use App\Http\Middleware\UseSystemSessionCookie;
use App\Support\Auth\PlatformCapabilities;
use App\Support\Filament\PanelThemeAsset;
use Filament\Http\Middleware\Authenticate;
use Filament\Http\Middleware\AuthenticateSession;
use Filament\Http\Middleware\DisableBladeIconComponents;
@ -60,6 +61,6 @@ public function panel(Panel $panel): Panel
Authenticate::class,
'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\Resources\TenantReviewResource;
use App\Models\Tenant;
use App\Support\Filament\PanelThemeAsset;
use App\Support\Middleware\DenyNonMemberTenantAccess;
use Filament\Facades\Filament;
use Filament\Http\Middleware\Authenticate;
@ -112,7 +113,7 @@ public function panel(Panel $panel): Panel
]);
if (! app()->runningUnitTests()) {
$panel->viteTheme('resources/css/filament/admin/theme.css');
$panel->theme(PanelThemeAsset::resolve('resources/css/filament/admin/theme.css'));
}
return $panel;

View File

@ -8,6 +8,7 @@
use App\Models\RestoreRun;
use App\Support\OperationRunOutcome;
use App\Support\OperationRunStatus;
use App\Support\Operations\LifecycleReconciliationReason;
use App\Support\RestoreRunStatus;
use Carbon\CarbonImmutable;
use Illuminate\Database\Eloquent\Builder;
@ -151,25 +152,23 @@ private function reconcileOne(OperationRun $run, bool $dryRun): ?array
/** @var OperationRunService $runs */
$runs = app(OperationRunService::class);
$runs->updateRun(
$run,
$runs->updateRunWithReconciliation(
run: $run,
status: $opStatus,
outcome: $opOutcome,
summaryCounts: $summaryCounts,
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();
$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) {
$run->started_at = $restoreRun->started_at;
}

View File

@ -15,6 +15,7 @@
use App\Support\Baselines\BaselineProfileStatus;
use App\Support\Baselines\BaselineReasonCodes;
use App\Support\Baselines\BaselineScope;
use App\Support\Baselines\BaselineSupportCapabilityGuard;
use App\Support\OperationRunType;
final class BaselineCaptureService
@ -22,6 +23,7 @@ final class BaselineCaptureService
public function __construct(
private readonly OperationRunService $runs,
private readonly BaselineFullContentRolloutGate $rolloutGate,
private readonly BaselineSupportCapabilityGuard $capabilityGuard,
) {}
/**
@ -53,7 +55,7 @@ public function startCapture(
],
'baseline_profile_id' => (int) $profile->getKey(),
'source_tenant_id' => (int) $sourceTenant->getKey(),
'effective_scope' => $effectiveScope->toEffectiveScopeContext(),
'effective_scope' => $effectiveScope->toEffectiveScopeContext($this->capabilityGuard, 'capture'),
'capture_mode' => $captureMode->value,
];

View File

@ -17,17 +17,21 @@
use App\Support\Baselines\BaselineProfileStatus;
use App\Support\Baselines\BaselineReasonCodes;
use App\Support\Baselines\BaselineScope;
use App\Support\Baselines\BaselineSupportCapabilityGuard;
use App\Support\OperationRunType;
use App\Support\ReasonTranslation\ReasonPresenter;
final class BaselineCompareService
{
public function __construct(
private readonly OperationRunService $runs,
private readonly BaselineFullContentRolloutGate $rolloutGate,
private readonly BaselineSnapshotTruthResolver $snapshotTruthResolver,
private readonly BaselineSupportCapabilityGuard $capabilityGuard,
) {}
/**
* @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(
Tenant $tenant,
@ -40,38 +44,45 @@ public function startCompare(
->first();
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);
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, hasExplicitSnapshotSelection: $hasExplicitSnapshotSelection);
$precondition = $this->validatePreconditions($profile);
if ($precondition !== null) {
return ['ok' => false, 'reason_code' => $precondition];
return $this->failedStart($precondition);
}
$snapshotId = $baselineSnapshotId !== null ? (int) $baselineSnapshotId : 0;
$selectedSnapshot = null;
if ($snapshotId > 0) {
$snapshot = BaselineSnapshot::query()
if (is_int($baselineSnapshotId) && $baselineSnapshotId > 0) {
$selectedSnapshot = BaselineSnapshot::query()
->where('workspace_id', (int) $profile->workspace_id)
->where('baseline_profile_id', (int) $profile->getKey())
->whereKey($snapshotId)
->first(['id']);
->whereKey((int) $baselineSnapshotId)
->first();
if (! $snapshot instanceof BaselineSnapshot) {
return ['ok' => false, 'reason_code' => BaselineReasonCodes::COMPARE_INVALID_SNAPSHOT];
if (! $selectedSnapshot instanceof BaselineSnapshot) {
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(
is_array($profile->scope_jsonb) ? $profile->scope_jsonb : null,
);
@ -92,7 +103,7 @@ public function startCompare(
],
'baseline_profile_id' => (int) $profile->getKey(),
'baseline_snapshot_id' => $snapshotId,
'effective_scope' => $effectiveScope->toEffectiveScopeContext(),
'effective_scope' => $effectiveScope->toEffectiveScopeContext($this->capabilityGuard, 'compare'),
'capture_mode' => $captureMode->value,
];
@ -113,7 +124,7 @@ public function startCompare(
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) {
return BaselineReasonCodes::COMPARE_PROFILE_NOT_ACTIVE;
@ -123,10 +134,20 @@ private function validatePreconditions(BaselineProfile $profile, bool $hasExplic
return BaselineReasonCodes::COMPARE_ROLLOUT_DISABLED;
}
if (! $hasExplicitSnapshotSelection && $profile->active_snapshot_id === null) {
return BaselineReasonCodes::COMPARE_NO_ACTIVE_SNAPSHOT;
}
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

@ -10,22 +10,28 @@
use App\Services\Intune\PolicyCaptureOrchestrator;
use App\Support\Baselines\BaselineEvidenceResumeToken;
use App\Support\Baselines\PolicyVersionCapturePurpose;
use App\Support\Baselines\ResolutionOutcomeRecord;
use App\Support\Baselines\ResolutionPath;
use App\Support\Baselines\SubjectDescriptor;
use App\Support\Baselines\SubjectResolver;
use Throwable;
final class BaselineContentCapturePhase
{
public function __construct(
private readonly PolicyCaptureOrchestrator $captureOrchestrator,
private readonly ?SubjectResolver $subjectResolver = null,
) {}
/**
* Capture baseline-purpose policy versions (content + assignments + scope tags) within a run budget.
*
* @param list<array{policy_type: string, subject_external_id: string}> $subjects
* @param list<array{policy_type: string, subject_external_id: string, subject_key?: string}> $subjects
* @param array{max_items_per_run: int, max_concurrency: int, max_retries: int} $budgets
* @return array{
* stats: array{requested: int, succeeded: int, skipped: int, failed: int, throttled: int},
* gaps: array<string, int>,
* gap_subjects: list<array<string, mixed>>,
* resume_token: ?string,
* captured_versions: array<string, array{
* policy_type: string,
@ -76,6 +82,8 @@ public function capture(
/** @var array<string, int> $gaps */
$gaps = [];
/** @var list<array<string, mixed>> $gapSubjects */
$gapSubjects = [];
$capturedVersions = [];
/**
@ -87,24 +95,40 @@ public function capture(
foreach ($chunk as $subject) {
$policyType = trim((string) ($subject['policy_type'] ?? ''));
$externalId = trim((string) ($subject['subject_external_id'] ?? ''));
$subjectKey = trim((string) ($subject['subject_key'] ?? ''));
$descriptor = $this->resolver()->describeForCapture(
$policyType !== '' ? $policyType : 'unknown',
$externalId !== '' ? $externalId : null,
$subjectKey !== '' ? $subjectKey : null,
);
if ($policyType === '' || $externalId === '') {
$gaps['invalid_subject'] = ($gaps['invalid_subject'] ?? 0) + 1;
$this->recordGap($gaps, $gapSubjects, $descriptor, $this->resolver()->invalidSubject($descriptor));
$stats['failed']++;
continue;
}
$subjectKey = $policyType.'|'.$externalId;
$captureKey = $policyType.'|'.$externalId;
if (isset($seen[$subjectKey])) {
$gaps['duplicate_subject'] = ($gaps['duplicate_subject'] ?? 0) + 1;
if (isset($seen[$captureKey])) {
$this->recordGap($gaps, $gapSubjects, $descriptor, $this->resolver()->duplicateSubject($descriptor));
$stats['skipped']++;
continue;
}
$seen[$subjectKey] = true;
$seen[$captureKey] = true;
if (
$descriptor->resolutionPath === ResolutionPath::FoundationInventory
|| $descriptor->resolutionPath === ResolutionPath::Inventory
) {
$this->recordGap($gaps, $gapSubjects, $descriptor, $this->resolver()->structuralInventoryOnly($descriptor));
$stats['skipped']++;
continue;
}
$policy = Policy::query()
->where('tenant_id', (int) $tenant->getKey())
@ -113,7 +137,7 @@ public function capture(
->first();
if (! $policy instanceof Policy) {
$gaps['policy_not_found'] = ($gaps['policy_not_found'] ?? 0) + 1;
$this->recordGap($gaps, $gapSubjects, $descriptor, $this->resolver()->missingExpectedRecord($descriptor));
$stats['failed']++;
continue;
@ -152,7 +176,7 @@ public function capture(
$version = $result['version'] ?? null;
if ($version instanceof PolicyVersion) {
$capturedVersions[$subjectKey] = [
$capturedVersions[$captureKey] = [
'policy_type' => $policyType,
'subject_external_id' => $externalId,
'version' => $version,
@ -178,10 +202,10 @@ public function capture(
}
if ($isThrottled) {
$gaps['throttled'] = ($gaps['throttled'] ?? 0) + 1;
$this->recordGap($gaps, $gapSubjects, $descriptor, $this->resolver()->throttled($descriptor));
$stats['throttled']++;
} else {
$gaps['capture_failed'] = ($gaps['capture_failed'] ?? 0) + 1;
$this->recordGap($gaps, $gapSubjects, $descriptor, $this->resolver()->captureFailed($descriptor));
$stats['failed']++;
}
@ -201,7 +225,22 @@ public function capture(
$remainingCount = max(0, count($subjects) - $processed);
if ($remainingCount > 0) {
$gaps['budget_exhausted'] = ($gaps['budget_exhausted'] ?? 0) + $remainingCount;
foreach (array_slice($subjects, $processed) as $remainingSubject) {
$remainingPolicyType = trim((string) ($remainingSubject['policy_type'] ?? ''));
$remainingExternalId = trim((string) ($remainingSubject['subject_external_id'] ?? ''));
$remainingSubjectKey = trim((string) ($remainingSubject['subject_key'] ?? ''));
if ($remainingPolicyType === '' || $remainingExternalId === '') {
continue;
}
$remainingDescriptor = $this->resolver()->describeForCapture(
$remainingPolicyType,
$remainingExternalId,
$remainingSubjectKey !== '' ? $remainingSubjectKey : null,
);
$this->recordGap($gaps, $gapSubjects, $remainingDescriptor, $this->resolver()->budgetExhausted($remainingDescriptor));
}
}
}
@ -210,11 +249,27 @@ public function capture(
return [
'stats' => $stats,
'gaps' => $gaps,
'gap_subjects' => $gapSubjects,
'resume_token' => $resumeTokenOut,
'captured_versions' => $capturedVersions,
];
}
/**
* @param array<string, int> $gaps
* @param list<array<string, mixed>> $gapSubjects
*/
private function recordGap(array &$gaps, array &$gapSubjects, SubjectDescriptor $descriptor, ResolutionOutcomeRecord $outcome): void
{
$gaps[$outcome->reasonCode] = ($gaps[$outcome->reasonCode] ?? 0) + 1;
$gapSubjects[] = array_merge($descriptor->toArray(), $outcome->toArray());
}
private function resolver(): SubjectResolver
{
return $this->subjectResolver ?? app(SubjectResolver::class);
}
private function retryDelayMs(int $attempt): int
{
$attempt = max(0, $attempt);

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

@ -14,6 +14,7 @@
use App\Support\Ui\EnterpriseDetail\EnterpriseDetailPageData;
use App\Support\Ui\EnterpriseDetail\EnterpriseDetailSectionFactory;
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\Support\Carbon;
@ -99,13 +100,21 @@ public function presentEnterpriseDetail(BaselineSnapshot $snapshot, array $relat
{
$rendered = $this->present($snapshot);
$factory = new EnterpriseDetailSectionFactory;
$truth = app(ArtifactTruthPresenter::class)->forBaselineSnapshot($snapshot);
$stateSpec = $this->gapStatusSpec($rendered->overallGapCount);
$stateBadge = $factory->statusBadge(
$stateSpec->label,
$stateSpec->color,
$stateSpec->icon,
$stateSpec->iconColor,
$truthBadge = $factory->statusBadge(
$truth->primaryBadgeSpec()->label,
$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);
@ -120,20 +129,27 @@ public function presentEnterpriseDetail(BaselineSnapshot $snapshot, array $relat
static fn (array $row): int => (int) ($row['itemCount'] ?? 0),
$rendered->summaryRows,
));
$truth = app(ArtifactTruthPresenter::class)->forBaselineSnapshot($snapshot);
$currentTruth = $this->currentTruthPresentation($truth);
$currentTruthBadge = $factory->statusBadge(
$currentTruth['label'],
$currentTruth['color'],
$currentTruth['icon'],
$currentTruth['iconColor'],
);
$operatorExplanation = $truth->operatorExplanation;
return EnterpriseDetailBuilder::make('baseline_snapshot', 'workspace')
->header(new SummaryHeaderData(
title: $rendered->baselineProfileName ?? 'Baseline snapshot',
subtitle: 'Snapshot #'.$rendered->snapshotId,
statusBadges: [$stateBadge, $fidelityBadge],
statusBadges: [$truthBadge, $lifecycleBadge, $fidelityBadge],
keyFacts: [
$factory->keyFact('Captured', $this->formatTimestamp($rendered->capturedAt)),
$factory->keyFact('Current truth', $currentTruth['label'], badge: $currentTruthBadge),
$factory->keyFact('Evidence mix', $rendered->fidelitySummary),
$factory->keyFact('Evidence gaps', $rendered->overallGapCount),
$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(
$factory->viewSection(
@ -175,11 +191,30 @@ public function presentEnterpriseDetail(BaselineSnapshot $snapshot, array $relat
->addSupportingCard(
$factory->supportingFactsCard(
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: [
$factory->keyFact('State', $rendered->stateLabel, badge: $stateBadge),
$factory->keyFact('Overall fidelity', $fidelitySpec->label, badge: $fidelityBadge),
$factory->keyFact('Evidence gaps', $rendered->overallGapCount),
$factory->keyFact('Captured items', $capturedItemCount),
],
),
$factory->supportingFactsCard(
@ -187,6 +222,8 @@ public function presentEnterpriseDetail(BaselineSnapshot $snapshot, array $relat
title: 'Capture timing',
items: [
$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),
],
),
@ -338,6 +375,33 @@ private function gapStatusSpec(int $gapCount): \App\Support\Badges\BadgeSpec
);
}
/**
* @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
{
return InventoryPolicyTypeMeta::baselineCompareLabel($policyType)

View File

@ -17,6 +17,7 @@
use App\Support\OperationRunStatus;
use App\Support\Operations\ExecutionAuthorityMode;
use App\Support\Operations\ExecutionDenialReasonCode;
use App\Support\Operations\LifecycleReconciliationReason;
use App\Support\Operations\OperationRunCapabilityResolver;
use App\Support\Operations\QueuedExecutionLegitimacyDecision;
use App\Support\OpsUx\BulkRunContext;
@ -62,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
{
return $this->updateRun(
return $this->forceFailNonTerminalRun(
$run,
status: OperationRunStatus::Completed->value,
outcome: OperationRunOutcome::Failed->value,
failures: [
[
'code' => 'run.stale_queued',
'message' => $message,
],
reasonCode: LifecycleReconciliationReason::StaleQueued->value,
message: $message,
source: 'scheduled_reconciler',
evidence: [
'status' => OperationRunStatus::Queued->value,
'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(),
],
);
}
@ -721,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.
*
@ -1033,16 +1194,49 @@ private function isDirectlyTranslatableReason(string $reasonCode): bool
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
{
$tenant = $run->tenant;
$workspace = $run->workspace;
$context = is_array($run->context) ? $run->context : [];
$executionLegitimacy = is_array($context['execution_legitimacy'] ?? null) ? $context['execution_legitimacy'] : [];
$reconciliation = is_array($context['reconciliation'] ?? null) ? $context['reconciliation'] : [];
$operationLabel = OperationCatalog::label((string) $run->type);
$action = match ($run->outcome) {
@ -1072,6 +1266,7 @@ private function writeTerminalAudit(OperationRun $run): void
'authority_mode' => $executionLegitimacy['authority_mode'] ?? ($context['execution_authority_mode'] ?? null),
'acting_identity_type' => $executionLegitimacy['initiator']['identity_type'] ?? ($run->user instanceof User ? 'user' : 'system'),
'blocked_by' => $context['blocked_by'] ?? null,
'reconciliation' => $reconciliation !== [] ? $reconciliation : null,
],
],
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

@ -20,6 +20,9 @@ final class BadgeCatalog
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::BaselineSnapshotGapStatus->value => Domains\BaselineSnapshotGapStatusBadge::class,
BadgeDomain::OperationRunStatus->value => Domains\OperationRunStatusBadge::class,

View File

@ -11,6 +11,9 @@ enum BadgeDomain: string
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 BaselineSnapshotGapStatus = 'baseline_snapshot_gap_status';
case OperationRunStatus = 'operation_run_status';

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

@ -8,12 +8,46 @@
use App\Support\Badges\BadgeSpec;
use App\Support\Badges\OperatorOutcomeTaxonomy;
use App\Support\OperationRunOutcome;
use App\Support\OperationRunStatus;
use App\Support\Operations\OperationRunFreshnessState;
final class OperationRunOutcomeBadge implements BadgeMapper
{
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) {
OperationRunOutcome::Pending->value => OperatorOutcomeTaxonomy::spec(BadgeDomain::OperationRunOutcome, $state, 'heroicon-m-clock'),

View File

@ -8,12 +8,33 @@
use App\Support\Badges\BadgeSpec;
use App\Support\Badges\OperatorOutcomeTaxonomy;
use App\Support\OperationRunStatus;
use App\Support\Operations\OperationRunFreshnessState;
final class OperationRunStatusBadge implements BadgeMapper
{
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) {
OperationRunStatus::Queued->value => OperatorOutcomeTaxonomy::spec(BadgeDomain::OperationRunStatus, $state, 'heroicon-m-clock'),

View File

@ -0,0 +1,29 @@
<?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'),
'failed_result' => OperatorOutcomeTaxonomy::spec(BadgeDomain::OperatorExplanationEvaluationResult, $state, 'heroicon-m-x-circle'),
'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

@ -71,7 +71,7 @@ final class OperatorOutcomeTaxonomy
],
'partial' => [
'axis' => 'data_coverage',
'label' => 'Partial',
'label' => 'Partially complete',
'color' => 'warning',
'classification' => 'primary',
'next_action_policy' => 'required',
@ -136,7 +136,7 @@ final class OperatorOutcomeTaxonomy
],
'stale' => [
'axis' => 'data_freshness',
'label' => 'Stale',
'label' => 'Refresh recommended',
'color' => 'warning',
'classification' => 'primary',
'next_action_policy' => 'optional',
@ -183,7 +183,7 @@ final class OperatorOutcomeTaxonomy
],
'blocked' => [
'axis' => 'publication_readiness',
'label' => 'Blocked',
'label' => 'Publication blocked',
'color' => 'warning',
'classification' => 'primary',
'next_action_policy' => 'required',
@ -220,6 +220,138 @@ final class OperatorOutcomeTaxonomy
'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.',
],
'failed_result' => [
'axis' => 'execution_outcome',
'label' => 'Failed result',
'color' => 'danger',
'classification' => 'primary',
'next_action_policy' => 'required',
'legacy_aliases' => ['Execution failed'],
'notes' => 'The workflow ended without producing a usable result and needs operator investigation.',
],
'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',

View File

@ -0,0 +1,661 @@
<?php
declare(strict_types=1);
namespace App\Support\Baselines;
use App\Models\OperationRun;
use Illuminate\Support\Str;
final class BaselineCompareEvidenceGapDetails
{
public static function fromOperationRun(?OperationRun $run): array
{
if (! $run instanceof OperationRun || ! is_array($run->context)) {
return self::empty();
}
return self::fromContext($run->context);
}
/**
* @param array<string, mixed> $context
*/
public static function fromContext(array $context): array
{
$baselineCompare = $context['baseline_compare'] ?? null;
if (! is_array($baselineCompare)) {
return self::empty();
}
return self::fromBaselineCompare($baselineCompare);
}
/**
* @param array<string, mixed> $baselineCompare
*/
public static function fromBaselineCompare(array $baselineCompare): array
{
$evidenceGaps = $baselineCompare['evidence_gaps'] ?? null;
$evidenceGaps = is_array($evidenceGaps) ? $evidenceGaps : [];
$byReason = self::normalizeCounts($evidenceGaps['by_reason'] ?? null);
$normalizedSubjects = self::normalizeSubjects($evidenceGaps['subjects'] ?? null);
foreach ($normalizedSubjects['subjects'] as $reasonCode => $subjects) {
if (! array_key_exists($reasonCode, $byReason)) {
$byReason[$reasonCode] = count($subjects);
}
}
$count = self::normalizeTotalCount(
$evidenceGaps['count'] ?? null,
$byReason,
$normalizedSubjects['subjects'],
);
$detailState = self::detailState($count, $normalizedSubjects);
$buckets = [];
foreach (self::orderedReasons($byReason, $normalizedSubjects['subjects']) as $reasonCode) {
$rows = $detailState === 'structured_details_recorded'
? array_map(
static fn (array $subject): array => self::projectSubjectRow($subject),
$normalizedSubjects['subjects'][$reasonCode] ?? [],
)
: [];
$reasonCount = $byReason[$reasonCode] ?? count($rows);
if ($reasonCount <= 0 && $rows === []) {
continue;
}
$recordedCount = count($rows);
$structuralCount = count(array_filter(
$rows,
static fn (array $row): bool => (bool) ($row['structural'] ?? false),
));
$transientCount = count(array_filter(
$rows,
static fn (array $row): bool => (bool) ($row['retryable'] ?? false),
));
$operationalCount = max(0, $recordedCount - $structuralCount - $transientCount);
$searchText = trim(implode(' ', array_filter([
Str::lower($reasonCode),
Str::lower(self::reasonLabel($reasonCode)),
...array_map(
static fn (array $row): string => (string) ($row['search_text'] ?? ''),
$rows,
),
])));
$buckets[] = [
'reason_code' => $reasonCode,
'reason_label' => self::reasonLabel($reasonCode),
'count' => $reasonCount,
'recorded_count' => $recordedCount,
'missing_detail_count' => max(0, $reasonCount - $recordedCount),
'structural_count' => $structuralCount,
'operational_count' => $operationalCount,
'transient_count' => $transientCount,
'detail_state' => self::bucketDetailState($detailState, $recordedCount),
'search_text' => $searchText,
'rows' => $rows,
];
}
$recordedSubjectsTotal = array_sum(array_map(
static fn (array $bucket): int => (int) ($bucket['recorded_count'] ?? 0),
$buckets,
));
$structuralCount = array_sum(array_map(
static fn (array $bucket): int => (int) ($bucket['structural_count'] ?? 0),
$buckets,
));
$operationalCount = array_sum(array_map(
static fn (array $bucket): int => (int) ($bucket['operational_count'] ?? 0),
$buckets,
));
$transientCount = array_sum(array_map(
static fn (array $bucket): int => (int) ($bucket['transient_count'] ?? 0),
$buckets,
));
$legacyMode = $detailState === 'legacy_broad_reason';
return [
'summary' => [
'count' => $count,
'by_reason' => $byReason,
'detail_state' => $detailState,
'recorded_subjects_total' => $recordedSubjectsTotal,
'missing_detail_count' => max(0, $count - $recordedSubjectsTotal),
'structural_count' => $structuralCount,
'operational_count' => $operationalCount,
'transient_count' => $transientCount,
'legacy_mode' => $legacyMode,
'requires_regeneration' => $legacyMode,
],
'buckets' => $buckets,
];
}
/**
* @param array<string, mixed> $baselineCompare
* @return array<string, mixed>
*/
public static function diagnosticsPayload(array $baselineCompare): array
{
return array_filter([
'reason_code' => self::stringOrNull($baselineCompare['reason_code'] ?? null),
'subjects_total' => self::intOrNull($baselineCompare['subjects_total'] ?? null),
'resume_token' => self::stringOrNull($baselineCompare['resume_token'] ?? null),
'fidelity' => self::stringOrNull($baselineCompare['fidelity'] ?? null),
'coverage' => is_array($baselineCompare['coverage'] ?? null) ? $baselineCompare['coverage'] : null,
'evidence_capture' => is_array($baselineCompare['evidence_capture'] ?? null) ? $baselineCompare['evidence_capture'] : null,
'evidence_gaps' => is_array($baselineCompare['evidence_gaps'] ?? null) ? $baselineCompare['evidence_gaps'] : null,
], static fn (mixed $value): bool => $value !== null && $value !== []);
}
public static function reasonLabel(string $reason): string
{
$reason = trim($reason);
return match ($reason) {
'ambiguous_match' => 'Ambiguous inventory match',
'policy_record_missing' => 'Policy record missing',
'inventory_record_missing' => 'Inventory record missing',
'foundation_not_policy_backed' => 'Foundation not policy-backed',
'invalid_subject' => 'Invalid subject',
'duplicate_subject' => 'Duplicate subject',
'capture_failed' => 'Evidence capture failed',
'retryable_capture_failure' => 'Retryable evidence capture failure',
'budget_exhausted' => 'Capture budget exhausted',
'throttled' => 'Graph throttled',
'invalid_support_config' => 'Invalid support configuration',
'missing_current' => 'Missing current evidence',
'missing_role_definition_baseline_version_reference' => 'Missing baseline role definition evidence',
'missing_role_definition_current_version_reference' => 'Missing current role definition evidence',
'missing_role_definition_compare_surface' => 'Missing role definition compare surface',
'rollout_disabled' => 'Rollout disabled',
'policy_not_found' => 'Legacy policy not found',
default => Str::of($reason)->replace('_', ' ')->trim()->ucfirst()->toString(),
};
}
public static function subjectClassLabel(string $subjectClass): string
{
return match (trim($subjectClass)) {
SubjectClass::PolicyBacked->value => 'Policy-backed',
SubjectClass::InventoryBacked->value => 'Inventory-backed',
SubjectClass::FoundationBacked->value => 'Foundation-backed',
default => 'Derived',
};
}
public static function resolutionOutcomeLabel(string $resolutionOutcome): string
{
return match (trim($resolutionOutcome)) {
ResolutionOutcome::ResolvedPolicy->value => 'Resolved policy',
ResolutionOutcome::ResolvedInventory->value => 'Resolved inventory',
ResolutionOutcome::PolicyRecordMissing->value => 'Policy record missing',
ResolutionOutcome::InventoryRecordMissing->value => 'Inventory record missing',
ResolutionOutcome::FoundationInventoryOnly->value => 'Foundation inventory only',
ResolutionOutcome::InvalidSubject->value => 'Invalid subject',
ResolutionOutcome::DuplicateSubject->value => 'Duplicate subject',
ResolutionOutcome::AmbiguousMatch->value => 'Ambiguous match',
ResolutionOutcome::InvalidSupportConfig->value => 'Invalid support configuration',
ResolutionOutcome::Throttled->value => 'Graph throttled',
ResolutionOutcome::CaptureFailed->value => 'Capture failed',
ResolutionOutcome::RetryableCaptureFailure->value => 'Retryable capture failure',
ResolutionOutcome::BudgetExhausted->value => 'Budget exhausted',
};
}
public static function operatorActionCategoryLabel(string $operatorActionCategory): string
{
return match (trim($operatorActionCategory)) {
OperatorActionCategory::Retry->value => 'Retry',
OperatorActionCategory::RunInventorySync->value => 'Run inventory sync',
OperatorActionCategory::RunPolicySyncOrBackup->value => 'Run policy sync or backup',
OperatorActionCategory::ReviewPermissions->value => 'Review permissions',
OperatorActionCategory::InspectSubjectMapping->value => 'Inspect subject mapping',
OperatorActionCategory::ProductFollowUp->value => 'Product follow-up',
default => 'No action',
};
}
/**
* @param array<string, int> $byReason
* @return list<array{reason_code: string, reason_label: string, count: int}>
*/
public static function topReasons(array $byReason, int $limit = 5): array
{
$normalized = self::normalizeCounts($byReason);
arsort($normalized);
return array_map(
static fn (string $reason, int $count): array => [
'reason_code' => $reason,
'reason_label' => self::reasonLabel($reason),
'count' => $count,
],
array_slice(array_keys($normalized), 0, $limit),
array_slice(array_values($normalized), 0, $limit),
);
}
/**
* @param list<array<string, mixed>> $buckets
* @return list<array<string, mixed>>
*/
public static function tableRows(array $buckets): array
{
$rows = [];
foreach ($buckets as $bucket) {
if (! is_array($bucket)) {
continue;
}
$bucketRows = is_array($bucket['rows'] ?? null) ? $bucket['rows'] : [];
foreach ($bucketRows as $row) {
if (! is_array($row)) {
continue;
}
$reasonCode = self::stringOrNull($row['reason_code'] ?? null);
$policyType = self::stringOrNull($row['policy_type'] ?? null);
$subjectKey = self::stringOrNull($row['subject_key'] ?? null);
$subjectClass = self::stringOrNull($row['subject_class'] ?? null);
$resolutionOutcome = self::stringOrNull($row['resolution_outcome'] ?? null);
$operatorActionCategory = self::stringOrNull($row['operator_action_category'] ?? null);
if ($reasonCode === null || $policyType === null || $subjectKey === null || $subjectClass === null || $resolutionOutcome === null || $operatorActionCategory === null) {
continue;
}
$rows[] = [
'__id' => md5(implode('|', [$reasonCode, $policyType, $subjectKey, $resolutionOutcome])),
'reason_code' => $reasonCode,
'reason_label' => self::reasonLabel($reasonCode),
'policy_type' => $policyType,
'subject_key' => $subjectKey,
'subject_class' => $subjectClass,
'subject_class_label' => self::subjectClassLabel($subjectClass),
'resolution_path' => self::stringOrNull($row['resolution_path'] ?? null),
'resolution_outcome' => $resolutionOutcome,
'resolution_outcome_label' => self::resolutionOutcomeLabel($resolutionOutcome),
'operator_action_category' => $operatorActionCategory,
'operator_action_category_label' => self::operatorActionCategoryLabel($operatorActionCategory),
'structural' => (bool) ($row['structural'] ?? false),
'retryable' => (bool) ($row['retryable'] ?? false),
'search_text' => self::stringOrNull($row['search_text'] ?? null) ?? '',
];
}
}
return $rows;
}
/**
* @param list<array<string, mixed>> $rows
* @return array<string, string>
*/
public static function reasonFilterOptions(array $rows): array
{
return collect($rows)
->filter(fn (array $row): bool => filled($row['reason_code'] ?? null) && filled($row['reason_label'] ?? null))
->mapWithKeys(fn (array $row): array => [
(string) $row['reason_code'] => (string) $row['reason_label'],
])
->sortBy(fn (string $label): string => Str::lower($label))
->all();
}
/**
* @param list<array<string, mixed>> $rows
* @return array<string, string>
*/
public static function policyTypeFilterOptions(array $rows): array
{
return collect($rows)
->pluck('policy_type')
->filter(fn (mixed $value): bool => is_string($value) && trim($value) !== '')
->mapWithKeys(fn (string $value): array => [$value => $value])
->sortKeysUsing('strnatcasecmp')
->all();
}
/**
* @param list<array<string, mixed>> $rows
* @return array<string, string>
*/
public static function subjectClassFilterOptions(array $rows): array
{
return collect($rows)
->filter(fn (array $row): bool => filled($row['subject_class'] ?? null))
->mapWithKeys(fn (array $row): array => [
(string) $row['subject_class'] => (string) ($row['subject_class_label'] ?? self::subjectClassLabel((string) $row['subject_class'])),
])
->sortBy(fn (string $label): string => Str::lower($label))
->all();
}
/**
* @param list<array<string, mixed>> $rows
* @return array<string, string>
*/
public static function actionCategoryFilterOptions(array $rows): array
{
return collect($rows)
->filter(fn (array $row): bool => filled($row['operator_action_category'] ?? null))
->mapWithKeys(fn (array $row): array => [
(string) $row['operator_action_category'] => (string) ($row['operator_action_category_label'] ?? self::operatorActionCategoryLabel((string) $row['operator_action_category'])),
])
->sortBy(fn (string $label): string => Str::lower($label))
->all();
}
private static function empty(): array
{
return [
'summary' => [
'count' => 0,
'by_reason' => [],
'detail_state' => 'no_gaps',
'recorded_subjects_total' => 0,
'missing_detail_count' => 0,
'structural_count' => 0,
'operational_count' => 0,
'transient_count' => 0,
'legacy_mode' => false,
'requires_regeneration' => false,
],
'buckets' => [],
];
}
/**
* @return array<string, int>
*/
private static function normalizeCounts(mixed $value): array
{
if (! is_array($value)) {
return [];
}
$normalized = [];
foreach ($value as $reason => $count) {
if (! is_string($reason) || trim($reason) === '' || ! is_numeric($count)) {
continue;
}
$intCount = (int) $count;
if ($intCount <= 0) {
continue;
}
$normalized[trim($reason)] = $intCount;
}
arsort($normalized);
return $normalized;
}
/**
* @return array{
* subjects: array<string, list<array<string, mixed>>>,
* legacy_mode: bool
* }
*/
private static function normalizeSubjects(mixed $value): array
{
if ($value === null) {
return [
'subjects' => [],
'legacy_mode' => false,
];
}
if (! is_array($value)) {
return [
'subjects' => [],
'legacy_mode' => true,
];
}
if (! array_is_list($value)) {
return [
'subjects' => [],
'legacy_mode' => true,
];
}
$subjects = [];
foreach ($value as $item) {
$normalized = self::normalizeStructuredSubject($item);
if ($normalized === null) {
return [
'subjects' => [],
'legacy_mode' => true,
];
}
$subjects[$normalized['reason_code']][] = $normalized;
}
foreach ($subjects as &$bucket) {
usort($bucket, static function (array $left, array $right): int {
return [$left['policy_type'], $left['subject_key'], $left['resolution_outcome']]
<=> [$right['policy_type'], $right['subject_key'], $right['resolution_outcome']];
});
}
unset($bucket);
ksort($subjects);
return [
'subjects' => $subjects,
'legacy_mode' => false,
];
}
/**
* @return array<string, mixed>|null
*/
private static function normalizeStructuredSubject(mixed $value): ?array
{
if (! is_array($value)) {
return null;
}
$policyType = self::stringOrNull($value['policy_type'] ?? null);
$subjectKey = self::stringOrNull($value['subject_key'] ?? null);
$subjectClass = self::stringOrNull($value['subject_class'] ?? null);
$resolutionPath = self::stringOrNull($value['resolution_path'] ?? null);
$resolutionOutcome = self::stringOrNull($value['resolution_outcome'] ?? null);
$reasonCode = self::stringOrNull($value['reason_code'] ?? null);
$operatorActionCategory = self::stringOrNull($value['operator_action_category'] ?? null);
if ($policyType === null
|| $subjectKey === null
|| $subjectClass === null
|| $resolutionPath === null
|| $resolutionOutcome === null
|| $reasonCode === null
|| $operatorActionCategory === null) {
return null;
}
if (! SubjectClass::tryFrom($subjectClass) instanceof SubjectClass
|| ! ResolutionPath::tryFrom($resolutionPath) instanceof ResolutionPath
|| ! ResolutionOutcome::tryFrom($resolutionOutcome) instanceof ResolutionOutcome
|| ! OperatorActionCategory::tryFrom($operatorActionCategory) instanceof OperatorActionCategory) {
return null;
}
$sourceModelExpected = self::stringOrNull($value['source_model_expected'] ?? null);
$sourceModelExpected = in_array($sourceModelExpected, ['policy', 'inventory', 'derived'], true) ? $sourceModelExpected : null;
$sourceModelFound = self::stringOrNull($value['source_model_found'] ?? null);
$sourceModelFound = in_array($sourceModelFound, ['policy', 'inventory', 'derived'], true) ? $sourceModelFound : null;
return [
'policy_type' => $policyType,
'subject_external_id' => self::stringOrNull($value['subject_external_id'] ?? null),
'subject_key' => $subjectKey,
'subject_class' => $subjectClass,
'resolution_path' => $resolutionPath,
'resolution_outcome' => $resolutionOutcome,
'reason_code' => $reasonCode,
'operator_action_category' => $operatorActionCategory,
'structural' => self::boolOrFalse($value['structural'] ?? null),
'retryable' => self::boolOrFalse($value['retryable'] ?? null),
'source_model_expected' => $sourceModelExpected,
'source_model_found' => $sourceModelFound,
'legacy_reason_code' => self::stringOrNull($value['legacy_reason_code'] ?? null),
];
}
/**
* @param array<string, int> $byReason
* @param array<string, list<array<string, mixed>>> $subjects
* @return list<string>
*/
private static function orderedReasons(array $byReason, array $subjects): array
{
$reasons = array_keys($byReason);
foreach (array_keys($subjects) as $reason) {
if (! in_array($reason, $reasons, true)) {
$reasons[] = $reason;
}
}
return $reasons;
}
/**
* @param array<string, int> $byReason
* @param array<string, list<array<string, mixed>>> $subjects
*/
private static function normalizeTotalCount(mixed $count, array $byReason, array $subjects): int
{
if (is_numeric($count)) {
$intCount = (int) $count;
if ($intCount >= 0) {
return $intCount;
}
}
$byReasonCount = array_sum($byReason);
if ($byReasonCount > 0) {
return $byReasonCount;
}
return array_sum(array_map(
static fn (array $rows): int => count($rows),
$subjects,
));
}
/**
* @param array{subjects: array<string, list<array<string, mixed>>>, legacy_mode: bool} $subjects
*/
private static function detailState(int $count, array $subjects): string
{
if ($count <= 0) {
return 'no_gaps';
}
if ($subjects['legacy_mode']) {
return 'legacy_broad_reason';
}
return $subjects['subjects'] !== [] ? 'structured_details_recorded' : 'details_not_recorded';
}
private static function bucketDetailState(string $detailState, int $recordedCount): string
{
if ($detailState === 'legacy_broad_reason') {
return 'legacy_broad_reason';
}
if ($recordedCount > 0) {
return 'structured_details_recorded';
}
return 'details_not_recorded';
}
/**
* @param array<string, mixed> $subject
* @return array<string, mixed>
*/
private static function projectSubjectRow(array $subject): array
{
$reasonCode = (string) $subject['reason_code'];
$subjectClass = (string) $subject['subject_class'];
$resolutionOutcome = (string) $subject['resolution_outcome'];
$operatorActionCategory = (string) $subject['operator_action_category'];
return array_merge($subject, [
'reason_label' => self::reasonLabel($reasonCode),
'subject_class_label' => self::subjectClassLabel($subjectClass),
'resolution_outcome_label' => self::resolutionOutcomeLabel($resolutionOutcome),
'operator_action_category_label' => self::operatorActionCategoryLabel($operatorActionCategory),
'search_text' => Str::lower(trim(implode(' ', array_filter([
$reasonCode,
self::reasonLabel($reasonCode),
(string) ($subject['policy_type'] ?? ''),
(string) ($subject['subject_key'] ?? ''),
$subjectClass,
self::subjectClassLabel($subjectClass),
(string) ($subject['resolution_path'] ?? ''),
$resolutionOutcome,
self::resolutionOutcomeLabel($resolutionOutcome),
$operatorActionCategory,
self::operatorActionCategoryLabel($operatorActionCategory),
(string) ($subject['subject_external_id'] ?? ''),
])))),
]);
}
private static function stringOrNull(mixed $value): ?string
{
if (! is_string($value)) {
return null;
}
$trimmed = trim($value);
return $trimmed !== '' ? $trimmed : null;
}
private static function intOrNull(mixed $value): ?int
{
return is_numeric($value) ? (int) $value : null;
}
private static function boolOrFalse(mixed $value): bool
{
if (is_bool($value)) {
return $value;
}
if (is_int($value) || is_float($value) || is_string($value)) {
return filter_var($value, FILTER_VALIDATE_BOOL);
}
return false;
}
}

View File

@ -0,0 +1,247 @@
<?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;
$isFailed = $stats->state === 'failed';
$isInProgress = $stats->state === 'comparing';
$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) {
$isInProgress => ExplanationFamily::InProgress,
$isFailed => 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 = $isFailed
? 'failed_result'
: 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 (true) {
$isFailed => 'The comparison failed before it produced a usable result.',
default => 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.',
$isFailed => 'The last compare run did not finish cleanly, so current counts should not be treated as decision-grade.',
$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.',
$isInProgress => '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 = $isFailed
? 'The last compare failed, so the tenant needs review before you rely on this posture.'
: 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 = $isFailed
? 'Review the failed compare run before relying on this tenant posture'
: ($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: $isFailed
? 'inspect_run'
: ($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->evidenceGapStructuralCount !== null && $stats->evidenceGapStructuralCount > 0) {
$descriptors[] = new CountDescriptor(
label: 'Structural gaps',
value: (int) $stats->evidenceGapStructuralCount,
role: CountDescriptor::ROLE_RELIABILITY_SIGNAL,
qualifier: 'product or support limit',
visibilityTier: CountDescriptor::VISIBILITY_DIAGNOSTIC,
);
}
if ($stats->evidenceGapOperationalCount !== null && $stats->evidenceGapOperationalCount > 0) {
$descriptors[] = new CountDescriptor(
label: 'Operational gaps',
value: (int) $stats->evidenceGapOperationalCount,
role: CountDescriptor::ROLE_RELIABILITY_SIGNAL,
qualifier: 'local evidence missing',
visibilityTier: CountDescriptor::VISIBILITY_DIAGNOSTIC,
);
}
if ($stats->evidenceGapTransientCount !== null && $stats->evidenceGapTransientCount > 0) {
$descriptors[] = new CountDescriptor(
label: 'Transient gaps',
value: (int) $stats->evidenceGapTransientCount,
role: CountDescriptor::ROLE_RELIABILITY_SIGNAL,
qualifier: 'retry may help',
visibilityTier: CountDescriptor::VISIBILITY_DIAGNOSTIC,
);
}
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;
use App\Support\Ui\OperatorExplanation\ExplanationFamily;
use App\Support\Ui\OperatorExplanation\TrustworthinessLevel;
enum BaselineCompareReasonCode: string
{
case NoSubjectsInScope = 'no_subjects_in_scope';
@ -22,4 +25,42 @@ public function message(): string
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',
};
}
public function supportsPositiveClaim(): bool
{
return $this === self::NoDriftDetected;
}
}

View File

@ -5,13 +5,17 @@
namespace App\Support\Baselines;
use App\Models\BaselineProfile;
use App\Models\BaselineSnapshot;
use App\Models\BaselineTenantAssignment;
use App\Models\Finding;
use App\Models\InventoryItem;
use App\Models\OperationRun;
use App\Models\Tenant;
use App\Services\Baselines\BaselineSnapshotTruthResolver;
use App\Support\OperationRunStatus;
use App\Support\OperationRunType;
use App\Support\Ui\OperatorExplanation\CountDescriptor;
use App\Support\Ui\OperatorExplanation\OperatorExplanationPattern;
use Illuminate\Support\Facades\Cache;
final class BaselineCompareStats
@ -20,6 +24,32 @@ final class BaselineCompareStats
* @param array<string, int> $severityCounts
* @param list<string> $uncoveredTypes
* @param array<string, int> $evidenceGapsTopReasons
* @param array{
* summary: array{
* count: int,
* by_reason: array<string, int>,
* detail_state: string,
* recorded_subjects_total: int,
* missing_detail_count: int
* },
* buckets: list<array{
* reason_code: string,
* reason_label: string,
* count: int,
* recorded_count: int,
* missing_detail_count: int,
* detail_state: string,
* search_text: string,
* rows: list<array{
* reason_code: string,
* reason_label: string,
* policy_type: string,
* subject_key: string,
* search_text: string
* }>
* }>
* } $evidenceGapDetails
* @param array<string, mixed> $baselineCompareDiagnostics
*/
private function __construct(
public readonly string $state,
@ -28,6 +58,7 @@ private function __construct(
public readonly ?int $profileId,
public readonly ?int $snapshotId,
public readonly ?int $duplicateNamePoliciesCount,
public readonly ?int $duplicateNameSubjectsCount,
public readonly ?int $operationRunId,
public readonly ?int $findingsCount,
public readonly array $severityCounts,
@ -43,6 +74,12 @@ private function __construct(
public readonly ?int $evidenceGapsCount = null,
public readonly array $evidenceGapsTopReasons = [],
public readonly ?array $rbacRoleDefinitionSummary = null,
public readonly array $evidenceGapDetails = [],
public readonly array $baselineCompareDiagnostics = [],
public readonly ?int $evidenceGapStructuralCount = null,
public readonly ?int $evidenceGapOperationalCount = null,
public readonly ?int $evidenceGapTransientCount = null,
public readonly ?bool $evidenceGapLegacyMode = null,
) {}
public static function forTenant(?Tenant $tenant): self
@ -73,7 +110,11 @@ public static function forTenant(?Tenant $tenant): self
$profileName = (string) $profile->name;
$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(
is_array($profile->scope_jsonb) ? $profile->scope_jsonb : null,
@ -83,15 +124,27 @@ public static function forTenant(?Tenant $tenant): self
: null;
$effectiveScope = BaselineScope::effective($profileScope, $overrideScope);
$duplicateNamePoliciesCount = self::duplicateNamePoliciesCount($tenant, $effectiveScope);
$duplicateNameStats = self::duplicateNameStats($tenant, $effectiveScope);
$duplicateNamePoliciesCount = $duplicateNameStats['policy_count'];
$duplicateNameSubjectsCount = $duplicateNameStats['subject_count'];
if ($snapshotId === null) {
return self::empty(
'no_snapshot',
'The baseline profile has no active snapshot yet. A workspace manager needs to capture a snapshot first.',
return new self(
state: 'no_snapshot',
message: $snapshotReasonMessage ?? 'The baseline profile has no complete snapshot yet. A workspace manager needs to capture a baseline first.',
profileName: $profileName,
profileId: $profileId,
snapshotId: null,
duplicateNamePoliciesCount: $duplicateNamePoliciesCount,
duplicateNameSubjectsCount: $duplicateNameSubjectsCount,
operationRunId: null,
findingsCount: null,
severityCounts: [],
lastComparedHuman: null,
lastComparedIso: null,
failureReason: null,
reasonCode: $snapshotReasonCode,
reasonMessage: $snapshotReasonMessage,
);
}
@ -105,6 +158,21 @@ public static function forTenant(?Tenant $tenant): self
[$evidenceGapsCount, $evidenceGapsTopReasons] = self::evidenceGapSummaryForRun($latestRun);
[$reasonCode, $reasonMessage] = self::reasonInfoForRun($latestRun);
$rbacRoleDefinitionSummary = self::rbacRoleDefinitionSummaryForRun($latestRun);
$evidenceGapDetails = self::evidenceGapDetailsForRun($latestRun);
$baselineCompareDiagnostics = self::baselineCompareDiagnosticsForRun($latestRun);
$evidenceGapSummary = is_array($evidenceGapDetails['summary'] ?? null) ? $evidenceGapDetails['summary'] : [];
$evidenceGapStructuralCount = is_numeric($evidenceGapSummary['structural_count'] ?? null)
? (int) $evidenceGapSummary['structural_count']
: null;
$evidenceGapOperationalCount = is_numeric($evidenceGapSummary['operational_count'] ?? null)
? (int) $evidenceGapSummary['operational_count']
: null;
$evidenceGapTransientCount = is_numeric($evidenceGapSummary['transient_count'] ?? null)
? (int) $evidenceGapSummary['transient_count']
: null;
$evidenceGapLegacyMode = is_bool($evidenceGapSummary['legacy_mode'] ?? null)
? (bool) $evidenceGapSummary['legacy_mode']
: null;
// Active run (queued/running)
if ($latestRun instanceof OperationRun && in_array($latestRun->status, ['queued', 'running'], true)) {
@ -115,6 +183,7 @@ public static function forTenant(?Tenant $tenant): self
profileId: $profileId,
snapshotId: $snapshotId,
duplicateNamePoliciesCount: $duplicateNamePoliciesCount,
duplicateNameSubjectsCount: $duplicateNameSubjectsCount,
operationRunId: (int) $latestRun->getKey(),
findingsCount: null,
severityCounts: [],
@ -130,6 +199,12 @@ public static function forTenant(?Tenant $tenant): self
evidenceGapsCount: $evidenceGapsCount,
evidenceGapsTopReasons: $evidenceGapsTopReasons,
rbacRoleDefinitionSummary: $rbacRoleDefinitionSummary,
evidenceGapDetails: $evidenceGapDetails,
baselineCompareDiagnostics: $baselineCompareDiagnostics,
evidenceGapStructuralCount: $evidenceGapStructuralCount,
evidenceGapOperationalCount: $evidenceGapOperationalCount,
evidenceGapTransientCount: $evidenceGapTransientCount,
evidenceGapLegacyMode: $evidenceGapLegacyMode,
);
}
@ -147,6 +222,7 @@ public static function forTenant(?Tenant $tenant): self
profileId: $profileId,
snapshotId: $snapshotId,
duplicateNamePoliciesCount: $duplicateNamePoliciesCount,
duplicateNameSubjectsCount: $duplicateNameSubjectsCount,
operationRunId: (int) $latestRun->getKey(),
findingsCount: null,
severityCounts: [],
@ -162,6 +238,12 @@ public static function forTenant(?Tenant $tenant): self
evidenceGapsCount: $evidenceGapsCount,
evidenceGapsTopReasons: $evidenceGapsTopReasons,
rbacRoleDefinitionSummary: $rbacRoleDefinitionSummary,
evidenceGapDetails: $evidenceGapDetails,
baselineCompareDiagnostics: $baselineCompareDiagnostics,
evidenceGapStructuralCount: $evidenceGapStructuralCount,
evidenceGapOperationalCount: $evidenceGapOperationalCount,
evidenceGapTransientCount: $evidenceGapTransientCount,
evidenceGapLegacyMode: $evidenceGapLegacyMode,
);
}
@ -201,6 +283,7 @@ public static function forTenant(?Tenant $tenant): self
profileId: $profileId,
snapshotId: $snapshotId,
duplicateNamePoliciesCount: $duplicateNamePoliciesCount,
duplicateNameSubjectsCount: $duplicateNameSubjectsCount,
operationRunId: $latestRun instanceof OperationRun ? (int) $latestRun->getKey() : null,
findingsCount: $totalFindings,
severityCounts: $severityCounts,
@ -216,6 +299,12 @@ public static function forTenant(?Tenant $tenant): self
evidenceGapsCount: $evidenceGapsCount,
evidenceGapsTopReasons: $evidenceGapsTopReasons,
rbacRoleDefinitionSummary: $rbacRoleDefinitionSummary,
evidenceGapDetails: $evidenceGapDetails,
baselineCompareDiagnostics: $baselineCompareDiagnostics,
evidenceGapStructuralCount: $evidenceGapStructuralCount,
evidenceGapOperationalCount: $evidenceGapOperationalCount,
evidenceGapTransientCount: $evidenceGapTransientCount,
evidenceGapLegacyMode: $evidenceGapLegacyMode,
);
}
@ -229,6 +318,7 @@ public static function forTenant(?Tenant $tenant): self
profileId: $profileId,
snapshotId: $snapshotId,
duplicateNamePoliciesCount: $duplicateNamePoliciesCount,
duplicateNameSubjectsCount: $duplicateNameSubjectsCount,
operationRunId: (int) $latestRun->getKey(),
findingsCount: 0,
severityCounts: $severityCounts,
@ -244,6 +334,12 @@ public static function forTenant(?Tenant $tenant): self
evidenceGapsCount: $evidenceGapsCount,
evidenceGapsTopReasons: $evidenceGapsTopReasons,
rbacRoleDefinitionSummary: $rbacRoleDefinitionSummary,
evidenceGapDetails: $evidenceGapDetails,
baselineCompareDiagnostics: $baselineCompareDiagnostics,
evidenceGapStructuralCount: $evidenceGapStructuralCount,
evidenceGapOperationalCount: $evidenceGapOperationalCount,
evidenceGapTransientCount: $evidenceGapTransientCount,
evidenceGapLegacyMode: $evidenceGapLegacyMode,
);
}
@ -254,6 +350,7 @@ public static function forTenant(?Tenant $tenant): self
profileId: $profileId,
snapshotId: $snapshotId,
duplicateNamePoliciesCount: $duplicateNamePoliciesCount,
duplicateNameSubjectsCount: $duplicateNameSubjectsCount,
operationRunId: null,
findingsCount: null,
severityCounts: $severityCounts,
@ -269,6 +366,12 @@ public static function forTenant(?Tenant $tenant): self
evidenceGapsCount: $evidenceGapsCount,
evidenceGapsTopReasons: $evidenceGapsTopReasons,
rbacRoleDefinitionSummary: $rbacRoleDefinitionSummary,
evidenceGapDetails: $evidenceGapDetails,
baselineCompareDiagnostics: $baselineCompareDiagnostics,
evidenceGapStructuralCount: $evidenceGapStructuralCount,
evidenceGapOperationalCount: $evidenceGapOperationalCount,
evidenceGapTransientCount: $evidenceGapTransientCount,
evidenceGapLegacyMode: $evidenceGapLegacyMode,
);
}
@ -291,6 +394,11 @@ public static function forWidget(?Tenant $tenant): self
}
$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();
$severityRows = Finding::query()
@ -314,12 +422,13 @@ public static function forWidget(?Tenant $tenant): self
->first();
return new self(
state: $totalFindings > 0 ? 'ready' : 'idle',
message: null,
state: $snapshotId === null ? 'no_snapshot' : ($totalFindings > 0 ? 'ready' : 'idle'),
message: $snapshotId === null ? $snapshotReasonMessage : null,
profileName: (string) $profile->name,
profileId: (int) $profile->getKey(),
snapshotId: $profile->active_snapshot_id !== null ? (int) $profile->active_snapshot_id : null,
snapshotId: $snapshotId,
duplicateNamePoliciesCount: null,
duplicateNameSubjectsCount: null,
operationRunId: $latestRun instanceof OperationRun ? (int) $latestRun->getKey() : null,
findingsCount: $totalFindings,
severityCounts: [
@ -330,20 +439,28 @@ public static function forWidget(?Tenant $tenant): self
lastComparedHuman: $latestRun?->finished_at?->diffForHumans(),
lastComparedIso: $latestRun?->finished_at?->toIso8601String(),
failureReason: null,
reasonCode: $snapshotReasonCode,
reasonMessage: $snapshotReasonMessage,
);
}
private static function duplicateNamePoliciesCount(Tenant $tenant, BaselineScope $effectiveScope): int
/**
* @return array{policy_count: int, subject_count: int}
*/
private static function duplicateNameStats(Tenant $tenant, BaselineScope $effectiveScope): array
{
$policyTypes = $effectiveScope->allTypes();
if ($policyTypes === []) {
return 0;
return [
'policy_count' => 0,
'subject_count' => 0,
];
}
$latestInventorySyncRunId = self::latestInventorySyncRunId($tenant);
$compute = static function () use ($tenant, $policyTypes, $latestInventorySyncRunId): int {
$compute = static function () use ($tenant, $policyTypes, $latestInventorySyncRunId): array {
/**
* @var array<string, int> $countsByKey
*/
@ -376,14 +493,19 @@ private static function duplicateNamePoliciesCount(Tenant $tenant, BaselineScope
});
$duplicatePolicies = 0;
$duplicateSubjects = 0;
foreach ($countsByKey as $count) {
if ($count > 1) {
$duplicateSubjects++;
$duplicatePolicies += $count;
}
}
return $duplicatePolicies;
return [
'policy_count' => $duplicatePolicies,
'subject_count' => $duplicateSubjects,
];
};
if (app()->environment('testing')) {
@ -397,7 +519,10 @@ private static function duplicateNamePoliciesCount(Tenant $tenant, BaselineScope
$latestInventorySyncRunId ?? 'all',
);
return (int) Cache::remember($cacheKey, now()->addSeconds(60), $compute);
/** @var array{policy_count: int, subject_count: int} $stats */
$stats = Cache::remember($cacheKey, now()->addSeconds(60), $compute);
return $stats;
}
private static function latestInventorySyncRunId(Tenant $tenant): ?int
@ -491,48 +616,67 @@ private static function evidenceGapSummaryForRun(?OperationRun $run): array
return [null, []];
}
$details = self::evidenceGapDetailsForRun($run);
$summary = is_array($details['summary'] ?? null) ? $details['summary'] : [];
$count = is_numeric($summary['count'] ?? null) ? (int) $summary['count'] : null;
$byReason = is_array($summary['by_reason'] ?? null) ? $summary['by_reason'] : [];
return [$count, array_slice($byReason, 0, 6, true)];
}
/**
* @return array{
* summary: array{
* count: int,
* by_reason: array<string, int>,
* detail_state: string,
* recorded_subjects_total: int,
* missing_detail_count: int
* },
* buckets: list<array{
* reason_code: string,
* reason_label: string,
* count: int,
* recorded_count: int,
* missing_detail_count: int,
* detail_state: string,
* search_text: string,
* rows: list<array{
* reason_code: string,
* reason_label: string,
* policy_type: string,
* subject_key: string,
* search_text: string
* }>
* }>
* }
*/
private static function evidenceGapDetailsForRun(?OperationRun $run): array
{
if (! $run instanceof OperationRun) {
return BaselineCompareEvidenceGapDetails::fromContext([]);
}
return BaselineCompareEvidenceGapDetails::fromOperationRun($run);
}
/**
* @return array<string, mixed>
*/
private static function baselineCompareDiagnosticsForRun(?OperationRun $run): array
{
if (! $run instanceof OperationRun) {
return [];
}
$context = is_array($run->context) ? $run->context : [];
$baselineCompare = $context['baseline_compare'] ?? null;
if (! is_array($baselineCompare)) {
return [null, []];
return [];
}
$gaps = $baselineCompare['evidence_gaps'] ?? null;
if (! is_array($gaps)) {
return [null, []];
}
$count = $gaps['count'] ?? null;
$count = is_numeric($count) ? (int) $count : null;
$byReason = $gaps['by_reason'] ?? null;
$byReason = is_array($byReason) ? $byReason : [];
$normalized = [];
foreach ($byReason as $reason => $value) {
if (! is_string($reason) || trim($reason) === '' || ! is_numeric($value)) {
continue;
}
$intValue = (int) $value;
if ($intValue <= 0) {
continue;
}
$normalized[trim($reason)] = $intValue;
}
if ($count === null) {
$count = array_sum($normalized);
}
arsort($normalized);
return [$count, array_slice($normalized, 0, 6, true)];
return BaselineCompareEvidenceGapDetails::diagnosticsPayload($baselineCompare);
}
/**
@ -561,12 +705,46 @@ private static function rbacRoleDefinitionSummaryForRun(?OperationRun $run): ?ar
];
}
public function operatorExplanation(): OperatorExplanationPattern
{
/** @var BaselineCompareExplanationRegistry $registry */
$registry = app(BaselineCompareExplanationRegistry::class);
return $registry->forStats($this);
}
public function summaryAssessment(): BaselineCompareSummaryAssessment
{
/** @var BaselineCompareSummaryAssessor $assessor */
$assessor = app(BaselineCompareSummaryAssessor::class);
return $assessor->assess($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(
string $state,
?string $message,
?string $profileName = null,
?int $profileId = null,
?int $duplicateNamePoliciesCount = null,
?int $duplicateNameSubjectsCount = null,
): self {
return new self(
state: $state,
@ -575,6 +753,7 @@ private static function empty(
profileId: $profileId,
snapshotId: null,
duplicateNamePoliciesCount: $duplicateNamePoliciesCount,
duplicateNameSubjectsCount: $duplicateNameSubjectsCount,
operationRunId: null,
findingsCount: null,
severityCounts: [],
@ -583,4 +762,15 @@ private static function empty(
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

@ -0,0 +1,150 @@
<?php
declare(strict_types=1);
namespace App\Support\Baselines;
use InvalidArgumentException;
final readonly class BaselineCompareSummaryAssessment
{
public const string STATE_POSITIVE = 'positive';
public const string STATE_CAUTION = 'caution';
public const string STATE_STALE = 'stale';
public const string STATE_ACTION_REQUIRED = 'action_required';
public const string STATE_UNAVAILABLE = 'unavailable';
public const string STATE_IN_PROGRESS = 'in_progress';
public const string EVIDENCE_NONE = 'none';
public const string EVIDENCE_COVERAGE_WARNING = 'coverage_warning';
public const string EVIDENCE_EVIDENCE_GAP = 'evidence_gap';
public const string EVIDENCE_STALE_RESULT = 'stale_result';
public const string EVIDENCE_SUPPRESSED_OUTPUT = 'suppressed_output';
public const string EVIDENCE_UNAVAILABLE = 'unavailable';
public const string NEXT_TARGET_LANDING = 'landing';
public const string NEXT_TARGET_FINDINGS = 'findings';
public const string NEXT_TARGET_RUN = 'run';
public const string NEXT_TARGET_NONE = 'none';
/**
* @param array{label: string, target: string} $nextAction
*/
public function __construct(
public string $stateFamily,
public string $headline,
public ?string $supportingMessage,
public string $tone,
public bool $positiveClaimAllowed,
public string $trustworthinessLevel,
public string $evaluationResult,
public string $evidenceImpact,
public int $findingsVisibleCount,
public int $highSeverityCount,
public array $nextAction,
public ?string $lastComparedLabel = null,
public ?string $reasonCode = null,
) {
if (! in_array($this->stateFamily, [
self::STATE_POSITIVE,
self::STATE_CAUTION,
self::STATE_STALE,
self::STATE_ACTION_REQUIRED,
self::STATE_UNAVAILABLE,
self::STATE_IN_PROGRESS,
], true)) {
throw new InvalidArgumentException('Unsupported baseline summary state family: '.$this->stateFamily);
}
if (trim($this->headline) === '') {
throw new InvalidArgumentException('Baseline summary assessments require a headline.');
}
if (! in_array($this->evidenceImpact, [
self::EVIDENCE_NONE,
self::EVIDENCE_COVERAGE_WARNING,
self::EVIDENCE_EVIDENCE_GAP,
self::EVIDENCE_STALE_RESULT,
self::EVIDENCE_SUPPRESSED_OUTPUT,
self::EVIDENCE_UNAVAILABLE,
], true)) {
throw new InvalidArgumentException('Unsupported baseline summary evidence impact: '.$this->evidenceImpact);
}
if (! in_array($this->nextAction['target'] ?? null, [
self::NEXT_TARGET_LANDING,
self::NEXT_TARGET_FINDINGS,
self::NEXT_TARGET_RUN,
self::NEXT_TARGET_NONE,
], true)) {
throw new InvalidArgumentException('Unsupported baseline summary next-action target.');
}
if (trim((string) ($this->nextAction['label'] ?? '')) === '') {
throw new InvalidArgumentException('Baseline summary assessments require a next-action label.');
}
if ($this->positiveClaimAllowed && $this->stateFamily !== self::STATE_POSITIVE) {
throw new InvalidArgumentException('Positive claim eligibility must resolve to the positive summary state.');
}
}
public function nextActionLabel(): string
{
return $this->nextAction['label'];
}
public function nextActionTarget(): string
{
return $this->nextAction['target'];
}
/**
* @return array{
* stateFamily: string,
* headline: string,
* supportingMessage: ?string,
* tone: string,
* positiveClaimAllowed: bool,
* trustworthinessLevel: string,
* evaluationResult: string,
* evidenceImpact: string,
* findingsVisibleCount: int,
* highSeverityCount: int,
* nextAction: array{label: string, target: string},
* lastComparedLabel: ?string,
* reasonCode: ?string
* }
*/
public function toArray(): array
{
return [
'stateFamily' => $this->stateFamily,
'headline' => $this->headline,
'supportingMessage' => $this->supportingMessage,
'tone' => $this->tone,
'positiveClaimAllowed' => $this->positiveClaimAllowed,
'trustworthinessLevel' => $this->trustworthinessLevel,
'evaluationResult' => $this->evaluationResult,
'evidenceImpact' => $this->evidenceImpact,
'findingsVisibleCount' => $this->findingsVisibleCount,
'highSeverityCount' => $this->highSeverityCount,
'nextAction' => $this->nextAction,
'lastComparedLabel' => $this->lastComparedLabel,
'reasonCode' => $this->reasonCode,
];
}
}

View File

@ -0,0 +1,285 @@
<?php
declare(strict_types=1);
namespace App\Support\Baselines;
use App\Support\Ui\OperatorExplanation\OperatorExplanationPattern;
use App\Support\Ui\OperatorExplanation\TrustworthinessLevel;
use Carbon\CarbonImmutable;
final class BaselineCompareSummaryAssessor
{
private const int STALE_AFTER_DAYS = 7;
public function assess(BaselineCompareStats $stats): BaselineCompareSummaryAssessment
{
$explanation = $stats->operatorExplanation();
$findingsVisibleCount = (int) ($stats->findingsCount ?? 0);
$highSeverityCount = (int) ($stats->severityCounts['high'] ?? 0);
$reasonCode = is_string($stats->reasonCode) ? BaselineCompareReasonCode::tryFrom($stats->reasonCode) : null;
$evaluationResult = $stats->state === 'failed'
? 'failed_result'
: $explanation->evaluationResult;
$positiveClaimAllowed = $this->positiveClaimAllowed($stats, $explanation, $reasonCode, $evaluationResult);
$isStale = $this->hasStaleResult($stats, $evaluationResult);
$stateFamily = $this->stateFamily($stats, $findingsVisibleCount, $positiveClaimAllowed, $isStale);
return new BaselineCompareSummaryAssessment(
stateFamily: $stateFamily,
headline: $this->headline($stats, $stateFamily, $findingsVisibleCount, $highSeverityCount, $evaluationResult),
supportingMessage: $this->supportingMessage($stats, $stateFamily, $findingsVisibleCount, $evaluationResult),
tone: $this->tone($stats, $stateFamily),
positiveClaimAllowed: $positiveClaimAllowed,
trustworthinessLevel: $explanation->trustworthinessLevel->value,
evaluationResult: $evaluationResult,
evidenceImpact: $this->evidenceImpact($stats, $evaluationResult, $isStale),
findingsVisibleCount: $findingsVisibleCount,
highSeverityCount: $highSeverityCount,
nextAction: $this->nextAction($stats, $stateFamily, $findingsVisibleCount, $evaluationResult),
lastComparedLabel: $stats->lastComparedHuman,
reasonCode: $stats->reasonCode,
);
}
private function positiveClaimAllowed(
BaselineCompareStats $stats,
OperatorExplanationPattern $explanation,
?BaselineCompareReasonCode $reasonCode,
string $evaluationResult,
): bool {
if ($stats->state !== 'ready') {
return false;
}
if ((int) ($stats->findingsCount ?? 0) > 0) {
return false;
}
if ($evaluationResult !== 'no_result') {
return false;
}
if ($explanation->trustworthinessLevel !== TrustworthinessLevel::Trustworthy) {
return false;
}
if (in_array($stats->coverageStatus, ['warning', 'unproven'], true)) {
return false;
}
if ((int) ($stats->evidenceGapsCount ?? 0) > 0) {
return false;
}
if ($this->hasStaleResult($stats, $evaluationResult)) {
return false;
}
if ($stats->reasonCode === null) {
return true;
}
return $reasonCode?->supportsPositiveClaim() ?? false;
}
private function stateFamily(
BaselineCompareStats $stats,
int $findingsVisibleCount,
bool $positiveClaimAllowed,
bool $isStale,
): string {
return match (true) {
$stats->state === 'comparing' => BaselineCompareSummaryAssessment::STATE_IN_PROGRESS,
$stats->state === 'failed',
$findingsVisibleCount > 0 => BaselineCompareSummaryAssessment::STATE_ACTION_REQUIRED,
in_array($stats->state, ['no_tenant', 'no_assignment', 'no_snapshot', 'idle'], true) => BaselineCompareSummaryAssessment::STATE_UNAVAILABLE,
$isStale => BaselineCompareSummaryAssessment::STATE_STALE,
$positiveClaimAllowed => BaselineCompareSummaryAssessment::STATE_POSITIVE,
default => BaselineCompareSummaryAssessment::STATE_CAUTION,
};
}
private function evidenceImpact(BaselineCompareStats $stats, string $evaluationResult, bool $isStale): string
{
if (in_array($stats->state, ['no_tenant', 'no_assignment', 'no_snapshot', 'idle', 'failed'], true)) {
return BaselineCompareSummaryAssessment::EVIDENCE_UNAVAILABLE;
}
if ($isStale) {
return BaselineCompareSummaryAssessment::EVIDENCE_STALE_RESULT;
}
if ($evaluationResult === 'suppressed_result') {
return BaselineCompareSummaryAssessment::EVIDENCE_SUPPRESSED_OUTPUT;
}
if ((int) ($stats->evidenceGapsCount ?? 0) > 0) {
return BaselineCompareSummaryAssessment::EVIDENCE_EVIDENCE_GAP;
}
if (in_array($stats->coverageStatus, ['warning', 'unproven'], true)) {
return BaselineCompareSummaryAssessment::EVIDENCE_COVERAGE_WARNING;
}
return BaselineCompareSummaryAssessment::EVIDENCE_NONE;
}
private function headline(
BaselineCompareStats $stats,
string $stateFamily,
int $findingsVisibleCount,
int $highSeverityCount,
string $evaluationResult,
): string {
return match ($stateFamily) {
BaselineCompareSummaryAssessment::STATE_POSITIVE => 'No confirmed drift in the latest baseline compare.',
BaselineCompareSummaryAssessment::STATE_CAUTION => match (true) {
$evaluationResult === 'suppressed_result' => 'The last compare finished, but normal result output was suppressed.',
(int) ($stats->evidenceGapsCount ?? 0) > 0 => 'No confirmed drift is visible, but evidence gaps still limit this result.',
in_array($stats->coverageStatus, ['warning', 'unproven'], true) => 'No confirmed drift is visible, but coverage limits this compare.',
default => 'The latest compare result needs caution before you treat it as an all-clear.',
},
BaselineCompareSummaryAssessment::STATE_STALE => 'The latest baseline compare result is stale.',
BaselineCompareSummaryAssessment::STATE_ACTION_REQUIRED => match (true) {
$stats->state === 'failed' || $evaluationResult === 'failed_result' => 'The latest baseline compare failed before it produced a usable result.',
$highSeverityCount > 0 => sprintf('%d high-severity drift finding%s need review.', $highSeverityCount, $highSeverityCount === 1 ? '' : 's'),
default => sprintf('%d open drift finding%s need review.', $findingsVisibleCount, $findingsVisibleCount === 1 ? '' : 's'),
},
BaselineCompareSummaryAssessment::STATE_IN_PROGRESS => 'Baseline compare is in progress.',
default => match ($stats->state) {
'no_assignment' => 'This tenant does not have an assigned baseline yet.',
'no_snapshot' => 'The current baseline snapshot is not available for compare.',
'idle' => 'A current baseline compare result is not available yet.',
default => 'A usable baseline compare result is not currently available.',
},
};
}
private function supportingMessage(
BaselineCompareStats $stats,
string $stateFamily,
int $findingsVisibleCount,
string $evaluationResult,
): ?string {
return match ($stateFamily) {
BaselineCompareSummaryAssessment::STATE_POSITIVE => $stats->lastComparedHuman !== null
? 'Last compared '.$stats->lastComparedHuman.'.'
: 'The latest compare result is trustworthy enough to treat zero findings as current.',
BaselineCompareSummaryAssessment::STATE_CAUTION => match (true) {
$evaluationResult === 'suppressed_result' => 'Review the run detail before treating zero visible findings as complete.',
(int) ($stats->evidenceGapsCount ?? 0) > 0 => 'Review the compare detail to see which evidence gaps still limit trust.',
in_array($stats->coverageStatus, ['warning', 'unproven'], true) => 'Coverage warnings mean zero visible findings are not an all-clear on their own.',
default => $stats->reasonMessage ?? $stats->message,
},
BaselineCompareSummaryAssessment::STATE_STALE => $stats->lastComparedHuman !== null
? 'Last compared '.$stats->lastComparedHuman.'. Refresh compare before relying on this posture.'
: 'Refresh compare before relying on this posture.',
BaselineCompareSummaryAssessment::STATE_ACTION_REQUIRED => match (true) {
$stats->state === 'failed' => $stats->failureReason,
$findingsVisibleCount > 0 => 'Open findings remain on this tenant and need review.',
default => $stats->message,
},
BaselineCompareSummaryAssessment::STATE_IN_PROGRESS => 'Current counts are diagnostic only until the compare run finishes.',
default => $stats->message,
};
}
private function tone(BaselineCompareStats $stats, string $stateFamily): string
{
return match ($stateFamily) {
BaselineCompareSummaryAssessment::STATE_POSITIVE => 'success',
BaselineCompareSummaryAssessment::STATE_ACTION_REQUIRED => 'danger',
BaselineCompareSummaryAssessment::STATE_IN_PROGRESS => 'info',
BaselineCompareSummaryAssessment::STATE_UNAVAILABLE => $stats->state === 'no_snapshot' ? 'warning' : 'gray',
default => 'warning',
};
}
/**
* @return array{label: string, target: string}
*/
private function nextAction(
BaselineCompareStats $stats,
string $stateFamily,
int $findingsVisibleCount,
string $evaluationResult,
): array {
if ($findingsVisibleCount > 0) {
return [
'label' => 'Open findings',
'target' => BaselineCompareSummaryAssessment::NEXT_TARGET_FINDINGS,
];
}
return match ($stateFamily) {
BaselineCompareSummaryAssessment::STATE_ACTION_REQUIRED => [
'label' => $evaluationResult === 'failed_result' ? 'Review the failed run' : 'Review compare detail',
'target' => $stats->operationRunId !== null
? BaselineCompareSummaryAssessment::NEXT_TARGET_RUN
: BaselineCompareSummaryAssessment::NEXT_TARGET_LANDING,
],
BaselineCompareSummaryAssessment::STATE_CAUTION => [
'label' => 'Review compare detail',
'target' => $stats->operationRunId !== null
? BaselineCompareSummaryAssessment::NEXT_TARGET_RUN
: BaselineCompareSummaryAssessment::NEXT_TARGET_LANDING,
],
BaselineCompareSummaryAssessment::STATE_STALE => [
'label' => 'Open Baseline Compare',
'target' => BaselineCompareSummaryAssessment::NEXT_TARGET_LANDING,
],
BaselineCompareSummaryAssessment::STATE_IN_PROGRESS => [
'label' => $stats->operationRunId !== null ? 'View run' : 'Open Baseline Compare',
'target' => $stats->operationRunId !== null
? BaselineCompareSummaryAssessment::NEXT_TARGET_RUN
: BaselineCompareSummaryAssessment::NEXT_TARGET_LANDING,
],
BaselineCompareSummaryAssessment::STATE_UNAVAILABLE => match ($stats->state) {
'no_assignment' => [
'label' => 'Assign a baseline first',
'target' => BaselineCompareSummaryAssessment::NEXT_TARGET_NONE,
],
'no_snapshot' => [
'label' => 'Review baseline prerequisites',
'target' => BaselineCompareSummaryAssessment::NEXT_TARGET_LANDING,
],
'idle' => [
'label' => 'Open Baseline Compare',
'target' => BaselineCompareSummaryAssessment::NEXT_TARGET_LANDING,
],
default => [
'label' => 'Review compare availability',
'target' => BaselineCompareSummaryAssessment::NEXT_TARGET_NONE,
],
},
default => [
'label' => 'No action needed',
'target' => BaselineCompareSummaryAssessment::NEXT_TARGET_NONE,
],
};
}
private function hasStaleResult(BaselineCompareStats $stats, string $evaluationResult): bool
{
if ($stats->state !== 'ready') {
return false;
}
if ($stats->lastComparedIso === null) {
return false;
}
if (! in_array($evaluationResult, ['full_result', 'no_result', 'incomplete_result', 'suppressed_result'], true)) {
return false;
}
try {
$lastComparedAt = CarbonImmutable::parse($stats->lastComparedIso);
} catch (\Throwable) {
return false;
}
return $lastComparedAt->lt(now()->subDays(self::STALE_AFTER_DAYS));
}
}

View File

@ -18,13 +18,125 @@ final class BaselineReasonCodes
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_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_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_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

@ -118,6 +118,17 @@ public function allTypes(): array
));
}
/**
* @return list<string>
*/
public function truthfulTypes(string $operation, ?BaselineSupportCapabilityGuard $guard = null): array
{
$guard ??= app(BaselineSupportCapabilityGuard::class);
$guardResult = $guard->guardTypes($this->allTypes(), $operation);
return $guardResult['allowed_types'];
}
/**
* @return array<string, mixed>
*/
@ -134,17 +145,32 @@ public function toJsonb(): array
*
* @return array{policy_types: list<string>, foundation_types: list<string>, all_types: list<string>, foundations_included: bool}
*/
public function toEffectiveScopeContext(): array
public function toEffectiveScopeContext(?BaselineSupportCapabilityGuard $guard = null, ?string $operation = null): array
{
$expanded = $this->expandDefaults();
$allTypes = self::uniqueSorted(array_merge($expanded->policyTypes, $expanded->foundationTypes));
return [
$context = [
'policy_types' => $expanded->policyTypes,
'foundation_types' => $expanded->foundationTypes,
'all_types' => $allTypes,
'foundations_included' => $expanded->foundationTypes !== [],
];
if (! is_string($operation) || $operation === '') {
return $context;
}
$guard ??= app(BaselineSupportCapabilityGuard::class);
$guardResult = $guard->guardTypes($allTypes, $operation);
return array_merge($context, [
'truthful_types' => $guardResult['allowed_types'],
'limited_types' => $guardResult['limited_types'],
'unsupported_types' => $guardResult['unsupported_types'],
'invalid_support_types' => $guardResult['invalid_support_types'],
'capabilities' => $guardResult['capabilities'],
]);
}
/**

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

@ -0,0 +1,79 @@
<?php
declare(strict_types=1);
namespace App\Support\Baselines;
final class BaselineSupportCapabilityGuard
{
public function __construct(
private readonly SubjectResolver $resolver,
) {}
public function inspectType(string $policyType): SupportCapabilityRecord
{
return $this->resolver->capability($policyType);
}
/**
* @param list<string> $policyTypes
* @return array{
* allowed_types: list<string>,
* limited_types: list<string>,
* unsupported_types: list<string>,
* invalid_support_types: list<string>,
* capabilities: array<string, array<string, mixed>>
* }
*/
public function guardTypes(array $policyTypes, string $operation): array
{
$allowedTypes = [];
$limitedTypes = [];
$unsupportedTypes = [];
$invalidSupportTypes = [];
$capabilities = [];
foreach (array_values(array_unique(array_filter($policyTypes, 'is_string'))) as $policyType) {
$record = $this->inspectType($policyType);
$mode = $record->supportModeFor($operation);
$capabilities[$policyType] = array_merge(
$record->toArray(),
['support_mode' => $mode],
);
if ($mode === 'invalid_support_config') {
$invalidSupportTypes[] = $policyType;
$unsupportedTypes[] = $policyType;
continue;
}
if ($record->allows($operation)) {
$allowedTypes[] = $policyType;
if ($mode === 'limited') {
$limitedTypes[] = $policyType;
}
continue;
}
$unsupportedTypes[] = $policyType;
}
sort($allowedTypes, SORT_STRING);
sort($limitedTypes, SORT_STRING);
sort($unsupportedTypes, SORT_STRING);
sort($invalidSupportTypes, SORT_STRING);
ksort($capabilities);
return [
'allowed_types' => $allowedTypes,
'limited_types' => $limitedTypes,
'unsupported_types' => $unsupportedTypes,
'invalid_support_types' => $invalidSupportTypes,
'capabilities' => $capabilities,
];
}
}

View File

@ -0,0 +1,16 @@
<?php
declare(strict_types=1);
namespace App\Support\Baselines;
enum OperatorActionCategory: string
{
case None = 'none';
case Retry = 'retry';
case RunInventorySync = 'run_inventory_sync';
case RunPolicySyncOrBackup = 'run_policy_sync_or_backup';
case ReviewPermissions = 'review_permissions';
case InspectSubjectMapping = 'inspect_subject_mapping';
case ProductFollowUp = 'product_follow_up';
}

View File

@ -0,0 +1,25 @@
<?php
declare(strict_types=1);
namespace App\Support\Baselines;
enum ResolutionOutcome: string
{
case ResolvedPolicy = 'resolved_policy';
case ResolvedInventory = 'resolved_inventory';
case PolicyRecordMissing = 'policy_record_missing';
case InventoryRecordMissing = 'inventory_record_missing';
case FoundationInventoryOnly = 'foundation_inventory_only';
case ResolutionTypeMismatch = 'resolution_type_mismatch';
case UnresolvableSubject = 'unresolvable_subject';
case InvalidSupportConfig = 'invalid_support_config';
case PermissionOrScopeBlocked = 'permission_or_scope_blocked';
case AmbiguousMatch = 'ambiguous_match';
case InvalidSubject = 'invalid_subject';
case DuplicateSubject = 'duplicate_subject';
case RetryableCaptureFailure = 'retryable_capture_failure';
case CaptureFailed = 'capture_failed';
case Throttled = 'throttled';
case BudgetExhausted = 'budget_exhausted';
}

View File

@ -0,0 +1,36 @@
<?php
declare(strict_types=1);
namespace App\Support\Baselines;
final class ResolutionOutcomeRecord
{
/**
* @param non-empty-string $reasonCode
* @param 'policy'|'inventory'|'derived'|null $sourceModelExpected
* @param 'policy'|'inventory'|'derived'|null $sourceModelFound
*/
public function __construct(
public readonly ResolutionOutcome $resolutionOutcome,
public readonly string $reasonCode,
public readonly OperatorActionCategory $operatorActionCategory,
public readonly bool $structural,
public readonly bool $retryable,
public readonly ?string $sourceModelExpected = null,
public readonly ?string $sourceModelFound = null,
) {}
public function toArray(): array
{
return [
'resolution_outcome' => $this->resolutionOutcome->value,
'reason_code' => $this->reasonCode,
'operator_action_category' => $this->operatorActionCategory->value,
'structural' => $this->structural,
'retryable' => $this->retryable,
'source_model_expected' => $this->sourceModelExpected,
'source_model_found' => $this->sourceModelFound,
];
}
}

View File

@ -0,0 +1,14 @@
<?php
declare(strict_types=1);
namespace App\Support\Baselines;
enum ResolutionPath: string
{
case Policy = 'policy';
case Inventory = 'inventory';
case FoundationPolicy = 'foundation_policy';
case FoundationInventory = 'foundation_inventory';
case Derived = 'derived';
}

View File

@ -0,0 +1,13 @@
<?php
declare(strict_types=1);
namespace App\Support\Baselines;
enum SubjectClass: string
{
case PolicyBacked = 'policy_backed';
case InventoryBacked = 'inventory_backed';
case FoundationBacked = 'foundation_backed';
case Derived = 'derived';
}

View File

@ -0,0 +1,47 @@
<?php
declare(strict_types=1);
namespace App\Support\Baselines;
final class SubjectDescriptor
{
/**
* @param non-empty-string $policyType
* @param non-empty-string $subjectKey
* @param 'supported'|'limited'|'excluded'|'invalid_support_config' $supportMode
* @param 'policy'|'inventory'|'derived'|null $sourceModelExpected
*/
public function __construct(
public readonly string $policyType,
public readonly ?string $subjectExternalId,
public readonly string $subjectKey,
public readonly SubjectClass $subjectClass,
public readonly ResolutionPath $resolutionPath,
public readonly string $supportMode,
public readonly ?string $sourceModelExpected,
) {}
public function expectsPolicy(): bool
{
return $this->sourceModelExpected === 'policy';
}
public function expectsInventory(): bool
{
return $this->sourceModelExpected === 'inventory';
}
public function toArray(): array
{
return [
'policy_type' => $this->policyType,
'subject_external_id' => $this->subjectExternalId,
'subject_key' => $this->subjectKey,
'subject_class' => $this->subjectClass->value,
'resolution_path' => $this->resolutionPath->value,
'support_mode' => $this->supportMode,
'source_model_expected' => $this->sourceModelExpected,
];
}
}

View File

@ -0,0 +1,201 @@
<?php
declare(strict_types=1);
namespace App\Support\Baselines;
use App\Support\Inventory\InventoryPolicyTypeMeta;
final class SubjectResolver
{
public function capability(string $policyType): SupportCapabilityRecord
{
$contract = InventoryPolicyTypeMeta::baselineSupportContract($policyType);
return new SupportCapabilityRecord(
policyType: $policyType,
subjectClass: SubjectClass::from($contract['subject_class']),
compareCapability: $contract['compare_capability'],
captureCapability: $contract['capture_capability'],
resolutionPath: ResolutionPath::from($contract['resolution_path']),
configSupported: (bool) $contract['config_supported'],
runtimeValid: (bool) $contract['runtime_valid'],
sourceModelExpected: $contract['source_model_expected'],
);
}
public function describeForCompare(string $policyType, ?string $subjectExternalId = null, ?string $subjectKey = null): SubjectDescriptor
{
return $this->describe(operation: 'compare', policyType: $policyType, subjectExternalId: $subjectExternalId, subjectKey: $subjectKey);
}
public function describeForCapture(string $policyType, ?string $subjectExternalId = null, ?string $subjectKey = null): SubjectDescriptor
{
return $this->describe(operation: 'capture', policyType: $policyType, subjectExternalId: $subjectExternalId, subjectKey: $subjectKey);
}
public function resolved(SubjectDescriptor $descriptor, ?string $sourceModelFound = null): ResolutionOutcomeRecord
{
$outcome = $descriptor->expectsPolicy()
? ResolutionOutcome::ResolvedPolicy
: ResolutionOutcome::ResolvedInventory;
return new ResolutionOutcomeRecord(
resolutionOutcome: $outcome,
reasonCode: $outcome->value,
operatorActionCategory: OperatorActionCategory::None,
structural: false,
retryable: false,
sourceModelExpected: $descriptor->sourceModelExpected,
sourceModelFound: $sourceModelFound ?? $descriptor->sourceModelExpected,
);
}
public function missingExpectedRecord(SubjectDescriptor $descriptor): ResolutionOutcomeRecord
{
$expectsPolicy = $descriptor->expectsPolicy();
return new ResolutionOutcomeRecord(
resolutionOutcome: $expectsPolicy ? ResolutionOutcome::PolicyRecordMissing : ResolutionOutcome::InventoryRecordMissing,
reasonCode: $expectsPolicy ? 'policy_record_missing' : 'inventory_record_missing',
operatorActionCategory: $expectsPolicy ? OperatorActionCategory::RunPolicySyncOrBackup : OperatorActionCategory::RunInventorySync,
structural: false,
retryable: false,
sourceModelExpected: $descriptor->sourceModelExpected,
);
}
public function structuralInventoryOnly(SubjectDescriptor $descriptor): ResolutionOutcomeRecord
{
return new ResolutionOutcomeRecord(
resolutionOutcome: ResolutionOutcome::FoundationInventoryOnly,
reasonCode: 'foundation_not_policy_backed',
operatorActionCategory: OperatorActionCategory::ProductFollowUp,
structural: true,
retryable: false,
sourceModelExpected: $descriptor->sourceModelExpected,
sourceModelFound: 'inventory',
);
}
public function invalidSubject(SubjectDescriptor $descriptor): ResolutionOutcomeRecord
{
return new ResolutionOutcomeRecord(
resolutionOutcome: ResolutionOutcome::InvalidSubject,
reasonCode: 'invalid_subject',
operatorActionCategory: OperatorActionCategory::InspectSubjectMapping,
structural: false,
retryable: false,
sourceModelExpected: $descriptor->sourceModelExpected,
);
}
public function duplicateSubject(SubjectDescriptor $descriptor): ResolutionOutcomeRecord
{
return new ResolutionOutcomeRecord(
resolutionOutcome: ResolutionOutcome::DuplicateSubject,
reasonCode: 'duplicate_subject',
operatorActionCategory: OperatorActionCategory::InspectSubjectMapping,
structural: false,
retryable: false,
sourceModelExpected: $descriptor->sourceModelExpected,
);
}
public function ambiguousMatch(SubjectDescriptor $descriptor): ResolutionOutcomeRecord
{
return new ResolutionOutcomeRecord(
resolutionOutcome: ResolutionOutcome::AmbiguousMatch,
reasonCode: 'ambiguous_match',
operatorActionCategory: OperatorActionCategory::InspectSubjectMapping,
structural: false,
retryable: false,
sourceModelExpected: $descriptor->sourceModelExpected,
);
}
public function invalidSupportConfiguration(SupportCapabilityRecord $capability): ResolutionOutcomeRecord
{
return new ResolutionOutcomeRecord(
resolutionOutcome: ResolutionOutcome::InvalidSupportConfig,
reasonCode: 'invalid_support_config',
operatorActionCategory: OperatorActionCategory::ProductFollowUp,
structural: true,
retryable: false,
sourceModelExpected: $capability->sourceModelExpected,
);
}
public function throttled(SubjectDescriptor $descriptor): ResolutionOutcomeRecord
{
return new ResolutionOutcomeRecord(
resolutionOutcome: ResolutionOutcome::Throttled,
reasonCode: 'throttled',
operatorActionCategory: OperatorActionCategory::Retry,
structural: false,
retryable: true,
sourceModelExpected: $descriptor->sourceModelExpected,
);
}
public function captureFailed(SubjectDescriptor $descriptor, bool $retryable = false): ResolutionOutcomeRecord
{
return new ResolutionOutcomeRecord(
resolutionOutcome: $retryable ? ResolutionOutcome::RetryableCaptureFailure : ResolutionOutcome::CaptureFailed,
reasonCode: $retryable ? 'retryable_capture_failure' : 'capture_failed',
operatorActionCategory: OperatorActionCategory::Retry,
structural: false,
retryable: $retryable,
sourceModelExpected: $descriptor->sourceModelExpected,
);
}
public function budgetExhausted(SubjectDescriptor $descriptor): ResolutionOutcomeRecord
{
return new ResolutionOutcomeRecord(
resolutionOutcome: ResolutionOutcome::BudgetExhausted,
reasonCode: 'budget_exhausted',
operatorActionCategory: OperatorActionCategory::Retry,
structural: false,
retryable: true,
sourceModelExpected: $descriptor->sourceModelExpected,
);
}
private function describe(string $operation, string $policyType, ?string $subjectExternalId = null, ?string $subjectKey = null): SubjectDescriptor
{
$capability = $this->capability($policyType);
$resolvedSubjectKey = $this->normalizeSubjectKey($policyType, $subjectExternalId, $subjectKey);
return new SubjectDescriptor(
policyType: $policyType,
subjectExternalId: $subjectExternalId !== null && trim($subjectExternalId) !== '' ? trim($subjectExternalId) : null,
subjectKey: $resolvedSubjectKey,
subjectClass: $capability->subjectClass,
resolutionPath: $capability->resolutionPath,
supportMode: $capability->supportModeFor($operation),
sourceModelExpected: $capability->sourceModelExpected,
);
}
private function normalizeSubjectKey(string $policyType, ?string $subjectExternalId, ?string $subjectKey): string
{
$trimmedSubjectKey = is_string($subjectKey) ? trim($subjectKey) : '';
if ($trimmedSubjectKey !== '') {
return $trimmedSubjectKey;
}
$generated = BaselineSubjectKey::forPolicy($policyType, subjectExternalId: $subjectExternalId);
if (is_string($generated) && $generated !== '') {
return $generated;
}
$fallbackExternalId = is_string($subjectExternalId) && trim($subjectExternalId) !== ''
? trim($subjectExternalId)
: 'unknown';
return trim($policyType).'|'.$fallbackExternalId;
}
}

View File

@ -0,0 +1,67 @@
<?php
declare(strict_types=1);
namespace App\Support\Baselines;
use InvalidArgumentException;
final class SupportCapabilityRecord
{
/**
* @param non-empty-string $policyType
* @param 'supported'|'limited'|'unsupported' $compareCapability
* @param 'supported'|'limited'|'unsupported' $captureCapability
* @param 'policy'|'inventory'|'derived'|null $sourceModelExpected
*/
public function __construct(
public readonly string $policyType,
public readonly SubjectClass $subjectClass,
public readonly string $compareCapability,
public readonly string $captureCapability,
public readonly ResolutionPath $resolutionPath,
public readonly bool $configSupported,
public readonly bool $runtimeValid,
public readonly ?string $sourceModelExpected = null,
) {}
/**
* @return 'supported'|'limited'|'excluded'|'invalid_support_config'
*/
public function supportModeFor(string $operation): string
{
$capability = match ($operation) {
'compare' => $this->compareCapability,
'capture' => $this->captureCapability,
default => throw new InvalidArgumentException('Unsupported operation ['.$operation.'].'),
};
if ($this->configSupported && ! $this->runtimeValid) {
return 'invalid_support_config';
}
return match ($capability) {
'supported', 'limited' => $capability,
default => 'excluded',
};
}
public function allows(string $operation): bool
{
return in_array($this->supportModeFor($operation), ['supported', 'limited'], true);
}
public function toArray(): array
{
return [
'policy_type' => $this->policyType,
'subject_class' => $this->subjectClass->value,
'compare_capability' => $this->compareCapability,
'capture_capability' => $this->captureCapability,
'resolution_path' => $this->resolutionPath->value,
'config_supported' => $this->configSupported,
'runtime_valid' => $this->runtimeValid,
'source_model_expected' => $this->sourceModelExpected,
];
}
}

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

@ -4,6 +4,9 @@
namespace App\Support\Inventory;
use App\Support\Baselines\ResolutionPath;
use App\Support\Baselines\SubjectClass;
class InventoryPolicyTypeMeta
{
/**
@ -175,4 +178,141 @@ public static function baselineCompareLabel(?string $type): ?string
return static::label($type);
}
/**
* @return array{
* config_supported: bool,
* runtime_valid: bool,
* subject_class: string,
* resolution_path: string,
* compare_capability: string,
* capture_capability: string,
* source_model_expected: 'policy'|'inventory'|'derived'|null
* }
*/
public static function baselineSupportContract(?string $type): array
{
$contract = static::defaultBaselineSupportContract($type);
$resolution = static::baselineCompareMeta($type)['resolution'] ?? null;
if (is_array($resolution)) {
$contract = array_replace($contract, array_filter([
'subject_class' => is_string($resolution['subject_class'] ?? null) ? $resolution['subject_class'] : null,
'resolution_path' => is_string($resolution['resolution_path'] ?? null) ? $resolution['resolution_path'] : null,
'compare_capability' => is_string($resolution['compare_capability'] ?? null) ? $resolution['compare_capability'] : null,
'capture_capability' => is_string($resolution['capture_capability'] ?? null) ? $resolution['capture_capability'] : null,
'source_model_expected' => is_string($resolution['source_model_expected'] ?? null) ? $resolution['source_model_expected'] : null,
], static fn (mixed $value): bool => $value !== null));
}
$subjectClass = SubjectClass::tryFrom((string) ($contract['subject_class'] ?? ''));
$resolutionPath = ResolutionPath::tryFrom((string) ($contract['resolution_path'] ?? ''));
$compareCapability = in_array($contract['compare_capability'] ?? null, ['supported', 'limited', 'unsupported'], true)
? (string) $contract['compare_capability']
: 'unsupported';
$captureCapability = in_array($contract['capture_capability'] ?? null, ['supported', 'limited', 'unsupported'], true)
? (string) $contract['capture_capability']
: 'unsupported';
$sourceModelExpected = in_array($contract['source_model_expected'] ?? null, ['policy', 'inventory', 'derived'], true)
? (string) $contract['source_model_expected']
: null;
$runtimeValid = $subjectClass instanceof SubjectClass
&& $resolutionPath instanceof ResolutionPath
&& static::pathMatchesSubjectClass($subjectClass, $resolutionPath)
&& static::pathMatchesExpectedSource($resolutionPath, $sourceModelExpected);
if (! $runtimeValid) {
$compareCapability = 'unsupported';
$captureCapability = 'unsupported';
}
return [
'config_supported' => (bool) ($contract['config_supported'] ?? false),
'runtime_valid' => $runtimeValid,
'subject_class' => ($subjectClass ?? SubjectClass::Derived)->value,
'resolution_path' => ($resolutionPath ?? ResolutionPath::Derived)->value,
'compare_capability' => $compareCapability,
'capture_capability' => $captureCapability,
'source_model_expected' => $sourceModelExpected,
];
}
/**
* @return array{
* config_supported: bool,
* subject_class: string,
* resolution_path: string,
* compare_capability: string,
* capture_capability: string,
* source_model_expected: 'policy'|'inventory'|'derived'|null
* }
*/
private static function defaultBaselineSupportContract(?string $type): array
{
if (filled($type) && ! static::isFoundation($type) && static::metaFor($type) !== []) {
return [
'config_supported' => true,
'subject_class' => SubjectClass::PolicyBacked->value,
'resolution_path' => ResolutionPath::Policy->value,
'compare_capability' => 'supported',
'capture_capability' => 'supported',
'source_model_expected' => 'policy',
];
}
if (static::isFoundation($type)) {
$supported = (bool) (static::baselineCompareMeta($type)['supported'] ?? false);
$identityStrategy = static::baselineCompareIdentityStrategy($type);
$usesPolicyPath = $identityStrategy === 'external_id';
return [
'config_supported' => $supported,
'subject_class' => SubjectClass::FoundationBacked->value,
'resolution_path' => $usesPolicyPath
? ResolutionPath::FoundationPolicy->value
: ResolutionPath::FoundationInventory->value,
'compare_capability' => ! $supported
? 'unsupported'
: ($usesPolicyPath ? 'supported' : 'limited'),
'capture_capability' => ! $supported
? 'unsupported'
: ($usesPolicyPath ? 'supported' : 'limited'),
'source_model_expected' => $usesPolicyPath ? 'policy' : 'inventory',
];
}
return [
'config_supported' => false,
'subject_class' => SubjectClass::Derived->value,
'resolution_path' => ResolutionPath::Derived->value,
'compare_capability' => 'unsupported',
'capture_capability' => 'unsupported',
'source_model_expected' => 'derived',
];
}
private static function pathMatchesSubjectClass(SubjectClass $subjectClass, ResolutionPath $resolutionPath): bool
{
return match ($subjectClass) {
SubjectClass::PolicyBacked => $resolutionPath === ResolutionPath::Policy,
SubjectClass::InventoryBacked => $resolutionPath === ResolutionPath::Inventory,
SubjectClass::FoundationBacked => in_array($resolutionPath, [
ResolutionPath::FoundationInventory,
ResolutionPath::FoundationPolicy,
], true),
SubjectClass::Derived => $resolutionPath === ResolutionPath::Derived,
};
}
private static function pathMatchesExpectedSource(ResolutionPath $resolutionPath, ?string $sourceModelExpected): bool
{
return match ($resolutionPath) {
ResolutionPath::Policy,
ResolutionPath::FoundationPolicy => $sourceModelExpected === 'policy',
ResolutionPath::Inventory,
ResolutionPath::FoundationInventory => $sourceModelExpected === 'inventory',
ResolutionPath::Derived => $sourceModelExpected === 'derived',
};
}
}

View File

@ -378,7 +378,7 @@ private function resolveBaselineProfileRule(NavigationMatrixRule $rule, Baseline
return match ($rule->relationKey) {
'baseline_snapshot' => $this->baselineSnapshotEntry(
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,
),
default => null,

View File

@ -121,4 +121,12 @@ public static function isGovernanceArtifactOperation(string $operationType): boo
{
return self::governanceArtifactFamily($operationType) !== null;
}
public static function supportsOperatorExplanation(string $operationType): bool
{
$operationType = trim($operationType);
return self::isGovernanceArtifactOperation($operationType)
|| $operationType === 'baseline_compare';
}
}

View File

@ -0,0 +1,83 @@
<?php
declare(strict_types=1);
namespace App\Support\Operations;
use App\Support\ReasonTranslation\NextStepOption;
use App\Support\ReasonTranslation\ReasonResolutionEnvelope;
enum LifecycleReconciliationReason: string
{
case StaleQueued = 'run.stale_queued';
case StaleRunning = 'run.stale_running';
case InfrastructureTimeoutOrAbandonment = 'run.infrastructure_timeout_or_abandonment';
case QueueFailureBridge = 'run.queue_failure_bridge';
case AdapterOutOfSync = 'run.adapter_out_of_sync';
public function operatorLabel(): string
{
return match ($this) {
self::StaleQueued => 'Run never started',
self::StaleRunning => 'Run stopped reporting progress',
self::InfrastructureTimeoutOrAbandonment => 'Infrastructure ended the run',
self::QueueFailureBridge => 'Queue failure was reconciled',
self::AdapterOutOfSync => 'Lifecycle was reconciled from related records',
};
}
public function shortExplanation(): string
{
return match ($this) {
self::StaleQueued => 'The run stayed queued past its lifecycle window and was marked failed.',
self::StaleRunning => 'The run stayed active past its lifecycle window and was marked failed.',
self::InfrastructureTimeoutOrAbandonment => 'Queue infrastructure ended the job before normal completion could update the run.',
self::QueueFailureBridge => 'The platform bridged a queue failure back to the owning run and marked it failed.',
self::AdapterOutOfSync => 'A related restore record reached terminal truth before the operation run was updated.',
};
}
public function actionability(): string
{
return match ($this) {
self::AdapterOutOfSync => 'non_actionable',
default => 'retryable_transient',
};
}
/**
* @return array<int, NextStepOption>
*/
public function nextSteps(): array
{
return match ($this) {
self::AdapterOutOfSync => [
NextStepOption::instruction('Review the related restore record before deciding whether to run the workflow again.'),
],
default => [
NextStepOption::instruction('Review worker health and logs before retrying this operation.'),
],
};
}
public function defaultMessage(): string
{
return $this->shortExplanation();
}
/**
* @param array<string, mixed> $context
*/
public function toReasonResolutionEnvelope(string $surface = 'detail', array $context = []): ReasonResolutionEnvelope
{
return new ReasonResolutionEnvelope(
internalCode: $this->value,
operatorLabel: $this->operatorLabel(),
shortExplanation: $this->shortExplanation(),
actionability: $this->actionability(),
nextSteps: $this->nextSteps(),
showNoActionNeeded: false,
diagnosticCodeLabel: $this->value,
);
}
}

View File

@ -0,0 +1,152 @@
<?php
declare(strict_types=1);
namespace App\Support\Operations;
use Illuminate\Support\Arr;
final class OperationLifecyclePolicy
{
/**
* @return array<string, array{
* job_class?: class-string,
* queued_stale_after_seconds?: int,
* running_stale_after_seconds?: int,
* expected_max_runtime_seconds?: int,
* direct_failed_bridge?: bool,
* scheduled_reconciliation?: bool
* }>
*/
public function coveredTypes(): array
{
$coveredTypes = config('tenantpilot.operations.lifecycle.covered_types', []);
return is_array($coveredTypes) ? $coveredTypes : [];
}
/**
* @return array{
* job_class?: class-string,
* queued_stale_after_seconds:int,
* running_stale_after_seconds:int,
* expected_max_runtime_seconds:?int,
* direct_failed_bridge:bool,
* scheduled_reconciliation:bool
* }|null
*/
public function definition(string $operationType): ?array
{
$operationType = trim($operationType);
if ($operationType === '') {
return null;
}
$definition = $this->coveredTypes()[$operationType] ?? null;
if (! is_array($definition)) {
return null;
}
return [
'job_class' => is_string($definition['job_class'] ?? null) ? $definition['job_class'] : null,
'queued_stale_after_seconds' => max(1, (int) ($definition['queued_stale_after_seconds'] ?? 300)),
'running_stale_after_seconds' => max(1, (int) ($definition['running_stale_after_seconds'] ?? 900)),
'expected_max_runtime_seconds' => is_numeric($definition['expected_max_runtime_seconds'] ?? null)
? max(1, (int) $definition['expected_max_runtime_seconds'])
: null,
'direct_failed_bridge' => (bool) ($definition['direct_failed_bridge'] ?? false),
'scheduled_reconciliation' => (bool) ($definition['scheduled_reconciliation'] ?? true),
];
}
public function supports(string $operationType): bool
{
return $this->definition($operationType) !== null;
}
/**
* @return list<string>
*/
public function coveredTypeNames(): array
{
return array_values(array_keys($this->coveredTypes()));
}
public function queuedStaleAfterSeconds(string $operationType): int
{
return (int) ($this->definition($operationType)['queued_stale_after_seconds'] ?? 300);
}
public function runningStaleAfterSeconds(string $operationType): int
{
return (int) ($this->definition($operationType)['running_stale_after_seconds'] ?? 900);
}
public function expectedMaxRuntimeSeconds(string $operationType): ?int
{
$expectedMaxRuntimeSeconds = $this->definition($operationType)['expected_max_runtime_seconds'] ?? null;
return is_int($expectedMaxRuntimeSeconds) ? $expectedMaxRuntimeSeconds : null;
}
public function requiresDirectFailedBridge(string $operationType): bool
{
return (bool) ($this->definition($operationType)['direct_failed_bridge'] ?? false);
}
public function supportsScheduledReconciliation(string $operationType): bool
{
return (bool) ($this->definition($operationType)['scheduled_reconciliation'] ?? false);
}
public function reconciliationBatchLimit(): int
{
return max(1, (int) config('tenantpilot.operations.lifecycle.reconciliation.batch_limit', 100));
}
public function reconciliationScheduleMinutes(): int
{
return max(1, (int) config('tenantpilot.operations.lifecycle.reconciliation.schedule_minutes', 5));
}
public function retryAfterSafetyMarginSeconds(): int
{
return max(1, (int) config('queue.lifecycle_invariants.retry_after_safety_margin', 30));
}
public function queueConnection(string $operationType): ?string
{
$jobClass = $this->jobClass($operationType);
if ($jobClass === null || ! class_exists($jobClass)) {
return null;
}
$connection = Arr::get(get_class_vars($jobClass), 'connection');
return is_string($connection) && trim($connection) !== '' ? trim($connection) : config('queue.default');
}
public function queueRetryAfterSeconds(?string $connection = null): ?int
{
$connection = is_string($connection) && trim($connection) !== '' ? trim($connection) : (string) config('queue.default', 'database');
$retryAfter = config("queue.connections.{$connection}.retry_after");
if (is_numeric($retryAfter)) {
return max(1, (int) $retryAfter);
}
$databaseRetryAfter = config('queue.connections.database.retry_after');
return is_numeric($databaseRetryAfter) ? max(1, (int) $databaseRetryAfter) : null;
}
public function jobClass(string $operationType): ?string
{
$jobClass = $this->definition($operationType)['job_class'] ?? null;
return is_string($jobClass) && $jobClass !== '' ? $jobClass : null;
}
}

View File

@ -2,10 +2,16 @@
namespace App\Support\Operations;
use App\Models\OperationRun;
use App\Support\Auth\Capabilities;
final class OperationRunCapabilityResolver
{
public function requiredCapabilityForRun(OperationRun $run): ?string
{
return $this->requiredCapabilityForType((string) $run->type);
}
public function requiredCapabilityForType(string $operationType): ?string
{
$operationType = trim($operationType);

View File

@ -0,0 +1,69 @@
<?php
declare(strict_types=1);
namespace App\Support\Operations;
use App\Models\OperationRun;
use App\Support\OperationRunStatus;
enum OperationRunFreshnessState: string
{
case FreshActive = 'fresh_active';
case LikelyStale = 'likely_stale';
case ReconciledFailed = 'reconciled_failed';
case TerminalNormal = 'terminal_normal';
case Unknown = 'unknown';
public static function forRun(OperationRun $run, ?OperationLifecyclePolicy $policy = null): self
{
$policy ??= app(OperationLifecyclePolicy::class);
if ((string) $run->status === OperationRunStatus::Completed->value) {
return $run->isLifecycleReconciled() ? self::ReconciledFailed : self::TerminalNormal;
}
if (! $policy->supports((string) $run->type)) {
return self::Unknown;
}
if ((string) $run->status === OperationRunStatus::Queued->value) {
if ($run->started_at !== null || $run->created_at === null) {
return self::Unknown;
}
return $run->created_at->lte(now()->subSeconds($policy->queuedStaleAfterSeconds((string) $run->type)))
? self::LikelyStale
: self::FreshActive;
}
if ((string) $run->status === OperationRunStatus::Running->value) {
$startedAt = $run->started_at ?? $run->created_at;
if ($startedAt === null) {
return self::Unknown;
}
return $startedAt->lte(now()->subSeconds($policy->runningStaleAfterSeconds((string) $run->type)))
? self::LikelyStale
: self::FreshActive;
}
return self::Unknown;
}
public function isFreshActive(): bool
{
return $this === self::FreshActive;
}
public function isLikelyStale(): bool
{
return $this === self::LikelyStale;
}
public function isReconciledFailed(): bool
{
return $this === self::ReconciledFailed;
}
}

View File

@ -7,8 +7,11 @@
use App\Models\OperationRun;
use App\Models\Tenant;
use App\Support\OperationCatalog;
use App\Support\Operations\OperationRunFreshnessState;
use App\Support\ReasonTranslation\ReasonPresenter;
use App\Support\RedactionIntegrity;
use App\Support\Ui\GovernanceArtifactTruth\ArtifactTruthPresenter;
use App\Support\Ui\OperatorExplanation\OperatorExplanationPattern;
use Filament\Notifications\Notification as FilamentNotification;
final class OperationUxPresenter
@ -98,10 +101,32 @@ public static function surfaceGuidance(OperationRun $run): ?string
$uxStatus = OperationStatusNormalizer::toUxStatus($run->status, $run->outcome);
$reasonEnvelope = self::reasonEnvelope($run);
$reasonGuidance = app(ReasonPresenter::class)->guidance($reasonEnvelope);
$operatorExplanationGuidance = self::operatorExplanationGuidance($run);
$nextStepLabel = self::firstNextStepLabel($run);
$freshnessState = self::freshnessState($run);
if (in_array($uxStatus, ['blocked', 'failed', 'partial'], true) && $reasonGuidance !== null) {
return $reasonGuidance;
if ($freshnessState->isLikelyStale()) {
return 'This run is past its lifecycle window. Review worker health and logs before retrying from the start surface.';
}
if ($freshnessState->isReconciledFailed()) {
return $operatorExplanationGuidance
?? $reasonGuidance
?? 'TenantPilot reconciled this run after lifecycle truth was lost. Review the recorded evidence before retrying.';
}
if (in_array($uxStatus, ['blocked', 'failed', 'partial'], true)) {
if ($operatorExplanationGuidance !== null) {
return $operatorExplanationGuidance;
}
if ($reasonGuidance !== null) {
return $reasonGuidance;
}
}
if ($uxStatus === 'succeeded' && $operatorExplanationGuidance !== null) {
return $operatorExplanationGuidance;
}
return match ($uxStatus) {
@ -124,15 +149,44 @@ public static function surfaceGuidance(OperationRun $run): ?string
public static function surfaceFailureDetail(OperationRun $run): ?string
{
$operatorExplanation = self::governanceOperatorExplanation($run);
if (is_string($operatorExplanation?->dominantCauseExplanation) && trim($operatorExplanation->dominantCauseExplanation) !== '') {
return trim($operatorExplanation->dominantCauseExplanation);
}
$failureMessage = (string) (($run->failure_summary[0]['message'] ?? '') ?? '');
$sanitizedFailureMessage = self::sanitizeFailureMessage($failureMessage);
if ($sanitizedFailureMessage !== null) {
return $sanitizedFailureMessage;
}
$reasonEnvelope = self::reasonEnvelope($run);
if ($reasonEnvelope !== null) {
return $reasonEnvelope->shortExplanation;
}
$failureMessage = (string) (($run->failure_summary[0]['message'] ?? '') ?? '');
if (self::freshnessState($run)->isLikelyStale()) {
return 'This run is no longer within its normal lifecycle window and may no longer be progressing.';
}
return self::sanitizeFailureMessage($failureMessage);
return null;
}
public static function freshnessState(OperationRun $run): OperationRunFreshnessState
{
return $run->freshnessState();
}
public static function lifecycleAttentionSummary(OperationRun $run): ?string
{
return match (self::freshnessState($run)) {
OperationRunFreshnessState::LikelyStale => 'Likely stale',
OperationRunFreshnessState::ReconciledFailed => 'Automatically reconciled',
default => null,
};
}
/**
@ -142,6 +196,15 @@ private static function terminalPresentation(OperationRun $run): array
{
$uxStatus = OperationStatusNormalizer::toUxStatus($run->status, $run->outcome);
$reasonEnvelope = self::reasonEnvelope($run);
$freshnessState = self::freshnessState($run);
if ($freshnessState->isReconciledFailed()) {
return [
'titleSuffix' => 'was automatically reconciled',
'body' => $reasonEnvelope?->operatorLabel ?? 'Automatically reconciled after infrastructure failure.',
'status' => 'danger',
];
}
return match ($uxStatus) {
'succeeded' => [
@ -223,4 +286,32 @@ private static function reasonEnvelope(OperationRun $run): ?\App\Support\ReasonT
{
return app(ReasonPresenter::class)->forOperationRun($run, 'notification');
}
private static function operatorExplanationGuidance(OperationRun $run): ?string
{
$operatorExplanation = self::governanceOperatorExplanation($run);
if (! is_string($operatorExplanation?->nextActionText) || trim($operatorExplanation->nextActionText) === '') {
return null;
}
$text = trim($operatorExplanation->nextActionText);
if (str_ends_with($text, '.')) {
return $text;
}
return $text === 'No action needed'
? 'No action needed.'
: 'Next step: '.$text.'.';
}
private static function governanceOperatorExplanation(OperationRun $run): ?OperatorExplanationPattern
{
if (! $run->supportsOperatorExplanation()) {
return null;
}
return app(ArtifactTruthPresenter::class)->forOperationRun($run)?->operatorExplanation;
}
}

View File

@ -6,6 +6,7 @@
use App\Models\OperationRun;
use App\Support\OperationCatalog;
use App\Support\Operations\OperationRunFreshnessState;
use Illuminate\Support\Facades\Cache;
final class RunDurationInsights
@ -118,6 +119,10 @@ public static function expectedHuman(OperationRun $run): ?string
public static function stuckGuidance(OperationRun $run): ?string
{
if ($run->freshnessState() === OperationRunFreshnessState::LikelyStale) {
return 'Past the lifecycle window. Review worker health and logs before retrying.';
}
$uxStatus = OperationStatusNormalizer::toUxStatus($run->status, $run->outcome);
if (! in_array($uxStatus, ['queued', 'running'], true)) {

View File

@ -3,7 +3,9 @@
namespace App\Support\OpsUx;
use App\Services\Intune\SecretClassificationService;
use App\Support\Baselines\BaselineReasonCodes;
use App\Support\Operations\ExecutionDenialReasonCode;
use App\Support\Operations\LifecycleReconciliationReason;
use App\Support\Providers\ProviderReasonCodes;
final class RunFailureSanitizer
@ -130,7 +132,15 @@ public static function isStructuredOperatorReasonCode(string $candidate): bool
ExecutionDenialReasonCode::cases(),
);
return ProviderReasonCodes::isKnown($candidate) || in_array($candidate, $executionDenialReasonCodes, true);
$lifecycleReasonCodes = array_map(
static fn (LifecycleReconciliationReason $reasonCode): string => $reasonCode->value,
LifecycleReconciliationReason::cases(),
);
return ProviderReasonCodes::isKnown($candidate)
|| BaselineReasonCodes::isKnown($candidate)
|| in_array($candidate, $executionDenialReasonCodes, true)
|| in_array($candidate, $lifecycleReasonCodes, true);
}
public static function sanitizeMessage(string $message): string

View File

@ -5,6 +5,7 @@
namespace App\Support\ReasonTranslation;
use App\Support\ReasonTranslation\Contracts\TranslatesReasonCode;
use App\Support\Ui\OperatorExplanation\TrustworthinessLevel;
use Illuminate\Support\Str;
final class FallbackReasonTranslator implements TranslatesReasonCode
@ -43,6 +44,8 @@ public function translate(string $reasonCode, string $surface = 'detail', array
nextSteps: $nextSteps,
showNoActionNeeded: $actionability === 'non_actionable',
diagnosticCodeLabel: $normalizedCode,
trustImpact: $this->trustImpactFor($actionability),
absencePattern: $this->absencePatternFor($normalizedCode, $actionability),
);
}
@ -109,4 +112,36 @@ private function fallbackNextStepsFor(string $actionability): array
default => [NextStepOption::instruction('Review access and configuration before retrying.')],
};
}
private function trustImpactFor(string $actionability): string
{
return match ($actionability) {
'non_actionable' => TrustworthinessLevel::Trustworthy->value,
'retryable_transient' => TrustworthinessLevel::LimitedConfidence->value,
default => TrustworthinessLevel::Unusable->value,
};
}
private function absencePatternFor(string $reasonCode, string $actionability): ?string
{
$normalizedCode = strtolower($reasonCode);
if (str_contains($normalizedCode, 'suppressed')) {
return 'suppressed_output';
}
if (str_contains($normalizedCode, 'missing') || str_contains($normalizedCode, 'stale')) {
return 'missing_input';
}
if ($actionability === 'prerequisite_missing') {
return 'blocked_prerequisite';
}
if ($actionability === 'non_actionable') {
return 'true_no_result';
}
return 'unavailable';
}
}

View File

@ -8,6 +8,7 @@
use App\Models\ProviderConnection;
use App\Models\Tenant;
use App\Support\Operations\ExecutionDenialReasonCode;
use App\Support\Operations\LifecycleReconciliationReason;
use App\Support\Providers\ProviderReasonCodes;
use App\Support\Providers\ProviderReasonTranslator;
use App\Support\RbacReason;
@ -24,14 +25,16 @@ public function __construct(
public function forOperationRun(OperationRun $run, string $surface = 'detail'): ?ReasonResolutionEnvelope
{
$context = is_array($run->context) ? $run->context : [];
$storedTranslation = is_array($context['reason_translation'] ?? null) ? $context['reason_translation'] : null;
$storedTranslation = $this->storedOperationRunTranslation($context);
if ($storedTranslation !== null) {
$storedEnvelope = ReasonResolutionEnvelope::fromArray($storedTranslation);
if ($storedEnvelope instanceof ReasonResolutionEnvelope) {
if ($storedEnvelope->nextSteps === [] && is_array($context['next_steps'] ?? null)) {
return $storedEnvelope->withNextSteps(NextStepOption::collect($context['next_steps']));
$nextSteps = $this->operationRunNextSteps($context);
if ($storedEnvelope->nextSteps === [] && $nextSteps !== []) {
return $storedEnvelope->withNextSteps($nextSteps);
}
return $storedEnvelope;
@ -39,7 +42,8 @@ public function forOperationRun(OperationRun $run, string $surface = 'detail'):
}
$contextReasonCode = data_get($context, 'execution_legitimacy.reason_code')
?? data_get($context, 'reason_code');
?? data_get($context, 'reason_code')
?? data_get($context, 'baseline_compare.reason_code');
if (is_string($contextReasonCode) && trim($contextReasonCode) !== '') {
return $this->translateOperationRunReason(trim($contextReasonCode), $surface, $context);
@ -67,11 +71,33 @@ public function forOperationRun(OperationRun $run, string $surface = 'detail'):
return $envelope;
}
$legacyNextSteps = is_array($context['next_steps'] ?? null) ? NextStepOption::collect($context['next_steps']) : [];
$legacyNextSteps = $this->operationRunNextSteps($context);
return $legacyNextSteps !== [] ? $envelope->withNextSteps($legacyNextSteps) : $envelope;
}
/**
* @param array<string, mixed> $context
* @return array<string, mixed>|null
*/
private function storedOperationRunTranslation(array $context): ?array
{
$storedTranslation = $context['reason_translation'] ?? data_get($context, 'baseline_compare.reason_translation');
return is_array($storedTranslation) ? $storedTranslation : null;
}
/**
* @param array<string, mixed> $context
* @return array<int, NextStepOption>
*/
private function operationRunNextSteps(array $context): array
{
$nextSteps = $context['next_steps'] ?? data_get($context, 'baseline_compare.next_steps');
return is_array($nextSteps) ? NextStepOption::collect($nextSteps) : [];
}
/**
* @param array<string, mixed> $context
*/
@ -91,6 +117,7 @@ private function isDirectlyTranslatableOperationReason(string $reasonCode): bool
return ProviderReasonCodes::isKnown($reasonCode)
|| ExecutionDenialReasonCode::tryFrom($reasonCode) instanceof ExecutionDenialReasonCode
|| LifecycleReconciliationReason::tryFrom($reasonCode) instanceof LifecycleReconciliationReason
|| TenantOperabilityReasonCode::tryFrom($reasonCode) instanceof TenantOperabilityReasonCode
|| RbacReason::tryFrom($reasonCode) instanceof RbacReason;
}
@ -167,6 +194,26 @@ public function shortExplanation(?ReasonResolutionEnvelope $envelope): ?string
return $envelope?->shortExplanation;
}
public function dominantCauseLabel(?ReasonResolutionEnvelope $envelope): ?string
{
return $envelope?->operatorLabel;
}
public function dominantCauseExplanation(?ReasonResolutionEnvelope $envelope): ?string
{
return $envelope?->shortExplanation;
}
public function trustImpact(?ReasonResolutionEnvelope $envelope): ?string
{
return $envelope?->trustImpact;
}
public function absencePattern(?ReasonResolutionEnvelope $envelope): ?string
{
return $envelope?->absencePattern;
}
public function guidance(?ReasonResolutionEnvelope $envelope): ?string
{
return $envelope?->guidanceText();

View File

@ -4,6 +4,7 @@
namespace App\Support\ReasonTranslation;
use App\Support\Ui\OperatorExplanation\TrustworthinessLevel;
use InvalidArgumentException;
final readonly class ReasonResolutionEnvelope
@ -19,6 +20,8 @@ public function __construct(
public array $nextSteps = [],
public bool $showNoActionNeeded = false,
public ?string $diagnosticCodeLabel = null,
public string $trustImpact = TrustworthinessLevel::LimitedConfidence->value,
public ?string $absencePattern = null,
) {
if (trim($this->internalCode) === '') {
throw new InvalidArgumentException('Reason envelopes must preserve an internal code.');
@ -41,6 +44,24 @@ public function __construct(
throw new InvalidArgumentException('Unsupported reason actionability: '.$this->actionability);
}
if (! in_array($this->trustImpact, array_map(
static fn (TrustworthinessLevel $level): string => $level->value,
TrustworthinessLevel::cases(),
), true)) {
throw new InvalidArgumentException('Unsupported reason trust impact: '.$this->trustImpact);
}
if ($this->absencePattern !== null && ! in_array($this->absencePattern, [
'none',
'true_no_result',
'missing_input',
'blocked_prerequisite',
'suppressed_output',
'unavailable',
], true)) {
throw new InvalidArgumentException('Unsupported reason absence pattern: '.$this->absencePattern);
}
foreach ($this->nextSteps as $nextStep) {
if (! $nextStep instanceof NextStepOption) {
throw new InvalidArgumentException('Reason envelopes only support NextStepOption instances.');
@ -70,6 +91,12 @@ public static function fromArray(array $data): ?self
$diagnosticCodeLabel = is_string($data['diagnostic_code_label'] ?? null)
? trim((string) $data['diagnostic_code_label'])
: (is_string($data['diagnosticCodeLabel'] ?? null) ? trim((string) $data['diagnosticCodeLabel']) : null);
$trustImpact = is_string($data['trust_impact'] ?? null)
? trim((string) $data['trust_impact'])
: (is_string($data['trustImpact'] ?? null) ? trim((string) $data['trustImpact']) : TrustworthinessLevel::LimitedConfidence->value);
$absencePattern = is_string($data['absence_pattern'] ?? null)
? trim((string) $data['absence_pattern'])
: (is_string($data['absencePattern'] ?? null) ? trim((string) $data['absencePattern']) : null);
if ($internalCode === '' || $operatorLabel === '' || $shortExplanation === '' || $actionability === '') {
return null;
@ -83,6 +110,8 @@ public static function fromArray(array $data): ?self
nextSteps: $nextSteps,
showNoActionNeeded: $showNoActionNeeded,
diagnosticCodeLabel: $diagnosticCodeLabel !== '' ? $diagnosticCodeLabel : null,
trustImpact: $trustImpact !== '' ? $trustImpact : TrustworthinessLevel::LimitedConfidence->value,
absencePattern: $absencePattern !== '' ? $absencePattern : null,
);
}
@ -99,6 +128,8 @@ public function withNextSteps(array $nextSteps): self
nextSteps: $nextSteps,
showNoActionNeeded: $this->showNoActionNeeded,
diagnosticCodeLabel: $this->diagnosticCodeLabel,
trustImpact: $this->trustImpact,
absencePattern: $this->absencePattern,
);
}
@ -179,6 +210,8 @@ public function toLegacyNextSteps(): array
* }>,
* show_no_action_needed: bool,
* diagnostic_code_label: string
* trust_impact: string,
* absence_pattern: ?string
* }
*/
public function toArray(): array
@ -194,6 +227,8 @@ public function toArray(): array
),
'show_no_action_needed' => $this->showNoActionNeeded,
'diagnostic_code_label' => $this->diagnosticCode(),
'trust_impact' => $this->trustImpact,
'absence_pattern' => $this->absencePattern,
];
}
}

View File

@ -4,11 +4,15 @@
namespace App\Support\ReasonTranslation;
use App\Support\Baselines\BaselineCompareReasonCode;
use App\Support\Baselines\BaselineReasonCodes;
use App\Support\Operations\ExecutionDenialReasonCode;
use App\Support\Operations\LifecycleReconciliationReason;
use App\Support\Providers\ProviderReasonCodes;
use App\Support\Providers\ProviderReasonTranslator;
use App\Support\RbacReason;
use App\Support\Tenants\TenantOperabilityReasonCode;
use App\Support\Ui\OperatorExplanation\TrustworthinessLevel;
final class ReasonTranslator
{
@ -43,8 +47,13 @@ public function translate(
return match (true) {
$artifactKey === ProviderReasonTranslator::ARTIFACT_KEY,
$artifactKey === null && $this->providerReasonTranslator->canTranslate($reasonCode) => $this->providerReasonTranslator->translate($reasonCode, $surface, $context),
$artifactKey === self::GOVERNANCE_ARTIFACT_TRUTH_ARTIFACT && BaselineCompareReasonCode::tryFrom($reasonCode) instanceof BaselineCompareReasonCode => $this->translateBaselineCompareReason($reasonCode),
$artifactKey === null && BaselineCompareReasonCode::tryFrom($reasonCode) instanceof BaselineCompareReasonCode => $this->translateBaselineCompareReason($reasonCode),
$artifactKey === self::GOVERNANCE_ARTIFACT_TRUTH_ARTIFACT && BaselineReasonCodes::isKnown($reasonCode) => $this->translateBaselineReason($reasonCode),
$artifactKey === null && BaselineReasonCodes::isKnown($reasonCode) => $this->translateBaselineReason($reasonCode),
$artifactKey === self::EXECUTION_DENIAL_ARTIFACT,
$artifactKey === null && ExecutionDenialReasonCode::tryFrom($reasonCode) instanceof ExecutionDenialReasonCode => ExecutionDenialReasonCode::tryFrom($reasonCode)?->toReasonResolutionEnvelope($surface, $context),
$artifactKey === null && LifecycleReconciliationReason::tryFrom($reasonCode) instanceof LifecycleReconciliationReason => LifecycleReconciliationReason::tryFrom($reasonCode)?->toReasonResolutionEnvelope($surface, $context),
$artifactKey === self::TENANT_OPERABILITY_ARTIFACT,
$artifactKey === null && TenantOperabilityReasonCode::tryFrom($reasonCode) instanceof TenantOperabilityReasonCode => TenantOperabilityReasonCode::tryFrom($reasonCode)?->toReasonResolutionEnvelope($surface, $context),
$artifactKey === self::RBAC_ARTIFACT,
@ -74,4 +83,184 @@ private function fallbackTranslate(
return $this->fallbackReasonTranslator->translate($reasonCode, $surface, $context);
}
private function translateBaselineReason(string $reasonCode): ReasonResolutionEnvelope
{
[$operatorLabel, $shortExplanation, $actionability, $nextStep] = match ($reasonCode) {
BaselineReasonCodes::CAPTURE_MISSING_SOURCE_TENANT => [
'Source tenant unavailable',
'The selected tenant is not available in this workspace for baseline capture.',
'prerequisite_missing',
'Select a source tenant from the same workspace before capturing again.',
],
BaselineReasonCodes::CAPTURE_PROFILE_NOT_ACTIVE => [
'Baseline profile inactive',
'Only active baseline profiles can be captured or compared.',
'prerequisite_missing',
'Activate the baseline profile before retrying this action.',
],
BaselineReasonCodes::CAPTURE_ROLLOUT_DISABLED,
BaselineReasonCodes::COMPARE_ROLLOUT_DISABLED => [
'Full-content rollout disabled',
'This workflow is disabled by rollout configuration in the current environment.',
'prerequisite_missing',
'Enable the rollout before retrying full-content baseline work.',
],
BaselineReasonCodes::SNAPSHOT_BUILDING,
BaselineReasonCodes::COMPARE_SNAPSHOT_BUILDING => [
'Baseline still building',
'The selected baseline snapshot is still building and cannot be trusted for compare yet.',
'prerequisite_missing',
'Wait for capture to finish or use the current complete snapshot instead.',
],
BaselineReasonCodes::SNAPSHOT_INCOMPLETE,
BaselineReasonCodes::COMPARE_SNAPSHOT_INCOMPLETE => [
'Baseline snapshot incomplete',
'The snapshot did not finish cleanly, so TenantPilot will not use it for compare.',
'prerequisite_missing',
'Capture a new baseline and wait for it to complete before comparing.',
],
BaselineReasonCodes::SNAPSHOT_SUPERSEDED,
BaselineReasonCodes::COMPARE_SNAPSHOT_SUPERSEDED => [
'Snapshot superseded',
'A newer complete baseline snapshot is current, so this historical snapshot is not compare input anymore.',
'prerequisite_missing',
'Use the current complete snapshot for compare instead of this historical copy.',
],
BaselineReasonCodes::SNAPSHOT_CAPTURE_FAILED => [
'Baseline capture failed',
'Snapshot capture stopped after the row was created, so the artifact remains unusable.',
'retryable_transient',
'Review the run details, then retry the capture once the failure is addressed.',
],
BaselineReasonCodes::SNAPSHOT_COMPLETION_PROOF_FAILED => [
'Completion proof failed',
'TenantPilot could not prove that every expected snapshot item was persisted successfully.',
'prerequisite_missing',
'Capture the baseline again so a complete snapshot can be finalized.',
],
BaselineReasonCodes::SNAPSHOT_LEGACY_NO_PROOF => [
'Legacy completion unproven',
'This older snapshot has no reliable completion proof, so it is blocked from compare.',
'prerequisite_missing',
'Recapture the baseline to create a complete snapshot with explicit lifecycle proof.',
],
BaselineReasonCodes::SNAPSHOT_LEGACY_CONTRADICTORY => [
'Legacy completion contradictory',
'Stored counts or producer-run evidence disagree, so TenantPilot treats this snapshot as incomplete.',
'prerequisite_missing',
'Recapture the baseline to replace this ambiguous historical snapshot.',
],
BaselineReasonCodes::COMPARE_NO_ASSIGNMENT => [
'No baseline assigned',
'This tenant has no assigned baseline profile yet.',
'prerequisite_missing',
'Assign a baseline profile to the tenant before starting compare.',
],
BaselineReasonCodes::COMPARE_PROFILE_NOT_ACTIVE => [
'Assigned baseline inactive',
'The assigned baseline profile is not active, so compare cannot start.',
'prerequisite_missing',
'Activate the assigned baseline profile or assign a different active profile.',
],
BaselineReasonCodes::COMPARE_NO_ACTIVE_SNAPSHOT,
BaselineReasonCodes::COMPARE_NO_CONSUMABLE_SNAPSHOT => [
'Current baseline unavailable',
'No complete baseline snapshot is currently available for compare.',
'prerequisite_missing',
'Capture a baseline and wait for it to complete before comparing.',
],
BaselineReasonCodes::COMPARE_NO_ELIGIBLE_TARGET => [
'No eligible compare target',
'No assigned tenant with compare access is currently available for this baseline profile.',
'prerequisite_missing',
'Assign this baseline to a tenant you can compare, or use an account with access to an assigned tenant.',
],
BaselineReasonCodes::COMPARE_INVALID_SNAPSHOT => [
'Selected snapshot unavailable',
'The requested baseline snapshot could not be found for this profile.',
'prerequisite_missing',
'Refresh the page and select a valid snapshot for this baseline profile.',
],
default => [
'Baseline workflow blocked',
'TenantPilot recorded a baseline precondition that prevents this workflow from continuing safely.',
'prerequisite_missing',
'Review the recorded baseline state before retrying.',
],
};
return new ReasonResolutionEnvelope(
internalCode: $reasonCode,
operatorLabel: $operatorLabel,
shortExplanation: $shortExplanation,
actionability: $actionability,
nextSteps: [
NextStepOption::instruction($nextStep),
],
diagnosticCodeLabel: $reasonCode,
trustImpact: BaselineReasonCodes::trustImpact($reasonCode) ?? TrustworthinessLevel::Unusable->value,
absencePattern: BaselineReasonCodes::absencePattern($reasonCode),
);
}
private function translateBaselineCompareReason(string $reasonCode): ReasonResolutionEnvelope
{
$enum = BaselineCompareReasonCode::tryFrom($reasonCode);
if (! $enum instanceof BaselineCompareReasonCode) {
return $this->fallbackReasonTranslator->translate($reasonCode) ?? new ReasonResolutionEnvelope(
internalCode: $reasonCode,
operatorLabel: 'Baseline compare needs review',
shortExplanation: 'TenantPilot recorded a baseline-compare state that needs operator review.',
actionability: 'permanent_configuration',
);
}
[$operatorLabel, $shortExplanation, $actionability, $nextStep] = match ($enum) {
BaselineCompareReasonCode::NoDriftDetected => [
'No drift detected',
'The comparison completed with enough coverage to treat the absence of drift findings as trustworthy.',
'non_actionable',
'No action needed unless you expected a newer compare result.',
],
BaselineCompareReasonCode::CoverageUnproven => [
'Coverage proof missing',
'The comparison finished, but missing coverage proof means some findings may have been suppressed for safety.',
'prerequisite_missing',
'Run inventory sync and compare again before treating this as complete.',
],
BaselineCompareReasonCode::EvidenceCaptureIncomplete => [
'Evidence capture incomplete',
'The comparison finished, but incomplete evidence capture limits how much confidence you should place in the visible result.',
'prerequisite_missing',
'Resume or rerun evidence capture before relying on this compare result.',
],
BaselineCompareReasonCode::RolloutDisabled => [
'Compare rollout disabled',
'The comparison path was limited by rollout configuration, so the result is not decision-grade.',
'prerequisite_missing',
'Enable the rollout or use the supported compare mode before retrying.',
],
BaselineCompareReasonCode::NoSubjectsInScope => [
'Nothing was eligible to compare',
'No in-scope subjects were available for evaluation, so the compare could not produce a normal result.',
'prerequisite_missing',
'Review scope selection and baseline inputs before comparing again.',
],
};
return new ReasonResolutionEnvelope(
internalCode: $reasonCode,
operatorLabel: $operatorLabel,
shortExplanation: $shortExplanation,
actionability: $actionability,
nextSteps: [
NextStepOption::instruction($nextStep),
],
diagnosticCodeLabel: $reasonCode,
trustImpact: $enum->trustworthinessLevel()->value,
absencePattern: $enum->absencePattern(),
);
}
}

View File

@ -10,6 +10,11 @@ final class EnterpriseDetailBuilder
{
private ?SummaryHeaderData $header = null;
/**
* @var array<string, mixed>|null
*/
private ?array $decisionZone = null;
/**
* @var list<DetailSectionData>
*/
@ -18,7 +23,7 @@ final class EnterpriseDetailBuilder
/**
* @var list<SupportingCardData>
*/
private array $supportingCards = [];
private array $supportingGroups = [];
/**
* @var list<TechnicalDetailData>
@ -47,6 +52,16 @@ public function header(SummaryHeaderData $header): self
return $this;
}
/**
* @param array<string, mixed> $decisionZone
*/
public function decisionZone(array $decisionZone): self
{
$this->decisionZone = $decisionZone;
return $this;
}
public function addSection(DetailSectionData ...$sections): self
{
foreach ($sections as $section) {
@ -58,8 +73,13 @@ public function addSection(DetailSectionData ...$sections): self
public function addSupportingCard(SupportingCardData ...$cards): self
{
foreach ($cards as $card) {
$this->supportingCards[] = $card;
return $this->addSupportingGroup(...$cards);
}
public function addSupportingGroup(SupportingCardData ...$groups): self
{
foreach ($groups as $group) {
$this->supportingGroups[] = $group;
}
return $this;
@ -94,13 +114,16 @@ public function build(): EnterpriseDetailPageData
resourceType: $this->resourceType,
scope: $this->scope,
header: $this->header,
decisionZone: is_array($this->decisionZone) && $this->decisionZone !== []
? $this->decisionZone
: null,
mainSections: array_values(array_filter(
$this->mainSections,
static fn (DetailSectionData $section): bool => $section->shouldRender(),
)),
supportingCards: array_values(array_filter(
$this->supportingCards,
static fn (SupportingCardData $card): bool => $card->shouldRender(),
supportingGroups: array_values(array_filter(
$this->supportingGroups,
static fn (SupportingCardData $group): bool => $group->shouldRender(),
)),
technicalSections: array_values(array_filter(
$this->technicalSections,

View File

@ -7,8 +7,25 @@
final readonly class EnterpriseDetailPageData
{
/**
* @param array{
* title?: string,
* description?: ?string,
* facts?: list<array<string, mixed>>,
* primaryNextStep?: array{
* label?: string,
* text: string,
* source: string,
* secondaryGuidance?: list<array{label: string, text: string, source: string}>
* },
* compactCounts?: array{
* summaryLine?: ?string,
* primaryFacts?: list<array<string, mixed>>,
* diagnosticFacts?: list<array<string, mixed>>
* },
* attentionNote?: ?string
* }|null $decisionZone
* @param list<DetailSectionData> $mainSections
* @param list<SupportingCardData> $supportingCards
* @param list<SupportingCardData> $supportingGroups
* @param list<TechnicalDetailData> $technicalSections
* @param list<array{title: string, description?: ?string, icon?: ?string}> $emptyStateNotes
*/
@ -16,8 +33,9 @@ public function __construct(
public string $resourceType,
public string $scope,
public SummaryHeaderData $header,
public ?array $decisionZone = null,
public array $mainSections = [],
public array $supportingCards = [],
public array $supportingGroups = [],
public array $technicalSections = [],
public array $emptyStateNotes = [],
) {}
@ -34,8 +52,9 @@ public function __construct(
* primaryActions: list<array{label: string, placement: string, url: ?string, actionName: ?string, destructive: bool, requiresConfirmation: bool, visible: bool, icon: ?string, openInNewTab: bool}>,
* descriptionHint: ?string
* },
* decisionZone: array<string, mixed>|null,
* mainSections: list<array<string, mixed>>,
* supportingCards: list<array<string, mixed>>,
* supportingGroups: list<array<string, mixed>>,
* technicalSections: list<array<string, mixed>>,
* emptyStateNotes: list<array{title: string, description?: ?string, icon?: ?string}>
* }
@ -46,13 +65,14 @@ public function toArray(): array
'resourceType' => $this->resourceType,
'scope' => $this->scope,
'header' => $this->header->toArray(),
'decisionZone' => $this->decisionZone,
'mainSections' => array_values(array_map(
static fn (DetailSectionData $section): array => $section->toArray(),
$this->mainSections,
)),
'supportingCards' => array_values(array_map(
static fn (SupportingCardData $card): array => $card->toArray(),
$this->supportingCards,
'supportingGroups' => array_values(array_map(
static fn (SupportingCardData $group): array => $group->toArray(),
$this->supportingGroups,
)),
'technicalSections' => array_values(array_map(
static fn (TechnicalDetailData $section): array => $section->toArray(),

View File

@ -8,9 +8,11 @@ final class EnterpriseDetailSectionFactory
{
/**
* @param array{label: string, color?: string, icon?: ?string, iconColor?: ?string}|null $badge
* @return array{label: string, value: string, hint?: ?string, badge?: ?array{label: string, color?: string, icon?: ?string, iconColor?: ?string}}
* @param 'default'|'danger'|'success'|'warning'|null $tone Optional color tone for the card border/value
* @param bool $mono Whether the value should be rendered in monospace font (e.g. hashes, IDs)
* @return array{label: string, value: string, hint?: ?string, badge?: ?array{label: string, color?: string, icon?: ?string, iconColor?: ?string}, tone?: string, mono?: bool}
*/
public function keyFact(string $label, mixed $value, ?string $hint = null, ?array $badge = null): array
public function keyFact(string $label, mixed $value, ?string $hint = null, ?array $badge = null, ?string $tone = null, bool $mono = false): array
{
$displayValue = match (true) {
is_bool($value) => $value ? 'Yes' : 'No',
@ -24,6 +26,8 @@ public function keyFact(string $label, mixed $value, ?string $hint = null, ?arra
'value' => $displayValue,
'hint' => $hint,
'badge' => $badge,
'tone' => $tone,
'mono' => $mono ?: null,
], static fn (mixed $item): bool => $item !== null);
}
@ -52,6 +56,92 @@ public function emptyState(string $title, ?string $description = null, ?string $
], static fn (mixed $item): bool => $item !== null);
}
/**
* @param list<array<string, mixed>> $facts
* @param array{
* label?: string,
* text: string,
* source: string,
* secondaryGuidance?: list<array{label: string, text: string, source: string}>
* } $primaryNextStep
* @param array{
* summaryLine?: ?string,
* primaryFacts?: list<array<string, mixed>>,
* diagnosticFacts?: list<array<string, mixed>>
* }|null $compactCounts
* @return array{
* title: string,
* description?: ?string,
* facts: list<array<string, mixed>>,
* primaryNextStep: array{
* label?: string,
* text: string,
* source: string,
* secondaryGuidance?: list<array{label: string, text: string, source: string}>
* },
* compactCounts?: array{
* summaryLine?: ?string,
* primaryFacts?: list<array<string, mixed>>,
* diagnosticFacts?: list<array<string, mixed>>
* },
* attentionNote?: ?string
* }
*/
public function decisionZone(
array $facts,
array $primaryNextStep,
?string $description = null,
?array $compactCounts = null,
?string $attentionNote = null,
string $title = 'Decision',
): array {
return array_filter([
'title' => $title,
'description' => $description,
'facts' => array_values($facts),
'primaryNextStep' => $primaryNextStep,
'compactCounts' => $compactCounts,
'attentionNote' => $attentionNote,
], static fn (mixed $item): bool => $item !== null);
}
/**
* @param list<array{label: string, text: string, source: string}> $secondaryGuidance
* @return array{
* label: string,
* text: string,
* source: string,
* secondaryGuidance: list<array{label: string, text: string, source: string}>
* }
*/
public function primaryNextStep(string $text, string $source, array $secondaryGuidance = [], string $label = 'Primary next step'): array
{
return [
'label' => $label,
'text' => $text,
'source' => $source,
'secondaryGuidance' => array_values($secondaryGuidance),
];
}
/**
* @param list<array<string, mixed>> $primaryFacts
* @param list<array<string, mixed>> $diagnosticFacts
* @return array{
* summaryLine?: ?string,
* primaryFacts: list<array<string, mixed>>,
* diagnosticFacts: list<array<string, mixed>>
* }
*/
public function countPresentation(?string $summaryLine = null, array $primaryFacts = [], array $diagnosticFacts = []): array
{
return [
'summaryLine' => $summaryLine,
'primaryFacts' => array_values($primaryFacts),
'diagnosticFacts' => array_values($diagnosticFacts),
];
}
/**
* @param list<array<string, mixed>> $items
*/
@ -174,6 +264,7 @@ public function technicalDetail(
bool $visible = true,
bool $collapsible = true,
bool $collapsed = true,
string $variant = 'technical',
): TechnicalDetailData {
return new TechnicalDetailData(
title: $title,
@ -185,6 +276,7 @@ public function technicalDetail(
view: $view,
viewData: $viewData,
emptyState: $emptyState,
variant: $variant,
);
}
}

View File

@ -21,6 +21,7 @@ public function __construct(
public ?string $view = null,
public array $viewData = [],
public ?array $emptyState = null,
public string $variant = 'technical',
) {}
public function shouldRender(): bool
@ -59,6 +60,7 @@ public function toArray(): array
'view' => $this->view,
'viewData' => $this->viewData,
'emptyState' => $this->emptyState,
'variant' => $this->variant,
];
}
}

View File

@ -6,6 +6,7 @@
use App\Support\ReasonTranslation\NextStepOption;
use App\Support\ReasonTranslation\ReasonResolutionEnvelope;
use App\Support\Ui\OperatorExplanation\TrustworthinessLevel;
final readonly class ArtifactTruthCause
{
@ -18,6 +19,8 @@ public function __construct(
public ?string $operatorLabel,
public ?string $shortExplanation,
public ?string $diagnosticCode,
public string $trustImpact,
public ?string $absencePattern,
public array $nextSteps = [],
) {}
@ -35,6 +38,8 @@ public static function fromReasonResolutionEnvelope(
operatorLabel: $reason->operatorLabel,
shortExplanation: $reason->shortExplanation,
diagnosticCode: $reason->diagnosticCode(),
trustImpact: $reason->trustImpact,
absencePattern: $reason->absencePattern,
nextSteps: array_values(array_map(
static fn (NextStepOption $nextStep): string => $nextStep->label,
$reason->nextSteps,
@ -42,6 +47,23 @@ public static function fromReasonResolutionEnvelope(
);
}
public function toReasonResolutionEnvelope(): ReasonResolutionEnvelope
{
return new ReasonResolutionEnvelope(
internalCode: $this->reasonCode ?? 'artifact_truth_reason',
operatorLabel: $this->operatorLabel ?? 'Operator attention required',
shortExplanation: $this->shortExplanation ?? 'Technical diagnostics are available for this result.',
actionability: $this->absencePattern === 'true_no_result' ? 'non_actionable' : 'prerequisite_missing',
nextSteps: array_map(
static fn (string $label): NextStepOption => NextStepOption::instruction($label),
$this->nextSteps,
),
diagnosticCodeLabel: $this->diagnosticCode,
trustImpact: $this->trustImpact !== '' ? $this->trustImpact : TrustworthinessLevel::LimitedConfidence->value,
absencePattern: $this->absencePattern,
);
}
/**
* @return array{
* reasonCode: ?string,
@ -49,6 +71,8 @@ public static function fromReasonResolutionEnvelope(
* operatorLabel: ?string,
* shortExplanation: ?string,
* diagnosticCode: ?string,
* trustImpact: string,
* absencePattern: ?string,
* nextSteps: array<int, string>
* }
*/
@ -60,6 +84,8 @@ public function toArray(): array
'operatorLabel' => $this->operatorLabel,
'shortExplanation' => $this->shortExplanation,
'diagnosticCode' => $this->diagnosticCode,
'trustImpact' => $this->trustImpact,
'absencePattern' => $this->absencePattern,
'nextSteps' => $this->nextSteps,
];
}

View File

@ -5,6 +5,7 @@
namespace App\Support\Ui\GovernanceArtifactTruth;
use App\Support\Badges\BadgeSpec;
use App\Support\Ui\OperatorExplanation\OperatorExplanationPattern;
final readonly class ArtifactTruthEnvelope
{
@ -32,6 +33,7 @@ public function __construct(
public ?string $relatedArtifactUrl,
public array $dimensions = [],
public ?ArtifactTruthCause $reason = null,
public ?OperatorExplanationPattern $operatorExplanation = null,
) {}
public function primaryDimension(): ?ArtifactTruthDimension
@ -99,8 +101,11 @@ public function nextStepText(): string
* operatorLabel: ?string,
* shortExplanation: ?string,
* diagnosticCode: ?string,
* trustImpact: string,
* absencePattern: ?string,
* nextSteps: array<int, string>
* }
* },
* operatorExplanation: ?array<string, mixed>
* }
*/
public function toArray(): array
@ -132,6 +137,7 @@ public function toArray(): array
),
)),
'reason' => $this->reason?->toArray(),
'operatorExplanation' => $this->operatorExplanation?->toArray(),
];
}
}

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