Compare commits

...

4 Commits

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

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

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

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

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

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

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

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

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

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

## Testing
- not run in this step

Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de>
Reviewed-on: #188
2026-03-23 00:13:57 +00:00
229 changed files with 16255 additions and 859 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -24,6 +24,8 @@
use App\Support\Tenants\ReferencedTenantLifecyclePresentation; use App\Support\Tenants\ReferencedTenantLifecyclePresentation;
use App\Support\Tenants\TenantInteractionLane; use App\Support\Tenants\TenantInteractionLane;
use App\Support\Tenants\TenantOperabilityQuestion; use App\Support\Tenants\TenantOperabilityQuestion;
use App\Support\Ui\GovernanceArtifactTruth\ArtifactTruthPresenter;
use App\Support\Ui\OperatorExplanation\OperatorExplanationPattern;
use Filament\Actions\Action; use Filament\Actions\Action;
use Filament\Actions\ActionGroup; use Filament\Actions\ActionGroup;
use Filament\Notifications\Notification; use Filament\Notifications\Notification;
@ -170,11 +172,18 @@ public function blockedExecutionBanner(): ?array
return null; return null;
} }
$operatorExplanation = $this->governanceOperatorExplanation();
$reasonEnvelope = app(ReasonPresenter::class)->forOperationRun($this->run, 'run_detail'); $reasonEnvelope = app(ReasonPresenter::class)->forOperationRun($this->run, 'run_detail');
$lines = $reasonEnvelope?->toBodyLines() ?? [ $lines = $operatorExplanation instanceof OperatorExplanationPattern
? array_values(array_filter([
$operatorExplanation->headline,
$operatorExplanation->dominantCauseExplanation,
OperationUxPresenter::surfaceGuidance($this->run),
]))
: ($reasonEnvelope?->toBodyLines() ?? [
OperationUxPresenter::surfaceFailureDetail($this->run) ?? 'The queued run was refused before side effects could begin.', OperationUxPresenter::surfaceFailureDetail($this->run) ?? 'The queued run was refused before side effects could begin.',
OperationUxPresenter::surfaceGuidance($this->run) ?? 'Review the blocked prerequisite before retrying.', OperationUxPresenter::surfaceGuidance($this->run) ?? 'Review the blocked prerequisite before retrying.',
]; ]);
return [ return [
'tone' => 'amber', 'tone' => 'amber',
@ -183,6 +192,40 @@ public function blockedExecutionBanner(): ?array
]; ];
} }
/**
* @return array{tone: string, title: string, body: string}|null
*/
public function lifecycleBanner(): ?array
{
if (! isset($this->run)) {
return null;
}
$attention = OperationUxPresenter::lifecycleAttentionSummary($this->run);
if ($attention === null) {
return null;
}
$detail = OperationUxPresenter::surfaceFailureDetail($this->run) ?? 'Lifecycle truth needs operator review.';
$guidance = OperationUxPresenter::surfaceGuidance($this->run);
$body = $guidance !== null ? $detail.' '.$guidance : $detail;
return match ($this->run->freshnessState()->value) {
'likely_stale' => [
'tone' => 'amber',
'title' => 'Likely stale run',
'body' => $body,
],
'reconciled_failed' => [
'tone' => 'rose',
'title' => 'Automatically reconciled',
'body' => $body,
],
default => null,
};
}
/** /**
* @return array{tone: string, title: string, body: string}|null * @return array{tone: string, title: string, body: string}|null
*/ */
@ -417,4 +460,13 @@ private function relatedLinksTenant(): ?Tenant
lane: TenantInteractionLane::StandardActiveOperating, lane: TenantInteractionLane::StandardActiveOperating,
)->allowed ? $tenant : null; )->allowed ? $tenant : null;
} }
private function governanceOperatorExplanation(): ?OperatorExplanationPattern
{
if (! isset($this->run) || ! $this->run->supportsOperatorExplanation()) {
return null;
}
return app(ArtifactTruthPresenter::class)->forOperationRun($this->run)?->operatorExplanation;
}
} }

View File

@ -22,6 +22,7 @@
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceInspectAffordance; use App\Support\Ui\ActionSurface\Enums\ActionSurfaceInspectAffordance;
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile; use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile;
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceSlot; use App\Support\Ui\ActionSurface\Enums\ActionSurfaceSlot;
use App\Support\Ui\GovernanceArtifactTruth\ArtifactTruthPresenter;
use App\Support\Workspaces\WorkspaceContext; use App\Support\Workspaces\WorkspaceContext;
use BackedEnum; use BackedEnum;
use Filament\Actions\Action; use Filament\Actions\Action;
@ -114,6 +115,15 @@ public function table(Table $table): Table
->color(BadgeRenderer::color(BadgeDomain::TenantReviewStatus)) ->color(BadgeRenderer::color(BadgeDomain::TenantReviewStatus))
->icon(BadgeRenderer::icon(BadgeDomain::TenantReviewStatus)) ->icon(BadgeRenderer::icon(BadgeDomain::TenantReviewStatus))
->iconColor(BadgeRenderer::iconColor(BadgeDomain::TenantReviewStatus)), ->iconColor(BadgeRenderer::iconColor(BadgeDomain::TenantReviewStatus)),
TextColumn::make('artifact_truth')
->label('Artifact truth')
->badge()
->getStateUsing(fn (TenantReview $record): string => app(ArtifactTruthPresenter::class)->forTenantReview($record)->primaryLabel)
->color(fn (TenantReview $record): string => app(ArtifactTruthPresenter::class)->forTenantReview($record)->primaryBadgeSpec()->color)
->icon(fn (TenantReview $record): ?string => app(ArtifactTruthPresenter::class)->forTenantReview($record)->primaryBadgeSpec()->icon)
->iconColor(fn (TenantReview $record): ?string => app(ArtifactTruthPresenter::class)->forTenantReview($record)->primaryBadgeSpec()->iconColor)
->description(fn (TenantReview $record): ?string => app(ArtifactTruthPresenter::class)->forTenantReview($record)->operatorExplanation?->headline ?? app(ArtifactTruthPresenter::class)->forTenantReview($record)->primaryExplanation)
->wrap(),
TextColumn::make('completeness_state') TextColumn::make('completeness_state')
->label('Completeness') ->label('Completeness')
->badge() ->badge()
@ -123,15 +133,29 @@ public function table(Table $table): Table
->iconColor(BadgeRenderer::iconColor(BadgeDomain::TenantReviewCompleteness)), ->iconColor(BadgeRenderer::iconColor(BadgeDomain::TenantReviewCompleteness)),
TextColumn::make('generated_at')->dateTime()->placeholder('—')->sortable(), TextColumn::make('generated_at')->dateTime()->placeholder('—')->sortable(),
TextColumn::make('published_at')->dateTime()->placeholder('—')->sortable(), TextColumn::make('published_at')->dateTime()->placeholder('—')->sortable(),
TextColumn::make('summary.publish_blockers') TextColumn::make('publication_truth')
->label('Publish blockers') ->label('Publication')
->formatStateUsing(static function (mixed $state): string { ->badge()
if (! is_array($state) || $state === []) { ->getStateUsing(fn (TenantReview $record): string => BadgeCatalog::spec(
return '0'; BadgeDomain::GovernanceArtifactPublicationReadiness,
} app(ArtifactTruthPresenter::class)->forTenantReview($record)->publicationReadiness ?? 'internal_only',
)->label)
return (string) count($state); ->color(fn (TenantReview $record): string => BadgeCatalog::spec(
}), BadgeDomain::GovernanceArtifactPublicationReadiness,
app(ArtifactTruthPresenter::class)->forTenantReview($record)->publicationReadiness ?? 'internal_only',
)->color)
->icon(fn (TenantReview $record): ?string => BadgeCatalog::spec(
BadgeDomain::GovernanceArtifactPublicationReadiness,
app(ArtifactTruthPresenter::class)->forTenantReview($record)->publicationReadiness ?? 'internal_only',
)->icon)
->iconColor(fn (TenantReview $record): ?string => BadgeCatalog::spec(
BadgeDomain::GovernanceArtifactPublicationReadiness,
app(ArtifactTruthPresenter::class)->forTenantReview($record)->publicationReadiness ?? 'internal_only',
)->iconColor),
TextColumn::make('artifact_next_step')
->label('Next step')
->getStateUsing(fn (TenantReview $record): string => app(ArtifactTruthPresenter::class)->forTenantReview($record)->operatorExplanation?->nextActionText ?? app(ArtifactTruthPresenter::class)->forTenantReview($record)->nextStepText())
->wrap(),
]) ])
->filters([ ->filters([
SelectFilter::make('tenant_id') SelectFilter::make('tenant_id')

View File

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

View File

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

View File

@ -9,10 +9,12 @@
use App\Models\BaselineSnapshot; use App\Models\BaselineSnapshot;
use App\Models\User; use App\Models\User;
use App\Models\Workspace; use App\Models\Workspace;
use App\Services\Baselines\BaselineSnapshotTruthResolver;
use App\Services\Baselines\SnapshotRendering\FidelityState; use App\Services\Baselines\SnapshotRendering\FidelityState;
use App\Support\Auth\Capabilities; use App\Support\Auth\Capabilities;
use App\Support\Badges\BadgeCatalog; use App\Support\Badges\BadgeCatalog;
use App\Support\Badges\BadgeDomain; use App\Support\Badges\BadgeDomain;
use App\Support\Baselines\BaselineSnapshotLifecycleState;
use App\Support\Filament\FilterPresets; use App\Support\Filament\FilterPresets;
use App\Support\Navigation\CrossResourceNavigationMatrix; use App\Support\Navigation\CrossResourceNavigationMatrix;
use App\Support\Navigation\RelatedContextEntry; use App\Support\Navigation\RelatedContextEntry;
@ -21,6 +23,8 @@
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceInspectAffordance; use App\Support\Ui\ActionSurface\Enums\ActionSurfaceInspectAffordance;
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile; use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile;
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceSlot; use App\Support\Ui\ActionSurface\Enums\ActionSurfaceSlot;
use App\Support\Ui\GovernanceArtifactTruth\ArtifactTruthEnvelope;
use App\Support\Ui\GovernanceArtifactTruth\ArtifactTruthPresenter;
use App\Support\Workspaces\WorkspaceContext; use App\Support\Workspaces\WorkspaceContext;
use BackedEnum; use BackedEnum;
use Filament\Actions\Action; use Filament\Actions\Action;
@ -168,17 +172,39 @@ public static function table(Table $table): Table
->label('Captured') ->label('Captured')
->since() ->since()
->sortable(), ->sortable(),
TextColumn::make('artifact_truth')
->label('Artifact truth')
->badge()
->getStateUsing(static fn (BaselineSnapshot $record): string => self::truthEnvelope($record)->primaryLabel)
->color(static fn (BaselineSnapshot $record): string => self::truthEnvelope($record)->primaryBadgeSpec()->color)
->icon(static fn (BaselineSnapshot $record): ?string => self::truthEnvelope($record)->primaryBadgeSpec()->icon)
->iconColor(static fn (BaselineSnapshot $record): ?string => self::truthEnvelope($record)->primaryBadgeSpec()->iconColor)
->description(static fn (BaselineSnapshot $record): ?string => self::truthEnvelope($record)->operatorExplanation?->headline ?? self::truthEnvelope($record)->primaryExplanation)
->wrap(),
TextColumn::make('lifecycle_state')
->label('Lifecycle')
->badge()
->getStateUsing(static fn (BaselineSnapshot $record): string => self::lifecycleSpec($record)->label)
->color(static fn (BaselineSnapshot $record): string => self::lifecycleSpec($record)->color)
->icon(static fn (BaselineSnapshot $record): ?string => self::lifecycleSpec($record)->icon)
->iconColor(static fn (BaselineSnapshot $record): ?string => self::lifecycleSpec($record)->iconColor)
->sortable(),
TextColumn::make('current_truth')
->label('Current truth')
->badge()
->getStateUsing(static fn (BaselineSnapshot $record): string => self::currentTruthLabel($record))
->color(static fn (BaselineSnapshot $record): string => self::currentTruthColor($record))
->icon(static fn (BaselineSnapshot $record): ?string => self::currentTruthIcon($record))
->description(static fn (BaselineSnapshot $record): ?string => self::currentTruthDescription($record))
->wrap(),
TextColumn::make('fidelity_summary') TextColumn::make('fidelity_summary')
->label('Fidelity') ->label('Fidelity')
->getStateUsing(static fn (BaselineSnapshot $record): string => self::fidelitySummary($record)) ->getStateUsing(static fn (BaselineSnapshot $record): string => self::fidelitySummary($record))
->wrap(), ->wrap(),
TextColumn::make('snapshot_state') TextColumn::make('artifact_next_step')
->label('State') ->label('Next step')
->badge() ->getStateUsing(static fn (BaselineSnapshot $record): string => self::truthEnvelope($record)->operatorExplanation?->nextActionText ?? self::truthEnvelope($record)->nextStepText())
->getStateUsing(static fn (BaselineSnapshot $record): string => self::stateLabel($record)) ->wrap(),
->color(static fn (BaselineSnapshot $record): string => self::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) ->recordUrl(static fn (BaselineSnapshot $record): ?string => static::canView($record)
? static::getUrl('view', ['record' => $record]) ? static::getUrl('view', ['record' => $record])
@ -188,10 +214,10 @@ public static function table(Table $table): Table
->label('Baseline') ->label('Baseline')
->options(static::baselineProfileOptions()) ->options(static::baselineProfileOptions())
->searchable(), ->searchable(),
SelectFilter::make('snapshot_state') SelectFilter::make('lifecycle_state')
->label('State') ->label('Lifecycle')
->options(static::snapshotStateOptions()) ->options(static::lifecycleOptions())
->query(fn (Builder $query, array $data): Builder => static::applySnapshotStateFilter($query, $data['value'] ?? null)), ->query(fn (Builder $query, array $data): Builder => static::applyLifecycleFilter($query, $data['value'] ?? null)),
FilterPresets::dateRange('captured_at', 'Captured', 'captured_at'), FilterPresets::dateRange('captured_at', 'Captured', 'captured_at'),
]) ])
->actions([ ->actions([
@ -252,9 +278,9 @@ private static function baselineProfileOptions(): array
/** /**
* @return array<string, string> * @return array<string, string>
*/ */
private static function snapshotStateOptions(): array private static function lifecycleOptions(): array
{ {
return BadgeCatalog::options(BadgeDomain::BaselineSnapshotGapStatus, ['clear', 'gaps_present']); return BadgeCatalog::options(BadgeDomain::BaselineSnapshotLifecycle, BaselineSnapshotLifecycleState::values());
} }
public static function resolveWorkspace(): ?Workspace public static function resolveWorkspace(): ?Workspace
@ -328,24 +354,18 @@ private static function hasGaps(BaselineSnapshot $snapshot): bool
return self::gapsCount($snapshot) > 0; return self::gapsCount($snapshot) > 0;
} }
private static function stateLabel(BaselineSnapshot $snapshot): string private static function lifecycleSpec(BaselineSnapshot $snapshot): \App\Support\Badges\BadgeSpec
{ {
return self::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) === '') { if (! is_string($value) || trim($value) === '') {
return $query; return $query;
} }
$gapCountExpression = self::gapCountExpression($query); return $query->where('lifecycle_state', trim($value));
return match ($value) {
'clear' => $query->whereRaw("{$gapCountExpression} = 0"),
'gaps_present' => $query->whereRaw("{$gapCountExpression} > 0"),
default => $query,
};
} }
private static function gapCountExpression(Builder $query): string private static function gapCountExpression(Builder $query): string
@ -364,4 +384,56 @@ private static function gapSpec(BaselineSnapshot $snapshot): \App\Support\Badges
self::hasGaps($snapshot) ? 'gaps_present' : 'clear', self::hasGaps($snapshot) ? 'gaps_present' : 'clear',
); );
} }
private static function truthEnvelope(BaselineSnapshot $snapshot): ArtifactTruthEnvelope
{
return app(ArtifactTruthPresenter::class)->forBaselineSnapshot($snapshot);
}
private static function currentTruthLabel(BaselineSnapshot $snapshot): string
{
return match (self::currentTruthState($snapshot)) {
'current' => 'Current baseline',
'historical' => 'Historical trace',
default => 'Not compare input',
};
}
private static function currentTruthDescription(BaselineSnapshot $snapshot): ?string
{
return match (self::currentTruthState($snapshot)) {
'current' => 'Compare resolves to this snapshot as the current baseline truth.',
'historical' => 'A newer complete snapshot is now the current baseline truth for this profile.',
default => self::truthEnvelope($snapshot)->primaryExplanation,
};
}
private static function currentTruthColor(BaselineSnapshot $snapshot): string
{
return match (self::currentTruthState($snapshot)) {
'current' => 'success',
'historical' => 'gray',
default => 'warning',
};
}
private static function currentTruthIcon(BaselineSnapshot $snapshot): ?string
{
return match (self::currentTruthState($snapshot)) {
'current' => 'heroicon-m-check-badge',
'historical' => 'heroicon-m-clock',
default => 'heroicon-m-exclamation-triangle',
};
}
private static function currentTruthState(BaselineSnapshot $snapshot): string
{
if (! $snapshot->isConsumable()) {
return 'unusable';
}
return app(BaselineSnapshotTruthResolver::class)->isHistoricallySuperseded($snapshot)
? 'historical'
: 'current';
}
} }

View File

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

View File

