Compare commits

..

1 Commits

Author SHA1 Message Date
Ahmed Darrazi
5f11b28e04 feat: normalize operator outcome taxonomy 2026-03-22 11:24:10 +01:00
292 changed files with 1011 additions and 22438 deletions

View File

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

View File

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

View File

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

View File

@ -13,7 +13,6 @@
use App\Services\Baselines\BaselineCompareService;
use App\Support\Auth\Capabilities;
use App\Support\Baselines\BaselineCaptureMode;
use App\Support\Baselines\BaselineCompareEvidenceGapDetails;
use App\Support\Baselines\BaselineCompareStats;
use App\Support\OperationRunLinks;
use App\Support\OpsUx\OperationUxPresenter;
@ -87,21 +86,9 @@ class BaselineCompareLanding extends Page
/** @var array<string, int>|null */
public ?array $evidenceGapsTopReasons = null;
/** @var array<string, mixed>|null */
public ?array $evidenceGapSummary = null;
/** @var list<array<string, mixed>>|null */
public ?array $evidenceGapBuckets = null;
/** @var array<string, mixed>|null */
public ?array $baselineCompareDiagnostics = null;
/** @var array<string, int>|null */
public ?array $rbacRoleDefinitionSummary = null;
/** @var array<string, mixed>|null */
public ?array $operatorExplanation = null;
public static function canAccess(): bool
{
$user = auth()->user();
@ -152,17 +139,7 @@ public function refreshStats(): void
$this->evidenceGapsCount = $stats->evidenceGapsCount;
$this->evidenceGapsTopReasons = $stats->evidenceGapsTopReasons !== [] ? $stats->evidenceGapsTopReasons : null;
$this->evidenceGapSummary = is_array($stats->evidenceGapDetails['summary'] ?? null)
? $stats->evidenceGapDetails['summary']
: null;
$this->evidenceGapBuckets = is_array($stats->evidenceGapDetails['buckets'] ?? null) && $stats->evidenceGapDetails['buckets'] !== []
? $stats->evidenceGapDetails['buckets']
: null;
$this->baselineCompareDiagnostics = $stats->baselineCompareDiagnostics !== []
? $stats->baselineCompareDiagnostics
: null;
$this->rbacRoleDefinitionSummary = $stats->rbacRoleDefinitionSummary !== [] ? $stats->rbacRoleDefinitionSummary : null;
$this->operatorExplanation = $stats->operatorExplanation()->toArray();
}
/**
@ -175,32 +152,26 @@ public function refreshStats(): void
*/
protected function getViewData(): array
{
$evidenceGapSummary = is_array($this->evidenceGapSummary) ? $this->evidenceGapSummary : [];
$hasCoverageWarnings = in_array($this->coverageStatus, ['warning', 'unproven'], true);
$evidenceGapsCountValue = is_numeric($evidenceGapSummary['count'] ?? null)
? (int) $evidenceGapSummary['count']
: (int) ($this->evidenceGapsCount ?? 0);
$evidenceGapsCountValue = (int) ($this->evidenceGapsCount ?? 0);
$hasEvidenceGaps = $evidenceGapsCountValue > 0;
$hasWarnings = $hasCoverageWarnings || $hasEvidenceGaps;
$hasRbacRoleDefinitionSummary = is_array($this->rbacRoleDefinitionSummary)
&& array_sum($this->rbacRoleDefinitionSummary) > 0;
$evidenceGapDetailState = is_string($evidenceGapSummary['detail_state'] ?? null)
? (string) $evidenceGapSummary['detail_state']
: 'no_gaps';
$hasEvidenceGapDetailSection = $evidenceGapDetailState !== 'no_gaps';
$hasEvidenceGapDiagnostics = is_array($this->baselineCompareDiagnostics) && $this->baselineCompareDiagnostics !== [];
$evidenceGapsSummary = null;
$evidenceGapsTooltip = null;
if ($hasEvidenceGaps) {
$parts = array_map(
static fn (array $reason): string => $reason['reason_label'].' ('.$reason['count'].')',
BaselineCompareEvidenceGapDetails::topReasons(
is_array($evidenceGapSummary['by_reason'] ?? null) ? $evidenceGapSummary['by_reason'] : [],
5,
),
);
if ($hasEvidenceGaps && is_array($this->evidenceGapsTopReasons) && $this->evidenceGapsTopReasons !== []) {
$parts = [];
foreach (array_slice($this->evidenceGapsTopReasons, 0, 5, true) as $reason => $count) {
if (! is_string($reason) || $reason === '' || ! is_numeric($count)) {
continue;
}
$parts[] = $reason.' ('.((int) $count).')';
}
if ($parts !== []) {
$evidenceGapsSummary = implode(', ', $parts);
@ -236,9 +207,6 @@ protected function getViewData(): array
'hasEvidenceGaps' => $hasEvidenceGaps,
'hasWarnings' => $hasWarnings,
'hasRbacRoleDefinitionSummary' => $hasRbacRoleDefinitionSummary,
'evidenceGapDetailState' => $evidenceGapDetailState,
'hasEvidenceGapDetailSection' => $hasEvidenceGapDetailSection,
'hasEvidenceGapDiagnostics' => $hasEvidenceGapDiagnostics,
'evidenceGapsSummary' => $evidenceGapsSummary,
'evidenceGapsTooltip' => $evidenceGapsTooltip,
'findingsColorClass' => $findingsColorClass,
@ -339,22 +307,9 @@ private function compareNowAction(): Action
$result = $service->startCompare($tenant, $user);
if (! ($result['ok'] ?? false)) {
$reasonCode = is_string($result['reason_code'] ?? null) ? (string) $result['reason_code'] : 'unknown';
$message = match ($reasonCode) {
\App\Support\Baselines\BaselineReasonCodes::COMPARE_ROLLOUT_DISABLED => 'Full-content baseline compare is currently disabled for controlled rollout.',
\App\Support\Baselines\BaselineReasonCodes::COMPARE_PROFILE_NOT_ACTIVE => 'The assigned baseline profile is not active.',
\App\Support\Baselines\BaselineReasonCodes::COMPARE_NO_ACTIVE_SNAPSHOT,
\App\Support\Baselines\BaselineReasonCodes::COMPARE_NO_CONSUMABLE_SNAPSHOT => 'No complete baseline snapshot is currently available for compare.',
\App\Support\Baselines\BaselineReasonCodes::COMPARE_SNAPSHOT_BUILDING => 'The latest baseline capture is still building. Compare will be available after it completes.',
\App\Support\Baselines\BaselineReasonCodes::COMPARE_SNAPSHOT_INCOMPLETE => 'The latest baseline capture is incomplete. Capture a new baseline before comparing.',
\App\Support\Baselines\BaselineReasonCodes::COMPARE_SNAPSHOT_SUPERSEDED => 'A newer complete baseline snapshot is current. Compare uses the latest complete baseline only.',
default => 'Reason: '.$reasonCode,
};
Notification::make()
->title('Cannot start comparison')
->body($message)
->body('Reason: '.($result['reason_code'] ?? 'unknown'))
->danger()
->send();

View File

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

View File

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

View File

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

View File

@ -19,13 +19,10 @@
use App\Support\OpsUx\OperationUxPresenter;
use App\Support\OpsUx\OpsUxBrowserEvents;
use App\Support\OpsUx\RunDetailPolling;
use App\Support\ReasonTranslation\ReasonPresenter;
use App\Support\RedactionIntegrity;
use App\Support\Tenants\ReferencedTenantLifecyclePresentation;
use App\Support\Tenants\TenantInteractionLane;
use App\Support\Tenants\TenantOperabilityQuestion;
use App\Support\Ui\GovernanceArtifactTruth\ArtifactTruthPresenter;
use App\Support\Ui\OperatorExplanation\OperatorExplanationPattern;
use Filament\Actions\Action;
use Filament\Actions\ActionGroup;
use Filament\Notifications\Notification;
@ -172,60 +169,24 @@ public function blockedExecutionBanner(): ?array
return null;
}
$operatorExplanation = $this->governanceOperatorExplanation();
$reasonEnvelope = app(ReasonPresenter::class)->forOperationRun($this->run, 'run_detail');
$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::surfaceGuidance($this->run) ?? 'Review the blocked prerequisite before retrying.',
]);
$context = is_array($this->run->context) ? $this->run->context : [];
$reasonCode = data_get($context, 'reason_code');
if (! is_string($reasonCode) || trim($reasonCode) === '') {
$reasonCode = data_get($context, 'execution_legitimacy.reason_code');
}
$reasonCode = is_string($reasonCode) && trim($reasonCode) !== '' ? trim($reasonCode) : 'unknown_error';
$message = OperationUxPresenter::surfaceFailureDetail($this->run) ?? 'The queued run was refused before side effects could begin.';
$guidance = OperationUxPresenter::surfaceGuidance($this->run) ?? 'Review the blocked prerequisite before retrying.';
return [
'tone' => 'amber',
'title' => 'Blocked by prerequisite',
'body' => implode(' ', $lines),
'body' => sprintf('%s Reason code: %s. %s', $message, $reasonCode, $guidance),
];
}
/**
* @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
*/
@ -460,13 +421,4 @@ private function relatedLinksTenant(): ?Tenant
lane: TenantInteractionLane::StandardActiveOperating,
)->allowed ? $tenant : null;
}
private function governanceOperatorExplanation(): ?OperatorExplanationPattern
{
if (! isset($this->run) || ! $this->run->supportsOperatorExplanation()) {
return null;
}
return app(ArtifactTruthPresenter::class)->forOperationRun($this->run)?->operatorExplanation;
}
}

View File

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

View File

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

View File

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

View File

@ -183,7 +183,7 @@ private function compareNowAction(): Action
$modalDescription = $captureMode === BaselineCaptureMode::FullContent
? 'Select the target tenant. This will refresh content evidence on demand (redacted) before comparing.'
: 'Select the target tenant to compare its current inventory against the effective current baseline snapshot.';
: 'Select the target tenant to compare its current inventory against the active baseline snapshot.';
return Action::make('compareNow')
->label($label)
@ -198,7 +198,7 @@ private function compareNowAction(): Action
->required()
->searchable(),
])
->disabled(fn (): bool => $this->getEligibleCompareTenantOptions() === [] || ! $this->profileHasConsumableSnapshot())
->disabled(fn (): bool => $this->getEligibleCompareTenantOptions() === [])
->action(function (array $data): void {
$user = auth()->user();
@ -256,11 +256,7 @@ private function compareNowAction(): Action
$message = match ($reasonCode) {
BaselineReasonCodes::COMPARE_ROLLOUT_DISABLED => 'Full-content baseline compare is currently disabled for controlled rollout.',
BaselineReasonCodes::COMPARE_PROFILE_NOT_ACTIVE => 'This baseline profile is not active.',
BaselineReasonCodes::COMPARE_NO_ACTIVE_SNAPSHOT,
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.',
BaselineReasonCodes::COMPARE_NO_ACTIVE_SNAPSHOT => 'This baseline profile has no active snapshot.',
default => 'Reason: '.str_replace('.', ' ', $reasonCode),
};
@ -399,12 +395,4 @@ private function hasManageCapability(): bool
return $resolver->isMember($user, $workspace)
&& $resolver->can($user, $workspace, Capabilities::WORKSPACE_BASELINES_MANAGE);
}
private function profileHasConsumableSnapshot(): bool
{
/** @var BaselineProfile $profile */
$profile = $this->getRecord();
return $profile->resolveCurrentConsumableSnapshot() !== null;
}
}

View File