@ -28,6 +28,8 @@
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceInspectAffordance; use App\Support\Ui\ActionSurface\Enums\ActionSurfaceInspectAffordance;
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile; use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile;
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceSlot; use App\Support\Ui\ActionSurface\Enums\ActionSurfaceSlot;
use App\Support\Ui\GovernanceArtifactTruth\ArtifactTruthEnvelope;
use App\Support\Ui\GovernanceArtifactTruth\ArtifactTruthPresenter;
use BackedEnum; use BackedEnum;
use Filament\Actions; use Filament\Actions;
use Filament\Facades\Filament; use Filament\Facades\Filament;
@ -133,6 +135,15 @@ public static function form(Schema $schema): Schema
public static function infolist(Schema $schema): Schema public static function infolist(Schema $schema): Schema
{ {
return $schema->schema([ return $schema->schema([
Section::make('Artifact truth')
->schema([
ViewEntry::make('artifact_truth')
->hiddenLabel()
->view('filament.infolists.entries.governance-artifact-truth')
->state(fn (EvidenceSnapshot $record): array => static::truthEnvelope($record)->toArray())
->columnSpanFull(),
])
->columnSpanFull(),
Section::make('Snapshot') Section::make('Snapshot')
->schema([ ->schema([
TextEntry::make('status') TextEntry::make('status')
@ -214,6 +225,15 @@ public static function table(Table $table): Table
->icon(BadgeRenderer::icon(BadgeDomain::EvidenceSnapshotStatus)) ->icon(BadgeRenderer::icon(BadgeDomain::EvidenceSnapshotStatus))
->iconColor(BadgeRenderer::iconColor(BadgeDomain::EvidenceSnapshotStatus)) ->iconColor(BadgeRenderer::iconColor(BadgeDomain::EvidenceSnapshotStatus))
->sortable(), ->sortable(),
Tables\Columns\TextColumn::make('artifact_truth')
->label('Artifact truth')
->badge()
->getStateUsing(fn (EvidenceSnapshot $record): string => static::truthEnvelope($record)->primaryLabel)
->color(fn (EvidenceSnapshot $record): string => static::truthEnvelope($record)->primaryBadgeSpec()->color)
->icon(fn (EvidenceSnapshot $record): ?string => static::truthEnvelope($record)->primaryBadgeSpec()->icon)
->iconColor(fn (EvidenceSnapshot $record): ?string => static::truthEnvelope($record)->primaryBadgeSpec()->iconColor)
->description(fn (EvidenceSnapshot $record): ?string => static::truthEnvelope($record)->primaryExplanation)
->wrap(),
Tables\Columns\TextColumn::make('completeness_state') Tables\Columns\TextColumn::make('completeness_state')
->label('Completeness') ->label('Completeness')
->badge() ->badge()
@ -225,6 +245,10 @@ public static function table(Table $table): Table
Tables\Columns\TextColumn::make('generated_at')->dateTime()->sortable()->placeholder('—'), Tables\Columns\TextColumn::make('generated_at')->dateTime()->sortable()->placeholder('—'),
Tables\Columns\TextColumn::make('summary.finding_count')->label('Findings'), Tables\Columns\TextColumn::make('summary.finding_count')->label('Findings'),
Tables\Columns\TextColumn::make('summary.missing_dimensions')->label(static::evidenceCompletenessCountLabel(EvidenceCompletenessState::Missing->value)), Tables\Columns\TextColumn::make('summary.missing_dimensions')->label(static::evidenceCompletenessCountLabel(EvidenceCompletenessState::Missing->value)),
Tables\Columns\TextColumn::make('artifact_next_step')
->label('Next step')
->getStateUsing(fn (EvidenceSnapshot $record): string => static::truthEnvelope($record)->nextStepText())
->wrap(),
]) ])
->filters([ ->filters([
Tables\Filters\SelectFilter::make('status') Tables\Filters\SelectFilter::make('status')
@ -588,6 +612,11 @@ private static function badgeLabel(BadgeDomain $domain, ?string $state): ?string
return $label === 'Unknown' ? null : $label; return $label === 'Unknown' ? null : $label;
} }
private static function truthEnvelope(EvidenceSnapshot $record): ArtifactTruthEnvelope
{
return app(ArtifactTruthPresenter::class)->forEvidenceSnapshot($record);
}
private static function stringifySummaryValue(mixed $value): string private static function stringifySummaryValue(mixed $value): string
{ {
return match (true) { return match (true) {

View File

@ -32,6 +32,7 @@
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceInspectAffordance; use App\Support\Ui\ActionSurface\Enums\ActionSurfaceInspectAffordance;
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile; use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile;
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceSlot; use App\Support\Ui\ActionSurface\Enums\ActionSurfaceSlot;
use App\Support\Ui\GovernanceArtifactTruth\ArtifactTruthPresenter;
use App\Support\Workspaces\WorkspaceContext; use App\Support\Workspaces\WorkspaceContext;
use BackedEnum; use BackedEnum;
use Filament\Actions; use Filament\Actions;
@ -127,10 +128,11 @@ public static function table(Table $table): Table
->columns([ ->columns([
Tables\Columns\TextColumn::make('status') Tables\Columns\TextColumn::make('status')
->badge() ->badge()
->formatStateUsing(BadgeRenderer::label(BadgeDomain::OperationRunStatus)) ->formatStateUsing(fn (mixed $state, OperationRun $record): string => BadgeRenderer::spec(BadgeDomain::OperationRunStatus, static::statusBadgeState($record))->label)
->color(BadgeRenderer::color(BadgeDomain::OperationRunStatus)) ->color(fn (mixed $state, OperationRun $record): string => BadgeRenderer::spec(BadgeDomain::OperationRunStatus, static::statusBadgeState($record))->color)
->icon(BadgeRenderer::icon(BadgeDomain::OperationRunStatus)) ->icon(fn (mixed $state, OperationRun $record): ?string => BadgeRenderer::spec(BadgeDomain::OperationRunStatus, static::statusBadgeState($record))->icon)
->iconColor(BadgeRenderer::iconColor(BadgeDomain::OperationRunStatus)), ->iconColor(fn (mixed $state, OperationRun $record): ?string => BadgeRenderer::spec(BadgeDomain::OperationRunStatus, static::statusBadgeState($record))->iconColor)
->description(fn (OperationRun $record): ?string => OperationUxPresenter::lifecycleAttentionSummary($record)),
Tables\Columns\TextColumn::make('type') Tables\Columns\TextColumn::make('type')
->label('Operation') ->label('Operation')
->formatStateUsing(fn (?string $state): string => OperationCatalog::label((string) $state)) ->formatStateUsing(fn (?string $state): string => OperationCatalog::label((string) $state))
@ -153,10 +155,10 @@ public static function table(Table $table): Table
}), }),
Tables\Columns\TextColumn::make('outcome') Tables\Columns\TextColumn::make('outcome')
->badge() ->badge()
->formatStateUsing(BadgeRenderer::label(BadgeDomain::OperationRunOutcome)) ->formatStateUsing(fn (mixed $state, OperationRun $record): string => BadgeRenderer::spec(BadgeDomain::OperationRunOutcome, static::outcomeBadgeState($record))->label)
->color(BadgeRenderer::color(BadgeDomain::OperationRunOutcome)) ->color(fn (mixed $state, OperationRun $record): string => BadgeRenderer::spec(BadgeDomain::OperationRunOutcome, static::outcomeBadgeState($record))->color)
->icon(BadgeRenderer::icon(BadgeDomain::OperationRunOutcome)) ->icon(fn (mixed $state, OperationRun $record): ?string => BadgeRenderer::spec(BadgeDomain::OperationRunOutcome, static::outcomeBadgeState($record))->icon)
->iconColor(BadgeRenderer::iconColor(BadgeDomain::OperationRunOutcome)) ->iconColor(fn (mixed $state, OperationRun $record): ?string => BadgeRenderer::spec(BadgeDomain::OperationRunOutcome, static::outcomeBadgeState($record))->iconColor)
->description(fn (OperationRun $record): ?string => OperationUxPresenter::surfaceGuidance($record)), ->description(fn (OperationRun $record): ?string => OperationUxPresenter::surfaceGuidance($record)),
]) ])
->filters([ ->filters([
@ -252,13 +254,25 @@ private static function enterpriseDetailPage(OperationRun $record): \App\Support
{ {
$factory = new \App\Support\Ui\EnterpriseDetail\EnterpriseDetailSectionFactory; $factory = new \App\Support\Ui\EnterpriseDetail\EnterpriseDetailSectionFactory;
$statusSpec = BadgeRenderer::spec(BadgeDomain::OperationRunStatus, $record->status); $statusSpec = BadgeRenderer::spec(BadgeDomain::OperationRunStatus, static::statusBadgeState($record));
$outcomeSpec = BadgeRenderer::spec(BadgeDomain::OperationRunOutcome, $record->outcome); $outcomeSpec = BadgeRenderer::spec(BadgeDomain::OperationRunOutcome, static::outcomeBadgeState($record));
$targetScope = static::targetScopeDisplay($record); $targetScope = static::targetScopeDisplay($record);
$summaryLine = \App\Support\OpsUx\SummaryCountsNormalizer::renderSummaryLine(is_array($record->summary_counts) ? $record->summary_counts : []); $summaryLine = \App\Support\OpsUx\SummaryCountsNormalizer::renderSummaryLine(is_array($record->summary_counts) ? $record->summary_counts : []);
$referencedTenantLifecycle = $record->tenant instanceof Tenant $referencedTenantLifecycle = $record->tenant instanceof Tenant
? ReferencedTenantLifecyclePresentation::forOperationRun($record->tenant) ? ReferencedTenantLifecyclePresentation::forOperationRun($record->tenant)
: null; : null;
$artifactTruth = $record->supportsOperatorExplanation()
? app(ArtifactTruthPresenter::class)->forOperationRun($record)
: null;
$operatorExplanation = $artifactTruth?->operatorExplanation;
$artifactTruthBadge = $artifactTruth !== null
? $factory->statusBadge(
$artifactTruth->primaryBadgeSpec()->label,
$artifactTruth->primaryBadgeSpec()->color,
$artifactTruth->primaryBadgeSpec()->icon,
$artifactTruth->primaryBadgeSpec()->iconColor,
)
: null;
$builder = \App\Support\Ui\EnterpriseDetail\EnterpriseDetailBuilder::make('operation_run', 'workspace-context') $builder = \App\Support\Ui\EnterpriseDetail\EnterpriseDetailBuilder::make('operation_run', 'workspace-context')
->header(new \App\Support\Ui\EnterpriseDetail\SummaryHeaderData( ->header(new \App\Support\Ui\EnterpriseDetail\SummaryHeaderData(
@ -288,6 +302,15 @@ private static function enterpriseDetailPage(OperationRun $record): \App\Support
$factory->keyFact('Expected duration', RunDurationInsights::expectedHuman($record)), $factory->keyFact('Expected duration', RunDurationInsights::expectedHuman($record)),
], ],
), ),
$factory->viewSection(
id: 'artifact_truth',
kind: 'current_status',
title: 'Artifact truth',
view: 'filament.infolists.entries.governance-artifact-truth',
viewData: ['artifactTruthState' => $artifactTruth?->toArray()],
visible: $artifactTruth !== null,
description: 'Run lifecycle stays separate from whether the intended governance artifact was actually produced and usable.',
),
$factory->viewSection( $factory->viewSection(
id: 'related_context', id: 'related_context',
kind: 'related_context', kind: 'related_context',
@ -305,6 +328,15 @@ private static function enterpriseDetailPage(OperationRun $record): \App\Support
items: array_values(array_filter([ items: array_values(array_filter([
$factory->keyFact('Status', $statusSpec->label, badge: $factory->statusBadge($statusSpec->label, $statusSpec->color, $statusSpec->icon, $statusSpec->iconColor)), $factory->keyFact('Status', $statusSpec->label, badge: $factory->statusBadge($statusSpec->label, $statusSpec->color, $statusSpec->icon, $statusSpec->iconColor)),
$factory->keyFact('Outcome', $outcomeSpec->label, badge: $factory->statusBadge($outcomeSpec->label, $outcomeSpec->color, $outcomeSpec->icon, $outcomeSpec->iconColor)), $factory->keyFact('Outcome', $outcomeSpec->label, badge: $factory->statusBadge($outcomeSpec->label, $outcomeSpec->color, $outcomeSpec->icon, $outcomeSpec->iconColor)),
$artifactTruth !== null
? $factory->keyFact('Artifact truth', $artifactTruth->primaryLabel, badge: $artifactTruthBadge)
: null,
$operatorExplanation !== null
? $factory->keyFact('Result meaning', $operatorExplanation->evaluationResultLabel())
: null,
$operatorExplanation !== null
? $factory->keyFact('Result trust', $operatorExplanation->trustworthinessLabel())
: null,
$referencedTenantLifecycle !== null $referencedTenantLifecycle !== null
? $factory->keyFact( ? $factory->keyFact(
'Tenant lifecycle', 'Tenant lifecycle',
@ -323,6 +355,26 @@ private static function enterpriseDetailPage(OperationRun $record): \App\Support
$referencedTenantLifecycle?->contextNote !== null $referencedTenantLifecycle?->contextNote !== null
? $factory->keyFact('Viewer context', $referencedTenantLifecycle->contextNote) ? $factory->keyFact('Viewer context', $referencedTenantLifecycle->contextNote)
: null, : null,
static::freshnessLabel($record) !== null
? $factory->keyFact('Freshness', (string) static::freshnessLabel($record))
: null,
static::reconciliationHeadline($record) !== null
? $factory->keyFact('Lifecycle truth', (string) static::reconciliationHeadline($record))
: null,
static::reconciledAtLabel($record) !== null
? $factory->keyFact('Reconciled at', (string) static::reconciledAtLabel($record))
: null,
static::reconciliationSourceLabel($record) !== null
? $factory->keyFact('Reconciled by', (string) static::reconciliationSourceLabel($record))
: null,
$operatorExplanation !== null
? $factory->keyFact('Artifact next step', $operatorExplanation->nextActionText)
: ($artifactTruth !== null
? $factory->keyFact('Artifact next step', $artifactTruth->nextStepText())
: null),
$operatorExplanation !== null && $operatorExplanation->coverageStatement !== null
? $factory->keyFact('Coverage', $operatorExplanation->coverageStatement)
: null,
OperationUxPresenter::surfaceGuidance($record) !== null OperationUxPresenter::surfaceGuidance($record) !== null
? $factory->keyFact('Next step', OperationUxPresenter::surfaceGuidance($record)) ? $factory->keyFact('Next step', OperationUxPresenter::surfaceGuidance($record))
: null, : null,
@ -389,6 +441,19 @@ private static function enterpriseDetailPage(OperationRun $record): \App\Support
); );
} }
if (static::reconciliationPayload($record) !== []) {
$builder->addSection(
$factory->viewSection(
id: 'reconciliation',
kind: 'operational_context',
title: 'Lifecycle reconciliation',
view: 'filament.infolists.entries.snapshot-json',
viewData: ['payload' => static::reconciliationPayload($record)],
description: 'Lifecycle reconciliation is diagnostic evidence showing when TenantPilot force-resolved the run.',
),
);
}
if ((string) $record->type === 'baseline_compare') { if ((string) $record->type === 'baseline_compare') {
$baselineCompareFacts = static::baselineCompareFacts($record, $factory); $baselineCompareFacts = static::baselineCompareFacts($record, $factory);
$baselineCompareEvidence = static::baselineCompareEvidencePayload($record); $baselineCompareEvidence = static::baselineCompareEvidencePayload($record);
@ -699,6 +764,82 @@ private static function contextPayload(OperationRun $record): array
return $context; return $context;
} }
/**
* @return array{status:string,freshness_state:string}
*/
private static function statusBadgeState(OperationRun $record): array
{
return [
'status' => (string) $record->status,
'freshness_state' => $record->freshnessState()->value,
];
}
/**
* @return array{outcome:string,status:string,freshness_state:string}
*/
private static function outcomeBadgeState(OperationRun $record): array
{
return [
'outcome' => (string) $record->outcome,
'status' => (string) $record->status,
'freshness_state' => $record->freshnessState()->value,
];
}
private static function freshnessLabel(OperationRun $record): ?string
{
return match ($record->freshnessState()->value) {
'fresh_active' => 'Fresh activity',
'likely_stale' => 'Likely stale',
'reconciled_failed' => 'Automatically reconciled',
'terminal_normal' => 'Terminal truth confirmed',
default => null,
};
}
private static function reconciliationHeadline(OperationRun $record): ?string
{
if (! $record->isLifecycleReconciled()) {
return null;
}
return 'TenantPilot force-resolved this run after normal lifecycle truth was lost.';
}
private static function reconciledAtLabel(OperationRun $record): ?string
{
$reconciledAt = data_get($record->reconciliation(), 'reconciled_at');
return is_string($reconciledAt) && trim($reconciledAt) !== '' ? trim($reconciledAt) : null;
}
private static function reconciliationSourceLabel(OperationRun $record): ?string
{
$source = data_get($record->reconciliation(), 'source');
if (! is_string($source) || trim($source) === '') {
return null;
}
return match (trim($source)) {
'failed_callback' => 'Direct failed() bridge',
'scheduled_reconciler' => 'Scheduled reconciler',
'adapter_reconciler' => 'Adapter reconciler',
default => ucfirst(str_replace('_', ' ', trim($source))),
};
}
/**
* @return array<string, mixed>
*/
private static function reconciliationPayload(OperationRun $record): array
{
$reconciliation = $record->reconciliation();
return $reconciliation;
}
private static function formatDetailTimestamp(mixed $value): string private static function formatDetailTimestamp(mixed $value): string
{ {
if (! $value instanceof \Illuminate\Support\Carbon) { if (! $value instanceof \Illuminate\Support\Carbon) {

View File

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

View File

@ -28,6 +28,8 @@
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceInspectAffordance; use App\Support\Ui\ActionSurface\Enums\ActionSurfaceInspectAffordance;
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile; use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile;
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceSlot; use App\Support\Ui\ActionSurface\Enums\ActionSurfaceSlot;
use App\Support\Ui\GovernanceArtifactTruth\ArtifactTruthEnvelope;
use App\Support\Ui\GovernanceArtifactTruth\ArtifactTruthPresenter;
use BackedEnum; use BackedEnum;
use Filament\Actions; use Filament\Actions;
use Filament\Facades\Filament; use Filament\Facades\Filament;
@ -143,6 +145,15 @@ public static function form(Schema $schema): Schema
public static function infolist(Schema $schema): Schema public static function infolist(Schema $schema): Schema
{ {
return $schema->schema([ return $schema->schema([
Section::make('Artifact truth')
->schema([
ViewEntry::make('artifact_truth')
->hiddenLabel()
->view('filament.infolists.entries.governance-artifact-truth')
->state(fn (TenantReview $record): array => static::truthEnvelope($record)->toArray())
->columnSpanFull(),
])
->columnSpanFull(),
Section::make('Review') Section::make('Review')
->schema([ ->schema([
TextEntry::make('status') TextEntry::make('status')
@ -239,6 +250,15 @@ public static function table(Table $table): Table
->icon(BadgeRenderer::icon(BadgeDomain::TenantReviewStatus)) ->icon(BadgeRenderer::icon(BadgeDomain::TenantReviewStatus))
->iconColor(BadgeRenderer::iconColor(BadgeDomain::TenantReviewStatus)) ->iconColor(BadgeRenderer::iconColor(BadgeDomain::TenantReviewStatus))
->sortable(), ->sortable(),
Tables\Columns\TextColumn::make('artifact_truth')
->label('Artifact truth')
->badge()
->getStateUsing(fn (TenantReview $record): string => static::truthEnvelope($record)->primaryLabel)
->color(fn (TenantReview $record): string => static::truthEnvelope($record)->primaryBadgeSpec()->color)
->icon(fn (TenantReview $record): ?string => static::truthEnvelope($record)->primaryBadgeSpec()->icon)
->iconColor(fn (TenantReview $record): ?string => static::truthEnvelope($record)->primaryBadgeSpec()->iconColor)
->description(fn (TenantReview $record): ?string => static::truthEnvelope($record)->operatorExplanation?->headline ?? static::truthEnvelope($record)->primaryExplanation)
->wrap(),
Tables\Columns\TextColumn::make('completeness_state') Tables\Columns\TextColumn::make('completeness_state')
->label('Completeness') ->label('Completeness')
->badge() ->badge()
@ -251,9 +271,32 @@ public static function table(Table $table): Table
Tables\Columns\TextColumn::make('published_at')->dateTime()->placeholder('—')->sortable(), Tables\Columns\TextColumn::make('published_at')->dateTime()->placeholder('—')->sortable(),
Tables\Columns\TextColumn::make('summary.finding_count')->label('Findings'), Tables\Columns\TextColumn::make('summary.finding_count')->label('Findings'),
Tables\Columns\TextColumn::make('summary.section_state_counts.missing')->label(static::reviewCompletenessCountLabel(TenantReviewCompletenessState::Missing->value)), Tables\Columns\TextColumn::make('summary.section_state_counts.missing')->label(static::reviewCompletenessCountLabel(TenantReviewCompletenessState::Missing->value)),
Tables\Columns\TextColumn::make('publication_truth')
->label('Publication')
->badge()
->getStateUsing(fn (TenantReview $record): string => BadgeCatalog::spec(
BadgeDomain::GovernanceArtifactPublicationReadiness,
static::truthEnvelope($record)->publicationReadiness ?? 'internal_only',
)->label)
->color(fn (TenantReview $record): string => BadgeCatalog::spec(
BadgeDomain::GovernanceArtifactPublicationReadiness,
static::truthEnvelope($record)->publicationReadiness ?? 'internal_only',
)->color)
->icon(fn (TenantReview $record): ?string => BadgeCatalog::spec(
BadgeDomain::GovernanceArtifactPublicationReadiness,
static::truthEnvelope($record)->publicationReadiness ?? 'internal_only',
)->icon)
->iconColor(fn (TenantReview $record): ?string => BadgeCatalog::spec(
BadgeDomain::GovernanceArtifactPublicationReadiness,
static::truthEnvelope($record)->publicationReadiness ?? 'internal_only',
)->iconColor),
Tables\Columns\IconColumn::make('summary.has_ready_export') Tables\Columns\IconColumn::make('summary.has_ready_export')
->label('Export') ->label('Export')
->boolean(), ->boolean(),
Tables\Columns\TextColumn::make('artifact_next_step')
->label('Next step')
->getStateUsing(fn (TenantReview $record): string => static::truthEnvelope($record)->operatorExplanation?->nextActionText ?? static::truthEnvelope($record)->nextStepText())
->wrap(),
Tables\Columns\TextColumn::make('fingerprint') Tables\Columns\TextColumn::make('fingerprint')
->toggleable(isToggledHiddenByDefault: true) ->toggleable(isToggledHiddenByDefault: true)
->searchable(), ->searchable(),
@ -520,6 +563,7 @@ private static function summaryPresentation(TenantReview $record): array
$summary = is_array($record->summary) ? $record->summary : []; $summary = is_array($record->summary) ? $record->summary : [];
return [ return [
'operator_explanation' => static::truthEnvelope($record)->operatorExplanation?->toArray(),
'highlights' => is_array($summary['highlights'] ?? null) ? $summary['highlights'] : [], 'highlights' => is_array($summary['highlights'] ?? null) ? $summary['highlights'] : [],
'next_actions' => is_array($summary['recommended_next_actions'] ?? null) ? $summary['recommended_next_actions'] : [], 'next_actions' => is_array($summary['recommended_next_actions'] ?? null) ? $summary['recommended_next_actions'] : [],
'publish_blockers' => is_array($summary['publish_blockers'] ?? null) ? $summary['publish_blockers'] : [], 'publish_blockers' => is_array($summary['publish_blockers'] ?? null) ? $summary['publish_blockers'] : [],
@ -561,4 +605,9 @@ private static function sectionPresentation(TenantReviewSection $section): array
'links' => [], 'links' => [],
]; ];
} }
private static function truthEnvelope(TenantReview $record): ArtifactTruthEnvelope
{
return app(ArtifactTruthPresenter::class)->forTenantReview($record);
}
} }

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -14,6 +14,8 @@
use App\Support\Ui\EnterpriseDetail\EnterpriseDetailPageData; use App\Support\Ui\EnterpriseDetail\EnterpriseDetailPageData;
use App\Support\Ui\EnterpriseDetail\EnterpriseDetailSectionFactory; use App\Support\Ui\EnterpriseDetail\EnterpriseDetailSectionFactory;
use App\Support\Ui\EnterpriseDetail\SummaryHeaderData; use App\Support\Ui\EnterpriseDetail\SummaryHeaderData;
use App\Support\Ui\GovernanceArtifactTruth\ArtifactTruthEnvelope;
use App\Support\Ui\GovernanceArtifactTruth\ArtifactTruthPresenter;
use Illuminate\Database\Eloquent\Collection as EloquentCollection; use Illuminate\Database\Eloquent\Collection as EloquentCollection;
use Illuminate\Support\Carbon; use Illuminate\Support\Carbon;
use Illuminate\Support\Collection; use Illuminate\Support\Collection;
@ -98,13 +100,21 @@ public function presentEnterpriseDetail(BaselineSnapshot $snapshot, array $relat
{ {
$rendered = $this->present($snapshot); $rendered = $this->present($snapshot);
$factory = new EnterpriseDetailSectionFactory; $factory = new EnterpriseDetailSectionFactory;
$truth = app(ArtifactTruthPresenter::class)->forBaselineSnapshot($snapshot);
$stateSpec = $this->gapStatusSpec($rendered->overallGapCount); $truthBadge = $factory->statusBadge(
$stateBadge = $factory->statusBadge( $truth->primaryBadgeSpec()->label,
$stateSpec->label, $truth->primaryBadgeSpec()->color,
$stateSpec->color, $truth->primaryBadgeSpec()->icon,
$stateSpec->icon, $truth->primaryBadgeSpec()->iconColor,
$stateSpec->iconColor, );
$lifecycleSpec = BadgeRenderer::spec(BadgeDomain::BaselineSnapshotLifecycle, $snapshot->lifecycleState()->value);
$lifecycleBadge = $factory->statusBadge(
$lifecycleSpec->label,
$lifecycleSpec->color,
$lifecycleSpec->icon,
$lifecycleSpec->iconColor,
); );
$fidelitySpec = BadgeRenderer::spec(BadgeDomain::BaselineSnapshotFidelity, $rendered->overallFidelity->value); $fidelitySpec = BadgeRenderer::spec(BadgeDomain::BaselineSnapshotFidelity, $rendered->overallFidelity->value);
@ -119,21 +129,37 @@ public function presentEnterpriseDetail(BaselineSnapshot $snapshot, array $relat
static fn (array $row): int => (int) ($row['itemCount'] ?? 0), static fn (array $row): int => (int) ($row['itemCount'] ?? 0),
$rendered->summaryRows, $rendered->summaryRows,
)); ));
$currentTruth = $this->currentTruthPresentation($truth);
$currentTruthBadge = $factory->statusBadge(
$currentTruth['label'],
$currentTruth['color'],
$currentTruth['icon'],
$currentTruth['iconColor'],
);
$operatorExplanation = $truth->operatorExplanation;
return EnterpriseDetailBuilder::make('baseline_snapshot', 'workspace') return EnterpriseDetailBuilder::make('baseline_snapshot', 'workspace')
->header(new SummaryHeaderData( ->header(new SummaryHeaderData(
title: $rendered->baselineProfileName ?? 'Baseline snapshot', title: $rendered->baselineProfileName ?? 'Baseline snapshot',
subtitle: 'Snapshot #'.$rendered->snapshotId, subtitle: 'Snapshot #'.$rendered->snapshotId,
statusBadges: [$stateBadge, $fidelityBadge], statusBadges: [$truthBadge, $lifecycleBadge, $fidelityBadge],
keyFacts: [ keyFacts: [
$factory->keyFact('Captured', $this->formatTimestamp($rendered->capturedAt)), $factory->keyFact('Captured', $this->formatTimestamp($rendered->capturedAt)),
$factory->keyFact('Current truth', $currentTruth['label'], badge: $currentTruthBadge),
$factory->keyFact('Evidence mix', $rendered->fidelitySummary), $factory->keyFact('Evidence mix', $rendered->fidelitySummary),
$factory->keyFact('Evidence gaps', $rendered->overallGapCount),
$factory->keyFact('Captured items', $capturedItemCount), $factory->keyFact('Captured items', $capturedItemCount),
], ],
descriptionHint: 'Capture context, coverage, and governance links stay ahead of technical payload detail.', descriptionHint: 'Current baseline truth, lifecycle proof, and coverage stay ahead of technical payload detail.',
)) ))
->addSection( ->addSection(
$factory->viewSection(
id: 'artifact_truth',
kind: 'current_status',
title: 'Artifact truth',
view: 'filament.infolists.entries.governance-artifact-truth',
viewData: ['state' => $truth->toArray()],
description: 'Trustworthy artifact state stays separate from historical trace and support diagnostics.',
),
$factory->viewSection( $factory->viewSection(
id: 'coverage_summary', id: 'coverage_summary',
kind: 'current_status', kind: 'current_status',
@ -165,11 +191,30 @@ public function presentEnterpriseDetail(BaselineSnapshot $snapshot, array $relat
->addSupportingCard( ->addSupportingCard(
$factory->supportingFactsCard( $factory->supportingFactsCard(
kind: 'status', kind: 'status',
title: 'Snapshot status', title: 'Snapshot truth',
items: array_values(array_filter([
$factory->keyFact('Artifact truth', $truth->primaryLabel, badge: $truthBadge),
$operatorExplanation !== null
? $factory->keyFact('Result meaning', $operatorExplanation->evaluationResultLabel())
: null,
$operatorExplanation !== null
? $factory->keyFact('Result trust', $operatorExplanation->trustworthinessLabel())
: null,
$factory->keyFact('Lifecycle', $lifecycleSpec->label, badge: $lifecycleBadge),
$factory->keyFact('Current truth', $currentTruth['label'], badge: $currentTruthBadge),
$operatorExplanation !== null && $operatorExplanation->coverageStatement !== null
? $factory->keyFact('Coverage', $operatorExplanation->coverageStatement)
: null,
$factory->keyFact('Next step', $operatorExplanation?->nextActionText ?? $truth->nextStepText()),
])),
),
$factory->supportingFactsCard(
kind: 'coverage',
title: 'Coverage',
items: [ items: [
$factory->keyFact('State', $rendered->stateLabel, badge: $stateBadge),
$factory->keyFact('Overall fidelity', $fidelitySpec->label, badge: $fidelityBadge), $factory->keyFact('Overall fidelity', $fidelitySpec->label, badge: $fidelityBadge),
$factory->keyFact('Evidence gaps', $rendered->overallGapCount), $factory->keyFact('Evidence gaps', $rendered->overallGapCount),
$factory->keyFact('Captured items', $capturedItemCount),
], ],
), ),
$factory->supportingFactsCard( $factory->supportingFactsCard(
@ -177,6 +222,8 @@ public function presentEnterpriseDetail(BaselineSnapshot $snapshot, array $relat
title: 'Capture timing', title: 'Capture timing',
items: [ items: [
$factory->keyFact('Captured', $this->formatTimestamp($rendered->capturedAt)), $factory->keyFact('Captured', $this->formatTimestamp($rendered->capturedAt)),
$factory->keyFact('Completed', $this->formatTimestamp($snapshot->completed_at?->toIso8601String())),
$factory->keyFact('Failed', $this->formatTimestamp($snapshot->failed_at?->toIso8601String())),
$factory->keyFact('Identity hash', $rendered->snapshotIdentityHash), $factory->keyFact('Identity hash', $rendered->snapshotIdentityHash),
], ],
), ),
@ -328,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 private function typeLabel(string $policyType): string
{ {
return InventoryPolicyTypeMeta::baselineCompareLabel($policyType) return InventoryPolicyTypeMeta::baselineCompareLabel($policyType)

View File

@ -17,6 +17,7 @@
use App\Support\OperationRunStatus; use App\Support\OperationRunStatus;
use App\Support\Operations\ExecutionAuthorityMode; use App\Support\Operations\ExecutionAuthorityMode;
use App\Support\Operations\ExecutionDenialReasonCode; use App\Support\Operations\ExecutionDenialReasonCode;
use App\Support\Operations\LifecycleReconciliationReason;
use App\Support\Operations\OperationRunCapabilityResolver; use App\Support\Operations\OperationRunCapabilityResolver;
use App\Support\Operations\QueuedExecutionLegitimacyDecision; use App\Support\Operations\QueuedExecutionLegitimacyDecision;
use App\Support\OpsUx\BulkRunContext; use App\Support\OpsUx\BulkRunContext;
@ -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 public function failStaleQueuedRun(OperationRun $run, string $message = 'Run was queued but never started.'): OperationRun
{ {
return $this->updateRun( return $this->forceFailNonTerminalRun(
$run, $run,
status: OperationRunStatus::Completed->value, reasonCode: LifecycleReconciliationReason::StaleQueued->value,
outcome: OperationRunOutcome::Failed->value, message: $message,
failures: [ source: 'scheduled_reconciler',
[ evidence: [
'code' => 'run.stale_queued', 'status' => OperationRunStatus::Queued->value,
'message' => $message, 'created_at' => $run->created_at?->toIso8601String(),
], ],
);
}
public function isStaleRunningRun(OperationRun $run, int $thresholdMinutes = 15): bool
{
if ($run->status !== OperationRunStatus::Running->value) {
return false;
}
$startedAt = $run->started_at ?? $run->created_at;
if ($startedAt === null) {
return false;
}
return $startedAt->lte(now()->subMinutes(max(1, $thresholdMinutes)));
}
public function failStaleRunningRun(
OperationRun $run,
string $message = 'Run stopped reporting progress and was marked failed.',
): OperationRun {
return $this->forceFailNonTerminalRun(
$run,
reasonCode: LifecycleReconciliationReason::StaleRunning->value,
message: $message,
source: 'scheduled_reconciler',
evidence: [
'status' => OperationRunStatus::Running->value,
'started_at' => ($run->started_at ?? $run->created_at)?->toIso8601String(),
], ],
); );
} }
@ -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. * 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) return ProviderReasonCodes::isKnown($reasonCode)
|| ExecutionDenialReasonCode::tryFrom($reasonCode) instanceof ExecutionDenialReasonCode || ExecutionDenialReasonCode::tryFrom($reasonCode) instanceof ExecutionDenialReasonCode
|| LifecycleReconciliationReason::tryFrom($reasonCode) instanceof LifecycleReconciliationReason
|| TenantOperabilityReasonCode::tryFrom($reasonCode) instanceof TenantOperabilityReasonCode || TenantOperabilityReasonCode::tryFrom($reasonCode) instanceof TenantOperabilityReasonCode
|| RbacReason::tryFrom($reasonCode) instanceof RbacReason; || RbacReason::tryFrom($reasonCode) instanceof RbacReason;
} }
/**
* @param array<string, mixed> $evidence
* @return array<string, mixed>
*/
private function reconciliationMetadata(
string $reasonCode,
string $reasonMessage,
string $source,
array $evidence,
): array {
return [
'reconciled_at' => now()->toIso8601String(),
'reason' => RunFailureSanitizer::normalizeReasonCode($reasonCode),
'reason_code' => RunFailureSanitizer::normalizeReasonCode($reasonCode),
'reason_message' => $this->sanitizeMessage($reasonMessage),
'source' => $this->sanitizeFailureCode($source),
'evidence' => $evidence,
];
}
private function bridgeReasonForThrowable(Throwable $exception): LifecycleReconciliationReason
{
$className = strtolower(class_basename($exception));
if (str_contains($className, 'timeout') || str_contains($className, 'attempts')) {
return LifecycleReconciliationReason::InfrastructureTimeoutOrAbandonment;
}
return LifecycleReconciliationReason::QueueFailureBridge;
}
private function writeTerminalAudit(OperationRun $run): void private function writeTerminalAudit(OperationRun $run): void
{ {
$tenant = $run->tenant; $tenant = $run->tenant;
$workspace = $run->workspace; $workspace = $run->workspace;
$context = is_array($run->context) ? $run->context : []; $context = is_array($run->context) ? $run->context : [];
$executionLegitimacy = is_array($context['execution_legitimacy'] ?? null) ? $context['execution_legitimacy'] : []; $executionLegitimacy = is_array($context['execution_legitimacy'] ?? null) ? $context['execution_legitimacy'] : [];
$reconciliation = is_array($context['reconciliation'] ?? null) ? $context['reconciliation'] : [];
$operationLabel = OperationCatalog::label((string) $run->type); $operationLabel = OperationCatalog::label((string) $run->type);
$action = match ($run->outcome) { $action = match ($run->outcome) {
@ -1072,6 +1266,7 @@ private function writeTerminalAudit(OperationRun $run): void
'authority_mode' => $executionLegitimacy['authority_mode'] ?? ($context['execution_authority_mode'] ?? null), 'authority_mode' => $executionLegitimacy['authority_mode'] ?? ($context['execution_authority_mode'] ?? null),
'acting_identity_type' => $executionLegitimacy['initiator']['identity_type'] ?? ($run->user instanceof User ? 'user' : 'system'), 'acting_identity_type' => $executionLegitimacy['initiator']['identity_type'] ?? ($run->user instanceof User ? 'user' : 'system'),
'blocked_by' => $context['blocked_by'] ?? null, 'blocked_by' => $context['blocked_by'] ?? null,
'reconciliation' => $reconciliation !== [] ? $reconciliation : null,
], ],
], ],
workspace: $workspace, workspace: $workspace,

View File

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

View File

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

View File

@ -15,6 +15,14 @@ final class BadgeCatalog
private const DOMAIN_MAPPERS = [ private const DOMAIN_MAPPERS = [
BadgeDomain::AuditOutcome->value => Domains\AuditOutcomeBadge::class, BadgeDomain::AuditOutcome->value => Domains\AuditOutcomeBadge::class,
BadgeDomain::AuditActorType->value => Domains\AuditActorTypeBadge::class, BadgeDomain::AuditActorType->value => Domains\AuditActorTypeBadge::class,
BadgeDomain::GovernanceArtifactExistence->value => Domains\GovernanceArtifactExistenceBadge::class,
BadgeDomain::GovernanceArtifactContent->value => Domains\GovernanceArtifactContentBadge::class,
BadgeDomain::GovernanceArtifactFreshness->value => Domains\GovernanceArtifactFreshnessBadge::class,
BadgeDomain::GovernanceArtifactPublicationReadiness->value => Domains\GovernanceArtifactPublicationReadinessBadge::class,
BadgeDomain::GovernanceArtifactActionability->value => Domains\GovernanceArtifactActionabilityBadge::class,
BadgeDomain::OperatorExplanationEvaluationResult->value => Domains\OperatorExplanationEvaluationResultBadge::class,
BadgeDomain::OperatorExplanationTrustworthiness->value => Domains\OperatorExplanationTrustworthinessBadge::class,
BadgeDomain::BaselineSnapshotLifecycle->value => Domains\BaselineSnapshotLifecycleBadge::class,
BadgeDomain::BaselineSnapshotFidelity->value => Domains\BaselineSnapshotFidelityBadge::class, BadgeDomain::BaselineSnapshotFidelity->value => Domains\BaselineSnapshotFidelityBadge::class,
BadgeDomain::BaselineSnapshotGapStatus->value => Domains\BaselineSnapshotGapStatusBadge::class, BadgeDomain::BaselineSnapshotGapStatus->value => Domains\BaselineSnapshotGapStatusBadge::class,
BadgeDomain::OperationRunStatus->value => Domains\OperationRunStatusBadge::class, BadgeDomain::OperationRunStatus->value => Domains\OperationRunStatusBadge::class,

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

@ -8,12 +8,46 @@
use App\Support\Badges\BadgeSpec; use App\Support\Badges\BadgeSpec;
use App\Support\Badges\OperatorOutcomeTaxonomy; use App\Support\Badges\OperatorOutcomeTaxonomy;
use App\Support\OperationRunOutcome; use App\Support\OperationRunOutcome;
use App\Support\OperationRunStatus;
use App\Support\Operations\OperationRunFreshnessState;
final class OperationRunOutcomeBadge implements BadgeMapper final class OperationRunOutcomeBadge implements BadgeMapper
{ {
public function spec(mixed $value): BadgeSpec public function spec(mixed $value): BadgeSpec
{ {
$state = BadgeCatalog::normalizeState($value); $state = null;
if (is_array($value)) {
$outcome = BadgeCatalog::normalizeState($value['outcome'] ?? null);
$status = BadgeCatalog::normalizeState($value['status'] ?? null);
$freshnessState = BadgeCatalog::normalizeState($value['freshness_state'] ?? null);
if ($outcome === null) {
if ($freshnessState === OperationRunFreshnessState::ReconciledFailed->value) {
$outcome = OperationRunOutcome::Failed->value;
} elseif (
$freshnessState === OperationRunFreshnessState::LikelyStale->value
|| in_array($status, [OperationRunStatus::Queued->value, OperationRunStatus::Running->value], true)
) {
$outcome = OperationRunOutcome::Pending->value;
}
}
if ($outcome === OperationRunOutcome::Failed->value
&& $freshnessState === OperationRunFreshnessState::ReconciledFailed->value
) {
return new BadgeSpec(
label: 'Reconciled failed',
color: 'danger',
icon: 'heroicon-m-arrow-path-rounded-square',
iconColor: 'danger',
);
}
$state = $outcome;
}
$state ??= BadgeCatalog::normalizeState($value);
return match ($state) { return match ($state) {
OperationRunOutcome::Pending->value => OperatorOutcomeTaxonomy::spec(BadgeDomain::OperationRunOutcome, $state, 'heroicon-m-clock'), 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\BadgeSpec;
use App\Support\Badges\OperatorOutcomeTaxonomy; use App\Support\Badges\OperatorOutcomeTaxonomy;
use App\Support\OperationRunStatus; use App\Support\OperationRunStatus;
use App\Support\Operations\OperationRunFreshnessState;
final class OperationRunStatusBadge implements BadgeMapper final class OperationRunStatusBadge implements BadgeMapper
{ {
public function spec(mixed $value): BadgeSpec public function spec(mixed $value): BadgeSpec
{ {
$state = BadgeCatalog::normalizeState($value); $state = null;
if (is_array($value)) {
$status = BadgeCatalog::normalizeState($value['status'] ?? null);
$freshnessState = BadgeCatalog::normalizeState($value['freshness_state'] ?? null);
if (in_array($status, [OperationRunStatus::Queued->value, OperationRunStatus::Running->value], true)
&& $freshnessState === OperationRunFreshnessState::LikelyStale->value
) {
return new BadgeSpec(
label: 'Likely stale',
color: 'warning',
icon: 'heroicon-m-exclamation-triangle',
iconColor: 'warning',
);
}
$state = $status;
}
$state ??= BadgeCatalog::normalizeState($value);
return match ($state) { return match ($state) {
OperationRunStatus::Queued->value => OperatorOutcomeTaxonomy::spec(BadgeDomain::OperationRunStatus, $state, 'heroicon-m-clock'), OperationRunStatus::Queued->value => OperatorOutcomeTaxonomy::spec(BadgeDomain::OperationRunStatus, $state, 'heroicon-m-clock'),

View File

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

View File

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

View File

@ -21,6 +21,328 @@ final class OperatorOutcomeTaxonomy
* }>> * }>>
*/ */
private const ENTRIES = [ private const ENTRIES = [
'governance_artifact_existence' => [
'not_created' => [
'axis' => 'artifact_existence',
'label' => 'Not created yet',
'color' => 'info',
'classification' => 'primary',
'next_action_policy' => 'optional',
'legacy_aliases' => ['No artifact'],
'notes' => 'The intended artifact has not been produced yet.',
],
'historical_only' => [
'axis' => 'artifact_existence',
'label' => 'Historical artifact',
'color' => 'gray',
'classification' => 'primary',
'next_action_policy' => 'none',
'legacy_aliases' => ['Historical only'],
'notes' => 'The artifact remains readable for history but is no longer the current working artifact.',
],
'created' => [
'axis' => 'artifact_existence',
'label' => 'Artifact available',
'color' => 'success',
'classification' => 'primary',
'next_action_policy' => 'none',
'legacy_aliases' => ['Created'],
'notes' => 'The intended artifact exists and can be inspected.',
],
'created_but_not_usable' => [
'axis' => 'artifact_existence',
'label' => 'Artifact not usable',
'color' => 'warning',
'classification' => 'primary',
'next_action_policy' => 'required',
'legacy_aliases' => ['Created but not usable'],
'notes' => 'The artifact record exists, but the operator cannot safely rely on it for the primary task.',
],
],
'governance_artifact_content' => [
'trusted' => [
'axis' => 'data_coverage',
'label' => 'Trustworthy artifact',
'color' => 'success',
'classification' => 'primary',
'next_action_policy' => 'none',
'legacy_aliases' => ['Trusted'],
'notes' => 'The artifact content is fit for the primary operator workflow.',
],
'partial' => [
'axis' => 'data_coverage',
'label' => 'Partially complete',
'color' => 'warning',
'classification' => 'primary',
'next_action_policy' => 'required',
'legacy_aliases' => ['Partially complete'],
'notes' => 'The artifact exists but key content is incomplete.',
],
'missing_input' => [
'axis' => 'data_coverage',
'label' => 'Missing input',
'color' => 'warning',
'classification' => 'primary',
'next_action_policy' => 'required',
'legacy_aliases' => ['Missing'],
'notes' => 'The artifact is blocked by missing upstream inputs.',
],
'metadata_only' => [
'axis' => 'evidence_depth',
'label' => 'Metadata only',
'color' => 'info',
'classification' => 'diagnostic',
'next_action_policy' => 'none',
'legacy_aliases' => ['Metadata-only'],
'notes' => 'Only metadata is available. This is diagnostic context and should not replace the primary truth state.',
],
'reference_only' => [
'axis' => 'evidence_depth',
'label' => 'Reference only',
'color' => 'info',
'classification' => 'diagnostic',
'next_action_policy' => 'none',
'legacy_aliases' => ['Reference-only'],
'notes' => 'Only reference placeholders are available. This is diagnostic context and should not replace the primary truth state.',
],
'empty' => [
'axis' => 'data_coverage',
'label' => 'Empty snapshot',
'color' => 'warning',
'classification' => 'primary',
'next_action_policy' => 'required',
'legacy_aliases' => ['Empty'],
'notes' => 'The artifact exists but captured no usable content.',
],
'unsupported' => [
'axis' => 'product_support_maturity',
'label' => 'Support limited',
'color' => 'gray',
'classification' => 'diagnostic',
'next_action_policy' => 'none',
'legacy_aliases' => ['Unsupported'],
'notes' => 'The product is representing the source with limited fidelity. This remains diagnostic unless a stronger truth dimension applies.',
],
],
'governance_artifact_freshness' => [
'current' => [
'axis' => 'data_freshness',
'label' => 'Current',
'color' => 'success',
'classification' => 'primary',
'next_action_policy' => 'none',
'legacy_aliases' => ['Fresh'],
'notes' => 'The available artifact is current enough for the primary task.',
],
'stale' => [
'axis' => 'data_freshness',
'label' => 'Refresh recommended',
'color' => 'warning',
'classification' => 'primary',
'next_action_policy' => 'optional',
'legacy_aliases' => ['Refresh recommended'],
'notes' => 'The artifact exists but should be refreshed before relying on it.',
],
'unknown' => [
'axis' => 'data_freshness',
'label' => 'Freshness unknown',
'color' => 'gray',
'classification' => 'diagnostic',
'next_action_policy' => 'none',
'legacy_aliases' => ['Unknown'],
'notes' => 'The system cannot determine freshness from the available payload.',
],
],
'governance_artifact_publication_readiness' => [
'not_applicable' => [
'axis' => 'publication_readiness',
'label' => 'Not applicable',
'color' => 'gray',
'classification' => 'diagnostic',
'next_action_policy' => 'none',
'legacy_aliases' => ['N/A'],
'notes' => 'Publication readiness does not apply to this artifact family.',
],
'internal_only' => [
'axis' => 'publication_readiness',
'label' => 'Internal only',
'color' => 'info',
'classification' => 'primary',
'next_action_policy' => 'optional',
'legacy_aliases' => ['Draft'],
'notes' => 'The artifact is useful internally but not ready for stakeholder delivery.',
],
'publishable' => [
'axis' => 'publication_readiness',
'label' => 'Publishable',
'color' => 'success',
'classification' => 'primary',
'next_action_policy' => 'none',
'legacy_aliases' => ['Ready'],
'notes' => 'The artifact is ready for stakeholder publication or export.',
],
'blocked' => [
'axis' => 'publication_readiness',
'label' => 'Publication blocked',
'color' => 'warning',
'classification' => 'primary',
'next_action_policy' => 'required',
'legacy_aliases' => ['Not publishable'],
'notes' => 'The artifact exists but is blocked from publication or export.',
],
],
'governance_artifact_actionability' => [
'none' => [
'axis' => 'operator_actionability',
'label' => 'No action needed',
'color' => 'gray',
'classification' => 'primary',
'next_action_policy' => 'none',
'legacy_aliases' => ['No follow-up'],
'notes' => 'The current non-green state is informational only and does not require action.',
],
'optional' => [
'axis' => 'operator_actionability',
'label' => 'Review recommended',
'color' => 'info',
'classification' => 'primary',
'next_action_policy' => 'optional',
'legacy_aliases' => ['Optional follow-up'],
'notes' => 'The artifact can be used, but the operator should review the follow-up guidance.',
],
'required' => [
'axis' => 'operator_actionability',
'label' => 'Action required',
'color' => 'danger',
'classification' => 'primary',
'next_action_policy' => 'required',
'legacy_aliases' => ['Required follow-up'],
'notes' => 'The artifact cannot be trusted for the primary task until an operator addresses the issue.',
],
],
'operator_explanation_evaluation_result' => [
'full_result' => [
'axis' => 'execution_outcome',
'label' => 'Complete result',
'color' => 'success',
'classification' => 'primary',
'next_action_policy' => 'none',
'legacy_aliases' => ['Full result'],
'notes' => 'The result can be read as complete for the intended operator decision.',
],
'incomplete_result' => [
'axis' => 'data_coverage',
'label' => 'Incomplete result',
'color' => 'warning',
'classification' => 'primary',
'next_action_policy' => 'required',
'legacy_aliases' => ['Partial result'],
'notes' => 'A result exists, but missing or partial coverage limits what it means.',
],
'suppressed_result' => [
'axis' => 'data_coverage',
'label' => 'Suppressed result',
'color' => 'warning',
'classification' => 'primary',
'next_action_policy' => 'required',
'legacy_aliases' => ['Suppressed'],
'notes' => 'Normal output was intentionally suppressed because the system could not safely present it as final.',
],
'no_result' => [
'axis' => 'execution_outcome',
'label' => 'No issues detected',
'color' => 'success',
'classification' => 'primary',
'next_action_policy' => 'none',
'legacy_aliases' => ['No result'],
'notes' => 'The workflow produced no decision-relevant follow-up for the operator.',
],
'unavailable' => [
'axis' => 'execution_outcome',
'label' => 'Result unavailable',
'color' => 'warning',
'classification' => 'primary',
'next_action_policy' => 'required',
'legacy_aliases' => ['Unavailable'],
'notes' => 'A usable result is not currently available for this surface.',
],
],
'operator_explanation_trustworthiness' => [
'trustworthy' => [
'axis' => 'data_coverage',
'label' => 'Trustworthy',
'color' => 'success',
'classification' => 'primary',
'next_action_policy' => 'none',
'legacy_aliases' => ['Decision grade'],
'notes' => 'The operator can rely on this result for the intended task.',
],
'limited_confidence' => [
'axis' => 'data_coverage',
'label' => 'Limited confidence',
'color' => 'warning',
'classification' => 'primary',
'next_action_policy' => 'optional',
'legacy_aliases' => ['Use with caution'],
'notes' => 'The result is still useful, but the operator should account for documented limitations.',
],
'diagnostic_only' => [
'axis' => 'evidence_depth',
'label' => 'Diagnostic only',
'color' => 'info',
'classification' => 'diagnostic',
'next_action_policy' => 'none',
'legacy_aliases' => ['Diagnostics only'],
'notes' => 'The result is suitable for diagnostics only, not for a final decision.',
],
'unusable' => [
'axis' => 'operator_actionability',
'label' => 'Not usable yet',
'color' => 'danger',
'classification' => 'primary',
'next_action_policy' => 'required',
'legacy_aliases' => ['Unusable'],
'notes' => 'The operator should not rely on this result until the blocking issue is resolved.',
],
],
'baseline_snapshot_lifecycle' => [
'building' => [
'axis' => 'execution_lifecycle',
'label' => 'Building',
'color' => 'info',
'classification' => 'primary',
'next_action_policy' => 'none',
'legacy_aliases' => ['In progress'],
'notes' => 'The snapshot row exists, but completion proof has not finished yet.',
],
'complete' => [
'axis' => 'data_coverage',
'label' => 'Complete',
'color' => 'success',
'classification' => 'primary',
'next_action_policy' => 'none',
'legacy_aliases' => ['Ready'],
'notes' => 'The snapshot passed completion proof and is eligible for compare.',
],
'incomplete' => [
'axis' => 'data_coverage',
'label' => 'Incomplete',
'color' => 'warning',
'classification' => 'primary',
'next_action_policy' => 'required',
'legacy_aliases' => ['Partial'],
'notes' => 'The snapshot exists but did not finish cleanly and is not usable for compare.',
],
'superseded' => [
'axis' => 'data_freshness',
'label' => 'Superseded',
'color' => 'gray',
'classification' => 'primary',
'next_action_policy' => 'none',
'legacy_aliases' => ['Historical'],
'notes' => 'A newer complete snapshot is the effective current baseline truth.',
],
],
'operation_run_status' => [ 'operation_run_status' => [
'queued' => [ 'queued' => [
'axis' => 'execution_lifecycle', 'axis' => 'execution_lifecycle',
@ -586,6 +908,11 @@ public static function curatedExamples(): array
{ {
return [ return [
['name' => 'Operation blocked by missing prerequisite', 'domain' => BadgeDomain::OperationRunOutcome, 'raw_value' => 'blocked'], ['name' => 'Operation blocked by missing prerequisite', 'domain' => BadgeDomain::OperationRunOutcome, 'raw_value' => 'blocked'],
['name' => 'Artifact exists but is not usable', 'domain' => BadgeDomain::GovernanceArtifactExistence, 'raw_value' => 'created_but_not_usable'],
['name' => 'Artifact is trustworthy', 'domain' => BadgeDomain::GovernanceArtifactContent, 'raw_value' => 'trusted'],
['name' => 'Artifact is stale', 'domain' => BadgeDomain::GovernanceArtifactFreshness, 'raw_value' => 'stale'],
['name' => 'Artifact is publishable', 'domain' => BadgeDomain::GovernanceArtifactPublicationReadiness, 'raw_value' => 'publishable'],
['name' => 'Artifact requires action', 'domain' => BadgeDomain::GovernanceArtifactActionability, 'raw_value' => 'required'],
['name' => 'Operation completed with follow-up', 'domain' => BadgeDomain::OperationRunOutcome, 'raw_value' => 'partially_succeeded'], ['name' => 'Operation completed with follow-up', 'domain' => BadgeDomain::OperationRunOutcome, 'raw_value' => 'partially_succeeded'],
['name' => 'Operation completed successfully', 'domain' => BadgeDomain::OperationRunOutcome, 'raw_value' => 'succeeded'], ['name' => 'Operation completed successfully', 'domain' => BadgeDomain::OperationRunOutcome, 'raw_value' => 'succeeded'],
['name' => 'Evidence not collected yet', 'domain' => BadgeDomain::EvidenceCompleteness, 'raw_value' => 'missing'], ['name' => 'Evidence not collected yet', 'domain' => BadgeDomain::EvidenceCompleteness, 'raw_value' => 'missing'],

View File

@ -6,6 +6,7 @@
enum OperatorSemanticAxis: string enum OperatorSemanticAxis: string
{ {
case ArtifactExistence = 'artifact_existence';
case ExecutionLifecycle = 'execution_lifecycle'; case ExecutionLifecycle = 'execution_lifecycle';
case ExecutionOutcome = 'execution_outcome'; case ExecutionOutcome = 'execution_outcome';
case ItemResult = 'item_result'; case ItemResult = 'item_result';
@ -20,6 +21,7 @@ enum OperatorSemanticAxis: string
public function label(): string public function label(): string
{ {
return match ($this) { return match ($this) {
self::ArtifactExistence => 'Artifact existence',
self::ExecutionLifecycle => 'Execution lifecycle', self::ExecutionLifecycle => 'Execution lifecycle',
self::ExecutionOutcome => 'Execution outcome', self::ExecutionOutcome => 'Execution outcome',
self::ItemResult => 'Item result', self::ItemResult => 'Item result',
@ -36,6 +38,7 @@ public function label(): string
public function definition(): string public function definition(): string
{ {
return match ($this) { return match ($this) {
self::ArtifactExistence => 'Whether the intended governance artifact actually exists and can be located.',
self::ExecutionLifecycle => 'Where a run sits in its execution flow.', self::ExecutionLifecycle => 'Where a run sits in its execution flow.',
self::ExecutionOutcome => 'What happened when execution finished or stopped.', self::ExecutionOutcome => 'What happened when execution finished or stopped.',
self::ItemResult => 'How one restore or preview item resolved.', self::ItemResult => 'How one restore or preview item resolved.',

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -105,4 +105,28 @@ public static function allowedSummaryKeys(): array
{ {
return OperationSummaryKeys::all(); return OperationSummaryKeys::all();
} }
public static function governanceArtifactFamily(string $operationType): ?string
{
return match (trim($operationType)) {
'baseline_capture' => 'baseline_snapshot',
'tenant.evidence.snapshot.generate' => 'evidence_snapshot',
'tenant.review.compose' => 'tenant_review',
'tenant.review_pack.generate' => 'review_pack',
default => null,
};
}
public static function isGovernanceArtifactOperation(string $operationType): bool
{
return self::governanceArtifactFamily($operationType) !== null;
}
public static function supportsOperatorExplanation(string $operationType): bool
{
$operationType = trim($operationType);
return self::isGovernanceArtifactOperation($operationType)
|| $operationType === 'baseline_compare';
}
} }

View File

@ -5,13 +5,20 @@
use App\Filament\Pages\BaselineCompareLanding; use App\Filament\Pages\BaselineCompareLanding;
use App\Filament\Resources\BackupScheduleResource; use App\Filament\Resources\BackupScheduleResource;
use App\Filament\Resources\BackupSetResource; use App\Filament\Resources\BackupSetResource;
use App\Filament\Resources\BaselineSnapshotResource;
use App\Filament\Resources\EntraGroupResource; use App\Filament\Resources\EntraGroupResource;
use App\Filament\Resources\EvidenceSnapshotResource;
use App\Filament\Resources\InventoryItemResource; use App\Filament\Resources\InventoryItemResource;
use App\Filament\Resources\PolicyResource; use App\Filament\Resources\PolicyResource;
use App\Filament\Resources\ProviderConnectionResource; use App\Filament\Resources\ProviderConnectionResource;
use App\Filament\Resources\RestoreRunResource; use App\Filament\Resources\RestoreRunResource;
use App\Filament\Resources\ReviewPackResource;
use App\Filament\Resources\TenantReviewResource;
use App\Models\EvidenceSnapshot;
use App\Models\OperationRun; use App\Models\OperationRun;
use App\Models\ReviewPack;
use App\Models\Tenant; use App\Models\Tenant;
use App\Models\TenantReview;
use App\Support\Navigation\CanonicalNavigationContext; use App\Support\Navigation\CanonicalNavigationContext;
final class OperationRunLinks final class OperationRunLinks
@ -79,6 +86,14 @@ public static function related(OperationRun $run, ?Tenant $tenant): array
$links['Drift'] = BaselineCompareLanding::getUrl(panel: 'tenant', tenant: $tenant); $links['Drift'] = BaselineCompareLanding::getUrl(panel: 'tenant', tenant: $tenant);
} }
if ($run->type === 'baseline_capture') {
$snapshotId = data_get($context, 'result.snapshot_id');
if (is_numeric($snapshotId)) {
$links['Baseline Snapshot'] = BaselineSnapshotResource::getUrl('view', ['record' => (int) $snapshotId], panel: 'admin');
}
}
if (in_array($run->type, ['backup_set.add_policies', 'backup_set.remove_policies'], true)) { if (in_array($run->type, ['backup_set.add_policies', 'backup_set.remove_policies'], true)) {
$links['Backup Sets'] = BackupSetResource::getUrl('index', panel: 'tenant', tenant: $tenant); $links['Backup Sets'] = BackupSetResource::getUrl('index', panel: 'tenant', tenant: $tenant);
@ -101,6 +116,39 @@ public static function related(OperationRun $run, ?Tenant $tenant): array
} }
} }
if ($run->type === 'tenant.evidence.snapshot.generate') {
$snapshot = EvidenceSnapshot::query()
->where('operation_run_id', (int) $run->getKey())
->latest('id')
->first();
if ($snapshot instanceof EvidenceSnapshot) {
$links['Evidence Snapshot'] = EvidenceSnapshotResource::getUrl('view', ['record' => $snapshot], tenant: $tenant);
}
}
if ($run->type === 'tenant.review.compose') {
$review = TenantReview::query()
->where('operation_run_id', (int) $run->getKey())
->latest('id')
->first();
if ($review instanceof TenantReview) {
$links['Tenant Review'] = TenantReviewResource::tenantScopedUrl('view', ['record' => $review], $tenant);
}
}
if ($run->type === 'tenant.review_pack.generate') {
$pack = ReviewPack::query()
->where('operation_run_id', (int) $run->getKey())
->latest('id')
->first();
if ($pack instanceof ReviewPack) {
$links['Review Pack'] = ReviewPackResource::getUrl('view', ['record' => $pack], tenant: $tenant);
}
}
return array_filter($links, static fn (?string $url): bool => is_string($url) && $url !== ''); return array_filter($links, static fn (?string $url): bool => is_string($url) && $url !== '');
} }
} }

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; namespace App\Support\Operations;
use App\Models\OperationRun;
use App\Support\Auth\Capabilities; use App\Support\Auth\Capabilities;
final class OperationRunCapabilityResolver final class OperationRunCapabilityResolver
{ {
public function requiredCapabilityForRun(OperationRun $run): ?string
{
return $this->requiredCapabilityForType((string) $run->type);
}
public function requiredCapabilityForType(string $operationType): ?string public function requiredCapabilityForType(string $operationType): ?string
{ {
$operationType = trim($operationType); $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\OperationRun;
use App\Models\Tenant; use App\Models\Tenant;
use App\Support\OperationCatalog; use App\Support\OperationCatalog;
use App\Support\Operations\OperationRunFreshnessState;
use App\Support\ReasonTranslation\ReasonPresenter; use App\Support\ReasonTranslation\ReasonPresenter;
use App\Support\RedactionIntegrity; use App\Support\RedactionIntegrity;
use App\Support\Ui\GovernanceArtifactTruth\ArtifactTruthPresenter;
use App\Support\Ui\OperatorExplanation\OperatorExplanationPattern;
use Filament\Notifications\Notification as FilamentNotification; use Filament\Notifications\Notification as FilamentNotification;
final class OperationUxPresenter final class OperationUxPresenter
@ -98,11 +101,33 @@ public static function surfaceGuidance(OperationRun $run): ?string
$uxStatus = OperationStatusNormalizer::toUxStatus($run->status, $run->outcome); $uxStatus = OperationStatusNormalizer::toUxStatus($run->status, $run->outcome);
$reasonEnvelope = self::reasonEnvelope($run); $reasonEnvelope = self::reasonEnvelope($run);
$reasonGuidance = app(ReasonPresenter::class)->guidance($reasonEnvelope); $reasonGuidance = app(ReasonPresenter::class)->guidance($reasonEnvelope);
$operatorExplanationGuidance = self::operatorExplanationGuidance($run);
$nextStepLabel = self::firstNextStepLabel($run); $nextStepLabel = self::firstNextStepLabel($run);
$freshnessState = self::freshnessState($run);
if (in_array($uxStatus, ['blocked', 'failed', 'partial'], true) && $reasonGuidance !== null) { 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; return $reasonGuidance;
} }
}
if ($uxStatus === 'succeeded' && $operatorExplanationGuidance !== null) {
return $operatorExplanationGuidance;
}
return match ($uxStatus) { return match ($uxStatus) {
'queued' => 'No action needed yet. The run is waiting for a worker.', 'queued' => 'No action needed yet. The run is waiting for a worker.',
@ -124,15 +149,44 @@ public static function surfaceGuidance(OperationRun $run): ?string
public static function surfaceFailureDetail(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); $reasonEnvelope = self::reasonEnvelope($run);
if ($reasonEnvelope !== null) { if ($reasonEnvelope !== null) {
return $reasonEnvelope->shortExplanation; 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); $uxStatus = OperationStatusNormalizer::toUxStatus($run->status, $run->outcome);
$reasonEnvelope = self::reasonEnvelope($run); $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) { return match ($uxStatus) {
'succeeded' => [ 'succeeded' => [
@ -223,4 +286,32 @@ private static function reasonEnvelope(OperationRun $run): ?\App\Support\ReasonT
{ {
return app(ReasonPresenter::class)->forOperationRun($run, 'notification'); 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\Models\OperationRun;
use App\Support\OperationCatalog; use App\Support\OperationCatalog;
use App\Support\Operations\OperationRunFreshnessState;
use Illuminate\Support\Facades\Cache; use Illuminate\Support\Facades\Cache;
final class RunDurationInsights final class RunDurationInsights
@ -118,6 +119,10 @@ public static function expectedHuman(OperationRun $run): ?string
public static function stuckGuidance(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); $uxStatus = OperationStatusNormalizer::toUxStatus($run->status, $run->outcome);
if (! in_array($uxStatus, ['queued', 'running'], true)) { if (! in_array($uxStatus, ['queued', 'running'], true)) {

View File

@ -3,7 +3,9 @@
namespace App\Support\OpsUx; namespace App\Support\OpsUx;
use App\Services\Intune\SecretClassificationService; use App\Services\Intune\SecretClassificationService;
use App\Support\Baselines\BaselineReasonCodes;
use App\Support\Operations\ExecutionDenialReasonCode; use App\Support\Operations\ExecutionDenialReasonCode;
use App\Support\Operations\LifecycleReconciliationReason;
use App\Support\Providers\ProviderReasonCodes; use App\Support\Providers\ProviderReasonCodes;
final class RunFailureSanitizer final class RunFailureSanitizer
@ -130,7 +132,15 @@ public static function isStructuredOperatorReasonCode(string $candidate): bool
ExecutionDenialReasonCode::cases(), 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 public static function sanitizeMessage(string $message): string

View File

@ -5,6 +5,7 @@
namespace App\Support\ReasonTranslation; namespace App\Support\ReasonTranslation;
use App\Support\ReasonTranslation\Contracts\TranslatesReasonCode; use App\Support\ReasonTranslation\Contracts\TranslatesReasonCode;
use App\Support\Ui\OperatorExplanation\TrustworthinessLevel;
use Illuminate\Support\Str; use Illuminate\Support\Str;
final class FallbackReasonTranslator implements TranslatesReasonCode final class FallbackReasonTranslator implements TranslatesReasonCode
@ -43,6 +44,8 @@ public function translate(string $reasonCode, string $surface = 'detail', array
nextSteps: $nextSteps, nextSteps: $nextSteps,
showNoActionNeeded: $actionability === 'non_actionable', showNoActionNeeded: $actionability === 'non_actionable',
diagnosticCodeLabel: $normalizedCode, 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.')], 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\ProviderConnection;
use App\Models\Tenant; use App\Models\Tenant;
use App\Support\Operations\ExecutionDenialReasonCode; use App\Support\Operations\ExecutionDenialReasonCode;
use App\Support\Operations\LifecycleReconciliationReason;
use App\Support\Providers\ProviderReasonCodes; use App\Support\Providers\ProviderReasonCodes;
use App\Support\Providers\ProviderReasonTranslator; use App\Support\Providers\ProviderReasonTranslator;
use App\Support\RbacReason; use App\Support\RbacReason;
@ -15,6 +16,8 @@
final class ReasonPresenter final class ReasonPresenter
{ {
public const string GOVERNANCE_ARTIFACT_TRUTH_ARTIFACT = ReasonTranslator::GOVERNANCE_ARTIFACT_TRUTH_ARTIFACT;
public function __construct( public function __construct(
private readonly ReasonTranslator $reasonTranslator, private readonly ReasonTranslator $reasonTranslator,
) {} ) {}
@ -22,14 +25,16 @@ public function __construct(
public function forOperationRun(OperationRun $run, string $surface = 'detail'): ?ReasonResolutionEnvelope public function forOperationRun(OperationRun $run, string $surface = 'detail'): ?ReasonResolutionEnvelope
{ {
$context = is_array($run->context) ? $run->context : []; $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) { if ($storedTranslation !== null) {
$storedEnvelope = ReasonResolutionEnvelope::fromArray($storedTranslation); $storedEnvelope = ReasonResolutionEnvelope::fromArray($storedTranslation);
if ($storedEnvelope instanceof ReasonResolutionEnvelope) { if ($storedEnvelope instanceof ReasonResolutionEnvelope) {
if ($storedEnvelope->nextSteps === [] && is_array($context['next_steps'] ?? null)) { $nextSteps = $this->operationRunNextSteps($context);
return $storedEnvelope->withNextSteps(NextStepOption::collect($context['next_steps']));
if ($storedEnvelope->nextSteps === [] && $nextSteps !== []) {
return $storedEnvelope->withNextSteps($nextSteps);
} }
return $storedEnvelope; return $storedEnvelope;
@ -37,7 +42,8 @@ public function forOperationRun(OperationRun $run, string $surface = 'detail'):
} }
$contextReasonCode = data_get($context, 'execution_legitimacy.reason_code') $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) !== '') { if (is_string($contextReasonCode) && trim($contextReasonCode) !== '') {
return $this->translateOperationRunReason(trim($contextReasonCode), $surface, $context); return $this->translateOperationRunReason(trim($contextReasonCode), $surface, $context);
@ -65,11 +71,33 @@ public function forOperationRun(OperationRun $run, string $surface = 'detail'):
return $envelope; return $envelope;
} }
$legacyNextSteps = is_array($context['next_steps'] ?? null) ? NextStepOption::collect($context['next_steps']) : []; $legacyNextSteps = $this->operationRunNextSteps($context);
return $legacyNextSteps !== [] ? $envelope->withNextSteps($legacyNextSteps) : $envelope; 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 * @param array<string, mixed> $context
*/ */
@ -89,6 +117,7 @@ private function isDirectlyTranslatableOperationReason(string $reasonCode): bool
return ProviderReasonCodes::isKnown($reasonCode) return ProviderReasonCodes::isKnown($reasonCode)
|| ExecutionDenialReasonCode::tryFrom($reasonCode) instanceof ExecutionDenialReasonCode || ExecutionDenialReasonCode::tryFrom($reasonCode) instanceof ExecutionDenialReasonCode
|| LifecycleReconciliationReason::tryFrom($reasonCode) instanceof LifecycleReconciliationReason
|| TenantOperabilityReasonCode::tryFrom($reasonCode) instanceof TenantOperabilityReasonCode || TenantOperabilityReasonCode::tryFrom($reasonCode) instanceof TenantOperabilityReasonCode
|| RbacReason::tryFrom($reasonCode) instanceof RbacReason; || RbacReason::tryFrom($reasonCode) instanceof RbacReason;
} }
@ -134,6 +163,22 @@ public function forRbacReason(RbacReason|string|null $reasonCode, string $surfac
); );
} }
/**
* @param array<string, mixed> $context
*/
public function forArtifactTruth(
?string $reasonCode,
string $surface = 'detail',
array $context = [],
): ?ReasonResolutionEnvelope {
return $this->reasonTranslator->translate(
reasonCode: $reasonCode,
artifactKey: self::GOVERNANCE_ARTIFACT_TRUTH_ARTIFACT,
surface: $surface,
context: $context,
);
}
public function diagnosticCode(?ReasonResolutionEnvelope $envelope): ?string public function diagnosticCode(?ReasonResolutionEnvelope $envelope): ?string
{ {
return $envelope?->diagnosticCode(); return $envelope?->diagnosticCode();
@ -149,6 +194,26 @@ public function shortExplanation(?ReasonResolutionEnvelope $envelope): ?string
return $envelope?->shortExplanation; 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 public function guidance(?ReasonResolutionEnvelope $envelope): ?string
{ {
return $envelope?->guidanceText(); return $envelope?->guidanceText();

View File

@ -4,6 +4,7 @@
namespace App\Support\ReasonTranslation; namespace App\Support\ReasonTranslation;
use App\Support\Ui\OperatorExplanation\TrustworthinessLevel;
use InvalidArgumentException; use InvalidArgumentException;
final readonly class ReasonResolutionEnvelope final readonly class ReasonResolutionEnvelope
@ -19,6 +20,8 @@ public function __construct(
public array $nextSteps = [], public array $nextSteps = [],
public bool $showNoActionNeeded = false, public bool $showNoActionNeeded = false,
public ?string $diagnosticCodeLabel = null, public ?string $diagnosticCodeLabel = null,
public string $trustImpact = TrustworthinessLevel::LimitedConfidence->value,
public ?string $absencePattern = null,
) { ) {
if (trim($this->internalCode) === '') { if (trim($this->internalCode) === '') {
throw new InvalidArgumentException('Reason envelopes must preserve an internal code.'); 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); 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) { foreach ($this->nextSteps as $nextStep) {
if (! $nextStep instanceof NextStepOption) { if (! $nextStep instanceof NextStepOption) {
throw new InvalidArgumentException('Reason envelopes only support NextStepOption instances.'); 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) $diagnosticCodeLabel = is_string($data['diagnostic_code_label'] ?? null)
? trim((string) $data['diagnostic_code_label']) ? trim((string) $data['diagnostic_code_label'])
: (is_string($data['diagnosticCodeLabel'] ?? null) ? trim((string) $data['diagnosticCodeLabel']) : null); : (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 === '') { if ($internalCode === '' || $operatorLabel === '' || $shortExplanation === '' || $actionability === '') {
return null; return null;
@ -83,6 +110,8 @@ public static function fromArray(array $data): ?self
nextSteps: $nextSteps, nextSteps: $nextSteps,
showNoActionNeeded: $showNoActionNeeded, showNoActionNeeded: $showNoActionNeeded,
diagnosticCodeLabel: $diagnosticCodeLabel !== '' ? $diagnosticCodeLabel : null, 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, nextSteps: $nextSteps,
showNoActionNeeded: $this->showNoActionNeeded, showNoActionNeeded: $this->showNoActionNeeded,
diagnosticCodeLabel: $this->diagnosticCodeLabel, diagnosticCodeLabel: $this->diagnosticCodeLabel,
trustImpact: $this->trustImpact,
absencePattern: $this->absencePattern,
); );
} }
@ -179,6 +210,8 @@ public function toLegacyNextSteps(): array
* }>, * }>,
* show_no_action_needed: bool, * show_no_action_needed: bool,
* diagnostic_code_label: string * diagnostic_code_label: string
* trust_impact: string,
* absence_pattern: ?string
* } * }
*/ */
public function toArray(): array public function toArray(): array
@ -194,6 +227,8 @@ public function toArray(): array
), ),
'show_no_action_needed' => $this->showNoActionNeeded, 'show_no_action_needed' => $this->showNoActionNeeded,
'diagnostic_code_label' => $this->diagnosticCode(), 'diagnostic_code_label' => $this->diagnosticCode(),
'trust_impact' => $this->trustImpact,
'absence_pattern' => $this->absencePattern,
]; ];
} }
} }

View File

@ -4,11 +4,15 @@
namespace App\Support\ReasonTranslation; namespace App\Support\ReasonTranslation;
use App\Support\Baselines\BaselineCompareReasonCode;
use App\Support\Baselines\BaselineReasonCodes;
use App\Support\Operations\ExecutionDenialReasonCode; use App\Support\Operations\ExecutionDenialReasonCode;
use App\Support\Operations\LifecycleReconciliationReason;
use App\Support\Providers\ProviderReasonCodes; use App\Support\Providers\ProviderReasonCodes;
use App\Support\Providers\ProviderReasonTranslator; use App\Support\Providers\ProviderReasonTranslator;
use App\Support\RbacReason; use App\Support\RbacReason;
use App\Support\Tenants\TenantOperabilityReasonCode; use App\Support\Tenants\TenantOperabilityReasonCode;
use App\Support\Ui\OperatorExplanation\TrustworthinessLevel;
final class ReasonTranslator final class ReasonTranslator
{ {
@ -18,6 +22,8 @@ final class ReasonTranslator
public const string RBAC_ARTIFACT = 'rbac_reason'; public const string RBAC_ARTIFACT = 'rbac_reason';
public const string GOVERNANCE_ARTIFACT_TRUTH_ARTIFACT = 'governance_artifact_truth_reason';
public function __construct( public function __construct(
private readonly ProviderReasonTranslator $providerReasonTranslator, private readonly ProviderReasonTranslator $providerReasonTranslator,
private readonly FallbackReasonTranslator $fallbackReasonTranslator, private readonly FallbackReasonTranslator $fallbackReasonTranslator,
@ -41,12 +47,18 @@ public function translate(
return match (true) { return match (true) {
$artifactKey === ProviderReasonTranslator::ARTIFACT_KEY, $artifactKey === ProviderReasonTranslator::ARTIFACT_KEY,
$artifactKey === null && $this->providerReasonTranslator->canTranslate($reasonCode) => $this->providerReasonTranslator->translate($reasonCode, $surface, $context), $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 === self::EXECUTION_DENIAL_ARTIFACT,
$artifactKey === null && ExecutionDenialReasonCode::tryFrom($reasonCode) instanceof ExecutionDenialReasonCode => ExecutionDenialReasonCode::tryFrom($reasonCode)?->toReasonResolutionEnvelope($surface, $context), $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 === self::TENANT_OPERABILITY_ARTIFACT,
$artifactKey === null && TenantOperabilityReasonCode::tryFrom($reasonCode) instanceof TenantOperabilityReasonCode => TenantOperabilityReasonCode::tryFrom($reasonCode)?->toReasonResolutionEnvelope($surface, $context), $artifactKey === null && TenantOperabilityReasonCode::tryFrom($reasonCode) instanceof TenantOperabilityReasonCode => TenantOperabilityReasonCode::tryFrom($reasonCode)?->toReasonResolutionEnvelope($surface, $context),
$artifactKey === self::RBAC_ARTIFACT, $artifactKey === self::RBAC_ARTIFACT,
$artifactKey === null && RbacReason::tryFrom($reasonCode) instanceof RbacReason => RbacReason::tryFrom($reasonCode)?->toReasonResolutionEnvelope($surface, $context), $artifactKey === null && RbacReason::tryFrom($reasonCode) instanceof RbacReason => RbacReason::tryFrom($reasonCode)?->toReasonResolutionEnvelope($surface, $context),
$artifactKey === self::GOVERNANCE_ARTIFACT_TRUTH_ARTIFACT => $this->fallbackReasonTranslator->translate($reasonCode, $surface, $context),
$artifactKey === null && ProviderReasonCodes::isKnown($reasonCode) => $this->providerReasonTranslator->translate($reasonCode, $surface, $context), $artifactKey === null && ProviderReasonCodes::isKnown($reasonCode) => $this->providerReasonTranslator->translate($reasonCode, $surface, $context),
default => $this->fallbackTranslate($reasonCode, $artifactKey, $surface, $context), default => $this->fallbackTranslate($reasonCode, $artifactKey, $surface, $context),
}; };
@ -71,4 +83,184 @@ private function fallbackTranslate(
return $this->fallbackReasonTranslator->translate($reasonCode, $surface, $context); 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 for the in-scope subjects without recording drift findings.',
'non_actionable',
'No action needed unless you expected findings.',
],
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

@ -0,0 +1,92 @@
<?php
declare(strict_types=1);
namespace App\Support\Ui\GovernanceArtifactTruth;
use App\Support\ReasonTranslation\NextStepOption;
use App\Support\ReasonTranslation\ReasonResolutionEnvelope;
use App\Support\Ui\OperatorExplanation\TrustworthinessLevel;
final readonly class ArtifactTruthCause
{
/**
* @param array<int, string> $nextSteps
*/
public function __construct(
public ?string $reasonCode,
public ?string $translationArtifact,
public ?string $operatorLabel,
public ?string $shortExplanation,
public ?string $diagnosticCode,
public string $trustImpact,
public ?string $absencePattern,
public array $nextSteps = [],
) {}
public static function fromReasonResolutionEnvelope(
?ReasonResolutionEnvelope $reason,
?string $translationArtifact = null,
): ?self {
if (! $reason instanceof ReasonResolutionEnvelope) {
return null;
}
return new self(
reasonCode: $reason->internalCode,
translationArtifact: $translationArtifact,
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,
)),
);
}
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,
* translationArtifact: ?string,
* operatorLabel: ?string,
* shortExplanation: ?string,
* diagnosticCode: ?string,
* trustImpact: string,
* absencePattern: ?string,
* nextSteps: array<int, string>
* }
*/
public function toArray(): array
{
return [
'reasonCode' => $this->reasonCode,
'translationArtifact' => $this->translationArtifact,
'operatorLabel' => $this->operatorLabel,
'shortExplanation' => $this->shortExplanation,
'diagnosticCode' => $this->diagnosticCode,
'trustImpact' => $this->trustImpact,
'absencePattern' => $this->absencePattern,
'nextSteps' => $this->nextSteps,
];
}
}

View File

@ -0,0 +1,61 @@
<?php
declare(strict_types=1);
namespace App\Support\Ui\GovernanceArtifactTruth;
use App\Support\Badges\BadgeDomain;
use App\Support\Badges\BadgeSpec;
final readonly class ArtifactTruthDimension
{
public function __construct(
public string $axis,
public string $state,
public string $label,
public string $classification,
public ?BadgeDomain $badgeDomain = null,
public ?string $badgeState = null,
) {}
public function isPrimary(): bool
{
return $this->classification === 'primary';
}
public function isDiagnostic(): bool
{
return $this->classification === 'diagnostic';
}
public function badgeSpec(): ?BadgeSpec
{
if (! $this->badgeDomain instanceof BadgeDomain || ! is_string($this->badgeState) || trim($this->badgeState) === '') {
return null;
}
return \App\Support\Badges\BadgeCatalog::spec($this->badgeDomain, $this->badgeState);
}
/**
* @return array{
* axis: string,
* state: string,
* label: string,
* classification: string,
* badgeDomain: ?string,
* badgeState: ?string
* }
*/
public function toArray(): array
{
return [
'axis' => $this->axis,
'state' => $this->state,
'label' => $this->label,
'classification' => $this->classification,
'badgeDomain' => $this->badgeDomain?->value,
'badgeState' => $this->badgeState,
];
}
}

View File

@ -0,0 +1,143 @@
<?php
declare(strict_types=1);
namespace App\Support\Ui\GovernanceArtifactTruth;
use App\Support\Badges\BadgeSpec;
use App\Support\Ui\OperatorExplanation\OperatorExplanationPattern;
final readonly class ArtifactTruthEnvelope
{
/**
* @param array<int, ArtifactTruthDimension> $dimensions
*/
public function __construct(
public string $artifactFamily,
public string $artifactKey,
public int $workspaceId,
public ?int $tenantId,
public ?string $executionOutcome,
public string $artifactExistence,
public string $contentState,
public string $freshnessState,
public ?string $publicationReadiness,
public string $supportState,
public string $actionability,
public string $primaryLabel,
public ?string $primaryExplanation,
public ?string $diagnosticLabel,
public ?string $nextActionLabel,
public ?string $nextActionUrl,
public ?int $relatedRunId,
public ?string $relatedArtifactUrl,
public array $dimensions = [],
public ?ArtifactTruthCause $reason = null,
public ?OperatorExplanationPattern $operatorExplanation = null,
) {}
public function primaryDimension(): ?ArtifactTruthDimension
{
foreach ($this->dimensions as $dimension) {
if ($dimension instanceof ArtifactTruthDimension && $dimension->isPrimary()) {
return $dimension;
}
}
return null;
}
public function primaryBadgeSpec(): BadgeSpec
{
$dimension = $this->primaryDimension();
return $dimension?->badgeSpec() ?? \App\Support\Badges\BadgeSpec::unknown();
}
public function nextStepText(): string
{
if (is_string($this->nextActionLabel) && trim($this->nextActionLabel) !== '') {
return $this->nextActionLabel;
}
return match ($this->actionability) {
'none' => 'No action needed',
'optional' => 'Review recommended',
default => 'Action required',
};
}
/**
* @return array{
* artifactFamily: string,
* artifactKey: string,
* workspaceId: int,
* tenantId: ?int,
* executionOutcome: ?string,
* artifactExistence: string,
* contentState: string,
* freshnessState: string,
* publicationReadiness: ?string,
* supportState: string,
* actionability: string,
* primaryLabel: string,
* primaryExplanation: ?string,
* diagnosticLabel: ?string,
* nextActionLabel: ?string,
* nextActionUrl: ?string,
* relatedRunId: ?int,
* relatedArtifactUrl: ?string,
* dimensions: array<int, array{
* axis: string,
* state: string,
* label: string,
* classification: string,
* badgeDomain: ?string,
* badgeState: ?string
* }>,
* reason: ?array{
* reasonCode: ?string,
* translationArtifact: ?string,
* operatorLabel: ?string,
* shortExplanation: ?string,
* diagnosticCode: ?string,
* trustImpact: string,
* absencePattern: ?string,
* nextSteps: array<int, string>
* },
* operatorExplanation: ?array<string, mixed>
* }
*/
public function toArray(): array
{
return [
'artifactFamily' => $this->artifactFamily,
'artifactKey' => $this->artifactKey,
'workspaceId' => $this->workspaceId,
'tenantId' => $this->tenantId,
'executionOutcome' => $this->executionOutcome,
'artifactExistence' => $this->artifactExistence,
'contentState' => $this->contentState,
'freshnessState' => $this->freshnessState,
'publicationReadiness' => $this->publicationReadiness,
'supportState' => $this->supportState,
'actionability' => $this->actionability,
'primaryLabel' => $this->primaryLabel,
'primaryExplanation' => $this->primaryExplanation,
'diagnosticLabel' => $this->diagnosticLabel,
'nextActionLabel' => $this->nextActionLabel,
'nextActionUrl' => $this->nextActionUrl,
'relatedRunId' => $this->relatedRunId,
'relatedArtifactUrl' => $this->relatedArtifactUrl,
'dimensions' => array_values(array_map(
static fn (ArtifactTruthDimension $dimension): array => $dimension->toArray(),
array_filter(
$this->dimensions,
static fn (mixed $dimension): bool => $dimension instanceof ArtifactTruthDimension,
),
)),
'reason' => $this->reason?->toArray(),
'operatorExplanation' => $this->operatorExplanation?->toArray(),
];
}
}

View File

@ -0,0 +1,916 @@
<?php
declare(strict_types=1);
namespace App\Support\Ui\GovernanceArtifactTruth;
use App\Filament\Resources\BaselineSnapshotResource;
use App\Filament\Resources\EvidenceSnapshotResource;
use App\Filament\Resources\ReviewPackResource;
use App\Filament\Resources\TenantReviewResource;
use App\Models\BaselineSnapshot;
use App\Models\EvidenceSnapshot;
use App\Models\OperationRun;
use App\Models\ReviewPack;
use App\Models\TenantReview;
use App\Services\Baselines\BaselineSnapshotTruthResolver;
use App\Services\Baselines\SnapshotRendering\FidelityState;
use App\Support\Badges\BadgeCatalog;
use App\Support\Badges\BadgeDomain;
use App\Support\OperationCatalog;
use App\Support\OperationRunLinks;
use App\Support\OperationRunOutcome;
use App\Support\OperationRunStatus;
use App\Support\OpsUx\SummaryCountsNormalizer;
use App\Support\ReasonTranslation\ReasonPresenter;
use App\Support\ReasonTranslation\ReasonResolutionEnvelope;
use App\Support\ReviewPackStatus;
use App\Support\TenantReviewCompletenessState;
use App\Support\TenantReviewStatus;
use App\Support\Ui\OperatorExplanation\CountDescriptor;
use App\Support\Ui\OperatorExplanation\OperatorExplanationBuilder;
use Illuminate\Support\Arr;
final class ArtifactTruthPresenter
{
public function __construct(
private readonly ReasonPresenter $reasonPresenter,
private readonly BaselineSnapshotTruthResolver $snapshotTruthResolver,
private readonly OperatorExplanationBuilder $operatorExplanationBuilder,
) {}
public function for(mixed $record): ?ArtifactTruthEnvelope
{
return match (true) {
$record instanceof BaselineSnapshot => $this->forBaselineSnapshot($record),
$record instanceof EvidenceSnapshot => $this->forEvidenceSnapshot($record),
$record instanceof TenantReview => $this->forTenantReview($record),
$record instanceof ReviewPack => $this->forReviewPack($record),
$record instanceof OperationRun => $this->forOperationRun($record),
default => null,
};
}
public function forBaselineSnapshot(BaselineSnapshot $snapshot): ArtifactTruthEnvelope
{
$snapshot->loadMissing('baselineProfile');
$summary = is_array($snapshot->summary_jsonb) ? $snapshot->summary_jsonb : [];
$hasItems = (int) ($summary['total_items'] ?? 0) > 0;
$fidelity = FidelityState::fromSummary($summary, $hasItems);
$effectiveSnapshot = $snapshot->baselineProfile !== null
? $this->snapshotTruthResolver->resolveEffectiveSnapshot($snapshot->baselineProfile)
: null;
$isHistorical = $this->snapshotTruthResolver->isHistoricallySuperseded($snapshot, $effectiveSnapshot);
$gapReasons = is_array(Arr::get($summary, 'gaps.by_reason')) ? Arr::get($summary, 'gaps.by_reason') : [];
$severeGapReasons = array_filter(
$gapReasons,
static fn (mixed $count, string $reason): bool => is_numeric($count) && (int) $count > 0 && $reason !== 'meta_fallback',
ARRAY_FILTER_USE_BOTH,
);
$reasonCode = $this->snapshotTruthResolver->artifactReasonCode($snapshot, $effectiveSnapshot)
?? $this->firstReasonCode($severeGapReasons);
$reason = $this->reasonPresenter->forArtifactTruth($reasonCode, 'artifact_truth');
$artifactExistence = match (true) {
$isHistorical => 'historical_only',
$snapshot->isBuilding(), $snapshot->isIncomplete() => 'created_but_not_usable',
! $snapshot->isConsumable() => 'created_but_not_usable',
default => 'created',
};
$contentState = match ($fidelity) {
FidelityState::Full => $snapshot->isIncomplete()
? ($hasItems ? 'partial' : 'missing_input')
: ($severeGapReasons === [] ? 'trusted' : 'partial'),
FidelityState::Partial => $snapshot->isBuilding() ? 'missing_input' : 'partial',
FidelityState::ReferenceOnly => $snapshot->isBuilding() ? 'missing_input' : 'reference_only',
FidelityState::Unsupported => $snapshot->isBuilding() ? 'missing_input' : ($hasItems ? 'unsupported' : 'trusted'),
};
if (($snapshot->isBuilding() || $snapshot->isIncomplete()) && $reasonCode !== null) {
$contentState = 'missing_input';
}
$freshnessState = match (true) {
$snapshot->isBuilding() => 'unknown',
$isHistorical => 'stale',
default => 'current',
};
$supportState = in_array($contentState, ['reference_only', 'unsupported'], true) ? 'limited_support' : 'normal';
$actionability = match (true) {
$artifactExistence === 'historical_only' => 'none',
$snapshot->isBuilding() => 'optional',
$contentState === 'trusted' && $freshnessState === 'current' => 'none',
$freshnessState === 'stale' => 'optional',
in_array($contentState, ['reference_only', 'unsupported'], true) => 'optional',
default => 'required',
};
[$primaryDomain, $primaryState, $primaryExplanation, $diagnosticLabel] = match (true) {
$artifactExistence === 'historical_only' => [
BadgeDomain::GovernanceArtifactExistence,
'historical_only',
$reason?->shortExplanation ?? 'This snapshot remains readable for history, but a newer complete snapshot is the current baseline truth.',
BadgeCatalog::spec(BadgeDomain::BaselineSnapshotLifecycle, 'superseded')->label,
],
$artifactExistence === 'created_but_not_usable' => [
BadgeDomain::GovernanceArtifactExistence,
'created_but_not_usable',
$reason?->shortExplanation ?? 'A snapshot row exists, but it does not contain a trustworthy baseline artifact yet.',
BadgeCatalog::spec(BadgeDomain::BaselineSnapshotLifecycle, $snapshot->lifecycleState()->value)->label,
],
$contentState !== 'trusted' => [
BadgeDomain::GovernanceArtifactContent,
$contentState,
$reason?->shortExplanation ?? $this->contentExplanation($contentState),
$supportState === 'limited_support'
? 'Support limited'
: BadgeCatalog::spec(BadgeDomain::BaselineSnapshotLifecycle, $snapshot->lifecycleState()->value)->label,
],
default => [
BadgeDomain::GovernanceArtifactContent,
'trusted',
$hasItems
? 'Structured capture content is available for this baseline snapshot.'
: 'This empty baseline snapshot completed successfully and can still be used for compare.',
BadgeCatalog::spec(BadgeDomain::BaselineSnapshotLifecycle, $snapshot->lifecycleState()->value)->label,
],
};
return $this->makeEnvelope(
artifactFamily: 'baseline_snapshot',
artifactKey: 'baseline_snapshot:'.$snapshot->getKey(),
workspaceId: (int) $snapshot->workspace_id,
tenantId: null,
executionOutcome: null,
artifactExistence: $artifactExistence,
contentState: $contentState,
freshnessState: $freshnessState,
publicationReadiness: null,
supportState: $supportState,
actionability: $actionability,
primaryDomain: $primaryDomain,
primaryState: $primaryState,
primaryExplanation: $primaryExplanation,
diagnosticLabel: $diagnosticLabel,
reason: ArtifactTruthCause::fromReasonResolutionEnvelope($reason, ReasonPresenter::GOVERNANCE_ARTIFACT_TRUTH_ARTIFACT),
nextActionLabel: $this->nextActionLabel(
$actionability,
$reason,
match ($actionability) {
'required' => 'Inspect the related capture diagnostics before using this snapshot',
'optional' => 'Review the capture diagnostics before comparing this snapshot',
default => null,
},
),
nextActionUrl: null,
relatedRunId: null,
relatedArtifactUrl: BaselineSnapshotResource::getUrl('view', ['record' => $snapshot], panel: 'admin'),
includePublicationDimension: false,
countDescriptors: [
new CountDescriptor(
label: 'Captured items',
value: (int) ($summary['total_items'] ?? 0),
role: CountDescriptor::ROLE_EVALUATION_OUTPUT,
),
new CountDescriptor(
label: 'Evidence gaps',
value: (int) (Arr::get($summary, 'gaps.count', 0)),
role: CountDescriptor::ROLE_COVERAGE,
qualifier: (int) (Arr::get($summary, 'gaps.count', 0)) > 0 ? 'review needed' : null,
),
],
);
}
public function forEvidenceSnapshot(EvidenceSnapshot $snapshot): ArtifactTruthEnvelope
{
$snapshot->loadMissing('tenant');
$summary = is_array($snapshot->summary) ? $snapshot->summary : [];
$missingDimensions = (int) ($summary['missing_dimensions'] ?? 0);
$staleDimensions = (int) ($summary['stale_dimensions'] ?? 0);
$status = (string) $snapshot->status;
$artifactExistence = match ($status) {
'queued', 'generating' => 'not_created',
'expired', 'superseded' => 'historical_only',
'failed' => 'created_but_not_usable',
default => 'created',
};
$contentState = match (true) {
$artifactExistence === 'not_created' => 'missing_input',
$artifactExistence === 'historical_only' && $snapshot->completeness_state === 'missing' => 'empty',
$status === 'failed' => 'missing_input',
$snapshot->completeness_state === 'missing' => 'missing_input',
$snapshot->completeness_state === 'partial' => 'partial',
default => 'trusted',
};
if ((int) ($summary['dimension_count'] ?? 0) === 0 && $artifactExistence !== 'not_created') {
$contentState = 'empty';
}
$freshnessState = match (true) {
$artifactExistence === 'historical_only' => 'stale',
$snapshot->completeness_state === 'stale' || $staleDimensions > 0 => 'stale',
in_array($status, ['queued', 'generating'], true) => 'unknown',
default => 'current',
};
$actionability = match (true) {
$artifactExistence === 'historical_only' => 'none',
in_array($status, ['queued', 'generating'], true) => 'optional',
$contentState === 'trusted' && $freshnessState === 'current' => 'none',
$freshnessState === 'stale' => 'optional',
default => 'required',
};
$reasonCode = match (true) {
$status === 'failed' => 'evidence_generation_failed',
$missingDimensions > 0 => 'evidence_missing_dimensions',
$staleDimensions > 0 => 'evidence_stale_dimensions',
default => null,
};
$reason = $this->reasonPresenter->forArtifactTruth($reasonCode, 'artifact_truth');
[$primaryDomain, $primaryState, $primaryExplanation] = match (true) {
$artifactExistence === 'not_created' => [
BadgeDomain::GovernanceArtifactExistence,
'not_created',
'The evidence generation request exists, but no tenant snapshot is available yet.',
],
$artifactExistence === 'historical_only' => [
BadgeDomain::GovernanceArtifactExistence,
'historical_only',
'This evidence snapshot remains available for history, but it is not the current working evidence artifact.',
],
$contentState !== 'trusted' => [
BadgeDomain::GovernanceArtifactContent,
$contentState,
$reason?->shortExplanation ?? $this->contentExplanation($contentState),
],
$freshnessState === 'stale' => [
BadgeDomain::GovernanceArtifactFreshness,
'stale',
$reason?->shortExplanation ?? 'The snapshot exists, but one or more evidence dimensions should be refreshed before relying on it.',
],
default => [
BadgeDomain::GovernanceArtifactContent,
'trusted',
'A current evidence snapshot is available for review work.',
],
};
$nextActionUrl = $snapshot->operation_run_id
? OperationRunLinks::tenantlessView((int) $snapshot->operation_run_id)
: null;
return $this->makeEnvelope(
artifactFamily: 'evidence_snapshot',
artifactKey: 'evidence_snapshot:'.$snapshot->getKey(),
workspaceId: (int) $snapshot->workspace_id,
tenantId: $snapshot->tenant_id !== null ? (int) $snapshot->tenant_id : null,
executionOutcome: null,
artifactExistence: $artifactExistence,
contentState: $contentState,
freshnessState: $freshnessState,
publicationReadiness: null,
supportState: 'normal',
actionability: $actionability,
primaryDomain: $primaryDomain,
primaryState: $primaryState,
primaryExplanation: $primaryExplanation,
diagnosticLabel: $missingDimensions > 0 && $staleDimensions > 0
? sprintf('%d missing, %d stale dimensions', $missingDimensions, $staleDimensions)
: null,
reason: ArtifactTruthCause::fromReasonResolutionEnvelope($reason, ReasonPresenter::GOVERNANCE_ARTIFACT_TRUTH_ARTIFACT),
nextActionLabel: $this->nextActionLabel(
$actionability,
$reason,
match ($actionability) {
'required' => 'Refresh evidence before using this snapshot',
'optional' => in_array($status, ['queued', 'generating'], true)
? 'Wait for evidence generation to finish'
: 'Review the evidence freshness before relying on this snapshot',
default => null,
},
),
nextActionUrl: $nextActionUrl,
relatedRunId: $snapshot->operation_run_id !== null ? (int) $snapshot->operation_run_id : null,
relatedArtifactUrl: $snapshot->tenant !== null
? EvidenceSnapshotResource::getUrl('view', ['record' => $snapshot], tenant: $snapshot->tenant)
: null,
includePublicationDimension: false,
countDescriptors: [
new CountDescriptor(
label: 'Evidence dimensions',
value: (int) ($summary['dimension_count'] ?? 0),
role: CountDescriptor::ROLE_EVALUATION_OUTPUT,
),
new CountDescriptor(
label: 'Missing dimensions',
value: $missingDimensions,
role: CountDescriptor::ROLE_COVERAGE,
qualifier: $missingDimensions > 0 ? 'partial' : null,
),
new CountDescriptor(
label: 'Stale dimensions',
value: $staleDimensions,
role: CountDescriptor::ROLE_RELIABILITY_SIGNAL,
qualifier: $staleDimensions > 0 ? 'refresh recommended' : null,
),
],
);
}
public function forTenantReview(TenantReview $review): ArtifactTruthEnvelope
{
$review->loadMissing(['tenant', 'currentExportReviewPack']);
$summary = is_array($review->summary) ? $review->summary : [];
$publishBlockers = $review->publishBlockers();
$status = $review->statusEnum();
$completeness = $review->completenessEnum()->value;
$sectionCounts = is_array($summary['section_state_counts'] ?? null) ? $summary['section_state_counts'] : [];
$staleSections = (int) ($sectionCounts['stale'] ?? 0);
$artifactExistence = match ($status) {
TenantReviewStatus::Archived, TenantReviewStatus::Superseded => 'historical_only',
TenantReviewStatus::Failed => 'created_but_not_usable',
default => 'created',
};
$contentState = match ($completeness) {
TenantReviewCompletenessState::Complete->value => 'trusted',
TenantReviewCompletenessState::Partial->value => 'partial',
TenantReviewCompletenessState::Missing->value => 'missing_input',
TenantReviewCompletenessState::Stale->value => 'trusted',
default => 'partial',
};
$freshnessState = match (true) {
$artifactExistence === 'historical_only' => 'stale',
$completeness === TenantReviewCompletenessState::Stale->value || $staleSections > 0 => 'stale',
default => 'current',
};
$publicationReadiness = match (true) {
$artifactExistence === 'historical_only' => 'internal_only',
$status === TenantReviewStatus::Published => 'publishable',
$publishBlockers !== [] => 'blocked',
$status === TenantReviewStatus::Ready => 'publishable',
default => 'internal_only',
};
$actionability = match (true) {
$artifactExistence === 'historical_only' => 'none',
$publicationReadiness === 'publishable' && $freshnessState === 'current' => 'none',
$publicationReadiness === 'internal_only' && $contentState === 'trusted' => 'optional',
$freshnessState === 'stale' && $publishBlockers === [] => 'optional',
default => 'required',
};
$reasonCode = match (true) {
$publishBlockers !== [] => 'review_publish_blocked',
$status === TenantReviewStatus::Failed => 'review_generation_failed',
$completeness === TenantReviewCompletenessState::Missing->value => 'review_missing_sections',
$completeness === TenantReviewCompletenessState::Stale->value => 'review_stale_sections',
default => null,
};
$reason = $this->reasonPresenter->forArtifactTruth($reasonCode, 'artifact_truth');
[$primaryDomain, $primaryState, $primaryExplanation] = match (true) {
$artifactExistence === 'historical_only' => [
BadgeDomain::GovernanceArtifactExistence,
'historical_only',
'This review remains available as historical evidence, but it is no longer the current review artifact.',
],
$publicationReadiness === 'blocked' => [
BadgeDomain::GovernanceArtifactPublicationReadiness,
'blocked',
$publishBlockers[0] ?? $reason?->shortExplanation ?? 'This review exists, but it is blocked from publication or export.',
],
$publicationReadiness === 'internal_only' => [
BadgeDomain::GovernanceArtifactPublicationReadiness,
'internal_only',
'This review exists and is useful internally, but it is not yet ready for stakeholder publication.',
],
$freshnessState === 'stale' => [
BadgeDomain::GovernanceArtifactFreshness,
'stale',
$reason?->shortExplanation ?? 'The review exists, but one or more required sections should be refreshed before publication.',
],
default => [
BadgeDomain::GovernanceArtifactPublicationReadiness,
'publishable',
'This review is ready for publication and executive-pack export.',
],
};
$nextActionUrl = $review->operation_run_id
? OperationRunLinks::tenantlessView((int) $review->operation_run_id)
: null;
if ($publishBlockers !== [] && $review->tenant !== null) {
$nextActionUrl = TenantReviewResource::tenantScopedUrl('view', ['record' => $review], $review->tenant);
}
return $this->makeEnvelope(
artifactFamily: 'tenant_review',
artifactKey: 'tenant_review:'.$review->getKey(),
workspaceId: (int) $review->workspace_id,
tenantId: $review->tenant_id !== null ? (int) $review->tenant_id : null,
executionOutcome: null,
artifactExistence: $artifactExistence,
contentState: $contentState,
freshnessState: $freshnessState,
publicationReadiness: $publicationReadiness,
supportState: 'normal',
actionability: $actionability,
primaryDomain: $primaryDomain,
primaryState: $primaryState,
primaryExplanation: $primaryExplanation,
diagnosticLabel: $contentState !== 'trusted'
? BadgeCatalog::spec(BadgeDomain::GovernanceArtifactContent, $contentState)->label
: null,
reason: ArtifactTruthCause::fromReasonResolutionEnvelope($reason, ReasonPresenter::GOVERNANCE_ARTIFACT_TRUTH_ARTIFACT),
nextActionLabel: $this->nextActionLabel(
$actionability,
$reason,
match ($actionability) {
'required' => 'Resolve the review blockers before publication',
'optional' => 'Complete the remaining review work before publication',
default => null,
},
),
nextActionUrl: $nextActionUrl,
relatedRunId: $review->operation_run_id !== null ? (int) $review->operation_run_id : null,
relatedArtifactUrl: $review->tenant !== null
? TenantReviewResource::tenantScopedUrl('view', ['record' => $review], $review->tenant)
: null,
includePublicationDimension: true,
countDescriptors: [
new CountDescriptor(
label: 'Findings',
value: (int) ($summary['finding_count'] ?? 0),
role: CountDescriptor::ROLE_EVALUATION_OUTPUT,
),
new CountDescriptor(
label: 'Sections',
value: (int) ($summary['section_count'] ?? 0),
role: CountDescriptor::ROLE_EXECUTION,
),
new CountDescriptor(
label: 'Publish blockers',
value: count($publishBlockers),
role: CountDescriptor::ROLE_RELIABILITY_SIGNAL,
qualifier: $publishBlockers !== [] ? 'resolve before publish' : null,
),
],
);
}
public function forReviewPack(ReviewPack $pack): ArtifactTruthEnvelope
{
$pack->loadMissing(['tenant', 'tenantReview']);
$summary = is_array($pack->summary) ? $pack->summary : [];
$status = (string) $pack->status;
$evidenceResolution = is_array($summary['evidence_resolution'] ?? null) ? $summary['evidence_resolution'] : [];
$sourceReview = $pack->tenantReview;
$sourceBlockers = $sourceReview instanceof TenantReview ? $sourceReview->publishBlockers() : [];
$sourceReviewStatus = $sourceReview instanceof TenantReview ? $sourceReview->statusEnum() : null;
$artifactExistence = match ($status) {
ReviewPackStatus::Queued->value, ReviewPackStatus::Generating->value => 'not_created',
ReviewPackStatus::Expired->value => 'historical_only',
ReviewPackStatus::Failed->value => 'created_but_not_usable',
default => 'created',
};
$contentState = match (true) {
$artifactExistence === 'not_created' => 'missing_input',
$status === ReviewPackStatus::Failed->value => 'missing_input',
($evidenceResolution['outcome'] ?? null) !== 'resolved' => 'missing_input',
$sourceReview instanceof TenantReview && $sourceBlockers !== [] => 'partial',
default => 'trusted',
};
$freshnessState = $artifactExistence === 'historical_only' ? 'stale' : 'current';
$publicationReadiness = match (true) {
$artifactExistence === 'historical_only' => 'internal_only',
$artifactExistence === 'not_created' => 'blocked',
$status === ReviewPackStatus::Failed->value => 'blocked',
$sourceReview instanceof TenantReview && $sourceBlockers !== [] => 'blocked',
$sourceReviewStatus === TenantReviewStatus::Draft || $sourceReviewStatus === TenantReviewStatus::Failed => 'internal_only',
default => $status === ReviewPackStatus::Ready->value ? 'publishable' : 'blocked',
};
$actionability = match (true) {
$artifactExistence === 'historical_only' => 'none',
$publicationReadiness === 'publishable' => 'none',
$publicationReadiness === 'internal_only' => 'optional',
default => 'required',
};
$reasonCode = match (true) {
$status === ReviewPackStatus::Failed->value => 'review_pack_generation_failed',
($evidenceResolution['outcome'] ?? null) !== 'resolved' => 'review_pack_missing_snapshot',
$sourceReview instanceof TenantReview && $sourceBlockers !== [] => 'review_pack_source_not_publishable',
$artifactExistence === 'historical_only' => 'review_pack_expired',
default => null,
};
$reason = $this->reasonPresenter->forArtifactTruth($reasonCode, 'artifact_truth');
[$primaryDomain, $primaryState, $primaryExplanation] = match (true) {
$artifactExistence === 'historical_only' => [
BadgeDomain::GovernanceArtifactExistence,
'historical_only',
'This pack remains available as a historical export, but it is no longer the current stakeholder artifact.',
],
$publicationReadiness === 'blocked' => [
BadgeDomain::GovernanceArtifactPublicationReadiness,
'blocked',
$sourceBlockers[0] ?? $reason?->shortExplanation ?? 'A pack file is not yet available for trustworthy stakeholder delivery.',
],
$publicationReadiness === 'internal_only' => [
BadgeDomain::GovernanceArtifactPublicationReadiness,
'internal_only',
'This pack can be reviewed internally, but the source review is not currently publishable.',
],
default => [
BadgeDomain::GovernanceArtifactPublicationReadiness,
'publishable',
'This executive pack is ready for stakeholder delivery.',
],
};
$nextActionUrl = null;
if ($sourceReview instanceof TenantReview && $pack->tenant !== null) {
$nextActionUrl = TenantReviewResource::tenantScopedUrl('view', ['record' => $sourceReview], $pack->tenant);
} elseif ($pack->operation_run_id !== null) {
$nextActionUrl = OperationRunLinks::tenantlessView((int) $pack->operation_run_id);
}
return $this->makeEnvelope(
artifactFamily: 'review_pack',
artifactKey: 'review_pack:'.$pack->getKey(),
workspaceId: (int) $pack->workspace_id,
tenantId: $pack->tenant_id !== null ? (int) $pack->tenant_id : null,
executionOutcome: null,
artifactExistence: $artifactExistence,
contentState: $contentState,
freshnessState: $freshnessState,
publicationReadiness: $publicationReadiness,
supportState: 'normal',
actionability: $actionability,
primaryDomain: $primaryDomain,
primaryState: $primaryState,
primaryExplanation: $primaryExplanation,
diagnosticLabel: $contentState !== 'trusted'
? BadgeCatalog::spec(BadgeDomain::GovernanceArtifactContent, $contentState)->label
: null,
reason: ArtifactTruthCause::fromReasonResolutionEnvelope($reason, ReasonPresenter::GOVERNANCE_ARTIFACT_TRUTH_ARTIFACT),
nextActionLabel: $this->nextActionLabel(
$actionability,
$reason,
match ($actionability) {
'required' => 'Open the source review before sharing this pack',
'optional' => 'Review the source review before sharing this pack',
default => null,
},
),
nextActionUrl: $nextActionUrl,
relatedRunId: $pack->operation_run_id !== null ? (int) $pack->operation_run_id : null,
relatedArtifactUrl: $pack->tenant !== null
? ReviewPackResource::getUrl('view', ['record' => $pack], tenant: $pack->tenant)
: null,
includePublicationDimension: true,
countDescriptors: [
new CountDescriptor(
label: 'Findings',
value: (int) ($summary['finding_count'] ?? 0),
role: CountDescriptor::ROLE_EVALUATION_OUTPUT,
),
new CountDescriptor(
label: 'Reports',
value: (int) ($summary['report_count'] ?? 0),
role: CountDescriptor::ROLE_EXECUTION,
),
new CountDescriptor(
label: 'Operations',
value: (int) ($summary['operation_count'] ?? 0),
role: CountDescriptor::ROLE_EXECUTION,
visibilityTier: CountDescriptor::VISIBILITY_DIAGNOSTIC,
),
],
);
}
public function forOperationRun(OperationRun $run): ArtifactTruthEnvelope
{
$artifact = $this->resolveArtifactForRun($run);
$reason = $this->reasonPresenter->forOperationRun($run, 'run_detail');
if ($artifact !== null) {
$artifactEnvelope = $this->for($artifact);
if ($artifactEnvelope instanceof ArtifactTruthEnvelope) {
$diagnosticParts = array_values(array_filter([
BadgeCatalog::spec(BadgeDomain::OperationRunOutcome, $run->outcome)->label !== 'Unknown'
? BadgeCatalog::spec(BadgeDomain::OperationRunOutcome, $run->outcome)->label
: null,
$artifactEnvelope->diagnosticLabel,
]));
return $this->makeEnvelope(
artifactFamily: 'artifact_run',
artifactKey: 'artifact_run:'.$run->getKey(),
workspaceId: (int) $run->workspace_id,
tenantId: $run->tenant_id !== null ? (int) $run->tenant_id : null,
executionOutcome: $run->outcome !== null ? (string) $run->outcome : null,
artifactExistence: $artifactEnvelope->artifactExistence,
contentState: $artifactEnvelope->contentState,
freshnessState: $artifactEnvelope->freshnessState,
publicationReadiness: $artifactEnvelope->publicationReadiness,
supportState: $artifactEnvelope->supportState,
actionability: $artifactEnvelope->actionability,
primaryDomain: $artifactEnvelope->primaryDimension()?->badgeDomain ?? BadgeDomain::GovernanceArtifactExistence,
primaryState: $artifactEnvelope->primaryDimension()?->badgeState ?? $artifactEnvelope->artifactExistence,
primaryExplanation: $artifactEnvelope->primaryExplanation ?? $reason?->shortExplanation ?? 'The run finished, but the related artifact needs review.',
diagnosticLabel: $diagnosticParts === [] ? null : implode(' · ', $diagnosticParts),
reason: $artifactEnvelope->reason,
nextActionLabel: $artifactEnvelope->nextActionLabel,
nextActionUrl: $artifactEnvelope->relatedArtifactUrl ?? $artifactEnvelope->nextActionUrl,
relatedRunId: (int) $run->getKey(),
relatedArtifactUrl: $artifactEnvelope->relatedArtifactUrl,
includePublicationDimension: $artifactEnvelope->publicationReadiness !== null,
countDescriptors: array_merge(
$artifactEnvelope->operatorExplanation?->countDescriptors ?? [],
$this->runCountDescriptors($run),
),
);
}
}
$artifactExistence = match ((string) $run->status) {
OperationRunStatus::Queued->value, OperationRunStatus::Running->value => 'not_created',
default => 'not_created',
};
$contentState = in_array((string) $run->outcome, [OperationRunOutcome::Blocked->value, OperationRunOutcome::Failed->value], true)
? 'missing_input'
: 'empty';
$actionability = in_array((string) $run->outcome, [OperationRunOutcome::Blocked->value, OperationRunOutcome::Failed->value], true)
? 'required'
: 'optional';
$primaryState = in_array((string) $run->outcome, [OperationRunOutcome::Blocked->value, OperationRunOutcome::Failed->value], true)
? 'created_but_not_usable'
: 'not_created';
return $this->makeEnvelope(
artifactFamily: 'artifact_run',
artifactKey: 'artifact_run:'.$run->getKey(),
workspaceId: (int) $run->workspace_id,
tenantId: $run->tenant_id !== null ? (int) $run->tenant_id : null,
executionOutcome: $run->outcome !== null ? (string) $run->outcome : null,
artifactExistence: $artifactExistence,
contentState: $contentState,
freshnessState: 'unknown',
publicationReadiness: OperationCatalog::governanceArtifactFamily((string) $run->type) === 'review_pack'
|| OperationCatalog::governanceArtifactFamily((string) $run->type) === 'tenant_review'
? 'blocked'
: null,
supportState: 'normal',
actionability: $actionability,
primaryDomain: BadgeDomain::GovernanceArtifactExistence,
primaryState: $primaryState,
primaryExplanation: $reason?->shortExplanation ?? match ((string) $run->status) {
OperationRunStatus::Queued->value, OperationRunStatus::Running->value => 'The artifact-producing run is still in progress, so no artifact is available yet.',
default => 'The run finished without a usable artifact result.',
},
diagnosticLabel: BadgeCatalog::spec(BadgeDomain::OperationRunOutcome, $run->outcome)->label,
reason: ArtifactTruthCause::fromReasonResolutionEnvelope($reason, ReasonPresenter::GOVERNANCE_ARTIFACT_TRUTH_ARTIFACT),
nextActionLabel: $reason?->firstNextStep()?->label
?? ($actionability === 'required'
? 'Inspect the blocked run details before retrying'
: 'Wait for the artifact-producing run to finish'),
nextActionUrl: null,
relatedRunId: (int) $run->getKey(),
relatedArtifactUrl: null,
includePublicationDimension: OperationCatalog::governanceArtifactFamily((string) $run->type) === 'review_pack'
|| OperationCatalog::governanceArtifactFamily((string) $run->type) === 'tenant_review',
countDescriptors: $this->runCountDescriptors($run),
);
}
private function resolveArtifactForRun(OperationRun $run): BaselineSnapshot|EvidenceSnapshot|TenantReview|ReviewPack|null
{
return match (OperationCatalog::governanceArtifactFamily((string) $run->type)) {
'baseline_snapshot' => $run->relatedArtifactId() !== null
? BaselineSnapshot::query()->with('baselineProfile')->find($run->relatedArtifactId())
: null,
'evidence_snapshot' => EvidenceSnapshot::query()->with('tenant')->where('operation_run_id', (int) $run->getKey())->latest('id')->first(),
'tenant_review' => TenantReview::query()->with(['tenant', 'currentExportReviewPack'])->where('operation_run_id', (int) $run->getKey())->latest('id')->first(),
'review_pack' => ReviewPack::query()->with(['tenant', 'tenantReview'])->where('operation_run_id', (int) $run->getKey())->latest('id')->first(),
default => null,
};
}
private function contentExplanation(string $contentState): string
{
return match ($contentState) {
'partial' => 'The artifact exists, but the captured content is incomplete for the primary operator task.',
'missing_input' => 'The artifact is blocked by missing upstream inputs or failed capture prerequisites.',
'metadata_only' => 'Only metadata was captured for this artifact. Use diagnostics for context, not as the primary truth signal.',
'reference_only' => 'Only reference-level placeholders were captured for this artifact.',
'empty' => 'The artifact row exists, but it does not contain usable captured content.',
'unsupported' => 'Structured support is limited for this artifact family, so the current rendering should be treated as diagnostic only.',
default => 'The artifact content is available for the intended workflow.',
};
}
/**
* @param array<string, int> $reasons
*/
private function firstReasonCode(array $reasons): ?string
{
foreach ($reasons as $reason => $count) {
if ((int) $count > 0 && is_string($reason) && trim($reason) !== '') {
return trim($reason);
}
}
return null;
}
private function nextActionLabel(
string $actionability,
?ReasonResolutionEnvelope $reason,
?string $fallback = null,
): ?string {
if ($actionability === 'none') {
return 'No action needed';
}
if (is_string($fallback) && trim($fallback) !== '') {
return $fallback;
}
if ($reason instanceof ReasonResolutionEnvelope && $reason->firstNextStep() !== null) {
return $reason->firstNextStep()?->label;
}
return null;
}
private function makeEnvelope(
string $artifactFamily,
string $artifactKey,
int $workspaceId,
?int $tenantId,
?string $executionOutcome,
string $artifactExistence,
string $contentState,
string $freshnessState,
?string $publicationReadiness,
string $supportState,
string $actionability,
BadgeDomain $primaryDomain,
string $primaryState,
?string $primaryExplanation,
?string $diagnosticLabel,
?ArtifactTruthCause $reason,
?string $nextActionLabel,
?string $nextActionUrl,
?int $relatedRunId,
?string $relatedArtifactUrl,
bool $includePublicationDimension,
array $countDescriptors = [],
): ArtifactTruthEnvelope {
$primarySpec = BadgeCatalog::spec($primaryDomain, $primaryState);
$dimensions = [
$this->dimension(BadgeDomain::GovernanceArtifactExistence, $artifactExistence, 'artifact_existence', $primaryDomain === BadgeDomain::GovernanceArtifactExistence ? 'primary' : 'diagnostic'),
$this->dimension(BadgeDomain::GovernanceArtifactContent, $contentState, 'content_fidelity', $primaryDomain === BadgeDomain::GovernanceArtifactContent ? 'primary' : 'diagnostic'),
$this->dimension(BadgeDomain::GovernanceArtifactFreshness, $freshnessState, 'data_freshness', $primaryDomain === BadgeDomain::GovernanceArtifactFreshness ? 'primary' : 'diagnostic'),
$this->dimension(BadgeDomain::GovernanceArtifactActionability, $actionability, 'operator_actionability', 'diagnostic'),
];
if ($includePublicationDimension && $publicationReadiness !== null) {
$dimensions[] = $this->dimension(
BadgeDomain::GovernanceArtifactPublicationReadiness,
$publicationReadiness,
'publication_readiness',
$primaryDomain === BadgeDomain::GovernanceArtifactPublicationReadiness ? 'primary' : 'diagnostic',
);
}
if ($executionOutcome !== null && trim($executionOutcome) !== '') {
$dimensions[] = $this->dimension(BadgeDomain::OperationRunOutcome, $executionOutcome, 'execution_outcome', 'diagnostic');
}
if ($supportState === 'limited_support') {
$dimensions[] = new ArtifactTruthDimension(
axis: 'support_maturity',
state: 'limited_support',
label: 'Support limited',
classification: 'diagnostic',
badgeDomain: BadgeDomain::GovernanceArtifactContent,
badgeState: 'unsupported',
);
}
$draftEnvelope = new ArtifactTruthEnvelope(
artifactFamily: $artifactFamily,
artifactKey: $artifactKey,
workspaceId: $workspaceId,
tenantId: $tenantId,
executionOutcome: $executionOutcome,
artifactExistence: $artifactExistence,
contentState: $contentState,
freshnessState: $freshnessState,
publicationReadiness: $publicationReadiness,
supportState: $supportState,
actionability: $actionability,
primaryLabel: $primarySpec->label,
primaryExplanation: $primaryExplanation,
diagnosticLabel: $diagnosticLabel,
nextActionLabel: $nextActionLabel,
nextActionUrl: $nextActionUrl,
relatedRunId: $relatedRunId,
relatedArtifactUrl: $relatedArtifactUrl,
dimensions: array_values($dimensions),
reason: $reason,
);
return new ArtifactTruthEnvelope(
artifactFamily: $draftEnvelope->artifactFamily,
artifactKey: $draftEnvelope->artifactKey,
workspaceId: $draftEnvelope->workspaceId,
tenantId: $draftEnvelope->tenantId,
executionOutcome: $draftEnvelope->executionOutcome,
artifactExistence: $draftEnvelope->artifactExistence,
contentState: $draftEnvelope->contentState,
freshnessState: $draftEnvelope->freshnessState,
publicationReadiness: $draftEnvelope->publicationReadiness,
supportState: $draftEnvelope->supportState,
actionability: $draftEnvelope->actionability,
primaryLabel: $draftEnvelope->primaryLabel,
primaryExplanation: $draftEnvelope->primaryExplanation,
diagnosticLabel: $draftEnvelope->diagnosticLabel,
nextActionLabel: $draftEnvelope->nextActionLabel,
nextActionUrl: $draftEnvelope->nextActionUrl,
relatedRunId: $draftEnvelope->relatedRunId,
relatedArtifactUrl: $draftEnvelope->relatedArtifactUrl,
dimensions: $draftEnvelope->dimensions,
reason: $draftEnvelope->reason,
operatorExplanation: $this->operatorExplanationBuilder->fromArtifactTruthEnvelope($draftEnvelope, $countDescriptors),
);
}
private function dimension(
BadgeDomain $domain,
string $state,
string $axis,
string $classification,
): ArtifactTruthDimension {
return new ArtifactTruthDimension(
axis: $axis,
state: $state,
label: BadgeCatalog::spec($domain, $state)->label,
classification: $classification,
badgeDomain: $domain,
badgeState: $state,
);
}
/**
* @return array<int, CountDescriptor>
*/
private function runCountDescriptors(OperationRun $run): array
{
$descriptors = [];
foreach (SummaryCountsNormalizer::normalize(is_array($run->summary_counts) ? $run->summary_counts : []) as $key => $value) {
$role = match (true) {
in_array($key, ['total', 'processed'], true) => CountDescriptor::ROLE_EXECUTION,
str_contains($key, 'failed') || str_contains($key, 'warning') || str_contains($key, 'blocked') => CountDescriptor::ROLE_RELIABILITY_SIGNAL,
default => CountDescriptor::ROLE_EVALUATION_OUTPUT,
};
$descriptors[] = new CountDescriptor(
label: SummaryCountsNormalizer::label($key),
value: (int) $value,
role: $role,
visibilityTier: in_array($key, ['total', 'processed'], true)
? CountDescriptor::VISIBILITY_PRIMARY
: CountDescriptor::VISIBILITY_DIAGNOSTIC,
);
}
return $descriptors;
}
}

View File

@ -0,0 +1,67 @@
<?php
declare(strict_types=1);
namespace App\Support\Ui\OperatorExplanation;
use InvalidArgumentException;
final readonly class CountDescriptor
{
public const string ROLE_EXECUTION = 'execution';
public const string ROLE_EVALUATION_OUTPUT = 'evaluation_output';
public const string ROLE_COVERAGE = 'coverage';
public const string ROLE_RELIABILITY_SIGNAL = 'reliability_signal';
public const string VISIBILITY_PRIMARY = 'primary';
public const string VISIBILITY_DIAGNOSTIC = 'diagnostic';
public function __construct(
public string $label,
public int $value,
public string $role,
public ?string $qualifier = null,
public string $visibilityTier = self::VISIBILITY_PRIMARY,
) {
if (trim($this->label) === '') {
throw new InvalidArgumentException('Count descriptors require a label.');
}
if (! in_array($this->role, [
self::ROLE_EXECUTION,
self::ROLE_EVALUATION_OUTPUT,
self::ROLE_COVERAGE,
self::ROLE_RELIABILITY_SIGNAL,
], true)) {
throw new InvalidArgumentException('Unsupported count descriptor role: '.$this->role);
}
if (! in_array($this->visibilityTier, [self::VISIBILITY_PRIMARY, self::VISIBILITY_DIAGNOSTIC], true)) {
throw new InvalidArgumentException('Unsupported count descriptor visibility tier: '.$this->visibilityTier);
}
}
/**
* @return array{
* label: string,
* value: int,
* role: string,
* qualifier: ?string,
* visibilityTier: string
* }
*/
public function toArray(): array
{
return [
'label' => $this->label,
'value' => $this->value,
'role' => $this->role,
'qualifier' => $this->qualifier,
'visibilityTier' => $this->visibilityTier,
];
}
}

View File

@ -0,0 +1,17 @@
<?php
declare(strict_types=1);
namespace App\Support\Ui\OperatorExplanation;
enum ExplanationFamily: string
{
case TrustworthyResult = 'trustworthy_result';
case NoIssuesDetected = 'no_issues_detected';
case CompletedButLimited = 'completed_but_limited';
case SuppressedOutput = 'suppressed_output';
case MissingInput = 'missing_input';
case BlockedPrerequisite = 'blocked_prerequisite';
case Unavailable = 'unavailable';
case InProgress = 'in_progress';
}

View File

@ -0,0 +1,245 @@
<?php
declare(strict_types=1);
namespace App\Support\Ui\OperatorExplanation;
use App\Support\Badges\BadgeCatalog;
use App\Support\Badges\BadgeDomain;
use App\Support\ReasonTranslation\ReasonResolutionEnvelope;
use App\Support\Ui\GovernanceArtifactTruth\ArtifactTruthEnvelope;
final class OperatorExplanationBuilder
{
/**
* @param array<int, CountDescriptor> $countDescriptors
*/
public function build(
ExplanationFamily $family,
string $headline,
string $executionOutcome,
string $executionOutcomeLabel,
string $evaluationResult,
TrustworthinessLevel $trustworthinessLevel,
string $reliabilityStatement,
?string $coverageStatement,
?string $dominantCauseCode,
?string $dominantCauseLabel,
?string $dominantCauseExplanation,
string $nextActionCategory,
string $nextActionText,
array $countDescriptors = [],
bool $diagnosticsAvailable = false,
?string $diagnosticsSummary = null,
): OperatorExplanationPattern {
return new OperatorExplanationPattern(
family: $family,
headline: $headline,
executionOutcome: $executionOutcome,
executionOutcomeLabel: $executionOutcomeLabel,
evaluationResult: $evaluationResult,
trustworthinessLevel: $trustworthinessLevel,
reliabilityStatement: $reliabilityStatement,
coverageStatement: $coverageStatement,
dominantCauseCode: $dominantCauseCode,
dominantCauseLabel: $dominantCauseLabel,
dominantCauseExplanation: $dominantCauseExplanation,
nextActionCategory: $nextActionCategory,
nextActionText: $nextActionText,
countDescriptors: $countDescriptors,
diagnosticsAvailable: $diagnosticsAvailable,
diagnosticsSummary: $diagnosticsSummary,
);
}
/**
* @param array<int, CountDescriptor> $countDescriptors
*/
public function fromArtifactTruthEnvelope(
ArtifactTruthEnvelope $truth,
array $countDescriptors = [],
): OperatorExplanationPattern {
$reason = $truth->reason?->toReasonResolutionEnvelope();
$family = $this->familyForTruth($truth, $reason);
$trustworthiness = $this->trustworthinessForTruth($truth, $reason);
$evaluationResult = $this->evaluationResultForTruth($truth, $family);
$executionOutcome = $this->executionOutcomeKey($truth->executionOutcome);
$executionOutcomeLabel = $this->executionOutcomeLabel($truth->executionOutcome);
$dominantCauseCode = $reason?->internalCode;
$dominantCauseLabel = $reason?->operatorLabel ?? $truth->primaryLabel;
$dominantCauseExplanation = $reason?->shortExplanation ?? $truth->primaryExplanation;
$headline = $this->headlineForTruth($truth, $family, $trustworthiness);
$reliabilityStatement = $this->reliabilityStatementForTruth($truth, $trustworthiness);
$coverageStatement = $this->coverageStatementForTruth($truth, $reason);
$nextActionText = $truth->nextStepText();
$nextActionCategory = $this->nextActionCategory($truth->actionability, $reason);
$diagnosticsAvailable = $truth->reason !== null
|| $truth->diagnosticLabel !== null
|| $countDescriptors !== [];
return $this->build(
family: $family,
headline: $headline,
executionOutcome: $executionOutcome,
executionOutcomeLabel: $executionOutcomeLabel,
evaluationResult: $evaluationResult,
trustworthinessLevel: $trustworthiness,
reliabilityStatement: $reliabilityStatement,
coverageStatement: $coverageStatement,
dominantCauseCode: $dominantCauseCode,
dominantCauseLabel: $dominantCauseLabel,
dominantCauseExplanation: $dominantCauseExplanation,
nextActionCategory: $nextActionCategory,
nextActionText: $nextActionText,
countDescriptors: $countDescriptors,
diagnosticsAvailable: $diagnosticsAvailable,
diagnosticsSummary: $diagnosticsAvailable
? 'Technical truth detail remains available below the primary explanation.'
: null,
);
}
private function familyForTruth(
ArtifactTruthEnvelope $truth,
?ReasonResolutionEnvelope $reason,
): ExplanationFamily {
return match (true) {
$reason?->absencePattern === 'suppressed_output' => ExplanationFamily::SuppressedOutput,
$reason?->absencePattern === 'blocked_prerequisite' => ExplanationFamily::BlockedPrerequisite,
$truth->executionOutcome === 'pending' || $truth->artifactExistence === 'not_created' && $truth->actionability !== 'required' => ExplanationFamily::InProgress,
$truth->executionOutcome === 'failed' || $truth->executionOutcome === 'blocked' => ExplanationFamily::BlockedPrerequisite,
$truth->artifactExistence === 'created_but_not_usable' || $truth->contentState === 'missing_input' => ExplanationFamily::MissingInput,
$truth->contentState === 'trusted' && $truth->freshnessState === 'current' && $truth->actionability === 'none' && $truth->primaryLabel === 'Trustworthy artifact' => ExplanationFamily::TrustworthyResult,
$truth->contentState === 'trusted' && $truth->freshnessState === 'current' && $truth->actionability === 'none' => ExplanationFamily::NoIssuesDetected,
$truth->artifactExistence === 'historical_only' => ExplanationFamily::Unavailable,
default => ExplanationFamily::CompletedButLimited,
};
}
private function trustworthinessForTruth(
ArtifactTruthEnvelope $truth,
?ReasonResolutionEnvelope $reason,
): TrustworthinessLevel {
if ($reason?->trustImpact !== null) {
return TrustworthinessLevel::tryFrom($reason->trustImpact) ?? TrustworthinessLevel::LimitedConfidence;
}
return match (true) {
$truth->artifactExistence === 'created_but_not_usable',
$truth->contentState === 'missing_input',
$truth->executionOutcome === 'failed',
$truth->executionOutcome === 'blocked' => TrustworthinessLevel::Unusable,
$truth->supportState === 'limited_support',
in_array($truth->contentState, ['reference_only', 'unsupported'], true) => TrustworthinessLevel::DiagnosticOnly,
$truth->contentState === 'trusted' && $truth->freshnessState === 'current' && $truth->actionability === 'none' => TrustworthinessLevel::Trustworthy,
default => TrustworthinessLevel::LimitedConfidence,
};
}
private function evaluationResultForTruth(
ArtifactTruthEnvelope $truth,
ExplanationFamily $family,
): string {
return match ($family) {
ExplanationFamily::TrustworthyResult => 'full_result',
ExplanationFamily::NoIssuesDetected => 'no_result',
ExplanationFamily::SuppressedOutput => 'suppressed_result',
ExplanationFamily::MissingInput,
ExplanationFamily::BlockedPrerequisite,
ExplanationFamily::Unavailable => 'unavailable',
ExplanationFamily::InProgress => 'unavailable',
ExplanationFamily::CompletedButLimited => 'incomplete_result',
};
}
private function executionOutcomeKey(?string $executionOutcome): string
{
$normalized = BadgeCatalog::normalizeState($executionOutcome);
return match ($normalized) {
'queued', 'running', 'pending' => 'in_progress',
'partially_succeeded' => 'completed_with_follow_up',
'blocked' => 'blocked',
'failed' => 'failed',
default => 'completed',
};
}
private function executionOutcomeLabel(?string $executionOutcome): string
{
if (! is_string($executionOutcome) || trim($executionOutcome) === '') {
return 'Completed';
}
$spec = BadgeCatalog::spec(BadgeDomain::OperationRunOutcome, $executionOutcome);
return $spec->label !== 'Unknown' ? $spec->label : ucfirst(str_replace('_', ' ', trim($executionOutcome)));
}
private function headlineForTruth(
ArtifactTruthEnvelope $truth,
ExplanationFamily $family,
TrustworthinessLevel $trustworthiness,
): string {
return match ($family) {
ExplanationFamily::TrustworthyResult => 'The result is ready to use.',
ExplanationFamily::NoIssuesDetected => 'No follow-up was detected from this result.',
ExplanationFamily::SuppressedOutput => 'The run completed, but normal output was intentionally suppressed.',
ExplanationFamily::MissingInput => 'The result exists, but missing inputs keep it from being decision-grade.',
ExplanationFamily::BlockedPrerequisite => 'The workflow did not produce a usable result because a prerequisite blocked it.',
ExplanationFamily::InProgress => 'The result is still being prepared.',
ExplanationFamily::Unavailable => 'A result is not currently available for this surface.',
ExplanationFamily::CompletedButLimited => match ($trustworthiness) {
TrustworthinessLevel::DiagnosticOnly => 'The result is available for diagnostics, not for a final decision.',
TrustworthinessLevel::LimitedConfidence => 'The result is available, but it should be read with caution.',
TrustworthinessLevel::Unusable => 'The result is not reliable enough to use as-is.',
default => 'The result completed with operator follow-up.',
},
};
}
private function reliabilityStatementForTruth(
ArtifactTruthEnvelope $truth,
TrustworthinessLevel $trustworthiness,
): string {
return match ($trustworthiness) {
TrustworthinessLevel::Trustworthy => 'Trustworthiness is high for the intended operator task.',
TrustworthinessLevel::LimitedConfidence => $truth->primaryExplanation
?? 'Trustworthiness is limited because coverage, freshness, or publication readiness still need review.',
TrustworthinessLevel::DiagnosticOnly => 'This output is suitable for diagnostics only and should not be treated as the final answer.',
TrustworthinessLevel::Unusable => 'This output is not reliable enough to support the intended operator action yet.',
};
}
private function coverageStatementForTruth(
ArtifactTruthEnvelope $truth,
?ReasonResolutionEnvelope $reason,
): ?string {
return match (true) {
$truth->contentState === 'trusted' && $truth->freshnessState === 'current' => 'Coverage and artifact quality are sufficient for the default reading path.',
$truth->freshnessState === 'stale' => 'The artifact exists, but freshness limits how confidently it should be used.',
$truth->contentState === 'partial' => 'Coverage is incomplete, so the visible output should be treated as partial.',
$truth->contentState === 'missing_input' => $reason?->shortExplanation ?? 'Required inputs were missing or unusable when this result was assembled.',
in_array($truth->contentState, ['reference_only', 'unsupported'], true) => 'Only reduced-fidelity support is available for this result.',
$truth->publicationReadiness === 'blocked' => 'The artifact exists, but it is still blocked from the intended downstream use.',
default => null,
};
}
private function nextActionCategory(
string $actionability,
?ReasonResolutionEnvelope $reason,
): string {
if ($reason?->actionability === 'retryable_transient') {
return 'retry_later';
}
return match ($actionability) {
'none' => 'none',
'optional' => 'review_evidence_gaps',
default => $reason?->actionability === 'prerequisite_missing'
? 'fix_prerequisite'
: 'manual_validate',
};
}
}

View File

@ -0,0 +1,135 @@
<?php
declare(strict_types=1);
namespace App\Support\Ui\OperatorExplanation;
use App\Support\Badges\BadgeCatalog;
use App\Support\Badges\BadgeDomain;
use InvalidArgumentException;
final readonly class OperatorExplanationPattern
{
/**
* @param array<int, CountDescriptor> $countDescriptors
*/
public function __construct(
public ExplanationFamily $family,
public string $headline,
public string $executionOutcome,
public string $executionOutcomeLabel,
public string $evaluationResult,
public TrustworthinessLevel $trustworthinessLevel,
public string $reliabilityStatement,
public ?string $coverageStatement,
public ?string $dominantCauseCode,
public ?string $dominantCauseLabel,
public ?string $dominantCauseExplanation,
public string $nextActionCategory,
public string $nextActionText,
public array $countDescriptors = [],
public bool $diagnosticsAvailable = false,
public ?string $diagnosticsSummary = null,
) {
if (trim($this->headline) === '') {
throw new InvalidArgumentException('Operator explanation patterns require a headline.');
}
if (trim($this->executionOutcome) === '' || trim($this->executionOutcomeLabel) === '') {
throw new InvalidArgumentException('Operator explanation patterns require an execution outcome and label.');
}
if (trim($this->evaluationResult) === '') {
throw new InvalidArgumentException('Operator explanation patterns require an evaluation result state.');
}
if (trim($this->reliabilityStatement) === '') {
throw new InvalidArgumentException('Operator explanation patterns require a reliability statement.');
}
if (trim($this->nextActionCategory) === '' || trim($this->nextActionText) === '') {
throw new InvalidArgumentException('Operator explanation patterns require a next action category and text.');
}
foreach ($this->countDescriptors as $descriptor) {
if (! $descriptor instanceof CountDescriptor) {
throw new InvalidArgumentException('Operator explanation count descriptors must contain CountDescriptor instances.');
}
}
}
public function evaluationResultLabel(): string
{
return BadgeCatalog::spec(BadgeDomain::OperatorExplanationEvaluationResult, $this->evaluationResult)->label;
}
public function trustworthinessLabel(): string
{
return BadgeCatalog::spec(BadgeDomain::OperatorExplanationTrustworthiness, $this->trustworthinessLevel)->label;
}
/**
* @return array{
* family: string,
* headline: string,
* executionOutcome: string,
* executionOutcomeLabel: string,
* evaluationResult: string,
* evaluationResultLabel: string,
* trustworthinessLevel: string,
* reliabilityLevel: string,
* trustworthinessLabel: string,
* reliabilityStatement: string,
* coverageStatement: ?string,
* dominantCause: array{
* code: ?string,
* label: ?string,
* explanation: ?string
* },
* nextAction: array{
* category: string,
* text: string
* },
* countDescriptors: array<int, array{
* label: string,
* value: int,
* role: string,
* qualifier: ?string,
* visibilityTier: string
* }>,
* diagnosticsAvailable: bool,
* diagnosticsSummary: ?string
* }
*/
public function toArray(): array
{
return [
'family' => $this->family->value,
'headline' => $this->headline,
'executionOutcome' => $this->executionOutcome,
'executionOutcomeLabel' => $this->executionOutcomeLabel,
'evaluationResult' => $this->evaluationResult,
'evaluationResultLabel' => $this->evaluationResultLabel(),
'trustworthinessLevel' => $this->trustworthinessLevel->value,
'reliabilityLevel' => $this->trustworthinessLevel->value,
'trustworthinessLabel' => $this->trustworthinessLabel(),
'reliabilityStatement' => $this->reliabilityStatement,
'coverageStatement' => $this->coverageStatement,
'dominantCause' => [
'code' => $this->dominantCauseCode,
'label' => $this->dominantCauseLabel,
'explanation' => $this->dominantCauseExplanation,
],
'nextAction' => [
'category' => $this->nextActionCategory,
'text' => $this->nextActionText,
],
'countDescriptors' => array_map(
static fn (CountDescriptor $descriptor): array => $descriptor->toArray(),
$this->countDescriptors,
),
'diagnosticsAvailable' => $this->diagnosticsAvailable,
'diagnosticsSummary' => $this->diagnosticsSummary,
];
}
}

View File

@ -0,0 +1,13 @@
<?php
declare(strict_types=1);
namespace App\Support\Ui\OperatorExplanation;
enum TrustworthinessLevel: string
{
case Trustworthy = 'trustworthy';
case LimitedConfidence = 'limited_confidence';
case DiagnosticOnly = 'diagnostic_only';
case Unusable = 'unusable';
}

View File

@ -35,6 +35,7 @@ public static function firstSlice(): array
'evidence_snapshots', 'evidence_snapshots',
'inventory_items', 'inventory_items',
'entra_groups', 'entra_groups',
'tenant_reviews',
]; ];
} }

View File

@ -40,7 +40,7 @@
'connection' => env('DB_QUEUE_CONNECTION'), 'connection' => env('DB_QUEUE_CONNECTION'),
'table' => env('DB_QUEUE_TABLE', 'jobs'), 'table' => env('DB_QUEUE_TABLE', 'jobs'),
'queue' => env('DB_QUEUE', 'default'), 'queue' => env('DB_QUEUE', 'default'),
'retry_after' => (int) env('DB_QUEUE_RETRY_AFTER', 90), 'retry_after' => (int) env('DB_QUEUE_RETRY_AFTER', 600),
'after_commit' => false, 'after_commit' => false,
], ],
@ -48,7 +48,7 @@
'driver' => 'beanstalkd', 'driver' => 'beanstalkd',
'host' => env('BEANSTALKD_QUEUE_HOST', 'localhost'), 'host' => env('BEANSTALKD_QUEUE_HOST', 'localhost'),
'queue' => env('BEANSTALKD_QUEUE', 'default'), 'queue' => env('BEANSTALKD_QUEUE', 'default'),
'retry_after' => (int) env('BEANSTALKD_QUEUE_RETRY_AFTER', 90), 'retry_after' => (int) env('BEANSTALKD_QUEUE_RETRY_AFTER', 600),
'block_for' => 0, 'block_for' => 0,
'after_commit' => false, 'after_commit' => false,
], ],
@ -68,7 +68,7 @@
'driver' => 'redis', 'driver' => 'redis',
'connection' => env('REDIS_QUEUE_CONNECTION', 'default'), 'connection' => env('REDIS_QUEUE_CONNECTION', 'default'),
'queue' => env('REDIS_QUEUE', 'default'), 'queue' => env('REDIS_QUEUE', 'default'),
'retry_after' => (int) env('REDIS_QUEUE_RETRY_AFTER', 90), 'retry_after' => (int) env('REDIS_QUEUE_RETRY_AFTER', 600),
'block_for' => null, 'block_for' => null,
'after_commit' => false, 'after_commit' => false,
], ],
@ -126,4 +126,8 @@
'table' => 'failed_jobs', 'table' => 'failed_jobs',
], ],
'lifecycle_invariants' => [
'retry_after_safety_margin' => (int) env('QUEUE_RETRY_AFTER_SAFETY_MARGIN', 30),
],
]; ];

View File

@ -13,6 +13,113 @@
], ],
], ],
'operations' => [
'lifecycle' => [
'reconciliation' => [
'batch_limit' => (int) env('TENANTPILOT_OPERATION_RUN_RECONCILIATION_BATCH_LIMIT', 100),
'schedule_minutes' => (int) env('TENANTPILOT_OPERATION_RUN_RECONCILIATION_SCHEDULE_MINUTES', 5),
],
'covered_types' => [
'baseline_capture' => [
'job_class' => \App\Jobs\CaptureBaselineSnapshotJob::class,
'queued_stale_after_seconds' => 600,
'running_stale_after_seconds' => 1800,
'expected_max_runtime_seconds' => 300,
'direct_failed_bridge' => true,
'scheduled_reconciliation' => true,
],
'baseline_compare' => [
'job_class' => \App\Jobs\CompareBaselineToTenantJob::class,
'queued_stale_after_seconds' => 600,
'running_stale_after_seconds' => 1800,
'expected_max_runtime_seconds' => 300,
'direct_failed_bridge' => true,
'scheduled_reconciliation' => true,
],
'inventory_sync' => [
'job_class' => \App\Jobs\RunInventorySyncJob::class,
'queued_stale_after_seconds' => 300,
'running_stale_after_seconds' => 1200,
'expected_max_runtime_seconds' => 240,
'direct_failed_bridge' => true,
'scheduled_reconciliation' => true,
],
'policy.sync' => [
'job_class' => \App\Jobs\SyncPoliciesJob::class,
'queued_stale_after_seconds' => 300,
'running_stale_after_seconds' => 900,
'expected_max_runtime_seconds' => 180,
'direct_failed_bridge' => true,
'scheduled_reconciliation' => true,
],
'policy.sync_one' => [
'job_class' => \App\Jobs\SyncPoliciesJob::class,
'queued_stale_after_seconds' => 300,
'running_stale_after_seconds' => 900,
'expected_max_runtime_seconds' => 180,
'direct_failed_bridge' => true,
'scheduled_reconciliation' => true,
],
'entra_group_sync' => [
'job_class' => \App\Jobs\EntraGroupSyncJob::class,
'queued_stale_after_seconds' => 300,
'running_stale_after_seconds' => 900,
'expected_max_runtime_seconds' => 240,
'direct_failed_bridge' => false,
'scheduled_reconciliation' => true,
],
'directory_role_definitions.sync' => [
'job_class' => \App\Jobs\SyncRoleDefinitionsJob::class,
'queued_stale_after_seconds' => 300,
'running_stale_after_seconds' => 900,
'expected_max_runtime_seconds' => 240,
'direct_failed_bridge' => false,
'scheduled_reconciliation' => true,
],
'backup_schedule_run' => [
'job_class' => \App\Jobs\RunBackupScheduleJob::class,
'queued_stale_after_seconds' => 300,
'running_stale_after_seconds' => 1200,
'expected_max_runtime_seconds' => 300,
'direct_failed_bridge' => false,
'scheduled_reconciliation' => true,
],
'restore.execute' => [
'job_class' => \App\Jobs\ExecuteRestoreRunJob::class,
'queued_stale_after_seconds' => 300,
'running_stale_after_seconds' => 1500,
'expected_max_runtime_seconds' => 420,
'direct_failed_bridge' => false,
'scheduled_reconciliation' => true,
],
'tenant.review_pack.generate' => [
'job_class' => \App\Jobs\GenerateReviewPackJob::class,
'queued_stale_after_seconds' => 300,
'running_stale_after_seconds' => 900,
'expected_max_runtime_seconds' => 240,
'direct_failed_bridge' => false,
'scheduled_reconciliation' => true,
],
'tenant.review.compose' => [
'job_class' => \App\Jobs\ComposeTenantReviewJob::class,
'queued_stale_after_seconds' => 300,
'running_stale_after_seconds' => 900,
'expected_max_runtime_seconds' => 240,
'direct_failed_bridge' => true,
'scheduled_reconciliation' => true,
],
'tenant.evidence.snapshot.generate' => [
'job_class' => \App\Jobs\GenerateEvidenceSnapshotJob::class,
'queued_stale_after_seconds' => 300,
'running_stale_after_seconds' => 900,
'expected_max_runtime_seconds' => 240,
'direct_failed_bridge' => false,
'scheduled_reconciliation' => true,
],
],
],
],
'allow_admin_maintenance_actions' => (bool) env('ALLOW_ADMIN_MAINTENANCE_ACTIONS', false), 'allow_admin_maintenance_actions' => (bool) env('ALLOW_ADMIN_MAINTENANCE_ACTIONS', false),
'supported_policy_types' => [ 'supported_policy_types' => [

View File

@ -5,6 +5,7 @@
use App\Models\BaselineProfile; use App\Models\BaselineProfile;
use App\Models\BaselineSnapshot; use App\Models\BaselineSnapshot;
use App\Models\Workspace; use App\Models\Workspace;
use App\Support\Baselines\BaselineSnapshotLifecycleState;
use Illuminate\Database\Eloquent\Factories\Factory; use Illuminate\Database\Eloquent\Factories\Factory;
/** /**
@ -24,7 +25,50 @@ public function definition(): array
'baseline_profile_id' => BaselineProfile::factory(), 'baseline_profile_id' => BaselineProfile::factory(),
'snapshot_identity_hash' => hash('sha256', fake()->uuid()), 'snapshot_identity_hash' => hash('sha256', fake()->uuid()),
'captured_at' => now(), 'captured_at' => now(),
'lifecycle_state' => BaselineSnapshotLifecycleState::Complete->value,
'completed_at' => now(),
'failed_at' => null,
'summary_jsonb' => ['total_items' => 0], 'summary_jsonb' => ['total_items' => 0],
'completion_meta_jsonb' => [
'expected_items' => 0,
'persisted_items' => 0,
'producer_run_id' => null,
'was_empty_capture' => true,
],
]; ];
} }
public function building(): static
{
return $this->state(fn (): array => [
'lifecycle_state' => BaselineSnapshotLifecycleState::Building->value,
'completed_at' => null,
'failed_at' => null,
]);
}
public function complete(): static
{
return $this->state(fn (): array => [
'lifecycle_state' => BaselineSnapshotLifecycleState::Complete->value,
'completed_at' => now(),
'failed_at' => null,
]);
}
public function incomplete(?string $reasonCode = null): static
{
return $this->state(fn (): array => [
'lifecycle_state' => BaselineSnapshotLifecycleState::Incomplete->value,
'completed_at' => null,
'failed_at' => now(),
'completion_meta_jsonb' => [
'expected_items' => 0,
'persisted_items' => 0,
'producer_run_id' => null,
'was_empty_capture' => true,
'finalization_reason_code' => $reasonCode,
],
]);
}
} }

View File

@ -0,0 +1,292 @@
<?php
declare(strict_types=1);
use App\Support\Baselines\BaselineReasonCodes;
use App\Support\Baselines\BaselineSnapshotLifecycleState;
use App\Support\OperationRunOutcome;
use App\Support\OperationRunType;
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
if (! Schema::hasTable('baseline_snapshots')) {
return;
}
$needsLifecycleState = ! Schema::hasColumn('baseline_snapshots', 'lifecycle_state');
$needsCompletedAt = ! Schema::hasColumn('baseline_snapshots', 'completed_at');
$needsFailedAt = ! Schema::hasColumn('baseline_snapshots', 'failed_at');
$needsCompletionMeta = ! Schema::hasColumn('baseline_snapshots', 'completion_meta_jsonb');
if ($needsLifecycleState || $needsCompletedAt || $needsFailedAt || $needsCompletionMeta) {
Schema::table('baseline_snapshots', function (Blueprint $table) use ($needsLifecycleState, $needsCompletedAt, $needsFailedAt, $needsCompletionMeta): void {
if ($needsLifecycleState) {
$table->string('lifecycle_state')->nullable()->after('captured_at');
}
if ($needsCompletedAt) {
$table->timestampTz('completed_at')->nullable()->after('lifecycle_state');
}
if ($needsFailedAt) {
$table->timestampTz('failed_at')->nullable()->after('completed_at');
}
if ($needsCompletionMeta) {
$table->jsonb('completion_meta_jsonb')->nullable()->after('summary_jsonb');
}
});
}
DB::table('baseline_snapshots')
->orderBy('id')
->chunkById(200, function ($rows): void {
foreach ($rows as $row) {
$summary = $this->decodeJson($row->summary_jsonb);
$persistedItems = (int) DB::table('baseline_snapshot_items')
->where('baseline_snapshot_id', (int) $row->id)
->count();
$classification = $this->classifyLegacySnapshot($row, $summary, $persistedItems);
DB::table('baseline_snapshots')
->where('id', (int) $row->id)
->update([
'lifecycle_state' => $classification['lifecycle_state'],
'completed_at' => $classification['completed_at'],
'failed_at' => $classification['failed_at'],
'completion_meta_jsonb' => json_encode($classification['completion_meta'], JSON_THROW_ON_ERROR),
]);
}
}, 'id');
DB::table('baseline_snapshots')
->whereNull('lifecycle_state')
->update([
'lifecycle_state' => BaselineSnapshotLifecycleState::Incomplete->value,
]);
if ($needsLifecycleState) {
DB::statement(sprintf(
"UPDATE baseline_snapshots SET lifecycle_state = '%s' WHERE lifecycle_state IS NULL",
BaselineSnapshotLifecycleState::Incomplete->value,
));
Schema::table('baseline_snapshots', function (Blueprint $table): void {
$table->string('lifecycle_state')->default(BaselineSnapshotLifecycleState::Building->value)->nullable(false)->change();
});
}
if (! $this->hasIndex('baseline_snapshots_lifecycle_state_index')) {
Schema::table('baseline_snapshots', function (Blueprint $table): void {
$table->index('lifecycle_state', 'baseline_snapshots_lifecycle_state_index');
});
}
if (! $this->hasIndex('baseline_snapshots_profile_lifecycle_completed_idx')) {
Schema::table('baseline_snapshots', function (Blueprint $table): void {
$table->index(
['workspace_id', 'baseline_profile_id', 'lifecycle_state', 'completed_at'],
'baseline_snapshots_profile_lifecycle_completed_idx',
);
});
}
}
public function down(): void
{
if (! Schema::hasTable('baseline_snapshots')) {
return;
}
if ($this->hasIndex('baseline_snapshots_profile_lifecycle_completed_idx')) {
Schema::table('baseline_snapshots', function (Blueprint $table): void {
$table->dropIndex('baseline_snapshots_profile_lifecycle_completed_idx');
});
}
if ($this->hasIndex('baseline_snapshots_lifecycle_state_index')) {
Schema::table('baseline_snapshots', function (Blueprint $table): void {
$table->dropIndex('baseline_snapshots_lifecycle_state_index');
});
}
Schema::table('baseline_snapshots', function (Blueprint $table): void {
foreach (['completion_meta_jsonb', 'failed_at', 'completed_at', 'lifecycle_state'] as $column) {
if (Schema::hasColumn('baseline_snapshots', $column)) {
$table->dropColumn($column);
}
}
});
}
/**
* @param array<string, mixed> $summary
* @return array{
* lifecycle_state: string,
* completed_at: mixed,
* failed_at: mixed,
* completion_meta: array<string, mixed>
* }
*/
private function classifyLegacySnapshot(object $row, array $summary, int $persistedItems): array
{
$expectedItems = $this->normalizeInteger(data_get($summary, 'total_items'));
$producerRun = $this->resolveProducerRunForSnapshot((int) $row->id, (int) $row->workspace_id, (int) $row->baseline_profile_id);
$producerRunContext = $producerRun !== null ? $this->decodeJson($producerRun->context ?? null) : [];
$producerExpectedItems = $this->normalizeInteger(data_get($producerRunContext, 'result.items_captured'))
?? $this->normalizeInteger(data_get($producerRunContext, 'baseline_capture.subjects_total'));
$producerSubjectsTotal = $this->normalizeInteger(data_get($producerRunContext, 'baseline_capture.subjects_total'));
$producerSucceeded = $producerRun !== null
&& in_array((string) ($producerRun->outcome ?? ''), [
OperationRunOutcome::Succeeded->value,
OperationRunOutcome::PartiallySucceeded->value,
], true);
$completionMeta = [
'expected_items' => $expectedItems ?? $producerExpectedItems,
'persisted_items' => $persistedItems,
'producer_run_id' => $producerRun?->id !== null ? (int) $producerRun->id : null,
'was_empty_capture' => ($expectedItems ?? $producerExpectedItems ?? $producerSubjectsTotal) === 0 && $persistedItems === 0,
];
if ($expectedItems !== null && $expectedItems === $persistedItems) {
return [
'lifecycle_state' => BaselineSnapshotLifecycleState::Complete->value,
'completed_at' => $producerRun->completed_at ?? $row->captured_at ?? $row->created_at ?? $row->updated_at,
'failed_at' => null,
'completion_meta' => $completionMeta + [
'finalization_reason_code' => 'baseline.snapshot.legacy_count_proof',
],
];
}
if ($producerSucceeded && $producerExpectedItems !== null && $producerExpectedItems === $persistedItems) {
return [
'lifecycle_state' => BaselineSnapshotLifecycleState::Complete->value,
'completed_at' => $producerRun->completed_at ?? $row->captured_at ?? $row->created_at ?? $row->updated_at,
'failed_at' => null,
'completion_meta' => $completionMeta + [
'finalization_reason_code' => 'baseline.snapshot.legacy_producer_run_proof',
],
];
}
if ($producerSucceeded && $persistedItems === 0 && in_array(0, array_filter([
$expectedItems,
$producerExpectedItems,
$producerSubjectsTotal,
], static fn (?int $value): bool => $value !== null), true)) {
return [
'lifecycle_state' => BaselineSnapshotLifecycleState::Complete->value,
'completed_at' => $producerRun->completed_at ?? $row->captured_at ?? $row->created_at ?? $row->updated_at,
'failed_at' => null,
'completion_meta' => $completionMeta + [
'finalization_reason_code' => 'baseline.snapshot.legacy_empty_capture_proof',
],
];
}
$reasonCode = $expectedItems !== null
|| $producerExpectedItems !== null
|| $producerRun !== null
? BaselineReasonCodes::SNAPSHOT_LEGACY_CONTRADICTORY
: BaselineReasonCodes::SNAPSHOT_LEGACY_NO_PROOF;
return [
'lifecycle_state' => BaselineSnapshotLifecycleState::Incomplete->value,
'completed_at' => null,
'failed_at' => $row->updated_at ?? $row->captured_at ?? $row->created_at,
'completion_meta' => $completionMeta + [
'finalization_reason_code' => $reasonCode,
],
];
}
private function resolveProducerRunForSnapshot(int $snapshotId, int $workspaceId, int $profileId): ?object
{
$runs = DB::table('operation_runs')
->where('workspace_id', $workspaceId)
->where('type', OperationRunType::BaselineCapture->value)
->orderByDesc('completed_at')
->orderByDesc('id')
->limit(500)
->get(['id', 'outcome', 'completed_at', 'context']);
foreach ($runs as $run) {
$context = $this->decodeJson($run->context ?? null);
$resultSnapshotId = $this->normalizeInteger(data_get($context, 'result.snapshot_id'));
if ($resultSnapshotId === $snapshotId) {
return $run;
}
}
foreach ($runs as $run) {
$context = $this->decodeJson($run->context ?? null);
$runProfileId = $this->normalizeInteger(data_get($context, 'baseline_profile_id'));
if ($runProfileId === $profileId) {
return $run;
}
}
return null;
}
/**
* @return array<string, mixed>
*/
private function decodeJson(mixed $value): array
{
if (is_array($value)) {
return $value;
}
if (is_string($value) && trim($value) !== '') {
$decoded = json_decode($value, true);
return is_array($decoded) ? $decoded : [];
}
return [];
}
private function normalizeInteger(mixed $value): ?int
{
if (is_int($value)) {
return $value;
}
if (is_numeric($value)) {
return (int) $value;
}
if (is_string($value) && ctype_digit(trim($value))) {
return (int) trim($value);
}
return null;
}
private function hasIndex(string $indexName): bool
{
$driver = Schema::getConnection()->getDriverName();
return match ($driver) {
'pgsql' => DB::table('pg_indexes')
->where('schemaname', 'public')
->where('indexname', $indexName)
->exists(),
'sqlite' => collect(DB::select("PRAGMA index_list('baseline_snapshots')"))
->contains(fn (object $index): bool => ($index->name ?? null) === $indexName),
default => false,
};
}
};

View File

@ -150,6 +150,8 @@ ### Operations UX
- **3-surface feedback**: Toast (immediate) → Progress widget (polling) → DB notification (terminal). - **3-surface feedback**: Toast (immediate) → Progress widget (polling) → DB notification (terminal).
- **OperationRun lifecycle**: Service-owned transitions only via `OperationRunService` — no direct status writes. - **OperationRun lifecycle**: Service-owned transitions only via `OperationRunService` — no direct status writes.
- **Idempotent creation**: Hash-based dedup with partial unique index. - **Idempotent creation**: Hash-based dedup with partial unique index.
- **Lifecycle guarantees (Spec 160)**: Covered queued runs (`baseline_capture`, `baseline_compare`, `inventory_sync`, `policy.sync`, `policy.sync_one`, `entra_group_sync`, `directory_role_definitions.sync`, `backup_schedule_run`, `restore.execute`, `tenant.review_pack.generate`, `tenant.review.compose`, `tenant.evidence.snapshot.generate`) now have a config-backed lifecycle policy, direct failed-job bridges where declared, scheduled stale-run reconciliation, and UI freshness semantics for stale or automatically reconciled runs.
- **Queue timing invariant**: Covered job `timeout` values and policy `expected_max_runtime_seconds` must remain safely below queue `retry_after` with the configured safety margin. After changing queue lifecycle settings, restart workers (`php artisan queue:restart` in the target environment) so the new contract takes effect.
### Filament Standards ### Filament Standards

View File

@ -3,7 +3,7 @@ # Product Roadmap
> Strategic thematic blocks and release trajectory. > Strategic thematic blocks and release trajectory.
> This is the "big picture" — not individual specs. > This is the "big picture" — not individual specs.
**Last updated**: 2026-03-21 **Last updated**: 2026-03-23
--- ---
@ -26,7 +26,7 @@ ### Governance & Architecture Hardening
**Active specs**: 144 **Active specs**: 144
**Next wave candidates**: queued execution reauthorization and scope continuity, tenant-owned query canon and wrong-tenant guards, findings workflow enforcement and audit backstop, Livewire context locking and trusted-state reduction **Next wave candidates**: queued execution reauthorization and scope continuity, tenant-owned query canon and wrong-tenant guards, findings workflow enforcement and audit backstop, Livewire context locking and trusted-state reduction
**Operator truth initiative** (sequenced): Operator Outcome Taxonomy → Reason Code Translation → Provider Dispatch Gate Unification (see spec-candidates.md — "Operator Truth Initiative" sequencing note) **Operator truth initiative** (sequenced): Operator Outcome Taxonomy → Reason Code Translation → Artifact Truth Semantics → Governance Operator Outcome Compression; Provider Dispatch Gate Unification continues as the adjacent hardening lane (see spec-candidates.md — "Operator Truth Initiative" sequencing note)
**Source**: architecture audit 2026-03-15, audit constitution, semantic clarity audit 2026-03-21, product spec-candidates **Source**: architecture audit 2026-03-15, audit constitution, semantic clarity audit 2026-03-21, product spec-candidates
### UI & Product Maturity Polish ### UI & Product Maturity Polish

View File

@ -3,9 +3,9 @@ # Spec Candidates
> Concrete future specs waiting for prioritization. > Concrete future specs waiting for prioritization.
> Each entry has enough structure to become a real spec when the time comes. > Each entry has enough structure to become a real spec when the time comes.
> >
> **Flow**: Inbox → Qualified → Planned → Spec created → removed from this file > **Flow**: Inbox → Qualified → Planned → Spec created → moved to `Promoted to Spec`
**Last reviewed**: 2026-03-21 (operator semantic taxonomy, semantic-clarity domain follow-ups, OperationRun humanization candidate, and absorbed extension targets updated) **Last reviewed**: 2026-03-24 (added Operation Run Active-State Visibility & Stale Escalation candidate)
--- ---
@ -27,28 +27,23 @@ ## Inbox
--- ---
## Promoted to Spec
> Historical ledger for candidates that are no longer open. Keep them here so prioritization stays clean without losing decision history.
- Queued Execution Reauthorization and Scope Continuity → Spec 149 (`queued-execution-reauthorization`)
- Livewire Context Locking and Trusted-State Reduction → Spec 152 (`livewire-context-locking`)
- Evidence Domain Foundation → Spec 153 (`evidence-domain-foundation`)
- Operator Outcome Taxonomy and Cross-Domain State Separation → Spec 156 (`operator-outcome-taxonomy`)
- Operator Reason Code Translation and Humanization Contract → Spec 157 (`reason-code-translation`)
- Governance Artifact Truthful Outcomes & Fidelity Semantics → Spec 158 (`artifact-truth-semantics`)
---
## Qualified ## Qualified
> Problem + Nutzen klar. Scope noch offen. Braucht noch Priorisierung. > Problem + Nutzen klar. Scope noch offen. Braucht noch Priorisierung.
### Queued Execution Reauthorization and Scope Continuity
- **Type**: hardening
- **Source**: architecture audit 2026-03-15
- **Problem**: Queued work still relies too heavily on dispatch-time actor and tenant state. Execution-time scope continuity and capability revalidation are not yet hardened as a canonical backend contract.
- **Why it matters**: This is a backend trust-gap on the mutation path. It creates the class of failure where a UI action was valid at dispatch time but the queued execution is no longer legitimate when it runs.
- **Proposed direction**: Define execution-time reauthorization, tenant operability rechecks, denial semantics, and audit visibility as a dedicated spec instead of scattering local `authorize()` patches.
- **Dependencies**: Existing operations semantics, audit log foundation, queued job execution paths
- **Priority**: high
### Livewire Context Locking and Trusted-State Reduction
- **Type**: hardening
- **Source**: architecture audit 2026-03-15
- **Problem**: Complex Livewire and Filament flows still expose ownership-relevant context in public component state without one explicit repo-wide hardening standard.
- **Why it matters**: This is a trust-boundary problem. Even without a known exploit, mutable client-visible identifiers and workflow context make future authorization and isolation mistakes more likely.
- **Proposed direction**: Define a reusable hardening pattern for locked identifiers, server-derived workflow truth, and forged-state regression tests on tier-1 component families.
- **Dependencies**: Managed tenant onboarding draft identity (Spec 138), onboarding lifecycle checkpoint work (Spec 140)
- **Priority**: medium
### Tenant Draft Discard Lifecycle and Orphaned Draft Visibility ### Tenant Draft Discard Lifecycle and Orphaned Draft Visibility
- **Type**: hardening - **Type**: hardening
- **Source**: domain architecture analysis 2026-03-16 — tenant lifecycle vs onboarding workflow lifecycle review - **Source**: domain architecture analysis 2026-03-16 — tenant lifecycle vs onboarding workflow lifecycle review
@ -112,77 +107,6 @@ ### Baseline Capture Truthful Outcomes and Upstream Guardrails
- If product wants stale successful inventory fallback instead of strict "latest credible only", that needs an explicit rule rather than hidden fallback behavior. - If product wants stale successful inventory fallback instead of strict "latest credible only", that needs an explicit rule rather than hidden fallback behavior.
- **Dependencies**: Baseline drift engine stable (Specs 116119), inventory coverage context, canonical operation-run presentation work (Specs 054, 114, 144), audit log foundation (Spec 134), tenant operability and execution legitimacy direction (Specs 148, 149) - **Dependencies**: Baseline drift engine stable (Specs 116119), inventory coverage context, canonical operation-run presentation work (Specs 054, 114, 144), audit log foundation (Spec 134), tenant operability and execution legitimacy direction (Specs 148, 149)
- **Related specs / candidates**: Spec 101 (baseline governance), Specs 116119 (baseline drift engine), Spec 144 (canonical operation viewer context decoupling), Spec 149 (queued execution reauthorization), `discoveries.md` entry "Drift engine hard-fail when no Inventory Sync exists" - **Related specs / candidates**: Spec 101 (baseline governance), Specs 116119 (baseline drift engine), Spec 144 (canonical operation viewer context decoupling), Spec 149 (queued execution reauthorization), `discoveries.md` entry "Drift engine hard-fail when no Inventory Sync exists"
- **Priority**: high
### Operator Outcome Taxonomy and Cross-Domain State Separation
- **Type**: foundation / hardening
- **Source**: semantic clarity & operator-language audit 2026-03-21, prerequisite-handling architecture analysis
- **Problem**: TenantPilot uses ~12 overloaded words ("failed", "partial", "missing", "gaps", "unsupported", "stale", "blocked", "complete", "ready", "reference only", "metadata only", "needs attention") across at least 8 independent meaning axes that are systematically conflated. The product does not separate execution outcome from data completeness, evidence depth from product support maturity, governance deviation from publication readiness, data freshness from operator actionability. This produces a cross-product operator-trust problem: approximately 60% of warning-colored badges communicate something that is NOT a governance problem. Examples:
- Baseline snapshots show "Unsupported" (gray badge) and "Gaps present" (yellow badge) for policy types where the product simply uses a standard renderer — this is a product maturity fact, not a data quality failure, but it reads as a governance concern.
- Evidence completeness shows "Missing" (red/danger) when no findings exist yet — zero findings is a valid empty state, not missing evidence, but a new tenant permanently displays red badges.
- Restore runs show "Partial" (yellow) at both run and item level with different meanings — operators cannot determine scope of partial success or whether the tenant is in a consistent state.
- `OperationRunOutcome::PartiallySucceeded` provides no item-level breakdown — "99 of 100 succeeded" and "1 of 100 succeeded" are visually identical.
- "Blocked" appears across 4+ domains (operations, verification, restore, execution) without cause-specific explanation or next-action guidance.
- "Stale" is colored gray (passive/archived) when it actually represents data that requires operator attention (freshness issue, should be yellow/orange).
- Product support tier (e.g. fallback renderer vs. dedicated renderer) is surfaced on operator-facing badges where it should be diagnostics-only.
- **Why it matters now**: This is not cosmetic polish — it is a governance credibility problem. TenantPilot's core value proposition is trustworthy tenant governance and review. When 60% of warning badges are false alarms, operators are trained to ignore all warnings, which then masks the badges that represent real governance gaps. Every new domain (Entra Role Governance, Enterprise App Governance, Evidence Domain) will reproduce this conflation pattern unless a shared taxonomy is established first. The semantic-clarity audit classified three of the top five findings as P0 (actively damages operator trust). The problem is systemic and cross-domain — it cannot be solved by individual surface fixes without a shared foundation. The existing badge infrastructure (`BadgeCatalog`, `BadgeRenderer`, 43 badge domains, ~500 case values) is architecturally sound; the taxonomy feeding it is structurally wrong.
- **Proposed direction**:
- **Define mandatory state-axis separation**: establish at minimum 8 independent axes that must never be flattened into a single badge or enum: execution lifecycle, execution outcome, item-level result, data coverage, evidence depth, product support tier, data freshness, and operator actionability. Each axis has its own vocabulary, its own badge domain, and its own color rules.
- **Cross-domain term dictionary**: produce a canonical vocabulary where each term has exactly one meaning across the entire product. Replace the 12 overloaded terms with axis-specific alternatives (e.g. "Partially succeeded" → item-breakdown-aware messaging; "Missing" → "Not collected" / "Not generated" / "Not granted" depending on axis; "Gaps" → categorized coverage notes with cause separation; "Unsupported" → "Standard rendering" moved to diagnostics only; "Stale" → freshness axis with correct severity color).
- **Color-severity decision rules**: codify when red/yellow/blue/green/gray are appropriate. Red = execution failure, governance violation, data loss risk. Yellow = operator action recommended, approaching threshold, mixed outcome. Blue = in-progress, informational. Green = succeeded, complete. Gray = archived, not applicable. Never use yellow for product maturity facts. Never use gray for freshness issues. Never use red for valid-empty states.
- **Diagnostic vs. primary classification**: every piece of state information must be classified as either primary (operator-facing badge/summary) or diagnostic (expandable/secondary technical detail). Product support tier, raw reason codes, Graph API error codes, internal IDs, and renderer metadata are diagnostic-only. Execution outcome, governance status, data freshness, and operator next-actions are primary.
- **Mandatory next-action rule**: every non-green, non-gray state must include either an inline explanation of what happened and whether action is needed, a link to a resolution path, or an explicit "No action needed — this is expected" indicator. States that fail this rule are treated as incomplete operator surfaces.
- **Shared reference document**: produce `docs/ui/operator-semantic-taxonomy.md` (or equivalent) that all domain specs, badge mappers, and new surface implementations reference. This becomes the cross-domain truth source for operator-facing state presentation.
- **Key domain decisions to encode**:
- Product support maturity (renderer tier, capture mode) is NEVER operator-alarming — it belongs in diagnostics
- Valid empty states (zero findings, zero operations, no evidence yet) are NEVER "Missing" (red) — they are "Not yet collected" (neutral) or "Empty" (informational)
- Freshness is a separate axis from completeness — stale data requires action (yellow/orange), not archival (gray)
- "Partial" must always be qualified: partial execution (N of M items), partial coverage (which dimensions), partial depth (which items) — never bare "Partial" without context
- "Blocked" must always specify cause and next action
- **Scope boundaries**:
- **In scope**: state-axis separation rules, term dictionary, color-severity rules, diagnostic/primary classification, next-action policy, enum/badge restructuring guidance, shared reference document, domain adoption sequence recommendations
- **Out of scope**: individual domain adoption (baseline cleanup, evidence reclassification, restore semantic cleanup, etc. are separate domain follow-up specs that consume this foundation), badge rendering infrastructure changes (BadgeCatalog/BadgeRenderer are architecturally sound — the taxonomy they consume is the problem), visual design system or theme work, new component development, operation naming vocabulary (tracked separately as "Operations Naming Harmonization")
- **Why this should be one coherent candidate rather than fragmented domain fixes**: The semantic-clarity audit proves the problem is structural, not local. The same 12 overloaded terms leak into every domain independently. Fixing baselines without a shared taxonomy produces a different vocabulary than fixing evidence, which produces a different vocabulary than fixing operations. Domain-by-domain cleanup without a shared foundation guarantees vocabulary drift between domains. This foundation spec defines rules; domain specs apply them. The foundation is small and decisive (it produces a reference document and restructuring guidelines); the domain adoption specs do the actual refactoring.
- **Affected workflow families / surfaces**: Operations (all run types), Baselines (snapshots, profiles, compare), Evidence (snapshots, completeness), Findings (governance validity, diff messages), Reviews / Review Packs (completeness, freshness, publication readiness), Restore (run status, item results, preview decisions), Inventory (KPI badges, coverage, snapshot mode), Onboarding / Verification (report status, check status), Alerts / Notifications (delivery status, failure messages)
- **Dependencies**: None — this is foundational work that other candidates consume. The badge infrastructure (`BadgeCatalog`, `BadgeRenderer`) is stable and does not need changes — only the taxonomy it serves.
- **Related specs / candidates**: Baseline Capture Truthful Outcomes (consumes this taxonomy for baseline-specific reason codes), Operator Presentation & Lifecycle Action Hardening (complementary — rendering enforcement), Operations Naming Harmonization (complementary — operation type vocabulary), Surface Signal-to-Noise Optimization (complementary — visual weight hierarchy), Admin Visual Language Canon (broader visual convention codification), semantic-clarity-audit.md (source audit)
- **Strategic sequencing**: This is the recommended FIRST candidate in the operator-truth initiative sequence. The Reason Code Translation candidate depends on this taxonomy to define human-readable label targets. The Provider Dispatch Gate candidate benefits from shared outcome vocabulary for preflight results. Without this foundation, both downstream candidates will invent local vocabularies.
- **Priority**: high
### Operator Reason Code Translation and Humanization Contract
- **Type**: hardening
- **Source**: semantic clarity & operator-language audit 2026-03-21, prerequisite-handling architecture analysis, cross-domain reason code inventory
- **Problem**: TenantPilot has 6 distinct reason-code artifacts across 4 different structural patterns (final class with string constants, backed enum with `->message()`, backed enum without `->message()`, spec-level taxonomy) spread across provider, baseline, execution, operability, RBAC, and verification domains. These reason codes are the backend's primary mechanism for explaining why an operation was blocked, denied, degraded, or failed. But the product lacks a consistent translation/humanization contract for surfacing these codes to operators:
- `ProviderReasonCodes` (24 codes) and `BaselineReasonCodes` (7 codes) are raw string constants with **no human-readable translation** — they leak directly into run detail contexts, notifications, and banners as technical fragments like `RateLimited`, `ProviderAuthFailed`, `ConsentNotGranted`, `CredentialsInvalid`.
- `TenantOperabilityReasonCode` (10 cases) and `RbacReason` (10 cases) are backed enums with **no `->message()` method** — they can reach operator-facing surfaces as raw enum values without translation.
- `BaselineCompareReasonCode` (5 cases) and `ExecutionDenialReasonCode` (9 cases) have inline `->message()` methods with hardcoded English strings — better, but inconsistent with the rest.
- `RunFailureSanitizer` already performs ad-hoc normalization across reason taxonomies using heuristic string-matching and its own `REASON_*` constants, proving the need for a systematic approach.
- `ProviderNextStepsRegistry` maps some provider reason codes to link-only remediation steps — but this is limited to provider-domain codes and provides navigation links, not human-readable explanations.
- Notification payloads (`OperationRunQueued`, `OperationRunCompleted`, `OperationUxPresenter`) include sanitized but still technical reason strings — operators receive "Execution was blocked. Rate limited." without retry guidance or contextual explanation.
- Run summary counts expose raw internal keys (`errors_recorded`, `report_deduped`, `posture_score`) in operator-facing summary lines via `SummaryCountsNormalizer`.
- **Why it matters now**: Reason codes are the backend's richest source of "why did this happen?" truth. The backend frequently already knows the cause, the prerequisite, and the next step — but this knowledge reaches operators as raw technical fragments because there is no systematic translation layer. As TenantPilot adds more provider domains, more operation types, and more governance workflows, every new reason code that lacks a human-readable translation reproduces the same operator-trust degradation. Some parts of the product already translate codes well (`BaselineCompareReasonCode::message()`, `RunbookReason::options()`), proving the pattern is viable. The gap is not the pattern — it is its inconsistent adoption across all 6+ reason-code families.
- **Proposed direction**:
- **Reason code humanization contract**: every reason-code artifact (whether `final class` constants or backed enums) must provide a `->label()` or equivalent method that returns a human-readable, operator-appropriate string. Raw string constants that cannot provide methods must be wrapped in or migrated to enums that can.
- **Translation target vocabulary**: human-readable labels must use the vocabulary defined by the Operator Outcome Taxonomy. Internal codes remain stable for machines/logs/tests; operator-facing surfaces exclusively use translated labels. The translation contract is the bridge between internal precision and external clarity.
- **Structured reason resolution**: reason code translation should return not just a label but a structured resolution envelope containing: (1) human-readable label, (2) optional short explanation, (3) optional next-action link/text, (4) severity classification (retryable transient vs. permanent configuration vs. prerequisite missing). This envelope replaces the current pattern of ad-hoc string formatting in `RunFailureSanitizer`, notification builders, and presenter classes.
- **Cross-domain registry or convention**: either a central `ReasonCodeTranslator` service that dispatches to domain-specific translators, or a mandatory `Translatable` interface/trait that all reason-code artifacts must implement. Prefer the latter (each domain owns its translations) over a central monolith, but enforce the contract architecturally.
- **Summary count humanization**: `SummaryCountsNormalizer` (or its successor) must map internal metric keys to operator-readable labels. `errors_recorded` → "Errors", `report_deduped` → "Reports deduplicated", etc. Raw internal keys must never reach operator-facing summary lines.
- **Next-steps enrichment**: expand `ProviderNextStepsRegistry` pattern to all reason-code families — not just provider codes. Every operator-visible reason code that implies a prerequisite or recoverable condition should include actionable next-step guidance (link, instruction, or "contact support" fallback).
- **Notification payload cleanup**: notification builders (`OperationUxPresenter`, terminal notifications) must consume translated labels, not raw reason strings. Failure messages must include cause + retryability + next action, not just a sanitized error string.
- **Key decisions to encode**:
- Internal codes remain stable and must not be renamed for cosmetic reasons — they are machine contracts used in logs, tests, and audit events
- Operator-facing surfaces exclusively use translated labels — raw codes move to diagnostic/secondary detail areas
- Every reason code must be classifiable as retryable-transient, permanent-configuration, or prerequisite-missing — this classification drives notification tone and next-action guidance
- `RunFailureSanitizer` should be superseded by the structured translation contract, not extended with more heuristic string-matching
- **Scope boundaries**:
- **In scope**: reason code humanization contract, translation interface/trait, structured resolution envelope, migration plan for existing 6 artifacts, summary count humanization, notification payload cleanup, next-steps enrichment across all reason families
- **Out of scope**: creating new reason codes (domain specs own that), changing the semantic meaning of existing codes, badge infrastructure changes (badges consume translated labels — the rendering infrastructure is stable), operation naming vocabulary (tracked separately), individual domain-specific notification redesign beyond label substitution
- **Affected workflow families / surfaces**: Operations (run detail, summary, notifications), Provider (connection health, blocked notifications, next-steps banners), Baselines (compare skip reasons, capture precondition reasons), Restore (run result messages, check severity explanations), Verification (check status explanations), RBAC (health check reasons), Onboarding (lifecycle denial reasons), Findings (diff unavailable messages, governance validity labels), System Console (triage, failure details)
- **Why this should be one coherent candidate rather than fragmented per-domain fixes**: The 6 reason-code artifacts share the same structural gap (no consistent humanization contract), and per-domain fixes would each invent a different translation pattern. The contract must be defined once so that all domains implement it consistently. A per-domain approach would produce 6 different label formats, 6 different next-step patterns, and no shared resolution envelope — exactly the inconsistency this candidate eliminates. The implementation touches each domain's reason-code artifact, but the contract that governs all of them must be a single decision.
- **Dependencies**: Operator Outcome Taxonomy (provides the target vocabulary that reason code labels translate into). Soft dependency — translation work can begin with pragmatic labels before the full taxonomy is ratified, but labels should converge with the taxonomy once available.
- **Related specs / candidates**: Operator Outcome Taxonomy and Cross-Domain State Separation (provides vocabulary target), Operator Presentation & Lifecycle Action Hardening (provides rendering enforcement), Baseline Capture Truthful Outcomes (consumes baseline-specific reason code translations), Provider Connection Resolution Normalization (provides backend connection plumbing that gate results reference), Operations Naming Harmonization (complementary — operation type labels vs. reason code labels)
- **Strategic sequencing**: Recommended SECOND in the operator-truth initiative sequence, after the Outcome Taxonomy. Can begin in parallel if pragmatic interim labels are acceptable, but final label convergence depends on the taxonomy.
- **Priority**: high
### Provider-Backed Action Preflight and Dispatch Gate Unification ### Provider-Backed Action Preflight and Dispatch Gate Unification
- **Type**: hardening - **Type**: hardening
@ -214,21 +138,216 @@ ### Provider-Backed Action Preflight and Dispatch Gate Unification
- **Why this should be one coherent candidate rather than per-action fixes**: The Gen 1 pattern is used by ~20 services. Fixing each service independently would produce 20 local preflight implementations with inconsistent result rendering, different dedup logic, and incompatible notification patterns. The value is the unified contract: one gate, one result envelope, one presenter, one notification pattern. Per-action fixes cannot deliver this convergence. - **Why this should be one coherent candidate rather than per-action fixes**: The Gen 1 pattern is used by ~20 services. Fixing each service independently would produce 20 local preflight implementations with inconsistent result rendering, different dedup logic, and incompatible notification patterns. The value is the unified contract: one gate, one result envelope, one presenter, one notification pattern. Per-action fixes cannot deliver this convergence.
- **Dependencies**: Provider Connection Resolution Normalization (soft dependency — gate unification is more coherent when all services receive explicit connection IDs, but the gate itself can be extended before full backend normalization is complete since `ProviderConnectionResolver::resolveDefault()` already exists). Operator Reason Code Translation (for translated blocked-reason labels in gate results). Operator Outcome Taxonomy (for consistent outcome vocabulary in gate result presentation). - **Dependencies**: Provider Connection Resolution Normalization (soft dependency — gate unification is more coherent when all services receive explicit connection IDs, but the gate itself can be extended before full backend normalization is complete since `ProviderConnectionResolver::resolveDefault()` already exists). Operator Reason Code Translation (for translated blocked-reason labels in gate results). Operator Outcome Taxonomy (for consistent outcome vocabulary in gate result presentation).
- **Related specs / candidates**: Provider Connection Resolution Normalization (backend plumbing), Provider Connection UX Clarity (UX labels), Provider Connection Legacy Cleanup (post-normalization cleanup), Queued Execution Reauthorization (execution-time revalidation — complementary to dispatch-time gating), Baseline Capture Truthful Outcomes (consumes gate results for baseline-specific preconditions), Operator Presentation & Lifecycle Action Hardening (rendering conventions for gate results) - **Related specs / candidates**: Provider Connection Resolution Normalization (backend plumbing), Provider Connection UX Clarity (UX labels), Provider Connection Legacy Cleanup (post-normalization cleanup), Queued Execution Reauthorization (execution-time revalidation — complementary to dispatch-time gating), Baseline Capture Truthful Outcomes (consumes gate results for baseline-specific preconditions), Operator Presentation & Lifecycle Action Hardening (rendering conventions for gate results)
- **Strategic sequencing**: Recommended THIRD in the operator-truth initiative sequence. Benefits from the vocabulary defined by the Outcome Taxonomy and the humanized labels from Reason Code Translation. However, the backend extension of `ProviderOperationStartGate` scope can proceed independently and in parallel with the other two candidates. - **Strategic sequencing**: Recommended as the adjacent hardening lane after the shared taxonomy and translation work are in place, while governance-surface adoption proceeds through Spec 158 and the governance compression follow-up. It benefits from the vocabulary defined by the Outcome Taxonomy and the humanized labels from Reason Code Translation, but much of the backend extension of `ProviderOperationStartGate` scope can proceed independently and in parallel with governance-surface work.
- **Priority**: high
### Governance Operator Outcome Compression
- **Type**: hardening
- **Source**: product follow-up recommendation 2026-03-23; direct continuation of Spec 158 (`artifact-truth-semantics`)
- **Vehicle**: new standalone candidate
- **Problem**: Spec 158 establishes the correct internal truth model for governance artifacts, but several governance-facing list and summary surfaces still risk exposing too many internal semantic axes as first-class UI language. On baseline, evidence, review, and pack surfaces the product can still read as academically correct but operator-heavy: multiple adjacent status badges, architecture-derived labels, and equal treatment of existence, readiness, freshness, completeness, and publication semantics. Normal operators are forced to synthesize the answer to three simple workflow questions themselves: Is this artifact usable, why not, and what should I do next?
- **Why it matters**: This is the cockpit follow-up to Spec 158's engine work. Without it, TenantPilot preserves semantic correctness internally but leaks too much of that structure directly into governance UX. The result is lower scanability, weaker operator confidence, and a real risk that baseline, evidence, review, and pack domains each evolve their own local status dialect despite sharing the same truth foundation. Shipping this follow-up before broader governance expansion stabilizes operator language where MSP admins actually work.
- **Proposed direction**:
- Introduce a **compressed operator outcome layer** for governance artifacts that consumes the existing `ArtifactTruthEnvelope`, outcome taxonomy, and reason translation contracts without discarding any internal truth dimensions
- Define rendering rules that classify each truth dimension as **primary operator view**, **secondary explanatory detail**, or **diagnostics only**
- Make list and overview rows answer three questions first: **primary state**, **short reason**, **next action**
- Normalize visible operator language so internal architectural terms such as `artifact truth`, `missing_input`, `metadata_only`, or `publication truth` do not dominate primary workflow surfaces
- Clarify where **publication readiness** is the primary business statement versus where it is only one secondary dimension, especially for tenant reviews and review packs
- Keep diagnostics available on detail and run-detail pages, but demote raw reason structures, fidelity sub-axes, JSON context, and renderer/support facts behind the primary operator explanation
- **Primary adoption surfaces**:
- Baseline snapshot lists and detail pages
- Evidence snapshot lists and detail pages
- Evidence overview
- Tenant review lists and detail pages
- Review register
- Review pack lists and detail pages
- Shared governance detail templates and artifact-truth presenter surfaces
- Artifact-oriented run-detail pages only where the run is explaining baseline, evidence, review, or review-pack truth
- **Scope boundaries**:
- **In scope**: visible operator labels, list-column hierarchy, detail-page information hierarchy, mapping from artifact-truth envelopes to compressed operator states, explicit separation between default operator view and diagnostic detail, review/pack publication-readiness primacy rules, governance run-detail explanation hierarchy
- **Out of scope**: full operations-list redesign, broad visual polish, color or spacing retuning as the primary goal, new semantic foundation axes, broad findings or workspace overview rewrites, compliance/audit PDF output changes, alert routing or notification copy rewrites, domain-model refactors that change the underlying truth representation
- **Core product principles to encode**:
- One primary operator statement per artifact on scan surfaces
- No truth loss: internal artifact truth, reason structures, APIs, audit context, and JSON diagnostics remain intact and available
- Diagnostics are second-layer, not the default operator language
- Context-specific business language beats architecture-first vocabulary on primary governance surfaces
- Lists are scan surfaces, not diagnosis surfaces
- **Candidate requirements**:
- **R1 Composite operator outcome**: governance artifacts expose a compressed operator-facing outcome derived from the existing truth and reason model
- **R2 Primary / secondary / diagnostic rendering rules**: the system defines which semantic dimensions may appear in each rendering tier
- **R3 List-surface simplification**: governance lists stop defaulting to multi-column badge explosions for separate semantic axes
- **R4 Detail-surface hierarchy**: details lead with outcome, explanation, and next action before diagnostics
- **R5 Operator language normalization**: internal architecture terms are translated or removed from primary governance UI
- **R6 Review / pack publication clarity**: review and pack surfaces explicitly state when publishability is the main business decision and when it is not
- **R7 No truth loss**: APIs, audit, diagnostics, and raw context remain available even when the primary presentation is compressed
- **Acceptance points**:
- Governance lists no longer present multiple equal-weight semantic badge columns as the default mental model
- `artifact truth` and sibling architecture-first labels stop dominating primary operator surfaces
- Governance detail pages clearly separate primary state, explanatory reason, next action, and diagnostics
- Review and pack surfaces clearly answer whether the artifact is ready to publish or share
- Baseline and evidence surfaces clearly answer whether the artifact is trustworthy and usable
- Governance run-detail pages make the dominant problem and next action understandable without reading raw JSON
- The internal truth model remains fully usable for diagnostics, audit, and downstream APIs
- **Dependencies**: Spec 156 (operator-outcome-taxonomy), Spec 157 (reason-code-translation), Spec 158 (artifact-truth-semantics), shared governance detail templates, review-layer and evidence-domain adoption surfaces already in flight
- **Related specs / candidates**: Spec 153 (evidence-domain-foundation), Spec 155 (tenant-review-layer), Spec 158 (artifact-truth-semantics), Baseline Snapshot Fidelity Semantics candidate, Compliance Readiness & Executive Review Packs candidate
- **Strategic sequencing**: Recommended immediately after Spec 158 and before any major additional governance-surface expansion. This is the adoption layer that turns the truth semantics foundation into an operator-tolerable cockpit instead of a direct dump of internal semantic richness.
- **Priority**: high
### Humanized Diagnostic Summaries for Governance Operations
- **Type**: hardening
- **Source**: operator-facing governance run-detail gap analysis 2026-03-23; follow-up to Specs 156, 157, and 158
- **Vehicle**: new standalone candidate
- **Problem**: Governance run-detail pages now have the right outcome, reason, and artifact-truth semantics, but the operator explanation often still lives in raw JSON. A run can read as `Completed with follow-up`, `Partial`, `Blocked`, or `Missing input` while the important meaning stays hidden: how much was affected, which cause dominates, whether retry or resume helps, and whether the resulting artifact is actually trustworthy.
- **Why it matters**: This is the missing middle layer between Spec 158's truth engine and the operator's actual decision. Without it, TenantPilot stays semantically correct but too technical on one of its highest-trust governance surfaces. Raw JSON remains part of normal troubleshooting when it should be optional.
- **Proposed direction**:
- Add a humanized diagnostic summary layer on governance run-detail pages between semantic verdicts and raw JSON
- Lead with impact, dominant cause, artifact trustworthiness, and next action instead of forcing operators to infer those from badges plus raw context
- Render a compact dominant-cause breakdown for multi-cause degraded runs, including counts or relative scale where useful
- Separate processing-success counters from artifact usability so technically correct metrics do not read as false-green artifact success
- Upgrade generic `Inspect diagnostics` guidance into cause-aware next steps such as retry later, resume capture, refresh policy inventory, verify missing policies, review ambiguous matches, or fix access or scope configuration
- Keep raw JSON and low-level context fully available, but explicitly secondary
- **Primary adoption surfaces**:
- Canonical Monitoring run-detail pages for governance operation types
- Shared tenantless canonical run viewers and run-detail templates
- Governance run detail reached from baseline capture, baseline compare, evidence refresh or snapshot generation, tenant review generation, and review-pack generation
- **Scope boundaries**:
- **In scope**: run-detail explanation hierarchy, humanized impact summaries, dominant-cause breakdowns, clearer processing-versus-artifact terminology, reusable guidance pattern for governance run families
- **Out of scope**: full Operations redesign, broad list or dashboard overhaul, new persistence models for summaries, removal of raw JSON, new truth axes beyond the existing outcome or artifact-truth model, generalized rewrite of all governance artifact detail pages
- **Acceptance points**:
- A normal operator can understand the dominant problem and next step on a governance run-detail page without opening raw JSON
- Runs with technically successful processing but degraded artifacts explicitly explain why those truths diverge
- Multi-cause degraded runs show the dominant causes and their scale instead of only one flattened abstract state
- Guidance distinguishes retryable or resumable situations from structural prerequisite problems when the data supports that distinction
- Raw JSON remains present for audit and debugging but is clearly secondary in the page hierarchy
- **Dependencies**: Spec 156 (operator-outcome-taxonomy), Spec 157 (reason-code-translation), Spec 158 (artifact-truth-semantics), canonical operation viewer work, shared governance detail templates
- **Related specs / candidates**: Spec 144 (canonical operation viewer context decoupling), Spec 153 (evidence-domain-foundation), Spec 155 (tenant-review-layer), Spec 158 (artifact-truth-semantics), Governance Operator Outcome Compression candidate
- **Strategic sequencing**: Best treated as the run-detail explainability companion to Governance Operator Outcome Compression. Compression improves governance artifact scan surfaces; this candidate makes governance run detail self-explanatory once an operator drills in.
- **Priority**: high
### Operator Explanation Layer for Degraded / Partial / Suppressed Results
- **Type**: cross-cutting UX / domain semantics / operator clarity
- **Source**: product analysis 2026-03-23; direct follow-up to Spec 156 (`operator-outcome-taxonomy`)
- **Vehicle**: new standalone candidate
- **Problem**: Spec 156 improves status, outcome, and run vocabulary, but pure outcome taxonomy does not resolve a deeper operator-readability gap: the product stores truth across multiple semantic dimensions (execution, evaluation, reliability, coverage, recommended action) that are currently presented side by side without separation or explanation. Several governance and operational surfaces — Baseline Compare, Baseline Capture, Operation Run detail, Tenant Reviews, evidence-dependent results — show technically correct but operatorisch schwer lesbare combinations such as `Run finished` + `Counts 0` + `Why no findings: evidence_capture_incomplete`. These force operators to synthesize whether: the run was technically successful, the result is trustworthy, findings were genuinely absent, data was missing, assignments were ambiguous, or follow-up is required. The product preserves truth better than it explains truth. Distinct truth dimensions collapse into shared reading surfaces — count blocks that look like complete results when they show only a subset, `0 findings` that reads as reassurance when evaluation was incomplete, reason codes that serve as primary operator explanation when they are diagnostic material. Enterprise operators need the UI to make without JSON and without implicit product knowledge clear: what happened, how reliable the result is, and what to do next.
- **Why it matters**: This is the missing interpretation layer between the outcome taxonomy foundation (Spec 156) and the operator's actual decision. Without it, the product remains formally correct but interpretively weak on its highest-trust governance surfaces. Concrete consequences: operators read `0 findings` as all-clear when evaluation was constrained by evidence gaps; support and debug cost stays high because run-detail pages require JSON or expert knowledge; reason codes leak into operator surfaces as primary explanation; semantic inconsistencies multiply with each new governance/evidence/review feature; enterprise readiness suffers despite strong internal modelling. The gap is systemic — not a single-surface fix — because the same class of problem (execution succeeded, but result is degraded/partial/suppressed) recurs across baselines, reviews, evidence, monitoring, and future governance domains.
- **Proposed direction**:
- **R1 — Multi-dimensional outcome model in the UI**: operator surfaces must separate result communication into at least five distinguishable axes: execution status, evaluation result, reliability/confidence/trustworthiness, coverage/completeness, recommended action. Not every page needs all five equally prominent, but the UI model must keep them distinct instead of collapsing them into a single badge or count block.
- **R2 — Primary operator explanation over raw reason codes**: every relevant reason code or reason cluster gets an operator-readable title, clear meaning description, impact explanation, and next-step guidance. Technical reason codes remain stored and diagnostically usable but stop being the primary explanation. Example: not `evidence_capture_incomplete` as the headline, but "Vergleich unvollständig bewertet — 16 Evidence-Lücken verhinderten vollständige Bewertung."
- **R3 — Semantically unambiguous counts**: count blocks must not imply completeness when they represent only a subset. The spec defines which counts are execution counts, which are evaluation-output counts, which are data-quality/completeness counts, and how suppressed/incomplete/degraded states surface. `0 findings` when evidence gaps exist must not read as reassurance.
- **R4 — "Why no findings / no results / no report" explanation patterns**: a shared pattern for explaining absent output, distinguishing between genuinely no issues found, no results producible due to missing inputs, results suppressed due to unreliable evidence, prerequisite failure, viewer/rendering limitation, pending calculation, and intentionally unpublished.
- **R5 — Reliability/confidence visibility**: compare-, review-, and evidence-dependent results must show whether the result is trustworthy, limited in trustworthiness, incomplete, diagnostically usable but not decision-grade, or not usable. Terms like fidelity, coverage, partial, meta, full, degraded get a defined reading direction instead of appearing loosely side by side.
- **R6 — Semantically derived next-step guidance**: the next-step surface must distinguish between no action needed, observe, re-run, fix prerequisite, update inventory/sync, check evidence gaps, manually validate, and escalate — derived from cause and severity, not a generic fallback phrase.
- **R7 — Diagnostics available but not dominant**: technical raw data, run context, and reason codes remain accessible for debugging but the default reading path leads with meaning and action: what happened → how reliable → why it looks this way → what to do → then technical details.
- **R8 — Shared explanation patterns, not page-by-page special logic**: define reusable cross-domain explanation patterns: completed but degraded, completed but incomplete, no output because suppressed, no output because insufficient evidence, partial result with fallback, output exists but not publication-ready, viewer limitation vs source limitation, prerequisite missing vs execution failed.
- **R9 — Baseline Compare as reference case**: Baseline Compare is the golden path for this spec. The case where compare finishes technically, driftResults = 0, counts = 0, evidence gaps exist, and "why no findings" is currently reason-code-heavy must become immediately understandable without additional product knowledge.
- **R10 — No contradiction between top-level state and detail interpretation**: top-level states (run finished, completed with follow-up, succeeded, partially succeeded) must not conflict with the underlying result surfaces. Technically successful but fachlich limited results must read as a consistent composite.
- **Primary adoption surfaces**:
- Operation Run detail pages
- Baseline Capture
- Baseline Compare
- Baseline Snapshot / Compare truth surfaces
- Tenant Reviews / review generation states
- Evidence-dependent result surfaces
- Run summaries, state banners, next-step texts, count summaries
- Reason code presentation / translation layer
- Count semantics for runs with suppressed / incomplete / degraded results
- **Optional extension surfaces**: restore/backup operation outcomes, prerequisite/readiness/missing-input explanation surfaces, alerts that rely on degraded semantics
- **UX/semantics principles**:
- Truth first, but operator-first wording — truth is not softened but expressed in readable operator language
- No false reassurance — `0 findings` or `no errors` must not read as all-clear when evaluation was constrained
- Separate execution from confidence — a technically successful run can still produce a limited-confidence result
- Default path before diagnostics — the normal reading flow answers meaning and action first, then technical detail
- Consistent semantics across domains — the same kind of state must not be named or explained differently in baselines, reviews, and evidence
- **Scope boundaries**:
- **In scope**: explanation dimension definitions, outcome-to-operator-message mapping, shared explanation pattern library, reference implementation for Baseline Compare / Operation Run detail / Baseline Capture, count semantics rules, reliability visibility rules, next-step guidance patterns, reason code presentation hierarchy
- **Out of scope**: queue/retry/job-runtime model redesign, large-scale raw data model restructuring (existing run-context data should suffice), purely visual polishing without semantic improvement, reason code elimination (they remain for diagnostics), complete redesign of every product page, compliance/governance domain logic changes
- **Phased delivery**:
- Phase 1 — Semantics model: define explanation dimensions, outcome vs reliability vs coverage vs action separation, reason-code-to-operator-message mapping, shared explanation pattern library
- Phase 2 — Reference implementation: Operation Run detail page, Baseline Compare, Baseline Capture
- Phase 3 — Extension: Tenant Reviews, evidence/report surfaces, further degraded/prerequisite-heavy flows
- **Success criteria**:
- An operator can understand degraded/suppressed results without JSON: what was executed, whether results were produced, how reliable they are, why nothing was generated, and what to do next
- Baseline Compare with 0 findings + evidence gaps shows no implicit all-clear
- Reason codes remain technically usable but no longer serve as sole primary explanation
- Count surfaces are semantically unambiguous, distinguishing output counts from completeness/reliability signals
- At least one shared explanation pattern is reused across domains rather than landing as one-off page logic
- **Dependencies**: Spec 156 (operator-outcome-taxonomy — shared vocabulary foundation), Spec 157 (reason-code-translation — humanized code labels), Spec 158 (artifact-truth-semantics — governance artifact truth model), Spec 159 (baseline-snapshot-truth — baseline truth surfaces)
- **Related specs / candidates**: Governance Operator Outcome Compression (governance-artifact list/scan compression — complementary, narrower scope), Humanized Diagnostic Summaries for Governance Operations (governance run-detail explainability — complementary, narrower scope), Baseline Capture Truthful Outcomes (capture-specific precondition and outcome hardening), OperationRun Humanization & Diagnostics Boundary (run-detail operator-first hierarchy)
- **Strategic importance**: This is not UI polish but an enterprise-readability and governance-trust layer. As the roadmap expands toward baseline governance, findings workflow, exceptions/risk acceptance, stored reports, tenant reviews, evidence packs, and MSP portfolio views, the cost of not having a systematic explanation layer increases with every new feature. The longer this is deferred, the more governance surfaces ship with locally invented explanation patterns that diverge instead of converging. This candidate is strategically anschlussfähig rather than locally reactive.
- **Priority**: high - **Priority**: high
> **Operator Truth Initiative — Sequencing Note** > **Operator Truth Initiative — Sequencing Note**
> >
> The three candidates above (Operator Outcome Taxonomy, Reason Code Translation, Provider Dispatch Gate Unification) form a coherent cross-product initiative addressing the systemic gap between backend truth richness and operator-facing truth quality. They are sequenced as a dependency chain with parallelization opportunities: > The operator-truth work now has two connected lanes: a shared truth-foundation lane and a governance-surface compression lane. Together they address the systemic gap between backend truth richness and operator-facing truth quality without forcing operators to parse raw internal semantics.
> >
> **Recommended order:** > **Recommended order:**
> 1. **Operator Outcome Taxonomy and Cross-Domain State Separation** — defines the shared vocabulary, state-axis separation rules, and color-severity conventions that all other operator-facing work references. This is the smallest deliverable (a reference document + restructuring guidelines) but the highest-leverage decision. Without it, the other two candidates will invent local vocabularies that diverge. > 1. **Operator Outcome Taxonomy and Cross-Domain State Separation** — defines the shared vocabulary, state-axis separation rules, and color-severity conventions that all other operator-facing work references. This is the smallest deliverable (a reference document + restructuring guidelines) but the highest-leverage decision. Without it, the other candidates will invent local vocabularies that diverge.
> 2. **Operator Reason Code Translation and Humanization Contract** — defines the translation bridge from internal codes to operator-facing labels using the Outcome Taxonomy's vocabulary. Can begin in parallel with the taxonomy using pragmatic interim labels, but final convergence depends on the taxonomy. > 2. **Operator Reason Code Translation and Humanization Contract** — defines the translation bridge from internal codes to operator-facing labels using the Outcome Taxonomy's vocabulary. Can begin in parallel with the taxonomy using pragmatic interim labels, but final convergence depends on the taxonomy.
> 3. **Provider-Backed Action Preflight and Dispatch Gate Unification** — extends the proven Gen 2 gate pattern to all provider-backed operations and establishes a shared result presenter. Benefits from both upstream candidates but has significant backend scope (gate extension + scope-busy enforcement) that can proceed independently. > 3. **Governance Artifact Truthful Outcomes & Fidelity Semantics** — establishes the full internal truth model for governance artifacts, keeping existence, usability, freshness, completeness, publication readiness, and actionability distinct.
> 4. **Operator Explanation Layer for Degraded / Partial / Suppressed Results** — defines the cross-cutting interpretation layer that turns internal truth dimensions into operator-readable explanation: multi-dimensional outcome separation (execution, evaluation, reliability, coverage, action), shared explanation patterns for degraded/partial/suppressed states, count semantics rules, "why no findings" patterns, and reliability visibility. Consumes the taxonomy, translation, and artifact-truth foundations; provides the shared explanation pattern library that compression and humanized summaries adopt on their respective surfaces. Baseline Compare is the golden-path reference implementation.
> 5. **Governance Operator Outcome Compression** — applies the foundation and explanation layer to governance workflow surfaces so lists and details answer the operator's primary question first, while preserving diagnostics as second-layer detail.
> 6. **Humanized Diagnostic Summaries for Governance Operations** — the run-detail explainability companion to compression; makes governance run detail self-explanatory using the explanation patterns established in step 4.
> 7. **Provider-Backed Action Preflight and Dispatch Gate Unification** — extends the proven Gen 2 gate pattern to all provider-backed operations and establishes a shared result presenter. Benefits from the shared vocabulary work but remains a parallel hardening lane rather than a governance-surface adoption slice.
> >
> **Why this order rather than the inverse:** The semantic-clarity audit classified the taxonomy problem as P0 (60% of warning badges are false alarms — actively damages operator trust). Reason code translation and gate unification are both P1-level (strongly confusing, should be fixed soon). The taxonomy is also the smallest and most decisive deliverable — it produces a reference that all other candidates consume. Shipping the taxonomy first prevents the other two candidates from making locally correct but globally inconsistent vocabulary choices. The gate unification has the largest implementation surface (~20 services) but much of its backend work (extending `ProviderOperationStartGate` scope, adding connection locking, dedup enforcement) can proceed in parallel once the taxonomy establishes the shared vocabulary for gate result presentation. > **Why this order rather than the inverse:** The semantic-clarity audit classified the taxonomy problem as P0 (60% of warning badges are false alarms — actively damages operator trust). Reason code translation creates the shared human-facing language. Spec 158 establishes the correct internal truth engine for governance artifacts. The Operator Explanation Layer then defines the cross-cutting interpretation patterns that all downstream surfaces need — the systemwide rules for how degraded, partial, suppressed, and incomplete results are separated, explained, and acted upon. Compression and humanized summaries are adoption slices that apply those patterns to specific surface families (governance artifact lists and governance run details respectively). Gate unification remains highly valuable but is a neighboring hardening lane.
> >
> **Why these are not one spec:** Each candidate has a different implementation surface, different stakeholders, and different shippability boundaries. The taxonomy is a cross-cutting decision document. Reason code translation touches 6+ reason-code artifacts and notification builders. Gate unification touches ~20 services, the gate class, Filament action handlers, and notification templates. Merging them would create an unshippable monolith. Keeping them as a sequenced initiative preserves independent delivery while ensuring vocabulary convergence. > **Why these are not one spec:** Each candidate has a different implementation surface, different stakeholders, and different shippability boundary. The taxonomy is a cross-cutting decision document. Reason code translation touches reason-code artifacts and notification builders. Spec 158 defines the richer artifact truth engine. The Operator Explanation Layer defines the shared interpretation semantics and explanation patterns. Governance operator outcome compression is a UI-information-architecture adoption slice across governance artifact surfaces. Humanized diagnostic summaries are an adoption slice for governance run-detail pages. Gate unification touches provider dispatch and notification plumbing across ~20 services. Merging them would create an unshippable monolith. Keeping them sequenced preserves independent delivery while still converging on one operator language.
### Operation Run Active-State Visibility & Stale Escalation
- **Type**: hardening
- **Source**: product/operator visibility analysis 2026-03-24; operation-run lifecycle and stale-state communication review
- **Vehicle**: new standalone candidate
- **Problem**: TenantPilot already has the core lifecycle foundations for `OperationRun` records: canonical run modelling, workspace-level run viewing, per-type lifecycle policies, freshness and stale detection, overdue-run reconciliation, terminal notifications, and tenant-local active-run hints. The gap is no longer primarily lifecycle logic. The gap is that the same lifecycle truth is not communicated with enough consistency and urgency across the operator surfaces that matter. A run can be past its expected lifecycle or likely stuck while still looking like normal active work on tenant-local cards or dashboard attention surfaces. Operators then have to drill into the canonical run viewer to learn that the run is no longer healthy, which weakens monitoring trust and makes hanging work look deceptively normal.
- **Why it matters**: This is an observability and operator-trust problem in a core platform layer, not visual polish. If `queued` or `running` remains visually neutral after lifecycle expectations have been exceeded, operators receive false reassurance, support burden rises, queue or worker issues are discovered later, and the product trains users that active-state surfaces are not trustworthy without manual drill-down. As TenantPilot pushes more governance, review, drift, and evidence workflows through `OperationRun`, stale active work must never read as healthy progress anywhere in the product.
- **Proposed direction**:
- Reuse the existing lifecycle, freshness, and reconciliation truth to define one **cross-surface active-state presentation contract** that distinguishes at least: `active / normal`, `active / past expected lifecycle`, `stale / likely stuck`, and `terminal / no longer active`
- Upgrade **tenant-local active-run and progress cards** so stale or past-lifecycle runs are visibly and linguistically different from healthy active work instead of reading as neutral `Queued • 1d` or `Running • 45m`
- Upgrade **tenant dashboard and attention surfaces** so they distinguish between healthy activity, activity that needs attention, and activity that is likely stale or hanging
- Upgrade the **workspace operations list / monitoring views** so problematic active runs become scanable at row level instead of being discoverable only through subtle secondary text or by opening each run
- Preserve the **workspace-level canonical run viewer** as the authoritative diagnostic surface, while ensuring compact and summary surfaces do not contradict it
- Apply a **same meaning, different density** rule: tenant cards, dashboard signals, list rows, and run detail may vary in information density, but not in lifecycle meaning or operator implication
- **Core product principles**:
- Execution lifecycle, freshness, and operator attention are related but not identical dimensions
- Compact surfaces may compress information, but must not downplay stale or hanging work
- The workspace-level run viewer remains canonical; this candidate improves visibility, not source-of-truth ownership
- Stale or past-lifecycle work must not look like healthy progress anywhere
- **Candidate requirements**:
- **R1 Cross-surface lifecycle visibility**: all relevant active-run surfaces can distinguish at least normal active, past-lifecycle active, stale/likely stuck, and terminal states
- **R2 Tenant active-run escalation**: tenant-local active-run and progress cards visibly and linguistically escalate stale or past-lifecycle work
- **R3 Dashboard attention separation**: dashboard and attention surfaces distinguish healthy activity from concerning active work
- **R4 Operations-list scanability**: the workspace operations list makes problematic active runs quickly identifiable without requiring row-by-row interpretation or drill-in
- **R5 Canonical viewer preservation**: the workspace-level run viewer remains the detailed and authoritative truth surface
- **R6 No hidden contradiction**: a run that is clearly stale or lifecycle-problematic on the detail page must not appear as ordinary active work on tenant or monitoring surfaces
- **R7 Existing lifecycle logic reuse**: the candidate reuses current freshness, lifecycle, and reconciliation semantics instead of introducing parallel UI-only heuristics
- **R8 No new backend lifecycle semantics unless necessary**: new status values or model-level lifecycle semantics are out unless the current semantics cannot carry the presentation contract cleanly
- **Scope boundaries**:
- **In scope**: tenant-local active-run cards, tenant dashboard activity and attention surfaces, workspace operations list and monitoring surfaces, shared lifecycle presentation contract for active-state visibility, copy and visual semantics needed to distinguish healthy active work from stale active work
- **Out of scope**: retry, cancel, force-fail, or reconcile-now operator actions; queue or worker architecture changes; new scheduler or timeout engines; new notification channels; a full operations-hub redesign; cross-workspace fleet monitoring; introducing new `OperationRun` status values unless existing semantics are proven insufficient
- **Acceptance points**:
- An active run outside its lifecycle expectation is visibly distinct from healthy active work on tenant-local progress cards
- Tenant dashboard and attention surfaces clearly represent the difference between healthy activity and active work that needs attention
- The workspace operations list makes stale or problematic active runs quickly scanable
- No surface shows a run as stale/problematic while another still presents it as normal active work
- The canonical workspace-level run viewer remains the most detailed lifecycle and diagnosis surface
- Existing lifecycle and freshness logic is reused rather than duplicated into local UI-only state rules
- No retry, cancel, or force-fail intervention actions are introduced by this candidate
- Fresh active runs do not regress into false escalation
- Tenant and workspace scoping remain correct; no cross-tenant leakage appears in cards or monitoring views
- Regression coverage includes fresh and stale active runs across tenant and workspace surfaces
- **Suggested test matrix**:
- queued run within expected lifecycle
- queued run well past expected lifecycle
- running run within expected lifecycle
- running run well past expected lifecycle
- run becomes terminal while an operator navigates between tenant and run-detail surfaces
- stale state on detail surface remains semantically stale on tenant and monitoring surfaces
- fresh active runs do not escalate falsely
- tenant-scoped surfaces never show another tenant's runs
- operations list clearly surfaces problematic active runs for fast scan
- **Dependencies**: existing operation-run lifecycle policy and stale detection foundations, canonical run viewer work, tenant-local active-run surfaces, operations monitoring list surfaces
- **Related specs / candidates**: Spec 114 (system console / operations monitoring foundations), Spec 144 (canonical operation viewer context decoupling), Spec 149 (queued execution reauthorization and lifecycle continuity), Operator Explanation Layer for Degraded / Partial / Suppressed Results (adjacent but broader interpretation layer), Provider-Backed Action Preflight and Dispatch Gate Unification (neighboring operational hardening lane)
- **Strategic sequencing**: Best treated before any broader operator intervention-actions spec. First make stale or hanging work visibly truthful across all existing surfaces; only then add retry / force-fail / reconcile-now UX on top of a coherent active-state language.
- **Priority**: high
### Baseline Snapshot Fidelity Semantics ### Baseline Snapshot Fidelity Semantics
- **Type**: hardening - **Type**: hardening
@ -269,18 +388,6 @@ ### Exception / Risk-Acceptance Workflow for Findings
- **Dependencies**: Findings workflow (Spec 111) complete, audit log foundation (Spec 134) - **Dependencies**: Findings workflow (Spec 111) complete, audit log foundation (Spec 134)
- **Priority**: high - **Priority**: high
### Evidence Domain Foundation
- **Type**: feature
- **Source**: HANDOVER gap, R2 theme completion
- **Vehicle note**: Promoted into existing Spec 153. Do not create a second evidence-domain candidate for semantic cleanup; extend Spec 153 when evidence completeness / freshness semantics need to be corrected.
- **Problem**: Review pack export (Spec 109) and permission posture reports (104/105) exist as separate output artifacts. There is no first-class evidence domain model that curates, bundles, and tracks these artifacts as a coherent compliance deliverable for external audit submission.
- **Why it matters**: Enterprise customers need a single, versioned, auditor-ready package — not a collection of separate exports assembled manually. The gap is not export packaging (Spec 109 handles that); it is the absence of an evidence domain layer that owns curation, completeness tracking, and audit-trail linkage.
- **Proposed direction**: Evidence domain model with curated artifact references (review packs, posture reports, findings summaries, baseline governance snapshots). Completeness metadata. Immutable snapshots with generation timestamp and actor. Not a re-implementation of export — a higher-order assembly layer.
- **Explicit non-goals**: Not a presentation or reporting layer — this candidate owns data curation, completeness tracking, artifact storage, and immutable snapshots. Executive summaries, framework-oriented readiness views, management-ready outputs, and stakeholder-facing packaging belong to the Compliance Readiness & Executive Review Packs candidate, which consumes this foundation. Not a replacement for Spec 109's export packaging. Not a generic BI or data warehouse initiative.
- **Boundary with Compliance Readiness**: Evidence Domain Foundation = lower-level data assembly (what artifacts exist, are they complete, are they immutable). Compliance Readiness = upper-level presentation (how to arrange evidence into framework-oriented, stakeholder-facing deliverables). This candidate is a prerequisite; Compliance Readiness is a downstream consumer.
- **Dependencies**: Review pack export (109), permission posture (104/105)
- **Priority**: high
### Compliance Readiness & Executive Review Packs ### Compliance Readiness & Executive Review Packs
- **Type**: feature - **Type**: feature
- **Source**: roadmap-to-spec coverage audit 2026-03-18, R2 theme completion, product positioning for German midmarket / MSP governance - **Source**: roadmap-to-spec coverage audit 2026-03-18, R2 theme completion, product positioning for German midmarket / MSP governance

View File

@ -0,0 +1,170 @@
(() => {
if (typeof window === 'undefined') {
return;
}
if (typeof window.opsUxProgressWidgetPoller === 'function') {
return;
}
window.opsUxProgressWidgetPoller = function opsUxProgressWidgetPoller() {
return {
timer: null,
activeSinceMs: null,
fastUntilMs: null,
teardownObserver: null,
init() {
this.onVisibilityChange = this.onVisibilityChange.bind(this);
window.addEventListener('visibilitychange', this.onVisibilityChange);
this.onNavigated = this.onNavigated.bind(this);
window.addEventListener('livewire:navigated', this.onNavigated);
this.teardownObserver = new MutationObserver(() => {
if (!this.$el || this.$el.isConnected !== true) {
this.destroy();
}
});
this.teardownObserver.observe(document.body, { childList: true, subtree: true });
this.schedule(0);
},
destroy() {
this.stop();
window.removeEventListener('visibilitychange', this.onVisibilityChange);
window.removeEventListener('livewire:navigated', this.onNavigated);
if (this.teardownObserver) {
this.teardownObserver.disconnect();
this.teardownObserver = null;
}
},
stop() {
if (this.timer) {
clearTimeout(this.timer);
this.timer = null;
}
},
isModalOpen() {
return document.querySelector('[role="dialog"][aria-modal="true"]') !== null;
},
isPaused() {
if (document.hidden === true) {
return true;
}
if (this.isModalOpen()) {
return true;
}
if (!this.$el || this.$el.isConnected !== true) {
return true;
}
return false;
},
onVisibilityChange() {
if (!this.isPaused()) {
this.schedule(0);
}
},
onNavigated() {
if (!this.isPaused()) {
this.schedule(0);
}
},
activeAgeSeconds() {
if (this.activeSinceMs === null) {
return 0;
}
return Math.floor((Date.now() - this.activeSinceMs) / 1000);
},
nextIntervalMs() {
if (this.$wire?.disabled === true) {
return null;
}
if (this.$wire?.hasActiveRuns !== true) {
this.activeSinceMs = null;
return 30_000;
}
if (this.activeSinceMs === null) {
this.activeSinceMs = Date.now();
}
const now = Date.now();
if (this.fastUntilMs && now < this.fastUntilMs) {
return 1_000;
}
const age = this.activeAgeSeconds();
if (age < 10) {
return 1_000;
}
if (age < 60) {
return 5_000;
}
return 10_000;
},
async tick() {
if (this.isPaused()) {
this.schedule(2_000);
return;
}
try {
await this.$wire.refreshRuns();
} catch (error) {
const isCancellation = Boolean(
error &&
typeof error === 'object' &&
error.status === null &&
error.body === null &&
error.json === null &&
error.errors === null,
);
if (!isCancellation) {
console.warn('Ops UX widget refreshRuns failed', error);
}
}
const next = this.nextIntervalMs();
if (next === null) {
this.stop();
return;
}
this.schedule(next);
},
schedule(delayMs) {
this.stop();
const delay = Math.max(0, Number(delayMs ?? 0));
this.timer = setTimeout(() => {
this.tick().catch(() => {});
}, delay);
},
};
};
})();

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