@ -9,12 +9,10 @@
use App\Models\BaselineSnapshot;
use App\Models\User;
use App\Models\Workspace;
use App\Services\Baselines\BaselineSnapshotTruthResolver;
use App\Services\Baselines\SnapshotRendering\FidelityState;
use App\Support\Auth\Capabilities;
use App\Support\Badges\BadgeCatalog;
use App\Support\Badges\BadgeDomain;
use App\Support\Baselines\BaselineSnapshotLifecycleState;
use App\Support\Filament\FilterPresets;
use App\Support\Navigation\CrossResourceNavigationMatrix;
use App\Support\Navigation\RelatedContextEntry;
@ -23,8 +21,6 @@
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceInspectAffordance;
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile;
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceSlot;
use App\Support\Ui\GovernanceArtifactTruth\ArtifactTruthEnvelope;
use App\Support\Ui\GovernanceArtifactTruth\ArtifactTruthPresenter;
use App\Support\Workspaces\WorkspaceContext;
use BackedEnum;
use Filament\Actions\Action;
@ -172,39 +168,17 @@ public static function table(Table $table): Table
->label('Captured')
->since()
->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')
->label('Fidelity')
->getStateUsing(static fn (BaselineSnapshot $record): string => self::fidelitySummary($record))
->wrap(),
TextColumn::make('artifact_next_step')
->label('Next step')
->getStateUsing(static fn (BaselineSnapshot $record): string => self::truthEnvelope($record)->operatorExplanation?->nextActionText ?? self::truthEnvelope($record)->nextStepText())
->wrap(),
TextColumn::make('snapshot_state')
->label('State')
->badge()
->getStateUsing(static fn (BaselineSnapshot $record): string => self::stateLabel($record))
->color(static fn (BaselineSnapshot $record): string => self::gapSpec($record)->color)
->icon(static fn (BaselineSnapshot $record): ?string => self::gapSpec($record)->icon)
->iconColor(static fn (BaselineSnapshot $record): ?string => self::gapSpec($record)->iconColor),
])
->recordUrl(static fn (BaselineSnapshot $record): ?string => static::canView($record)
? static::getUrl('view', ['record' => $record])
@ -214,10 +188,10 @@ public static function table(Table $table): Table
->label('Baseline')
->options(static::baselineProfileOptions())
->searchable(),
SelectFilter::make('lifecycle_state')
->label('Lifecycle')
->options(static::lifecycleOptions())
->query(fn (Builder $query, array $data): Builder => static::applyLifecycleFilter($query, $data['value'] ?? null)),
SelectFilter::make('snapshot_state')
->label('State')
->options(static::snapshotStateOptions())
->query(fn (Builder $query, array $data): Builder => static::applySnapshotStateFilter($query, $data['value'] ?? null)),
FilterPresets::dateRange('captured_at', 'Captured', 'captured_at'),
])
->actions([
@ -278,9 +252,9 @@ private static function baselineProfileOptions(): array
/**
* @return array<string, string>
*/
private static function lifecycleOptions(): array
private static function snapshotStateOptions(): array
{
return BadgeCatalog::options(BadgeDomain::BaselineSnapshotLifecycle, BaselineSnapshotLifecycleState::values());
return BadgeCatalog::options(BadgeDomain::BaselineSnapshotGapStatus, ['clear', 'gaps_present']);
}
public static function resolveWorkspace(): ?Workspace
@ -354,18 +328,24 @@ private static function hasGaps(BaselineSnapshot $snapshot): bool
return self::gapsCount($snapshot) > 0;
}
private static function lifecycleSpec(BaselineSnapshot $snapshot): \App\Support\Badges\BadgeSpec
private static function stateLabel(BaselineSnapshot $snapshot): string
{
return BadgeCatalog::spec(BadgeDomain::BaselineSnapshotLifecycle, $snapshot->lifecycleState()->value);
return self::gapSpec($snapshot)->label;
}
private static function applyLifecycleFilter(Builder $query, mixed $value): Builder
private static function applySnapshotStateFilter(Builder $query, mixed $value): Builder
{
if (! is_string($value) || trim($value) === '') {
return $query;
}
return $query->where('lifecycle_state', trim($value));
$gapCountExpression = self::gapCountExpression($query);
return match ($value) {
'clear' => $query->whereRaw("{$gapCountExpression} = 0"),
'gaps_present' => $query->whereRaw("{$gapCountExpression} > 0"),
default => $query,
};
}
private static function gapCountExpression(Builder $query): string
@ -384,56 +364,4 @@ private static function gapSpec(BaselineSnapshot $snapshot): \App\Support\Badges
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,8 +35,6 @@ public function mount(int|string $record): void
$snapshot = $this->getRecord();
if ($snapshot instanceof BaselineSnapshot) {
$snapshot->loadMissing(['baselineProfile', 'items']);
$relatedContext = app(RelatedNavigationResolver::class)
->detailEntries(CrossResourceNavigationMatrix::SOURCE_BASELINE_SNAPSHOT, $snapshot);

View File

@ -28,8 +28,6 @@
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceInspectAffordance;
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile;
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceSlot;
use App\Support\Ui\GovernanceArtifactTruth\ArtifactTruthEnvelope;
use App\Support\Ui\GovernanceArtifactTruth\ArtifactTruthPresenter;
use BackedEnum;
use Filament\Actions;
use Filament\Facades\Filament;
@ -135,15 +133,6 @@ public static function form(Schema $schema): Schema
public static function infolist(Schema $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')
->schema([
TextEntry::make('status')
@ -225,15 +214,6 @@ public static function table(Table $table): Table
->icon(BadgeRenderer::icon(BadgeDomain::EvidenceSnapshotStatus))
->iconColor(BadgeRenderer::iconColor(BadgeDomain::EvidenceSnapshotStatus))
->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')
->label('Completeness')
->badge()
@ -245,10 +225,6 @@ public static function table(Table $table): Table
Tables\Columns\TextColumn::make('generated_at')->dateTime()->sortable()->placeholder('—'),
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('artifact_next_step')
->label('Next step')
->getStateUsing(fn (EvidenceSnapshot $record): string => static::truthEnvelope($record)->nextStepText())
->wrap(),
])
->filters([
Tables\Filters\SelectFilter::make('status')
@ -612,11 +588,6 @@ private static function badgeLabel(BadgeDomain $domain, ?string $state): ?string
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
{
return match (true) {

View File

@ -11,7 +11,6 @@
use App\Support\Badges\BadgeCatalog;
use App\Support\Badges\BadgeDomain;
use App\Support\Badges\BadgeRenderer;
use App\Support\Baselines\BaselineCompareEvidenceGapDetails;
use App\Support\Baselines\BaselineCompareReasonCode;
use App\Support\Filament\FilterOptionCatalog;
use App\Support\Filament\FilterPresets;
@ -26,14 +25,12 @@
use App\Support\OpsUx\OperationUxPresenter;
use App\Support\OpsUx\RunDurationInsights;
use App\Support\OpsUx\SummaryCountsNormalizer;
use App\Support\ReasonTranslation\ReasonPresenter;
use App\Support\Tenants\ReferencedTenantLifecyclePresentation;
use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration;
use App\Support\Ui\ActionSurface\ActionSurfaceDefaults;
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceInspectAffordance;
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile;
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceSlot;
use App\Support\Ui\GovernanceArtifactTruth\ArtifactTruthPresenter;
use App\Support\Workspaces\WorkspaceContext;
use BackedEnum;
use Filament\Actions;
@ -129,11 +126,10 @@ public static function table(Table $table): Table
->columns([
Tables\Columns\TextColumn::make('status')
->badge()
->formatStateUsing(fn (mixed $state, OperationRun $record): string => BadgeRenderer::spec(BadgeDomain::OperationRunStatus, static::statusBadgeState($record))->label)
->color(fn (mixed $state, OperationRun $record): string => BadgeRenderer::spec(BadgeDomain::OperationRunStatus, static::statusBadgeState($record))->color)
->icon(fn (mixed $state, OperationRun $record): ?string => BadgeRenderer::spec(BadgeDomain::OperationRunStatus, static::statusBadgeState($record))->icon)
->iconColor(fn (mixed $state, OperationRun $record): ?string => BadgeRenderer::spec(BadgeDomain::OperationRunStatus, static::statusBadgeState($record))->iconColor)
->description(fn (OperationRun $record): ?string => OperationUxPresenter::lifecycleAttentionSummary($record)),
->formatStateUsing(BadgeRenderer::label(BadgeDomain::OperationRunStatus))
->color(BadgeRenderer::color(BadgeDomain::OperationRunStatus))
->icon(BadgeRenderer::icon(BadgeDomain::OperationRunStatus))
->iconColor(BadgeRenderer::iconColor(BadgeDomain::OperationRunStatus)),
Tables\Columns\TextColumn::make('type')
->label('Operation')
->formatStateUsing(fn (?string $state): string => OperationCatalog::label((string) $state))
@ -156,10 +152,10 @@ public static function table(Table $table): Table
}),
Tables\Columns\TextColumn::make('outcome')
->badge()
->formatStateUsing(fn (mixed $state, OperationRun $record): string => BadgeRenderer::spec(BadgeDomain::OperationRunOutcome, static::outcomeBadgeState($record))->label)
->color(fn (mixed $state, OperationRun $record): string => BadgeRenderer::spec(BadgeDomain::OperationRunOutcome, static::outcomeBadgeState($record))->color)
->icon(fn (mixed $state, OperationRun $record): ?string => BadgeRenderer::spec(BadgeDomain::OperationRunOutcome, static::outcomeBadgeState($record))->icon)
->iconColor(fn (mixed $state, OperationRun $record): ?string => BadgeRenderer::spec(BadgeDomain::OperationRunOutcome, static::outcomeBadgeState($record))->iconColor)
->formatStateUsing(BadgeRenderer::label(BadgeDomain::OperationRunOutcome))
->color(BadgeRenderer::color(BadgeDomain::OperationRunOutcome))
->icon(BadgeRenderer::icon(BadgeDomain::OperationRunOutcome))
->iconColor(BadgeRenderer::iconColor(BadgeDomain::OperationRunOutcome))
->description(fn (OperationRun $record): ?string => OperationUxPresenter::surfaceGuidance($record)),
])
->filters([
@ -255,25 +251,13 @@ private static function enterpriseDetailPage(OperationRun $record): \App\Support
{
$factory = new \App\Support\Ui\EnterpriseDetail\EnterpriseDetailSectionFactory;
$statusSpec = BadgeRenderer::spec(BadgeDomain::OperationRunStatus, static::statusBadgeState($record));
$outcomeSpec = BadgeRenderer::spec(BadgeDomain::OperationRunOutcome, static::outcomeBadgeState($record));
$statusSpec = BadgeRenderer::spec(BadgeDomain::OperationRunStatus, $record->status);
$outcomeSpec = BadgeRenderer::spec(BadgeDomain::OperationRunOutcome, $record->outcome);
$targetScope = static::targetScopeDisplay($record);
$summaryLine = \App\Support\OpsUx\SummaryCountsNormalizer::renderSummaryLine(is_array($record->summary_counts) ? $record->summary_counts : []);
$referencedTenantLifecycle = $record->tenant instanceof Tenant
? ReferencedTenantLifecyclePresentation::forOperationRun($record->tenant)
: null;
$artifactTruth = $record->supportsOperatorExplanation()
? app(ArtifactTruthPresenter::class)->forOperationRun($record)
: null;
$operatorExplanation = $artifactTruth?->operatorExplanation;
$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')
->header(new \App\Support\Ui\EnterpriseDetail\SummaryHeaderData(
@ -303,15 +287,6 @@ private static function enterpriseDetailPage(OperationRun $record): \App\Support
$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(
id: 'related_context',
kind: 'related_context',
@ -329,15 +304,6 @@ private static function enterpriseDetailPage(OperationRun $record): \App\Support
items: array_values(array_filter([
$factory->keyFact('Status', $statusSpec->label, badge: $factory->statusBadge($statusSpec->label, $statusSpec->color, $statusSpec->icon, $statusSpec->iconColor)),
$factory->keyFact('Outcome', $outcomeSpec->label, badge: $factory->statusBadge($outcomeSpec->label, $outcomeSpec->color, $outcomeSpec->icon, $outcomeSpec->iconColor)),
$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
? $factory->keyFact(
'Tenant lifecycle',
@ -356,26 +322,6 @@ private static function enterpriseDetailPage(OperationRun $record): \App\Support
$referencedTenantLifecycle?->contextNote !== null
? $factory->keyFact('Viewer context', $referencedTenantLifecycle->contextNote)
: null,
static::freshnessLabel($record) !== null
? $factory->keyFact('Freshness', (string) static::freshnessLabel($record))
: null,
static::reconciliationHeadline($record) !== null
? $factory->keyFact('Lifecycle truth', (string) static::reconciliationHeadline($record))
: null,
static::reconciledAtLabel($record) !== null
? $factory->keyFact('Reconciled at', (string) static::reconciledAtLabel($record))
: null,
static::reconciliationSourceLabel($record) !== null
? $factory->keyFact('Reconciled by', (string) static::reconciliationSourceLabel($record))
: null,
$operatorExplanation !== null
? $factory->keyFact('Artifact next step', $operatorExplanation->nextActionText)
: ($artifactTruth !== null
? $factory->keyFact('Artifact next step', $artifactTruth->nextStepText())
: null),
$operatorExplanation !== null && $operatorExplanation->coverageStatement !== null
? $factory->keyFact('Coverage', $operatorExplanation->coverageStatement)
: null,
OperationUxPresenter::surfaceGuidance($record) !== null
? $factory->keyFact('Next step', OperationUxPresenter::surfaceGuidance($record))
: null,
@ -442,25 +388,9 @@ 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') {
$baselineCompareFacts = static::baselineCompareFacts($record, $factory);
$baselineCompareEvidence = static::baselineCompareEvidencePayload($record);
$gapDetails = BaselineCompareEvidenceGapDetails::fromOperationRun($record);
$gapSummary = is_array($gapDetails['summary'] ?? null) ? $gapDetails['summary'] : [];
$gapBuckets = is_array($gapDetails['buckets'] ?? null) ? $gapDetails['buckets'] : [];
if ($baselineCompareFacts !== []) {
$builder->addSection(
@ -473,25 +403,6 @@ private static function enterpriseDetailPage(OperationRun $record): \App\Support
);
}
if (($gapSummary['detail_state'] ?? 'no_gaps') !== 'no_gaps') {
$builder->addSection(
$factory->viewSection(
id: 'baseline_compare_gap_details',
kind: 'operational_context',
title: 'Evidence gap details',
description: 'Policies affected by evidence gaps, grouped by reason and searchable by reason, policy type, or subject key.',
view: 'filament.infolists.entries.evidence-gap-subjects',
viewData: [
'summary' => $gapSummary,
'buckets' => $gapBuckets,
'searchId' => 'baseline-compare-gap-search-'.$record->getKey(),
],
collapsible: true,
collapsed: false,
),
);
}
if ($baselineCompareEvidence !== []) {
$builder->addSection(
$factory->viewSection(
@ -558,13 +469,8 @@ private static function blockedExecutionReasonCode(OperationRun $record): ?strin
return null;
}
$reasonEnvelope = app(ReasonPresenter::class)->forOperationRun($record, 'run_detail');
if ($reasonEnvelope !== null) {
return $reasonEnvelope->operatorLabel;
}
$context = is_array($record->context) ? $record->context : [];
$reasonCode = data_get($context, 'execution_legitimacy.reason_code')
?? data_get($context, 'reason_code')
?? data_get($record->failure_summary, '0.reason_code');
@ -578,12 +484,6 @@ private static function blockedExecutionDetail(OperationRun $record): ?string
return null;
}
$reasonEnvelope = app(ReasonPresenter::class)->forOperationRun($record, 'run_detail');
if ($reasonEnvelope !== null) {
return $reasonEnvelope->shortExplanation;
}
$message = data_get($record->failure_summary, '0.message');
return is_string($message) && trim($message) !== '' ? trim($message) : 'Execution was refused before work began.';
@ -616,8 +516,6 @@ private static function baselineCompareFacts(
\App\Support\Ui\EnterpriseDetail\EnterpriseDetailSectionFactory $factory,
): array {
$context = is_array($record->context) ? $record->context : [];
$gapDetails = BaselineCompareEvidenceGapDetails::fromOperationRun($record);
$gapSummary = is_array($gapDetails['summary'] ?? null) ? $gapDetails['summary'] : [];
$facts = [];
$fidelity = data_get($context, 'baseline_compare.fidelity');
@ -649,17 +547,6 @@ private static function baselineCompareFacts(
);
}
if ((int) ($gapSummary['count'] ?? 0) > 0) {
$facts[] = $factory->keyFact(
'Evidence gap detail',
match ($gapSummary['detail_state'] ?? 'no_gaps') {
'details_recorded' => 'Recorded subjects available',
'details_not_recorded' => 'Detailed rows were not recorded',
default => 'No evidence gaps recorded',
},
);
}
if ($uncoveredTypes !== []) {
sort($uncoveredTypes, SORT_STRING);
$facts[] = $factory->keyFact('Uncovered types', implode(', ', array_slice($uncoveredTypes, 0, 12)).(count($uncoveredTypes) > 12 ? '…' : ''));
@ -800,82 +687,6 @@ private static function contextPayload(OperationRun $record): array
return $context;
}
/**
* @return array{status:string,freshness_state:string}
*/
private static function statusBadgeState(OperationRun $record): array
{
return [
'status' => (string) $record->status,
'freshness_state' => $record->freshnessState()->value,
];
}
/**
* @return array{outcome:string,status:string,freshness_state:string}
*/
private static function outcomeBadgeState(OperationRun $record): array
{
return [
'outcome' => (string) $record->outcome,
'status' => (string) $record->status,
'freshness_state' => $record->freshnessState()->value,
];
}
private static function freshnessLabel(OperationRun $record): ?string
{
return match ($record->freshnessState()->value) {
'fresh_active' => 'Fresh activity',
'likely_stale' => 'Likely stale',
'reconciled_failed' => 'Automatically reconciled',
'terminal_normal' => 'Terminal truth confirmed',
default => null,
};
}
private static function reconciliationHeadline(OperationRun $record): ?string
{
if (! $record->isLifecycleReconciled()) {
return null;
}
return 'TenantPilot force-resolved this run after normal lifecycle truth was lost.';
}
private static function reconciledAtLabel(OperationRun $record): ?string
{
$reconciledAt = data_get($record->reconciliation(), 'reconciled_at');
return is_string($reconciledAt) && trim($reconciledAt) !== '' ? trim($reconciledAt) : null;
}
private static function reconciliationSourceLabel(OperationRun $record): ?string
{
$source = data_get($record->reconciliation(), 'source');
if (! is_string($source) || trim($source) === '') {
return null;
}
return match (trim($source)) {
'failed_callback' => 'Direct failed() bridge',
'scheduled_reconciler' => 'Scheduled reconciler',
'adapter_reconciler' => 'Adapter reconciler',
default => ucfirst(str_replace('_', ' ', trim($source))),
};
}
/**
* @return array<string, mixed>
*/
private static function reconciliationPayload(OperationRun $record): array
{
$reconciliation = $record->reconciliation();
return $reconciliation;
}
private static function formatDetailTimestamp(mixed $value): string
{
if (! $value instanceof \Illuminate\Support\Carbon) {

View File

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

View File

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

View File

@ -10,7 +10,6 @@
use App\Models\User;
use App\Services\ReviewPackService;
use App\Support\Auth\Capabilities;
use App\Support\Badges\BadgeCatalog;
use App\Support\Badges\BadgeDomain;
use App\Support\Badges\BadgeRenderer;
use App\Support\OpsUx\OperationUxPresenter;
@ -20,14 +19,11 @@
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceInspectAffordance;
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile;
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceSlot;
use App\Support\Ui\GovernanceArtifactTruth\ArtifactTruthEnvelope;
use App\Support\Ui\GovernanceArtifactTruth\ArtifactTruthPresenter;
use BackedEnum;
use Filament\Actions;
use Filament\Facades\Filament;
use Filament\Forms\Components\Toggle;
use Filament\Infolists\Components\TextEntry;
use Filament\Infolists\Components\ViewEntry;
use Filament\Notifications\Notification;
use Filament\Resources\Resource;
use Filament\Schemas\Components\Section;
@ -115,15 +111,6 @@ public static function infolist(Schema $schema): Schema
{
return $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')
->schema([
TextEntry::make('status')
@ -251,15 +238,6 @@ public static function table(Table $table): Table
->icon(BadgeRenderer::icon(BadgeDomain::ReviewPackStatus))
->iconColor(BadgeRenderer::iconColor(BadgeDomain::ReviewPackStatus))
->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')
->label('Tenant')
->searchable(),
@ -279,29 +257,6 @@ public static function table(Table $table): Table
->label('Size')
->formatStateUsing(fn ($state): string => $state ? Number::fileSize((int) $state) : '—')
->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')
->label('Created')
->since()
@ -397,11 +352,6 @@ public static function getPages(): array
];
}
private static function truthEnvelope(ReviewPack $record): ArtifactTruthEnvelope
{
return app(ArtifactTruthPresenter::class)->forReviewPack($record);
}
/**
* @param array<string, mixed> $data
*/

View File

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

View File

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

View File

@ -28,8 +28,6 @@
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceInspectAffordance;
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile;
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceSlot;
use App\Support\Ui\GovernanceArtifactTruth\ArtifactTruthEnvelope;
use App\Support\Ui\GovernanceArtifactTruth\ArtifactTruthPresenter;
use BackedEnum;
use Filament\Actions;
use Filament\Facades\Filament;
@ -145,15 +143,6 @@ public static function form(Schema $schema): Schema
public static function infolist(Schema $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')
->schema([
TextEntry::make('status')
@ -250,15 +239,6 @@ public static function table(Table $table): Table
->icon(BadgeRenderer::icon(BadgeDomain::TenantReviewStatus))
->iconColor(BadgeRenderer::iconColor(BadgeDomain::TenantReviewStatus))
->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')
->label('Completeness')
->badge()
@ -271,32 +251,9 @@ public static function table(Table $table): Table
Tables\Columns\TextColumn::make('published_at')->dateTime()->placeholder('—')->sortable(),
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('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')
->label('Export')
->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')
->toggleable(isToggledHiddenByDefault: true)
->searchable(),
@ -563,7 +520,6 @@ private static function summaryPresentation(TenantReview $record): array
$summary = is_array($record->summary) ? $record->summary : [];
return [
'operator_explanation' => static::truthEnvelope($record)->operatorExplanation?->toArray(),
'highlights' => is_array($summary['highlights'] ?? null) ? $summary['highlights'] : [],
'next_actions' => is_array($summary['recommended_next_actions'] ?? null) ? $summary['recommended_next_actions'] : [],
'publish_blockers' => is_array($summary['publish_blockers'] ?? null) ? $summary['publish_blockers'] : [],
@ -605,9 +561,4 @@ private static function sectionPresentation(TenantReviewSection $section): array
'links' => [],
];
}
private static function truthEnvelope(TenantReview $record): ArtifactTruthEnvelope
{
return app(ArtifactTruthPresenter::class)->forTenantReview($record);
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -4,7 +4,6 @@
namespace App\Jobs;
use App\Jobs\Concerns\BridgesFailedOperationRun;
use App\Jobs\Middleware\TrackOperationRun;
use App\Models\BaselineProfile;
use App\Models\BaselineSnapshot;
@ -19,7 +18,6 @@
use App\Services\Baselines\BaselineAutoCloseService;
use App\Services\Baselines\BaselineContentCapturePhase;
use App\Services\Baselines\BaselineSnapshotIdentity;
use App\Services\Baselines\BaselineSnapshotTruthResolver;
use App\Services\Baselines\CurrentStateHashResolver;
use App\Services\Baselines\Evidence\BaselinePolicyVersionResolver;
use App\Services\Baselines\Evidence\ContentEvidenceProvider;
@ -39,7 +37,6 @@
use App\Support\Baselines\BaselineCaptureMode;
use App\Support\Baselines\BaselineCompareReasonCode;
use App\Support\Baselines\BaselineFullContentRolloutGate;
use App\Support\Baselines\BaselineReasonCodes;
use App\Support\Baselines\BaselineScope;
use App\Support\Baselines\BaselineSubjectKey;
use App\Support\Baselines\PolicyVersionCapturePurpose;
@ -47,7 +44,6 @@
use App\Support\OperationRunOutcome;
use App\Support\OperationRunStatus;
use App\Support\OperationRunType;
use App\Support\ReasonTranslation\ReasonPresenter;
use Carbon\CarbonImmutable;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
@ -58,15 +54,7 @@
class CompareBaselineToTenantJob implements ShouldQueue
{
use BridgesFailedOperationRun;
use Dispatchable;
use InteractsWithQueue;
use Queueable;
use SerializesModels;
public int $timeout = 300;
public bool $failOnTimeout = true;
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
/**
* @var array<int, string>
@ -96,7 +84,6 @@ public function handle(
?SettingsResolver $settingsResolver = null,
?BaselineAutoCloseService $baselineAutoCloseService = null,
?CurrentStateHashResolver $hashResolver = null,
?BaselineSnapshotTruthResolver $snapshotTruthResolver = null,
?MetaEvidenceProvider $metaEvidenceProvider = null,
?BaselineContentCapturePhase $contentCapturePhase = null,
?BaselineFullContentRolloutGate $rolloutGate = null,
@ -105,7 +92,6 @@ public function handle(
$settingsResolver ??= app(SettingsResolver::class);
$baselineAutoCloseService ??= app(BaselineAutoCloseService::class);
$hashResolver ??= app(CurrentStateHashResolver::class);
$snapshotTruthResolver ??= app(BaselineSnapshotTruthResolver::class);
$metaEvidenceProvider ??= app(MetaEvidenceProvider::class);
$contentCapturePhase ??= app(BaselineContentCapturePhase::class);
$rolloutGate ??= app(BaselineFullContentRolloutGate::class);
@ -292,52 +278,12 @@ public function handle(
->where('workspace_id', (int) $profile->workspace_id)
->where('baseline_profile_id', (int) $profile->getKey())
->whereKey($snapshotId)
->first();
->first(['id', 'captured_at']);
if (! $snapshot instanceof BaselineSnapshot) {
throw new RuntimeException("BaselineSnapshot #{$snapshotId} not found.");
}
$snapshotResolution = $snapshotTruthResolver->resolveCompareSnapshot($profile, $snapshot);
if (! ($snapshotResolution['ok'] ?? false)) {
$reasonCode = is_string($snapshotResolution['reason_code'] ?? null)
? (string) $snapshotResolution['reason_code']
: BaselineReasonCodes::COMPARE_NO_CONSUMABLE_SNAPSHOT;
$context = is_array($this->operationRun->context) ? $this->operationRun->context : [];
$context['baseline_compare'] = array_merge(
is_array($context['baseline_compare'] ?? null) ? $context['baseline_compare'] : [],
[
'reason_code' => $reasonCode,
'effective_snapshot_id' => $snapshotResolution['effective_snapshot']?->getKey(),
'latest_attempted_snapshot_id' => $snapshotResolution['latest_attempted_snapshot']?->getKey(),
],
);
$context['result'] = array_merge(
is_array($context['result'] ?? null) ? $context['result'] : [],
[
'snapshot_id' => (int) $snapshot->getKey(),
],
);
$context = $this->withCompareReasonTranslation($context, $reasonCode);
$this->operationRun->update(['context' => $context]);
$this->operationRun->refresh();
$operationRunService->finalizeBlockedRun(
run: $this->operationRun,
reasonCode: $reasonCode,
message: $this->snapshotBlockedMessage($reasonCode),
);
return;
}
/** @var BaselineSnapshot $snapshot */
$snapshot = $snapshotResolution['snapshot'];
$snapshotId = (int) $snapshot->getKey();
$since = $snapshot->captured_at instanceof \DateTimeInterface
? CarbonImmutable::instance($snapshot->captured_at)
: null;
@ -388,7 +334,6 @@ public function handle(
];
$phaseResult = [];
$phaseGaps = [];
$phaseGapSubjects = [];
$resumeToken = null;
if ($captureMode === BaselineCaptureMode::FullContent) {
@ -417,7 +362,6 @@ public function handle(
$phaseStats = is_array($phaseResult['stats'] ?? null) ? $phaseResult['stats'] : $phaseStats;
$phaseGaps = is_array($phaseResult['gaps'] ?? null) ? $phaseResult['gaps'] : [];
$phaseGapSubjects = is_array($phaseResult['gap_subjects'] ?? null) ? $phaseResult['gap_subjects'] : [];
$resumeToken = is_string($phaseResult['resume_token'] ?? null) ? $phaseResult['resume_token'] : null;
}
@ -497,12 +441,6 @@ public function handle(
$gapsByReason = $this->mergeGapCounts($baselineGaps, $currentGaps, $phaseGaps, $driftGaps);
$gapsCount = array_sum($gapsByReason);
$gapSubjects = $this->collectGapSubjects(
ambiguousKeys: $ambiguousKeys,
phaseGapSubjects: $phaseGapSubjects ?? [],
driftGapSubjects: $computeResult['evidence_gap_subjects'] ?? [],
);
$summaryCounts = [
'total' => count($driftResults),
'processed' => count($driftResults),
@ -580,7 +518,6 @@ public function handle(
'count' => $gapsCount,
'by_reason' => $gapsByReason,
...$gapsByReason,
'subjects' => $gapSubjects !== [] ? $gapSubjects : null,
],
'resume_token' => $resumeToken,
'coverage' => [
@ -608,10 +545,6 @@ public function handle(
'findings_resolved' => $resolvedCount,
'severity_breakdown' => $severityBreakdown,
];
$updatedContext = $this->withCompareReasonTranslation(
$updatedContext,
$reasonCode?->value,
);
$this->operationRun->update(['context' => $updatedContext]);
$this->auditCompleted(
@ -857,7 +790,6 @@ private function completeWithCoverageWarning(
'findings_resolved' => 0,
'severity_breakdown' => [],
];
$updatedContext = $this->withCompareReasonTranslation($updatedContext, $reasonCode->value);
$this->operationRun->update(['context' => $updatedContext]);
@ -964,34 +896,6 @@ 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".
*
@ -1100,17 +1004,6 @@ private function resolveLatestInventorySyncRun(Tenant $tenant): ?OperationRun
return $run instanceof OperationRun ? $run : null;
}
private function snapshotBlockedMessage(string $reasonCode): string
{
return match ($reasonCode) {
BaselineReasonCodes::COMPARE_SNAPSHOT_BUILDING => 'The selected baseline snapshot is still building and cannot be used for compare yet.',
BaselineReasonCodes::COMPARE_SNAPSHOT_INCOMPLETE => 'The selected baseline snapshot is incomplete and cannot be used for compare.',
BaselineReasonCodes::COMPARE_SNAPSHOT_SUPERSEDED => 'A newer complete baseline snapshot is now current, so this historical snapshot is blocked from compare.',
BaselineReasonCodes::COMPARE_INVALID_SNAPSHOT => 'The selected baseline snapshot is no longer available.',
default => 'No consumable baseline snapshot is currently available for compare.',
};
}
/**
* Compare baseline items vs current inventory and produce drift results.
*
@ -1143,7 +1036,6 @@ private function computeDrift(
): array {
$drift = [];
$evidenceGaps = [];
$evidenceGapSubjects = [];
$rbacRoleDefinitionSummary = $this->emptyRbacRoleDefinitionSummary();
$roleDefinitionNormalizer = app(IntuneRoleDefinitionNormalizer::class);
@ -1185,7 +1077,6 @@ private function computeDrift(
if (! is_array($currentItem)) {
if ($isRbacRoleDefinition && $baselinePolicyVersionId === null) {
$evidenceGaps['missing_role_definition_baseline_version_reference'] = ($evidenceGaps['missing_role_definition_baseline_version_reference'] ?? 0) + 1;
$evidenceGapSubjects['missing_role_definition_baseline_version_reference'][] = $key;
continue;
}
@ -1250,7 +1141,6 @@ private function computeDrift(
if (! $currentEvidence instanceof ResolvedEvidence) {
$evidenceGaps['missing_current'] = ($evidenceGaps['missing_current'] ?? 0) + 1;
$evidenceGapSubjects['missing_current'][] = $key;
continue;
}
@ -1267,14 +1157,12 @@ private function computeDrift(
if ($isRbacRoleDefinition) {
if ($baselinePolicyVersionId === null) {
$evidenceGaps['missing_role_definition_baseline_version_reference'] = ($evidenceGaps['missing_role_definition_baseline_version_reference'] ?? 0) + 1;
$evidenceGapSubjects['missing_role_definition_baseline_version_reference'][] = $key;
continue;
}
if ($currentPolicyVersionId === null) {
$evidenceGaps['missing_role_definition_current_version_reference'] = ($evidenceGaps['missing_role_definition_current_version_reference'] ?? 0) + 1;
$evidenceGapSubjects['missing_role_definition_current_version_reference'][] = $key;
continue;
}
@ -1288,7 +1176,6 @@ private function computeDrift(
if ($roleDefinitionDiff === null) {
$evidenceGaps['missing_role_definition_compare_surface'] = ($evidenceGaps['missing_role_definition_compare_surface'] ?? 0) + 1;
$evidenceGapSubjects['missing_role_definition_compare_surface'][] = $key;
continue;
}
@ -1369,7 +1256,6 @@ private function computeDrift(
if (! $currentEvidence instanceof ResolvedEvidence) {
$evidenceGaps['missing_current'] = ($evidenceGaps['missing_current'] ?? 0) + 1;
$evidenceGapSubjects['missing_current'][] = $key;
continue;
}
@ -1385,7 +1271,6 @@ private function computeDrift(
if ($isRbacRoleDefinition && $currentPolicyVersionId === null) {
$evidenceGaps['missing_role_definition_current_version_reference'] = ($evidenceGaps['missing_role_definition_current_version_reference'] ?? 0) + 1;
$evidenceGapSubjects['missing_role_definition_current_version_reference'][] = $key;
continue;
}
@ -1445,7 +1330,6 @@ private function computeDrift(
return [
'drift' => $drift,
'evidence_gaps' => $evidenceGaps,
'evidence_gap_subjects' => $evidenceGapSubjects,
'rbac_role_definitions' => $rbacRoleDefinitionSummary,
];
}
@ -1957,44 +1841,6 @@ private function mergeGapCounts(array ...$gaps): array
return $merged;
}
private const GAP_SUBJECTS_LIMIT = 50;
/**
* @param list<string> $ambiguousKeys
* @param array<string, list<string>> $phaseGapSubjects
* @param array<string, list<string>> $driftGapSubjects
* @return array<string, list<string>>
*/
private function collectGapSubjects(array $ambiguousKeys, array $phaseGapSubjects, array $driftGapSubjects): array
{
$subjects = [];
if ($ambiguousKeys !== []) {
$subjects['ambiguous_match'] = array_slice($ambiguousKeys, 0, self::GAP_SUBJECTS_LIMIT);
}
foreach ([$phaseGapSubjects, $driftGapSubjects] as $subjectMap) {
foreach ($subjectMap as $reason => $keys) {
if (! is_string($reason) || ! is_array($keys) || $keys === []) {
continue;
}
$subjects[$reason] = array_slice(
array_values(array_unique([
...($subjects[$reason] ?? []),
...array_values(array_filter($keys, 'is_string')),
])),
0,
self::GAP_SUBJECTS_LIMIT,
);
}
}
ksort($subjects);
return $subjects;
}
/**
* @param array<string, array{subject_external_id: string, policy_type: string, meta_jsonb: array<string, mixed>}> $currentItems
* @param array<string, ResolvedEvidence|null> $resolvedCurrentEvidence

View File

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

View File

@ -1,58 +0,0 @@
<?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,10 +20,6 @@ class EntraGroupSyncJob implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public int $timeout = 240;
public bool $failOnTimeout = true;
public ?OperationRun $operationRun = null;
public function __construct(

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,191 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Livewire;
use App\Support\Badges\TagBadgeDomain;
use App\Support\Badges\TagBadgeRenderer;
use App\Support\Baselines\BaselineCompareEvidenceGapDetails;
use App\Support\Filament\TablePaginationProfiles;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Filters\SelectFilter;
use Filament\Tables\Table;
use Filament\Tables\TableComponent;
use Illuminate\Contracts\View\View;
use Illuminate\Pagination\LengthAwarePaginator;
use Illuminate\Support\Collection;
use Illuminate\Support\Str;
class BaselineCompareEvidenceGapTable extends TableComponent
{
/**
* @var list<array{
* __id: string,
* reason_code: string,
* reason_label: string,
* policy_type: string,
* subject_key: string,
* search_text: string
* }>
*/
public array $gapRows = [];
public string $context = 'default';
/**
* @param list<array<string, mixed>> $buckets
*/
public function mount(array $buckets = [], string $context = 'default'): void
{
$this->gapRows = BaselineCompareEvidenceGapDetails::tableRows($buckets);
$this->context = $context;
}
public function table(Table $table): Table
{
return $table
->queryStringIdentifier('baselineCompareEvidenceGap'.Str::studly($this->context))
->defaultSort('reason_label')
->defaultPaginationPageOption(10)
->paginated(TablePaginationProfiles::picker())
->searchable()
->searchPlaceholder(__('baseline-compare.evidence_gap_search_placeholder'))
->records(function (
?string $sortColumn,
?string $sortDirection,
?string $search,
array $filters,
int $page,
int $recordsPerPage
): LengthAwarePaginator {
$rows = $this->filterRows(
rows: collect($this->gapRows),
search: $search,
filters: $filters,
);
$rows = $this->sortRows(
rows: $rows,
sortColumn: $sortColumn,
sortDirection: $sortDirection,
);
return $this->paginateRows(
rows: $rows,
page: $page,
recordsPerPage: $recordsPerPage,
);
})
->filters([
SelectFilter::make('reason_code')
->label(__('baseline-compare.evidence_gap_reason'))
->options(BaselineCompareEvidenceGapDetails::reasonFilterOptions($this->gapRows)),
SelectFilter::make('policy_type')
->label(__('baseline-compare.evidence_gap_policy_type'))
->options(BaselineCompareEvidenceGapDetails::policyTypeFilterOptions($this->gapRows)),
])
->striped()
->deferLoading(! app()->runningUnitTests())
->columns([
TextColumn::make('reason_label')
->label(__('baseline-compare.evidence_gap_reason'))
->searchable()
->sortable()
->wrap(),
TextColumn::make('policy_type')
->label(__('baseline-compare.evidence_gap_policy_type'))
->badge()
->formatStateUsing(TagBadgeRenderer::label(TagBadgeDomain::PolicyType))
->color(TagBadgeRenderer::color(TagBadgeDomain::PolicyType))
->icon(TagBadgeRenderer::icon(TagBadgeDomain::PolicyType))
->iconColor(TagBadgeRenderer::iconColor(TagBadgeDomain::PolicyType))
->searchable()
->sortable()
->wrap(),
TextColumn::make('subject_key')
->label(__('baseline-compare.evidence_gap_subject_key'))
->searchable()
->sortable()
->wrap()
->extraAttributes(['class' => 'font-mono text-xs']),
])
->actions([])
->bulkActions([])
->emptyStateHeading(__('baseline-compare.evidence_gap_table_empty_heading'))
->emptyStateDescription(__('baseline-compare.evidence_gap_table_empty_description'));
}
public function render(): View
{
return view('livewire.baseline-compare-evidence-gap-table');
}
/**
* @param Collection<int, array<string, mixed>> $rows
* @param array<string, mixed> $filters
* @return Collection<int, array<string, mixed>>
*/
private function filterRows(Collection $rows, ?string $search, array $filters): Collection
{
$normalizedSearch = Str::lower(trim((string) $search));
$reasonCode = $filters['reason_code']['value'] ?? null;
$policyType = $filters['policy_type']['value'] ?? null;
return $rows
->when(
$normalizedSearch !== '',
function (Collection $rows) use ($normalizedSearch): Collection {
return $rows->filter(function (array $row) use ($normalizedSearch): bool {
return str_contains(Str::lower((string) ($row['search_text'] ?? '')), $normalizedSearch);
});
}
)
->when(
filled($reasonCode),
fn (Collection $rows): Collection => $rows->where('reason_code', (string) $reasonCode)
)
->when(
filled($policyType),
fn (Collection $rows): Collection => $rows->where('policy_type', (string) $policyType)
)
->values();
}
/**
* @param Collection<int, array<string, mixed>> $rows
* @return Collection<int, array<string, mixed>>
*/
private function sortRows(Collection $rows, ?string $sortColumn, ?string $sortDirection): Collection
{
if (! filled($sortColumn)) {
return $rows;
}
$direction = Str::lower((string) ($sortDirection ?? 'asc')) === 'desc' ? 'desc' : 'asc';
return $rows->sortBy(
fn (array $row): string => (string) ($row[$sortColumn] ?? ''),
SORT_NATURAL | SORT_FLAG_CASE,
$direction === 'desc'
)->values();
}
/**
* @param Collection<int, array<string, mixed>> $rows
*/
private function paginateRows(Collection $rows, int $page, int $recordsPerPage): LengthAwarePaginator
{
$perPage = max(1, $recordsPerPage);
$currentPage = max(1, $page);
$total = $rows->count();
$items = $rows->forPage($currentPage, $perPage)->values();
return new LengthAwarePaginator(
$items,
$total,
$perPage,
$currentPage,
);
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -26,7 +26,6 @@ public function __construct(
* @return array{
* stats: array{requested: int, succeeded: int, skipped: int, failed: int, throttled: int},
* gaps: array<string, int>,
* gap_subjects: array<string, list<string>>,
* resume_token: ?string,
* captured_versions: array<string, array{
* policy_type: string,
@ -77,8 +76,6 @@ public function capture(
/** @var array<string, int> $gaps */
$gaps = [];
/** @var array<string, list<string>> $gapSubjects */
$gapSubjects = [];
$capturedVersions = [];
/**
@ -93,7 +90,6 @@ public function capture(
if ($policyType === '' || $externalId === '') {
$gaps['invalid_subject'] = ($gaps['invalid_subject'] ?? 0) + 1;
$gapSubjects['invalid_subject'][] = ($policyType !== '' ? $policyType : 'unknown').'|'.($externalId !== '' ? $externalId : 'unknown');
$stats['failed']++;
continue;
@ -103,7 +99,6 @@ public function capture(
if (isset($seen[$subjectKey])) {
$gaps['duplicate_subject'] = ($gaps['duplicate_subject'] ?? 0) + 1;
$gapSubjects['duplicate_subject'][] = $subjectKey;
$stats['skipped']++;
continue;
@ -119,7 +114,6 @@ public function capture(
if (! $policy instanceof Policy) {
$gaps['policy_not_found'] = ($gaps['policy_not_found'] ?? 0) + 1;
$gapSubjects['policy_not_found'][] = $subjectKey;
$stats['failed']++;
continue;
@ -185,11 +179,9 @@ public function capture(
if ($isThrottled) {
$gaps['throttled'] = ($gaps['throttled'] ?? 0) + 1;
$gapSubjects['throttled'][] = $subjectKey;
$stats['throttled']++;
} else {
$gaps['capture_failed'] = ($gaps['capture_failed'] ?? 0) + 1;
$gapSubjects['capture_failed'][] = $subjectKey;
$stats['failed']++;
}
@ -210,27 +202,14 @@ public function capture(
$remainingCount = max(0, count($subjects) - $processed);
if ($remainingCount > 0) {
$gaps['budget_exhausted'] = ($gaps['budget_exhausted'] ?? 0) + $remainingCount;
foreach (array_slice($subjects, $processed) as $remainingSubject) {
$remainingPolicyType = trim((string) ($remainingSubject['policy_type'] ?? ''));
$remainingExternalId = trim((string) ($remainingSubject['subject_external_id'] ?? ''));
if ($remainingPolicyType === '' || $remainingExternalId === '') {
continue;
}
$gapSubjects['budget_exhausted'][] = $remainingPolicyType.'|'.$remainingExternalId;
}
}
}
ksort($gaps);
ksort($gapSubjects);
return [
'stats' => $stats,
'gaps' => $gaps,
'gap_subjects' => $gapSubjects,
'resume_token' => $resumeTokenOut,
'captured_versions' => $capturedVersions,
];

View File

@ -1,90 +0,0 @@
<?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

@ -1,175 +0,0 @@
<?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,8 +14,6 @@
use App\Support\Ui\EnterpriseDetail\EnterpriseDetailPageData;
use App\Support\Ui\EnterpriseDetail\EnterpriseDetailSectionFactory;
use App\Support\Ui\EnterpriseDetail\SummaryHeaderData;
use App\Support\Ui\GovernanceArtifactTruth\ArtifactTruthEnvelope;
use App\Support\Ui\GovernanceArtifactTruth\ArtifactTruthPresenter;
use Illuminate\Database\Eloquent\Collection as EloquentCollection;
use Illuminate\Support\Carbon;
use Illuminate\Support\Collection;
@ -100,21 +98,13 @@ public function presentEnterpriseDetail(BaselineSnapshot $snapshot, array $relat
{
$rendered = $this->present($snapshot);
$factory = new EnterpriseDetailSectionFactory;
$truth = app(ArtifactTruthPresenter::class)->forBaselineSnapshot($snapshot);
$truthBadge = $factory->statusBadge(
$truth->primaryBadgeSpec()->label,
$truth->primaryBadgeSpec()->color,
$truth->primaryBadgeSpec()->icon,
$truth->primaryBadgeSpec()->iconColor,
);
$lifecycleSpec = BadgeRenderer::spec(BadgeDomain::BaselineSnapshotLifecycle, $snapshot->lifecycleState()->value);
$lifecycleBadge = $factory->statusBadge(
$lifecycleSpec->label,
$lifecycleSpec->color,
$lifecycleSpec->icon,
$lifecycleSpec->iconColor,
$stateSpec = $this->gapStatusSpec($rendered->overallGapCount);
$stateBadge = $factory->statusBadge(
$stateSpec->label,
$stateSpec->color,
$stateSpec->icon,
$stateSpec->iconColor,
);
$fidelitySpec = BadgeRenderer::spec(BadgeDomain::BaselineSnapshotFidelity, $rendered->overallFidelity->value);
@ -129,37 +119,21 @@ public function presentEnterpriseDetail(BaselineSnapshot $snapshot, array $relat
static fn (array $row): int => (int) ($row['itemCount'] ?? 0),
$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')
->header(new SummaryHeaderData(
title: $rendered->baselineProfileName ?? 'Baseline snapshot',
subtitle: 'Snapshot #'.$rendered->snapshotId,
statusBadges: [$truthBadge, $lifecycleBadge, $fidelityBadge],
statusBadges: [$stateBadge, $fidelityBadge],
keyFacts: [
$factory->keyFact('Captured', $this->formatTimestamp($rendered->capturedAt)),
$factory->keyFact('Current truth', $currentTruth['label'], badge: $currentTruthBadge),
$factory->keyFact('Evidence mix', $rendered->fidelitySummary),
$factory->keyFact('Evidence gaps', $rendered->overallGapCount),
$factory->keyFact('Captured items', $capturedItemCount),
],
descriptionHint: 'Current baseline truth, lifecycle proof, and coverage stay ahead of technical payload detail.',
descriptionHint: 'Capture context, coverage, and governance links stay ahead of technical payload detail.',
))
->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(
id: 'coverage_summary',
kind: 'current_status',
@ -191,30 +165,11 @@ public function presentEnterpriseDetail(BaselineSnapshot $snapshot, array $relat
->addSupportingCard(
$factory->supportingFactsCard(
kind: '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',
title: 'Snapshot status',
items: [
$factory->keyFact('State', $rendered->stateLabel, badge: $stateBadge),
$factory->keyFact('Overall fidelity', $fidelitySpec->label, badge: $fidelityBadge),
$factory->keyFact('Evidence gaps', $rendered->overallGapCount),
$factory->keyFact('Captured items', $capturedItemCount),
],
),
$factory->supportingFactsCard(
@ -222,8 +177,6 @@ public function presentEnterpriseDetail(BaselineSnapshot $snapshot, array $relat
title: 'Capture timing',
items: [
$factory->keyFact('Captured', $this->formatTimestamp($rendered->capturedAt)),
$factory->keyFact('Completed', $this->formatTimestamp($snapshot->completed_at?->toIso8601String())),
$factory->keyFact('Failed', $this->formatTimestamp($snapshot->failed_at?->toIso8601String())),
$factory->keyFact('Identity hash', $rendered->snapshotIdentityHash),
],
),
@ -375,33 +328,6 @@ private function gapStatusSpec(int $gapCount): \App\Support\Badges\BadgeSpec
);
}
/**
* @return array{label: string, color: string, icon: string, iconColor: string}
*/
private function currentTruthPresentation(ArtifactTruthEnvelope $truth): array
{
return match ($truth->artifactExistence) {
'historical_only' => [
'label' => 'Historical trace',
'color' => 'gray',
'icon' => 'heroicon-m-clock',
'iconColor' => 'gray',
],
'created_but_not_usable' => [
'label' => 'Not compare input',
'color' => 'warning',
'icon' => 'heroicon-m-exclamation-triangle',
'iconColor' => 'warning',
],
default => [
'label' => 'Current baseline',
'color' => 'success',
'icon' => 'heroicon-m-check-badge',
'iconColor' => 'success',
],
};
}
private function typeLabel(string $policyType): string
{
return InventoryPolicyTypeMeta::baselineCompareLabel($policyType)

View File

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

View File

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

View File

@ -1,139 +0,0 @@
<?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

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

View File

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

View File

@ -15,14 +15,6 @@ final class BadgeCatalog
private const DOMAIN_MAPPERS = [
BadgeDomain::AuditOutcome->value => Domains\AuditOutcomeBadge::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::BaselineSnapshotGapStatus->value => Domains\BaselineSnapshotGapStatusBadge::class,
BadgeDomain::OperationRunStatus->value => Domains\OperationRunStatusBadge::class,

View File

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

View File

@ -1,28 +0,0 @@
<?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

@ -1,26 +0,0 @@
<?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

@ -1,30 +0,0 @@
<?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

@ -1,27 +0,0 @@
<?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

@ -1,26 +0,0 @@
<?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

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

View File

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

View File

@ -1,28 +0,0 @@
<?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

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

View File

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

View File

@ -1,557 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Support\Baselines;
use App\Models\OperationRun;
use Illuminate\Support\Str;
final class BaselineCompareEvidenceGapDetails
{
/**
* @return array{
* summary: array{
* count: int,
* by_reason: array<string, int>,
* detail_state: string,
* recorded_subjects_total: int,
* missing_detail_count: int
* },
* buckets: list<array{
* reason_code: string,
* reason_label: string,
* count: int,
* recorded_count: int,
* missing_detail_count: int,
* detail_state: string,
* search_text: string,
* rows: list<array{
* reason_code: string,
* reason_label: string,
* policy_type: string,
* subject_key: string,
* search_text: string
* }>
* }>
* }
*/
public static function fromOperationRun(?OperationRun $run): array
{
if (! $run instanceof OperationRun || ! is_array($run->context)) {
return self::empty();
}
return self::fromContext($run->context);
}
/**
* @param array<string, mixed> $context
* @return array{
* summary: array{
* count: int,
* by_reason: array<string, int>,
* detail_state: string,
* recorded_subjects_total: int,
* missing_detail_count: int
* },
* buckets: list<array{
* reason_code: string,
* reason_label: string,
* count: int,
* recorded_count: int,
* missing_detail_count: int,
* detail_state: string,
* search_text: string,
* rows: list<array{
* reason_code: string,
* reason_label: string,
* policy_type: string,
* subject_key: string,
* search_text: string
* }>
* }>
* }
*/
public static function fromContext(array $context): array
{
$baselineCompare = $context['baseline_compare'] ?? null;
if (! is_array($baselineCompare)) {
return self::empty();
}
return self::fromBaselineCompare($baselineCompare);
}
/**
* @param array<string, mixed> $baselineCompare
* @return array{
* summary: array{
* count: int,
* by_reason: array<string, int>,
* detail_state: string,
* recorded_subjects_total: int,
* missing_detail_count: int
* },
* buckets: list<array{
* reason_code: string,
* reason_label: string,
* count: int,
* recorded_count: int,
* missing_detail_count: int,
* detail_state: string,
* search_text: string,
* rows: list<array{
* reason_code: string,
* reason_label: string,
* policy_type: string,
* subject_key: string,
* search_text: string
* }>
* }>
* }
*/
public static function fromBaselineCompare(array $baselineCompare): array
{
$evidenceGaps = $baselineCompare['evidence_gaps'] ?? null;
$evidenceGaps = is_array($evidenceGaps) ? $evidenceGaps : [];
$byReason = self::normalizeCounts($evidenceGaps['by_reason'] ?? null);
$subjects = self::normalizeSubjects($evidenceGaps['subjects'] ?? null);
foreach ($subjects as $reason => $keys) {
if (! array_key_exists($reason, $byReason)) {
$byReason[$reason] = count($keys);
}
}
$count = self::normalizeTotalCount($evidenceGaps['count'] ?? null, $byReason, $subjects);
$detailState = self::detailState($count, $subjects);
$buckets = [];
foreach (self::orderedReasons($byReason, $subjects) as $reason) {
$rows = self::rowsForReason($reason, $subjects[$reason] ?? []);
$reasonCount = $byReason[$reason] ?? count($rows);
if ($reasonCount <= 0 && $rows === []) {
continue;
}
$recordedCount = count($rows);
$searchText = trim(implode(' ', array_filter([
Str::lower($reason),
Str::lower(self::reasonLabel($reason)),
...array_map(
static fn (array $row): string => (string) ($row['search_text'] ?? ''),
$rows,
),
])));
$buckets[] = [
'reason_code' => $reason,
'reason_label' => self::reasonLabel($reason),
'count' => $reasonCount,
'recorded_count' => $recordedCount,
'missing_detail_count' => max(0, $reasonCount - $recordedCount),
'detail_state' => $recordedCount > 0 ? 'details_recorded' : 'details_not_recorded',
'search_text' => $searchText,
'rows' => $rows,
];
}
$recordedSubjectsTotal = array_sum(array_map(
static fn (array $bucket): int => (int) ($bucket['recorded_count'] ?? 0),
$buckets,
));
return [
'summary' => [
'count' => $count,
'by_reason' => $byReason,
'detail_state' => $detailState,
'recorded_subjects_total' => $recordedSubjectsTotal,
'missing_detail_count' => max(0, $count - $recordedSubjectsTotal),
],
'buckets' => $buckets,
];
}
/**
* @param array<string, mixed> $baselineCompare
* @return array<string, mixed>
*/
public static function diagnosticsPayload(array $baselineCompare): array
{
return array_filter([
'reason_code' => self::stringOrNull($baselineCompare['reason_code'] ?? null),
'subjects_total' => self::intOrNull($baselineCompare['subjects_total'] ?? null),
'resume_token' => self::stringOrNull($baselineCompare['resume_token'] ?? null),
'fidelity' => self::stringOrNull($baselineCompare['fidelity'] ?? null),
'coverage' => is_array($baselineCompare['coverage'] ?? null) ? $baselineCompare['coverage'] : null,
'evidence_capture' => is_array($baselineCompare['evidence_capture'] ?? null) ? $baselineCompare['evidence_capture'] : null,
'evidence_gaps' => is_array($baselineCompare['evidence_gaps'] ?? null) ? $baselineCompare['evidence_gaps'] : null,
], static fn (mixed $value): bool => $value !== null && $value !== []);
}
public static function reasonLabel(string $reason): string
{
$reason = trim($reason);
return match ($reason) {
'ambiguous_match' => 'Ambiguous inventory match',
'policy_not_found' => 'Policy not found',
'missing_current' => 'Missing current evidence',
'invalid_subject' => 'Invalid subject',
'duplicate_subject' => 'Duplicate subject',
'capture_failed' => 'Evidence capture failed',
'budget_exhausted' => 'Capture budget exhausted',
'throttled' => 'Graph throttled',
'missing_role_definition_baseline_version_reference' => 'Missing baseline role definition evidence',
'missing_role_definition_current_version_reference' => 'Missing current role definition evidence',
'missing_role_definition_compare_surface' => 'Missing role definition compare surface',
'rollout_disabled' => 'Rollout disabled',
default => Str::of($reason)->replace('_', ' ')->trim()->ucfirst()->toString(),
};
}
/**
* @param array<string, int> $byReason
* @return list<array{reason_code: string, reason_label: string, count: int}>
*/
public static function topReasons(array $byReason, int $limit = 5): array
{
$normalized = self::normalizeCounts($byReason);
arsort($normalized);
return array_map(
static fn (string $reason, int $count): array => [
'reason_code' => $reason,
'reason_label' => self::reasonLabel($reason),
'count' => $count,
],
array_slice(array_keys($normalized), 0, $limit),
array_slice(array_values($normalized), 0, $limit),
);
}
/**
* @param list<array<string, mixed>> $buckets
* @return list<array{
* __id: string,
* reason_code: string,
* reason_label: string,
* policy_type: string,
* subject_key: string,
* search_text: string
* }>
*/
public static function tableRows(array $buckets): array
{
$rows = [];
foreach ($buckets as $bucket) {
if (! is_array($bucket)) {
continue;
}
$bucketRows = is_array($bucket['rows'] ?? null) ? $bucket['rows'] : [];
foreach ($bucketRows as $row) {
if (! is_array($row)) {
continue;
}
$reasonCode = self::stringOrNull($row['reason_code'] ?? null);
$reasonLabel = self::stringOrNull($row['reason_label'] ?? null);
$policyType = self::stringOrNull($row['policy_type'] ?? null);
$subjectKey = self::stringOrNull($row['subject_key'] ?? null);
if ($reasonCode === null || $reasonLabel === null || $policyType === null || $subjectKey === null) {
continue;
}
$rows[] = [
'__id' => md5(implode('|', [$reasonCode, $policyType, $subjectKey])),
'reason_code' => $reasonCode,
'reason_label' => $reasonLabel,
'policy_type' => $policyType,
'subject_key' => $subjectKey,
'search_text' => Str::lower(implode(' ', [
$reasonCode,
$reasonLabel,
$policyType,
$subjectKey,
])),
];
}
}
return $rows;
}
/**
* @param list<array<string, mixed>> $rows
* @return array<string, string>
*/
public static function reasonFilterOptions(array $rows): array
{
return collect($rows)
->filter(fn (array $row): bool => filled($row['reason_code'] ?? null) && filled($row['reason_label'] ?? null))
->mapWithKeys(fn (array $row): array => [
(string) $row['reason_code'] => (string) $row['reason_label'],
])
->sortBy(fn (string $label): string => Str::lower($label))
->all();
}
/**
* @param list<array<string, mixed>> $rows
* @return array<string, string>
*/
public static function policyTypeFilterOptions(array $rows): array
{
return collect($rows)
->pluck('policy_type')
->filter(fn (mixed $value): bool => is_string($value) && trim($value) !== '')
->mapWithKeys(fn (string $value): array => [$value => $value])
->sortKeysUsing('strnatcasecmp')
->all();
}
/**
* @return array{
* summary: array{
* count: int,
* by_reason: array<string, int>,
* detail_state: string,
* recorded_subjects_total: int,
* missing_detail_count: int
* },
* buckets: list<array{
* reason_code: string,
* reason_label: string,
* count: int,
* recorded_count: int,
* missing_detail_count: int,
* detail_state: string,
* search_text: string,
* rows: list<array{
* reason_code: string,
* reason_label: string,
* policy_type: string,
* subject_key: string,
* search_text: string
* }>
* }>
* }
*/
private static function empty(): array
{
return [
'summary' => [
'count' => 0,
'by_reason' => [],
'detail_state' => 'no_gaps',
'recorded_subjects_total' => 0,
'missing_detail_count' => 0,
],
'buckets' => [],
];
}
/**
* @return array<string, int>
*/
private static function normalizeCounts(mixed $value): array
{
if (! is_array($value)) {
return [];
}
$normalized = [];
foreach ($value as $reason => $count) {
if (! is_string($reason) || trim($reason) === '' || ! is_numeric($count)) {
continue;
}
$intCount = (int) $count;
if ($intCount <= 0) {
continue;
}
$normalized[trim($reason)] = $intCount;
}
arsort($normalized);
return $normalized;
}
/**
* @return array<string, list<string>>
*/
private static function normalizeSubjects(mixed $value): array
{
if (! is_array($value)) {
return [];
}
$normalized = [];
foreach ($value as $reason => $keys) {
if (! is_string($reason) || trim($reason) === '' || ! is_array($keys)) {
continue;
}
$items = array_values(array_unique(array_filter(array_map(
static fn (mixed $item): ?string => is_string($item) && trim($item) !== '' ? trim($item) : null,
$keys,
))));
if ($items === []) {
continue;
}
$normalized[trim($reason)] = $items;
}
ksort($normalized);
return $normalized;
}
/**
* @param array<string, int> $byReason
* @param array<string, list<string>> $subjects
* @return list<string>
*/
private static function orderedReasons(array $byReason, array $subjects): array
{
$reasons = array_keys($byReason);
foreach (array_keys($subjects) as $reason) {
if (! in_array($reason, $reasons, true)) {
$reasons[] = $reason;
}
}
return $reasons;
}
/**
* @param array<string, int> $byReason
* @param array<string, list<string>> $subjects
*/
private static function normalizeTotalCount(mixed $count, array $byReason, array $subjects): int
{
if (is_numeric($count)) {
$intCount = (int) $count;
if ($intCount >= 0) {
return $intCount;
}
}
$byReasonCount = array_sum($byReason);
if ($byReasonCount > 0) {
return $byReasonCount;
}
return array_sum(array_map(
static fn (array $keys): int => count($keys),
$subjects,
));
}
/**
* @param array<string, list<string>> $subjects
*/
private static function detailState(int $count, array $subjects): string
{
if ($count <= 0) {
return 'no_gaps';
}
return $subjects !== [] ? 'details_recorded' : 'details_not_recorded';
}
/**
* @param list<string> $subjects
* @return list<array{
* reason_code: string,
* reason_label: string,
* policy_type: string,
* subject_key: string,
* search_text: string
* }>
*/
private static function rowsForReason(string $reason, array $subjects): array
{
$rows = [];
foreach ($subjects as $subject) {
[$policyType, $subjectKey] = self::splitSubject($subject);
if ($policyType === null || $subjectKey === null) {
continue;
}
$rows[] = [
'reason_code' => $reason,
'reason_label' => self::reasonLabel($reason),
'policy_type' => $policyType,
'subject_key' => $subjectKey,
'search_text' => Str::lower(implode(' ', [
$reason,
self::reasonLabel($reason),
$policyType,
$subjectKey,
])),
];
}
return $rows;
}
/**
* @return array{0: ?string, 1: ?string}
*/
private static function splitSubject(string $subject): array
{
$parts = explode('|', $subject, 2);
if (count($parts) !== 2) {
return [null, null];
}
$policyType = trim($parts[0]);
$subjectKey = trim($parts[1]);
if ($policyType === '' || $subjectKey === '') {
return [null, null];
}
return [$policyType, $subjectKey];
}
private static function stringOrNull(mixed $value): ?string
{
if (! is_string($value)) {
return null;
}
$value = trim($value);
return $value !== '' ? $value : null;
}
private static function intOrNull(mixed $value): ?int
{
return is_numeric($value) ? (int) $value : null;
}
}

View File

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

View File

@ -5,17 +5,13 @@
namespace App\Support\Baselines;
use App\Models\BaselineProfile;
use App\Models\BaselineSnapshot;
use App\Models\BaselineTenantAssignment;
use App\Models\Finding;
use App\Models\InventoryItem;
use App\Models\OperationRun;
use App\Models\Tenant;
use App\Services\Baselines\BaselineSnapshotTruthResolver;
use App\Support\OperationRunStatus;
use App\Support\OperationRunType;
use App\Support\Ui\OperatorExplanation\CountDescriptor;
use App\Support\Ui\OperatorExplanation\OperatorExplanationPattern;
use Illuminate\Support\Facades\Cache;
final class BaselineCompareStats
@ -24,32 +20,6 @@ final class BaselineCompareStats
* @param array<string, int> $severityCounts
* @param list<string> $uncoveredTypes
* @param array<string, int> $evidenceGapsTopReasons
* @param array{
* summary: array{
* count: int,
* by_reason: array<string, int>,
* detail_state: string,
* recorded_subjects_total: int,
* missing_detail_count: int
* },
* buckets: list<array{
* reason_code: string,
* reason_label: string,
* count: int,
* recorded_count: int,
* missing_detail_count: int,
* detail_state: string,
* search_text: string,
* rows: list<array{
* reason_code: string,
* reason_label: string,
* policy_type: string,
* subject_key: string,
* search_text: string
* }>
* }>
* } $evidenceGapDetails
* @param array<string, mixed> $baselineCompareDiagnostics
*/
private function __construct(
public readonly string $state,
@ -73,8 +43,6 @@ private function __construct(
public readonly ?int $evidenceGapsCount = null,
public readonly array $evidenceGapsTopReasons = [],
public readonly ?array $rbacRoleDefinitionSummary = null,
public readonly array $evidenceGapDetails = [],
public readonly array $baselineCompareDiagnostics = [],
) {}
public static function forTenant(?Tenant $tenant): self
@ -105,11 +73,7 @@ public static function forTenant(?Tenant $tenant): self
$profileName = (string) $profile->name;
$profileId = (int) $profile->getKey();
$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);
$snapshotId = $profile->active_snapshot_id !== null ? (int) $profile->active_snapshot_id : null;
$profileScope = BaselineScope::fromJsonb(
is_array($profile->scope_jsonb) ? $profile->scope_jsonb : null,
@ -122,21 +86,12 @@ public static function forTenant(?Tenant $tenant): self
$duplicateNamePoliciesCount = self::duplicateNamePoliciesCount($tenant, $effectiveScope);
if ($snapshotId === null) {
return new self(
state: 'no_snapshot',
message: $snapshotReasonMessage ?? 'The baseline profile has no complete snapshot yet. A workspace manager needs to capture a baseline first.',
return self::empty(
'no_snapshot',
'The baseline profile has no active snapshot yet. A workspace manager needs to capture a snapshot first.',
profileName: $profileName,
profileId: $profileId,
snapshotId: null,
duplicateNamePoliciesCount: $duplicateNamePoliciesCount,
operationRunId: null,
findingsCount: null,
severityCounts: [],
lastComparedHuman: null,
lastComparedIso: null,
failureReason: null,
reasonCode: $snapshotReasonCode,
reasonMessage: $snapshotReasonMessage,
);
}
@ -150,8 +105,6 @@ public static function forTenant(?Tenant $tenant): self
[$evidenceGapsCount, $evidenceGapsTopReasons] = self::evidenceGapSummaryForRun($latestRun);
[$reasonCode, $reasonMessage] = self::reasonInfoForRun($latestRun);
$rbacRoleDefinitionSummary = self::rbacRoleDefinitionSummaryForRun($latestRun);
$evidenceGapDetails = self::evidenceGapDetailsForRun($latestRun);
$baselineCompareDiagnostics = self::baselineCompareDiagnosticsForRun($latestRun);
// Active run (queued/running)
if ($latestRun instanceof OperationRun && in_array($latestRun->status, ['queued', 'running'], true)) {
@ -177,8 +130,6 @@ public static function forTenant(?Tenant $tenant): self
evidenceGapsCount: $evidenceGapsCount,
evidenceGapsTopReasons: $evidenceGapsTopReasons,
rbacRoleDefinitionSummary: $rbacRoleDefinitionSummary,
evidenceGapDetails: $evidenceGapDetails,
baselineCompareDiagnostics: $baselineCompareDiagnostics,
);
}
@ -211,8 +162,6 @@ public static function forTenant(?Tenant $tenant): self
evidenceGapsCount: $evidenceGapsCount,
evidenceGapsTopReasons: $evidenceGapsTopReasons,
rbacRoleDefinitionSummary: $rbacRoleDefinitionSummary,
evidenceGapDetails: $evidenceGapDetails,
baselineCompareDiagnostics: $baselineCompareDiagnostics,
);
}
@ -267,8 +216,6 @@ public static function forTenant(?Tenant $tenant): self
evidenceGapsCount: $evidenceGapsCount,
evidenceGapsTopReasons: $evidenceGapsTopReasons,
rbacRoleDefinitionSummary: $rbacRoleDefinitionSummary,
evidenceGapDetails: $evidenceGapDetails,
baselineCompareDiagnostics: $baselineCompareDiagnostics,
);
}
@ -297,8 +244,6 @@ public static function forTenant(?Tenant $tenant): self
evidenceGapsCount: $evidenceGapsCount,
evidenceGapsTopReasons: $evidenceGapsTopReasons,
rbacRoleDefinitionSummary: $rbacRoleDefinitionSummary,
evidenceGapDetails: $evidenceGapDetails,
baselineCompareDiagnostics: $baselineCompareDiagnostics,
);
}
@ -324,8 +269,6 @@ public static function forTenant(?Tenant $tenant): self
evidenceGapsCount: $evidenceGapsCount,
evidenceGapsTopReasons: $evidenceGapsTopReasons,
rbacRoleDefinitionSummary: $rbacRoleDefinitionSummary,
evidenceGapDetails: $evidenceGapDetails,
baselineCompareDiagnostics: $baselineCompareDiagnostics,
);
}
@ -348,11 +291,6 @@ public static function forWidget(?Tenant $tenant): self
}
$profile = $assignment->baselineProfile;
$truthResolution = app(BaselineSnapshotTruthResolver::class)->resolveCompareSnapshot($profile);
$effectiveSnapshot = $truthResolution['effective_snapshot'] ?? null;
$snapshotId = $effectiveSnapshot instanceof BaselineSnapshot ? (int) $effectiveSnapshot->getKey() : null;
$snapshotReasonCode = is_string($truthResolution['reason_code'] ?? null) ? (string) $truthResolution['reason_code'] : null;
$snapshotReasonMessage = self::missingSnapshotMessage($snapshotReasonCode);
$scopeKey = 'baseline_profile:'.$profile->getKey();
$severityRows = Finding::query()
@ -376,11 +314,11 @@ public static function forWidget(?Tenant $tenant): self
->first();
return new self(
state: $snapshotId === null ? 'no_snapshot' : ($totalFindings > 0 ? 'ready' : 'idle'),
message: $snapshotId === null ? $snapshotReasonMessage : null,
state: $totalFindings > 0 ? 'ready' : 'idle',
message: null,
profileName: (string) $profile->name,
profileId: (int) $profile->getKey(),
snapshotId: $snapshotId,
snapshotId: $profile->active_snapshot_id !== null ? (int) $profile->active_snapshot_id : null,
duplicateNamePoliciesCount: null,
operationRunId: $latestRun instanceof OperationRun ? (int) $latestRun->getKey() : null,
findingsCount: $totalFindings,
@ -392,8 +330,6 @@ public static function forWidget(?Tenant $tenant): self
lastComparedHuman: $latestRun?->finished_at?->diffForHumans(),
lastComparedIso: $latestRun?->finished_at?->toIso8601String(),
failureReason: null,
reasonCode: $snapshotReasonCode,
reasonMessage: $snapshotReasonMessage,
);
}
@ -555,67 +491,48 @@ private static function evidenceGapSummaryForRun(?OperationRun $run): array
return [null, []];
}
$details = self::evidenceGapDetailsForRun($run);
$summary = is_array($details['summary'] ?? null) ? $details['summary'] : [];
$count = is_numeric($summary['count'] ?? null) ? (int) $summary['count'] : null;
$byReason = is_array($summary['by_reason'] ?? null) ? $summary['by_reason'] : [];
return [$count, array_slice($byReason, 0, 6, true)];
}
/**
* @return array{
* summary: array{
* count: int,
* by_reason: array<string, int>,
* detail_state: string,
* recorded_subjects_total: int,
* missing_detail_count: int
* },
* buckets: list<array{
* reason_code: string,
* reason_label: string,
* count: int,
* recorded_count: int,
* missing_detail_count: int,
* detail_state: string,
* search_text: string,
* rows: list<array{
* reason_code: string,
* reason_label: string,
* policy_type: string,
* subject_key: string,
* search_text: string
* }>
* }>
* }
*/
private static function evidenceGapDetailsForRun(?OperationRun $run): array
{
if (! $run instanceof OperationRun) {
return BaselineCompareEvidenceGapDetails::fromContext([]);
}
return BaselineCompareEvidenceGapDetails::fromOperationRun($run);
}
/**
* @return array<string, mixed>
*/
private static function baselineCompareDiagnosticsForRun(?OperationRun $run): array
{
if (! $run instanceof OperationRun) {
return [];
}
$context = is_array($run->context) ? $run->context : [];
$baselineCompare = $context['baseline_compare'] ?? null;
if (! is_array($baselineCompare)) {
return [];
return [null, []];
}
return BaselineCompareEvidenceGapDetails::diagnosticsPayload($baselineCompare);
$gaps = $baselineCompare['evidence_gaps'] ?? null;
if (! is_array($gaps)) {
return [null, []];
}
$count = $gaps['count'] ?? null;
$count = is_numeric($count) ? (int) $count : null;
$byReason = $gaps['by_reason'] ?? null;
$byReason = is_array($byReason) ? $byReason : [];
$normalized = [];
foreach ($byReason as $reason => $value) {
if (! is_string($reason) || trim($reason) === '' || ! is_numeric($value)) {
continue;
}
$intValue = (int) $value;
if ($intValue <= 0) {
continue;
}
$normalized[trim($reason)] = $intValue;
}
if ($count === null) {
$count = array_sum($normalized);
}
arsort($normalized);
return [$count, array_slice($normalized, 0, 6, true)];
}
/**
@ -644,31 +561,6 @@ 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(
string $state,
?string $message,
@ -691,15 +583,4 @@ private static function empty(
failureReason: null,
);
}
private static function missingSnapshotMessage(?string $reasonCode): ?string
{
return match ($reasonCode) {
BaselineReasonCodes::COMPARE_SNAPSHOT_BUILDING => 'The latest baseline capture is still building. Compare becomes available after a complete snapshot is finalized.',
BaselineReasonCodes::COMPARE_SNAPSHOT_INCOMPLETE => 'The latest baseline capture is incomplete and there is no current complete snapshot to compare against.',
BaselineReasonCodes::COMPARE_NO_CONSUMABLE_SNAPSHOT,
BaselineReasonCodes::COMPARE_NO_ACTIVE_SNAPSHOT => 'The baseline profile has no complete snapshot yet. A workspace manager needs to capture a baseline first.',
default => null,
};
}
}

View File

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

View File

@ -1,42 +0,0 @@
<?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

@ -1,27 +0,0 @@
<?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) {
'baseline_snapshot' => $this->baselineSnapshotEntry(
rule: $rule,
snapshotId: $profile->resolveCurrentConsumableSnapshot()?->getKey(),
snapshotId: is_numeric($profile->active_snapshot_id ?? null) ? (int) $profile->active_snapshot_id : null,
workspaceId: (int) $profile->workspace_id,
),
default => null,

View File

@ -105,28 +105,4 @@ public static function allowedSummaryKeys(): array
{
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,20 +5,13 @@
use App\Filament\Pages\BaselineCompareLanding;
use App\Filament\Resources\BackupScheduleResource;
use App\Filament\Resources\BackupSetResource;
use App\Filament\Resources\BaselineSnapshotResource;
use App\Filament\Resources\EntraGroupResource;
use App\Filament\Resources\EvidenceSnapshotResource;
use App\Filament\Resources\InventoryItemResource;
use App\Filament\Resources\PolicyResource;
use App\Filament\Resources\ProviderConnectionResource;
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\ReviewPack;
use App\Models\Tenant;
use App\Models\TenantReview;
use App\Support\Navigation\CanonicalNavigationContext;
final class OperationRunLinks
@ -86,14 +79,6 @@ public static function related(OperationRun $run, ?Tenant $tenant): array
$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)) {
$links['Backup Sets'] = BackupSetResource::getUrl('index', panel: 'tenant', tenant: $tenant);
@ -116,39 +101,6 @@ 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 !== '');
}
}

View File

@ -4,9 +4,6 @@
namespace App\Support\Operations;
use App\Support\ReasonTranslation\NextStepOption;
use App\Support\ReasonTranslation\ReasonResolutionEnvelope;
enum ExecutionDenialReasonCode: string
{
case WorkspaceMismatch = 'workspace_mismatch';
@ -46,85 +43,4 @@ public function message(): string
self::ExecutionPrerequisiteInvalid => 'Operation blocked because the queued execution prerequisites are no longer satisfied.',
};
}
public function operatorLabel(): string
{
return match ($this) {
self::WorkspaceMismatch => 'Workspace context changed',
self::TenantNotEntitled => 'Tenant access removed',
self::MissingCapability => 'Permission required',
self::TenantNotOperable => 'Tenant not ready',
self::TenantMissing => 'Tenant record unavailable',
self::InitiatorMissing => 'Initiator no longer available',
self::InitiatorNotEntitled => 'Initiator lost tenant access',
self::ProviderConnectionInvalid => 'Provider connection needs review',
self::WriteGateBlocked => 'Write protection blocked execution',
self::ExecutionPrerequisiteInvalid => 'Execution prerequisite changed',
};
}
public function shortExplanation(): string
{
return match ($this) {
self::WorkspaceMismatch => 'The queued run no longer matches the current workspace scope.',
self::TenantNotEntitled => 'The queued tenant is no longer entitled for this run.',
self::MissingCapability => 'The initiating actor no longer has the capability required for this queued run.',
self::TenantNotOperable => 'The target tenant is not currently operable for this action.',
self::TenantMissing => 'The target tenant could not be resolved when execution resumed.',
self::InitiatorMissing => 'The initiating actor could not be resolved when execution resumed.',
self::InitiatorNotEntitled => 'The initiating actor is no longer entitled to the target tenant.',
self::ProviderConnectionInvalid => 'The queued provider connection is no longer valid for this scope.',
self::WriteGateBlocked => 'Current write hardening refuses execution for this tenant until the gate is satisfied.',
self::ExecutionPrerequisiteInvalid => 'The queued execution prerequisites are no longer satisfied.',
};
}
public function actionability(): string
{
return match ($this) {
self::TenantNotOperable => 'retryable_transient',
self::ProviderConnectionInvalid, self::WriteGateBlocked, self::ExecutionPrerequisiteInvalid => 'prerequisite_missing',
default => 'permanent_configuration',
};
}
/**
* @return array<int, NextStepOption>
*/
public function nextSteps(): array
{
return match ($this) {
self::MissingCapability, self::TenantNotEntitled, self::InitiatorNotEntitled, self::WorkspaceMismatch => [
NextStepOption::instruction('Review workspace or tenant access before retrying.', scope: 'workspace'),
],
self::TenantNotOperable, self::ExecutionPrerequisiteInvalid => [
NextStepOption::instruction('Review tenant readiness before retrying.', scope: 'tenant'),
],
self::ProviderConnectionInvalid => [
NextStepOption::instruction('Review the provider connection before retrying.', scope: 'tenant'),
],
self::WriteGateBlocked => [
NextStepOption::instruction('Review the write gate state before retrying.', scope: 'tenant'),
],
self::TenantMissing, self::InitiatorMissing => [
NextStepOption::instruction('Requeue the operation from a current tenant context.', scope: 'tenant'),
],
};
}
/**
* @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

@ -1,83 +0,0 @@
<?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

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

View File

@ -1,69 +0,0 @@
<?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,11 +7,7 @@
use App\Models\OperationRun;
use App\Models\Tenant;
use App\Support\OperationCatalog;
use App\Support\Operations\OperationRunFreshnessState;
use App\Support\ReasonTranslation\ReasonPresenter;
use App\Support\RedactionIntegrity;
use App\Support\Ui\GovernanceArtifactTruth\ArtifactTruthPresenter;
use App\Support\Ui\OperatorExplanation\OperatorExplanationPattern;
use Filament\Notifications\Notification as FilamentNotification;
final class OperationUxPresenter
@ -99,35 +95,7 @@ public static function terminalDatabaseNotification(OperationRun $run, ?Tenant $
public static function surfaceGuidance(OperationRun $run): ?string
{
$uxStatus = OperationStatusNormalizer::toUxStatus($run->status, $run->outcome);
$reasonEnvelope = self::reasonEnvelope($run);
$reasonGuidance = app(ReasonPresenter::class)->guidance($reasonEnvelope);
$operatorExplanationGuidance = self::operatorExplanationGuidance($run);
$nextStepLabel = self::firstNextStepLabel($run);
$freshnessState = self::freshnessState($run);
if ($freshnessState->isLikelyStale()) {
return 'This run is past its lifecycle window. Review worker health and logs before retrying from the start surface.';
}
if ($freshnessState->isReconciledFailed()) {
return $operatorExplanationGuidance
?? $reasonGuidance
?? 'TenantPilot reconciled this run after lifecycle truth was lost. Review the recorded evidence before retrying.';
}
if (in_array($uxStatus, ['blocked', 'failed', 'partial'], true)) {
if ($operatorExplanationGuidance !== null) {
return $operatorExplanationGuidance;
}
if ($reasonGuidance !== null) {
return $reasonGuidance;
}
}
if ($uxStatus === 'succeeded' && $operatorExplanationGuidance !== null) {
return $operatorExplanationGuidance;
}
return match ($uxStatus) {
'queued' => 'No action needed yet. The run is waiting for a worker.',
@ -149,44 +117,9 @@ public static function surfaceGuidance(OperationRun $run): ?string
public static function surfaceFailureDetail(OperationRun $run): ?string
{
$operatorExplanation = self::governanceOperatorExplanation($run);
if (is_string($operatorExplanation?->dominantCauseExplanation) && trim($operatorExplanation->dominantCauseExplanation) !== '') {
return trim($operatorExplanation->dominantCauseExplanation);
}
$failureMessage = (string) (($run->failure_summary[0]['message'] ?? '') ?? '');
$sanitizedFailureMessage = self::sanitizeFailureMessage($failureMessage);
if ($sanitizedFailureMessage !== null) {
return $sanitizedFailureMessage;
}
$reasonEnvelope = self::reasonEnvelope($run);
if ($reasonEnvelope !== null) {
return $reasonEnvelope->shortExplanation;
}
if (self::freshnessState($run)->isLikelyStale()) {
return 'This run is no longer within its normal lifecycle window and may no longer be progressing.';
}
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,
};
return self::sanitizeFailureMessage($failureMessage);
}
/**
@ -195,16 +128,6 @@ public static function lifecycleAttentionSummary(OperationRun $run): ?string
private static function terminalPresentation(OperationRun $run): array
{
$uxStatus = OperationStatusNormalizer::toUxStatus($run->status, $run->outcome);
$reasonEnvelope = self::reasonEnvelope($run);
$freshnessState = self::freshnessState($run);
if ($freshnessState->isReconciledFailed()) {
return [
'titleSuffix' => 'was automatically reconciled',
'body' => $reasonEnvelope?->operatorLabel ?? 'Automatically reconciled after infrastructure failure.',
'status' => 'danger',
];
}
return match ($uxStatus) {
'succeeded' => [
@ -219,12 +142,12 @@ private static function terminalPresentation(OperationRun $run): array
],
'blocked' => [
'titleSuffix' => 'blocked by prerequisite',
'body' => $reasonEnvelope?->operatorLabel ?? 'Blocked by prerequisite.',
'body' => 'Blocked by prerequisite.',
'status' => 'warning',
],
default => [
'titleSuffix' => 'execution failed',
'body' => $reasonEnvelope?->operatorLabel ?? 'Execution failed.',
'body' => 'Execution failed.',
'status' => 'danger',
],
};
@ -281,37 +204,4 @@ private static function sanitizeFailureMessage(string $failureMessage): ?string
return $failureMessage !== '' ? $failureMessage : null;
}
private static function reasonEnvelope(OperationRun $run): ?\App\Support\ReasonTranslation\ReasonResolutionEnvelope
{
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,7 +6,6 @@
use App\Models\OperationRun;
use App\Support\OperationCatalog;
use App\Support\Operations\OperationRunFreshnessState;
use Illuminate\Support\Facades\Cache;
final class RunDurationInsights
@ -119,10 +118,6 @@ public static function expectedHuman(OperationRun $run): ?string
public static function stuckGuidance(OperationRun $run): ?string
{
if ($run->freshnessState() === OperationRunFreshnessState::LikelyStale) {
return 'Past the lifecycle window. Review worker health and logs before retrying.';
}
$uxStatus = OperationStatusNormalizer::toUxStatus($run->status, $run->outcome);
if (! in_array($uxStatus, ['queued', 'running'], true)) {

View File

@ -3,9 +3,7 @@
namespace App\Support\OpsUx;
use App\Services\Intune\SecretClassificationService;
use App\Support\Baselines\BaselineReasonCodes;
use App\Support\Operations\ExecutionDenialReasonCode;
use App\Support\Operations\LifecycleReconciliationReason;
use App\Support\Providers\ProviderReasonCodes;
final class RunFailureSanitizer
@ -40,12 +38,16 @@ public static function sanitizeCode(string $code): string
public static function normalizeReasonCode(string $candidate): string
{
$candidate = strtolower(trim($candidate));
$executionDenialReasonCodes = array_map(
static fn (ExecutionDenialReasonCode $reasonCode): string => $reasonCode->value,
ExecutionDenialReasonCode::cases(),
);
if ($candidate === '') {
return ProviderReasonCodes::UnknownError;
}
if (self::isStructuredOperatorReasonCode($candidate) || in_array($candidate, ['ok', 'not_applicable'], true)) {
if (ProviderReasonCodes::isKnown($candidate) || in_array($candidate, ['ok', 'not_applicable'], true) || in_array($candidate, $executionDenialReasonCodes, true)) {
return $candidate;
}
@ -83,11 +85,11 @@ public static function normalizeReasonCode(string $candidate): string
default => $candidate,
};
if (self::isStructuredOperatorReasonCode($candidate) || in_array($candidate, ['ok', 'not_applicable'], true)) {
if (ProviderReasonCodes::isKnown($candidate) || in_array($candidate, ['ok', 'not_applicable'], true) || in_array($candidate, $executionDenialReasonCodes, true)) {
return $candidate;
}
// Heuristic normalization for ad-hoc inputs is bounded fallback behavior only.
// Heuristic normalization for ad-hoc codes used across jobs/services.
if (str_contains($candidate, 'throttle') || str_contains($candidate, '429')) {
return ProviderReasonCodes::RateLimited;
}
@ -119,30 +121,6 @@ public static function normalizeReasonCode(string $candidate): string
return ProviderReasonCodes::UnknownError;
}
public static function isStructuredOperatorReasonCode(string $candidate): bool
{
$candidate = strtolower(trim($candidate));
if ($candidate === '') {
return false;
}
$executionDenialReasonCodes = array_map(
static fn (ExecutionDenialReasonCode $reasonCode): string => $reasonCode->value,
ExecutionDenialReasonCode::cases(),
);
$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
{
$message = trim(str_replace(["\r", "\n"], ' ', $message));

View File

@ -4,8 +4,6 @@
namespace App\Support\OpsUx;
use App\Support\ReasonTranslation\ReasonTranslator;
final class SummaryCountsNormalizer
{
/**
@ -86,22 +84,6 @@ public static function renderSummaryLine(array $summaryCounts): ?string
*/
public static function label(string $key): string
{
$reasonCode = null;
if (str_starts_with($key, 'reason_')) {
$reasonCode = substr($key, strlen('reason_'));
} elseif (str_starts_with($key, 'blocked_reason_')) {
$reasonCode = substr($key, strlen('blocked_reason_'));
}
if (is_string($reasonCode) && $reasonCode !== '') {
$translation = app(ReasonTranslator::class)->translate($reasonCode, surface: 'summary_line');
if ($translation !== null) {
return 'Reason: '.$translation->operatorLabel;
}
}
return match ($key) {
'total' => 'Total',
'processed' => 'Processed',

View File

@ -2,23 +2,91 @@
namespace App\Support\Providers;
use App\Filament\Resources\ProviderConnectionResource;
use App\Models\ProviderConnection;
use App\Models\Tenant;
use App\Support\ReasonTranslation\ReasonPresenter;
use App\Support\Links\RequiredPermissionsLinks;
final class ProviderNextStepsRegistry
{
public function __construct(
private readonly ReasonPresenter $reasonPresenter,
) {}
/**
* @return array<int, array{label: string, url: string}>
*/
public function forReason(Tenant $tenant, string $reasonCode, ?ProviderConnection $connection = null): array
{
$envelope = $this->reasonPresenter->forProviderReason($tenant, $reasonCode, $connection, 'helper_copy');
return $envelope?->toLegacyNextSteps() ?? [];
return match ($reasonCode) {
ProviderReasonCodes::ProviderConnectionMissing,
ProviderReasonCodes::ProviderConnectionInvalid,
ProviderReasonCodes::TenantTargetMismatch,
ProviderReasonCodes::PlatformIdentityMissing,
ProviderReasonCodes::PlatformIdentityIncomplete,
ProviderReasonCodes::ProviderConnectionReviewRequired => [
[
'label' => $connection instanceof ProviderConnection ? 'Review migration classification' : 'Manage Provider Connections',
'url' => $connection instanceof ProviderConnection
? ProviderConnectionResource::getUrl('view', ['tenant' => $tenant->external_id, 'record' => (int) $connection->getKey()], panel: 'admin')
: ProviderConnectionResource::getUrl('index', ['tenant' => $tenant->external_id], panel: 'admin'),
],
[
'label' => 'Review effective app details',
'url' => $connection instanceof ProviderConnection
? ProviderConnectionResource::getUrl('edit', ['tenant' => $tenant->external_id, 'record' => (int) $connection->getKey()], panel: 'admin')
: ProviderConnectionResource::getUrl('index', ['tenant' => $tenant->external_id], panel: 'admin'),
],
],
ProviderReasonCodes::DedicatedCredentialMissing,
ProviderReasonCodes::DedicatedCredentialInvalid => [
[
'label' => $connection instanceof ProviderConnection ? 'Manage dedicated connection' : 'Manage Provider Connections',
'url' => $connection instanceof ProviderConnection
? ProviderConnectionResource::getUrl('edit', ['tenant' => $tenant->external_id, 'record' => (int) $connection->getKey()], panel: 'admin')
: ProviderConnectionResource::getUrl('index', ['tenant' => $tenant->external_id], panel: 'admin'),
],
],
ProviderReasonCodes::ProviderCredentialMissing,
ProviderReasonCodes::ProviderCredentialInvalid,
ProviderReasonCodes::ProviderConsentFailed,
ProviderReasonCodes::ProviderConsentRevoked,
ProviderReasonCodes::ProviderAuthFailed,
ProviderReasonCodes::ProviderConsentMissing => [
[
'label' => 'Grant admin consent',
'url' => RequiredPermissionsLinks::adminConsentPrimaryUrl($tenant),
],
[
'label' => $connection instanceof ProviderConnection
? ($connection->connection_type === ProviderConnectionType::Dedicated ? 'Manage dedicated connection' : 'Review platform connection')
: 'Manage Provider Connections',
'url' => $connection instanceof ProviderConnection
? ProviderConnectionResource::getUrl('edit', ['tenant' => $tenant->external_id, 'record' => (int) $connection->getKey()], panel: 'admin')
: ProviderConnectionResource::getUrl('index', ['tenant' => $tenant->external_id], panel: 'admin'),
],
],
ProviderReasonCodes::ProviderPermissionMissing,
ProviderReasonCodes::ProviderPermissionDenied,
ProviderReasonCodes::ProviderPermissionRefreshFailed,
ProviderReasonCodes::IntuneRbacPermissionMissing => [
[
'label' => 'Open Required Permissions',
'url' => RequiredPermissionsLinks::requiredPermissions($tenant),
],
],
ProviderReasonCodes::NetworkUnreachable,
ProviderReasonCodes::RateLimited,
ProviderReasonCodes::UnknownError => [
[
'label' => 'Review Provider Connection',
'url' => $connection instanceof ProviderConnection
? ProviderConnectionResource::getUrl('edit', ['tenant' => $tenant->external_id, 'record' => (int) $connection->getKey()], panel: 'admin')
: ProviderConnectionResource::getUrl('index', ['tenant' => $tenant->external_id], panel: 'admin'),
],
],
default => [
[
'label' => 'Manage Provider Connections',
'url' => ProviderConnectionResource::getUrl('index', ['tenant' => $tenant->external_id], panel: 'admin'),
],
],
};
}
}

View File

@ -1,364 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Support\Providers;
use App\Filament\Resources\ProviderConnectionResource;
use App\Models\ProviderConnection;
use App\Models\Tenant;
use App\Support\Links\RequiredPermissionsLinks;
use App\Support\ReasonTranslation\Contracts\TranslatesReasonCode;
use App\Support\ReasonTranslation\NextStepOption;
use App\Support\ReasonTranslation\ReasonResolutionEnvelope;
final class ProviderReasonTranslator implements TranslatesReasonCode
{
public const string ARTIFACT_KEY = 'provider_reason_codes';
public function artifactKey(): string
{
return self::ARTIFACT_KEY;
}
public function canTranslate(string $reasonCode): bool
{
return ProviderReasonCodes::isKnown(trim($reasonCode));
}
/**
* @param array<string, mixed> $context
*/
public function translate(string $reasonCode, string $surface = 'detail', array $context = []): ?ReasonResolutionEnvelope
{
$reasonCode = trim($reasonCode);
if ($reasonCode === '') {
return null;
}
$normalizedCode = ProviderReasonCodes::isKnown($reasonCode)
? $reasonCode
: ProviderReasonCodes::UnknownError;
$tenant = $context['tenant'] ?? null;
$connection = $context['connection'] ?? null;
if (! $tenant instanceof Tenant) {
$nextSteps = $this->fallbackNextSteps($normalizedCode);
} else {
$nextSteps = $this->nextStepsFor($tenant, $normalizedCode, $connection instanceof ProviderConnection ? $connection : null);
}
return match ($normalizedCode) {
ProviderReasonCodes::ProviderConnectionMissing => $this->envelope(
reasonCode: $normalizedCode,
operatorLabel: 'Provider connection required',
shortExplanation: 'This tenant does not have a usable provider connection for Microsoft operations.',
actionability: 'prerequisite_missing',
nextSteps: $nextSteps,
),
ProviderReasonCodes::ProviderConnectionInvalid => $this->envelope(
reasonCode: $normalizedCode,
operatorLabel: 'Provider connection needs review',
shortExplanation: 'The selected provider connection is incomplete or no longer valid for this workflow.',
actionability: 'prerequisite_missing',
nextSteps: $nextSteps,
),
ProviderReasonCodes::ProviderCredentialMissing => $this->envelope(
reasonCode: $normalizedCode,
operatorLabel: 'Credentials missing',
shortExplanation: 'The provider connection is missing the credentials required to authenticate.',
actionability: 'prerequisite_missing',
nextSteps: $nextSteps,
),
ProviderReasonCodes::ProviderCredentialInvalid => $this->envelope(
reasonCode: $normalizedCode,
operatorLabel: 'Credentials need review',
shortExplanation: 'Stored provider credentials are no longer valid for the selected provider connection.',
actionability: 'prerequisite_missing',
nextSteps: $nextSteps,
),
ProviderReasonCodes::ProviderConnectionTypeInvalid => $this->envelope(
reasonCode: $normalizedCode,
operatorLabel: 'Connection type unsupported',
shortExplanation: 'The selected provider connection type cannot be used for this workflow.',
actionability: 'permanent_configuration',
nextSteps: $nextSteps,
),
ProviderReasonCodes::PlatformIdentityMissing => $this->envelope(
reasonCode: $normalizedCode,
operatorLabel: 'Platform identity missing',
shortExplanation: 'The platform provider connection is missing the app identity details required to continue.',
actionability: 'prerequisite_missing',
nextSteps: $nextSteps,
),
ProviderReasonCodes::PlatformIdentityIncomplete => $this->envelope(
reasonCode: $normalizedCode,
operatorLabel: 'Platform identity incomplete',
shortExplanation: 'The platform provider connection needs more app identity details before it can continue.',
actionability: 'prerequisite_missing',
nextSteps: $nextSteps,
),
ProviderReasonCodes::DedicatedCredentialMissing => $this->envelope(
reasonCode: $normalizedCode,
operatorLabel: 'Dedicated credentials required',
shortExplanation: 'This dedicated provider connection cannot continue until dedicated credentials are configured.',
actionability: 'prerequisite_missing',
nextSteps: $nextSteps,
),
ProviderReasonCodes::DedicatedCredentialInvalid => $this->envelope(
reasonCode: $normalizedCode,
operatorLabel: 'Dedicated credentials need review',
shortExplanation: 'The dedicated credentials are no longer valid for this provider connection.',
actionability: 'prerequisite_missing',
nextSteps: $nextSteps,
),
ProviderReasonCodes::ProviderConsentMissing => $this->envelope(
reasonCode: $normalizedCode,
operatorLabel: 'Admin consent required',
shortExplanation: 'The provider connection cannot continue until admin consent is granted.',
actionability: 'prerequisite_missing',
nextSteps: $nextSteps,
),
ProviderReasonCodes::ProviderConsentFailed => $this->envelope(
reasonCode: $normalizedCode,
operatorLabel: 'Admin consent check failed',
shortExplanation: 'TenantPilot could not confirm admin consent for this provider connection.',
actionability: 'prerequisite_missing',
nextSteps: $nextSteps,
),
ProviderReasonCodes::ProviderConsentRevoked => $this->envelope(
reasonCode: $normalizedCode,
operatorLabel: 'Admin consent revoked',
shortExplanation: 'Previously granted admin consent is no longer valid for this provider connection.',
actionability: 'prerequisite_missing',
nextSteps: $nextSteps,
),
ProviderReasonCodes::ProviderConnectionReviewRequired => $this->envelope(
reasonCode: $normalizedCode,
operatorLabel: 'Connection classification needs review',
shortExplanation: 'TenantPilot needs you to confirm how this provider connection should be used.',
actionability: 'prerequisite_missing',
nextSteps: $nextSteps,
),
ProviderReasonCodes::ProviderAuthFailed => $this->envelope(
reasonCode: $normalizedCode,
operatorLabel: 'Provider authentication failed',
shortExplanation: 'The provider connection could not authenticate with the stored credentials.',
actionability: 'prerequisite_missing',
nextSteps: $nextSteps,
),
ProviderReasonCodes::ProviderPermissionMissing => $this->envelope(
reasonCode: $normalizedCode,
operatorLabel: 'Permissions missing',
shortExplanation: 'The provider app is missing required Microsoft Graph permissions.',
actionability: 'prerequisite_missing',
nextSteps: $nextSteps,
),
ProviderReasonCodes::ProviderPermissionDenied => $this->envelope(
reasonCode: $normalizedCode,
operatorLabel: 'Permission denied',
shortExplanation: 'Microsoft Graph denied the requested permission for this provider connection.',
actionability: 'permanent_configuration',
nextSteps: $nextSteps,
),
ProviderReasonCodes::ProviderPermissionRefreshFailed => $this->envelope(
reasonCode: $normalizedCode,
operatorLabel: 'Permission refresh failed',
shortExplanation: 'TenantPilot could not refresh the provider permission snapshot.',
actionability: 'retryable_transient',
nextSteps: $nextSteps,
),
ProviderReasonCodes::IntuneRbacPermissionMissing => $this->envelope(
reasonCode: $normalizedCode,
operatorLabel: 'Intune RBAC permission missing',
shortExplanation: 'The provider app lacks the Intune RBAC permission needed for this workflow.',
actionability: 'prerequisite_missing',
nextSteps: $nextSteps,
),
ProviderReasonCodes::TenantTargetMismatch => $this->envelope(
reasonCode: $normalizedCode,
operatorLabel: 'Connection targets a different tenant',
shortExplanation: 'The selected provider connection points to a different Microsoft tenant than the current scope.',
actionability: 'permanent_configuration',
nextSteps: $nextSteps,
),
ProviderReasonCodes::NetworkUnreachable => $this->envelope(
reasonCode: $normalizedCode,
operatorLabel: 'Microsoft Graph temporarily unreachable',
shortExplanation: 'TenantPilot could not reach Microsoft Graph or the provider dependency.',
actionability: 'retryable_transient',
nextSteps: $nextSteps,
),
ProviderReasonCodes::RateLimited => $this->envelope(
reasonCode: $normalizedCode,
operatorLabel: 'Request rate limited',
shortExplanation: 'Microsoft Graph asked TenantPilot to slow down before retrying.',
actionability: 'retryable_transient',
nextSteps: $nextSteps,
),
ProviderReasonCodes::IntuneRbacNotConfigured => $this->envelope(
reasonCode: $normalizedCode,
operatorLabel: 'Intune RBAC not configured',
shortExplanation: 'Intune RBAC has not been configured for this tenant yet.',
actionability: 'prerequisite_missing',
nextSteps: $nextSteps,
),
ProviderReasonCodes::IntuneRbacUnhealthy => $this->envelope(
reasonCode: $normalizedCode,
operatorLabel: 'Intune RBAC health degraded',
shortExplanation: 'The latest Intune RBAC health check found a blocking issue.',
actionability: 'prerequisite_missing',
nextSteps: $nextSteps,
),
ProviderReasonCodes::IntuneRbacStale => $this->envelope(
reasonCode: $normalizedCode,
operatorLabel: 'Intune RBAC check is stale',
shortExplanation: 'The latest Intune RBAC health check is too old to trust for write operations.',
actionability: 'prerequisite_missing',
nextSteps: $nextSteps,
),
default => $this->envelope(
reasonCode: $normalizedCode,
operatorLabel: str_starts_with($normalizedCode, 'ext.')
? 'Provider configuration needs review'
: 'Provider check needs review',
shortExplanation: 'TenantPilot recorded a provider error that does not yet have a domain-specific translation.',
actionability: 'permanent_configuration',
nextSteps: $nextSteps,
),
};
}
/**
* @param array<int, NextStepOption> $nextSteps
*/
private function envelope(
string $reasonCode,
string $operatorLabel,
string $shortExplanation,
string $actionability,
array $nextSteps,
): ReasonResolutionEnvelope {
return new ReasonResolutionEnvelope(
internalCode: $reasonCode,
operatorLabel: $operatorLabel,
shortExplanation: $shortExplanation,
actionability: $actionability,
nextSteps: $nextSteps,
showNoActionNeeded: false,
diagnosticCodeLabel: $reasonCode,
);
}
/**
* @return array<int, NextStepOption>
*/
private function fallbackNextSteps(string $reasonCode): array
{
return match ($reasonCode) {
ProviderReasonCodes::NetworkUnreachable, ProviderReasonCodes::RateLimited => [
NextStepOption::instruction('Retry after the provider dependency recovers.'),
],
ProviderReasonCodes::UnknownError => [
NextStepOption::instruction('Review the provider connection and retry once the cause is understood.'),
],
default => [
NextStepOption::instruction('Review the provider connection before retrying.'),
],
};
}
/**
* @return array<int, NextStepOption>
*/
private function nextStepsFor(
Tenant $tenant,
string $reasonCode,
?ProviderConnection $connection = null,
): array {
return match ($reasonCode) {
ProviderReasonCodes::ProviderConnectionMissing,
ProviderReasonCodes::ProviderConnectionInvalid,
ProviderReasonCodes::TenantTargetMismatch,
ProviderReasonCodes::PlatformIdentityMissing,
ProviderReasonCodes::PlatformIdentityIncomplete,
ProviderReasonCodes::ProviderConnectionReviewRequired => [
NextStepOption::link(
label: $connection instanceof ProviderConnection ? 'Review migration classification' : 'Manage Provider Connections',
destination: $connection instanceof ProviderConnection
? ProviderConnectionResource::getUrl('view', ['tenant' => $tenant->external_id, 'record' => (int) $connection->getKey()], panel: 'admin')
: ProviderConnectionResource::getUrl('index', ['tenant' => $tenant->external_id], panel: 'admin'),
),
NextStepOption::link(
label: 'Review effective app details',
destination: $connection instanceof ProviderConnection
? ProviderConnectionResource::getUrl('edit', ['tenant' => $tenant->external_id, 'record' => (int) $connection->getKey()], panel: 'admin')
: ProviderConnectionResource::getUrl('index', ['tenant' => $tenant->external_id], panel: 'admin'),
),
],
ProviderReasonCodes::DedicatedCredentialMissing,
ProviderReasonCodes::DedicatedCredentialInvalid => [
NextStepOption::link(
label: $connection instanceof ProviderConnection ? 'Manage dedicated connection' : 'Manage Provider Connections',
destination: $connection instanceof ProviderConnection
? ProviderConnectionResource::getUrl('edit', ['tenant' => $tenant->external_id, 'record' => (int) $connection->getKey()], panel: 'admin')
: ProviderConnectionResource::getUrl('index', ['tenant' => $tenant->external_id], panel: 'admin'),
),
],
ProviderReasonCodes::ProviderCredentialMissing,
ProviderReasonCodes::ProviderCredentialInvalid,
ProviderReasonCodes::ProviderConsentFailed,
ProviderReasonCodes::ProviderConsentRevoked,
ProviderReasonCodes::ProviderAuthFailed,
ProviderReasonCodes::ProviderConsentMissing => [
NextStepOption::link(
label: 'Grant admin consent',
destination: RequiredPermissionsLinks::adminConsentPrimaryUrl($tenant),
),
NextStepOption::link(
label: $connection instanceof ProviderConnection
? ($connection->connection_type === ProviderConnectionType::Dedicated ? 'Manage dedicated connection' : 'Review platform connection')
: 'Manage Provider Connections',
destination: $connection instanceof ProviderConnection
? ProviderConnectionResource::getUrl('edit', ['tenant' => $tenant->external_id, 'record' => (int) $connection->getKey()], panel: 'admin')
: ProviderConnectionResource::getUrl('index', ['tenant' => $tenant->external_id], panel: 'admin'),
),
],
ProviderReasonCodes::ProviderPermissionMissing,
ProviderReasonCodes::ProviderPermissionDenied,
ProviderReasonCodes::ProviderPermissionRefreshFailed,
ProviderReasonCodes::IntuneRbacPermissionMissing => [
NextStepOption::link(
label: 'Open Required Permissions',
destination: RequiredPermissionsLinks::requiredPermissions($tenant),
),
],
ProviderReasonCodes::IntuneRbacNotConfigured,
ProviderReasonCodes::IntuneRbacUnhealthy,
ProviderReasonCodes::IntuneRbacStale => [
NextStepOption::link(
label: 'Review provider connections',
destination: ProviderConnectionResource::getUrl('index', ['tenant' => $tenant->external_id], panel: 'admin'),
),
NextStepOption::instruction('Refresh the tenant RBAC health check before retrying.', scope: 'tenant'),
],
ProviderReasonCodes::NetworkUnreachable,
ProviderReasonCodes::RateLimited => [
NextStepOption::instruction('Retry after the provider dependency recovers.', scope: 'tenant'),
NextStepOption::link(
label: 'Review provider connection',
destination: $connection instanceof ProviderConnection
? ProviderConnectionResource::getUrl('edit', ['tenant' => $tenant->external_id, 'record' => (int) $connection->getKey()], panel: 'admin')
: ProviderConnectionResource::getUrl('index', ['tenant' => $tenant->external_id], panel: 'admin'),
),
],
default => [
NextStepOption::link(
label: 'Manage Provider Connections',
destination: ProviderConnectionResource::getUrl('index', ['tenant' => $tenant->external_id], panel: 'admin'),
),
],
};
}
}

View File

@ -2,9 +2,6 @@
namespace App\Support;
use App\Support\ReasonTranslation\NextStepOption;
use App\Support\ReasonTranslation\ReasonResolutionEnvelope;
enum RbacReason: string
{
case MissingArtifacts = 'missing_artifacts';
@ -17,81 +14,4 @@ enum RbacReason: string
case CanaryFailed = 'canary_failed';
case ManualAssignmentRequired = 'manual_assignment_required';
case UnsupportedApi = 'unsupported_api';
public function operatorLabel(): string
{
return match ($this) {
self::MissingArtifacts => 'RBAC setup incomplete',
self::ServicePrincipalMissing => 'Service principal missing',
self::GroupMissing => 'RBAC group missing',
self::ServicePrincipalNotMember => 'Service principal not in RBAC group',
self::AssignmentMissing => 'RBAC assignment missing',
self::RoleMismatch => 'RBAC role mismatch',
self::ScopeMismatch => 'RBAC scope mismatch',
self::CanaryFailed => 'RBAC validation needs review',
self::ManualAssignmentRequired => 'Manual role assignment required',
self::UnsupportedApi => 'RBAC API unsupported',
};
}
public function shortExplanation(): string
{
return match ($this) {
self::MissingArtifacts => 'TenantPilot could not find the RBAC artifacts required for this tenant.',
self::ServicePrincipalMissing => 'The provider app service principal could not be resolved in Microsoft Graph.',
self::GroupMissing => 'The configured Intune RBAC group could not be found.',
self::ServicePrincipalNotMember => 'The provider app service principal is not currently a member of the configured RBAC group.',
self::AssignmentMissing => 'No matching Intune RBAC assignment could be confirmed for this tenant.',
self::RoleMismatch => 'The existing Intune RBAC assignment uses a different role than expected.',
self::ScopeMismatch => 'The existing Intune RBAC assignment targets a different scope than expected.',
self::CanaryFailed => 'The RBAC canary checks reported a mismatch after setup completed.',
self::ManualAssignmentRequired => 'This tenant requires a manual Intune RBAC role assignment outside the automated API path.',
self::UnsupportedApi => 'This account type does not support the required Intune RBAC API path.',
};
}
public function actionability(): string
{
return match ($this) {
self::CanaryFailed => 'retryable_transient',
self::ManualAssignmentRequired => 'prerequisite_missing',
self::UnsupportedApi => 'non_actionable',
default => 'prerequisite_missing',
};
}
/**
* @return array<int, NextStepOption>
*/
public function nextSteps(): array
{
return match ($this) {
self::UnsupportedApi => [],
self::ManualAssignmentRequired => [
NextStepOption::instruction('Complete the Intune role assignment manually, then refresh RBAC status.', scope: 'tenant'),
],
self::CanaryFailed => [
NextStepOption::instruction('Review the RBAC canary checks and rerun the health check.', scope: 'tenant'),
],
default => [
NextStepOption::instruction('Review the RBAC setup and refresh the tenant RBAC status.', scope: 'tenant'),
],
};
}
/**
* @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: $this->actionability() === 'non_actionable',
diagnosticCodeLabel: $this->value,
);
}
}

View File

@ -1,19 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Support\ReasonTranslation\Contracts;
use App\Support\ReasonTranslation\ReasonResolutionEnvelope;
interface TranslatesReasonCode
{
public function artifactKey(): string;
public function canTranslate(string $reasonCode): bool;
/**
* @param array<string, mixed> $context
*/
public function translate(string $reasonCode, string $surface = 'detail', array $context = []): ?ReasonResolutionEnvelope;
}

View File

@ -1,147 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Support\ReasonTranslation;
use App\Support\ReasonTranslation\Contracts\TranslatesReasonCode;
use App\Support\Ui\OperatorExplanation\TrustworthinessLevel;
use Illuminate\Support\Str;
final class FallbackReasonTranslator implements TranslatesReasonCode
{
public const string ARTIFACT_KEY = 'fallback_reason_code';
public function artifactKey(): string
{
return self::ARTIFACT_KEY;
}
public function canTranslate(string $reasonCode): bool
{
return trim($reasonCode) !== '';
}
/**
* @param array<string, mixed> $context
*/
public function translate(string $reasonCode, string $surface = 'detail', array $context = []): ?ReasonResolutionEnvelope
{
$normalizedCode = trim($reasonCode);
if ($normalizedCode === '') {
return null;
}
$actionability = $this->actionabilityFor($normalizedCode);
$nextSteps = $this->fallbackNextStepsFor($actionability);
return new ReasonResolutionEnvelope(
internalCode: $normalizedCode,
operatorLabel: $this->operatorLabelFor($normalizedCode),
shortExplanation: $this->shortExplanationFor($actionability),
actionability: $actionability,
nextSteps: $nextSteps,
showNoActionNeeded: $actionability === 'non_actionable',
diagnosticCodeLabel: $normalizedCode,
trustImpact: $this->trustImpactFor($actionability),
absencePattern: $this->absencePatternFor($normalizedCode, $actionability),
);
}
private function operatorLabelFor(string $reasonCode): string
{
return Str::headline(str_replace(['.', '-'], '_', $reasonCode));
}
private function actionabilityFor(string $reasonCode): string
{
$reasonCode = strtolower($reasonCode);
if (str_contains($reasonCode, 'timeout')
|| str_contains($reasonCode, 'throttle')
|| str_contains($reasonCode, 'rate')
|| str_contains($reasonCode, 'network')
|| str_contains($reasonCode, 'unreachable')
|| str_contains($reasonCode, 'transient')
|| str_contains($reasonCode, 'retry')
) {
return 'retryable_transient';
}
if (str_contains($reasonCode, 'missing')
|| str_contains($reasonCode, 'required')
|| str_contains($reasonCode, 'consent')
|| str_contains($reasonCode, 'stale')
|| str_contains($reasonCode, 'prerequisite')
|| str_contains($reasonCode, 'invalid')
) {
return 'prerequisite_missing';
}
if (str_contains($reasonCode, 'already_')
|| str_contains($reasonCode, 'not_applicable')
|| str_contains($reasonCode, 'no_action')
|| str_contains($reasonCode, 'info')
) {
return 'non_actionable';
}
return 'permanent_configuration';
}
private function shortExplanationFor(string $actionability): string
{
return match ($actionability) {
'retryable_transient' => 'TenantPilot recorded a transient dependency issue. Retry after the dependency recovers.',
'prerequisite_missing' => 'TenantPilot recorded a missing or invalid prerequisite for this workflow.',
'non_actionable' => 'TenantPilot recorded this state for visibility only. No operator action is required.',
default => 'TenantPilot recorded an access, scope, or configuration issue that needs review before retrying.',
};
}
/**
* @return array<int, NextStepOption>
*/
private function fallbackNextStepsFor(string $actionability): array
{
return match ($actionability) {
'retryable_transient' => [NextStepOption::instruction('Retry after the dependency recovers.')],
'prerequisite_missing' => [NextStepOption::instruction('Review the recorded prerequisite before retrying.')],
'non_actionable' => [],
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

@ -1,153 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Support\ReasonTranslation;
use InvalidArgumentException;
final readonly class NextStepOption
{
public function __construct(
public string $label,
public string $kind,
public ?string $destination = null,
public bool $authorizationRequired = false,
public string $scope = 'none',
) {
$label = trim($this->label);
$kind = trim($this->kind);
$scope = trim($this->scope);
if ($label === '') {
throw new InvalidArgumentException('Next-step labels must not be empty.');
}
if (! in_array($kind, ['link', 'instruction', 'diagnostic_only'], true)) {
throw new InvalidArgumentException('Unsupported next-step kind: '.$kind);
}
if (! in_array($scope, ['tenant', 'workspace', 'system', 'none'], true)) {
throw new InvalidArgumentException('Unsupported next-step scope: '.$scope);
}
if ($kind === 'link' && trim((string) $this->destination) === '') {
throw new InvalidArgumentException('Link next steps require a destination.');
}
}
public static function link(
string $label,
string $destination,
bool $authorizationRequired = true,
string $scope = 'tenant',
): self {
return new self(
label: $label,
kind: 'link',
destination: $destination,
authorizationRequired: $authorizationRequired,
scope: $scope,
);
}
public static function instruction(string $label, string $scope = 'none'): self
{
return new self(
label: $label,
kind: 'instruction',
scope: $scope,
);
}
public static function diagnosticOnly(string $label): self
{
return new self(
label: $label,
kind: 'diagnostic_only',
scope: 'none',
);
}
/**
* @param array<string, mixed> $data
*/
public static function fromArray(array $data): ?self
{
$label = is_string($data['label'] ?? null) ? trim((string) $data['label']) : '';
$kind = is_string($data['kind'] ?? null)
? trim((string) $data['kind'])
: ((is_string($data['url'] ?? null) || is_string($data['destination'] ?? null)) ? 'link' : 'instruction');
$destination = is_string($data['destination'] ?? null)
? trim((string) $data['destination'])
: (is_string($data['url'] ?? null) ? trim((string) $data['url']) : null);
$authorizationRequired = (bool) ($data['authorization_required'] ?? $data['authorizationRequired'] ?? false);
$scope = is_string($data['scope'] ?? null) ? trim((string) $data['scope']) : 'none';
if ($label === '') {
return null;
}
return new self(
label: $label,
kind: $kind,
destination: $destination !== '' ? $destination : null,
authorizationRequired: $authorizationRequired,
scope: $scope,
);
}
/**
* @param array<int, array<string, mixed>> $items
* @return array<int, self>
*/
public static function collect(array $items): array
{
$options = [];
foreach ($items as $item) {
if (! is_array($item)) {
continue;
}
$option = self::fromArray($item);
if ($option instanceof self) {
$options[] = $option;
}
}
return $options;
}
/**
* @return array{
* label: string,
* kind: string,
* destination: ?string,
* authorization_required: bool,
* scope: string
* }
*/
public function toArray(): array
{
return [
'label' => $this->label,
'kind' => $this->kind,
'destination' => $this->destination,
'authorization_required' => $this->authorizationRequired,
'scope' => $this->scope,
];
}
/**
* @return array{label: string, url: string}
*/
public function toLegacyArray(): array
{
return [
'label' => $this->label,
'url' => (string) $this->destination,
];
}
}

View File

@ -1,229 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Support\ReasonTranslation;
use App\Models\OperationRun;
use App\Models\ProviderConnection;
use App\Models\Tenant;
use App\Support\Operations\ExecutionDenialReasonCode;
use App\Support\Operations\LifecycleReconciliationReason;
use App\Support\Providers\ProviderReasonCodes;
use App\Support\Providers\ProviderReasonTranslator;
use App\Support\RbacReason;
use App\Support\Tenants\TenantOperabilityReasonCode;
final class ReasonPresenter
{
public const string GOVERNANCE_ARTIFACT_TRUTH_ARTIFACT = ReasonTranslator::GOVERNANCE_ARTIFACT_TRUTH_ARTIFACT;
public function __construct(
private readonly ReasonTranslator $reasonTranslator,
) {}
public function forOperationRun(OperationRun $run, string $surface = 'detail'): ?ReasonResolutionEnvelope
{
$context = is_array($run->context) ? $run->context : [];
$storedTranslation = $this->storedOperationRunTranslation($context);
if ($storedTranslation !== null) {
$storedEnvelope = ReasonResolutionEnvelope::fromArray($storedTranslation);
if ($storedEnvelope instanceof ReasonResolutionEnvelope) {
$nextSteps = $this->operationRunNextSteps($context);
if ($storedEnvelope->nextSteps === [] && $nextSteps !== []) {
return $storedEnvelope->withNextSteps($nextSteps);
}
return $storedEnvelope;
}
}
$contextReasonCode = data_get($context, 'execution_legitimacy.reason_code')
?? data_get($context, 'reason_code')
?? data_get($context, 'baseline_compare.reason_code');
if (is_string($contextReasonCode) && trim($contextReasonCode) !== '') {
return $this->translateOperationRunReason(trim($contextReasonCode), $surface, $context);
}
$failureReasonCode = data_get($run->failure_summary, '0.reason_code');
if (! is_string($failureReasonCode) || trim($failureReasonCode) === '') {
return null;
}
$failureReasonCode = trim($failureReasonCode);
if (! $this->isDirectlyTranslatableOperationReason($failureReasonCode)) {
return null;
}
$envelope = $this->translateOperationRunReason($failureReasonCode, $surface, $context);
if (! $envelope instanceof ReasonResolutionEnvelope) {
return null;
}
if ($envelope->nextSteps !== []) {
return $envelope;
}
$legacyNextSteps = $this->operationRunNextSteps($context);
return $legacyNextSteps !== [] ? $envelope->withNextSteps($legacyNextSteps) : $envelope;
}
/**
* @param array<string, mixed> $context
* @return array<string, mixed>|null
*/
private function storedOperationRunTranslation(array $context): ?array
{
$storedTranslation = $context['reason_translation'] ?? data_get($context, 'baseline_compare.reason_translation');
return is_array($storedTranslation) ? $storedTranslation : null;
}
/**
* @param array<string, mixed> $context
* @return array<int, NextStepOption>
*/
private function operationRunNextSteps(array $context): array
{
$nextSteps = $context['next_steps'] ?? data_get($context, 'baseline_compare.next_steps');
return is_array($nextSteps) ? NextStepOption::collect($nextSteps) : [];
}
/**
* @param array<string, mixed> $context
*/
private function translateOperationRunReason(
string $reasonCode,
string $surface,
array $context,
): ?ReasonResolutionEnvelope {
return $this->reasonTranslator->translate($reasonCode, surface: $surface, context: $context);
}
private function isDirectlyTranslatableOperationReason(string $reasonCode): bool
{
if ($reasonCode === ProviderReasonCodes::UnknownError) {
return false;
}
return ProviderReasonCodes::isKnown($reasonCode)
|| ExecutionDenialReasonCode::tryFrom($reasonCode) instanceof ExecutionDenialReasonCode
|| LifecycleReconciliationReason::tryFrom($reasonCode) instanceof LifecycleReconciliationReason
|| TenantOperabilityReasonCode::tryFrom($reasonCode) instanceof TenantOperabilityReasonCode
|| RbacReason::tryFrom($reasonCode) instanceof RbacReason;
}
public function forProviderReason(
Tenant $tenant,
string $reasonCode,
?ProviderConnection $connection = null,
string $surface = 'detail',
): ?ReasonResolutionEnvelope {
return $this->reasonTranslator->translate(
reasonCode: $reasonCode,
artifactKey: ProviderReasonTranslator::ARTIFACT_KEY,
surface: $surface,
context: [
'tenant' => $tenant,
'connection' => $connection,
],
);
}
public function forTenantOperabilityReason(
TenantOperabilityReasonCode|string|null $reasonCode,
string $surface = 'detail',
): ?ReasonResolutionEnvelope {
$normalizedCode = $reasonCode instanceof TenantOperabilityReasonCode ? $reasonCode->value : $reasonCode;
return $this->reasonTranslator->translate(
reasonCode: $normalizedCode,
artifactKey: ReasonTranslator::TENANT_OPERABILITY_ARTIFACT,
surface: $surface,
);
}
public function forRbacReason(RbacReason|string|null $reasonCode, string $surface = 'detail'): ?ReasonResolutionEnvelope
{
$normalizedCode = $reasonCode instanceof RbacReason ? $reasonCode->value : $reasonCode;
return $this->reasonTranslator->translate(
reasonCode: $normalizedCode,
artifactKey: ReasonTranslator::RBAC_ARTIFACT,
surface: $surface,
);
}
/**
* @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
{
return $envelope?->diagnosticCode();
}
public function primaryLabel(?ReasonResolutionEnvelope $envelope): ?string
{
return $envelope?->operatorLabel;
}
public function shortExplanation(?ReasonResolutionEnvelope $envelope): ?string
{
return $envelope?->shortExplanation;
}
public function dominantCauseLabel(?ReasonResolutionEnvelope $envelope): ?string
{
return $envelope?->operatorLabel;
}
public function dominantCauseExplanation(?ReasonResolutionEnvelope $envelope): ?string
{
return $envelope?->shortExplanation;
}
public function trustImpact(?ReasonResolutionEnvelope $envelope): ?string
{
return $envelope?->trustImpact;
}
public function absencePattern(?ReasonResolutionEnvelope $envelope): ?string
{
return $envelope?->absencePattern;
}
public function guidance(?ReasonResolutionEnvelope $envelope): ?string
{
return $envelope?->guidanceText();
}
/**
* @return array<int, string>
*/
public function bodyLines(?ReasonResolutionEnvelope $envelope, bool $includeGuidance = true): array
{
return $envelope?->toBodyLines($includeGuidance) ?? [];
}
}

View File

@ -1,234 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Support\ReasonTranslation;
use App\Support\Ui\OperatorExplanation\TrustworthinessLevel;
use InvalidArgumentException;
final readonly class ReasonResolutionEnvelope
{
/**
* @param array<int, NextStepOption> $nextSteps
*/
public function __construct(
public string $internalCode,
public string $operatorLabel,
public string $shortExplanation,
public string $actionability,
public array $nextSteps = [],
public bool $showNoActionNeeded = false,
public ?string $diagnosticCodeLabel = null,
public string $trustImpact = TrustworthinessLevel::LimitedConfidence->value,
public ?string $absencePattern = null,
) {
if (trim($this->internalCode) === '') {
throw new InvalidArgumentException('Reason envelopes must preserve an internal code.');
}
if (trim($this->operatorLabel) === '') {
throw new InvalidArgumentException('Reason envelopes require an operator label.');
}
if (trim($this->shortExplanation) === '') {
throw new InvalidArgumentException('Reason envelopes require a short explanation.');
}
if (! in_array($this->actionability, [
'retryable_transient',
'permanent_configuration',
'prerequisite_missing',
'non_actionable',
], true)) {
throw new InvalidArgumentException('Unsupported reason actionability: '.$this->actionability);
}
if (! in_array($this->trustImpact, array_map(
static fn (TrustworthinessLevel $level): string => $level->value,
TrustworthinessLevel::cases(),
), true)) {
throw new InvalidArgumentException('Unsupported reason trust impact: '.$this->trustImpact);
}
if ($this->absencePattern !== null && ! in_array($this->absencePattern, [
'none',
'true_no_result',
'missing_input',
'blocked_prerequisite',
'suppressed_output',
'unavailable',
], true)) {
throw new InvalidArgumentException('Unsupported reason absence pattern: '.$this->absencePattern);
}
foreach ($this->nextSteps as $nextStep) {
if (! $nextStep instanceof NextStepOption) {
throw new InvalidArgumentException('Reason envelopes only support NextStepOption instances.');
}
}
}
/**
* @param array<string, mixed> $data
*/
public static function fromArray(array $data): ?self
{
$internalCode = is_string($data['internal_code'] ?? null)
? trim((string) $data['internal_code'])
: (is_string($data['internalCode'] ?? null) ? trim((string) $data['internalCode']) : '');
$operatorLabel = is_string($data['operator_label'] ?? null)
? trim((string) $data['operator_label'])
: (is_string($data['operatorLabel'] ?? null) ? trim((string) $data['operatorLabel']) : '');
$shortExplanation = is_string($data['short_explanation'] ?? null)
? trim((string) $data['short_explanation'])
: (is_string($data['shortExplanation'] ?? null) ? trim((string) $data['shortExplanation']) : '');
$actionability = is_string($data['actionability'] ?? null) ? trim((string) $data['actionability']) : '';
$nextSteps = is_array($data['next_steps'] ?? null)
? NextStepOption::collect($data['next_steps'])
: (is_array($data['nextSteps'] ?? null) ? NextStepOption::collect($data['nextSteps']) : []);
$showNoActionNeeded = (bool) ($data['show_no_action_needed'] ?? $data['showNoActionNeeded'] ?? false);
$diagnosticCodeLabel = is_string($data['diagnostic_code_label'] ?? null)
? trim((string) $data['diagnostic_code_label'])
: (is_string($data['diagnosticCodeLabel'] ?? null) ? trim((string) $data['diagnosticCodeLabel']) : null);
$trustImpact = is_string($data['trust_impact'] ?? null)
? trim((string) $data['trust_impact'])
: (is_string($data['trustImpact'] ?? null) ? trim((string) $data['trustImpact']) : TrustworthinessLevel::LimitedConfidence->value);
$absencePattern = is_string($data['absence_pattern'] ?? null)
? trim((string) $data['absence_pattern'])
: (is_string($data['absencePattern'] ?? null) ? trim((string) $data['absencePattern']) : null);
if ($internalCode === '' || $operatorLabel === '' || $shortExplanation === '' || $actionability === '') {
return null;
}
return new self(
internalCode: $internalCode,
operatorLabel: $operatorLabel,
shortExplanation: $shortExplanation,
actionability: $actionability,
nextSteps: $nextSteps,
showNoActionNeeded: $showNoActionNeeded,
diagnosticCodeLabel: $diagnosticCodeLabel !== '' ? $diagnosticCodeLabel : null,
trustImpact: $trustImpact !== '' ? $trustImpact : TrustworthinessLevel::LimitedConfidence->value,
absencePattern: $absencePattern !== '' ? $absencePattern : null,
);
}
/**
* @param array<int, NextStepOption> $nextSteps
*/
public function withNextSteps(array $nextSteps): self
{
return new self(
internalCode: $this->internalCode,
operatorLabel: $this->operatorLabel,
shortExplanation: $this->shortExplanation,
actionability: $this->actionability,
nextSteps: $nextSteps,
showNoActionNeeded: $this->showNoActionNeeded,
diagnosticCodeLabel: $this->diagnosticCodeLabel,
trustImpact: $this->trustImpact,
absencePattern: $this->absencePattern,
);
}
public function firstNextStep(): ?NextStepOption
{
return $this->nextSteps[0] ?? null;
}
public function guidanceText(): ?string
{
$nextStep = $this->firstNextStep();
if ($nextStep instanceof NextStepOption) {
return 'Next step: '.rtrim($nextStep->label, ". \t\n\r\0\x0B").'.';
}
if ($this->showNoActionNeeded) {
return 'No action needed.';
}
return null;
}
/**
* @return array<int, string>
*/
public function toBodyLines(bool $includeGuidance = true): array
{
$lines = [
$this->operatorLabel,
$this->shortExplanation,
];
if ($includeGuidance) {
$guidance = $this->guidanceText();
if (is_string($guidance) && $guidance !== '') {
$lines[] = $guidance;
}
}
return array_values(array_filter($lines, static fn (?string $line): bool => is_string($line) && trim($line) !== ''));
}
public function diagnosticCode(): string
{
return $this->diagnosticCodeLabel !== null && trim($this->diagnosticCodeLabel) !== ''
? $this->diagnosticCodeLabel
: $this->internalCode;
}
/**
* @return array<int, array{label: string, url: string}>
*/
public function toLegacyNextSteps(): array
{
return array_values(array_map(
static fn (NextStepOption $nextStep): array => $nextStep->toLegacyArray(),
array_filter(
$this->nextSteps,
static fn (NextStepOption $nextStep): bool => $nextStep->kind === 'link' && $nextStep->destination !== null,
),
));
}
/**
* @return array{
* internal_code: string,
* operator_label: string,
* short_explanation: string,
* actionability: string,
* next_steps: array<int, array{
* label: string,
* kind: string,
* destination: ?string,
* authorization_required: bool,
* scope: string
* }>,
* show_no_action_needed: bool,
* diagnostic_code_label: string
* trust_impact: string,
* absence_pattern: ?string
* }
*/
public function toArray(): array
{
return [
'internal_code' => $this->internalCode,
'operator_label' => $this->operatorLabel,
'short_explanation' => $this->shortExplanation,
'actionability' => $this->actionability,
'next_steps' => array_map(
static fn (NextStepOption $nextStep): array => $nextStep->toArray(),
$this->nextSteps,
),
'show_no_action_needed' => $this->showNoActionNeeded,
'diagnostic_code_label' => $this->diagnosticCode(),
'trust_impact' => $this->trustImpact,
'absence_pattern' => $this->absencePattern,
];
}
}

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