Compare commits
3 Commits
158-artifa
...
dev
| Author | SHA1 | Date | |
|---|---|---|---|
| 845d21db6d | |||
| 8426741068 | |||
| e7c9b4b853 |
6
.github/agents/copilot-instructions.md
vendored
6
.github/agents/copilot-instructions.md
vendored
@ -100,6 +100,8 @@ ## Active Technologies
|
||||
- PostgreSQL-backed existing records such as `operation_runs`, tenant governance records, onboarding workflow state, and provider connection state; no new business-domain table is required for the first slice (157-reason-code-translation)
|
||||
- PHP 8.4.15 + Laravel 12, Filament v5, Livewire v4, Pest v4, existing `BadgeCatalog` / `BadgeRenderer` / `OperatorOutcomeTaxonomy`, `ReasonPresenter`, `OperationRunService`, `TenantReviewReadinessGate`, existing baseline/evidence/review/review-pack resources and canonical pages (158-artifact-truth-semantics)
|
||||
- PostgreSQL with existing JSONB-backed `summary`, `summary_jsonb`, and `context` payloads on baseline snapshots, evidence snapshots, tenant reviews, review packs, and operation runs; no new primary storage required for the first slice (158-artifact-truth-semantics)
|
||||
- PHP 8.4.15 + Laravel 12, Filament 5, Livewire 4, Pest 4, Laravel queue workers, existing `OperationRunService`, `TrackOperationRun`, `OperationUxPresenter`, `ReasonPresenter`, `BadgeCatalog` domain badges, and current Operations Monitoring pages (160-operation-lifecycle-guarantees)
|
||||
- PostgreSQL for `operation_runs`, `jobs`, and `failed_jobs`; JSONB-backed `context`, `summary_counts`, and `failure_summary`; configuration in `config/queue.php` and `config/tenantpilot.php` (160-operation-lifecycle-guarantees)
|
||||
|
||||
- PHP 8.4.15 (feat/005-bulk-operations)
|
||||
|
||||
@ -119,8 +121,8 @@ ## Code Style
|
||||
PHP 8.4.15: Follow standard conventions
|
||||
|
||||
## Recent Changes
|
||||
- 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
|
||||
- 159-baseline-snapshot-truth: Added PHP 8.4 + Laravel 12, Filament v5, Livewire v4
|
||||
- 158-artifact-truth-semantics: Added PHP 8.4.15 + Laravel 12, Filament v5, Livewire v4, Pest v4, existing `BadgeCatalog` / `BadgeRenderer` / `OperatorOutcomeTaxonomy`, `ReasonPresenter`, `OperationRunService`, `TenantReviewReadinessGate`, existing baseline/evidence/review/review-pack resources and canonical pages
|
||||
- 157-reason-code-translation: Added PHP 8.4.15 + Laravel 12, Filament v5, Livewire v4, PostgreSQL, Laravel Sail, Pest v4
|
||||
- 156-operator-outcome-taxonomy: Added PHP 8.4.15 + Laravel 12, Filament v5, Livewire v4, PostgreSQL, Laravel Sail, Pest v4
|
||||
<!-- MANUAL ADDITIONS START -->
|
||||
<!-- MANUAL ADDITIONS END -->
|
||||
|
||||
@ -6,6 +6,7 @@
|
||||
use App\Models\OperationRun;
|
||||
use App\Models\Tenant;
|
||||
use App\Services\OperationRunService;
|
||||
use App\Services\Operations\OperationLifecycleReconciler;
|
||||
use App\Support\OperationRunOutcome;
|
||||
use Illuminate\Console\Command;
|
||||
|
||||
@ -18,8 +19,10 @@ class TenantpilotReconcileBackupScheduleOperationRuns extends Command
|
||||
|
||||
protected $description = 'Reconcile stuck backup schedule OperationRuns without legacy run-table lookups.';
|
||||
|
||||
public function handle(OperationRunService $operationRunService): int
|
||||
{
|
||||
public function handle(
|
||||
OperationRunService $operationRunService,
|
||||
OperationLifecycleReconciler $operationLifecycleReconciler,
|
||||
): int {
|
||||
$tenantIdentifiers = array_values(array_filter((array) $this->option('tenant')));
|
||||
$olderThanMinutes = max(0, (int) $this->option('older-than'));
|
||||
$dryRun = (bool) $this->option('dry-run');
|
||||
@ -96,31 +99,9 @@ public function handle(OperationRunService $operationRunService): int
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($operationRun->status === 'queued' && $operationRunService->isStaleQueuedRun($operationRun, max(1, $olderThanMinutes))) {
|
||||
if (! $dryRun) {
|
||||
$operationRunService->failStaleQueuedRun($operationRun, 'Backup schedule run was queued but never started.');
|
||||
}
|
||||
|
||||
$reconciled++;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($operationRun->status === 'running') {
|
||||
if (! $dryRun) {
|
||||
$operationRunService->updateRun(
|
||||
$operationRun,
|
||||
status: 'completed',
|
||||
outcome: OperationRunOutcome::Failed->value,
|
||||
failures: [
|
||||
[
|
||||
'code' => 'backup_schedule.stalled',
|
||||
'message' => 'Backup schedule run exceeded reconciliation timeout and was marked failed.',
|
||||
],
|
||||
],
|
||||
);
|
||||
}
|
||||
$change = $operationLifecycleReconciler->reconcileRun($operationRun, $dryRun);
|
||||
|
||||
if ($change !== null) {
|
||||
$reconciled++;
|
||||
|
||||
continue;
|
||||
|
||||
105
app/Console/Commands/TenantpilotReconcileOperationRuns.php
Normal file
105
app/Console/Commands/TenantpilotReconcileOperationRuns.php
Normal file
@ -0,0 +1,105 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Models\Tenant;
|
||||
use App\Services\Operations\OperationLifecycleReconciler;
|
||||
use App\Support\Operations\OperationLifecyclePolicy;
|
||||
use Illuminate\Console\Command;
|
||||
|
||||
class TenantpilotReconcileOperationRuns extends Command
|
||||
{
|
||||
protected $signature = 'tenantpilot:operation-runs:reconcile
|
||||
{--type=* : Limit reconciliation to one or more covered operation types}
|
||||
{--tenant=* : Limit reconciliation to tenant_id or tenant external_id}
|
||||
{--workspace=* : Limit reconciliation to workspace ids}
|
||||
{--limit=100 : Maximum number of active runs to inspect}
|
||||
{--dry-run : Report the changes without writing them}';
|
||||
|
||||
protected $description = 'Reconcile stale covered operation runs back to deterministic terminal truth.';
|
||||
|
||||
public function handle(
|
||||
OperationLifecycleReconciler $reconciler,
|
||||
OperationLifecyclePolicy $policy,
|
||||
): int {
|
||||
$types = array_values(array_filter(
|
||||
(array) $this->option('type'),
|
||||
static fn (mixed $type): bool => is_string($type) && trim($type) !== '',
|
||||
));
|
||||
$workspaceIds = array_values(array_filter(
|
||||
array_map(
|
||||
static fn (mixed $workspaceId): int => is_numeric($workspaceId) ? (int) $workspaceId : 0,
|
||||
(array) $this->option('workspace'),
|
||||
),
|
||||
static fn (int $workspaceId): bool => $workspaceId > 0,
|
||||
));
|
||||
$tenantIds = $this->resolveTenantIds(array_values(array_filter((array) $this->option('tenant'))));
|
||||
$dryRun = (bool) $this->option('dry-run');
|
||||
|
||||
if ($types === []) {
|
||||
$types = $policy->coveredTypeNames();
|
||||
}
|
||||
|
||||
$result = $reconciler->reconcile([
|
||||
'types' => $types,
|
||||
'tenant_ids' => $tenantIds,
|
||||
'workspace_ids' => $workspaceIds,
|
||||
'limit' => max(1, (int) $this->option('limit')),
|
||||
'dry_run' => $dryRun,
|
||||
]);
|
||||
|
||||
$rows = collect($result['changes'] ?? [])
|
||||
->map(static function (array $change): array {
|
||||
return [
|
||||
'Run' => (string) ($change['operation_run_id'] ?? '—'),
|
||||
'Type' => (string) ($change['type'] ?? '—'),
|
||||
'Reason' => (string) ($change['reason_code'] ?? '—'),
|
||||
'Applied' => (($change['applied'] ?? false) === true) ? 'yes' : 'no',
|
||||
];
|
||||
})
|
||||
->values()
|
||||
->all();
|
||||
|
||||
if ($rows !== []) {
|
||||
$this->table(['Run', 'Type', 'Reason', 'Applied'], $rows);
|
||||
}
|
||||
|
||||
$this->info(sprintf(
|
||||
'Inspected %d run(s); reconciled %d; skipped %d.',
|
||||
(int) ($result['candidates'] ?? 0),
|
||||
(int) ($result['reconciled'] ?? 0),
|
||||
(int) ($result['skipped'] ?? 0),
|
||||
));
|
||||
|
||||
if ($dryRun) {
|
||||
$this->comment('Dry-run: no changes written.');
|
||||
}
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, string> $tenantIdentifiers
|
||||
* @return array<int, int>
|
||||
*/
|
||||
private function resolveTenantIds(array $tenantIdentifiers): array
|
||||
{
|
||||
if ($tenantIdentifiers === []) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$tenantIds = [];
|
||||
|
||||
foreach ($tenantIdentifiers as $identifier) {
|
||||
$tenant = Tenant::query()->forTenant($identifier)->first();
|
||||
|
||||
if ($tenant instanceof Tenant) {
|
||||
$tenantIds[] = (int) $tenant->getKey();
|
||||
}
|
||||
}
|
||||
|
||||
return array_values(array_unique($tenantIds));
|
||||
}
|
||||
}
|
||||
@ -307,9 +307,22 @@ private function compareNowAction(): Action
|
||||
$result = $service->startCompare($tenant, $user);
|
||||
|
||||
if (! ($result['ok'] ?? false)) {
|
||||
$reasonCode = is_string($result['reason_code'] ?? null) ? (string) $result['reason_code'] : 'unknown';
|
||||
|
||||
$message = match ($reasonCode) {
|
||||
\App\Support\Baselines\BaselineReasonCodes::COMPARE_ROLLOUT_DISABLED => 'Full-content baseline compare is currently disabled for controlled rollout.',
|
||||
\App\Support\Baselines\BaselineReasonCodes::COMPARE_PROFILE_NOT_ACTIVE => 'The assigned baseline profile is not active.',
|
||||
\App\Support\Baselines\BaselineReasonCodes::COMPARE_NO_ACTIVE_SNAPSHOT,
|
||||
\App\Support\Baselines\BaselineReasonCodes::COMPARE_NO_CONSUMABLE_SNAPSHOT => 'No complete baseline snapshot is currently available for compare.',
|
||||
\App\Support\Baselines\BaselineReasonCodes::COMPARE_SNAPSHOT_BUILDING => 'The latest baseline capture is still building. Compare will be available after it completes.',
|
||||
\App\Support\Baselines\BaselineReasonCodes::COMPARE_SNAPSHOT_INCOMPLETE => 'The latest baseline capture is incomplete. Capture a new baseline before comparing.',
|
||||
\App\Support\Baselines\BaselineReasonCodes::COMPARE_SNAPSHOT_SUPERSEDED => 'A newer complete baseline snapshot is current. Compare uses the latest complete baseline only.',
|
||||
default => 'Reason: '.$reasonCode,
|
||||
};
|
||||
|
||||
Notification::make()
|
||||
->title('Cannot start comparison')
|
||||
->body('Reason: '.($result['reason_code'] ?? 'unknown'))
|
||||
->body($message)
|
||||
->danger()
|
||||
->send();
|
||||
|
||||
|
||||
@ -34,6 +34,7 @@
|
||||
use Filament\Tables\Contracts\HasTable;
|
||||
use Filament\Tables\Filters\SelectFilter;
|
||||
use Filament\Tables\Table;
|
||||
use Illuminate\Contracts\View\View;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
||||
use UnitEnum;
|
||||
@ -42,8 +43,6 @@ class AuditLog extends Page implements HasTable
|
||||
{
|
||||
use InteractsWithTable;
|
||||
|
||||
public ?int $selectedAuditLogId = null;
|
||||
|
||||
protected static bool $isDiscovered = false;
|
||||
|
||||
protected static bool $shouldRegisterNavigation = false;
|
||||
@ -82,14 +81,15 @@ public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
|
||||
public function mount(): void
|
||||
{
|
||||
$this->authorizePageAccess();
|
||||
$this->selectedAuditLogId = is_numeric(request()->query('event')) ? (int) request()->query('event') : null;
|
||||
$requestedEventId = is_numeric(request()->query('event')) ? (int) request()->query('event') : null;
|
||||
|
||||
app(CanonicalAdminTenantFilterState::class)->sync($this->getTableFiltersSessionKey(), request: request());
|
||||
|
||||
$this->mountInteractsWithTable();
|
||||
|
||||
if ($this->selectedAuditLogId !== null) {
|
||||
$this->selectedAuditLog();
|
||||
if ($requestedEventId !== null) {
|
||||
$this->resolveAuditLog($requestedEventId);
|
||||
$this->mountTableAction('inspect', (string) $requestedEventId);
|
||||
}
|
||||
}
|
||||
|
||||
@ -98,31 +98,10 @@ public function mount(): void
|
||||
*/
|
||||
protected function getHeaderActions(): array
|
||||
{
|
||||
$actions = app(OperateHubShell::class)->headerActions(
|
||||
return app(OperateHubShell::class)->headerActions(
|
||||
scopeActionName: 'operate_hub_scope_audit_log',
|
||||
returnActionName: 'operate_hub_return_audit_log',
|
||||
);
|
||||
|
||||
if ($this->selectedAuditLog() instanceof AuditLogModel) {
|
||||
$actions[] = Action::make('clear_selected_audit_event')
|
||||
->label('Close details')
|
||||
->color('gray')
|
||||
->action(function (): void {
|
||||
$this->clearSelectedAuditLog();
|
||||
});
|
||||
|
||||
$relatedLink = $this->selectedAuditLink();
|
||||
|
||||
if (is_array($relatedLink)) {
|
||||
$actions[] = Action::make('open_selected_audit_target')
|
||||
->label($relatedLink['label'])
|
||||
->icon('heroicon-o-arrow-top-right-on-square')
|
||||
->color('gray')
|
||||
->url($relatedLink['url']);
|
||||
}
|
||||
}
|
||||
|
||||
return $actions;
|
||||
}
|
||||
|
||||
public function table(Table $table): Table
|
||||
@ -195,9 +174,16 @@ public function table(Table $table): Table
|
||||
->label('Inspect event')
|
||||
->icon('heroicon-o-eye')
|
||||
->color('gray')
|
||||
->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')
|
||||
@ -209,48 +195,11 @@ public function table(Table $table): Table
|
||||
->icon('heroicon-o-x-mark')
|
||||
->color('gray')
|
||||
->action(function (): void {
|
||||
$this->selectedAuditLogId = null;
|
||||
$this->resetTable();
|
||||
}),
|
||||
]);
|
||||
}
|
||||
|
||||
public function clearSelectedAuditLog(): void
|
||||
{
|
||||
$this->selectedAuditLogId = null;
|
||||
}
|
||||
|
||||
public function selectedAuditLog(): ?AuditLogModel
|
||||
{
|
||||
if (! is_numeric($this->selectedAuditLogId)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$record = $this->auditBaseQuery()
|
||||
->whereKey((int) $this->selectedAuditLogId)
|
||||
->first();
|
||||
|
||||
if (! $record instanceof AuditLogModel) {
|
||||
throw new NotFoundHttpException;
|
||||
}
|
||||
|
||||
return $record;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{label: string, url: string}|null
|
||||
*/
|
||||
public function selectedAuditLink(): ?array
|
||||
{
|
||||
$record = $this->selectedAuditLog();
|
||||
|
||||
if (! $record instanceof AuditLogModel) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return app(RelatedNavigationResolver::class)->auditTargetLink($record);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, Tenant>
|
||||
*/
|
||||
@ -323,6 +272,27 @@ 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;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{label: string, url: string}|null
|
||||
*/
|
||||
private function auditTargetLink(AuditLogModel $record): ?array
|
||||
{
|
||||
return app(RelatedNavigationResolver::class)->auditTargetLink($record);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, string>
|
||||
*/
|
||||
|
||||
@ -13,6 +13,7 @@
|
||||
use App\Support\OperateHub\OperateHubShell;
|
||||
use App\Support\OperationRunOutcome;
|
||||
use App\Support\OperationRunStatus;
|
||||
use App\Support\Operations\OperationLifecyclePolicy;
|
||||
use App\Support\Workspaces\WorkspaceContext;
|
||||
use BackedEnum;
|
||||
use Filament\Actions\Action;
|
||||
@ -165,6 +166,68 @@ public function table(Table $table): Table
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{likely_stale:int,reconciled:int}
|
||||
*/
|
||||
public function lifecycleVisibilitySummary(): array
|
||||
{
|
||||
$baseQuery = $this->scopedSummaryQuery();
|
||||
|
||||
if (! $baseQuery instanceof Builder) {
|
||||
return [
|
||||
'likely_stale' => 0,
|
||||
'reconciled' => 0,
|
||||
];
|
||||
}
|
||||
|
||||
$reconciled = (clone $baseQuery)
|
||||
->whereNotNull('context->reconciliation->reconciled_at')
|
||||
->count();
|
||||
|
||||
$policy = app(OperationLifecyclePolicy::class);
|
||||
$likelyStale = (clone $baseQuery)
|
||||
->whereIn('status', [
|
||||
OperationRunStatus::Queued->value,
|
||||
OperationRunStatus::Running->value,
|
||||
])
|
||||
->where(function (Builder $query) use ($policy): void {
|
||||
foreach ($policy->coveredTypeNames() as $type) {
|
||||
$query->orWhere(function (Builder $typeQuery) use ($policy, $type): void {
|
||||
$typeQuery
|
||||
->where('type', $type)
|
||||
->where(function (Builder $stateQuery) use ($policy, $type): void {
|
||||
$stateQuery
|
||||
->where(function (Builder $queuedQuery) use ($policy, $type): void {
|
||||
$queuedQuery
|
||||
->where('status', OperationRunStatus::Queued->value)
|
||||
->whereNull('started_at')
|
||||
->where('created_at', '<=', now()->subSeconds($policy->queuedStaleAfterSeconds($type)));
|
||||
})
|
||||
->orWhere(function (Builder $runningQuery) use ($policy, $type): void {
|
||||
$runningQuery
|
||||
->where('status', OperationRunStatus::Running->value)
|
||||
->where(function (Builder $startedAtQuery) use ($policy, $type): void {
|
||||
$startedAtQuery
|
||||
->where('started_at', '<=', now()->subSeconds($policy->runningStaleAfterSeconds($type)))
|
||||
->orWhere(function (Builder $fallbackQuery) use ($policy, $type): void {
|
||||
$fallbackQuery
|
||||
->whereNull('started_at')
|
||||
->where('created_at', '<=', now()->subSeconds($policy->runningStaleAfterSeconds($type)));
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
})
|
||||
->count();
|
||||
|
||||
return [
|
||||
'likely_stale' => $likelyStale,
|
||||
'reconciled' => $reconciled,
|
||||
];
|
||||
}
|
||||
|
||||
private function applyActiveTab(Builder $query): Builder
|
||||
{
|
||||
return match ($this->activeTab) {
|
||||
@ -187,4 +250,26 @@ private function applyActiveTab(Builder $query): Builder
|
||||
default => $query,
|
||||
};
|
||||
}
|
||||
|
||||
private function scopedSummaryQuery(): ?Builder
|
||||
{
|
||||
$workspaceId = app(WorkspaceContext::class)->currentWorkspaceId(request());
|
||||
|
||||
if (! $workspaceId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$tenantFilter = data_get($this->tableFilters, 'tenant_id.value');
|
||||
|
||||
if (! is_numeric($tenantFilter)) {
|
||||
$tenantFilter = data_get(session()->get($this->getTableFiltersSessionKey(), []), 'tenant_id.value');
|
||||
}
|
||||
|
||||
return OperationRun::query()
|
||||
->where('workspace_id', (int) $workspaceId)
|
||||
->when(
|
||||
is_numeric($tenantFilter),
|
||||
fn (Builder $query): Builder => $query->where('tenant_id', (int) $tenantFilter),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -183,6 +183,40 @@ public function blockedExecutionBanner(): ?array
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{tone: string, title: string, body: string}|null
|
||||
*/
|
||||
public function lifecycleBanner(): ?array
|
||||
{
|
||||
if (! isset($this->run)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$attention = OperationUxPresenter::lifecycleAttentionSummary($this->run);
|
||||
|
||||
if ($attention === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$detail = OperationUxPresenter::surfaceFailureDetail($this->run) ?? 'Lifecycle truth needs operator review.';
|
||||
$guidance = OperationUxPresenter::surfaceGuidance($this->run);
|
||||
$body = $guidance !== null ? $detail.' '.$guidance : $detail;
|
||||
|
||||
return match ($this->run->freshnessState()->value) {
|
||||
'likely_stale' => [
|
||||
'tone' => 'amber',
|
||||
'title' => 'Likely stale run',
|
||||
'body' => $body,
|
||||
],
|
||||
'reconciled_failed' => [
|
||||
'tone' => 'rose',
|
||||
'title' => 'Automatically reconciled',
|
||||
'body' => $body,
|
||||
],
|
||||
default => null,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{tone: string, title: string, body: string}|null
|
||||
*/
|
||||
|
||||
@ -6,19 +6,28 @@
|
||||
|
||||
use App\Filament\Resources\BaselineProfileResource\Pages;
|
||||
use App\Models\BaselineProfile;
|
||||
use App\Models\BaselineSnapshot;
|
||||
use App\Models\BaselineTenantAssignment;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use App\Models\Workspace;
|
||||
use App\Services\Audit\WorkspaceAuditLogger;
|
||||
use App\Services\Auth\CapabilityResolver;
|
||||
use App\Services\Baselines\BaselineSnapshotTruthResolver;
|
||||
use App\Support\Audit\AuditActionId;
|
||||
use App\Support\Auth\Capabilities;
|
||||
use App\Support\Badges\BadgeCatalog;
|
||||
use App\Support\Badges\BadgeDomain;
|
||||
use App\Support\Badges\BadgeRenderer;
|
||||
use App\Support\Baselines\BaselineCaptureMode;
|
||||
use App\Support\Baselines\BaselineFullContentRolloutGate;
|
||||
use App\Support\Baselines\BaselineProfileStatus;
|
||||
use App\Support\Baselines\BaselineReasonCodes;
|
||||
use App\Support\Filament\FilterOptionCatalog;
|
||||
use App\Support\Inventory\InventoryPolicyTypeMeta;
|
||||
use App\Support\Rbac\WorkspaceUiEnforcement;
|
||||
use App\Support\ReasonTranslation\ReasonPresenter;
|
||||
use App\Support\ReasonTranslation\ReasonResolutionEnvelope;
|
||||
use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration;
|
||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceInspectAffordance;
|
||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile;
|
||||
@ -288,15 +297,32 @@ public static function infolist(Schema $schema): Schema
|
||||
->placeholder('None'),
|
||||
])
|
||||
->columnSpanFull(),
|
||||
Section::make('Baseline truth')
|
||||
->schema([
|
||||
TextEntry::make('current_snapshot_truth')
|
||||
->label('Current snapshot')
|
||||
->state(fn (BaselineProfile $record): string => self::currentSnapshotLabel($record)),
|
||||
TextEntry::make('latest_attempted_snapshot_truth')
|
||||
->label('Latest attempt')
|
||||
->state(fn (BaselineProfile $record): string => self::latestAttemptedSnapshotLabel($record)),
|
||||
TextEntry::make('compare_readiness')
|
||||
->label('Compare readiness')
|
||||
->badge()
|
||||
->state(fn (BaselineProfile $record): string => self::compareReadinessLabel($record))
|
||||
->color(fn (BaselineProfile $record): string => self::compareReadinessColor($record))
|
||||
->icon(fn (BaselineProfile $record): ?string => self::compareReadinessIcon($record)),
|
||||
TextEntry::make('baseline_next_step')
|
||||
->label('Next step')
|
||||
->state(fn (BaselineProfile $record): string => self::profileNextStep($record))
|
||||
->columnSpanFull(),
|
||||
])
|
||||
->columns(2)
|
||||
->columnSpanFull(),
|
||||
Section::make('Metadata')
|
||||
->schema([
|
||||
TextEntry::make('createdByUser.name')
|
||||
->label('Created by')
|
||||
->placeholder('—'),
|
||||
TextEntry::make('activeSnapshot.captured_at')
|
||||
->label('Last snapshot')
|
||||
->dateTime()
|
||||
->placeholder('No snapshot yet'),
|
||||
TextEntry::make('created_at')
|
||||
->dateTime(),
|
||||
TextEntry::make('updated_at')
|
||||
@ -355,10 +381,27 @@ public static function table(Table $table): Table
|
||||
TextColumn::make('tenant_assignments_count')
|
||||
->label('Assigned tenants')
|
||||
->counts('tenantAssignments'),
|
||||
TextColumn::make('activeSnapshot.captured_at')
|
||||
->label('Last snapshot')
|
||||
->dateTime()
|
||||
->placeholder('No snapshot'),
|
||||
TextColumn::make('current_snapshot_truth')
|
||||
->label('Current snapshot')
|
||||
->getStateUsing(fn (BaselineProfile $record): string => self::currentSnapshotLabel($record))
|
||||
->description(fn (BaselineProfile $record): ?string => self::currentSnapshotDescription($record))
|
||||
->wrap(),
|
||||
TextColumn::make('latest_attempted_snapshot_truth')
|
||||
->label('Latest attempt')
|
||||
->getStateUsing(fn (BaselineProfile $record): string => self::latestAttemptedSnapshotLabel($record))
|
||||
->description(fn (BaselineProfile $record): ?string => self::latestAttemptedSnapshotDescription($record))
|
||||
->wrap(),
|
||||
TextColumn::make('compare_readiness')
|
||||
->label('Compare readiness')
|
||||
->badge()
|
||||
->getStateUsing(fn (BaselineProfile $record): string => self::compareReadinessLabel($record))
|
||||
->color(fn (BaselineProfile $record): string => self::compareReadinessColor($record))
|
||||
->icon(fn (BaselineProfile $record): ?string => self::compareReadinessIcon($record))
|
||||
->wrap(),
|
||||
TextColumn::make('baseline_next_step')
|
||||
->label('Next step')
|
||||
->getStateUsing(fn (BaselineProfile $record): string => self::profileNextStep($record))
|
||||
->wrap(),
|
||||
TextColumn::make('created_at')
|
||||
->dateTime()
|
||||
->sortable()
|
||||
@ -545,4 +588,167 @@ private static function archiveTableAction(?Workspace $workspace): Action
|
||||
|
||||
return $action;
|
||||
}
|
||||
|
||||
private static function currentSnapshotLabel(BaselineProfile $profile): string
|
||||
{
|
||||
$snapshot = self::effectiveSnapshot($profile);
|
||||
|
||||
if (! $snapshot instanceof BaselineSnapshot) {
|
||||
return 'No complete snapshot';
|
||||
}
|
||||
|
||||
return self::snapshotReference($snapshot);
|
||||
}
|
||||
|
||||
private static function currentSnapshotDescription(BaselineProfile $profile): ?string
|
||||
{
|
||||
$snapshot = self::effectiveSnapshot($profile);
|
||||
|
||||
if (! $snapshot instanceof BaselineSnapshot) {
|
||||
return self::compareAvailabilityEnvelope($profile)?->shortExplanation;
|
||||
}
|
||||
|
||||
return $snapshot->captured_at?->toDayDateTimeString();
|
||||
}
|
||||
|
||||
private static function latestAttemptedSnapshotLabel(BaselineProfile $profile): string
|
||||
{
|
||||
$latestAttempt = self::latestAttemptedSnapshot($profile);
|
||||
|
||||
if (! $latestAttempt instanceof BaselineSnapshot) {
|
||||
return 'No capture attempts yet';
|
||||
}
|
||||
|
||||
$effectiveSnapshot = self::effectiveSnapshot($profile);
|
||||
|
||||
if ($effectiveSnapshot instanceof BaselineSnapshot && (int) $effectiveSnapshot->getKey() === (int) $latestAttempt->getKey()) {
|
||||
return 'Matches current snapshot';
|
||||
}
|
||||
|
||||
return self::snapshotReference($latestAttempt);
|
||||
}
|
||||
|
||||
private static function latestAttemptedSnapshotDescription(BaselineProfile $profile): ?string
|
||||
{
|
||||
$latestAttempt = self::latestAttemptedSnapshot($profile);
|
||||
|
||||
if (! $latestAttempt instanceof BaselineSnapshot) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$effectiveSnapshot = self::effectiveSnapshot($profile);
|
||||
|
||||
if ($effectiveSnapshot instanceof BaselineSnapshot && (int) $effectiveSnapshot->getKey() === (int) $latestAttempt->getKey()) {
|
||||
return 'No newer attempt is pending.';
|
||||
}
|
||||
|
||||
return $latestAttempt->captured_at?->toDayDateTimeString();
|
||||
}
|
||||
|
||||
private static function compareReadinessLabel(BaselineProfile $profile): string
|
||||
{
|
||||
return self::compareAvailabilityEnvelope($profile)?->operatorLabel ?? 'Ready';
|
||||
}
|
||||
|
||||
private static function compareReadinessColor(BaselineProfile $profile): string
|
||||
{
|
||||
return match (self::compareAvailabilityReason($profile)) {
|
||||
null => 'success',
|
||||
BaselineReasonCodes::COMPARE_PROFILE_NOT_ACTIVE => 'gray',
|
||||
default => 'warning',
|
||||
};
|
||||
}
|
||||
|
||||
private static function compareReadinessIcon(BaselineProfile $profile): ?string
|
||||
{
|
||||
return match (self::compareAvailabilityReason($profile)) {
|
||||
null => 'heroicon-m-check-badge',
|
||||
BaselineReasonCodes::COMPARE_PROFILE_NOT_ACTIVE => 'heroicon-m-pause-circle',
|
||||
default => 'heroicon-m-exclamation-triangle',
|
||||
};
|
||||
}
|
||||
|
||||
private static function profileNextStep(BaselineProfile $profile): string
|
||||
{
|
||||
return self::compareAvailabilityEnvelope($profile)?->guidanceText() ?? 'No action needed.';
|
||||
}
|
||||
|
||||
private static function effectiveSnapshot(BaselineProfile $profile): ?BaselineSnapshot
|
||||
{
|
||||
return app(BaselineSnapshotTruthResolver::class)->resolveEffectiveSnapshot($profile);
|
||||
}
|
||||
|
||||
private static function latestAttemptedSnapshot(BaselineProfile $profile): ?BaselineSnapshot
|
||||
{
|
||||
return app(BaselineSnapshotTruthResolver::class)->resolveLatestAttemptedSnapshot($profile);
|
||||
}
|
||||
|
||||
private static function compareAvailabilityReason(BaselineProfile $profile): ?string
|
||||
{
|
||||
$status = $profile->status instanceof BaselineProfileStatus
|
||||
? $profile->status
|
||||
: BaselineProfileStatus::tryFrom((string) $profile->status);
|
||||
|
||||
if ($status !== BaselineProfileStatus::Active) {
|
||||
return BaselineReasonCodes::COMPARE_PROFILE_NOT_ACTIVE;
|
||||
}
|
||||
|
||||
$resolution = app(BaselineSnapshotTruthResolver::class)->resolveCompareSnapshot($profile);
|
||||
$reasonCode = $resolution['reason_code'] ?? null;
|
||||
|
||||
if (is_string($reasonCode) && trim($reasonCode) !== '') {
|
||||
return trim($reasonCode);
|
||||
}
|
||||
|
||||
if (! self::hasEligibleCompareTarget($profile)) {
|
||||
return BaselineReasonCodes::COMPARE_NO_ELIGIBLE_TARGET;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static function compareAvailabilityEnvelope(BaselineProfile $profile): ?ReasonResolutionEnvelope
|
||||
{
|
||||
$reasonCode = self::compareAvailabilityReason($profile);
|
||||
|
||||
if (! is_string($reasonCode)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return app(ReasonPresenter::class)->forArtifactTruth($reasonCode, 'artifact_truth');
|
||||
}
|
||||
|
||||
private static function snapshotReference(BaselineSnapshot $snapshot): string
|
||||
{
|
||||
$lifecycleLabel = BadgeCatalog::spec(BadgeDomain::BaselineSnapshotLifecycle, $snapshot->lifecycleState()->value)->label;
|
||||
|
||||
return sprintf('Snapshot #%d (%s)', (int) $snapshot->getKey(), $lifecycleLabel);
|
||||
}
|
||||
|
||||
private static function hasEligibleCompareTarget(BaselineProfile $profile): bool
|
||||
{
|
||||
$user = auth()->user();
|
||||
|
||||
if (! $user instanceof User) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$tenantIds = BaselineTenantAssignment::query()
|
||||
->where('workspace_id', (int) $profile->workspace_id)
|
||||
->where('baseline_profile_id', (int) $profile->getKey())
|
||||
->pluck('tenant_id')
|
||||
->all();
|
||||
|
||||
if ($tenantIds === []) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$resolver = app(CapabilityResolver::class);
|
||||
|
||||
return Tenant::query()
|
||||
->where('workspace_id', (int) $profile->workspace_id)
|
||||
->whereIn('id', $tenantIds)
|
||||
->get(['id'])
|
||||
->contains(fn (Tenant $tenant): bool => $resolver->can($user, $tenant, Capabilities::TENANT_SYNC));
|
||||
}
|
||||
}
|
||||
|
||||
@ -183,7 +183,7 @@ private function compareNowAction(): Action
|
||||
|
||||
$modalDescription = $captureMode === BaselineCaptureMode::FullContent
|
||||
? 'Select the target tenant. This will refresh content evidence on demand (redacted) before comparing.'
|
||||
: 'Select the target tenant to compare its current inventory against the active baseline snapshot.';
|
||||
: 'Select the target tenant to compare its current inventory against the effective current baseline snapshot.';
|
||||
|
||||
return Action::make('compareNow')
|
||||
->label($label)
|
||||
@ -198,7 +198,7 @@ private function compareNowAction(): Action
|
||||
->required()
|
||||
->searchable(),
|
||||
])
|
||||
->disabled(fn (): bool => $this->getEligibleCompareTenantOptions() === [])
|
||||
->disabled(fn (): bool => $this->getEligibleCompareTenantOptions() === [] || ! $this->profileHasConsumableSnapshot())
|
||||
->action(function (array $data): void {
|
||||
$user = auth()->user();
|
||||
|
||||
@ -256,7 +256,11 @@ private function compareNowAction(): Action
|
||||
$message = match ($reasonCode) {
|
||||
BaselineReasonCodes::COMPARE_ROLLOUT_DISABLED => 'Full-content baseline compare is currently disabled for controlled rollout.',
|
||||
BaselineReasonCodes::COMPARE_PROFILE_NOT_ACTIVE => 'This baseline profile is not active.',
|
||||
BaselineReasonCodes::COMPARE_NO_ACTIVE_SNAPSHOT => 'This baseline profile has no active snapshot.',
|
||||
BaselineReasonCodes::COMPARE_NO_ACTIVE_SNAPSHOT,
|
||||
BaselineReasonCodes::COMPARE_NO_CONSUMABLE_SNAPSHOT => 'This baseline profile has no complete snapshot available for compare yet.',
|
||||
BaselineReasonCodes::COMPARE_SNAPSHOT_BUILDING => 'The latest baseline capture is still building. Compare will be available after it completes.',
|
||||
BaselineReasonCodes::COMPARE_SNAPSHOT_INCOMPLETE => 'The latest baseline capture is incomplete. Capture a new baseline before comparing.',
|
||||
BaselineReasonCodes::COMPARE_SNAPSHOT_SUPERSEDED => 'A newer complete snapshot is current. Compare uses the latest complete baseline only.',
|
||||
default => 'Reason: '.str_replace('.', ' ', $reasonCode),
|
||||
};
|
||||
|
||||
@ -395,4 +399,12 @@ private function hasManageCapability(): bool
|
||||
return $resolver->isMember($user, $workspace)
|
||||
&& $resolver->can($user, $workspace, Capabilities::WORKSPACE_BASELINES_MANAGE);
|
||||
}
|
||||
|
||||
private function profileHasConsumableSnapshot(): bool
|
||||
{
|
||||
/** @var BaselineProfile $profile */
|
||||
$profile = $this->getRecord();
|
||||
|
||||
return $profile->resolveCurrentConsumableSnapshot() !== null;
|
||||
}
|
||||
}
|
||||
|
||||
@ -9,10 +9,12 @@
|
||||
use App\Models\BaselineSnapshot;
|
||||
use App\Models\User;
|
||||
use App\Models\Workspace;
|
||||
use App\Services\Baselines\BaselineSnapshotTruthResolver;
|
||||
use App\Services\Baselines\SnapshotRendering\FidelityState;
|
||||
use App\Support\Auth\Capabilities;
|
||||
use App\Support\Badges\BadgeCatalog;
|
||||
use App\Support\Badges\BadgeDomain;
|
||||
use App\Support\Baselines\BaselineSnapshotLifecycleState;
|
||||
use App\Support\Filament\FilterPresets;
|
||||
use App\Support\Navigation\CrossResourceNavigationMatrix;
|
||||
use App\Support\Navigation\RelatedContextEntry;
|
||||
@ -179,6 +181,22 @@ public static function table(Table $table): Table
|
||||
->iconColor(static fn (BaselineSnapshot $record): ?string => self::truthEnvelope($record)->primaryBadgeSpec()->iconColor)
|
||||
->description(static fn (BaselineSnapshot $record): ?string => 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))
|
||||
@ -187,13 +205,6 @@ public static function table(Table $table): Table
|
||||
->label('Next step')
|
||||
->getStateUsing(static fn (BaselineSnapshot $record): string => self::truthEnvelope($record)->nextStepText())
|
||||
->wrap(),
|
||||
TextColumn::make('snapshot_state')
|
||||
->label('State')
|
||||
->badge()
|
||||
->getStateUsing(static fn (BaselineSnapshot $record): string => self::stateLabel($record))
|
||||
->color(static fn (BaselineSnapshot $record): string => self::gapSpec($record)->color)
|
||||
->icon(static fn (BaselineSnapshot $record): ?string => self::gapSpec($record)->icon)
|
||||
->iconColor(static fn (BaselineSnapshot $record): ?string => self::gapSpec($record)->iconColor),
|
||||
])
|
||||
->recordUrl(static fn (BaselineSnapshot $record): ?string => static::canView($record)
|
||||
? static::getUrl('view', ['record' => $record])
|
||||
@ -203,10 +214,10 @@ public static function table(Table $table): Table
|
||||
->label('Baseline')
|
||||
->options(static::baselineProfileOptions())
|
||||
->searchable(),
|
||||
SelectFilter::make('snapshot_state')
|
||||
->label('State')
|
||||
->options(static::snapshotStateOptions())
|
||||
->query(fn (Builder $query, array $data): Builder => static::applySnapshotStateFilter($query, $data['value'] ?? null)),
|
||||
SelectFilter::make('lifecycle_state')
|
||||
->label('Lifecycle')
|
||||
->options(static::lifecycleOptions())
|
||||
->query(fn (Builder $query, array $data): Builder => static::applyLifecycleFilter($query, $data['value'] ?? null)),
|
||||
FilterPresets::dateRange('captured_at', 'Captured', 'captured_at'),
|
||||
])
|
||||
->actions([
|
||||
@ -267,9 +278,9 @@ private static function baselineProfileOptions(): array
|
||||
/**
|
||||
* @return array<string, string>
|
||||
*/
|
||||
private static function snapshotStateOptions(): array
|
||||
private static function lifecycleOptions(): array
|
||||
{
|
||||
return BadgeCatalog::options(BadgeDomain::BaselineSnapshotGapStatus, ['clear', 'gaps_present']);
|
||||
return BadgeCatalog::options(BadgeDomain::BaselineSnapshotLifecycle, BaselineSnapshotLifecycleState::values());
|
||||
}
|
||||
|
||||
public static function resolveWorkspace(): ?Workspace
|
||||
@ -343,24 +354,18 @@ private static function hasGaps(BaselineSnapshot $snapshot): bool
|
||||
return self::gapsCount($snapshot) > 0;
|
||||
}
|
||||
|
||||
private static function stateLabel(BaselineSnapshot $snapshot): string
|
||||
private static function lifecycleSpec(BaselineSnapshot $snapshot): \App\Support\Badges\BadgeSpec
|
||||
{
|
||||
return self::gapSpec($snapshot)->label;
|
||||
return BadgeCatalog::spec(BadgeDomain::BaselineSnapshotLifecycle, $snapshot->lifecycleState()->value);
|
||||
}
|
||||
|
||||
private static function applySnapshotStateFilter(Builder $query, mixed $value): Builder
|
||||
private static function applyLifecycleFilter(Builder $query, mixed $value): Builder
|
||||
{
|
||||
if (! is_string($value) || trim($value) === '') {
|
||||
return $query;
|
||||
}
|
||||
|
||||
$gapCountExpression = self::gapCountExpression($query);
|
||||
|
||||
return match ($value) {
|
||||
'clear' => $query->whereRaw("{$gapCountExpression} = 0"),
|
||||
'gaps_present' => $query->whereRaw("{$gapCountExpression} > 0"),
|
||||
default => $query,
|
||||
};
|
||||
return $query->where('lifecycle_state', trim($value));
|
||||
}
|
||||
|
||||
private static function gapCountExpression(Builder $query): string
|
||||
@ -384,4 +389,51 @@ private static function truthEnvelope(BaselineSnapshot $snapshot): ArtifactTruth
|
||||
{
|
||||
return app(ArtifactTruthPresenter::class)->forBaselineSnapshot($snapshot);
|
||||
}
|
||||
|
||||
private static function currentTruthLabel(BaselineSnapshot $snapshot): string
|
||||
{
|
||||
return match (self::currentTruthState($snapshot)) {
|
||||
'current' => 'Current baseline',
|
||||
'historical' => 'Historical trace',
|
||||
default => 'Not compare input',
|
||||
};
|
||||
}
|
||||
|
||||
private static function currentTruthDescription(BaselineSnapshot $snapshot): ?string
|
||||
{
|
||||
return match (self::currentTruthState($snapshot)) {
|
||||
'current' => 'Compare resolves to this snapshot as the current baseline truth.',
|
||||
'historical' => 'A newer complete snapshot is now the current baseline truth for this profile.',
|
||||
default => self::truthEnvelope($snapshot)->primaryExplanation,
|
||||
};
|
||||
}
|
||||
|
||||
private static function currentTruthColor(BaselineSnapshot $snapshot): string
|
||||
{
|
||||
return match (self::currentTruthState($snapshot)) {
|
||||
'current' => 'success',
|
||||
'historical' => 'gray',
|
||||
default => 'warning',
|
||||
};
|
||||
}
|
||||
|
||||
private static function currentTruthIcon(BaselineSnapshot $snapshot): ?string
|
||||
{
|
||||
return match (self::currentTruthState($snapshot)) {
|
||||
'current' => 'heroicon-m-check-badge',
|
||||
'historical' => 'heroicon-m-clock',
|
||||
default => 'heroicon-m-exclamation-triangle',
|
||||
};
|
||||
}
|
||||
|
||||
private static function currentTruthState(BaselineSnapshot $snapshot): string
|
||||
{
|
||||
if (! $snapshot->isConsumable()) {
|
||||
return 'unusable';
|
||||
}
|
||||
|
||||
return app(BaselineSnapshotTruthResolver::class)->isHistoricallySuperseded($snapshot)
|
||||
? 'historical'
|
||||
: 'current';
|
||||
}
|
||||
}
|
||||
|
||||
@ -35,6 +35,8 @@ public function mount(int|string $record): void
|
||||
$snapshot = $this->getRecord();
|
||||
|
||||
if ($snapshot instanceof BaselineSnapshot) {
|
||||
$snapshot->loadMissing(['baselineProfile', 'items']);
|
||||
|
||||
$relatedContext = app(RelatedNavigationResolver::class)
|
||||
->detailEntries(CrossResourceNavigationMatrix::SOURCE_BASELINE_SNAPSHOT, $snapshot);
|
||||
|
||||
|
||||
@ -128,10 +128,11 @@ public static function table(Table $table): Table
|
||||
->columns([
|
||||
Tables\Columns\TextColumn::make('status')
|
||||
->badge()
|
||||
->formatStateUsing(BadgeRenderer::label(BadgeDomain::OperationRunStatus))
|
||||
->color(BadgeRenderer::color(BadgeDomain::OperationRunStatus))
|
||||
->icon(BadgeRenderer::icon(BadgeDomain::OperationRunStatus))
|
||||
->iconColor(BadgeRenderer::iconColor(BadgeDomain::OperationRunStatus)),
|
||||
->formatStateUsing(fn (mixed $state, OperationRun $record): string => BadgeRenderer::spec(BadgeDomain::OperationRunStatus, static::statusBadgeState($record))->label)
|
||||
->color(fn (mixed $state, OperationRun $record): string => BadgeRenderer::spec(BadgeDomain::OperationRunStatus, static::statusBadgeState($record))->color)
|
||||
->icon(fn (mixed $state, OperationRun $record): ?string => BadgeRenderer::spec(BadgeDomain::OperationRunStatus, static::statusBadgeState($record))->icon)
|
||||
->iconColor(fn (mixed $state, OperationRun $record): ?string => BadgeRenderer::spec(BadgeDomain::OperationRunStatus, static::statusBadgeState($record))->iconColor)
|
||||
->description(fn (OperationRun $record): ?string => OperationUxPresenter::lifecycleAttentionSummary($record)),
|
||||
Tables\Columns\TextColumn::make('type')
|
||||
->label('Operation')
|
||||
->formatStateUsing(fn (?string $state): string => OperationCatalog::label((string) $state))
|
||||
@ -154,10 +155,10 @@ public static function table(Table $table): Table
|
||||
}),
|
||||
Tables\Columns\TextColumn::make('outcome')
|
||||
->badge()
|
||||
->formatStateUsing(BadgeRenderer::label(BadgeDomain::OperationRunOutcome))
|
||||
->color(BadgeRenderer::color(BadgeDomain::OperationRunOutcome))
|
||||
->icon(BadgeRenderer::icon(BadgeDomain::OperationRunOutcome))
|
||||
->iconColor(BadgeRenderer::iconColor(BadgeDomain::OperationRunOutcome))
|
||||
->formatStateUsing(fn (mixed $state, OperationRun $record): string => BadgeRenderer::spec(BadgeDomain::OperationRunOutcome, static::outcomeBadgeState($record))->label)
|
||||
->color(fn (mixed $state, OperationRun $record): string => BadgeRenderer::spec(BadgeDomain::OperationRunOutcome, static::outcomeBadgeState($record))->color)
|
||||
->icon(fn (mixed $state, OperationRun $record): ?string => BadgeRenderer::spec(BadgeDomain::OperationRunOutcome, static::outcomeBadgeState($record))->icon)
|
||||
->iconColor(fn (mixed $state, OperationRun $record): ?string => BadgeRenderer::spec(BadgeDomain::OperationRunOutcome, static::outcomeBadgeState($record))->iconColor)
|
||||
->description(fn (OperationRun $record): ?string => OperationUxPresenter::surfaceGuidance($record)),
|
||||
])
|
||||
->filters([
|
||||
@ -253,13 +254,24 @@ private static function enterpriseDetailPage(OperationRun $record): \App\Support
|
||||
{
|
||||
$factory = new \App\Support\Ui\EnterpriseDetail\EnterpriseDetailSectionFactory;
|
||||
|
||||
$statusSpec = BadgeRenderer::spec(BadgeDomain::OperationRunStatus, $record->status);
|
||||
$outcomeSpec = BadgeRenderer::spec(BadgeDomain::OperationRunOutcome, $record->outcome);
|
||||
$statusSpec = BadgeRenderer::spec(BadgeDomain::OperationRunStatus, static::statusBadgeState($record));
|
||||
$outcomeSpec = BadgeRenderer::spec(BadgeDomain::OperationRunOutcome, static::outcomeBadgeState($record));
|
||||
$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->isGovernanceArtifactOperation()
|
||||
? app(ArtifactTruthPresenter::class)->forOperationRun($record)
|
||||
: null;
|
||||
$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(
|
||||
@ -294,7 +306,7 @@ private static function enterpriseDetailPage(OperationRun $record): \App\Support
|
||||
kind: 'current_status',
|
||||
title: 'Artifact truth',
|
||||
view: 'filament.infolists.entries.governance-artifact-truth',
|
||||
viewData: ['artifactTruthState' => app(ArtifactTruthPresenter::class)->forOperationRun($record)->toArray()],
|
||||
viewData: ['artifactTruthState' => $artifactTruth?->toArray()],
|
||||
visible: $record->isGovernanceArtifactOperation(),
|
||||
description: 'Run lifecycle stays separate from whether the intended governance artifact was actually produced and usable.',
|
||||
),
|
||||
@ -315,6 +327,9 @@ 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,
|
||||
$referencedTenantLifecycle !== null
|
||||
? $factory->keyFact(
|
||||
'Tenant lifecycle',
|
||||
@ -333,6 +348,21 @@ 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,
|
||||
$artifactTruth !== null
|
||||
? $factory->keyFact('Artifact next step', $artifactTruth->nextStepText())
|
||||
: null,
|
||||
OperationUxPresenter::surfaceGuidance($record) !== null
|
||||
? $factory->keyFact('Next step', OperationUxPresenter::surfaceGuidance($record))
|
||||
: null,
|
||||
@ -399,6 +429,19 @@ private static function enterpriseDetailPage(OperationRun $record): \App\Support
|
||||
);
|
||||
}
|
||||
|
||||
if (static::reconciliationPayload($record) !== []) {
|
||||
$builder->addSection(
|
||||
$factory->viewSection(
|
||||
id: 'reconciliation',
|
||||
kind: 'operational_context',
|
||||
title: 'Lifecycle reconciliation',
|
||||
view: 'filament.infolists.entries.snapshot-json',
|
||||
viewData: ['payload' => static::reconciliationPayload($record)],
|
||||
description: 'Lifecycle reconciliation is diagnostic evidence showing when TenantPilot force-resolved the run.',
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if ((string) $record->type === 'baseline_compare') {
|
||||
$baselineCompareFacts = static::baselineCompareFacts($record, $factory);
|
||||
$baselineCompareEvidence = static::baselineCompareEvidencePayload($record);
|
||||
@ -709,6 +752,82 @@ private static function contextPayload(OperationRun $record): array
|
||||
return $context;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{status:string,freshness_state:string}
|
||||
*/
|
||||
private static function statusBadgeState(OperationRun $record): array
|
||||
{
|
||||
return [
|
||||
'status' => (string) $record->status,
|
||||
'freshness_state' => $record->freshnessState()->value,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{outcome:string,status:string,freshness_state:string}
|
||||
*/
|
||||
private static function outcomeBadgeState(OperationRun $record): array
|
||||
{
|
||||
return [
|
||||
'outcome' => (string) $record->outcome,
|
||||
'status' => (string) $record->status,
|
||||
'freshness_state' => $record->freshnessState()->value,
|
||||
];
|
||||
}
|
||||
|
||||
private static function freshnessLabel(OperationRun $record): ?string
|
||||
{
|
||||
return match ($record->freshnessState()->value) {
|
||||
'fresh_active' => 'Fresh activity',
|
||||
'likely_stale' => 'Likely stale',
|
||||
'reconciled_failed' => 'Automatically reconciled',
|
||||
'terminal_normal' => 'Terminal truth confirmed',
|
||||
default => null,
|
||||
};
|
||||
}
|
||||
|
||||
private static function reconciliationHeadline(OperationRun $record): ?string
|
||||
{
|
||||
if (! $record->isLifecycleReconciled()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return 'TenantPilot force-resolved this run after normal lifecycle truth was lost.';
|
||||
}
|
||||
|
||||
private static function reconciledAtLabel(OperationRun $record): ?string
|
||||
{
|
||||
$reconciledAt = data_get($record->reconciliation(), 'reconciled_at');
|
||||
|
||||
return is_string($reconciledAt) && trim($reconciledAt) !== '' ? trim($reconciledAt) : null;
|
||||
}
|
||||
|
||||
private static function reconciliationSourceLabel(OperationRun $record): ?string
|
||||
{
|
||||
$source = data_get($record->reconciliation(), 'source');
|
||||
|
||||
if (! is_string($source) || trim($source) === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return match (trim($source)) {
|
||||
'failed_callback' => 'Direct failed() bridge',
|
||||
'scheduled_reconciler' => 'Scheduled reconciler',
|
||||
'adapter_reconciler' => 'Adapter reconciler',
|
||||
default => ucfirst(str_replace('_', ' ', trim($source))),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private static function reconciliationPayload(OperationRun $record): array
|
||||
{
|
||||
$reconciliation = $record->reconciliation();
|
||||
|
||||
return $reconciliation;
|
||||
}
|
||||
|
||||
private static function formatDetailTimestamp(mixed $value): string
|
||||
{
|
||||
if (! $value instanceof \Illuminate\Support\Carbon) {
|
||||
|
||||
@ -22,6 +22,8 @@ protected function getViewData(): array
|
||||
|
||||
$empty = [
|
||||
'hasAssignment' => false,
|
||||
'state' => 'no_assignment',
|
||||
'message' => null,
|
||||
'profileName' => null,
|
||||
'findingsCount' => 0,
|
||||
'highCount' => 0,
|
||||
@ -43,6 +45,8 @@ 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,
|
||||
|
||||
@ -44,8 +44,10 @@ protected function getViewData(): array
|
||||
}
|
||||
|
||||
return [
|
||||
'shouldShow' => $hasWarnings && $runUrl !== null,
|
||||
'shouldShow' => ($hasWarnings && $runUrl !== null) || $stats->state === 'no_snapshot',
|
||||
'runUrl' => $runUrl,
|
||||
'state' => $stats->state,
|
||||
'message' => $stats->message,
|
||||
'coverageStatus' => $coverageStatus,
|
||||
'fidelity' => $stats->fidelity,
|
||||
'uncoveredTypesCount' => $stats->uncoveredTypesCount ?? count($uncoveredTypes),
|
||||
|
||||
@ -2,6 +2,7 @@
|
||||
|
||||
namespace App\Jobs;
|
||||
|
||||
use App\Jobs\Concerns\BridgesFailedOperationRun;
|
||||
use App\Jobs\Operations\BackupSetRestoreWorkerJob;
|
||||
use App\Models\OperationRun;
|
||||
use App\Services\OperationRunService;
|
||||
@ -11,11 +12,18 @@
|
||||
use Illuminate\Queue\InteractsWithQueue;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
use RuntimeException;
|
||||
use Throwable;
|
||||
|
||||
class BulkBackupSetRestoreJob implements ShouldQueue
|
||||
{
|
||||
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
||||
use BridgesFailedOperationRun;
|
||||
use Dispatchable;
|
||||
use InteractsWithQueue;
|
||||
use Queueable;
|
||||
use SerializesModels;
|
||||
|
||||
public int $timeout = 300;
|
||||
|
||||
public bool $failOnTimeout = true;
|
||||
|
||||
public int $bulkRunId = 0;
|
||||
|
||||
@ -68,32 +76,6 @@ public function handle(OperationRunService $runs): void
|
||||
}
|
||||
}
|
||||
|
||||
public function failed(Throwable $e): void
|
||||
{
|
||||
$run = $this->operationRun;
|
||||
|
||||
if (! $run instanceof OperationRun && $this->bulkRunId > 0) {
|
||||
$run = OperationRun::query()->find($this->bulkRunId);
|
||||
}
|
||||
|
||||
if (! $run instanceof OperationRun) {
|
||||
return;
|
||||
}
|
||||
|
||||
/** @var OperationRunService $runs */
|
||||
$runs = app(OperationRunService::class);
|
||||
|
||||
$runs->updateRun(
|
||||
$run,
|
||||
status: 'completed',
|
||||
outcome: 'failed',
|
||||
failures: [[
|
||||
'code' => 'bulk_job.failed',
|
||||
'message' => $e->getMessage(),
|
||||
]],
|
||||
);
|
||||
}
|
||||
|
||||
private function resolveOperationRun(): OperationRun
|
||||
{
|
||||
if ($this->operationRun instanceof OperationRun) {
|
||||
|
||||
@ -2,6 +2,7 @@
|
||||
|
||||
namespace App\Jobs;
|
||||
|
||||
use App\Jobs\Concerns\BridgesFailedOperationRun;
|
||||
use App\Jobs\Middleware\EnsureQueuedExecutionLegitimate;
|
||||
use App\Jobs\Operations\TenantSyncWorkerJob;
|
||||
use App\Models\OperationRun;
|
||||
@ -15,7 +16,15 @@
|
||||
|
||||
class BulkTenantSyncJob implements ShouldQueue
|
||||
{
|
||||
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
||||
use BridgesFailedOperationRun;
|
||||
use Dispatchable;
|
||||
use InteractsWithQueue;
|
||||
use Queueable;
|
||||
use SerializesModels;
|
||||
|
||||
public int $timeout = 180;
|
||||
|
||||
public bool $failOnTimeout = true;
|
||||
|
||||
public ?OperationRun $operationRun = null;
|
||||
|
||||
|
||||
@ -2,6 +2,7 @@
|
||||
|
||||
namespace App\Jobs;
|
||||
|
||||
use App\Jobs\Concerns\BridgesFailedOperationRun;
|
||||
use App\Jobs\Middleware\TrackOperationRun;
|
||||
use App\Models\BaselineProfile;
|
||||
use App\Models\BaselineSnapshot;
|
||||
@ -21,7 +22,9 @@
|
||||
use App\Support\Baselines\BaselineCaptureMode;
|
||||
use App\Support\Baselines\BaselineFullContentRolloutGate;
|
||||
use App\Support\Baselines\BaselineProfileStatus;
|
||||
use App\Support\Baselines\BaselineReasonCodes;
|
||||
use App\Support\Baselines\BaselineScope;
|
||||
use App\Support\Baselines\BaselineSnapshotLifecycleState;
|
||||
use App\Support\Baselines\PolicyVersionCapturePurpose;
|
||||
use App\Support\Inventory\InventoryPolicyTypeMeta;
|
||||
use App\Support\OperationRunOutcome;
|
||||
@ -33,10 +36,19 @@
|
||||
use Illuminate\Queue\InteractsWithQueue;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
use RuntimeException;
|
||||
use Throwable;
|
||||
|
||||
class CaptureBaselineSnapshotJob implements ShouldQueue
|
||||
{
|
||||
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
||||
use BridgesFailedOperationRun;
|
||||
use Dispatchable;
|
||||
use InteractsWithQueue;
|
||||
use Queueable;
|
||||
use SerializesModels;
|
||||
|
||||
public int $timeout = 300;
|
||||
|
||||
public bool $failOnTimeout = true;
|
||||
|
||||
public ?OperationRun $operationRun = null;
|
||||
|
||||
@ -60,13 +72,13 @@ public function handle(
|
||||
AuditLogger $auditLogger,
|
||||
OperationRunService $operationRunService,
|
||||
?CurrentStateHashResolver $hashResolver = null,
|
||||
?BaselineSnapshotItemNormalizer $snapshotItemNormalizer = null,
|
||||
?BaselineContentCapturePhase $contentCapturePhase = null,
|
||||
?BaselineSnapshotItemNormalizer $snapshotItemNormalizer = null,
|
||||
?BaselineFullContentRolloutGate $rolloutGate = null,
|
||||
): void {
|
||||
$hashResolver ??= app(CurrentStateHashResolver::class);
|
||||
$snapshotItemNormalizer ??= app(BaselineSnapshotItemNormalizer::class);
|
||||
$contentCapturePhase ??= app(BaselineContentCapturePhase::class);
|
||||
$snapshotItemNormalizer ??= app(BaselineSnapshotItemNormalizer::class);
|
||||
$rolloutGate ??= app(BaselineFullContentRolloutGate::class);
|
||||
|
||||
if (! $this->operationRun instanceof OperationRun) {
|
||||
@ -208,16 +220,17 @@ public function handle(
|
||||
],
|
||||
];
|
||||
|
||||
$snapshot = $this->findOrCreateSnapshot(
|
||||
$snapshotResult = $this->captureSnapshotArtifact(
|
||||
$profile,
|
||||
$identityHash,
|
||||
$items,
|
||||
$snapshotSummary,
|
||||
);
|
||||
|
||||
$wasNewSnapshot = $snapshot->wasRecentlyCreated;
|
||||
$snapshot = $snapshotResult['snapshot'];
|
||||
$wasNewSnapshot = $snapshotResult['was_new_snapshot'];
|
||||
|
||||
if ($profile->status === BaselineProfileStatus::Active) {
|
||||
if ($profile->status === BaselineProfileStatus::Active && $snapshot->isConsumable()) {
|
||||
$profile->update(['active_snapshot_id' => $snapshot->getKey()]);
|
||||
}
|
||||
|
||||
@ -258,6 +271,7 @@ public function handle(
|
||||
'snapshot_identity_hash' => $identityHash,
|
||||
'was_new_snapshot' => $wasNewSnapshot,
|
||||
'items_captured' => $snapshotItems['items_count'],
|
||||
'snapshot_lifecycle' => $snapshot->lifecycleState()->value,
|
||||
];
|
||||
$this->operationRun->update(['context' => $updatedContext]);
|
||||
|
||||
@ -508,29 +522,151 @@ private function buildSnapshotItems(
|
||||
];
|
||||
}
|
||||
|
||||
private function findOrCreateSnapshot(
|
||||
/**
|
||||
* @param array<int, array<string, mixed>> $snapshotItems
|
||||
* @param array<string, mixed> $summaryJsonb
|
||||
* @return array{snapshot: BaselineSnapshot, was_new_snapshot: bool}
|
||||
*/
|
||||
private function captureSnapshotArtifact(
|
||||
BaselineProfile $profile,
|
||||
string $identityHash,
|
||||
array $snapshotItems,
|
||||
array $summaryJsonb,
|
||||
): BaselineSnapshot {
|
||||
): array {
|
||||
$existing = $this->findExistingConsumableSnapshot($profile, $identityHash);
|
||||
|
||||
if ($existing instanceof BaselineSnapshot) {
|
||||
$this->rememberSnapshotOnRun(
|
||||
snapshot: $existing,
|
||||
identityHash: $identityHash,
|
||||
wasNewSnapshot: false,
|
||||
expectedItems: count($snapshotItems),
|
||||
persistedItems: count($snapshotItems),
|
||||
);
|
||||
|
||||
return [
|
||||
'snapshot' => $existing,
|
||||
'was_new_snapshot' => false,
|
||||
];
|
||||
}
|
||||
|
||||
$expectedItems = count($snapshotItems);
|
||||
$snapshot = $this->createBuildingSnapshot($profile, $identityHash, $summaryJsonb, $expectedItems);
|
||||
|
||||
$this->rememberSnapshotOnRun(
|
||||
snapshot: $snapshot,
|
||||
identityHash: $identityHash,
|
||||
wasNewSnapshot: true,
|
||||
expectedItems: $expectedItems,
|
||||
persistedItems: 0,
|
||||
);
|
||||
|
||||
try {
|
||||
$persistedItems = $this->persistSnapshotItems($snapshot, $snapshotItems);
|
||||
|
||||
if ($persistedItems !== $expectedItems) {
|
||||
throw new RuntimeException('Baseline snapshot completion proof failed.');
|
||||
}
|
||||
|
||||
$snapshot->markComplete($identityHash, [
|
||||
'expected_identity_hash' => $identityHash,
|
||||
'expected_items' => $expectedItems,
|
||||
'persisted_items' => $persistedItems,
|
||||
'producer_run_id' => (int) $this->operationRun->getKey(),
|
||||
'was_empty_capture' => $expectedItems === 0,
|
||||
]);
|
||||
|
||||
$snapshot->refresh();
|
||||
|
||||
$this->rememberSnapshotOnRun(
|
||||
snapshot: $snapshot,
|
||||
identityHash: $identityHash,
|
||||
wasNewSnapshot: true,
|
||||
expectedItems: $expectedItems,
|
||||
persistedItems: $persistedItems,
|
||||
);
|
||||
|
||||
return [
|
||||
'snapshot' => $snapshot,
|
||||
'was_new_snapshot' => true,
|
||||
];
|
||||
} catch (Throwable $exception) {
|
||||
$persistedItems = (int) BaselineSnapshotItem::query()
|
||||
->where('baseline_snapshot_id', (int) $snapshot->getKey())
|
||||
->count();
|
||||
|
||||
$reasonCode = $exception instanceof RuntimeException
|
||||
&& $exception->getMessage() === 'Baseline snapshot completion proof failed.'
|
||||
? BaselineReasonCodes::SNAPSHOT_COMPLETION_PROOF_FAILED
|
||||
: BaselineReasonCodes::SNAPSHOT_CAPTURE_FAILED;
|
||||
|
||||
$snapshot->markIncomplete($reasonCode, [
|
||||
'expected_identity_hash' => $identityHash,
|
||||
'expected_items' => $expectedItems,
|
||||
'persisted_items' => $persistedItems,
|
||||
'producer_run_id' => (int) $this->operationRun->getKey(),
|
||||
'was_empty_capture' => $expectedItems === 0,
|
||||
]);
|
||||
|
||||
$snapshot->refresh();
|
||||
|
||||
$this->rememberSnapshotOnRun(
|
||||
snapshot: $snapshot,
|
||||
identityHash: $identityHash,
|
||||
wasNewSnapshot: true,
|
||||
expectedItems: $expectedItems,
|
||||
persistedItems: $persistedItems,
|
||||
reasonCode: $reasonCode,
|
||||
);
|
||||
|
||||
throw $exception;
|
||||
}
|
||||
}
|
||||
|
||||
private function findExistingConsumableSnapshot(BaselineProfile $profile, string $identityHash): ?BaselineSnapshot
|
||||
{
|
||||
$existing = BaselineSnapshot::query()
|
||||
->where('workspace_id', $profile->workspace_id)
|
||||
->where('baseline_profile_id', $profile->getKey())
|
||||
->where('snapshot_identity_hash', $identityHash)
|
||||
->where('lifecycle_state', BaselineSnapshotLifecycleState::Complete->value)
|
||||
->first();
|
||||
|
||||
if ($existing instanceof BaselineSnapshot) {
|
||||
return $existing;
|
||||
}
|
||||
return $existing instanceof BaselineSnapshot ? $existing : null;
|
||||
}
|
||||
|
||||
$snapshot = BaselineSnapshot::create([
|
||||
/**
|
||||
* @param array<string, mixed> $summaryJsonb
|
||||
*/
|
||||
private function createBuildingSnapshot(
|
||||
BaselineProfile $profile,
|
||||
string $identityHash,
|
||||
array $summaryJsonb,
|
||||
int $expectedItems,
|
||||
): BaselineSnapshot {
|
||||
return BaselineSnapshot::create([
|
||||
'workspace_id' => (int) $profile->workspace_id,
|
||||
'baseline_profile_id' => (int) $profile->getKey(),
|
||||
'snapshot_identity_hash' => $identityHash,
|
||||
'snapshot_identity_hash' => $this->temporarySnapshotIdentityHash($profile),
|
||||
'captured_at' => now(),
|
||||
'lifecycle_state' => BaselineSnapshotLifecycleState::Building->value,
|
||||
'summary_jsonb' => $summaryJsonb,
|
||||
'completion_meta_jsonb' => [
|
||||
'expected_identity_hash' => $identityHash,
|
||||
'expected_items' => $expectedItems,
|
||||
'persisted_items' => 0,
|
||||
'producer_run_id' => (int) $this->operationRun->getKey(),
|
||||
'was_empty_capture' => $expectedItems === 0,
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, array<string, mixed>> $snapshotItems
|
||||
*/
|
||||
private function persistSnapshotItems(BaselineSnapshot $snapshot, array $snapshotItems): int
|
||||
{
|
||||
$persistedItems = 0;
|
||||
|
||||
foreach (array_chunk($snapshotItems, 100) as $chunk) {
|
||||
$rows = array_map(
|
||||
@ -549,9 +685,56 @@ private function findOrCreateSnapshot(
|
||||
);
|
||||
|
||||
BaselineSnapshotItem::insert($rows);
|
||||
$persistedItems += count($rows);
|
||||
}
|
||||
|
||||
return $snapshot;
|
||||
return $persistedItems;
|
||||
}
|
||||
|
||||
private function temporarySnapshotIdentityHash(BaselineProfile $profile): string
|
||||
{
|
||||
return hash(
|
||||
'sha256',
|
||||
implode('|', [
|
||||
'building',
|
||||
(string) $profile->getKey(),
|
||||
(string) $this->operationRun->getKey(),
|
||||
(string) microtime(true),
|
||||
]),
|
||||
);
|
||||
}
|
||||
|
||||
private function rememberSnapshotOnRun(
|
||||
BaselineSnapshot $snapshot,
|
||||
string $identityHash,
|
||||
bool $wasNewSnapshot,
|
||||
int $expectedItems,
|
||||
int $persistedItems,
|
||||
?string $reasonCode = null,
|
||||
): void {
|
||||
$context = is_array($this->operationRun->context) ? $this->operationRun->context : [];
|
||||
$context['baseline_snapshot_id'] = (int) $snapshot->getKey();
|
||||
$context['result'] = array_merge(
|
||||
is_array($context['result'] ?? null) ? $context['result'] : [],
|
||||
[
|
||||
'snapshot_id' => (int) $snapshot->getKey(),
|
||||
'snapshot_identity_hash' => $identityHash,
|
||||
'was_new_snapshot' => $wasNewSnapshot,
|
||||
'items_captured' => $persistedItems,
|
||||
'snapshot_lifecycle' => $snapshot->lifecycleState()->value,
|
||||
'expected_items' => $expectedItems,
|
||||
],
|
||||
);
|
||||
|
||||
if (is_string($reasonCode) && $reasonCode !== '') {
|
||||
$context['reason_code'] = $reasonCode;
|
||||
$context['result']['snapshot_reason_code'] = $reasonCode;
|
||||
} else {
|
||||
unset($context['reason_code'], $context['result']['snapshot_reason_code']);
|
||||
}
|
||||
|
||||
$this->operationRun->update(['context' => $context]);
|
||||
$this->operationRun->refresh();
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@ -4,6 +4,7 @@
|
||||
|
||||
namespace App\Jobs;
|
||||
|
||||
use App\Jobs\Concerns\BridgesFailedOperationRun;
|
||||
use App\Jobs\Middleware\TrackOperationRun;
|
||||
use App\Models\BaselineProfile;
|
||||
use App\Models\BaselineSnapshot;
|
||||
@ -18,6 +19,7 @@
|
||||
use App\Services\Baselines\BaselineAutoCloseService;
|
||||
use App\Services\Baselines\BaselineContentCapturePhase;
|
||||
use App\Services\Baselines\BaselineSnapshotIdentity;
|
||||
use App\Services\Baselines\BaselineSnapshotTruthResolver;
|
||||
use App\Services\Baselines\CurrentStateHashResolver;
|
||||
use App\Services\Baselines\Evidence\BaselinePolicyVersionResolver;
|
||||
use App\Services\Baselines\Evidence\ContentEvidenceProvider;
|
||||
@ -37,6 +39,7 @@
|
||||
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;
|
||||
@ -54,7 +57,15 @@
|
||||
|
||||
class CompareBaselineToTenantJob implements ShouldQueue
|
||||
{
|
||||
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
||||
use BridgesFailedOperationRun;
|
||||
use Dispatchable;
|
||||
use InteractsWithQueue;
|
||||
use Queueable;
|
||||
use SerializesModels;
|
||||
|
||||
public int $timeout = 300;
|
||||
|
||||
public bool $failOnTimeout = true;
|
||||
|
||||
/**
|
||||
* @var array<int, string>
|
||||
@ -84,6 +95,7 @@ public function handle(
|
||||
?SettingsResolver $settingsResolver = null,
|
||||
?BaselineAutoCloseService $baselineAutoCloseService = null,
|
||||
?CurrentStateHashResolver $hashResolver = null,
|
||||
?BaselineSnapshotTruthResolver $snapshotTruthResolver = null,
|
||||
?MetaEvidenceProvider $metaEvidenceProvider = null,
|
||||
?BaselineContentCapturePhase $contentCapturePhase = null,
|
||||
?BaselineFullContentRolloutGate $rolloutGate = null,
|
||||
@ -92,6 +104,7 @@ public function handle(
|
||||
$settingsResolver ??= app(SettingsResolver::class);
|
||||
$baselineAutoCloseService ??= app(BaselineAutoCloseService::class);
|
||||
$hashResolver ??= app(CurrentStateHashResolver::class);
|
||||
$snapshotTruthResolver ??= app(BaselineSnapshotTruthResolver::class);
|
||||
$metaEvidenceProvider ??= app(MetaEvidenceProvider::class);
|
||||
$contentCapturePhase ??= app(BaselineContentCapturePhase::class);
|
||||
$rolloutGate ??= app(BaselineFullContentRolloutGate::class);
|
||||
@ -278,12 +291,51 @@ public function handle(
|
||||
->where('workspace_id', (int) $profile->workspace_id)
|
||||
->where('baseline_profile_id', (int) $profile->getKey())
|
||||
->whereKey($snapshotId)
|
||||
->first(['id', 'captured_at']);
|
||||
->first();
|
||||
|
||||
if (! $snapshot instanceof BaselineSnapshot) {
|
||||
throw new RuntimeException("BaselineSnapshot #{$snapshotId} not found.");
|
||||
}
|
||||
|
||||
$snapshotResolution = $snapshotTruthResolver->resolveCompareSnapshot($profile, $snapshot);
|
||||
|
||||
if (! ($snapshotResolution['ok'] ?? false)) {
|
||||
$reasonCode = is_string($snapshotResolution['reason_code'] ?? null)
|
||||
? (string) $snapshotResolution['reason_code']
|
||||
: BaselineReasonCodes::COMPARE_NO_CONSUMABLE_SNAPSHOT;
|
||||
|
||||
$context = is_array($this->operationRun->context) ? $this->operationRun->context : [];
|
||||
$context['baseline_compare'] = array_merge(
|
||||
is_array($context['baseline_compare'] ?? null) ? $context['baseline_compare'] : [],
|
||||
[
|
||||
'reason_code' => $reasonCode,
|
||||
'effective_snapshot_id' => $snapshotResolution['effective_snapshot']?->getKey(),
|
||||
'latest_attempted_snapshot_id' => $snapshotResolution['latest_attempted_snapshot']?->getKey(),
|
||||
],
|
||||
);
|
||||
$context['result'] = array_merge(
|
||||
is_array($context['result'] ?? null) ? $context['result'] : [],
|
||||
[
|
||||
'snapshot_id' => (int) $snapshot->getKey(),
|
||||
],
|
||||
);
|
||||
|
||||
$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;
|
||||
@ -1004,6 +1056,17 @@ 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.
|
||||
*
|
||||
|
||||
@ -4,6 +4,7 @@
|
||||
|
||||
namespace App\Jobs;
|
||||
|
||||
use App\Jobs\Concerns\BridgesFailedOperationRun;
|
||||
use App\Models\OperationRun;
|
||||
use App\Models\TenantReview;
|
||||
use App\Services\OperationRunService;
|
||||
@ -17,8 +18,13 @@
|
||||
|
||||
class ComposeTenantReviewJob implements ShouldQueue
|
||||
{
|
||||
use BridgesFailedOperationRun;
|
||||
use Queueable;
|
||||
|
||||
public int $timeout = 240;
|
||||
|
||||
public bool $failOnTimeout = true;
|
||||
|
||||
public function __construct(
|
||||
public int $tenantReviewId,
|
||||
public int $operationRunId,
|
||||
|
||||
58
app/Jobs/Concerns/BridgesFailedOperationRun.php
Normal file
58
app/Jobs/Concerns/BridgesFailedOperationRun.php
Normal file
@ -0,0 +1,58 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Jobs\Concerns;
|
||||
|
||||
use App\Models\OperationRun;
|
||||
use App\Services\OperationRunService;
|
||||
use Throwable;
|
||||
|
||||
trait BridgesFailedOperationRun
|
||||
{
|
||||
public function failed(Throwable $exception): void
|
||||
{
|
||||
$operationRun = $this->failedBridgeOperationRun();
|
||||
|
||||
if (! $operationRun instanceof OperationRun) {
|
||||
return;
|
||||
}
|
||||
|
||||
app(OperationRunService::class)->bridgeFailedJobFailure($operationRun, $exception);
|
||||
}
|
||||
|
||||
protected function failedBridgeOperationRun(): ?OperationRun
|
||||
{
|
||||
if (property_exists($this, 'operationRun') && $this->operationRun instanceof OperationRun) {
|
||||
return $this->operationRun;
|
||||
}
|
||||
|
||||
if (property_exists($this, 'run') && $this->run instanceof OperationRun) {
|
||||
return $this->run;
|
||||
}
|
||||
|
||||
$candidateIds = [];
|
||||
|
||||
foreach (['operationRunId', 'bulkRunId', 'runId'] as $property) {
|
||||
if (! property_exists($this, $property)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$value = $this->{$property};
|
||||
|
||||
if (is_numeric($value) && (int) $value > 0) {
|
||||
$candidateIds[] = (int) $value;
|
||||
}
|
||||
}
|
||||
|
||||
foreach (array_values(array_unique($candidateIds)) as $candidateId) {
|
||||
$operationRun = OperationRun::query()->find($candidateId);
|
||||
|
||||
if ($operationRun instanceof OperationRun) {
|
||||
return $operationRun;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@ -20,6 +20,10 @@ class EntraGroupSyncJob implements ShouldQueue
|
||||
{
|
||||
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
||||
|
||||
public int $timeout = 240;
|
||||
|
||||
public bool $failOnTimeout = true;
|
||||
|
||||
public ?OperationRun $operationRun = null;
|
||||
|
||||
public function __construct(
|
||||
|
||||
@ -25,6 +25,10 @@ class ExecuteRestoreRunJob implements ShouldQueue
|
||||
{
|
||||
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
||||
|
||||
public int $timeout = 420;
|
||||
|
||||
public bool $failOnTimeout = true;
|
||||
|
||||
public ?OperationRun $operationRun = null;
|
||||
|
||||
public function __construct(
|
||||
|
||||
@ -19,6 +19,10 @@ class GenerateEvidenceSnapshotJob implements ShouldQueue
|
||||
{
|
||||
use Queueable;
|
||||
|
||||
public int $timeout = 240;
|
||||
|
||||
public bool $failOnTimeout = true;
|
||||
|
||||
public function __construct(
|
||||
public int $snapshotId,
|
||||
public int $operationRunId,
|
||||
|
||||
@ -28,6 +28,10 @@ class GenerateReviewPackJob implements ShouldQueue
|
||||
{
|
||||
use Queueable;
|
||||
|
||||
public int $timeout = 240;
|
||||
|
||||
public bool $failOnTimeout = true;
|
||||
|
||||
public function __construct(
|
||||
public int $reviewPackId,
|
||||
public int $operationRunId,
|
||||
|
||||
@ -40,6 +40,10 @@ class RunBackupScheduleJob implements ShouldQueue
|
||||
|
||||
public int $tries = 3;
|
||||
|
||||
public int $timeout = 300;
|
||||
|
||||
public bool $failOnTimeout = true;
|
||||
|
||||
/**
|
||||
* Compatibility-only legacy field.
|
||||
*
|
||||
|
||||
@ -2,6 +2,7 @@
|
||||
|
||||
namespace App\Jobs;
|
||||
|
||||
use App\Jobs\Concerns\BridgesFailedOperationRun;
|
||||
use App\Jobs\Middleware\EnsureQueuedExecutionLegitimate;
|
||||
use App\Jobs\Middleware\TrackOperationRun;
|
||||
use App\Models\OperationRun;
|
||||
@ -24,7 +25,15 @@
|
||||
|
||||
class RunInventorySyncJob implements ShouldQueue
|
||||
{
|
||||
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
||||
use BridgesFailedOperationRun;
|
||||
use Dispatchable;
|
||||
use InteractsWithQueue;
|
||||
use Queueable;
|
||||
use SerializesModels;
|
||||
|
||||
public int $timeout = 240;
|
||||
|
||||
public bool $failOnTimeout = true;
|
||||
|
||||
public ?OperationRun $operationRun = null;
|
||||
|
||||
|
||||
@ -2,6 +2,7 @@
|
||||
|
||||
namespace App\Jobs;
|
||||
|
||||
use App\Jobs\Concerns\BridgesFailedOperationRun;
|
||||
use App\Jobs\Middleware\EnsureQueuedExecutionLegitimate;
|
||||
use App\Jobs\Middleware\TrackOperationRun;
|
||||
use App\Models\OperationRun;
|
||||
@ -21,7 +22,15 @@
|
||||
|
||||
class SyncPoliciesJob implements ShouldQueue
|
||||
{
|
||||
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
||||
use BridgesFailedOperationRun;
|
||||
use Dispatchable;
|
||||
use InteractsWithQueue;
|
||||
use Queueable;
|
||||
use SerializesModels;
|
||||
|
||||
public int $timeout = 180;
|
||||
|
||||
public bool $failOnTimeout = true;
|
||||
|
||||
public ?OperationRun $operationRun = null;
|
||||
|
||||
|
||||
@ -20,6 +20,10 @@ class SyncRoleDefinitionsJob implements ShouldQueue
|
||||
{
|
||||
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
||||
|
||||
public int $timeout = 240;
|
||||
|
||||
public bool $failOnTimeout = true;
|
||||
|
||||
public ?OperationRun $operationRun = null;
|
||||
|
||||
/**
|
||||
|
||||
@ -7,6 +7,7 @@
|
||||
use App\Support\Baselines\BaselineCaptureMode;
|
||||
use App\Support\Baselines\BaselineProfileStatus;
|
||||
use App\Support\Baselines\BaselineScope;
|
||||
use App\Support\Baselines\BaselineSnapshotLifecycleState;
|
||||
use Illuminate\Database\Eloquent\Casts\Attribute;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
@ -121,6 +122,37 @@ public function snapshots(): HasMany
|
||||
return $this->hasMany(BaselineSnapshot::class);
|
||||
}
|
||||
|
||||
public function resolveCurrentConsumableSnapshot(): ?BaselineSnapshot
|
||||
{
|
||||
$activeSnapshot = $this->relationLoaded('activeSnapshot')
|
||||
? $this->getRelation('activeSnapshot')
|
||||
: $this->activeSnapshot()->first();
|
||||
|
||||
if ($activeSnapshot instanceof BaselineSnapshot && $activeSnapshot->isConsumable()) {
|
||||
return $activeSnapshot;
|
||||
}
|
||||
|
||||
return $this->snapshots()
|
||||
->where('lifecycle_state', BaselineSnapshotLifecycleState::Complete->value)
|
||||
->orderByDesc('completed_at')
|
||||
->orderByDesc('captured_at')
|
||||
->orderByDesc('id')
|
||||
->first();
|
||||
}
|
||||
|
||||
public function resolveLatestAttemptedSnapshot(): ?BaselineSnapshot
|
||||
{
|
||||
return $this->snapshots()
|
||||
->orderByDesc('captured_at')
|
||||
->orderByDesc('id')
|
||||
->first();
|
||||
}
|
||||
|
||||
public function hasConsumableSnapshot(): bool
|
||||
{
|
||||
return $this->resolveCurrentConsumableSnapshot() instanceof BaselineSnapshot;
|
||||
}
|
||||
|
||||
public function tenantAssignments(): HasMany
|
||||
{
|
||||
return $this->hasMany(BaselineTenantAssignment::class);
|
||||
|
||||
@ -1,11 +1,17 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Support\Baselines\BaselineReasonCodes;
|
||||
use App\Support\Baselines\BaselineSnapshotLifecycleState;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use RuntimeException;
|
||||
|
||||
class BaselineSnapshot extends Model
|
||||
{
|
||||
@ -13,10 +19,20 @@ class BaselineSnapshot extends Model
|
||||
|
||||
protected $guarded = [];
|
||||
|
||||
protected $casts = [
|
||||
'summary_jsonb' => 'array',
|
||||
'captured_at' => 'datetime',
|
||||
];
|
||||
/**
|
||||
* @return array<string, string>
|
||||
*/
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'lifecycle_state' => BaselineSnapshotLifecycleState::class,
|
||||
'summary_jsonb' => 'array',
|
||||
'completion_meta_jsonb' => 'array',
|
||||
'captured_at' => 'datetime',
|
||||
'completed_at' => 'datetime',
|
||||
'failed_at' => 'datetime',
|
||||
];
|
||||
}
|
||||
|
||||
public function workspace(): BelongsTo
|
||||
{
|
||||
@ -32,4 +48,100 @@ public function items(): HasMany
|
||||
{
|
||||
return $this->hasMany(BaselineSnapshotItem::class);
|
||||
}
|
||||
|
||||
public function scopeConsumable(Builder $query): Builder
|
||||
{
|
||||
return $query->where('lifecycle_state', BaselineSnapshotLifecycleState::Complete->value);
|
||||
}
|
||||
|
||||
public function scopeLatestConsumable(Builder $query): Builder
|
||||
{
|
||||
return $query
|
||||
->consumable()
|
||||
->orderByDesc('completed_at')
|
||||
->orderByDesc('captured_at')
|
||||
->orderByDesc('id');
|
||||
}
|
||||
|
||||
public function isConsumable(): bool
|
||||
{
|
||||
return $this->lifecycleState() === BaselineSnapshotLifecycleState::Complete;
|
||||
}
|
||||
|
||||
public function isBuilding(): bool
|
||||
{
|
||||
return $this->lifecycleState() === BaselineSnapshotLifecycleState::Building;
|
||||
}
|
||||
|
||||
public function isComplete(): bool
|
||||
{
|
||||
return $this->lifecycleState() === BaselineSnapshotLifecycleState::Complete;
|
||||
}
|
||||
|
||||
public function isIncomplete(): bool
|
||||
{
|
||||
return $this->lifecycleState() === BaselineSnapshotLifecycleState::Incomplete;
|
||||
}
|
||||
|
||||
public function markBuilding(array $completionMeta = []): void
|
||||
{
|
||||
$this->forceFill([
|
||||
'lifecycle_state' => BaselineSnapshotLifecycleState::Building,
|
||||
'completed_at' => null,
|
||||
'failed_at' => null,
|
||||
'completion_meta_jsonb' => $this->mergedCompletionMeta($completionMeta),
|
||||
])->save();
|
||||
}
|
||||
|
||||
public function markComplete(string $identityHash, array $completionMeta = []): void
|
||||
{
|
||||
if ($this->isIncomplete()) {
|
||||
throw new RuntimeException('Incomplete baseline snapshots cannot transition back to complete.');
|
||||
}
|
||||
|
||||
$this->forceFill([
|
||||
'snapshot_identity_hash' => $identityHash,
|
||||
'lifecycle_state' => BaselineSnapshotLifecycleState::Complete,
|
||||
'completed_at' => now(),
|
||||
'failed_at' => null,
|
||||
'completion_meta_jsonb' => $this->mergedCompletionMeta($completionMeta),
|
||||
])->save();
|
||||
}
|
||||
|
||||
public function markIncomplete(?string $reasonCode = null, array $completionMeta = []): void
|
||||
{
|
||||
$this->forceFill([
|
||||
'lifecycle_state' => BaselineSnapshotLifecycleState::Incomplete,
|
||||
'completed_at' => null,
|
||||
'failed_at' => now(),
|
||||
'completion_meta_jsonb' => $this->mergedCompletionMeta(array_filter([
|
||||
'finalization_reason_code' => $reasonCode ?? BaselineReasonCodes::SNAPSHOT_INCOMPLETE,
|
||||
...$completionMeta,
|
||||
], static fn (mixed $value): bool => $value !== null)),
|
||||
])->save();
|
||||
}
|
||||
|
||||
public function lifecycleState(): BaselineSnapshotLifecycleState
|
||||
{
|
||||
if ($this->lifecycle_state instanceof BaselineSnapshotLifecycleState) {
|
||||
return $this->lifecycle_state;
|
||||
}
|
||||
|
||||
if (is_string($this->lifecycle_state) && BaselineSnapshotLifecycleState::tryFrom($this->lifecycle_state) instanceof BaselineSnapshotLifecycleState) {
|
||||
return BaselineSnapshotLifecycleState::from($this->lifecycle_state);
|
||||
}
|
||||
|
||||
return BaselineSnapshotLifecycleState::Incomplete;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $completionMeta
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function mergedCompletionMeta(array $completionMeta): array
|
||||
{
|
||||
$existing = is_array($this->completion_meta_jsonb) ? $this->completion_meta_jsonb : [];
|
||||
|
||||
return array_replace($existing, $completionMeta);
|
||||
}
|
||||
}
|
||||
|
||||
@ -3,6 +3,7 @@
|
||||
namespace App\Models;
|
||||
|
||||
use App\Support\OperationCatalog;
|
||||
use App\Support\Operations\OperationRunFreshnessState;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
@ -159,4 +160,32 @@ public function relatedArtifactId(): ?int
|
||||
default => null,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function reconciliation(): array
|
||||
{
|
||||
$context = is_array($this->context) ? $this->context : [];
|
||||
$reconciliation = $context['reconciliation'] ?? null;
|
||||
|
||||
return is_array($reconciliation) ? $reconciliation : [];
|
||||
}
|
||||
|
||||
public function isLifecycleReconciled(): bool
|
||||
{
|
||||
return $this->reconciliation() !== [];
|
||||
}
|
||||
|
||||
public function lifecycleReconciliationReasonCode(): ?string
|
||||
{
|
||||
$reasonCode = $this->reconciliation()['reason_code'] ?? null;
|
||||
|
||||
return is_string($reasonCode) && trim($reasonCode) !== '' ? trim($reasonCode) : null;
|
||||
}
|
||||
|
||||
public function freshnessState(): OperationRunFreshnessState
|
||||
{
|
||||
return OperationRunFreshnessState::forRun($this);
|
||||
}
|
||||
}
|
||||
|
||||
@ -61,7 +61,7 @@ public function view(User $user, OperationRun $run): Response|bool
|
||||
}
|
||||
|
||||
$requiredCapability = app(OperationRunCapabilityResolver::class)
|
||||
->requiredCapabilityForType((string) $run->type);
|
||||
->requiredCapabilityForRun($run);
|
||||
|
||||
if (! is_string($requiredCapability) || $requiredCapability === '') {
|
||||
return true;
|
||||
|
||||
@ -28,6 +28,7 @@
|
||||
use App\Services\Auth\WorkspaceCapabilityResolver;
|
||||
use App\Services\Auth\WorkspaceRoleCapabilityMap;
|
||||
use App\Support\Auth\Capabilities;
|
||||
use App\Support\Filament\PanelThemeAsset;
|
||||
use App\Support\Workspaces\WorkspaceContext;
|
||||
use Filament\Http\Middleware\Authenticate;
|
||||
use Filament\Http\Middleware\AuthenticateSession;
|
||||
@ -202,7 +203,7 @@ public function panel(Panel $panel): Panel
|
||||
]);
|
||||
|
||||
if (! app()->runningUnitTests()) {
|
||||
$panel->viteTheme('resources/css/filament/admin/theme.css');
|
||||
$panel->theme(PanelThemeAsset::resolve('resources/css/filament/admin/theme.css'));
|
||||
}
|
||||
|
||||
return $panel;
|
||||
|
||||
@ -6,6 +6,7 @@
|
||||
use App\Filament\System\Pages\Dashboard;
|
||||
use App\Http\Middleware\UseSystemSessionCookie;
|
||||
use App\Support\Auth\PlatformCapabilities;
|
||||
use App\Support\Filament\PanelThemeAsset;
|
||||
use Filament\Http\Middleware\Authenticate;
|
||||
use Filament\Http\Middleware\AuthenticateSession;
|
||||
use Filament\Http\Middleware\DisableBladeIconComponents;
|
||||
@ -60,6 +61,6 @@ public function panel(Panel $panel): Panel
|
||||
Authenticate::class,
|
||||
'ensure-platform-capability:'.PlatformCapabilities::ACCESS_SYSTEM_PANEL,
|
||||
])
|
||||
->viteTheme('resources/css/filament/system/theme.css');
|
||||
->theme(PanelThemeAsset::resolve('resources/css/filament/system/theme.css'));
|
||||
}
|
||||
}
|
||||
|
||||
@ -6,6 +6,7 @@
|
||||
use App\Filament\Pages\TenantDashboard;
|
||||
use App\Filament\Resources\TenantReviewResource;
|
||||
use App\Models\Tenant;
|
||||
use App\Support\Filament\PanelThemeAsset;
|
||||
use App\Support\Middleware\DenyNonMemberTenantAccess;
|
||||
use Filament\Facades\Filament;
|
||||
use Filament\Http\Middleware\Authenticate;
|
||||
@ -112,7 +113,7 @@ public function panel(Panel $panel): Panel
|
||||
]);
|
||||
|
||||
if (! app()->runningUnitTests()) {
|
||||
$panel->viteTheme('resources/css/filament/admin/theme.css');
|
||||
$panel->theme(PanelThemeAsset::resolve('resources/css/filament/admin/theme.css'));
|
||||
}
|
||||
|
||||
return $panel;
|
||||
|
||||
@ -8,6 +8,7 @@
|
||||
use App\Models\RestoreRun;
|
||||
use App\Support\OperationRunOutcome;
|
||||
use App\Support\OperationRunStatus;
|
||||
use App\Support\Operations\LifecycleReconciliationReason;
|
||||
use App\Support\RestoreRunStatus;
|
||||
use Carbon\CarbonImmutable;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
@ -151,25 +152,23 @@ private function reconcileOne(OperationRun $run, bool $dryRun): ?array
|
||||
/** @var OperationRunService $runs */
|
||||
$runs = app(OperationRunService::class);
|
||||
|
||||
$runs->updateRun(
|
||||
$run,
|
||||
$runs->updateRunWithReconciliation(
|
||||
run: $run,
|
||||
status: $opStatus,
|
||||
outcome: $opOutcome,
|
||||
summaryCounts: $summaryCounts,
|
||||
failures: $failures,
|
||||
reasonCode: LifecycleReconciliationReason::AdapterOutOfSync->value,
|
||||
reasonMessage: LifecycleReconciliationReason::AdapterOutOfSync->defaultMessage(),
|
||||
source: 'adapter_reconciler',
|
||||
evidence: [
|
||||
'restore_run_id' => (int) $restoreRun->getKey(),
|
||||
'restore_status' => $restoreStatus?->value,
|
||||
],
|
||||
);
|
||||
|
||||
$run->refresh();
|
||||
|
||||
$updatedContext = is_array($run->context) ? $run->context : [];
|
||||
$reconciliation = is_array($updatedContext['reconciliation'] ?? null) ? $updatedContext['reconciliation'] : [];
|
||||
$reconciliation['reconciled_at'] = CarbonImmutable::now()->toIso8601String();
|
||||
$reconciliation['reason'] = 'adapter_out_of_sync';
|
||||
|
||||
$updatedContext['reconciliation'] = $reconciliation;
|
||||
|
||||
$run->context = $updatedContext;
|
||||
|
||||
if ($run->started_at === null && $restoreRun->started_at !== null) {
|
||||
$run->started_at = $restoreRun->started_at;
|
||||
}
|
||||
|
||||
@ -24,6 +24,7 @@ final class BaselineCompareService
|
||||
public function __construct(
|
||||
private readonly OperationRunService $runs,
|
||||
private readonly BaselineFullContentRolloutGate $rolloutGate,
|
||||
private readonly BaselineSnapshotTruthResolver $snapshotTruthResolver,
|
||||
) {}
|
||||
|
||||
/**
|
||||
@ -49,29 +50,36 @@ public function startCompare(
|
||||
return ['ok' => false, 'reason_code' => BaselineReasonCodes::COMPARE_PROFILE_NOT_ACTIVE];
|
||||
}
|
||||
|
||||
$hasExplicitSnapshotSelection = is_int($baselineSnapshotId) && $baselineSnapshotId > 0;
|
||||
$precondition = $this->validatePreconditions($profile, hasExplicitSnapshotSelection: $hasExplicitSnapshotSelection);
|
||||
$precondition = $this->validatePreconditions($profile);
|
||||
|
||||
if ($precondition !== null) {
|
||||
return ['ok' => false, 'reason_code' => $precondition];
|
||||
}
|
||||
|
||||
$snapshotId = $baselineSnapshotId !== null ? (int) $baselineSnapshotId : 0;
|
||||
$selectedSnapshot = null;
|
||||
|
||||
if ($snapshotId > 0) {
|
||||
$snapshot = BaselineSnapshot::query()
|
||||
if (is_int($baselineSnapshotId) && $baselineSnapshotId > 0) {
|
||||
$selectedSnapshot = BaselineSnapshot::query()
|
||||
->where('workspace_id', (int) $profile->workspace_id)
|
||||
->where('baseline_profile_id', (int) $profile->getKey())
|
||||
->whereKey($snapshotId)
|
||||
->first(['id']);
|
||||
->whereKey((int) $baselineSnapshotId)
|
||||
->first();
|
||||
|
||||
if (! $snapshot instanceof BaselineSnapshot) {
|
||||
if (! $selectedSnapshot 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 ['ok' => false, 'reason_code' => $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,
|
||||
);
|
||||
@ -113,7 +121,7 @@ public function startCompare(
|
||||
return ['ok' => true, 'run' => $run];
|
||||
}
|
||||
|
||||
private function validatePreconditions(BaselineProfile $profile, bool $hasExplicitSnapshotSelection = false): ?string
|
||||
private function validatePreconditions(BaselineProfile $profile): ?string
|
||||
{
|
||||
if ($profile->status !== BaselineProfileStatus::Active) {
|
||||
return BaselineReasonCodes::COMPARE_PROFILE_NOT_ACTIVE;
|
||||
@ -123,10 +131,6 @@ private function validatePreconditions(BaselineProfile $profile, bool $hasExplic
|
||||
return BaselineReasonCodes::COMPARE_ROLLOUT_DISABLED;
|
||||
}
|
||||
|
||||
if (! $hasExplicitSnapshotSelection && $profile->active_snapshot_id === null) {
|
||||
return BaselineReasonCodes::COMPARE_NO_ACTIVE_SNAPSHOT;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
175
app/Services/Baselines/BaselineSnapshotTruthResolver.php
Normal file
175
app/Services/Baselines/BaselineSnapshotTruthResolver.php
Normal file
@ -0,0 +1,175 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Baselines;
|
||||
|
||||
use App\Models\BaselineProfile;
|
||||
use App\Models\BaselineSnapshot;
|
||||
use App\Support\Baselines\BaselineReasonCodes;
|
||||
use App\Support\Baselines\BaselineSnapshotLifecycleState;
|
||||
|
||||
final class BaselineSnapshotTruthResolver
|
||||
{
|
||||
public function resolveEffectiveSnapshot(BaselineProfile $profile): ?BaselineSnapshot
|
||||
{
|
||||
return BaselineSnapshot::query()
|
||||
->where('workspace_id', (int) $profile->workspace_id)
|
||||
->where('baseline_profile_id', (int) $profile->getKey())
|
||||
->where('lifecycle_state', BaselineSnapshotLifecycleState::Complete->value)
|
||||
->orderByDesc('completed_at')
|
||||
->orderByDesc('captured_at')
|
||||
->orderByDesc('id')
|
||||
->first();
|
||||
}
|
||||
|
||||
public function resolveLatestAttemptedSnapshot(BaselineProfile $profile): ?BaselineSnapshot
|
||||
{
|
||||
return BaselineSnapshot::query()
|
||||
->where('workspace_id', (int) $profile->workspace_id)
|
||||
->where('baseline_profile_id', (int) $profile->getKey())
|
||||
->orderByDesc('captured_at')
|
||||
->orderByDesc('id')
|
||||
->first();
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{
|
||||
* ok: bool,
|
||||
* snapshot: ?BaselineSnapshot,
|
||||
* effective_snapshot: ?BaselineSnapshot,
|
||||
* latest_attempted_snapshot: ?BaselineSnapshot,
|
||||
* reason_code: ?string
|
||||
* }
|
||||
*/
|
||||
public function resolveCompareSnapshot(BaselineProfile $profile, ?BaselineSnapshot $explicitSnapshot = null): array
|
||||
{
|
||||
$effectiveSnapshot = $this->resolveEffectiveSnapshot($profile);
|
||||
$latestAttemptedSnapshot = $this->resolveLatestAttemptedSnapshot($profile);
|
||||
|
||||
if ($explicitSnapshot instanceof BaselineSnapshot) {
|
||||
if ((int) $explicitSnapshot->workspace_id !== (int) $profile->workspace_id
|
||||
|| (int) $explicitSnapshot->baseline_profile_id !== (int) $profile->getKey()) {
|
||||
return [
|
||||
'ok' => false,
|
||||
'snapshot' => null,
|
||||
'effective_snapshot' => $effectiveSnapshot,
|
||||
'latest_attempted_snapshot' => $latestAttemptedSnapshot,
|
||||
'reason_code' => BaselineReasonCodes::COMPARE_INVALID_SNAPSHOT,
|
||||
];
|
||||
}
|
||||
|
||||
$reasonCode = $this->compareBlockedReasonForSnapshot($explicitSnapshot, $effectiveSnapshot, explicitSelection: true);
|
||||
|
||||
return [
|
||||
'ok' => $reasonCode === null,
|
||||
'snapshot' => $reasonCode === null ? $explicitSnapshot : null,
|
||||
'effective_snapshot' => $effectiveSnapshot,
|
||||
'latest_attempted_snapshot' => $latestAttemptedSnapshot,
|
||||
'reason_code' => $reasonCode,
|
||||
];
|
||||
}
|
||||
|
||||
if ($effectiveSnapshot instanceof BaselineSnapshot) {
|
||||
return [
|
||||
'ok' => true,
|
||||
'snapshot' => $effectiveSnapshot,
|
||||
'effective_snapshot' => $effectiveSnapshot,
|
||||
'latest_attempted_snapshot' => $latestAttemptedSnapshot,
|
||||
'reason_code' => null,
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
'ok' => false,
|
||||
'snapshot' => null,
|
||||
'effective_snapshot' => null,
|
||||
'latest_attempted_snapshot' => $latestAttemptedSnapshot,
|
||||
'reason_code' => $this->profileBlockedReason($latestAttemptedSnapshot),
|
||||
];
|
||||
}
|
||||
|
||||
public function isHistoricallySuperseded(BaselineSnapshot $snapshot, ?BaselineSnapshot $effectiveSnapshot = null): bool
|
||||
{
|
||||
$effectiveSnapshot ??= BaselineSnapshot::query()
|
||||
->where('workspace_id', (int) $snapshot->workspace_id)
|
||||
->where('baseline_profile_id', (int) $snapshot->baseline_profile_id)
|
||||
->where('lifecycle_state', BaselineSnapshotLifecycleState::Complete->value)
|
||||
->orderByDesc('completed_at')
|
||||
->orderByDesc('captured_at')
|
||||
->orderByDesc('id')
|
||||
->first();
|
||||
|
||||
if (! $effectiveSnapshot instanceof BaselineSnapshot) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return $snapshot->isConsumable()
|
||||
&& (int) $effectiveSnapshot->getKey() !== (int) $snapshot->getKey();
|
||||
}
|
||||
|
||||
public function artifactReasonCode(BaselineSnapshot $snapshot, ?BaselineSnapshot $effectiveSnapshot = null): ?string
|
||||
{
|
||||
if (! $effectiveSnapshot instanceof BaselineSnapshot) {
|
||||
$snapshot->loadMissing('baselineProfile');
|
||||
|
||||
$profile = $snapshot->baselineProfile;
|
||||
|
||||
if ($profile instanceof BaselineProfile) {
|
||||
$effectiveSnapshot = $this->resolveEffectiveSnapshot($profile);
|
||||
}
|
||||
}
|
||||
|
||||
if ($snapshot->isBuilding()) {
|
||||
return BaselineReasonCodes::SNAPSHOT_BUILDING;
|
||||
}
|
||||
|
||||
if ($snapshot->isIncomplete()) {
|
||||
$completionMeta = is_array($snapshot->completion_meta_jsonb) ? $snapshot->completion_meta_jsonb : [];
|
||||
$reasonCode = $completionMeta['finalization_reason_code'] ?? null;
|
||||
|
||||
return is_string($reasonCode) && trim($reasonCode) !== ''
|
||||
? trim($reasonCode)
|
||||
: BaselineReasonCodes::SNAPSHOT_INCOMPLETE;
|
||||
}
|
||||
|
||||
if ($this->isHistoricallySuperseded($snapshot, $effectiveSnapshot)) {
|
||||
return BaselineReasonCodes::SNAPSHOT_SUPERSEDED;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private function compareBlockedReasonForSnapshot(
|
||||
BaselineSnapshot $snapshot,
|
||||
?BaselineSnapshot $effectiveSnapshot,
|
||||
bool $explicitSelection,
|
||||
): ?string {
|
||||
if ($snapshot->isBuilding()) {
|
||||
return BaselineReasonCodes::COMPARE_SNAPSHOT_BUILDING;
|
||||
}
|
||||
|
||||
if ($snapshot->isIncomplete()) {
|
||||
return BaselineReasonCodes::COMPARE_SNAPSHOT_INCOMPLETE;
|
||||
}
|
||||
|
||||
if (! $snapshot->isConsumable()) {
|
||||
return BaselineReasonCodes::COMPARE_NO_CONSUMABLE_SNAPSHOT;
|
||||
}
|
||||
|
||||
if ($explicitSelection && $effectiveSnapshot instanceof BaselineSnapshot && (int) $effectiveSnapshot->getKey() !== (int) $snapshot->getKey()) {
|
||||
return BaselineReasonCodes::COMPARE_SNAPSHOT_SUPERSEDED;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private function profileBlockedReason(?BaselineSnapshot $latestAttemptedSnapshot): string
|
||||
{
|
||||
return match (true) {
|
||||
$latestAttemptedSnapshot?->isBuilding() === true => BaselineReasonCodes::COMPARE_SNAPSHOT_BUILDING,
|
||||
$latestAttemptedSnapshot?->isIncomplete() === true => BaselineReasonCodes::COMPARE_SNAPSHOT_INCOMPLETE,
|
||||
default => BaselineReasonCodes::COMPARE_NO_CONSUMABLE_SNAPSHOT,
|
||||
};
|
||||
}
|
||||
}
|
||||
@ -14,6 +14,7 @@
|
||||
use App\Support\Ui\EnterpriseDetail\EnterpriseDetailPageData;
|
||||
use App\Support\Ui\EnterpriseDetail\EnterpriseDetailSectionFactory;
|
||||
use App\Support\Ui\EnterpriseDetail\SummaryHeaderData;
|
||||
use App\Support\Ui\GovernanceArtifactTruth\ArtifactTruthEnvelope;
|
||||
use App\Support\Ui\GovernanceArtifactTruth\ArtifactTruthPresenter;
|
||||
use Illuminate\Database\Eloquent\Collection as EloquentCollection;
|
||||
use Illuminate\Support\Carbon;
|
||||
@ -99,13 +100,21 @@ public function presentEnterpriseDetail(BaselineSnapshot $snapshot, array $relat
|
||||
{
|
||||
$rendered = $this->present($snapshot);
|
||||
$factory = new EnterpriseDetailSectionFactory;
|
||||
$truth = app(ArtifactTruthPresenter::class)->forBaselineSnapshot($snapshot);
|
||||
|
||||
$stateSpec = $this->gapStatusSpec($rendered->overallGapCount);
|
||||
$stateBadge = $factory->statusBadge(
|
||||
$stateSpec->label,
|
||||
$stateSpec->color,
|
||||
$stateSpec->icon,
|
||||
$stateSpec->iconColor,
|
||||
$truthBadge = $factory->statusBadge(
|
||||
$truth->primaryBadgeSpec()->label,
|
||||
$truth->primaryBadgeSpec()->color,
|
||||
$truth->primaryBadgeSpec()->icon,
|
||||
$truth->primaryBadgeSpec()->iconColor,
|
||||
);
|
||||
|
||||
$lifecycleSpec = BadgeRenderer::spec(BadgeDomain::BaselineSnapshotLifecycle, $snapshot->lifecycleState()->value);
|
||||
$lifecycleBadge = $factory->statusBadge(
|
||||
$lifecycleSpec->label,
|
||||
$lifecycleSpec->color,
|
||||
$lifecycleSpec->icon,
|
||||
$lifecycleSpec->iconColor,
|
||||
);
|
||||
|
||||
$fidelitySpec = BadgeRenderer::spec(BadgeDomain::BaselineSnapshotFidelity, $rendered->overallFidelity->value);
|
||||
@ -120,20 +129,26 @@ public function presentEnterpriseDetail(BaselineSnapshot $snapshot, array $relat
|
||||
static fn (array $row): int => (int) ($row['itemCount'] ?? 0),
|
||||
$rendered->summaryRows,
|
||||
));
|
||||
$truth = app(ArtifactTruthPresenter::class)->forBaselineSnapshot($snapshot);
|
||||
$currentTruth = $this->currentTruthPresentation($truth);
|
||||
$currentTruthBadge = $factory->statusBadge(
|
||||
$currentTruth['label'],
|
||||
$currentTruth['color'],
|
||||
$currentTruth['icon'],
|
||||
$currentTruth['iconColor'],
|
||||
);
|
||||
|
||||
return EnterpriseDetailBuilder::make('baseline_snapshot', 'workspace')
|
||||
->header(new SummaryHeaderData(
|
||||
title: $rendered->baselineProfileName ?? 'Baseline snapshot',
|
||||
subtitle: 'Snapshot #'.$rendered->snapshotId,
|
||||
statusBadges: [$stateBadge, $fidelityBadge],
|
||||
statusBadges: [$truthBadge, $lifecycleBadge, $fidelityBadge],
|
||||
keyFacts: [
|
||||
$factory->keyFact('Captured', $this->formatTimestamp($rendered->capturedAt)),
|
||||
$factory->keyFact('Current truth', $currentTruth['label'], badge: $currentTruthBadge),
|
||||
$factory->keyFact('Evidence mix', $rendered->fidelitySummary),
|
||||
$factory->keyFact('Evidence gaps', $rendered->overallGapCount),
|
||||
$factory->keyFact('Captured items', $capturedItemCount),
|
||||
],
|
||||
descriptionHint: 'Capture context, coverage, and governance links stay ahead of technical payload detail.',
|
||||
descriptionHint: 'Current baseline truth, lifecycle proof, and coverage stay ahead of technical payload detail.',
|
||||
))
|
||||
->addSection(
|
||||
$factory->viewSection(
|
||||
@ -175,11 +190,21 @@ public function presentEnterpriseDetail(BaselineSnapshot $snapshot, array $relat
|
||||
->addSupportingCard(
|
||||
$factory->supportingFactsCard(
|
||||
kind: 'status',
|
||||
title: 'Snapshot status',
|
||||
title: 'Snapshot truth',
|
||||
items: [
|
||||
$factory->keyFact('Artifact truth', $truth->primaryLabel, badge: $truthBadge),
|
||||
$factory->keyFact('Lifecycle', $lifecycleSpec->label, badge: $lifecycleBadge),
|
||||
$factory->keyFact('Current truth', $currentTruth['label'], badge: $currentTruthBadge),
|
||||
$factory->keyFact('Next step', $truth->nextStepText()),
|
||||
],
|
||||
),
|
||||
$factory->supportingFactsCard(
|
||||
kind: 'coverage',
|
||||
title: 'Coverage',
|
||||
items: [
|
||||
$factory->keyFact('State', $rendered->stateLabel, badge: $stateBadge),
|
||||
$factory->keyFact('Overall fidelity', $fidelitySpec->label, badge: $fidelityBadge),
|
||||
$factory->keyFact('Evidence gaps', $rendered->overallGapCount),
|
||||
$factory->keyFact('Captured items', $capturedItemCount),
|
||||
],
|
||||
),
|
||||
$factory->supportingFactsCard(
|
||||
@ -187,6 +212,8 @@ public function presentEnterpriseDetail(BaselineSnapshot $snapshot, array $relat
|
||||
title: 'Capture timing',
|
||||
items: [
|
||||
$factory->keyFact('Captured', $this->formatTimestamp($rendered->capturedAt)),
|
||||
$factory->keyFact('Completed', $this->formatTimestamp($snapshot->completed_at?->toIso8601String())),
|
||||
$factory->keyFact('Failed', $this->formatTimestamp($snapshot->failed_at?->toIso8601String())),
|
||||
$factory->keyFact('Identity hash', $rendered->snapshotIdentityHash),
|
||||
],
|
||||
),
|
||||
@ -338,6 +365,33 @@ private function gapStatusSpec(int $gapCount): \App\Support\Badges\BadgeSpec
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{label: string, color: string, icon: string, iconColor: string}
|
||||
*/
|
||||
private function currentTruthPresentation(ArtifactTruthEnvelope $truth): array
|
||||
{
|
||||
return match ($truth->artifactExistence) {
|
||||
'historical_only' => [
|
||||
'label' => 'Historical trace',
|
||||
'color' => 'gray',
|
||||
'icon' => 'heroicon-m-clock',
|
||||
'iconColor' => 'gray',
|
||||
],
|
||||
'created_but_not_usable' => [
|
||||
'label' => 'Not compare input',
|
||||
'color' => 'warning',
|
||||
'icon' => 'heroicon-m-exclamation-triangle',
|
||||
'iconColor' => 'warning',
|
||||
],
|
||||
default => [
|
||||
'label' => 'Current baseline',
|
||||
'color' => 'success',
|
||||
'icon' => 'heroicon-m-check-badge',
|
||||
'iconColor' => 'success',
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
private function typeLabel(string $policyType): string
|
||||
{
|
||||
return InventoryPolicyTypeMeta::baselineCompareLabel($policyType)
|
||||
|
||||
@ -17,6 +17,7 @@
|
||||
use App\Support\OperationRunStatus;
|
||||
use App\Support\Operations\ExecutionAuthorityMode;
|
||||
use App\Support\Operations\ExecutionDenialReasonCode;
|
||||
use App\Support\Operations\LifecycleReconciliationReason;
|
||||
use App\Support\Operations\OperationRunCapabilityResolver;
|
||||
use App\Support\Operations\QueuedExecutionLegitimacyDecision;
|
||||
use App\Support\OpsUx\BulkRunContext;
|
||||
@ -62,15 +63,45 @@ public function isStaleQueuedRun(OperationRun $run, int $thresholdMinutes = 5):
|
||||
|
||||
public function failStaleQueuedRun(OperationRun $run, string $message = 'Run was queued but never started.'): OperationRun
|
||||
{
|
||||
return $this->updateRun(
|
||||
return $this->forceFailNonTerminalRun(
|
||||
$run,
|
||||
status: OperationRunStatus::Completed->value,
|
||||
outcome: OperationRunOutcome::Failed->value,
|
||||
failures: [
|
||||
[
|
||||
'code' => 'run.stale_queued',
|
||||
'message' => $message,
|
||||
],
|
||||
reasonCode: LifecycleReconciliationReason::StaleQueued->value,
|
||||
message: $message,
|
||||
source: 'scheduled_reconciler',
|
||||
evidence: [
|
||||
'status' => OperationRunStatus::Queued->value,
|
||||
'created_at' => $run->created_at?->toIso8601String(),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
public function isStaleRunningRun(OperationRun $run, int $thresholdMinutes = 15): bool
|
||||
{
|
||||
if ($run->status !== OperationRunStatus::Running->value) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$startedAt = $run->started_at ?? $run->created_at;
|
||||
|
||||
if ($startedAt === null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return $startedAt->lte(now()->subMinutes(max(1, $thresholdMinutes)));
|
||||
}
|
||||
|
||||
public function failStaleRunningRun(
|
||||
OperationRun $run,
|
||||
string $message = 'Run stopped reporting progress and was marked failed.',
|
||||
): OperationRun {
|
||||
return $this->forceFailNonTerminalRun(
|
||||
$run,
|
||||
reasonCode: LifecycleReconciliationReason::StaleRunning->value,
|
||||
message: $message,
|
||||
source: 'scheduled_reconciler',
|
||||
evidence: [
|
||||
'status' => OperationRunStatus::Running->value,
|
||||
'started_at' => ($run->started_at ?? $run->created_at)?->toIso8601String(),
|
||||
],
|
||||
);
|
||||
}
|
||||
@ -721,6 +752,136 @@ public function failRun(OperationRun $run, Throwable $e): OperationRun
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $evidence
|
||||
* @param array<string, mixed> $summaryCounts
|
||||
*/
|
||||
public function forceFailNonTerminalRun(
|
||||
OperationRun $run,
|
||||
string $reasonCode,
|
||||
string $message,
|
||||
string $source = 'scheduled_reconciler',
|
||||
array $evidence = [],
|
||||
array $summaryCounts = [],
|
||||
): OperationRun {
|
||||
return $this->updateRunWithReconciliation(
|
||||
run: $run,
|
||||
status: OperationRunStatus::Completed->value,
|
||||
outcome: OperationRunOutcome::Failed->value,
|
||||
summaryCounts: $summaryCounts,
|
||||
failures: [[
|
||||
'code' => $reasonCode,
|
||||
'reason_code' => $reasonCode,
|
||||
'message' => $message,
|
||||
]],
|
||||
reasonCode: $reasonCode,
|
||||
reasonMessage: $message,
|
||||
source: $source,
|
||||
evidence: $evidence,
|
||||
);
|
||||
}
|
||||
|
||||
public function bridgeFailedJobFailure(
|
||||
OperationRun $run,
|
||||
Throwable $exception,
|
||||
string $source = 'failed_callback',
|
||||
): OperationRun {
|
||||
$reason = $this->bridgeReasonForThrowable($exception);
|
||||
$message = $reason->defaultMessage();
|
||||
$exceptionMessage = $this->sanitizeMessage($exception->getMessage());
|
||||
|
||||
if ($exceptionMessage !== '') {
|
||||
$message = $exceptionMessage;
|
||||
}
|
||||
|
||||
return $this->forceFailNonTerminalRun(
|
||||
$run,
|
||||
reasonCode: $reason->value,
|
||||
message: $message,
|
||||
source: $source,
|
||||
evidence: [
|
||||
'exception_class' => $exception::class,
|
||||
'bridge_source' => $source,
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $summaryCounts
|
||||
* @param array<int, array{code?: mixed, reason_code?: mixed, message?: mixed}> $failures
|
||||
* @param array<string, mixed> $evidence
|
||||
*/
|
||||
public function updateRunWithReconciliation(
|
||||
OperationRun $run,
|
||||
string $status,
|
||||
string $outcome,
|
||||
array $summaryCounts,
|
||||
array $failures,
|
||||
string $reasonCode,
|
||||
string $reasonMessage,
|
||||
string $source = 'scheduled_reconciler',
|
||||
array $evidence = [],
|
||||
): OperationRun {
|
||||
/** @var OperationRun $updated */
|
||||
$updated = DB::transaction(function () use (
|
||||
$run,
|
||||
$status,
|
||||
$outcome,
|
||||
$summaryCounts,
|
||||
$failures,
|
||||
$reasonCode,
|
||||
$reasonMessage,
|
||||
$source,
|
||||
$evidence,
|
||||
): OperationRun {
|
||||
$locked = OperationRun::query()
|
||||
->whereKey($run->getKey())
|
||||
->lockForUpdate()
|
||||
->first();
|
||||
|
||||
if (! $locked instanceof OperationRun) {
|
||||
return $run;
|
||||
}
|
||||
|
||||
if ((string) $locked->status === OperationRunStatus::Completed->value) {
|
||||
return $locked;
|
||||
}
|
||||
|
||||
$context = is_array($locked->context) ? $locked->context : [];
|
||||
$context['reason_code'] = RunFailureSanitizer::normalizeReasonCode($reasonCode);
|
||||
$context['reconciliation'] = $this->reconciliationMetadata(
|
||||
reasonCode: $reasonCode,
|
||||
reasonMessage: $reasonMessage,
|
||||
source: $source,
|
||||
evidence: $evidence,
|
||||
);
|
||||
|
||||
$translatedContext = $this->withReasonTranslationContext(
|
||||
run: $locked,
|
||||
context: $context,
|
||||
failures: $failures,
|
||||
);
|
||||
|
||||
$locked->update([
|
||||
'context' => $translatedContext ?? $context,
|
||||
]);
|
||||
|
||||
$locked->refresh();
|
||||
|
||||
return $this->updateRun(
|
||||
$locked,
|
||||
status: $status,
|
||||
outcome: $outcome,
|
||||
summaryCounts: $summaryCounts,
|
||||
failures: $failures,
|
||||
);
|
||||
});
|
||||
|
||||
$updated->refresh();
|
||||
|
||||
return $updated;
|
||||
}
|
||||
|
||||
/**
|
||||
* Finalize a run as blocked with deterministic reason_code + link-only next steps.
|
||||
*
|
||||
@ -1033,16 +1194,49 @@ private function isDirectlyTranslatableReason(string $reasonCode): bool
|
||||
|
||||
return ProviderReasonCodes::isKnown($reasonCode)
|
||||
|| ExecutionDenialReasonCode::tryFrom($reasonCode) instanceof ExecutionDenialReasonCode
|
||||
|| LifecycleReconciliationReason::tryFrom($reasonCode) instanceof LifecycleReconciliationReason
|
||||
|| TenantOperabilityReasonCode::tryFrom($reasonCode) instanceof TenantOperabilityReasonCode
|
||||
|| RbacReason::tryFrom($reasonCode) instanceof RbacReason;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $evidence
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function reconciliationMetadata(
|
||||
string $reasonCode,
|
||||
string $reasonMessage,
|
||||
string $source,
|
||||
array $evidence,
|
||||
): array {
|
||||
return [
|
||||
'reconciled_at' => now()->toIso8601String(),
|
||||
'reason' => RunFailureSanitizer::normalizeReasonCode($reasonCode),
|
||||
'reason_code' => RunFailureSanitizer::normalizeReasonCode($reasonCode),
|
||||
'reason_message' => $this->sanitizeMessage($reasonMessage),
|
||||
'source' => $this->sanitizeFailureCode($source),
|
||||
'evidence' => $evidence,
|
||||
];
|
||||
}
|
||||
|
||||
private function bridgeReasonForThrowable(Throwable $exception): LifecycleReconciliationReason
|
||||
{
|
||||
$className = strtolower(class_basename($exception));
|
||||
|
||||
if (str_contains($className, 'timeout') || str_contains($className, 'attempts')) {
|
||||
return LifecycleReconciliationReason::InfrastructureTimeoutOrAbandonment;
|
||||
}
|
||||
|
||||
return LifecycleReconciliationReason::QueueFailureBridge;
|
||||
}
|
||||
|
||||
private function writeTerminalAudit(OperationRun $run): void
|
||||
{
|
||||
$tenant = $run->tenant;
|
||||
$workspace = $run->workspace;
|
||||
$context = is_array($run->context) ? $run->context : [];
|
||||
$executionLegitimacy = is_array($context['execution_legitimacy'] ?? null) ? $context['execution_legitimacy'] : [];
|
||||
$reconciliation = is_array($context['reconciliation'] ?? null) ? $context['reconciliation'] : [];
|
||||
$operationLabel = OperationCatalog::label((string) $run->type);
|
||||
|
||||
$action = match ($run->outcome) {
|
||||
@ -1072,6 +1266,7 @@ private function writeTerminalAudit(OperationRun $run): void
|
||||
'authority_mode' => $executionLegitimacy['authority_mode'] ?? ($context['execution_authority_mode'] ?? null),
|
||||
'acting_identity_type' => $executionLegitimacy['initiator']['identity_type'] ?? ($run->user instanceof User ? 'user' : 'system'),
|
||||
'blocked_by' => $context['blocked_by'] ?? null,
|
||||
'reconciliation' => $reconciliation !== [] ? $reconciliation : null,
|
||||
],
|
||||
],
|
||||
workspace: $workspace,
|
||||
|
||||
139
app/Services/Operations/OperationLifecyclePolicyValidator.php
Normal file
139
app/Services/Operations/OperationLifecyclePolicyValidator.php
Normal file
@ -0,0 +1,139 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Operations;
|
||||
|
||||
use App\Jobs\Concerns\BridgesFailedOperationRun;
|
||||
use App\Support\Operations\OperationLifecyclePolicy;
|
||||
use RuntimeException;
|
||||
|
||||
final class OperationLifecyclePolicyValidator
|
||||
{
|
||||
public function __construct(
|
||||
private readonly OperationLifecyclePolicy $policy,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* @return array{
|
||||
* valid:bool,
|
||||
* errors:array<int, string>,
|
||||
* definitions:array<string, array<string, mixed>>
|
||||
* }
|
||||
*/
|
||||
public function validate(): array
|
||||
{
|
||||
$errors = [];
|
||||
$definitions = [];
|
||||
|
||||
foreach ($this->policy->coveredTypeNames() as $operationType) {
|
||||
$definition = $this->policy->definition($operationType);
|
||||
|
||||
if ($definition === null) {
|
||||
$errors[] = sprintf('Missing lifecycle policy definition for [%s].', $operationType);
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$definitions[$operationType] = $definition;
|
||||
$jobClass = $this->policy->jobClass($operationType);
|
||||
|
||||
if ($jobClass === null || ! class_exists($jobClass)) {
|
||||
$errors[] = sprintf('Lifecycle policy [%s] points to a missing job class.', $operationType);
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$timeout = $this->jobTimeoutSeconds($operationType);
|
||||
|
||||
if (! is_int($timeout) || $timeout <= 0) {
|
||||
$errors[] = sprintf('Lifecycle policy [%s] requires an explicit positive job timeout.', $operationType);
|
||||
}
|
||||
|
||||
if (! $this->jobFailsOnTimeout($operationType)) {
|
||||
$errors[] = sprintf('Lifecycle policy [%s] requires failOnTimeout=true.', $operationType);
|
||||
}
|
||||
|
||||
if ($this->policy->requiresDirectFailedBridge($operationType) && ! $this->jobUsesDirectFailedBridge($operationType)) {
|
||||
$errors[] = sprintf('Lifecycle policy [%s] requires a direct failed-job bridge.', $operationType);
|
||||
}
|
||||
|
||||
$retryAfter = $this->policy->queueRetryAfterSeconds($this->policy->queueConnection($operationType));
|
||||
$safetyMargin = $this->policy->retryAfterSafetyMarginSeconds();
|
||||
|
||||
if (is_int($timeout) && is_int($retryAfter) && $timeout >= ($retryAfter - $safetyMargin)) {
|
||||
$errors[] = sprintf(
|
||||
'Lifecycle policy [%s] has timeout %d which is not safely below retry_after %d (margin %d).',
|
||||
$operationType,
|
||||
$timeout,
|
||||
$retryAfter,
|
||||
$safetyMargin,
|
||||
);
|
||||
}
|
||||
|
||||
$expectedMaxRuntime = $this->policy->expectedMaxRuntimeSeconds($operationType);
|
||||
|
||||
if (is_int($expectedMaxRuntime) && is_int($retryAfter) && $expectedMaxRuntime >= ($retryAfter - $safetyMargin)) {
|
||||
$errors[] = sprintf(
|
||||
'Lifecycle policy [%s] expected runtime %d is not safely below retry_after %d (margin %d).',
|
||||
$operationType,
|
||||
$expectedMaxRuntime,
|
||||
$retryAfter,
|
||||
$safetyMargin,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
'valid' => $errors === [],
|
||||
'errors' => $errors,
|
||||
'definitions' => $definitions,
|
||||
];
|
||||
}
|
||||
|
||||
public function assertValid(): void
|
||||
{
|
||||
$result = $this->validate();
|
||||
|
||||
if (($result['valid'] ?? false) === true) {
|
||||
return;
|
||||
}
|
||||
|
||||
throw new RuntimeException(implode(' ', $result['errors'] ?? ['Lifecycle policy validation failed.']));
|
||||
}
|
||||
|
||||
public function jobTimeoutSeconds(string $operationType): ?int
|
||||
{
|
||||
$jobClass = $this->policy->jobClass($operationType);
|
||||
|
||||
if ($jobClass === null || ! class_exists($jobClass)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$timeout = get_class_vars($jobClass)['timeout'] ?? null;
|
||||
|
||||
return is_numeric($timeout) ? (int) $timeout : null;
|
||||
}
|
||||
|
||||
public function jobFailsOnTimeout(string $operationType): bool
|
||||
{
|
||||
$jobClass = $this->policy->jobClass($operationType);
|
||||
|
||||
if ($jobClass === null || ! class_exists($jobClass)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return (bool) (get_class_vars($jobClass)['failOnTimeout'] ?? false);
|
||||
}
|
||||
|
||||
public function jobUsesDirectFailedBridge(string $operationType): bool
|
||||
{
|
||||
$jobClass = $this->policy->jobClass($operationType);
|
||||
|
||||
if ($jobClass === null || ! class_exists($jobClass)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return in_array(BridgesFailedOperationRun::class, class_uses_recursive($jobClass), true);
|
||||
}
|
||||
}
|
||||
200
app/Services/Operations/OperationLifecycleReconciler.php
Normal file
200
app/Services/Operations/OperationLifecycleReconciler.php
Normal file
@ -0,0 +1,200 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Operations;
|
||||
|
||||
use App\Models\OperationRun;
|
||||
use App\Services\OperationRunService;
|
||||
use App\Support\OperationRunOutcome;
|
||||
use App\Support\OperationRunStatus;
|
||||
use App\Support\Operations\LifecycleReconciliationReason;
|
||||
use App\Support\Operations\OperationLifecyclePolicy;
|
||||
use App\Support\Operations\OperationRunFreshnessState;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
|
||||
final class OperationLifecycleReconciler
|
||||
{
|
||||
public function __construct(
|
||||
private readonly OperationLifecyclePolicy $policy,
|
||||
private readonly OperationRunService $operationRunService,
|
||||
private readonly QueuedExecutionLegitimacyGate $queuedExecutionLegitimacyGate,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* @param array{
|
||||
* types?: array<int, string>,
|
||||
* tenant_ids?: array<int, int>,
|
||||
* workspace_ids?: array<int, int>,
|
||||
* limit?: int,
|
||||
* dry_run?: bool
|
||||
* } $options
|
||||
* @return array{candidates:int,reconciled:int,skipped:int,changes:array<int, array<string, mixed>>}
|
||||
*/
|
||||
public function reconcile(array $options = []): array
|
||||
{
|
||||
$types = array_values(array_filter(
|
||||
$options['types'] ?? $this->policy->coveredTypeNames(),
|
||||
static fn (mixed $type): bool => is_string($type) && trim($type) !== '',
|
||||
));
|
||||
$tenantIds = array_values(array_filter(
|
||||
$options['tenant_ids'] ?? [],
|
||||
static fn (mixed $tenantId): bool => is_int($tenantId) && $tenantId > 0,
|
||||
));
|
||||
$workspaceIds = array_values(array_filter(
|
||||
$options['workspace_ids'] ?? [],
|
||||
static fn (mixed $workspaceId): bool => is_int($workspaceId) && $workspaceId > 0,
|
||||
));
|
||||
$limit = min(max(1, (int) ($options['limit'] ?? $this->policy->reconciliationBatchLimit())), 500);
|
||||
$dryRun = (bool) ($options['dry_run'] ?? false);
|
||||
|
||||
$runs = OperationRun::query()
|
||||
->with(['tenant', 'user'])
|
||||
->whereIn('type', $types)
|
||||
->whereIn('status', [
|
||||
OperationRunStatus::Queued->value,
|
||||
OperationRunStatus::Running->value,
|
||||
])
|
||||
->when(
|
||||
$tenantIds !== [],
|
||||
fn (Builder $query): Builder => $query->whereIn('tenant_id', $tenantIds),
|
||||
)
|
||||
->when(
|
||||
$workspaceIds !== [],
|
||||
fn (Builder $query): Builder => $query->whereIn('workspace_id', $workspaceIds),
|
||||
)
|
||||
->orderBy('id')
|
||||
->limit($limit)
|
||||
->get();
|
||||
|
||||
$changes = [];
|
||||
$reconciled = 0;
|
||||
$skipped = 0;
|
||||
|
||||
foreach ($runs as $run) {
|
||||
$change = $this->reconcileRun($run, $dryRun);
|
||||
|
||||
if ($change === null) {
|
||||
$skipped++;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$changes[] = $change;
|
||||
|
||||
if (($change['applied'] ?? false) === true) {
|
||||
$reconciled++;
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
'candidates' => $runs->count(),
|
||||
'reconciled' => $reconciled,
|
||||
'skipped' => $skipped,
|
||||
'changes' => $changes,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>|null
|
||||
*/
|
||||
public function reconcileRun(OperationRun $run, bool $dryRun = false): ?array
|
||||
{
|
||||
$assessment = $this->assessment($run);
|
||||
|
||||
if ($assessment === null || ($assessment['should_reconcile'] ?? false) !== true) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$before = [
|
||||
'status' => (string) $run->status,
|
||||
'outcome' => (string) $run->outcome,
|
||||
'freshness_state' => OperationRunFreshnessState::forRun($run, $this->policy)->value,
|
||||
];
|
||||
$after = [
|
||||
'status' => OperationRunStatus::Completed->value,
|
||||
'outcome' => OperationRunOutcome::Failed->value,
|
||||
'freshness_state' => OperationRunFreshnessState::ReconciledFailed->value,
|
||||
];
|
||||
|
||||
if ($dryRun) {
|
||||
return [
|
||||
'applied' => false,
|
||||
'operation_run_id' => (int) $run->getKey(),
|
||||
'type' => (string) $run->type,
|
||||
'before' => $before,
|
||||
'after' => $after,
|
||||
'reason_code' => $assessment['reason_code'],
|
||||
'reason_message' => $assessment['reason_message'],
|
||||
'evidence' => $assessment['evidence'],
|
||||
];
|
||||
}
|
||||
|
||||
$updated = $this->operationRunService->forceFailNonTerminalRun(
|
||||
run: $run,
|
||||
reasonCode: (string) $assessment['reason_code'],
|
||||
message: (string) $assessment['reason_message'],
|
||||
source: 'scheduled_reconciler',
|
||||
evidence: is_array($assessment['evidence'] ?? null) ? $assessment['evidence'] : [],
|
||||
);
|
||||
|
||||
return [
|
||||
'applied' => true,
|
||||
'operation_run_id' => (int) $updated->getKey(),
|
||||
'type' => (string) $updated->type,
|
||||
'before' => $before,
|
||||
'after' => $after,
|
||||
'reason_code' => $assessment['reason_code'],
|
||||
'reason_message' => $assessment['reason_message'],
|
||||
'evidence' => $assessment['evidence'],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{should_reconcile:bool,reason_code:string,reason_message:string,evidence:array<string, mixed>}|null
|
||||
*/
|
||||
public function assessment(OperationRun $run): ?array
|
||||
{
|
||||
if ((string) $run->status === OperationRunStatus::Completed->value) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (! $this->policy->supports((string) $run->type) || ! $this->policy->supportsScheduledReconciliation((string) $run->type)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$freshnessState = OperationRunFreshnessState::forRun($run, $this->policy);
|
||||
|
||||
if (! $freshnessState->isLikelyStale()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$reason = (string) $run->status === OperationRunStatus::Queued->value
|
||||
? LifecycleReconciliationReason::StaleQueued
|
||||
: LifecycleReconciliationReason::StaleRunning;
|
||||
$referenceTime = (string) $run->status === OperationRunStatus::Queued->value
|
||||
? $run->created_at
|
||||
: ($run->started_at ?? $run->created_at);
|
||||
$thresholdSeconds = (string) $run->status === OperationRunStatus::Queued->value
|
||||
? $this->policy->queuedStaleAfterSeconds((string) $run->type)
|
||||
: $this->policy->runningStaleAfterSeconds((string) $run->type);
|
||||
$legitimacy = $this->queuedExecutionLegitimacyGate->evaluate($run)->toArray();
|
||||
|
||||
return [
|
||||
'should_reconcile' => true,
|
||||
'reason_code' => $reason->value,
|
||||
'reason_message' => $reason->defaultMessage(),
|
||||
'evidence' => [
|
||||
'evaluated_at' => now()->toIso8601String(),
|
||||
'freshness_state' => $freshnessState->value,
|
||||
'threshold_seconds' => $thresholdSeconds,
|
||||
'reference_time' => $referenceTime?->toIso8601String(),
|
||||
'status' => (string) $run->status,
|
||||
'execution_legitimacy' => $legitimacy,
|
||||
'terminal_truth_path' => $this->policy->requiresDirectFailedBridge((string) $run->type)
|
||||
? 'direct_and_scheduled'
|
||||
: 'scheduled_only',
|
||||
],
|
||||
];
|
||||
}
|
||||
}
|
||||
@ -20,6 +20,7 @@ final class BadgeCatalog
|
||||
BadgeDomain::GovernanceArtifactFreshness->value => Domains\GovernanceArtifactFreshnessBadge::class,
|
||||
BadgeDomain::GovernanceArtifactPublicationReadiness->value => Domains\GovernanceArtifactPublicationReadinessBadge::class,
|
||||
BadgeDomain::GovernanceArtifactActionability->value => Domains\GovernanceArtifactActionabilityBadge::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,
|
||||
|
||||
@ -11,6 +11,7 @@ enum BadgeDomain: string
|
||||
case GovernanceArtifactFreshness = 'governance_artifact_freshness';
|
||||
case GovernanceArtifactPublicationReadiness = 'governance_artifact_publication_readiness';
|
||||
case GovernanceArtifactActionability = 'governance_artifact_actionability';
|
||||
case BaselineSnapshotLifecycle = 'baseline_snapshot_lifecycle';
|
||||
case BaselineSnapshotFidelity = 'baseline_snapshot_fidelity';
|
||||
case BaselineSnapshotGapStatus = 'baseline_snapshot_gap_status';
|
||||
case OperationRunStatus = 'operation_run_status';
|
||||
|
||||
@ -0,0 +1,28 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Support\Badges\Domains;
|
||||
|
||||
use App\Support\Badges\BadgeCatalog;
|
||||
use App\Support\Badges\BadgeDomain;
|
||||
use App\Support\Badges\BadgeMapper;
|
||||
use App\Support\Badges\BadgeSpec;
|
||||
use App\Support\Badges\OperatorOutcomeTaxonomy;
|
||||
use App\Support\Baselines\BaselineSnapshotLifecycleState;
|
||||
|
||||
final class BaselineSnapshotLifecycleBadge implements BadgeMapper
|
||||
{
|
||||
public function spec(mixed $value): BadgeSpec
|
||||
{
|
||||
$state = BadgeCatalog::normalizeState($value);
|
||||
|
||||
return match ($state) {
|
||||
BaselineSnapshotLifecycleState::Building->value => OperatorOutcomeTaxonomy::spec(BadgeDomain::BaselineSnapshotLifecycle, $state, 'heroicon-m-arrow-path'),
|
||||
BaselineSnapshotLifecycleState::Complete->value => OperatorOutcomeTaxonomy::spec(BadgeDomain::BaselineSnapshotLifecycle, $state, 'heroicon-m-check-circle'),
|
||||
BaselineSnapshotLifecycleState::Incomplete->value => OperatorOutcomeTaxonomy::spec(BadgeDomain::BaselineSnapshotLifecycle, $state, 'heroicon-m-x-circle'),
|
||||
'superseded' => OperatorOutcomeTaxonomy::spec(BadgeDomain::BaselineSnapshotLifecycle, $state, 'heroicon-m-clock'),
|
||||
default => BadgeSpec::unknown(),
|
||||
} ?? BadgeSpec::unknown();
|
||||
}
|
||||
}
|
||||
@ -8,12 +8,46 @@
|
||||
use App\Support\Badges\BadgeSpec;
|
||||
use App\Support\Badges\OperatorOutcomeTaxonomy;
|
||||
use App\Support\OperationRunOutcome;
|
||||
use App\Support\OperationRunStatus;
|
||||
use App\Support\Operations\OperationRunFreshnessState;
|
||||
|
||||
final class OperationRunOutcomeBadge implements BadgeMapper
|
||||
{
|
||||
public function spec(mixed $value): BadgeSpec
|
||||
{
|
||||
$state = BadgeCatalog::normalizeState($value);
|
||||
$state = null;
|
||||
|
||||
if (is_array($value)) {
|
||||
$outcome = BadgeCatalog::normalizeState($value['outcome'] ?? null);
|
||||
$status = BadgeCatalog::normalizeState($value['status'] ?? null);
|
||||
$freshnessState = BadgeCatalog::normalizeState($value['freshness_state'] ?? null);
|
||||
|
||||
if ($outcome === null) {
|
||||
if ($freshnessState === OperationRunFreshnessState::ReconciledFailed->value) {
|
||||
$outcome = OperationRunOutcome::Failed->value;
|
||||
} elseif (
|
||||
$freshnessState === OperationRunFreshnessState::LikelyStale->value
|
||||
|| in_array($status, [OperationRunStatus::Queued->value, OperationRunStatus::Running->value], true)
|
||||
) {
|
||||
$outcome = OperationRunOutcome::Pending->value;
|
||||
}
|
||||
}
|
||||
|
||||
if ($outcome === OperationRunOutcome::Failed->value
|
||||
&& $freshnessState === OperationRunFreshnessState::ReconciledFailed->value
|
||||
) {
|
||||
return new BadgeSpec(
|
||||
label: 'Reconciled failed',
|
||||
color: 'danger',
|
||||
icon: 'heroicon-m-arrow-path-rounded-square',
|
||||
iconColor: 'danger',
|
||||
);
|
||||
}
|
||||
|
||||
$state = $outcome;
|
||||
}
|
||||
|
||||
$state ??= BadgeCatalog::normalizeState($value);
|
||||
|
||||
return match ($state) {
|
||||
OperationRunOutcome::Pending->value => OperatorOutcomeTaxonomy::spec(BadgeDomain::OperationRunOutcome, $state, 'heroicon-m-clock'),
|
||||
|
||||
@ -8,12 +8,33 @@
|
||||
use App\Support\Badges\BadgeSpec;
|
||||
use App\Support\Badges\OperatorOutcomeTaxonomy;
|
||||
use App\Support\OperationRunStatus;
|
||||
use App\Support\Operations\OperationRunFreshnessState;
|
||||
|
||||
final class OperationRunStatusBadge implements BadgeMapper
|
||||
{
|
||||
public function spec(mixed $value): BadgeSpec
|
||||
{
|
||||
$state = BadgeCatalog::normalizeState($value);
|
||||
$state = null;
|
||||
|
||||
if (is_array($value)) {
|
||||
$status = BadgeCatalog::normalizeState($value['status'] ?? null);
|
||||
$freshnessState = BadgeCatalog::normalizeState($value['freshness_state'] ?? null);
|
||||
|
||||
if (in_array($status, [OperationRunStatus::Queued->value, OperationRunStatus::Running->value], true)
|
||||
&& $freshnessState === OperationRunFreshnessState::LikelyStale->value
|
||||
) {
|
||||
return new BadgeSpec(
|
||||
label: 'Likely stale',
|
||||
color: 'warning',
|
||||
icon: 'heroicon-m-exclamation-triangle',
|
||||
iconColor: 'warning',
|
||||
);
|
||||
}
|
||||
|
||||
$state = $status;
|
||||
}
|
||||
|
||||
$state ??= BadgeCatalog::normalizeState($value);
|
||||
|
||||
return match ($state) {
|
||||
OperationRunStatus::Queued->value => OperatorOutcomeTaxonomy::spec(BadgeDomain::OperationRunStatus, $state, 'heroicon-m-clock'),
|
||||
|
||||
@ -220,6 +220,44 @@ final class OperatorOutcomeTaxonomy
|
||||
'notes' => 'The artifact cannot be trusted for the primary task until an operator addresses the issue.',
|
||||
],
|
||||
],
|
||||
'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',
|
||||
|
||||
@ -5,11 +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 Illuminate\Support\Facades\Cache;
|
||||
@ -73,7 +75,11 @@ public static function forTenant(?Tenant $tenant): self
|
||||
|
||||
$profileName = (string) $profile->name;
|
||||
$profileId = (int) $profile->getKey();
|
||||
$snapshotId = $profile->active_snapshot_id !== null ? (int) $profile->active_snapshot_id : null;
|
||||
$truthResolution = app(BaselineSnapshotTruthResolver::class)->resolveCompareSnapshot($profile);
|
||||
$effectiveSnapshot = $truthResolution['effective_snapshot'] ?? null;
|
||||
$snapshotId = $effectiveSnapshot instanceof BaselineSnapshot ? (int) $effectiveSnapshot->getKey() : null;
|
||||
$snapshotReasonCode = is_string($truthResolution['reason_code'] ?? null) ? (string) $truthResolution['reason_code'] : null;
|
||||
$snapshotReasonMessage = self::missingSnapshotMessage($snapshotReasonCode);
|
||||
|
||||
$profileScope = BaselineScope::fromJsonb(
|
||||
is_array($profile->scope_jsonb) ? $profile->scope_jsonb : null,
|
||||
@ -86,12 +92,21 @@ public static function forTenant(?Tenant $tenant): self
|
||||
$duplicateNamePoliciesCount = self::duplicateNamePoliciesCount($tenant, $effectiveScope);
|
||||
|
||||
if ($snapshotId === null) {
|
||||
return self::empty(
|
||||
'no_snapshot',
|
||||
'The baseline profile has no active snapshot yet. A workspace manager needs to capture a snapshot first.',
|
||||
return new self(
|
||||
state: 'no_snapshot',
|
||||
message: $snapshotReasonMessage ?? 'The baseline profile has no complete snapshot yet. A workspace manager needs to capture a baseline first.',
|
||||
profileName: $profileName,
|
||||
profileId: $profileId,
|
||||
snapshotId: null,
|
||||
duplicateNamePoliciesCount: $duplicateNamePoliciesCount,
|
||||
operationRunId: null,
|
||||
findingsCount: null,
|
||||
severityCounts: [],
|
||||
lastComparedHuman: null,
|
||||
lastComparedIso: null,
|
||||
failureReason: null,
|
||||
reasonCode: $snapshotReasonCode,
|
||||
reasonMessage: $snapshotReasonMessage,
|
||||
);
|
||||
}
|
||||
|
||||
@ -291,6 +306,11 @@ public static function forWidget(?Tenant $tenant): self
|
||||
}
|
||||
|
||||
$profile = $assignment->baselineProfile;
|
||||
$truthResolution = app(BaselineSnapshotTruthResolver::class)->resolveCompareSnapshot($profile);
|
||||
$effectiveSnapshot = $truthResolution['effective_snapshot'] ?? null;
|
||||
$snapshotId = $effectiveSnapshot instanceof BaselineSnapshot ? (int) $effectiveSnapshot->getKey() : null;
|
||||
$snapshotReasonCode = is_string($truthResolution['reason_code'] ?? null) ? (string) $truthResolution['reason_code'] : null;
|
||||
$snapshotReasonMessage = self::missingSnapshotMessage($snapshotReasonCode);
|
||||
$scopeKey = 'baseline_profile:'.$profile->getKey();
|
||||
|
||||
$severityRows = Finding::query()
|
||||
@ -314,11 +334,11 @@ public static function forWidget(?Tenant $tenant): self
|
||||
->first();
|
||||
|
||||
return new self(
|
||||
state: $totalFindings > 0 ? 'ready' : 'idle',
|
||||
message: null,
|
||||
state: $snapshotId === null ? 'no_snapshot' : ($totalFindings > 0 ? 'ready' : 'idle'),
|
||||
message: $snapshotId === null ? $snapshotReasonMessage : null,
|
||||
profileName: (string) $profile->name,
|
||||
profileId: (int) $profile->getKey(),
|
||||
snapshotId: $profile->active_snapshot_id !== null ? (int) $profile->active_snapshot_id : null,
|
||||
snapshotId: $snapshotId,
|
||||
duplicateNamePoliciesCount: null,
|
||||
operationRunId: $latestRun instanceof OperationRun ? (int) $latestRun->getKey() : null,
|
||||
findingsCount: $totalFindings,
|
||||
@ -330,6 +350,8 @@ public static function forWidget(?Tenant $tenant): self
|
||||
lastComparedHuman: $latestRun?->finished_at?->diffForHumans(),
|
||||
lastComparedIso: $latestRun?->finished_at?->toIso8601String(),
|
||||
failureReason: null,
|
||||
reasonCode: $snapshotReasonCode,
|
||||
reasonMessage: $snapshotReasonMessage,
|
||||
);
|
||||
}
|
||||
|
||||
@ -583,4 +605,15 @@ private static function empty(
|
||||
failureReason: null,
|
||||
);
|
||||
}
|
||||
|
||||
private static function missingSnapshotMessage(?string $reasonCode): ?string
|
||||
{
|
||||
return match ($reasonCode) {
|
||||
BaselineReasonCodes::COMPARE_SNAPSHOT_BUILDING => 'The latest baseline capture is still building. Compare becomes available after a complete snapshot is finalized.',
|
||||
BaselineReasonCodes::COMPARE_SNAPSHOT_INCOMPLETE => 'The latest baseline capture is incomplete and there is no current complete snapshot to compare against.',
|
||||
BaselineReasonCodes::COMPARE_NO_CONSUMABLE_SNAPSHOT,
|
||||
BaselineReasonCodes::COMPARE_NO_ACTIVE_SNAPSHOT => 'The baseline profile has no complete snapshot yet. A workspace manager needs to capture a baseline first.',
|
||||
default => null,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@ -18,13 +18,71 @@ 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);
|
||||
}
|
||||
}
|
||||
|
||||
42
app/Support/Baselines/BaselineSnapshotLifecycleState.php
Normal file
42
app/Support/Baselines/BaselineSnapshotLifecycleState.php
Normal file
@ -0,0 +1,42 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Support\Baselines;
|
||||
|
||||
enum BaselineSnapshotLifecycleState: string
|
||||
{
|
||||
case Building = 'building';
|
||||
case Complete = 'complete';
|
||||
case Incomplete = 'incomplete';
|
||||
|
||||
public function label(): string
|
||||
{
|
||||
return match ($this) {
|
||||
self::Building => 'Building',
|
||||
self::Complete => 'Complete',
|
||||
self::Incomplete => 'Incomplete',
|
||||
};
|
||||
}
|
||||
|
||||
public function isConsumable(): bool
|
||||
{
|
||||
return $this === self::Complete;
|
||||
}
|
||||
|
||||
public function isTerminal(): bool
|
||||
{
|
||||
return in_array($this, [self::Complete, self::Incomplete], true);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, string>
|
||||
*/
|
||||
public static function values(): array
|
||||
{
|
||||
return array_map(
|
||||
static fn (self $state): string => $state->value,
|
||||
self::cases(),
|
||||
);
|
||||
}
|
||||
}
|
||||
27
app/Support/Filament/PanelThemeAsset.php
Normal file
27
app/Support/Filament/PanelThemeAsset.php
Normal file
@ -0,0 +1,27 @@
|
||||
<?php
|
||||
|
||||
namespace App\Support\Filament;
|
||||
|
||||
use Illuminate\Support\Facades\Vite;
|
||||
|
||||
class PanelThemeAsset
|
||||
{
|
||||
public static function resolve(string $entry): string
|
||||
{
|
||||
$manifest = public_path('build/manifest.json');
|
||||
|
||||
if (! is_file($manifest)) {
|
||||
return Vite::asset($entry);
|
||||
}
|
||||
|
||||
/** @var array<string, array{file?: string}>|null $decoded */
|
||||
$decoded = json_decode((string) file_get_contents($manifest), true);
|
||||
$file = $decoded[$entry]['file'] ?? null;
|
||||
|
||||
if (! is_string($file) || $file === '') {
|
||||
return Vite::asset($entry);
|
||||
}
|
||||
|
||||
return asset('build/'.$file);
|
||||
}
|
||||
}
|
||||
@ -378,7 +378,7 @@ private function resolveBaselineProfileRule(NavigationMatrixRule $rule, Baseline
|
||||
return match ($rule->relationKey) {
|
||||
'baseline_snapshot' => $this->baselineSnapshotEntry(
|
||||
rule: $rule,
|
||||
snapshotId: is_numeric($profile->active_snapshot_id ?? null) ? (int) $profile->active_snapshot_id : null,
|
||||
snapshotId: $profile->resolveCurrentConsumableSnapshot()?->getKey(),
|
||||
workspaceId: (int) $profile->workspace_id,
|
||||
),
|
||||
default => null,
|
||||
|
||||
83
app/Support/Operations/LifecycleReconciliationReason.php
Normal file
83
app/Support/Operations/LifecycleReconciliationReason.php
Normal file
@ -0,0 +1,83 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Support\Operations;
|
||||
|
||||
use App\Support\ReasonTranslation\NextStepOption;
|
||||
use App\Support\ReasonTranslation\ReasonResolutionEnvelope;
|
||||
|
||||
enum LifecycleReconciliationReason: string
|
||||
{
|
||||
case StaleQueued = 'run.stale_queued';
|
||||
case StaleRunning = 'run.stale_running';
|
||||
case InfrastructureTimeoutOrAbandonment = 'run.infrastructure_timeout_or_abandonment';
|
||||
case QueueFailureBridge = 'run.queue_failure_bridge';
|
||||
case AdapterOutOfSync = 'run.adapter_out_of_sync';
|
||||
|
||||
public function operatorLabel(): string
|
||||
{
|
||||
return match ($this) {
|
||||
self::StaleQueued => 'Run never started',
|
||||
self::StaleRunning => 'Run stopped reporting progress',
|
||||
self::InfrastructureTimeoutOrAbandonment => 'Infrastructure ended the run',
|
||||
self::QueueFailureBridge => 'Queue failure was reconciled',
|
||||
self::AdapterOutOfSync => 'Lifecycle was reconciled from related records',
|
||||
};
|
||||
}
|
||||
|
||||
public function shortExplanation(): string
|
||||
{
|
||||
return match ($this) {
|
||||
self::StaleQueued => 'The run stayed queued past its lifecycle window and was marked failed.',
|
||||
self::StaleRunning => 'The run stayed active past its lifecycle window and was marked failed.',
|
||||
self::InfrastructureTimeoutOrAbandonment => 'Queue infrastructure ended the job before normal completion could update the run.',
|
||||
self::QueueFailureBridge => 'The platform bridged a queue failure back to the owning run and marked it failed.',
|
||||
self::AdapterOutOfSync => 'A related restore record reached terminal truth before the operation run was updated.',
|
||||
};
|
||||
}
|
||||
|
||||
public function actionability(): string
|
||||
{
|
||||
return match ($this) {
|
||||
self::AdapterOutOfSync => 'non_actionable',
|
||||
default => 'retryable_transient',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, NextStepOption>
|
||||
*/
|
||||
public function nextSteps(): array
|
||||
{
|
||||
return match ($this) {
|
||||
self::AdapterOutOfSync => [
|
||||
NextStepOption::instruction('Review the related restore record before deciding whether to run the workflow again.'),
|
||||
],
|
||||
default => [
|
||||
NextStepOption::instruction('Review worker health and logs before retrying this operation.'),
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
public function defaultMessage(): string
|
||||
{
|
||||
return $this->shortExplanation();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $context
|
||||
*/
|
||||
public function toReasonResolutionEnvelope(string $surface = 'detail', array $context = []): ReasonResolutionEnvelope
|
||||
{
|
||||
return new ReasonResolutionEnvelope(
|
||||
internalCode: $this->value,
|
||||
operatorLabel: $this->operatorLabel(),
|
||||
shortExplanation: $this->shortExplanation(),
|
||||
actionability: $this->actionability(),
|
||||
nextSteps: $this->nextSteps(),
|
||||
showNoActionNeeded: false,
|
||||
diagnosticCodeLabel: $this->value,
|
||||
);
|
||||
}
|
||||
}
|
||||
152
app/Support/Operations/OperationLifecyclePolicy.php
Normal file
152
app/Support/Operations/OperationLifecyclePolicy.php
Normal file
@ -0,0 +1,152 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Support\Operations;
|
||||
|
||||
use Illuminate\Support\Arr;
|
||||
|
||||
final class OperationLifecyclePolicy
|
||||
{
|
||||
/**
|
||||
* @return array<string, array{
|
||||
* job_class?: class-string,
|
||||
* queued_stale_after_seconds?: int,
|
||||
* running_stale_after_seconds?: int,
|
||||
* expected_max_runtime_seconds?: int,
|
||||
* direct_failed_bridge?: bool,
|
||||
* scheduled_reconciliation?: bool
|
||||
* }>
|
||||
*/
|
||||
public function coveredTypes(): array
|
||||
{
|
||||
$coveredTypes = config('tenantpilot.operations.lifecycle.covered_types', []);
|
||||
|
||||
return is_array($coveredTypes) ? $coveredTypes : [];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{
|
||||
* job_class?: class-string,
|
||||
* queued_stale_after_seconds:int,
|
||||
* running_stale_after_seconds:int,
|
||||
* expected_max_runtime_seconds:?int,
|
||||
* direct_failed_bridge:bool,
|
||||
* scheduled_reconciliation:bool
|
||||
* }|null
|
||||
*/
|
||||
public function definition(string $operationType): ?array
|
||||
{
|
||||
$operationType = trim($operationType);
|
||||
|
||||
if ($operationType === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
$definition = $this->coveredTypes()[$operationType] ?? null;
|
||||
|
||||
if (! is_array($definition)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return [
|
||||
'job_class' => is_string($definition['job_class'] ?? null) ? $definition['job_class'] : null,
|
||||
'queued_stale_after_seconds' => max(1, (int) ($definition['queued_stale_after_seconds'] ?? 300)),
|
||||
'running_stale_after_seconds' => max(1, (int) ($definition['running_stale_after_seconds'] ?? 900)),
|
||||
'expected_max_runtime_seconds' => is_numeric($definition['expected_max_runtime_seconds'] ?? null)
|
||||
? max(1, (int) $definition['expected_max_runtime_seconds'])
|
||||
: null,
|
||||
'direct_failed_bridge' => (bool) ($definition['direct_failed_bridge'] ?? false),
|
||||
'scheduled_reconciliation' => (bool) ($definition['scheduled_reconciliation'] ?? true),
|
||||
];
|
||||
}
|
||||
|
||||
public function supports(string $operationType): bool
|
||||
{
|
||||
return $this->definition($operationType) !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<string>
|
||||
*/
|
||||
public function coveredTypeNames(): array
|
||||
{
|
||||
return array_values(array_keys($this->coveredTypes()));
|
||||
}
|
||||
|
||||
public function queuedStaleAfterSeconds(string $operationType): int
|
||||
{
|
||||
return (int) ($this->definition($operationType)['queued_stale_after_seconds'] ?? 300);
|
||||
}
|
||||
|
||||
public function runningStaleAfterSeconds(string $operationType): int
|
||||
{
|
||||
return (int) ($this->definition($operationType)['running_stale_after_seconds'] ?? 900);
|
||||
}
|
||||
|
||||
public function expectedMaxRuntimeSeconds(string $operationType): ?int
|
||||
{
|
||||
$expectedMaxRuntimeSeconds = $this->definition($operationType)['expected_max_runtime_seconds'] ?? null;
|
||||
|
||||
return is_int($expectedMaxRuntimeSeconds) ? $expectedMaxRuntimeSeconds : null;
|
||||
}
|
||||
|
||||
public function requiresDirectFailedBridge(string $operationType): bool
|
||||
{
|
||||
return (bool) ($this->definition($operationType)['direct_failed_bridge'] ?? false);
|
||||
}
|
||||
|
||||
public function supportsScheduledReconciliation(string $operationType): bool
|
||||
{
|
||||
return (bool) ($this->definition($operationType)['scheduled_reconciliation'] ?? false);
|
||||
}
|
||||
|
||||
public function reconciliationBatchLimit(): int
|
||||
{
|
||||
return max(1, (int) config('tenantpilot.operations.lifecycle.reconciliation.batch_limit', 100));
|
||||
}
|
||||
|
||||
public function reconciliationScheduleMinutes(): int
|
||||
{
|
||||
return max(1, (int) config('tenantpilot.operations.lifecycle.reconciliation.schedule_minutes', 5));
|
||||
}
|
||||
|
||||
public function retryAfterSafetyMarginSeconds(): int
|
||||
{
|
||||
return max(1, (int) config('queue.lifecycle_invariants.retry_after_safety_margin', 30));
|
||||
}
|
||||
|
||||
public function queueConnection(string $operationType): ?string
|
||||
{
|
||||
$jobClass = $this->jobClass($operationType);
|
||||
|
||||
if ($jobClass === null || ! class_exists($jobClass)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$connection = Arr::get(get_class_vars($jobClass), 'connection');
|
||||
|
||||
return is_string($connection) && trim($connection) !== '' ? trim($connection) : config('queue.default');
|
||||
}
|
||||
|
||||
public function queueRetryAfterSeconds(?string $connection = null): ?int
|
||||
{
|
||||
$connection = is_string($connection) && trim($connection) !== '' ? trim($connection) : (string) config('queue.default', 'database');
|
||||
$retryAfter = config("queue.connections.{$connection}.retry_after");
|
||||
|
||||
if (is_numeric($retryAfter)) {
|
||||
return max(1, (int) $retryAfter);
|
||||
}
|
||||
|
||||
$databaseRetryAfter = config('queue.connections.database.retry_after');
|
||||
|
||||
return is_numeric($databaseRetryAfter) ? max(1, (int) $databaseRetryAfter) : null;
|
||||
}
|
||||
|
||||
public function jobClass(string $operationType): ?string
|
||||
{
|
||||
$jobClass = $this->definition($operationType)['job_class'] ?? null;
|
||||
|
||||
return is_string($jobClass) && $jobClass !== '' ? $jobClass : null;
|
||||
}
|
||||
}
|
||||
@ -2,10 +2,16 @@
|
||||
|
||||
namespace App\Support\Operations;
|
||||
|
||||
use App\Models\OperationRun;
|
||||
use App\Support\Auth\Capabilities;
|
||||
|
||||
final class OperationRunCapabilityResolver
|
||||
{
|
||||
public function requiredCapabilityForRun(OperationRun $run): ?string
|
||||
{
|
||||
return $this->requiredCapabilityForType((string) $run->type);
|
||||
}
|
||||
|
||||
public function requiredCapabilityForType(string $operationType): ?string
|
||||
{
|
||||
$operationType = trim($operationType);
|
||||
|
||||
69
app/Support/Operations/OperationRunFreshnessState.php
Normal file
69
app/Support/Operations/OperationRunFreshnessState.php
Normal file
@ -0,0 +1,69 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Support\Operations;
|
||||
|
||||
use App\Models\OperationRun;
|
||||
use App\Support\OperationRunStatus;
|
||||
|
||||
enum OperationRunFreshnessState: string
|
||||
{
|
||||
case FreshActive = 'fresh_active';
|
||||
case LikelyStale = 'likely_stale';
|
||||
case ReconciledFailed = 'reconciled_failed';
|
||||
case TerminalNormal = 'terminal_normal';
|
||||
case Unknown = 'unknown';
|
||||
|
||||
public static function forRun(OperationRun $run, ?OperationLifecyclePolicy $policy = null): self
|
||||
{
|
||||
$policy ??= app(OperationLifecyclePolicy::class);
|
||||
|
||||
if ((string) $run->status === OperationRunStatus::Completed->value) {
|
||||
return $run->isLifecycleReconciled() ? self::ReconciledFailed : self::TerminalNormal;
|
||||
}
|
||||
|
||||
if (! $policy->supports((string) $run->type)) {
|
||||
return self::Unknown;
|
||||
}
|
||||
|
||||
if ((string) $run->status === OperationRunStatus::Queued->value) {
|
||||
if ($run->started_at !== null || $run->created_at === null) {
|
||||
return self::Unknown;
|
||||
}
|
||||
|
||||
return $run->created_at->lte(now()->subSeconds($policy->queuedStaleAfterSeconds((string) $run->type)))
|
||||
? self::LikelyStale
|
||||
: self::FreshActive;
|
||||
}
|
||||
|
||||
if ((string) $run->status === OperationRunStatus::Running->value) {
|
||||
$startedAt = $run->started_at ?? $run->created_at;
|
||||
|
||||
if ($startedAt === null) {
|
||||
return self::Unknown;
|
||||
}
|
||||
|
||||
return $startedAt->lte(now()->subSeconds($policy->runningStaleAfterSeconds((string) $run->type)))
|
||||
? self::LikelyStale
|
||||
: self::FreshActive;
|
||||
}
|
||||
|
||||
return self::Unknown;
|
||||
}
|
||||
|
||||
public function isFreshActive(): bool
|
||||
{
|
||||
return $this === self::FreshActive;
|
||||
}
|
||||
|
||||
public function isLikelyStale(): bool
|
||||
{
|
||||
return $this === self::LikelyStale;
|
||||
}
|
||||
|
||||
public function isReconciledFailed(): bool
|
||||
{
|
||||
return $this === self::ReconciledFailed;
|
||||
}
|
||||
}
|
||||
@ -7,6 +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 Filament\Notifications\Notification as FilamentNotification;
|
||||
@ -99,6 +100,15 @@ public static function surfaceGuidance(OperationRun $run): ?string
|
||||
$reasonEnvelope = self::reasonEnvelope($run);
|
||||
$reasonGuidance = app(ReasonPresenter::class)->guidance($reasonEnvelope);
|
||||
$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 $reasonGuidance ?? 'TenantPilot reconciled this run after lifecycle truth was lost. Review the recorded evidence before retrying.';
|
||||
}
|
||||
|
||||
if (in_array($uxStatus, ['blocked', 'failed', 'partial'], true) && $reasonGuidance !== null) {
|
||||
return $reasonGuidance;
|
||||
@ -130,11 +140,29 @@ public static function surfaceFailureDetail(OperationRun $run): ?string
|
||||
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.';
|
||||
}
|
||||
|
||||
$failureMessage = (string) (($run->failure_summary[0]['message'] ?? '') ?? '');
|
||||
|
||||
return self::sanitizeFailureMessage($failureMessage);
|
||||
}
|
||||
|
||||
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 array{titleSuffix: string, body: string, status: string}
|
||||
*/
|
||||
@ -142,6 +170,15 @@ private static function terminalPresentation(OperationRun $run): array
|
||||
{
|
||||
$uxStatus = OperationStatusNormalizer::toUxStatus($run->status, $run->outcome);
|
||||
$reasonEnvelope = self::reasonEnvelope($run);
|
||||
$freshnessState = self::freshnessState($run);
|
||||
|
||||
if ($freshnessState->isReconciledFailed()) {
|
||||
return [
|
||||
'titleSuffix' => 'was automatically reconciled',
|
||||
'body' => $reasonEnvelope?->operatorLabel ?? 'Automatically reconciled after infrastructure failure.',
|
||||
'status' => 'danger',
|
||||
];
|
||||
}
|
||||
|
||||
return match ($uxStatus) {
|
||||
'succeeded' => [
|
||||
|
||||
@ -6,6 +6,7 @@
|
||||
|
||||
use App\Models\OperationRun;
|
||||
use App\Support\OperationCatalog;
|
||||
use App\Support\Operations\OperationRunFreshnessState;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
|
||||
final class RunDurationInsights
|
||||
@ -118,6 +119,10 @@ public static function expectedHuman(OperationRun $run): ?string
|
||||
|
||||
public static function stuckGuidance(OperationRun $run): ?string
|
||||
{
|
||||
if ($run->freshnessState() === OperationRunFreshnessState::LikelyStale) {
|
||||
return 'Past the lifecycle window. Review worker health and logs before retrying.';
|
||||
}
|
||||
|
||||
$uxStatus = OperationStatusNormalizer::toUxStatus($run->status, $run->outcome);
|
||||
|
||||
if (! in_array($uxStatus, ['queued', 'running'], true)) {
|
||||
|
||||
@ -3,7 +3,9 @@
|
||||
namespace App\Support\OpsUx;
|
||||
|
||||
use App\Services\Intune\SecretClassificationService;
|
||||
use App\Support\Baselines\BaselineReasonCodes;
|
||||
use App\Support\Operations\ExecutionDenialReasonCode;
|
||||
use App\Support\Operations\LifecycleReconciliationReason;
|
||||
use App\Support\Providers\ProviderReasonCodes;
|
||||
|
||||
final class RunFailureSanitizer
|
||||
@ -130,7 +132,15 @@ public static function isStructuredOperatorReasonCode(string $candidate): bool
|
||||
ExecutionDenialReasonCode::cases(),
|
||||
);
|
||||
|
||||
return ProviderReasonCodes::isKnown($candidate) || in_array($candidate, $executionDenialReasonCodes, true);
|
||||
$lifecycleReasonCodes = array_map(
|
||||
static fn (LifecycleReconciliationReason $reasonCode): string => $reasonCode->value,
|
||||
LifecycleReconciliationReason::cases(),
|
||||
);
|
||||
|
||||
return ProviderReasonCodes::isKnown($candidate)
|
||||
|| BaselineReasonCodes::isKnown($candidate)
|
||||
|| in_array($candidate, $executionDenialReasonCodes, true)
|
||||
|| in_array($candidate, $lifecycleReasonCodes, true);
|
||||
}
|
||||
|
||||
public static function sanitizeMessage(string $message): string
|
||||
|
||||
@ -8,6 +8,7 @@
|
||||
use App\Models\ProviderConnection;
|
||||
use App\Models\Tenant;
|
||||
use App\Support\Operations\ExecutionDenialReasonCode;
|
||||
use App\Support\Operations\LifecycleReconciliationReason;
|
||||
use App\Support\Providers\ProviderReasonCodes;
|
||||
use App\Support\Providers\ProviderReasonTranslator;
|
||||
use App\Support\RbacReason;
|
||||
@ -91,6 +92,7 @@ private function isDirectlyTranslatableOperationReason(string $reasonCode): bool
|
||||
|
||||
return ProviderReasonCodes::isKnown($reasonCode)
|
||||
|| ExecutionDenialReasonCode::tryFrom($reasonCode) instanceof ExecutionDenialReasonCode
|
||||
|| LifecycleReconciliationReason::tryFrom($reasonCode) instanceof LifecycleReconciliationReason
|
||||
|| TenantOperabilityReasonCode::tryFrom($reasonCode) instanceof TenantOperabilityReasonCode
|
||||
|| RbacReason::tryFrom($reasonCode) instanceof RbacReason;
|
||||
}
|
||||
|
||||
@ -4,7 +4,9 @@
|
||||
|
||||
namespace App\Support\ReasonTranslation;
|
||||
|
||||
use App\Support\Baselines\BaselineReasonCodes;
|
||||
use App\Support\Operations\ExecutionDenialReasonCode;
|
||||
use App\Support\Operations\LifecycleReconciliationReason;
|
||||
use App\Support\Providers\ProviderReasonCodes;
|
||||
use App\Support\Providers\ProviderReasonTranslator;
|
||||
use App\Support\RbacReason;
|
||||
@ -43,8 +45,11 @@ public function translate(
|
||||
return match (true) {
|
||||
$artifactKey === ProviderReasonTranslator::ARTIFACT_KEY,
|
||||
$artifactKey === null && $this->providerReasonTranslator->canTranslate($reasonCode) => $this->providerReasonTranslator->translate($reasonCode, $surface, $context),
|
||||
$artifactKey === self::GOVERNANCE_ARTIFACT_TRUTH_ARTIFACT && BaselineReasonCodes::isKnown($reasonCode) => $this->translateBaselineReason($reasonCode),
|
||||
$artifactKey === null && BaselineReasonCodes::isKnown($reasonCode) => $this->translateBaselineReason($reasonCode),
|
||||
$artifactKey === self::EXECUTION_DENIAL_ARTIFACT,
|
||||
$artifactKey === null && ExecutionDenialReasonCode::tryFrom($reasonCode) instanceof ExecutionDenialReasonCode => ExecutionDenialReasonCode::tryFrom($reasonCode)?->toReasonResolutionEnvelope($surface, $context),
|
||||
$artifactKey === null && LifecycleReconciliationReason::tryFrom($reasonCode) instanceof LifecycleReconciliationReason => LifecycleReconciliationReason::tryFrom($reasonCode)?->toReasonResolutionEnvelope($surface, $context),
|
||||
$artifactKey === self::TENANT_OPERABILITY_ARTIFACT,
|
||||
$artifactKey === null && TenantOperabilityReasonCode::tryFrom($reasonCode) instanceof TenantOperabilityReasonCode => TenantOperabilityReasonCode::tryFrom($reasonCode)?->toReasonResolutionEnvelope($surface, $context),
|
||||
$artifactKey === self::RBAC_ARTIFACT,
|
||||
@ -74,4 +79,122 @@ private function fallbackTranslate(
|
||||
|
||||
return $this->fallbackReasonTranslator->translate($reasonCode, $surface, $context);
|
||||
}
|
||||
|
||||
private function translateBaselineReason(string $reasonCode): ReasonResolutionEnvelope
|
||||
{
|
||||
[$operatorLabel, $shortExplanation, $actionability, $nextStep] = match ($reasonCode) {
|
||||
BaselineReasonCodes::CAPTURE_MISSING_SOURCE_TENANT => [
|
||||
'Source tenant unavailable',
|
||||
'The selected tenant is not available in this workspace for baseline capture.',
|
||||
'prerequisite_missing',
|
||||
'Select a source tenant from the same workspace before capturing again.',
|
||||
],
|
||||
BaselineReasonCodes::CAPTURE_PROFILE_NOT_ACTIVE => [
|
||||
'Baseline profile inactive',
|
||||
'Only active baseline profiles can be captured or compared.',
|
||||
'prerequisite_missing',
|
||||
'Activate the baseline profile before retrying this action.',
|
||||
],
|
||||
BaselineReasonCodes::CAPTURE_ROLLOUT_DISABLED,
|
||||
BaselineReasonCodes::COMPARE_ROLLOUT_DISABLED => [
|
||||
'Full-content rollout disabled',
|
||||
'This workflow is disabled by rollout configuration in the current environment.',
|
||||
'prerequisite_missing',
|
||||
'Enable the rollout before retrying full-content baseline work.',
|
||||
],
|
||||
BaselineReasonCodes::SNAPSHOT_BUILDING,
|
||||
BaselineReasonCodes::COMPARE_SNAPSHOT_BUILDING => [
|
||||
'Baseline still building',
|
||||
'The selected baseline snapshot is still building and cannot be trusted for compare yet.',
|
||||
'prerequisite_missing',
|
||||
'Wait for capture to finish or use the current complete snapshot instead.',
|
||||
],
|
||||
BaselineReasonCodes::SNAPSHOT_INCOMPLETE,
|
||||
BaselineReasonCodes::COMPARE_SNAPSHOT_INCOMPLETE => [
|
||||
'Baseline snapshot incomplete',
|
||||
'The snapshot did not finish cleanly, so TenantPilot will not use it for compare.',
|
||||
'prerequisite_missing',
|
||||
'Capture a new baseline and wait for it to complete before comparing.',
|
||||
],
|
||||
BaselineReasonCodes::SNAPSHOT_SUPERSEDED,
|
||||
BaselineReasonCodes::COMPARE_SNAPSHOT_SUPERSEDED => [
|
||||
'Snapshot superseded',
|
||||
'A newer complete baseline snapshot is current, so this historical snapshot is not compare input anymore.',
|
||||
'prerequisite_missing',
|
||||
'Use the current complete snapshot for compare instead of this historical copy.',
|
||||
],
|
||||
BaselineReasonCodes::SNAPSHOT_CAPTURE_FAILED => [
|
||||
'Baseline capture failed',
|
||||
'Snapshot capture stopped after the row was created, so the artifact remains unusable.',
|
||||
'retryable_transient',
|
||||
'Review the run details, then retry the capture once the failure is addressed.',
|
||||
],
|
||||
BaselineReasonCodes::SNAPSHOT_COMPLETION_PROOF_FAILED => [
|
||||
'Completion proof failed',
|
||||
'TenantPilot could not prove that every expected snapshot item was persisted successfully.',
|
||||
'prerequisite_missing',
|
||||
'Capture the baseline again so a complete snapshot can be finalized.',
|
||||
],
|
||||
BaselineReasonCodes::SNAPSHOT_LEGACY_NO_PROOF => [
|
||||
'Legacy completion unproven',
|
||||
'This older snapshot has no reliable completion proof, so it is blocked from compare.',
|
||||
'prerequisite_missing',
|
||||
'Recapture the baseline to create a complete snapshot with explicit lifecycle proof.',
|
||||
],
|
||||
BaselineReasonCodes::SNAPSHOT_LEGACY_CONTRADICTORY => [
|
||||
'Legacy completion contradictory',
|
||||
'Stored counts or producer-run evidence disagree, so TenantPilot treats this snapshot as incomplete.',
|
||||
'prerequisite_missing',
|
||||
'Recapture the baseline to replace this ambiguous historical snapshot.',
|
||||
],
|
||||
BaselineReasonCodes::COMPARE_NO_ASSIGNMENT => [
|
||||
'No baseline assigned',
|
||||
'This tenant has no assigned baseline profile yet.',
|
||||
'prerequisite_missing',
|
||||
'Assign a baseline profile to the tenant before starting compare.',
|
||||
],
|
||||
BaselineReasonCodes::COMPARE_PROFILE_NOT_ACTIVE => [
|
||||
'Assigned baseline inactive',
|
||||
'The assigned baseline profile is not active, so compare cannot start.',
|
||||
'prerequisite_missing',
|
||||
'Activate the assigned baseline profile or assign a different active profile.',
|
||||
],
|
||||
BaselineReasonCodes::COMPARE_NO_ACTIVE_SNAPSHOT,
|
||||
BaselineReasonCodes::COMPARE_NO_CONSUMABLE_SNAPSHOT => [
|
||||
'Current baseline unavailable',
|
||||
'No complete baseline snapshot is currently available for compare.',
|
||||
'prerequisite_missing',
|
||||
'Capture a baseline and wait for it to complete before comparing.',
|
||||
],
|
||||
BaselineReasonCodes::COMPARE_NO_ELIGIBLE_TARGET => [
|
||||
'No eligible compare target',
|
||||
'No assigned tenant with compare access is currently available for this baseline profile.',
|
||||
'prerequisite_missing',
|
||||
'Assign this baseline to a tenant you can compare, or use an account with access to an assigned tenant.',
|
||||
],
|
||||
BaselineReasonCodes::COMPARE_INVALID_SNAPSHOT => [
|
||||
'Selected snapshot unavailable',
|
||||
'The requested baseline snapshot could not be found for this profile.',
|
||||
'prerequisite_missing',
|
||||
'Refresh the page and select a valid snapshot for this baseline profile.',
|
||||
],
|
||||
default => [
|
||||
'Baseline workflow blocked',
|
||||
'TenantPilot recorded a baseline precondition that prevents this workflow from continuing safely.',
|
||||
'prerequisite_missing',
|
||||
'Review the recorded baseline state before retrying.',
|
||||
],
|
||||
};
|
||||
|
||||
return new ReasonResolutionEnvelope(
|
||||
internalCode: $reasonCode,
|
||||
operatorLabel: $operatorLabel,
|
||||
shortExplanation: $shortExplanation,
|
||||
actionability: $actionability,
|
||||
nextSteps: [
|
||||
NextStepOption::instruction($nextStep),
|
||||
],
|
||||
diagnosticCodeLabel: $reasonCode,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -13,6 +13,7 @@
|
||||
use App\Models\OperationRun;
|
||||
use App\Models\ReviewPack;
|
||||
use App\Models\TenantReview;
|
||||
use App\Services\Baselines\BaselineSnapshotTruthResolver;
|
||||
use App\Services\Baselines\SnapshotRendering\FidelityState;
|
||||
use App\Support\Badges\BadgeCatalog;
|
||||
use App\Support\Badges\BadgeDomain;
|
||||
@ -31,6 +32,7 @@ final class ArtifactTruthPresenter
|
||||
{
|
||||
public function __construct(
|
||||
private readonly ReasonPresenter $reasonPresenter,
|
||||
private readonly BaselineSnapshotTruthResolver $snapshotTruthResolver,
|
||||
) {}
|
||||
|
||||
public function for(mixed $record): ?ArtifactTruthEnvelope
|
||||
@ -52,38 +54,49 @@ public function forBaselineSnapshot(BaselineSnapshot $snapshot): ArtifactTruthEn
|
||||
$summary = is_array($snapshot->summary_jsonb) ? $snapshot->summary_jsonb : [];
|
||||
$hasItems = (int) ($summary['total_items'] ?? 0) > 0;
|
||||
$fidelity = FidelityState::fromSummary($summary, $hasItems);
|
||||
$isHistorical = (int) ($snapshot->baselineProfile?->active_snapshot_id ?? 0) !== (int) $snapshot->getKey()
|
||||
&& $snapshot->baselineProfile !== null;
|
||||
$effectiveSnapshot = $snapshot->baselineProfile !== null
|
||||
? $this->snapshotTruthResolver->resolveEffectiveSnapshot($snapshot->baselineProfile)
|
||||
: null;
|
||||
$isHistorical = $this->snapshotTruthResolver->isHistoricallySuperseded($snapshot, $effectiveSnapshot);
|
||||
$gapReasons = is_array(Arr::get($summary, 'gaps.by_reason')) ? Arr::get($summary, 'gaps.by_reason') : [];
|
||||
$severeGapReasons = array_filter(
|
||||
$gapReasons,
|
||||
static fn (mixed $count, string $reason): bool => is_numeric($count) && (int) $count > 0 && $reason !== 'meta_fallback',
|
||||
ARRAY_FILTER_USE_BOTH,
|
||||
);
|
||||
$reasonCode = $this->firstReasonCode($severeGapReasons);
|
||||
$reasonCode = $this->snapshotTruthResolver->artifactReasonCode($snapshot, $effectiveSnapshot)
|
||||
?? $this->firstReasonCode($severeGapReasons);
|
||||
$reason = $this->reasonPresenter->forArtifactTruth($reasonCode, 'artifact_truth');
|
||||
|
||||
$artifactExistence = match (true) {
|
||||
$isHistorical => 'historical_only',
|
||||
! $hasItems => 'created_but_not_usable',
|
||||
$snapshot->isBuilding(), $snapshot->isIncomplete() => 'created_but_not_usable',
|
||||
! $snapshot->isConsumable() => 'created_but_not_usable',
|
||||
default => 'created',
|
||||
};
|
||||
|
||||
$contentState = match ($fidelity) {
|
||||
FidelityState::Full => $severeGapReasons === [] ? 'trusted' : 'partial',
|
||||
FidelityState::Partial => 'partial',
|
||||
FidelityState::ReferenceOnly => 'reference_only',
|
||||
FidelityState::Unsupported => $hasItems ? 'unsupported' : 'empty',
|
||||
FidelityState::Full => $snapshot->isIncomplete()
|
||||
? ($hasItems ? 'partial' : 'missing_input')
|
||||
: ($severeGapReasons === [] ? 'trusted' : 'partial'),
|
||||
FidelityState::Partial => $snapshot->isBuilding() ? 'missing_input' : 'partial',
|
||||
FidelityState::ReferenceOnly => $snapshot->isBuilding() ? 'missing_input' : 'reference_only',
|
||||
FidelityState::Unsupported => $snapshot->isBuilding() ? 'missing_input' : ($hasItems ? 'unsupported' : 'trusted'),
|
||||
};
|
||||
|
||||
if (! $hasItems && $reasonCode !== null) {
|
||||
if (($snapshot->isBuilding() || $snapshot->isIncomplete()) && $reasonCode !== null) {
|
||||
$contentState = 'missing_input';
|
||||
}
|
||||
|
||||
$freshnessState = $isHistorical ? 'stale' : 'current';
|
||||
$freshnessState = match (true) {
|
||||
$snapshot->isBuilding() => 'unknown',
|
||||
$isHistorical => 'stale',
|
||||
default => 'current',
|
||||
};
|
||||
$supportState = in_array($contentState, ['reference_only', 'unsupported'], true) ? 'limited_support' : 'normal';
|
||||
$actionability = match (true) {
|
||||
$artifactExistence === 'historical_only' => 'none',
|
||||
$snapshot->isBuilding() => 'optional',
|
||||
$contentState === 'trusted' && $freshnessState === 'current' => 'none',
|
||||
$freshnessState === 'stale' => 'optional',
|
||||
in_array($contentState, ['reference_only', 'unsupported'], true) => 'optional',
|
||||
@ -94,26 +107,30 @@ public function forBaselineSnapshot(BaselineSnapshot $snapshot): ArtifactTruthEn
|
||||
$artifactExistence === 'historical_only' => [
|
||||
BadgeDomain::GovernanceArtifactExistence,
|
||||
'historical_only',
|
||||
'This snapshot remains readable for historical comparison, but it is not the current baseline artifact.',
|
||||
$supportState === 'limited_support' ? 'Support limited' : null,
|
||||
$reason?->shortExplanation ?? 'This snapshot remains readable for history, but a newer complete snapshot is the current baseline truth.',
|
||||
BadgeCatalog::spec(BadgeDomain::BaselineSnapshotLifecycle, 'superseded')->label,
|
||||
],
|
||||
$artifactExistence === 'created_but_not_usable' => [
|
||||
BadgeDomain::GovernanceArtifactExistence,
|
||||
'created_but_not_usable',
|
||||
$reason?->shortExplanation ?? 'A snapshot row exists, but it does not contain a trustworthy baseline artifact yet.',
|
||||
$supportState === 'limited_support' ? 'Support limited' : null,
|
||||
BadgeCatalog::spec(BadgeDomain::BaselineSnapshotLifecycle, $snapshot->lifecycleState()->value)->label,
|
||||
],
|
||||
$contentState !== 'trusted' => [
|
||||
BadgeDomain::GovernanceArtifactContent,
|
||||
$contentState,
|
||||
$reason?->shortExplanation ?? $this->contentExplanation($contentState),
|
||||
$supportState === 'limited_support' ? 'Support limited' : null,
|
||||
$supportState === 'limited_support'
|
||||
? 'Support limited'
|
||||
: BadgeCatalog::spec(BadgeDomain::BaselineSnapshotLifecycle, $snapshot->lifecycleState()->value)->label,
|
||||
],
|
||||
default => [
|
||||
BadgeDomain::GovernanceArtifactContent,
|
||||
'trusted',
|
||||
'Structured capture content is available for this baseline snapshot.',
|
||||
null,
|
||||
$hasItems
|
||||
? 'Structured capture content is available for this baseline snapshot.'
|
||||
: 'This empty baseline snapshot completed successfully and can still be used for compare.',
|
||||
BadgeCatalog::spec(BadgeDomain::BaselineSnapshotLifecycle, $snapshot->lifecycleState()->value)->label,
|
||||
],
|
||||
};
|
||||
|
||||
|
||||
@ -40,7 +40,7 @@
|
||||
'connection' => env('DB_QUEUE_CONNECTION'),
|
||||
'table' => env('DB_QUEUE_TABLE', 'jobs'),
|
||||
'queue' => env('DB_QUEUE', 'default'),
|
||||
'retry_after' => (int) env('DB_QUEUE_RETRY_AFTER', 90),
|
||||
'retry_after' => (int) env('DB_QUEUE_RETRY_AFTER', 600),
|
||||
'after_commit' => false,
|
||||
],
|
||||
|
||||
@ -48,7 +48,7 @@
|
||||
'driver' => 'beanstalkd',
|
||||
'host' => env('BEANSTALKD_QUEUE_HOST', 'localhost'),
|
||||
'queue' => env('BEANSTALKD_QUEUE', 'default'),
|
||||
'retry_after' => (int) env('BEANSTALKD_QUEUE_RETRY_AFTER', 90),
|
||||
'retry_after' => (int) env('BEANSTALKD_QUEUE_RETRY_AFTER', 600),
|
||||
'block_for' => 0,
|
||||
'after_commit' => false,
|
||||
],
|
||||
@ -68,7 +68,7 @@
|
||||
'driver' => 'redis',
|
||||
'connection' => env('REDIS_QUEUE_CONNECTION', 'default'),
|
||||
'queue' => env('REDIS_QUEUE', 'default'),
|
||||
'retry_after' => (int) env('REDIS_QUEUE_RETRY_AFTER', 90),
|
||||
'retry_after' => (int) env('REDIS_QUEUE_RETRY_AFTER', 600),
|
||||
'block_for' => null,
|
||||
'after_commit' => false,
|
||||
],
|
||||
@ -126,4 +126,8 @@
|
||||
'table' => 'failed_jobs',
|
||||
],
|
||||
|
||||
'lifecycle_invariants' => [
|
||||
'retry_after_safety_margin' => (int) env('QUEUE_RETRY_AFTER_SAFETY_MARGIN', 30),
|
||||
],
|
||||
|
||||
];
|
||||
|
||||
@ -13,6 +13,113 @@
|
||||
],
|
||||
],
|
||||
|
||||
'operations' => [
|
||||
'lifecycle' => [
|
||||
'reconciliation' => [
|
||||
'batch_limit' => (int) env('TENANTPILOT_OPERATION_RUN_RECONCILIATION_BATCH_LIMIT', 100),
|
||||
'schedule_minutes' => (int) env('TENANTPILOT_OPERATION_RUN_RECONCILIATION_SCHEDULE_MINUTES', 5),
|
||||
],
|
||||
'covered_types' => [
|
||||
'baseline_capture' => [
|
||||
'job_class' => \App\Jobs\CaptureBaselineSnapshotJob::class,
|
||||
'queued_stale_after_seconds' => 600,
|
||||
'running_stale_after_seconds' => 1800,
|
||||
'expected_max_runtime_seconds' => 300,
|
||||
'direct_failed_bridge' => true,
|
||||
'scheduled_reconciliation' => true,
|
||||
],
|
||||
'baseline_compare' => [
|
||||
'job_class' => \App\Jobs\CompareBaselineToTenantJob::class,
|
||||
'queued_stale_after_seconds' => 600,
|
||||
'running_stale_after_seconds' => 1800,
|
||||
'expected_max_runtime_seconds' => 300,
|
||||
'direct_failed_bridge' => true,
|
||||
'scheduled_reconciliation' => true,
|
||||
],
|
||||
'inventory_sync' => [
|
||||
'job_class' => \App\Jobs\RunInventorySyncJob::class,
|
||||
'queued_stale_after_seconds' => 300,
|
||||
'running_stale_after_seconds' => 1200,
|
||||
'expected_max_runtime_seconds' => 240,
|
||||
'direct_failed_bridge' => true,
|
||||
'scheduled_reconciliation' => true,
|
||||
],
|
||||
'policy.sync' => [
|
||||
'job_class' => \App\Jobs\SyncPoliciesJob::class,
|
||||
'queued_stale_after_seconds' => 300,
|
||||
'running_stale_after_seconds' => 900,
|
||||
'expected_max_runtime_seconds' => 180,
|
||||
'direct_failed_bridge' => true,
|
||||
'scheduled_reconciliation' => true,
|
||||
],
|
||||
'policy.sync_one' => [
|
||||
'job_class' => \App\Jobs\SyncPoliciesJob::class,
|
||||
'queued_stale_after_seconds' => 300,
|
||||
'running_stale_after_seconds' => 900,
|
||||
'expected_max_runtime_seconds' => 180,
|
||||
'direct_failed_bridge' => true,
|
||||
'scheduled_reconciliation' => true,
|
||||
],
|
||||
'entra_group_sync' => [
|
||||
'job_class' => \App\Jobs\EntraGroupSyncJob::class,
|
||||
'queued_stale_after_seconds' => 300,
|
||||
'running_stale_after_seconds' => 900,
|
||||
'expected_max_runtime_seconds' => 240,
|
||||
'direct_failed_bridge' => false,
|
||||
'scheduled_reconciliation' => true,
|
||||
],
|
||||
'directory_role_definitions.sync' => [
|
||||
'job_class' => \App\Jobs\SyncRoleDefinitionsJob::class,
|
||||
'queued_stale_after_seconds' => 300,
|
||||
'running_stale_after_seconds' => 900,
|
||||
'expected_max_runtime_seconds' => 240,
|
||||
'direct_failed_bridge' => false,
|
||||
'scheduled_reconciliation' => true,
|
||||
],
|
||||
'backup_schedule_run' => [
|
||||
'job_class' => \App\Jobs\RunBackupScheduleJob::class,
|
||||
'queued_stale_after_seconds' => 300,
|
||||
'running_stale_after_seconds' => 1200,
|
||||
'expected_max_runtime_seconds' => 300,
|
||||
'direct_failed_bridge' => false,
|
||||
'scheduled_reconciliation' => true,
|
||||
],
|
||||
'restore.execute' => [
|
||||
'job_class' => \App\Jobs\ExecuteRestoreRunJob::class,
|
||||
'queued_stale_after_seconds' => 300,
|
||||
'running_stale_after_seconds' => 1500,
|
||||
'expected_max_runtime_seconds' => 420,
|
||||
'direct_failed_bridge' => false,
|
||||
'scheduled_reconciliation' => true,
|
||||
],
|
||||
'tenant.review_pack.generate' => [
|
||||
'job_class' => \App\Jobs\GenerateReviewPackJob::class,
|
||||
'queued_stale_after_seconds' => 300,
|
||||
'running_stale_after_seconds' => 900,
|
||||
'expected_max_runtime_seconds' => 240,
|
||||
'direct_failed_bridge' => false,
|
||||
'scheduled_reconciliation' => true,
|
||||
],
|
||||
'tenant.review.compose' => [
|
||||
'job_class' => \App\Jobs\ComposeTenantReviewJob::class,
|
||||
'queued_stale_after_seconds' => 300,
|
||||
'running_stale_after_seconds' => 900,
|
||||
'expected_max_runtime_seconds' => 240,
|
||||
'direct_failed_bridge' => true,
|
||||
'scheduled_reconciliation' => true,
|
||||
],
|
||||
'tenant.evidence.snapshot.generate' => [
|
||||
'job_class' => \App\Jobs\GenerateEvidenceSnapshotJob::class,
|
||||
'queued_stale_after_seconds' => 300,
|
||||
'running_stale_after_seconds' => 900,
|
||||
'expected_max_runtime_seconds' => 240,
|
||||
'direct_failed_bridge' => false,
|
||||
'scheduled_reconciliation' => true,
|
||||
],
|
||||
],
|
||||
],
|
||||
],
|
||||
|
||||
'allow_admin_maintenance_actions' => (bool) env('ALLOW_ADMIN_MAINTENANCE_ACTIONS', false),
|
||||
|
||||
'supported_policy_types' => [
|
||||
|
||||
@ -5,6 +5,7 @@
|
||||
use App\Models\BaselineProfile;
|
||||
use App\Models\BaselineSnapshot;
|
||||
use App\Models\Workspace;
|
||||
use App\Support\Baselines\BaselineSnapshotLifecycleState;
|
||||
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||
|
||||
/**
|
||||
@ -24,7 +25,50 @@ public function definition(): array
|
||||
'baseline_profile_id' => BaselineProfile::factory(),
|
||||
'snapshot_identity_hash' => hash('sha256', fake()->uuid()),
|
||||
'captured_at' => now(),
|
||||
'lifecycle_state' => BaselineSnapshotLifecycleState::Complete->value,
|
||||
'completed_at' => now(),
|
||||
'failed_at' => null,
|
||||
'summary_jsonb' => ['total_items' => 0],
|
||||
'completion_meta_jsonb' => [
|
||||
'expected_items' => 0,
|
||||
'persisted_items' => 0,
|
||||
'producer_run_id' => null,
|
||||
'was_empty_capture' => true,
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
public function building(): static
|
||||
{
|
||||
return $this->state(fn (): array => [
|
||||
'lifecycle_state' => BaselineSnapshotLifecycleState::Building->value,
|
||||
'completed_at' => null,
|
||||
'failed_at' => null,
|
||||
]);
|
||||
}
|
||||
|
||||
public function complete(): static
|
||||
{
|
||||
return $this->state(fn (): array => [
|
||||
'lifecycle_state' => BaselineSnapshotLifecycleState::Complete->value,
|
||||
'completed_at' => now(),
|
||||
'failed_at' => null,
|
||||
]);
|
||||
}
|
||||
|
||||
public function incomplete(?string $reasonCode = null): static
|
||||
{
|
||||
return $this->state(fn (): array => [
|
||||
'lifecycle_state' => BaselineSnapshotLifecycleState::Incomplete->value,
|
||||
'completed_at' => null,
|
||||
'failed_at' => now(),
|
||||
'completion_meta_jsonb' => [
|
||||
'expected_items' => 0,
|
||||
'persisted_items' => 0,
|
||||
'producer_run_id' => null,
|
||||
'was_empty_capture' => true,
|
||||
'finalization_reason_code' => $reasonCode,
|
||||
],
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@ -0,0 +1,292 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Support\Baselines\BaselineReasonCodes;
|
||||
use App\Support\Baselines\BaselineSnapshotLifecycleState;
|
||||
use App\Support\OperationRunOutcome;
|
||||
use App\Support\OperationRunType;
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
if (! Schema::hasTable('baseline_snapshots')) {
|
||||
return;
|
||||
}
|
||||
|
||||
$needsLifecycleState = ! Schema::hasColumn('baseline_snapshots', 'lifecycle_state');
|
||||
$needsCompletedAt = ! Schema::hasColumn('baseline_snapshots', 'completed_at');
|
||||
$needsFailedAt = ! Schema::hasColumn('baseline_snapshots', 'failed_at');
|
||||
$needsCompletionMeta = ! Schema::hasColumn('baseline_snapshots', 'completion_meta_jsonb');
|
||||
|
||||
if ($needsLifecycleState || $needsCompletedAt || $needsFailedAt || $needsCompletionMeta) {
|
||||
Schema::table('baseline_snapshots', function (Blueprint $table) use ($needsLifecycleState, $needsCompletedAt, $needsFailedAt, $needsCompletionMeta): void {
|
||||
if ($needsLifecycleState) {
|
||||
$table->string('lifecycle_state')->nullable()->after('captured_at');
|
||||
}
|
||||
|
||||
if ($needsCompletedAt) {
|
||||
$table->timestampTz('completed_at')->nullable()->after('lifecycle_state');
|
||||
}
|
||||
|
||||
if ($needsFailedAt) {
|
||||
$table->timestampTz('failed_at')->nullable()->after('completed_at');
|
||||
}
|
||||
|
||||
if ($needsCompletionMeta) {
|
||||
$table->jsonb('completion_meta_jsonb')->nullable()->after('summary_jsonb');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
DB::table('baseline_snapshots')
|
||||
->orderBy('id')
|
||||
->chunkById(200, function ($rows): void {
|
||||
foreach ($rows as $row) {
|
||||
$summary = $this->decodeJson($row->summary_jsonb);
|
||||
$persistedItems = (int) DB::table('baseline_snapshot_items')
|
||||
->where('baseline_snapshot_id', (int) $row->id)
|
||||
->count();
|
||||
|
||||
$classification = $this->classifyLegacySnapshot($row, $summary, $persistedItems);
|
||||
|
||||
DB::table('baseline_snapshots')
|
||||
->where('id', (int) $row->id)
|
||||
->update([
|
||||
'lifecycle_state' => $classification['lifecycle_state'],
|
||||
'completed_at' => $classification['completed_at'],
|
||||
'failed_at' => $classification['failed_at'],
|
||||
'completion_meta_jsonb' => json_encode($classification['completion_meta'], JSON_THROW_ON_ERROR),
|
||||
]);
|
||||
}
|
||||
}, 'id');
|
||||
|
||||
DB::table('baseline_snapshots')
|
||||
->whereNull('lifecycle_state')
|
||||
->update([
|
||||
'lifecycle_state' => BaselineSnapshotLifecycleState::Incomplete->value,
|
||||
]);
|
||||
|
||||
if ($needsLifecycleState) {
|
||||
DB::statement(sprintf(
|
||||
"UPDATE baseline_snapshots SET lifecycle_state = '%s' WHERE lifecycle_state IS NULL",
|
||||
BaselineSnapshotLifecycleState::Incomplete->value,
|
||||
));
|
||||
|
||||
Schema::table('baseline_snapshots', function (Blueprint $table): void {
|
||||
$table->string('lifecycle_state')->default(BaselineSnapshotLifecycleState::Building->value)->nullable(false)->change();
|
||||
});
|
||||
}
|
||||
|
||||
if (! $this->hasIndex('baseline_snapshots_lifecycle_state_index')) {
|
||||
Schema::table('baseline_snapshots', function (Blueprint $table): void {
|
||||
$table->index('lifecycle_state', 'baseline_snapshots_lifecycle_state_index');
|
||||
});
|
||||
}
|
||||
|
||||
if (! $this->hasIndex('baseline_snapshots_profile_lifecycle_completed_idx')) {
|
||||
Schema::table('baseline_snapshots', function (Blueprint $table): void {
|
||||
$table->index(
|
||||
['workspace_id', 'baseline_profile_id', 'lifecycle_state', 'completed_at'],
|
||||
'baseline_snapshots_profile_lifecycle_completed_idx',
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
if (! Schema::hasTable('baseline_snapshots')) {
|
||||
return;
|
||||
}
|
||||
|
||||
if ($this->hasIndex('baseline_snapshots_profile_lifecycle_completed_idx')) {
|
||||
Schema::table('baseline_snapshots', function (Blueprint $table): void {
|
||||
$table->dropIndex('baseline_snapshots_profile_lifecycle_completed_idx');
|
||||
});
|
||||
}
|
||||
|
||||
if ($this->hasIndex('baseline_snapshots_lifecycle_state_index')) {
|
||||
Schema::table('baseline_snapshots', function (Blueprint $table): void {
|
||||
$table->dropIndex('baseline_snapshots_lifecycle_state_index');
|
||||
});
|
||||
}
|
||||
|
||||
Schema::table('baseline_snapshots', function (Blueprint $table): void {
|
||||
foreach (['completion_meta_jsonb', 'failed_at', 'completed_at', 'lifecycle_state'] as $column) {
|
||||
if (Schema::hasColumn('baseline_snapshots', $column)) {
|
||||
$table->dropColumn($column);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $summary
|
||||
* @return array{
|
||||
* lifecycle_state: string,
|
||||
* completed_at: mixed,
|
||||
* failed_at: mixed,
|
||||
* completion_meta: array<string, mixed>
|
||||
* }
|
||||
*/
|
||||
private function classifyLegacySnapshot(object $row, array $summary, int $persistedItems): array
|
||||
{
|
||||
$expectedItems = $this->normalizeInteger(data_get($summary, 'total_items'));
|
||||
$producerRun = $this->resolveProducerRunForSnapshot((int) $row->id, (int) $row->workspace_id, (int) $row->baseline_profile_id);
|
||||
$producerRunContext = $producerRun !== null ? $this->decodeJson($producerRun->context ?? null) : [];
|
||||
$producerExpectedItems = $this->normalizeInteger(data_get($producerRunContext, 'result.items_captured'))
|
||||
?? $this->normalizeInteger(data_get($producerRunContext, 'baseline_capture.subjects_total'));
|
||||
$producerSubjectsTotal = $this->normalizeInteger(data_get($producerRunContext, 'baseline_capture.subjects_total'));
|
||||
$producerSucceeded = $producerRun !== null
|
||||
&& in_array((string) ($producerRun->outcome ?? ''), [
|
||||
OperationRunOutcome::Succeeded->value,
|
||||
OperationRunOutcome::PartiallySucceeded->value,
|
||||
], true);
|
||||
|
||||
$completionMeta = [
|
||||
'expected_items' => $expectedItems ?? $producerExpectedItems,
|
||||
'persisted_items' => $persistedItems,
|
||||
'producer_run_id' => $producerRun?->id !== null ? (int) $producerRun->id : null,
|
||||
'was_empty_capture' => ($expectedItems ?? $producerExpectedItems ?? $producerSubjectsTotal) === 0 && $persistedItems === 0,
|
||||
];
|
||||
|
||||
if ($expectedItems !== null && $expectedItems === $persistedItems) {
|
||||
return [
|
||||
'lifecycle_state' => BaselineSnapshotLifecycleState::Complete->value,
|
||||
'completed_at' => $producerRun->completed_at ?? $row->captured_at ?? $row->created_at ?? $row->updated_at,
|
||||
'failed_at' => null,
|
||||
'completion_meta' => $completionMeta + [
|
||||
'finalization_reason_code' => 'baseline.snapshot.legacy_count_proof',
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
if ($producerSucceeded && $producerExpectedItems !== null && $producerExpectedItems === $persistedItems) {
|
||||
return [
|
||||
'lifecycle_state' => BaselineSnapshotLifecycleState::Complete->value,
|
||||
'completed_at' => $producerRun->completed_at ?? $row->captured_at ?? $row->created_at ?? $row->updated_at,
|
||||
'failed_at' => null,
|
||||
'completion_meta' => $completionMeta + [
|
||||
'finalization_reason_code' => 'baseline.snapshot.legacy_producer_run_proof',
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
if ($producerSucceeded && $persistedItems === 0 && in_array(0, array_filter([
|
||||
$expectedItems,
|
||||
$producerExpectedItems,
|
||||
$producerSubjectsTotal,
|
||||
], static fn (?int $value): bool => $value !== null), true)) {
|
||||
return [
|
||||
'lifecycle_state' => BaselineSnapshotLifecycleState::Complete->value,
|
||||
'completed_at' => $producerRun->completed_at ?? $row->captured_at ?? $row->created_at ?? $row->updated_at,
|
||||
'failed_at' => null,
|
||||
'completion_meta' => $completionMeta + [
|
||||
'finalization_reason_code' => 'baseline.snapshot.legacy_empty_capture_proof',
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
$reasonCode = $expectedItems !== null
|
||||
|| $producerExpectedItems !== null
|
||||
|| $producerRun !== null
|
||||
? BaselineReasonCodes::SNAPSHOT_LEGACY_CONTRADICTORY
|
||||
: BaselineReasonCodes::SNAPSHOT_LEGACY_NO_PROOF;
|
||||
|
||||
return [
|
||||
'lifecycle_state' => BaselineSnapshotLifecycleState::Incomplete->value,
|
||||
'completed_at' => null,
|
||||
'failed_at' => $row->updated_at ?? $row->captured_at ?? $row->created_at,
|
||||
'completion_meta' => $completionMeta + [
|
||||
'finalization_reason_code' => $reasonCode,
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
private function resolveProducerRunForSnapshot(int $snapshotId, int $workspaceId, int $profileId): ?object
|
||||
{
|
||||
$runs = DB::table('operation_runs')
|
||||
->where('workspace_id', $workspaceId)
|
||||
->where('type', OperationRunType::BaselineCapture->value)
|
||||
->orderByDesc('completed_at')
|
||||
->orderByDesc('id')
|
||||
->limit(500)
|
||||
->get(['id', 'outcome', 'completed_at', 'context']);
|
||||
|
||||
foreach ($runs as $run) {
|
||||
$context = $this->decodeJson($run->context ?? null);
|
||||
$resultSnapshotId = $this->normalizeInteger(data_get($context, 'result.snapshot_id'));
|
||||
|
||||
if ($resultSnapshotId === $snapshotId) {
|
||||
return $run;
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($runs as $run) {
|
||||
$context = $this->decodeJson($run->context ?? null);
|
||||
$runProfileId = $this->normalizeInteger(data_get($context, 'baseline_profile_id'));
|
||||
|
||||
if ($runProfileId === $profileId) {
|
||||
return $run;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function decodeJson(mixed $value): array
|
||||
{
|
||||
if (is_array($value)) {
|
||||
return $value;
|
||||
}
|
||||
|
||||
if (is_string($value) && trim($value) !== '') {
|
||||
$decoded = json_decode($value, true);
|
||||
|
||||
return is_array($decoded) ? $decoded : [];
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
private function normalizeInteger(mixed $value): ?int
|
||||
{
|
||||
if (is_int($value)) {
|
||||
return $value;
|
||||
}
|
||||
|
||||
if (is_numeric($value)) {
|
||||
return (int) $value;
|
||||
}
|
||||
|
||||
if (is_string($value) && ctype_digit(trim($value))) {
|
||||
return (int) trim($value);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private function hasIndex(string $indexName): bool
|
||||
{
|
||||
$driver = Schema::getConnection()->getDriverName();
|
||||
|
||||
return match ($driver) {
|
||||
'pgsql' => DB::table('pg_indexes')
|
||||
->where('schemaname', 'public')
|
||||
->where('indexname', $indexName)
|
||||
->exists(),
|
||||
'sqlite' => collect(DB::select("PRAGMA index_list('baseline_snapshots')"))
|
||||
->contains(fn (object $index): bool => ($index->name ?? null) === $indexName),
|
||||
default => false,
|
||||
};
|
||||
}
|
||||
};
|
||||
@ -150,6 +150,8 @@ ### Operations UX
|
||||
- **3-surface feedback**: Toast (immediate) → Progress widget (polling) → DB notification (terminal).
|
||||
- **OperationRun lifecycle**: Service-owned transitions only via `OperationRunService` — no direct status writes.
|
||||
- **Idempotent creation**: Hash-based dedup with partial unique index.
|
||||
- **Lifecycle guarantees (Spec 160)**: Covered queued runs (`baseline_capture`, `baseline_compare`, `inventory_sync`, `policy.sync`, `policy.sync_one`, `entra_group_sync`, `directory_role_definitions.sync`, `backup_schedule_run`, `restore.execute`, `tenant.review_pack.generate`, `tenant.review.compose`, `tenant.evidence.snapshot.generate`) now have a config-backed lifecycle policy, direct failed-job bridges where declared, scheduled stale-run reconciliation, and UI freshness semantics for stale or automatically reconciled runs.
|
||||
- **Queue timing invariant**: Covered job `timeout` values and policy `expected_max_runtime_seconds` must remain safely below queue `retry_after` with the configured safety margin. After changing queue lifecycle settings, restart workers (`php artisan queue:restart` in the target environment) so the new contract takes effect.
|
||||
|
||||
### Filament Standards
|
||||
|
||||
|
||||
@ -5,7 +5,7 @@ # Spec Candidates
|
||||
>
|
||||
> **Flow**: Inbox → Qualified → Planned → Spec created → moved to `Promoted to Spec`
|
||||
|
||||
**Last reviewed**: 2026-03-23 (added governance operator outcome compression follow-up; promoted Spec 158 into ledger)
|
||||
**Last reviewed**: 2026-03-23 (added Operator Explanation Layer candidate; added governance operator outcome compression follow-up; promoted Spec 158 into ledger)
|
||||
|
||||
---
|
||||
|
||||
@ -224,20 +224,74 @@ ### Humanized Diagnostic Summaries for Governance Operations
|
||||
- **Strategic sequencing**: Best treated as the run-detail explainability companion to Governance Operator Outcome Compression. Compression improves governance artifact scan surfaces; this candidate makes governance run detail self-explanatory once an operator drills in.
|
||||
- **Priority**: high
|
||||
|
||||
### Operator Explanation Layer for Degraded / Partial / Suppressed Results
|
||||
- **Type**: cross-cutting UX / domain semantics / operator clarity
|
||||
- **Source**: product analysis 2026-03-23; direct follow-up to Spec 156 (`operator-outcome-taxonomy`)
|
||||
- **Vehicle**: new standalone candidate
|
||||
- **Problem**: Spec 156 improves status, outcome, and run vocabulary, but pure outcome taxonomy does not resolve a deeper operator-readability gap: the product stores truth across multiple semantic dimensions (execution, evaluation, reliability, coverage, recommended action) that are currently presented side by side without separation or explanation. Several governance and operational surfaces — Baseline Compare, Baseline Capture, Operation Run detail, Tenant Reviews, evidence-dependent results — show technically correct but operatorisch schwer lesbare combinations such as `Run finished` + `Counts 0` + `Why no findings: evidence_capture_incomplete`. These force operators to synthesize whether: the run was technically successful, the result is trustworthy, findings were genuinely absent, data was missing, assignments were ambiguous, or follow-up is required. The product preserves truth better than it explains truth. Distinct truth dimensions collapse into shared reading surfaces — count blocks that look like complete results when they show only a subset, `0 findings` that reads as reassurance when evaluation was incomplete, reason codes that serve as primary operator explanation when they are diagnostic material. Enterprise operators need the UI to make without JSON and without implicit product knowledge clear: what happened, how reliable the result is, and what to do next.
|
||||
- **Why it matters**: This is the missing interpretation layer between the outcome taxonomy foundation (Spec 156) and the operator's actual decision. Without it, the product remains formally correct but interpretively weak on its highest-trust governance surfaces. Concrete consequences: operators read `0 findings` as all-clear when evaluation was constrained by evidence gaps; support and debug cost stays high because run-detail pages require JSON or expert knowledge; reason codes leak into operator surfaces as primary explanation; semantic inconsistencies multiply with each new governance/evidence/review feature; enterprise readiness suffers despite strong internal modelling. The gap is systemic — not a single-surface fix — because the same class of problem (execution succeeded, but result is degraded/partial/suppressed) recurs across baselines, reviews, evidence, monitoring, and future governance domains.
|
||||
- **Proposed direction**:
|
||||
- **R1 — Multi-dimensional outcome model in the UI**: operator surfaces must separate result communication into at least five distinguishable axes: execution status, evaluation result, reliability/confidence/trustworthiness, coverage/completeness, recommended action. Not every page needs all five equally prominent, but the UI model must keep them distinct instead of collapsing them into a single badge or count block.
|
||||
- **R2 — Primary operator explanation over raw reason codes**: every relevant reason code or reason cluster gets an operator-readable title, clear meaning description, impact explanation, and next-step guidance. Technical reason codes remain stored and diagnostically usable but stop being the primary explanation. Example: not `evidence_capture_incomplete` as the headline, but "Vergleich unvollständig bewertet — 16 Evidence-Lücken verhinderten vollständige Bewertung."
|
||||
- **R3 — Semantically unambiguous counts**: count blocks must not imply completeness when they represent only a subset. The spec defines which counts are execution counts, which are evaluation-output counts, which are data-quality/completeness counts, and how suppressed/incomplete/degraded states surface. `0 findings` when evidence gaps exist must not read as reassurance.
|
||||
- **R4 — "Why no findings / no results / no report" explanation patterns**: a shared pattern for explaining absent output, distinguishing between genuinely no issues found, no results producible due to missing inputs, results suppressed due to unreliable evidence, prerequisite failure, viewer/rendering limitation, pending calculation, and intentionally unpublished.
|
||||
- **R5 — Reliability/confidence visibility**: compare-, review-, and evidence-dependent results must show whether the result is trustworthy, limited in trustworthiness, incomplete, diagnostically usable but not decision-grade, or not usable. Terms like fidelity, coverage, partial, meta, full, degraded get a defined reading direction instead of appearing loosely side by side.
|
||||
- **R6 — Semantically derived next-step guidance**: the next-step surface must distinguish between no action needed, observe, re-run, fix prerequisite, update inventory/sync, check evidence gaps, manually validate, and escalate — derived from cause and severity, not a generic fallback phrase.
|
||||
- **R7 — Diagnostics available but not dominant**: technical raw data, run context, and reason codes remain accessible for debugging but the default reading path leads with meaning and action: what happened → how reliable → why it looks this way → what to do → then technical details.
|
||||
- **R8 — Shared explanation patterns, not page-by-page special logic**: define reusable cross-domain explanation patterns: completed but degraded, completed but incomplete, no output because suppressed, no output because insufficient evidence, partial result with fallback, output exists but not publication-ready, viewer limitation vs source limitation, prerequisite missing vs execution failed.
|
||||
- **R9 — Baseline Compare as reference case**: Baseline Compare is the golden path for this spec. The case where compare finishes technically, driftResults = 0, counts = 0, evidence gaps exist, and "why no findings" is currently reason-code-heavy must become immediately understandable without additional product knowledge.
|
||||
- **R10 — No contradiction between top-level state and detail interpretation**: top-level states (run finished, completed with follow-up, succeeded, partially succeeded) must not conflict with the underlying result surfaces. Technically successful but fachlich limited results must read as a consistent composite.
|
||||
- **Primary adoption surfaces**:
|
||||
- Operation Run detail pages
|
||||
- Baseline Capture
|
||||
- Baseline Compare
|
||||
- Baseline Snapshot / Compare truth surfaces
|
||||
- Tenant Reviews / review generation states
|
||||
- Evidence-dependent result surfaces
|
||||
- Run summaries, state banners, next-step texts, count summaries
|
||||
- Reason code presentation / translation layer
|
||||
- Count semantics for runs with suppressed / incomplete / degraded results
|
||||
- **Optional extension surfaces**: restore/backup operation outcomes, prerequisite/readiness/missing-input explanation surfaces, alerts that rely on degraded semantics
|
||||
- **UX/semantics principles**:
|
||||
- Truth first, but operator-first wording — truth is not softened but expressed in readable operator language
|
||||
- No false reassurance — `0 findings` or `no errors` must not read as all-clear when evaluation was constrained
|
||||
- Separate execution from confidence — a technically successful run can still produce a limited-confidence result
|
||||
- Default path before diagnostics — the normal reading flow answers meaning and action first, then technical detail
|
||||
- Consistent semantics across domains — the same kind of state must not be named or explained differently in baselines, reviews, and evidence
|
||||
- **Scope boundaries**:
|
||||
- **In scope**: explanation dimension definitions, outcome-to-operator-message mapping, shared explanation pattern library, reference implementation for Baseline Compare / Operation Run detail / Baseline Capture, count semantics rules, reliability visibility rules, next-step guidance patterns, reason code presentation hierarchy
|
||||
- **Out of scope**: queue/retry/job-runtime model redesign, large-scale raw data model restructuring (existing run-context data should suffice), purely visual polishing without semantic improvement, reason code elimination (they remain for diagnostics), complete redesign of every product page, compliance/governance domain logic changes
|
||||
- **Phased delivery**:
|
||||
- Phase 1 — Semantics model: define explanation dimensions, outcome vs reliability vs coverage vs action separation, reason-code-to-operator-message mapping, shared explanation pattern library
|
||||
- Phase 2 — Reference implementation: Operation Run detail page, Baseline Compare, Baseline Capture
|
||||
- Phase 3 — Extension: Tenant Reviews, evidence/report surfaces, further degraded/prerequisite-heavy flows
|
||||
- **Success criteria**:
|
||||
- An operator can understand degraded/suppressed results without JSON: what was executed, whether results were produced, how reliable they are, why nothing was generated, and what to do next
|
||||
- Baseline Compare with 0 findings + evidence gaps shows no implicit all-clear
|
||||
- Reason codes remain technically usable but no longer serve as sole primary explanation
|
||||
- Count surfaces are semantically unambiguous, distinguishing output counts from completeness/reliability signals
|
||||
- At least one shared explanation pattern is reused across domains rather than landing as one-off page logic
|
||||
- **Dependencies**: Spec 156 (operator-outcome-taxonomy — shared vocabulary foundation), Spec 157 (reason-code-translation — humanized code labels), Spec 158 (artifact-truth-semantics — governance artifact truth model), Spec 159 (baseline-snapshot-truth — baseline truth surfaces)
|
||||
- **Related specs / candidates**: Governance Operator Outcome Compression (governance-artifact list/scan compression — complementary, narrower scope), Humanized Diagnostic Summaries for Governance Operations (governance run-detail explainability — complementary, narrower scope), Baseline Capture Truthful Outcomes (capture-specific precondition and outcome hardening), OperationRun Humanization & Diagnostics Boundary (run-detail operator-first hierarchy)
|
||||
- **Strategic importance**: This is not UI polish but an enterprise-readability and governance-trust layer. As the roadmap expands toward baseline governance, findings workflow, exceptions/risk acceptance, stored reports, tenant reviews, evidence packs, and MSP portfolio views, the cost of not having a systematic explanation layer increases with every new feature. The longer this is deferred, the more governance surfaces ship with locally invented explanation patterns that diverge instead of converging. This candidate is strategically anschlussfähig rather than locally reactive.
|
||||
- **Priority**: high
|
||||
|
||||
> **Operator Truth Initiative — Sequencing Note**
|
||||
>
|
||||
> The operator-truth work now has two connected lanes: a shared truth-foundation lane and a governance-surface compression lane. Together they address the systemic gap between backend truth richness and operator-facing truth quality without forcing operators to parse raw internal semantics.
|
||||
>
|
||||
> **Recommended order:**
|
||||
> 1. **Operator Outcome Taxonomy and Cross-Domain State Separation** — defines the shared vocabulary, state-axis separation rules, and color-severity conventions that all other operator-facing work references. This is the smallest deliverable (a reference document + restructuring guidelines) but the highest-leverage decision. Without it, the other two candidates will invent local vocabularies that diverge.
|
||||
> 1. **Operator Outcome Taxonomy and Cross-Domain State Separation** — defines the shared vocabulary, state-axis separation rules, and color-severity conventions that all other operator-facing work references. This is the smallest deliverable (a reference document + restructuring guidelines) but the highest-leverage decision. Without it, the other candidates will invent local vocabularies that diverge.
|
||||
> 2. **Operator Reason Code Translation and Humanization Contract** — defines the translation bridge from internal codes to operator-facing labels using the Outcome Taxonomy's vocabulary. Can begin in parallel with the taxonomy using pragmatic interim labels, but final convergence depends on the taxonomy.
|
||||
> 3. **Governance Artifact Truthful Outcomes & Fidelity Semantics** — establishes the full internal truth model for governance artifacts, keeping existence, usability, freshness, completeness, publication readiness, and actionability distinct.
|
||||
> 4. **Governance Operator Outcome Compression** — applies the foundation to governance workflow surfaces so lists and details answer the operator's primary question first, while preserving diagnostics as second-layer detail.
|
||||
> 5. **Provider-Backed Action Preflight and Dispatch Gate Unification** — extends the proven Gen 2 gate pattern to all provider-backed operations and establishes a shared result presenter. Benefits from the shared vocabulary work but remains a parallel hardening lane rather than a governance-surface adoption slice.
|
||||
> 4. **Operator Explanation Layer for Degraded / Partial / Suppressed Results** — defines the cross-cutting interpretation layer that turns internal truth dimensions into operator-readable explanation: multi-dimensional outcome separation (execution, evaluation, reliability, coverage, action), shared explanation patterns for degraded/partial/suppressed states, count semantics rules, "why no findings" patterns, and reliability visibility. Consumes the taxonomy, translation, and artifact-truth foundations; provides the shared explanation pattern library that compression and humanized summaries adopt on their respective surfaces. Baseline Compare is the golden-path reference implementation.
|
||||
> 5. **Governance Operator Outcome Compression** — applies the foundation and explanation layer to governance workflow surfaces so lists and details answer the operator's primary question first, while preserving diagnostics as second-layer detail.
|
||||
> 6. **Humanized Diagnostic Summaries for Governance Operations** — the run-detail explainability companion to compression; makes governance run detail self-explanatory using the explanation patterns established in step 4.
|
||||
> 7. **Provider-Backed Action Preflight and Dispatch Gate Unification** — extends the proven Gen 2 gate pattern to all provider-backed operations and establishes a shared result presenter. Benefits from the shared vocabulary work but remains a parallel hardening lane rather than a governance-surface adoption slice.
|
||||
>
|
||||
> **Why this order rather than the inverse:** The semantic-clarity audit classified the taxonomy problem as P0 (60% of warning badges are false alarms — actively damages operator trust). Reason code translation creates the shared human-facing language. Spec 158 establishes the correct internal truth engine for governance artifacts. The compression follow-up is then what turns that engine into a scanable operator cockpit before more governance features land. Gate unification remains highly valuable, but it is a neighboring hardening lane rather than the immediate follow-up needed to make governance truth semantics feel product-ready.
|
||||
> **Why this order rather than the inverse:** The semantic-clarity audit classified the taxonomy problem as P0 (60% of warning badges are false alarms — actively damages operator trust). Reason code translation creates the shared human-facing language. Spec 158 establishes the correct internal truth engine for governance artifacts. The Operator Explanation Layer then defines the cross-cutting interpretation patterns that all downstream surfaces need — the systemwide rules for how degraded, partial, suppressed, and incomplete results are separated, explained, and acted upon. Compression and humanized summaries are adoption slices that apply those patterns to specific surface families (governance artifact lists and governance run details respectively). Gate unification remains highly valuable but is a neighboring hardening lane.
|
||||
>
|
||||
> **Why these are not one spec:** Each candidate has a different implementation surface, different stakeholders, and different shippability boundary. The taxonomy is a cross-cutting decision document. Reason code translation touches reason-code artifacts and notification builders. Spec 158 defines the richer artifact truth engine. Governance operator outcome compression is a UI-information-architecture adoption slice across governance surfaces. Gate unification touches provider dispatch and notification plumbing across ~20 services. Merging them would create an unshippable monolith. Keeping them sequenced preserves independent delivery while still converging on one operator language.
|
||||
> **Why these are not one spec:** Each candidate has a different implementation surface, different stakeholders, and different shippability boundary. The taxonomy is a cross-cutting decision document. Reason code translation touches reason-code artifacts and notification builders. Spec 158 defines the richer artifact truth engine. The Operator Explanation Layer defines the shared interpretation semantics and explanation patterns. Governance operator outcome compression is a UI-information-architecture adoption slice across governance artifact surfaces. Humanized diagnostic summaries are an adoption slice for governance run-detail pages. Gate unification touches provider dispatch and notification plumbing across ~20 services. Merging them would create an unshippable monolith. Keeping them sequenced preserves independent delivery while still converging on one operator language.
|
||||
|
||||
### Baseline Snapshot Fidelity Semantics
|
||||
- **Type**: hardening
|
||||
|
||||
170
public/js/tenantpilot/ops-ux-progress-widget-poller.js
Normal file
170
public/js/tenantpilot/ops-ux-progress-widget-poller.js
Normal file
@ -0,0 +1,170 @@
|
||||
(() => {
|
||||
if (typeof window === 'undefined') {
|
||||
return;
|
||||
}
|
||||
|
||||
if (typeof window.opsUxProgressWidgetPoller === 'function') {
|
||||
return;
|
||||
}
|
||||
|
||||
window.opsUxProgressWidgetPoller = function opsUxProgressWidgetPoller() {
|
||||
return {
|
||||
timer: null,
|
||||
activeSinceMs: null,
|
||||
fastUntilMs: null,
|
||||
teardownObserver: null,
|
||||
|
||||
init() {
|
||||
this.onVisibilityChange = this.onVisibilityChange.bind(this);
|
||||
window.addEventListener('visibilitychange', this.onVisibilityChange);
|
||||
|
||||
this.onNavigated = this.onNavigated.bind(this);
|
||||
window.addEventListener('livewire:navigated', this.onNavigated);
|
||||
|
||||
this.teardownObserver = new MutationObserver(() => {
|
||||
if (!this.$el || this.$el.isConnected !== true) {
|
||||
this.destroy();
|
||||
}
|
||||
});
|
||||
|
||||
this.teardownObserver.observe(document.body, { childList: true, subtree: true });
|
||||
|
||||
this.schedule(0);
|
||||
},
|
||||
|
||||
destroy() {
|
||||
this.stop();
|
||||
window.removeEventListener('visibilitychange', this.onVisibilityChange);
|
||||
window.removeEventListener('livewire:navigated', this.onNavigated);
|
||||
|
||||
if (this.teardownObserver) {
|
||||
this.teardownObserver.disconnect();
|
||||
this.teardownObserver = null;
|
||||
}
|
||||
},
|
||||
|
||||
stop() {
|
||||
if (this.timer) {
|
||||
clearTimeout(this.timer);
|
||||
this.timer = null;
|
||||
}
|
||||
},
|
||||
|
||||
isModalOpen() {
|
||||
return document.querySelector('[role="dialog"][aria-modal="true"]') !== null;
|
||||
},
|
||||
|
||||
isPaused() {
|
||||
if (document.hidden === true) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (this.isModalOpen()) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!this.$el || this.$el.isConnected !== true) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
},
|
||||
|
||||
onVisibilityChange() {
|
||||
if (!this.isPaused()) {
|
||||
this.schedule(0);
|
||||
}
|
||||
},
|
||||
|
||||
onNavigated() {
|
||||
if (!this.isPaused()) {
|
||||
this.schedule(0);
|
||||
}
|
||||
},
|
||||
|
||||
activeAgeSeconds() {
|
||||
if (this.activeSinceMs === null) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return Math.floor((Date.now() - this.activeSinceMs) / 1000);
|
||||
},
|
||||
|
||||
nextIntervalMs() {
|
||||
if (this.$wire?.disabled === true) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (this.$wire?.hasActiveRuns !== true) {
|
||||
this.activeSinceMs = null;
|
||||
return 30_000;
|
||||
}
|
||||
|
||||
if (this.activeSinceMs === null) {
|
||||
this.activeSinceMs = Date.now();
|
||||
}
|
||||
|
||||
const now = Date.now();
|
||||
|
||||
if (this.fastUntilMs && now < this.fastUntilMs) {
|
||||
return 1_000;
|
||||
}
|
||||
|
||||
const age = this.activeAgeSeconds();
|
||||
|
||||
if (age < 10) {
|
||||
return 1_000;
|
||||
}
|
||||
|
||||
if (age < 60) {
|
||||
return 5_000;
|
||||
}
|
||||
|
||||
return 10_000;
|
||||
},
|
||||
|
||||
async tick() {
|
||||
if (this.isPaused()) {
|
||||
this.schedule(2_000);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await this.$wire.refreshRuns();
|
||||
} catch (error) {
|
||||
const isCancellation = Boolean(
|
||||
error &&
|
||||
typeof error === 'object' &&
|
||||
error.status === null &&
|
||||
error.body === null &&
|
||||
error.json === null &&
|
||||
error.errors === null,
|
||||
);
|
||||
|
||||
if (!isCancellation) {
|
||||
console.warn('Ops UX widget refreshRuns failed', error);
|
||||
}
|
||||
}
|
||||
|
||||
const next = this.nextIntervalMs();
|
||||
|
||||
if (next === null) {
|
||||
this.stop();
|
||||
return;
|
||||
}
|
||||
|
||||
this.schedule(next);
|
||||
},
|
||||
|
||||
schedule(delayMs) {
|
||||
this.stop();
|
||||
|
||||
const delay = Math.max(0, Number(delayMs ?? 0));
|
||||
|
||||
this.timer = setTimeout(() => {
|
||||
this.tick().catch(() => {});
|
||||
}, delay);
|
||||
},
|
||||
};
|
||||
};
|
||||
})();
|
||||
@ -1,5 +1,9 @@
|
||||
@import '../../../../vendor/filament/filament/resources/css/theme.css';
|
||||
|
||||
@theme {
|
||||
--radius-2xl: 1rem;
|
||||
}
|
||||
|
||||
@source '../../../../app/Filament/**/*';
|
||||
@source '../../../../resources/views/filament/**/*.blade.php';
|
||||
@source '../../../../resources/views/livewire/**/*.blade.php';
|
||||
|
||||
@ -1,4 +1,8 @@
|
||||
@import '../../../../vendor/filament/filament/resources/css/theme.css';
|
||||
|
||||
@theme {
|
||||
--radius-2xl: 1rem;
|
||||
}
|
||||
|
||||
@source '../../../../app/Filament/System/**/*';
|
||||
@source '../../../../resources/views/filament/system/**/*.blade.php';
|
||||
|
||||
@ -8,21 +8,12 @@
|
||||
$action = is_array($card['action'] ?? null) ? $card['action'] : null;
|
||||
@endphp
|
||||
|
||||
<div class="rounded-2xl border border-gray-200 bg-white/90 p-4 shadow-sm dark:border-gray-800 dark:bg-gray-900/80">
|
||||
<div class="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<div class="text-sm font-semibold text-gray-950 dark:text-white">
|
||||
{{ $card['title'] ?? 'Supporting detail' }}
|
||||
</div>
|
||||
|
||||
@if (filled($card['description'] ?? null))
|
||||
<div class="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||
{{ $card['description'] }}
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
@if ($action !== null && filled($action['url'] ?? null))
|
||||
<x-filament::section
|
||||
:heading="$card['title'] ?? 'Supporting detail'"
|
||||
:description="$card['description'] ?? null"
|
||||
>
|
||||
@if ($action !== null && filled($action['url'] ?? null))
|
||||
<x-slot name="headerEnd">
|
||||
<a
|
||||
href="{{ $action['url'] }}"
|
||||
@if (($action['openInNewTab'] ?? false) === true) target="_blank" rel="noreferrer noopener" @endif
|
||||
@ -30,10 +21,10 @@ class="text-xs font-medium {{ ($action['destructive'] ?? false) === true ? 'text
|
||||
>
|
||||
{{ $action['label'] }}
|
||||
</a>
|
||||
@endif
|
||||
</div>
|
||||
</x-slot>
|
||||
@endif
|
||||
|
||||
<div class="mt-4">
|
||||
<div>
|
||||
@if ($view !== null)
|
||||
{!! view($view, is_array($card['viewData'] ?? null) ? $card['viewData'] : [])->render() !!}
|
||||
@elseif ($items !== [])
|
||||
@ -42,4 +33,4 @@ class="text-xs font-medium {{ ($action['destructive'] ?? false) === true ? 'text
|
||||
@include('filament.infolists.entries.enterprise-detail.empty-state', ['state' => $emptyState])
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
</x-filament::section>
|
||||
|
||||
@ -16,133 +16,4 @@
|
||||
</x-filament::section>
|
||||
|
||||
{{ $this->table }}
|
||||
|
||||
@php
|
||||
$selectedAudit = $this->selectedAuditLog();
|
||||
$selectedAuditLink = $this->selectedAuditLink();
|
||||
@endphp
|
||||
|
||||
@if ($selectedAudit)
|
||||
<x-filament::section
|
||||
:heading="$selectedAudit->summaryText()"
|
||||
:description="$selectedAudit->recorded_at?->toDayDateTimeString()"
|
||||
>
|
||||
<div class="flex flex-col gap-6">
|
||||
<div class="flex flex-wrap items-center gap-3">
|
||||
<span class="inline-flex items-center rounded-full bg-gray-100 px-3 py-1 text-xs font-medium text-gray-700 dark:bg-gray-800 dark:text-gray-200">
|
||||
{{ \App\Support\Badges\BadgeRenderer::label(\App\Support\Badges\BadgeDomain::AuditOutcome)($selectedAudit->normalizedOutcome()->value) }}
|
||||
</span>
|
||||
<span class="inline-flex items-center rounded-full bg-gray-100 px-3 py-1 text-xs font-medium text-gray-700 dark:bg-gray-800 dark:text-gray-200">
|
||||
{{ \App\Support\Badges\BadgeRenderer::label(\App\Support\Badges\BadgeDomain::AuditActorType)($selectedAudit->actorSnapshot()->type->value) }}
|
||||
</span>
|
||||
|
||||
@if (is_array($selectedAuditLink))
|
||||
<a
|
||||
class="inline-flex items-center rounded-lg border border-gray-300 px-3 py-2 text-sm font-medium text-gray-700 transition hover:bg-gray-50 dark:border-gray-700 dark:text-gray-200 dark:hover:bg-gray-900"
|
||||
href="{{ $selectedAuditLink['url'] }}"
|
||||
>
|
||||
{{ $selectedAuditLink['label'] }}
|
||||
</a>
|
||||
@endif
|
||||
|
||||
<button
|
||||
class="inline-flex items-center rounded-lg border border-transparent px-3 py-2 text-sm font-medium text-gray-500 transition hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200"
|
||||
type="button"
|
||||
wire:click="clearSelectedAuditLog"
|
||||
>
|
||||
Close details
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-4 lg:grid-cols-3">
|
||||
<div class="rounded-2xl border border-gray-200 bg-white p-4 shadow-sm dark:border-gray-800 dark:bg-gray-900">
|
||||
<div class="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">
|
||||
Actor
|
||||
</div>
|
||||
<div class="mt-2 text-sm font-medium text-gray-900 dark:text-gray-100">
|
||||
{{ $selectedAudit->actorDisplayLabel() }}
|
||||
</div>
|
||||
<div class="mt-1 text-sm text-gray-600 dark:text-gray-300">
|
||||
{{ $selectedAudit->actorSnapshot()->type->label() }}
|
||||
</div>
|
||||
@if ($selectedAudit->actorSnapshot()->email)
|
||||
<div class="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||
{{ $selectedAudit->actorSnapshot()->email }}
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
<div class="rounded-2xl border border-gray-200 bg-white p-4 shadow-sm dark:border-gray-800 dark:bg-gray-900">
|
||||
<div class="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">
|
||||
Target
|
||||
</div>
|
||||
<div class="mt-2 text-sm font-medium text-gray-900 dark:text-gray-100">
|
||||
{{ $selectedAudit->targetDisplayLabel() ?? 'No target snapshot' }}
|
||||
</div>
|
||||
<div class="mt-1 text-sm text-gray-600 dark:text-gray-300">
|
||||
{{ $selectedAudit->resource_type ? ucfirst(str_replace('_', ' ', $selectedAudit->resource_type)) : 'Workspace event' }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="rounded-2xl border border-gray-200 bg-white p-4 shadow-sm dark:border-gray-800 dark:bg-gray-900">
|
||||
<div class="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">
|
||||
Scope
|
||||
</div>
|
||||
<div class="mt-2 text-sm font-medium text-gray-900 dark:text-gray-100">
|
||||
{{ $selectedAudit->tenant?->name ?? 'Workspace-wide event' }}
|
||||
</div>
|
||||
<div class="mt-1 text-sm text-gray-600 dark:text-gray-300">
|
||||
Workspace #{{ $selectedAudit->workspace_id }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-4 lg:grid-cols-2">
|
||||
<div class="rounded-2xl border border-gray-200 bg-white p-4 shadow-sm dark:border-gray-800 dark:bg-gray-900">
|
||||
<div class="text-sm font-semibold text-gray-900 dark:text-gray-100">
|
||||
Readable context
|
||||
</div>
|
||||
|
||||
@if ($selectedAudit->contextItems() === [])
|
||||
<div class="mt-3 text-sm text-gray-600 dark:text-gray-300">
|
||||
No additional context was recorded for this event.
|
||||
</div>
|
||||
@else
|
||||
<dl class="mt-3 space-y-3">
|
||||
@foreach ($selectedAudit->contextItems() as $item)
|
||||
<div>
|
||||
<dt class="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">
|
||||
{{ $item['label'] }}
|
||||
</dt>
|
||||
<dd class="mt-1 text-sm text-gray-900 dark:text-gray-100">
|
||||
{{ is_bool($item['value']) ? ($item['value'] ? 'true' : 'false') : $item['value'] }}
|
||||
</dd>
|
||||
</div>
|
||||
@endforeach
|
||||
</dl>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
<div class="rounded-2xl border border-gray-200 bg-white p-4 shadow-sm dark:border-gray-800 dark:bg-gray-900">
|
||||
<div class="text-sm font-semibold text-gray-900 dark:text-gray-100">
|
||||
Technical metadata
|
||||
</div>
|
||||
|
||||
<dl class="mt-3 space-y-3">
|
||||
@foreach ($selectedAudit->technicalMetadata() as $label => $value)
|
||||
<div>
|
||||
<dt class="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">
|
||||
{{ $label }}
|
||||
</dt>
|
||||
<dd class="mt-1 break-all text-sm text-gray-900 dark:text-gray-100">
|
||||
{{ $value }}
|
||||
</dd>
|
||||
</div>
|
||||
@endforeach
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</x-filament::section>
|
||||
@endif
|
||||
</x-filament-panels::page>
|
||||
|
||||
@ -1,4 +1,6 @@
|
||||
<x-filament-panels::page>
|
||||
@php($lifecycleSummary = $this->lifecycleVisibilitySummary())
|
||||
|
||||
<x-filament::tabs label="Operations tabs">
|
||||
<x-filament::tabs.item
|
||||
:active="$this->activeTab === 'all'"
|
||||
@ -38,5 +40,12 @@
|
||||
</x-filament::tabs.item>
|
||||
</x-filament::tabs>
|
||||
|
||||
@if (($lifecycleSummary['likely_stale'] ?? 0) > 0 || ($lifecycleSummary['reconciled'] ?? 0) > 0)
|
||||
<div class="mb-4 rounded-lg border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-900 dark:border-amber-500/30 dark:bg-amber-500/10 dark:text-amber-100">
|
||||
{{ ($lifecycleSummary['likely_stale'] ?? 0) }} active run(s) are beyond their lifecycle window.
|
||||
{{ ($lifecycleSummary['reconciled'] ?? 0) }} run(s) have already been automatically reconciled.
|
||||
</div>
|
||||
@endif
|
||||
|
||||
{{ $this->table }}
|
||||
</x-filament-panels::page>
|
||||
|
||||
@ -0,0 +1,115 @@
|
||||
@php
|
||||
$selectedAudit = $selectedAudit ?? null;
|
||||
$selectedAuditLink = $selectedAuditLink ?? null;
|
||||
@endphp
|
||||
|
||||
@if ($selectedAudit)
|
||||
<div class="flex flex-col gap-6">
|
||||
<div class="flex flex-wrap items-center gap-3">
|
||||
<span class="inline-flex items-center rounded-full bg-gray-100 px-3 py-1 text-xs font-medium text-gray-700 dark:bg-gray-800 dark:text-gray-200">
|
||||
{{ \App\Support\Badges\BadgeRenderer::label(\App\Support\Badges\BadgeDomain::AuditOutcome)($selectedAudit->normalizedOutcome()->value) }}
|
||||
</span>
|
||||
<span class="inline-flex items-center rounded-full bg-gray-100 px-3 py-1 text-xs font-medium text-gray-700 dark:bg-gray-800 dark:text-gray-200">
|
||||
{{ \App\Support\Badges\BadgeRenderer::label(\App\Support\Badges\BadgeDomain::AuditActorType)($selectedAudit->actorSnapshot()->type->value) }}
|
||||
</span>
|
||||
|
||||
@if (is_array($selectedAuditLink))
|
||||
<a
|
||||
class="inline-flex items-center rounded-lg border border-gray-300 px-3 py-2 text-sm font-medium text-gray-700 transition hover:bg-gray-50 dark:border-gray-700 dark:text-gray-200 dark:hover:bg-gray-900"
|
||||
href="{{ $selectedAuditLink['url'] }}"
|
||||
>
|
||||
{{ $selectedAuditLink['label'] }}
|
||||
</a>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
<div class="grid gap-4 lg:grid-cols-3">
|
||||
<div class="rounded-2xl border border-gray-200 bg-white p-4 shadow-sm dark:border-gray-800 dark:bg-gray-900">
|
||||
<div class="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">
|
||||
Actor
|
||||
</div>
|
||||
<div class="mt-2 text-sm font-medium text-gray-900 dark:text-gray-100">
|
||||
{{ $selectedAudit->actorDisplayLabel() }}
|
||||
</div>
|
||||
<div class="mt-1 text-sm text-gray-600 dark:text-gray-300">
|
||||
{{ $selectedAudit->actorSnapshot()->type->label() }}
|
||||
</div>
|
||||
@if ($selectedAudit->actorSnapshot()->email)
|
||||
<div class="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||
{{ $selectedAudit->actorSnapshot()->email }}
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
<div class="rounded-2xl border border-gray-200 bg-white p-4 shadow-sm dark:border-gray-800 dark:bg-gray-900">
|
||||
<div class="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">
|
||||
Target
|
||||
</div>
|
||||
<div class="mt-2 text-sm font-medium text-gray-900 dark:text-gray-100">
|
||||
{{ $selectedAudit->targetDisplayLabel() ?? 'No target snapshot' }}
|
||||
</div>
|
||||
<div class="mt-1 text-sm text-gray-600 dark:text-gray-300">
|
||||
{{ $selectedAudit->resource_type ? ucfirst(str_replace('_', ' ', $selectedAudit->resource_type)) : 'Workspace event' }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="rounded-2xl border border-gray-200 bg-white p-4 shadow-sm dark:border-gray-800 dark:bg-gray-900">
|
||||
<div class="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">
|
||||
Scope
|
||||
</div>
|
||||
<div class="mt-2 text-sm font-medium text-gray-900 dark:text-gray-100">
|
||||
{{ $selectedAudit->tenant?->name ?? 'Workspace-wide event' }}
|
||||
</div>
|
||||
<div class="mt-1 text-sm text-gray-600 dark:text-gray-300">
|
||||
Workspace #{{ $selectedAudit->workspace_id }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-4 lg:grid-cols-2">
|
||||
<div class="rounded-2xl border border-gray-200 bg-white p-4 shadow-sm dark:border-gray-800 dark:bg-gray-900">
|
||||
<div class="text-sm font-semibold text-gray-900 dark:text-gray-100">
|
||||
Readable context
|
||||
</div>
|
||||
|
||||
@if ($selectedAudit->contextItems() === [])
|
||||
<div class="mt-3 text-sm text-gray-600 dark:text-gray-300">
|
||||
No additional context was recorded for this event.
|
||||
</div>
|
||||
@else
|
||||
<dl class="mt-3 space-y-3">
|
||||
@foreach ($selectedAudit->contextItems() as $item)
|
||||
<div>
|
||||
<dt class="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">
|
||||
{{ $item['label'] }}
|
||||
</dt>
|
||||
<dd class="mt-1 text-sm text-gray-900 dark:text-gray-100">
|
||||
{{ is_bool($item['value']) ? ($item['value'] ? 'true' : 'false') : $item['value'] }}
|
||||
</dd>
|
||||
</div>
|
||||
@endforeach
|
||||
</dl>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
<div class="rounded-2xl border border-gray-200 bg-white p-4 shadow-sm dark:border-gray-800 dark:bg-gray-900">
|
||||
<div class="text-sm font-semibold text-gray-900 dark:text-gray-100">
|
||||
Technical metadata
|
||||
</div>
|
||||
|
||||
<dl class="mt-3 space-y-3">
|
||||
@foreach ($selectedAudit->technicalMetadata() as $label => $value)
|
||||
<div>
|
||||
<dt class="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">
|
||||
{{ $label }}
|
||||
</dt>
|
||||
<dd class="mt-1 break-all text-sm text-gray-900 dark:text-gray-100">
|
||||
{{ $value }}
|
||||
</dd>
|
||||
</div>
|
||||
@endforeach
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
@ -1,6 +1,7 @@
|
||||
@php
|
||||
$contextBanner = $this->canonicalContextBanner();
|
||||
$blockedBanner = $this->blockedExecutionBanner();
|
||||
$lifecycleBanner = $this->lifecycleBanner();
|
||||
$pollInterval = $this->pollInterval();
|
||||
@endphp
|
||||
|
||||
@ -37,6 +38,20 @@
|
||||
</div>
|
||||
@endif
|
||||
|
||||
@if ($lifecycleBanner !== null)
|
||||
@php
|
||||
$lifecycleBannerClasses = match ($lifecycleBanner['tone']) {
|
||||
'rose' => 'border-rose-200 bg-rose-50 text-rose-900 dark:border-rose-500/30 dark:bg-rose-500/10 dark:text-rose-100',
|
||||
default => 'border-amber-200 bg-amber-50 text-amber-900 dark:border-amber-500/30 dark:bg-amber-500/10 dark:text-amber-100',
|
||||
};
|
||||
@endphp
|
||||
|
||||
<div class="mb-6 rounded-lg border px-4 py-3 text-sm {{ $lifecycleBannerClasses }}">
|
||||
<p class="font-semibold">{{ $lifecycleBanner['title'] }}</p>
|
||||
<p class="mt-1">{{ $lifecycleBanner['body'] }}</p>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
@if ($this->redactionIntegrityNote())
|
||||
<div class="mb-6 rounded-lg border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-900 dark:border-amber-500/30 dark:bg-amber-500/10 dark:text-amber-100">
|
||||
{{ $this->redactionIntegrityNote() }}
|
||||
|
||||
@ -1,2 +1,3 @@
|
||||
<script defer src="{{ asset('js/tenantpilot/livewire-intercept-shim.js') }}"></script>
|
||||
<script defer src="{{ asset('js/tenantpilot/filament-sidebar-store-fallback.js') }}"></script>
|
||||
<script src="{{ asset('js/tenantpilot/livewire-intercept-shim.js') }}"></script>
|
||||
<script src="{{ asset('js/tenantpilot/filament-sidebar-store-fallback.js') }}"></script>
|
||||
<script src="{{ asset('js/tenantpilot/ops-ux-progress-widget-poller.js') }}"></script>
|
||||
|
||||
@ -15,6 +15,14 @@
|
||||
<div class="mt-0.5 text-sm text-gray-500 dark:text-gray-400">Assign a baseline profile to start monitoring drift.</div>
|
||||
</div>
|
||||
</div>
|
||||
@elseif (($state ?? null) === 'no_snapshot')
|
||||
<div class="flex items-start gap-3 rounded-lg border border-warning-300 bg-warning-50 p-4 dark:border-warning-700 dark:bg-warning-950/40">
|
||||
<x-heroicon-o-camera class="mt-0.5 h-5 w-5 shrink-0 text-warning-500 dark:text-warning-400" />
|
||||
<div>
|
||||
<div class="text-sm font-medium text-warning-900 dark:text-warning-100">Current Baseline Unavailable</div>
|
||||
<div class="mt-0.5 text-sm text-warning-800 dark:text-warning-200">{{ $message }}</div>
|
||||
</div>
|
||||
</div>
|
||||
@else
|
||||
<div class="flex flex-col gap-3">
|
||||
{{-- Profile + last compared --}}
|
||||
|
||||
@ -1,6 +1,8 @@
|
||||
@php
|
||||
/** @var bool $shouldShow */
|
||||
/** @var ?string $runUrl */
|
||||
/** @var ?string $state */
|
||||
/** @var ?string $message */
|
||||
/** @var ?string $coverageStatus */
|
||||
/** @var ?string $fidelity */
|
||||
/** @var int $uncoveredTypesCount */
|
||||
@ -10,12 +12,20 @@
|
||||
@endphp
|
||||
|
||||
<div>
|
||||
@if ($shouldShow && $coverageHasWarnings)
|
||||
@if ($shouldShow && ($coverageHasWarnings || ($state ?? null) === 'no_snapshot'))
|
||||
<div class="rounded-lg border border-warning-300 bg-warning-50 p-4 text-warning-900 dark:border-warning-700 dark:bg-warning-950/40 dark:text-warning-100">
|
||||
<div class="flex flex-col gap-1">
|
||||
<div class="text-sm font-semibold">Baseline compare coverage warnings</div>
|
||||
<div class="text-sm font-semibold">
|
||||
@if (($state ?? null) === 'no_snapshot')
|
||||
Current baseline unavailable
|
||||
@else
|
||||
Baseline compare coverage warnings
|
||||
@endif
|
||||
</div>
|
||||
<div class="text-sm">
|
||||
@if (($coverageStatus ?? null) === 'unproven')
|
||||
@if (($state ?? null) === 'no_snapshot')
|
||||
{{ $message }}
|
||||
@elseif (($coverageStatus ?? null) === 'unproven')
|
||||
Coverage proof was missing or unreadable for the last baseline comparison, so findings were suppressed for safety.
|
||||
@else
|
||||
The last baseline comparison had incomplete coverage for {{ (int) $uncoveredTypesCount }} policy {{ Str::plural('type', (int) $uncoveredTypesCount) }}. Findings may be incomplete.
|
||||
@ -32,7 +42,7 @@
|
||||
</div>
|
||||
@endif
|
||||
|
||||
@if (filled($runUrl))
|
||||
@if (($state ?? null) !== 'no_snapshot' && filled($runUrl))
|
||||
<div class="mt-2">
|
||||
<a class="text-sm font-medium text-primary-600 hover:underline dark:text-primary-400" href="{{ $runUrl }}">
|
||||
View run
|
||||
@ -43,4 +53,3 @@
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
|
||||
@ -50,173 +50,3 @@ class="block rounded-lg bg-white/90 dark:bg-gray-800/90 px-4 py-2 text-center te
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
<script>
|
||||
window.opsUxProgressWidgetPoller ??= function opsUxProgressWidgetPoller() {
|
||||
return {
|
||||
timer: null,
|
||||
activeSinceMs: null,
|
||||
fastUntilMs: null,
|
||||
teardownObserver: null,
|
||||
|
||||
init() {
|
||||
this.onVisibilityChange = this.onVisibilityChange.bind(this);
|
||||
window.addEventListener('visibilitychange', this.onVisibilityChange);
|
||||
|
||||
this.onNavigated = this.onNavigated.bind(this);
|
||||
window.addEventListener('livewire:navigated', this.onNavigated);
|
||||
|
||||
// Ensure we always detach listeners when Livewire/Filament removes this
|
||||
// element (for example during modal unmounts or navigation/morph).
|
||||
this.teardownObserver = new MutationObserver(() => {
|
||||
if (!this.$el || this.$el.isConnected !== true) {
|
||||
this.destroy();
|
||||
}
|
||||
});
|
||||
|
||||
this.teardownObserver.observe(document.body, { childList: true, subtree: true });
|
||||
|
||||
// First sync immediately.
|
||||
this.schedule(0);
|
||||
},
|
||||
|
||||
destroy() {
|
||||
this.stop();
|
||||
window.removeEventListener('visibilitychange', this.onVisibilityChange);
|
||||
window.removeEventListener('livewire:navigated', this.onNavigated);
|
||||
|
||||
if (this.teardownObserver) {
|
||||
this.teardownObserver.disconnect();
|
||||
this.teardownObserver = null;
|
||||
}
|
||||
},
|
||||
|
||||
stop() {
|
||||
if (this.timer) {
|
||||
clearTimeout(this.timer);
|
||||
this.timer = null;
|
||||
}
|
||||
},
|
||||
|
||||
isModalOpen() {
|
||||
return document.querySelector('[role="dialog"][aria-modal="true"]') !== null;
|
||||
},
|
||||
|
||||
isPaused() {
|
||||
if (document.hidden === true) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (this.isModalOpen()) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!this.$el || this.$el.isConnected !== true) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
},
|
||||
|
||||
onVisibilityChange() {
|
||||
if (!this.isPaused()) {
|
||||
this.schedule(0);
|
||||
}
|
||||
},
|
||||
|
||||
onNavigated() {
|
||||
if (!this.isPaused()) {
|
||||
this.schedule(0);
|
||||
}
|
||||
},
|
||||
|
||||
activeAgeSeconds() {
|
||||
if (this.activeSinceMs === null) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return Math.floor((Date.now() - this.activeSinceMs) / 1000);
|
||||
},
|
||||
|
||||
nextIntervalMs() {
|
||||
// Stop polling entirely if server says this component is disabled.
|
||||
if (this.$wire?.disabled === true) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Discovery polling.
|
||||
if (this.$wire?.hasActiveRuns !== true) {
|
||||
this.activeSinceMs = null;
|
||||
return 30_000;
|
||||
}
|
||||
|
||||
// Active polling backoff.
|
||||
if (this.activeSinceMs === null) {
|
||||
this.activeSinceMs = Date.now();
|
||||
}
|
||||
|
||||
const now = Date.now();
|
||||
if (this.fastUntilMs && now < this.fastUntilMs) {
|
||||
return 1_000;
|
||||
}
|
||||
|
||||
const age = this.activeAgeSeconds();
|
||||
if (age < 10) {
|
||||
return 1_000;
|
||||
}
|
||||
|
||||
if (age < 60) {
|
||||
return 5_000;
|
||||
}
|
||||
|
||||
return 10_000;
|
||||
},
|
||||
|
||||
async tick() {
|
||||
if (this.isPaused()) {
|
||||
// Keep it calm: check again later.
|
||||
this.schedule(2_000);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await this.$wire.refreshRuns();
|
||||
} catch (error) {
|
||||
// Livewire will reject pending action promises when requests are
|
||||
// cancelled (for example during `wire:navigate`). That's expected
|
||||
// for this background poller, so we swallow cancellation rejections.
|
||||
const isCancellation = Boolean(
|
||||
error &&
|
||||
typeof error === 'object' &&
|
||||
error.status === null &&
|
||||
error.body === null &&
|
||||
error.json === null &&
|
||||
error.errors === null,
|
||||
);
|
||||
|
||||
if (!isCancellation) {
|
||||
console.warn('Ops UX widget refreshRuns failed', error);
|
||||
}
|
||||
}
|
||||
|
||||
const next = this.nextIntervalMs();
|
||||
if (next === null) {
|
||||
this.stop();
|
||||
return;
|
||||
}
|
||||
|
||||
this.schedule(next);
|
||||
},
|
||||
|
||||
schedule(delayMs) {
|
||||
this.stop();
|
||||
|
||||
const delay = Math.max(0, Number(delayMs ?? 0));
|
||||
|
||||
this.timer = setTimeout(() => {
|
||||
this.tick().catch(() => {});
|
||||
}, delay);
|
||||
},
|
||||
};
|
||||
};
|
||||
</script>
|
||||
|
||||
@ -29,6 +29,11 @@
|
||||
->name(ReconcileAdapterRunsJob::class)
|
||||
->withoutOverlapping();
|
||||
|
||||
Schedule::command('tenantpilot:operation-runs:reconcile')
|
||||
->everyFiveMinutes()
|
||||
->name('tenantpilot:operation-runs:reconcile')
|
||||
->withoutOverlapping();
|
||||
|
||||
Schedule::command('stored-reports:prune')
|
||||
->daily()
|
||||
->name('stored-reports:prune')
|
||||
|
||||
35
specs/159-baseline-snapshot-truth/checklists/requirements.md
Normal file
35
specs/159-baseline-snapshot-truth/checklists/requirements.md
Normal file
@ -0,0 +1,35 @@
|
||||
# Specification Quality Checklist: Artifact Truth & Downstream Consumption Guards for BaselineSnapshot
|
||||
|
||||
**Purpose**: Validate specification completeness and quality before proceeding to planning
|
||||
**Created**: 2026-03-23
|
||||
**Feature**: [spec.md](/Users/ahmeddarrazi/Documents/projects/TenantAtlas/specs/159-baseline-snapshot-truth/spec.md)
|
||||
|
||||
## Content Quality
|
||||
|
||||
- [x] No implementation details (languages, frameworks, APIs)
|
||||
- [x] Focused on user value and business needs
|
||||
- [x] Written for non-technical stakeholders
|
||||
- [x] All mandatory sections completed
|
||||
|
||||
## Requirement Completeness
|
||||
|
||||
- [x] No [NEEDS CLARIFICATION] markers remain
|
||||
- [x] Requirements are testable and unambiguous
|
||||
- [x] Success criteria are measurable
|
||||
- [x] Success criteria are technology-agnostic (no implementation details)
|
||||
- [x] All acceptance scenarios are defined
|
||||
- [x] Edge cases are identified
|
||||
- [x] Scope is clearly bounded
|
||||
- [x] Dependencies and assumptions identified
|
||||
|
||||
## Feature Readiness
|
||||
|
||||
- [x] All functional requirements have clear acceptance criteria
|
||||
- [x] User scenarios cover primary flows
|
||||
- [x] Feature meets measurable outcomes defined in Success Criteria
|
||||
- [x] No implementation details leak into specification
|
||||
|
||||
## Notes
|
||||
|
||||
- Validation pass completed after drafting the initial specification.
|
||||
- The spec keeps implementation choices open for the persistence strategy while making lifecycle, consumability, and downstream guard behavior explicit.
|
||||
262
specs/159-baseline-snapshot-truth/contracts/openapi.yaml
Normal file
262
specs/159-baseline-snapshot-truth/contracts/openapi.yaml
Normal file
@ -0,0 +1,262 @@
|
||||
openapi: 3.1.0
|
||||
info:
|
||||
title: BaselineSnapshot Artifact Truth Operator Contract
|
||||
version: 1.0.0
|
||||
summary: Logical operator-action contract for baseline snapshot lifecycle and compare guards
|
||||
description: |
|
||||
This contract captures the intended request/response semantics for the operator-facing
|
||||
capture, compare, and snapshot-truth flows in Spec 159. These are logical contracts for
|
||||
existing Filament/Livewire-backed actions and read models, not a commitment to public REST endpoints.
|
||||
servers:
|
||||
- url: https://tenantpilot.local
|
||||
tags:
|
||||
- name: BaselineProfiles
|
||||
- name: BaselineSnapshots
|
||||
- name: BaselineCompare
|
||||
paths:
|
||||
/workspaces/{workspaceId}/baseline-profiles/{profileId}/captures:
|
||||
post:
|
||||
tags: [BaselineProfiles]
|
||||
summary: Start a baseline capture
|
||||
description: Starts a baseline capture attempt. The produced snapshot begins in `building` and becomes consumable only if finalized `complete`.
|
||||
parameters:
|
||||
- $ref: '#/components/parameters/WorkspaceId'
|
||||
- $ref: '#/components/parameters/ProfileId'
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
required: [sourceTenantId]
|
||||
properties:
|
||||
sourceTenantId:
|
||||
type: integer
|
||||
responses:
|
||||
'202':
|
||||
description: Capture accepted and queued
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
required: [operationRunId, snapshot]
|
||||
properties:
|
||||
operationRunId:
|
||||
type: integer
|
||||
snapshot:
|
||||
$ref: '#/components/schemas/BaselineSnapshotTruth'
|
||||
'403':
|
||||
$ref: '#/components/responses/Forbidden'
|
||||
'404':
|
||||
$ref: '#/components/responses/NotFound'
|
||||
|
||||
/workspaces/{workspaceId}/baseline-profiles/{profileId}/effective-snapshot:
|
||||
get:
|
||||
tags: [BaselineProfiles]
|
||||
summary: Resolve effective current baseline snapshot
|
||||
description: Returns the latest complete snapshot that is valid as current baseline truth for the profile.
|
||||
parameters:
|
||||
- $ref: '#/components/parameters/WorkspaceId'
|
||||
- $ref: '#/components/parameters/ProfileId'
|
||||
responses:
|
||||
'200':
|
||||
description: Effective current baseline truth resolved
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
required: [profileId, effectiveSnapshot]
|
||||
properties:
|
||||
profileId:
|
||||
type: integer
|
||||
effectiveSnapshot:
|
||||
oneOf:
|
||||
- $ref: '#/components/schemas/BaselineSnapshotTruth'
|
||||
- type: 'null'
|
||||
latestAttemptedSnapshot:
|
||||
oneOf:
|
||||
- $ref: '#/components/schemas/BaselineSnapshotTruth'
|
||||
- type: 'null'
|
||||
compareAvailability:
|
||||
$ref: '#/components/schemas/CompareAvailability'
|
||||
'403':
|
||||
$ref: '#/components/responses/Forbidden'
|
||||
'404':
|
||||
$ref: '#/components/responses/NotFound'
|
||||
|
||||
/workspaces/{workspaceId}/baseline-snapshots/{snapshotId}:
|
||||
get:
|
||||
tags: [BaselineSnapshots]
|
||||
summary: Read baseline snapshot truth
|
||||
parameters:
|
||||
- $ref: '#/components/parameters/WorkspaceId'
|
||||
- $ref: '#/components/parameters/SnapshotId'
|
||||
responses:
|
||||
'200':
|
||||
description: Snapshot truth returned
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/BaselineSnapshotTruth'
|
||||
'403':
|
||||
$ref: '#/components/responses/Forbidden'
|
||||
'404':
|
||||
$ref: '#/components/responses/NotFound'
|
||||
|
||||
/tenants/{tenantId}/baseline-compares:
|
||||
post:
|
||||
tags: [BaselineCompare]
|
||||
summary: Start baseline compare
|
||||
description: Starts compare only when the resolved or explicitly selected snapshot is consumable.
|
||||
parameters:
|
||||
- $ref: '#/components/parameters/TenantId'
|
||||
requestBody:
|
||||
required: false
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
baselineSnapshotId:
|
||||
type: integer
|
||||
nullable: true
|
||||
responses:
|
||||
'202':
|
||||
description: Compare accepted and queued
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
required: [operationRunId, snapshot]
|
||||
properties:
|
||||
operationRunId:
|
||||
type: integer
|
||||
snapshot:
|
||||
$ref: '#/components/schemas/BaselineSnapshotTruth'
|
||||
'409':
|
||||
description: Compare blocked because no consumable snapshot is available
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/CompareAvailability'
|
||||
'403':
|
||||
$ref: '#/components/responses/Forbidden'
|
||||
'404':
|
||||
$ref: '#/components/responses/NotFound'
|
||||
|
||||
components:
|
||||
parameters:
|
||||
WorkspaceId:
|
||||
name: workspaceId
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: integer
|
||||
ProfileId:
|
||||
name: profileId
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: integer
|
||||
SnapshotId:
|
||||
name: snapshotId
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: integer
|
||||
TenantId:
|
||||
name: tenantId
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: integer
|
||||
|
||||
responses:
|
||||
Forbidden:
|
||||
description: Member lacks the required capability
|
||||
NotFound:
|
||||
description: Workspace or tenant scope is not entitled, or the record is not visible in that scope
|
||||
|
||||
schemas:
|
||||
BaselineSnapshotTruth:
|
||||
type: object
|
||||
required:
|
||||
- id
|
||||
- workspaceId
|
||||
- baselineProfileId
|
||||
- lifecycleState
|
||||
- consumable
|
||||
- capturedAt
|
||||
properties:
|
||||
id:
|
||||
type: integer
|
||||
workspaceId:
|
||||
type: integer
|
||||
baselineProfileId:
|
||||
type: integer
|
||||
lifecycleState:
|
||||
type: string
|
||||
enum: [building, complete, incomplete, superseded]
|
||||
consumable:
|
||||
type: boolean
|
||||
capturedAt:
|
||||
type: string
|
||||
format: date-time
|
||||
completedAt:
|
||||
type: string
|
||||
format: date-time
|
||||
nullable: true
|
||||
failedAt:
|
||||
type: string
|
||||
format: date-time
|
||||
nullable: true
|
||||
supersededAt:
|
||||
type: string
|
||||
format: date-time
|
||||
nullable: true
|
||||
completionMeta:
|
||||
type: object
|
||||
additionalProperties: true
|
||||
nullable: true
|
||||
usabilityLabel:
|
||||
type: string
|
||||
examples: [Complete, Incomplete, Building, Superseded, Not usable for compare]
|
||||
reasonCode:
|
||||
type: string
|
||||
nullable: true
|
||||
reasonMessage:
|
||||
type: string
|
||||
nullable: true
|
||||
|
||||
CompareAvailability:
|
||||
type: object
|
||||
required:
|
||||
- allowed
|
||||
properties:
|
||||
allowed:
|
||||
type: boolean
|
||||
effectiveSnapshotId:
|
||||
type: integer
|
||||
nullable: true
|
||||
latestAttemptedSnapshotId:
|
||||
type: integer
|
||||
nullable: true
|
||||
reasonCode:
|
||||
type: string
|
||||
nullable: true
|
||||
enum:
|
||||
- no_consumable_snapshot
|
||||
- snapshot_building
|
||||
- snapshot_incomplete
|
||||
- snapshot_superseded
|
||||
- invalid_snapshot_selection
|
||||
reasonMessage:
|
||||
type: string
|
||||
nullable: true
|
||||
nextAction:
|
||||
type: string
|
||||
nullable: true
|
||||
examples:
|
||||
- Wait for capture completion
|
||||
- Re-run baseline capture
|
||||
- Review failed capture
|
||||
174
specs/159-baseline-snapshot-truth/data-model.md
Normal file
174
specs/159-baseline-snapshot-truth/data-model.md
Normal file
@ -0,0 +1,174 @@
|
||||
# Data Model: BaselineSnapshot Artifact Truth
|
||||
|
||||
## Entity: BaselineSnapshot
|
||||
|
||||
Purpose:
|
||||
- Workspace-owned baseline artifact produced by `baseline.capture` and consumed by `baseline.compare` only when explicitly complete.
|
||||
|
||||
Existing fields used by this feature:
|
||||
- `id`
|
||||
- `workspace_id`
|
||||
- `baseline_profile_id`
|
||||
- `snapshot_identity_hash`
|
||||
- `captured_at`
|
||||
- `summary_jsonb`
|
||||
- `created_at`
|
||||
- `updated_at`
|
||||
|
||||
New or revised fields for V1:
|
||||
- `lifecycle_state`
|
||||
- Type: enum/string
|
||||
- Allowed values: `building`, `complete`, `incomplete`
|
||||
- Default: `building` for new capture attempts
|
||||
- `completed_at`
|
||||
- Type: nullable timestamp
|
||||
- Meaning: when the artifact was proven complete and became consumable
|
||||
- `failed_at`
|
||||
- Type: nullable timestamp
|
||||
- Meaning: when the artifact was finalized incomplete
|
||||
- `completion_meta_jsonb`
|
||||
- Type: nullable JSONB
|
||||
- Purpose: minimal integrity proof and failure diagnostics
|
||||
- Suggested contents:
|
||||
- `expected_items`
|
||||
- `persisted_items`
|
||||
- `finalization_reason_code`
|
||||
- `was_empty_capture`
|
||||
- `producer_run_id`
|
||||
|
||||
Relationships:
|
||||
- Belongs to one BaselineProfile
|
||||
- Has many BaselineSnapshotItems
|
||||
- Is optionally referenced by one BaselineProfile as `active_snapshot_id`
|
||||
- Is optionally referenced by many compare runs via `operation_runs.context.baseline_snapshot_id`
|
||||
|
||||
Validation / invariants:
|
||||
- Only `complete` snapshots are consumable.
|
||||
- `completed_at` must be non-null only when `lifecycle_state = complete`.
|
||||
- `failed_at` must be non-null only when `lifecycle_state = incomplete`.
|
||||
- A snapshot may become `complete` only after completion proof passes.
|
||||
- A snapshot may never transition from `incomplete` back to `complete` in V1.
|
||||
|
||||
State transitions:
|
||||
- `building -> complete`
|
||||
- Trigger: capture assembly completes successfully and finalization proof passes
|
||||
- `building -> incomplete`
|
||||
- Trigger: capture fails or terminates after snapshot creation but before successful completion
|
||||
- Forbidden in V1:
|
||||
- `complete -> building`
|
||||
- `incomplete -> complete`
|
||||
|
||||
Derived historical presentation:
|
||||
- A `complete` snapshot is rendered as `superseded` or historical when a newer `complete` snapshot for the same profile becomes the effective current truth.
|
||||
- This is a derived presentation concept, not a persisted lifecycle transition, so immutable snapshot rows keep their recorded terminal lifecycle state.
|
||||
|
||||
## Entity: BaselineSnapshotItem
|
||||
|
||||
Purpose:
|
||||
- Immutable or deterministic per-subject record within a BaselineSnapshot.
|
||||
|
||||
Existing fields:
|
||||
- `id`
|
||||
- `baseline_snapshot_id`
|
||||
- `subject_type`
|
||||
- `subject_external_id`
|
||||
- `policy_type`
|
||||
- `baseline_hash`
|
||||
- `meta_jsonb`
|
||||
- `created_at`
|
||||
- `updated_at`
|
||||
|
||||
Relationships:
|
||||
- Belongs to one BaselineSnapshot
|
||||
|
||||
Validation / invariants:
|
||||
- Unique per snapshot on `(baseline_snapshot_id, subject_type, subject_external_id)`.
|
||||
- Item persistence must remain deterministic across retries/reruns.
|
||||
- Item presence alone does not make the parent snapshot consumable.
|
||||
|
||||
## Entity: BaselineProfile
|
||||
|
||||
Purpose:
|
||||
- Workspace-owned baseline definition that exposes the effective current baseline truth to operators and compare consumers.
|
||||
|
||||
Relevant existing fields:
|
||||
- `id`
|
||||
- `workspace_id`
|
||||
- `status`
|
||||
- `capture_mode`
|
||||
- `scope_jsonb`
|
||||
- `active_snapshot_id`
|
||||
|
||||
Revised semantics:
|
||||
- `active_snapshot_id` points only to a `complete` snapshot.
|
||||
- Effective current baseline truth is the latest complete snapshot for the profile.
|
||||
- The latest attempted snapshot may differ from the effective current snapshot when the latest attempt is `building` or `incomplete`.
|
||||
|
||||
Validation / invariants:
|
||||
- A profile must never advance `active_snapshot_id` to a non-consumable snapshot.
|
||||
- When no complete snapshot exists, `active_snapshot_id` may remain null.
|
||||
|
||||
## Entity: OperationRun (capture/compare interaction only)
|
||||
|
||||
Purpose:
|
||||
- Execution/audit record for `baseline.capture` and `baseline.compare`.
|
||||
|
||||
Relevant fields:
|
||||
- `id`
|
||||
- `workspace_id`
|
||||
- `tenant_id`
|
||||
- `type`
|
||||
- `status`
|
||||
- `outcome`
|
||||
- `summary_counts`
|
||||
- `context`
|
||||
- `failure_summary`
|
||||
|
||||
Feature constraints:
|
||||
- Run truth remains separate from snapshot truth.
|
||||
- `summary_counts` remain numeric-only execution metrics.
|
||||
- Snapshot lifecycle and consumability do not live in `summary_counts`.
|
||||
- Compare execution must revalidate snapshot consumability even if `context.baseline_snapshot_id` is present.
|
||||
|
||||
## Derived Concepts
|
||||
|
||||
### Consumable Snapshot
|
||||
|
||||
Definition:
|
||||
- A BaselineSnapshot whose `lifecycle_state = complete`.
|
||||
|
||||
Authoritative rule:
|
||||
- Exposed through one shared helper/service and mirrored by `BaselineSnapshot::isConsumable()`.
|
||||
|
||||
### Effective Current Snapshot
|
||||
|
||||
Definition:
|
||||
- The snapshot a profile should use as current baseline truth.
|
||||
|
||||
Resolution rule:
|
||||
- Latest `complete` snapshot for the profile.
|
||||
- Prefer `active_snapshot_id` as a cached pointer when valid.
|
||||
- Never resolve to `building`, `incomplete`, or a historically superseded complete snapshot as current truth.
|
||||
|
||||
Explicit compare override rule:
|
||||
- In V1, a historically superseded complete snapshot is viewable but is not a valid explicit compare input once a newer complete snapshot is the effective current truth.
|
||||
|
||||
### Legacy Snapshot Proof Classification
|
||||
|
||||
Definition:
|
||||
- Backfill-time decision of whether an existing pre-feature snapshot can be trusted as complete.
|
||||
|
||||
Proof sources:
|
||||
- persisted item count
|
||||
- `summary_jsonb.total_items` or equivalent expected-item metadata
|
||||
- producing run context/result proving successful finalization, if available
|
||||
- explicit zero-item completion proof for empty snapshots
|
||||
|
||||
Fallback:
|
||||
- If proof is ambiguous, classify as `incomplete`.
|
||||
|
||||
Decision order:
|
||||
1. Count proof with exact expected-item to persisted-item match.
|
||||
2. Producing-run success proof with expected-item to persisted-item reconciliation.
|
||||
3. Proven empty capture proof where expected items and persisted items are both zero.
|
||||
4. Otherwise `incomplete`.
|
||||
486
specs/159-baseline-snapshot-truth/plan.md
Normal file
486
specs/159-baseline-snapshot-truth/plan.md
Normal file
@ -0,0 +1,486 @@
|
||||
# Implementation Plan: 159 — BaselineSnapshot Artifact Truth & Downstream Consumption Guards
|
||||
|
||||
**Branch**: `159-baseline-snapshot-truth` | **Date**: 2026-03-23 | **Spec**: `specs/159-baseline-snapshot-truth/spec.md`
|
||||
**Input**: Feature specification from `specs/159-baseline-snapshot-truth/spec.md`
|
||||
|
||||
## Summary
|
||||
|
||||
Make BaselineSnapshot explicitly authoritative for its own usability by (1) adding a first-class lifecycle state and completion metadata to the artifact, (2) finalizing capture with `building -> complete|incomplete` semantics while deriving historical `superseded` presentation status without mutating immutable snapshot records, (3) centralizing consumability and effective-current-snapshot resolution so compare and profile truth only use complete snapshots, and (4) reusing the existing artifact-truth, badge, and Filament surface patterns so operators can distinguish run outcome from artifact usability.
|
||||
|
||||
## Technical Context
|
||||
|
||||
**Language/Version**: PHP 8.4
|
||||
**Primary Dependencies**: Laravel 12, Filament v5, Livewire v4
|
||||
**Storage**: PostgreSQL (via Sail)
|
||||
**Testing**: Pest v4 (PHPUnit 12)
|
||||
**Target Platform**: Docker (Laravel Sail), deployed containerized on Dokploy
|
||||
**Project Type**: Web application (Laravel)
|
||||
**Performance Goals**: Preserve current chunked snapshot-item persistence characteristics and reject non-consumable snapshots before expensive compare/drift work
|
||||
**Constraints**:
|
||||
- `OperationRun` lifecycle and outcomes remain service-owned via `OperationRunService`
|
||||
- Capture and compare keep the existing Ops-UX 3-surface feedback contract
|
||||
- Workspace/tenant isolation and 404/403 semantics remain unchanged
|
||||
- No new Microsoft Graph contracts or synchronous render-time remote calls
|
||||
- Legacy snapshots must be backfilled conservatively; ambiguous history must fail safe
|
||||
**Scale/Scope**: Workspace-owned baseline profiles may accumulate many snapshots and each snapshot may hold many items via chunked inserts; this feature touches capture jobs, compare services/jobs, artifact-truth presentation, Filament admin/tenant surfaces, migrations, and focused baseline test suites
|
||||
|
||||
## Constitution Check
|
||||
|
||||
*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.*
|
||||
|
||||
- Inventory-first: PASS — Inventory remains the last-observed truth. BaselineSnapshot remains an explicit immutable capture artifact whose lifecycle becomes explicit without rewriting prior terminal states.
|
||||
- Read/write separation: PASS — this feature mutates TenantPilot-owned baseline records only; capture/compare start surfaces already require confirmation and auditability remains intact.
|
||||
- Graph contract path: PASS — no new Graph endpoints or contract-registry changes are introduced.
|
||||
- Deterministic capabilities: PASS — capability checks remain in existing registries/resolvers; no new raw capability strings.
|
||||
- Workspace + tenant isolation: PASS — BaselineProfile/BaselineSnapshot remain workspace-owned, compare execution remains tenant-owned, and current cross-plane rules remain unchanged.
|
||||
- Canonical-view scope: PASS — Monitoring run detail stays canonical-view scoped, defaults to active tenant context when present, and requires workspace plus referenced-tenant entitlement before revealing tenant-owned baseline runs.
|
||||
- Run observability (`OperationRun`): PASS — capture/compare continue using canonical queued jobs and `OperationRun` records.
|
||||
- Ops-UX 3-surface feedback: PASS — no additional toast/notification surfaces are introduced.
|
||||
- Ops-UX lifecycle: PASS — run transitions remain through `OperationRunService`; artifact lifecycle is added on BaselineSnapshot, not as a substitute for run lifecycle.
|
||||
- Ops-UX summary counts: PASS — artifact completeness moves to BaselineSnapshot fields; `summary_counts` remain numeric-only execution metrics.
|
||||
- Ops-UX guards: PASS — focused regression tests will cover that capture finalization and compare blocking do not regress service-owned run transitions.
|
||||
- Badge semantics (BADGE-001): PASS — lifecycle/usability presentation will extend the centralized badge/artifact-truth system instead of creating ad-hoc mappings.
|
||||
- UI naming (UI-NAMING-001): PASS — operator wording stays aligned to `Capture baseline`, `Compare now`, `Building`, `Complete`, `Incomplete`, `Superseded`, and `Not usable for compare`.
|
||||
- Operator surfaces (OPSURF-001): PASS — run outcome, snapshot lifecycle, snapshot usability, and compare readiness will be shown as distinct dimensions where relevant.
|
||||
- Filament UI Action Surface Contract: PASS — no new resource topology is introduced; existing immutable-snapshot exemptions remain valid.
|
||||
- Filament UX-001 layout: PASS — this feature changes labels, badges, and enablement logic on existing pages/resources rather than introducing new form layouts.
|
||||
- UI-STD-001: PASS — modified Baseline Profiles and Baseline Snapshots list surfaces will be reviewed against `docs/product/standards/list-surface-review-checklist.md`.
|
||||
|
||||
## Project Structure
|
||||
|
||||
### Documentation (this feature)
|
||||
|
||||
```text
|
||||
specs/159-baseline-snapshot-truth/
|
||||
├── plan.md
|
||||
├── research.md
|
||||
├── data-model.md
|
||||
├── quickstart.md
|
||||
├── contracts/
|
||||
│ └── openapi.yaml
|
||||
└── checklists/
|
||||
└── requirements.md
|
||||
```
|
||||
|
||||
### Source Code (repository root)
|
||||
|
||||
```text
|
||||
app/
|
||||
├── Filament/
|
||||
│ ├── Pages/
|
||||
│ │ └── BaselineCompareLanding.php
|
||||
│ └── Resources/
|
||||
│ ├── BaselineProfileResource.php
|
||||
│ ├── BaselineSnapshotResource.php
|
||||
│ ├── BaselineProfileResource/Pages/ViewBaselineProfile.php
|
||||
│ └── BaselineSnapshotResource/Pages/ViewBaselineSnapshot.php
|
||||
├── Jobs/
|
||||
│ ├── CaptureBaselineSnapshotJob.php
|
||||
│ └── CompareBaselineToTenantJob.php
|
||||
├── Models/
|
||||
│ ├── BaselineProfile.php
|
||||
│ ├── BaselineSnapshot.php
|
||||
│ ├── BaselineSnapshotItem.php
|
||||
│ └── OperationRun.php
|
||||
├── Services/
|
||||
│ └── Baselines/
|
||||
│ ├── BaselineCaptureService.php
|
||||
│ ├── BaselineCompareService.php
|
||||
│ ├── BaselineSnapshotIdentity.php
|
||||
│ └── SnapshotRendering/
|
||||
├── Support/
|
||||
│ ├── Baselines/
|
||||
│ ├── Badges/
|
||||
│ └── Ui/GovernanceArtifactTruth/
|
||||
database/
|
||||
├── migrations/
|
||||
└── factories/
|
||||
tests/
|
||||
├── Feature/Baselines/
|
||||
├── Feature/Filament/
|
||||
└── Unit/Badges/
|
||||
```
|
||||
|
||||
**Structure Decision**: Web application (Laravel 12). All work stays within the existing baseline jobs/services/models, centralized support layers for badges and artifact truth, the existing Filament resources/pages, one schema migration, and focused Pest coverage.
|
||||
|
||||
## Complexity Tracking
|
||||
|
||||
No constitution violations are required for this feature.
|
||||
|
||||
## Phase 0 — Outline & Research (DONE)
|
||||
|
||||
Outputs:
|
||||
- `specs/159-baseline-snapshot-truth/research.md`
|
||||
|
||||
Key decisions captured:
|
||||
- Use a single BaselineSnapshot lifecycle enum in V1 (`building`, `complete`, `incomplete`) and derive historical `superseded` presentation status from effective-truth resolution instead of mutating immutable snapshots.
|
||||
- Keep the existing chunked snapshot-item persistence strategy, but add explicit finalization and completion proof rather than attempting a large transactional rewrite.
|
||||
- Treat `baseline_profiles.active_snapshot_id` as a cached pointer to the latest complete snapshot only; effective truth resolves from complete snapshots, not from the latest attempted snapshot.
|
||||
- Backfill legacy snapshots conservatively using persisted item counts, summary metadata, and producing-run context where available; ambiguous snapshots default to non-consumable.
|
||||
- Reuse the existing `ArtifactTruthPresenter` and badge domains rather than inventing a parallel UI truth system.
|
||||
|
||||
## Phase 1 — Design & Contracts (DONE)
|
||||
|
||||
Outputs:
|
||||
- `specs/159-baseline-snapshot-truth/data-model.md`
|
||||
- `specs/159-baseline-snapshot-truth/contracts/openapi.yaml`
|
||||
- `specs/159-baseline-snapshot-truth/quickstart.md`
|
||||
|
||||
Design highlights:
|
||||
- BaselineSnapshot becomes the authoritative artifact-truth model with lifecycle state, completion timestamps, failure timestamps, and completion metadata sufficient to justify a `complete` transition.
|
||||
- `active_snapshot_id` remains the operator-facing current-snapshot pointer but only for complete snapshots; compare and UI consumers must use a shared effective-snapshot resolution helper.
|
||||
- Historical `superseded` status is derived in presentation and truth resolvers from the existence of a newer effective complete snapshot, not persisted as a mutable lifecycle transition.
|
||||
- Compare blocking happens both before enqueue and at job execution time so an explicit override or stale context cannot bypass the consumability rules.
|
||||
- Explicit compare overrides to older complete snapshots that are no longer the effective current truth are blocked in V1 and return a dedicated operator-safe reason.
|
||||
- Snapshot lifecycle display reuses centralized badge and artifact-truth presentation infrastructure so run outcome and artifact usability remain separate but consistent across list, detail, compare, and monitoring surfaces.
|
||||
|
||||
## Phase 1 — Agent Context Update (REQUIRED)
|
||||
|
||||
Run:
|
||||
- `.specify/scripts/bash/update-agent-context.sh copilot`
|
||||
|
||||
## Constitution Check — Post-Design Re-evaluation
|
||||
|
||||
- PASS — Design keeps snapshot truth local to BaselineSnapshot and does not expand into a generic artifact framework.
|
||||
- PASS — No new Graph calls or contract-registry changes.
|
||||
- PASS — Existing run observability patterns remain intact and artifact state does not bypass `OperationRunService`.
|
||||
- PASS — Authorization, destructive confirmation, and operator-surface contracts remain within existing patterns.
|
||||
- PASS — Badge and artifact-truth semantics remain centralized.
|
||||
|
||||
## Phase 2 — Implementation Plan
|
||||
|
||||
### Step 1 — Schema and domain lifecycle semantics
|
||||
|
||||
Goal: implement FR-001 through FR-006, FR-015, and FR-021.
|
||||
|
||||
Changes:
|
||||
- Add BaselineSnapshot lifecycle/completion columns in a new migration:
|
||||
- lifecycle state
|
||||
- `completed_at`
|
||||
- `failed_at`
|
||||
- completion metadata JSON for integrity proof and failure reason
|
||||
- Add BaselineSnapshot status enum/support type and model helpers/scopes:
|
||||
- lifecycle label helpers
|
||||
- `isConsumable()`
|
||||
- transition helpers or a dedicated domain service
|
||||
- scopes for complete/current-eligible snapshots
|
||||
- Add centralized effective-snapshot and consumability resolution in the baselines domain layer.
|
||||
|
||||
Tests:
|
||||
- Add unit/domain tests for lifecycle-to-consumability rules.
|
||||
- Add badge/artifact-truth tests for the new snapshot lifecycle states.
|
||||
|
||||
### Step 2 — Capture finalization and current-snapshot rollover
|
||||
|
||||
Goal: implement FR-002 through FR-005, FR-009 through FR-014, and the primary P1 regression path.
|
||||
|
||||
Changes:
|
||||
- Update capture flow so snapshot creation begins in `building` before item persistence.
|
||||
- Keep the existing deterministic deduplication/chunked insert strategy, but add explicit finalization after item persistence completes.
|
||||
- Only mark the snapshot `complete` after the completion proof passes:
|
||||
- persisted item count matches expected deduplicated item count
|
||||
- no unresolved assembly error occurred
|
||||
- finalization step completed successfully
|
||||
- Mark the snapshot `incomplete` when capture fails after row creation, including partial-item scenarios.
|
||||
- Persist completion-proof metadata and lifecycle context needed for later operator truth and legacy backfill decisions, including producing-run linkage and best-available incomplete reason data.
|
||||
- Advance `active_snapshot_id` only when the new snapshot is complete.
|
||||
- Derive the previously effective complete snapshot as historical or superseded in truth presentation only after the new snapshot becomes complete.
|
||||
|
||||
Tests:
|
||||
- Update `tests/Feature/Baselines/BaselineCaptureTest.php` for `building -> complete` semantics.
|
||||
- Add a partial-write failure regression test where a snapshot row exists but never becomes consumable.
|
||||
- Add retry/idempotency tests ensuring duplicate logical subjects do not produce a falsely complete snapshot.
|
||||
|
||||
### Step 3 — Effective snapshot resolution and compare guards
|
||||
|
||||
Goal: implement FR-007 through FR-010 and FR-018.
|
||||
|
||||
Changes:
|
||||
- Update compare start service to resolve the effective snapshot from the latest complete snapshot when no explicit override is supplied.
|
||||
- Validate explicit snapshot overrides against the same consumability rules.
|
||||
- Re-check snapshot consumability inside `CompareBaselineToTenantJob` before any normal compare work starts.
|
||||
- Introduce clear reason codes/messages for:
|
||||
- no consumable snapshot available
|
||||
- snapshot still building
|
||||
- snapshot incomplete
|
||||
- snapshot no longer effective for current truth because a newer complete snapshot is active
|
||||
- Ensure compare stats and related widgets derive availability from effective consumable truth, not raw snapshot existence.
|
||||
|
||||
Tests:
|
||||
- Update `tests/Feature/Baselines/BaselineComparePreconditionsTest.php` for blocked building/incomplete snapshots.
|
||||
- Add tests covering fallback to the last complete snapshot when the latest attempt is incomplete.
|
||||
- Add execution-path tests proving the compare job refuses non-consumable snapshots even if queued context contains a snapshot id.
|
||||
- Add regression coverage proving modified compare and capture entry points preserve confirmation and 404 or 403 authorization behavior while lifecycle messaging changes.
|
||||
|
||||
### Step 4 — UI semantic corrections and centralized presentation
|
||||
|
||||
Goal: implement FR-017, FR-019, and FR-020.
|
||||
|
||||
Changes:
|
||||
- Update `ArtifactTruthPresenter` and the relevant badge domain registrations for BaselineSnapshot lifecycle truth.
|
||||
- Update Baseline Profile detail and list surfaces to distinguish:
|
||||
- latest complete snapshot
|
||||
- latest attempted snapshot when different
|
||||
- compare availability and current baseline availability
|
||||
- Update Baseline Snapshot list/detail to show explicit lifecycle/usability semantics.
|
||||
- Update Baseline Compare landing and related widgets so compare unavailability explains the operator next step.
|
||||
- Update Monitoring/Operation Run detail presentation for baseline capture/compare so run outcome and produced snapshot lifecycle are clearly separate.
|
||||
|
||||
Tests:
|
||||
- Update or add Filament page/resource tests for operator-safe messaging and state display across baseline profile, baseline snapshot, and compare landing surfaces.
|
||||
- Add dedicated Monitoring run-detail truth-surface coverage so run outcome and artifact truth remain separately verifiable.
|
||||
- Extend artifact-truth presenter tests for historical/superseded presentation and incomplete snapshot rendering.
|
||||
|
||||
### Step 5 — Conservative historical backfill
|
||||
|
||||
Goal: implement FR-016 without overstating legacy trust.
|
||||
|
||||
Changes:
|
||||
- Backfill existing snapshots as `complete` only when the first matching proof rule in the spec decision table succeeds:
|
||||
- count proof: `summary_jsonb.total_items` or equivalent expected-item metadata matches persisted item count
|
||||
- producing-run success proof: producing-run outcome proves successful finalization and expected-item and persisted-item counts reconcile
|
||||
- proven empty capture proof: producing-run outcome proves successful finalization for the recorded scope and zero items were expected and persisted
|
||||
- Classify contradictory, partial, or null evidence as `incomplete` with no tie-breaker that can elevate it to `complete`.
|
||||
- Leave operator guidance pointing to recapture when legacy truth is unavailable.
|
||||
|
||||
Tests:
|
||||
- Add migration/backfill tests for count-proof complete, proven-empty complete, and ambiguous legacy rows.
|
||||
|
||||
### Step 6 — Focused regression and guard coverage
|
||||
|
||||
Goal: satisfy the spec test acceptance while keeping CI scope targeted.
|
||||
|
||||
Changes:
|
||||
- Update the most relevant existing baseline suites instead of creating broad duplicate coverage.
|
||||
- Add new focused tests in:
|
||||
- `tests/Feature/Baselines/`
|
||||
- `tests/Feature/Filament/`
|
||||
- `tests/Unit/Badges/`
|
||||
- Add focused lifecycle-auditability coverage proving producing-run linkage and best-available incomplete reasons remain queryable after complete and incomplete transitions, and that historical/superseded presentation is derived from effective-truth resolution.
|
||||
- Add a dedicated Ops-UX guard proving baseline capture/compare code does not bypass `OperationRunService` for status/outcome transitions and does not emit non-canonical terminal operation notifications.
|
||||
- Preserve existing guard tests for Filament action surfaces and Ops-UX patterns.
|
||||
|
||||
Tests:
|
||||
- Minimum focused run set:
|
||||
- baseline capture lifecycle tests
|
||||
- compare preconditions/guard tests
|
||||
- compare stats tests
|
||||
- artifact-truth/badge tests
|
||||
- affected Filament surface tests
|
||||
snapshot:
|
||||
$ref: '#/components/schemas/BaselineSnapshotTruth'
|
||||
'403':
|
||||
$ref: '#/components/responses/Forbidden'
|
||||
'404':
|
||||
$ref: '#/components/responses/NotFound'
|
||||
|
||||
/workspaces/{workspaceId}/baseline-profiles/{profileId}/effective-snapshot:
|
||||
get:
|
||||
tags: [BaselineProfiles]
|
||||
summary: Resolve effective current baseline snapshot
|
||||
description: Returns the latest complete snapshot that is valid as current baseline truth for the profile.
|
||||
parameters:
|
||||
- $ref: '#/components/parameters/WorkspaceId'
|
||||
- $ref: '#/components/parameters/ProfileId'
|
||||
responses:
|
||||
'200':
|
||||
description: Effective current baseline truth resolved
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
required: [profileId, effectiveSnapshot]
|
||||
properties:
|
||||
profileId:
|
||||
type: integer
|
||||
effectiveSnapshot:
|
||||
oneOf:
|
||||
- $ref: '#/components/schemas/BaselineSnapshotTruth'
|
||||
- type: 'null'
|
||||
latestAttemptedSnapshot:
|
||||
oneOf:
|
||||
- $ref: '#/components/schemas/BaselineSnapshotTruth'
|
||||
- type: 'null'
|
||||
compareAvailability:
|
||||
$ref: '#/components/schemas/CompareAvailability'
|
||||
'403':
|
||||
$ref: '#/components/responses/Forbidden'
|
||||
'404':
|
||||
$ref: '#/components/responses/NotFound'
|
||||
|
||||
/workspaces/{workspaceId}/baseline-snapshots/{snapshotId}:
|
||||
get:
|
||||
tags: [BaselineSnapshots]
|
||||
summary: Read baseline snapshot truth
|
||||
parameters:
|
||||
- $ref: '#/components/parameters/WorkspaceId'
|
||||
- $ref: '#/components/parameters/SnapshotId'
|
||||
responses:
|
||||
'200':
|
||||
description: Snapshot truth returned
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/BaselineSnapshotTruth'
|
||||
'403':
|
||||
$ref: '#/components/responses/Forbidden'
|
||||
'404':
|
||||
$ref: '#/components/responses/NotFound'
|
||||
|
||||
/tenants/{tenantId}/baseline-compares:
|
||||
post:
|
||||
tags: [BaselineCompare]
|
||||
summary: Start baseline compare
|
||||
description: Starts compare only when the resolved or explicitly selected snapshot is consumable.
|
||||
parameters:
|
||||
- $ref: '#/components/parameters/TenantId'
|
||||
requestBody:
|
||||
required: false
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
baselineSnapshotId:
|
||||
type: integer
|
||||
nullable: true
|
||||
responses:
|
||||
'202':
|
||||
description: Compare accepted and queued
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
required: [operationRunId, snapshot]
|
||||
properties:
|
||||
operationRunId:
|
||||
type: integer
|
||||
snapshot:
|
||||
$ref: '#/components/schemas/BaselineSnapshotTruth'
|
||||
'409':
|
||||
description: Compare blocked because no consumable snapshot is available
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/CompareAvailability'
|
||||
'403':
|
||||
$ref: '#/components/responses/Forbidden'
|
||||
'404':
|
||||
$ref: '#/components/responses/NotFound'
|
||||
|
||||
components:
|
||||
parameters:
|
||||
WorkspaceId:
|
||||
name: workspaceId
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: integer
|
||||
ProfileId:
|
||||
name: profileId
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: integer
|
||||
SnapshotId:
|
||||
name: snapshotId
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: integer
|
||||
TenantId:
|
||||
name: tenantId
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: integer
|
||||
|
||||
responses:
|
||||
Forbidden:
|
||||
description: Member lacks the required capability
|
||||
NotFound:
|
||||
description: Workspace or tenant scope is not entitled, or the record is not visible in that scope
|
||||
|
||||
schemas:
|
||||
BaselineSnapshotTruth:
|
||||
type: object
|
||||
required:
|
||||
- id
|
||||
- workspaceId
|
||||
- baselineProfileId
|
||||
- lifecycleState
|
||||
- consumable
|
||||
- capturedAt
|
||||
properties:
|
||||
id:
|
||||
type: integer
|
||||
workspaceId:
|
||||
type: integer
|
||||
baselineProfileId:
|
||||
type: integer
|
||||
lifecycleState:
|
||||
type: string
|
||||
enum: [building, complete, incomplete, superseded]
|
||||
consumable:
|
||||
type: boolean
|
||||
capturedAt:
|
||||
type: string
|
||||
format: date-time
|
||||
completedAt:
|
||||
type: string
|
||||
format: date-time
|
||||
nullable: true
|
||||
failedAt:
|
||||
type: string
|
||||
format: date-time
|
||||
nullable: true
|
||||
supersededAt:
|
||||
type: string
|
||||
format: date-time
|
||||
nullable: true
|
||||
completionMeta:
|
||||
type: object
|
||||
additionalProperties: true
|
||||
nullable: true
|
||||
usabilityLabel:
|
||||
type: string
|
||||
examples: [Complete, Incomplete, Building, Superseded, Not usable for compare]
|
||||
reasonCode:
|
||||
type: string
|
||||
nullable: true
|
||||
reasonMessage:
|
||||
type: string
|
||||
nullable: true
|
||||
|
||||
CompareAvailability:
|
||||
type: object
|
||||
required:
|
||||
- allowed
|
||||
properties:
|
||||
allowed:
|
||||
type: boolean
|
||||
effectiveSnapshotId:
|
||||
type: integer
|
||||
nullable: true
|
||||
latestAttemptedSnapshotId:
|
||||
type: integer
|
||||
nullable: true
|
||||
reasonCode:
|
||||
type: string
|
||||
nullable: true
|
||||
enum:
|
||||
- no_consumable_snapshot
|
||||
- snapshot_building
|
||||
- snapshot_incomplete
|
||||
- snapshot_superseded
|
||||
- invalid_snapshot_selection
|
||||
reasonMessage:
|
||||
type: string
|
||||
nullable: true
|
||||
nextAction:
|
||||
type: string
|
||||
nullable: true
|
||||
examples:
|
||||
- Wait for capture completion
|
||||
- Re-run baseline capture
|
||||
- Review failed capture
|
||||
54
specs/159-baseline-snapshot-truth/quickstart.md
Normal file
54
specs/159-baseline-snapshot-truth/quickstart.md
Normal file
@ -0,0 +1,54 @@
|
||||
# Quickstart: BaselineSnapshot Artifact Truth
|
||||
|
||||
## Goal
|
||||
|
||||
Implement and verify explicit BaselineSnapshot lifecycle/completeness semantics so baseline compare and profile truth consume only complete snapshots.
|
||||
|
||||
## Implementation sequence
|
||||
|
||||
1. Add the schema changes for BaselineSnapshot lifecycle and completion metadata.
|
||||
2. Add the BaselineSnapshot lifecycle enum/model helpers and a centralized consumability/effective-snapshot resolver.
|
||||
3. Update capture finalization to create `building` snapshots and finalize to `complete` or `incomplete`.
|
||||
4. Update profile current-snapshot promotion and supersession logic.
|
||||
5. Update compare start and compare execution guards to reject non-consumable snapshots.
|
||||
6. Update artifact-truth/badge presentation and the affected Filament surfaces.
|
||||
7. Add conservative historical backfill logic.
|
||||
8. Run focused Pest tests and format changed files.
|
||||
|
||||
## Focused verification commands
|
||||
|
||||
Start services if needed:
|
||||
|
||||
```bash
|
||||
vendor/bin/sail up -d
|
||||
```
|
||||
|
||||
Run the most relevant test groups:
|
||||
|
||||
```bash
|
||||
vendor/bin/sail artisan test --compact tests/Feature/Baselines/BaselineCaptureTest.php
|
||||
vendor/bin/sail artisan test --compact tests/Feature/Baselines/BaselineComparePreconditionsTest.php
|
||||
vendor/bin/sail artisan test --compact tests/Feature/Baselines/BaselineCompareStatsTest.php
|
||||
vendor/bin/sail artisan test --compact tests/Feature/Filament
|
||||
vendor/bin/sail artisan test --compact tests/Unit/Badges
|
||||
```
|
||||
|
||||
Run any new focused baseline lifecycle regression test file added during implementation:
|
||||
|
||||
```bash
|
||||
vendor/bin/sail artisan test --compact --filter=BaselineSnapshot
|
||||
```
|
||||
|
||||
Format changed files:
|
||||
|
||||
```bash
|
||||
vendor/bin/sail bin pint --dirty --format agent
|
||||
```
|
||||
|
||||
## Manual smoke checklist
|
||||
|
||||
1. Start a baseline capture from a workspace baseline profile and verify the produced snapshot becomes complete only after finalization.
|
||||
2. Force or simulate a mid-assembly failure and verify the produced snapshot remains incomplete and is not promoted to current truth.
|
||||
3. Open the baseline profile detail and confirm the latest complete snapshot remains the effective current snapshot when a newer attempt is incomplete.
|
||||
4. Open baseline compare for an assigned tenant and verify compare is blocked with an operator-safe explanation when no consumable snapshot exists.
|
||||
5. Open Monitoring run detail for capture/compare and confirm run outcome and snapshot lifecycle appear as separate truths.
|
||||
58
specs/159-baseline-snapshot-truth/research.md
Normal file
58
specs/159-baseline-snapshot-truth/research.md
Normal file
@ -0,0 +1,58 @@
|
||||
# Research: BaselineSnapshot Artifact Truth & Downstream Consumption Guards
|
||||
|
||||
## Decision 1: Model BaselineSnapshot with a single persisted lifecycle enum and derived historical truth in V1
|
||||
|
||||
- Decision: Add a first-class BaselineSnapshot lifecycle state with `building`, `complete`, and `incomplete`, and derive historical `superseded` truth in presentation/resolution instead of persisting it as a lifecycle mutation.
|
||||
- Rationale: The spec is intentionally narrow to BaselineSnapshot, but the constitution requires snapshots to remain immutable. A persisted three-state lifecycle is enough to separate artifact truth from run truth, while derived historical status lets the UI communicate that a snapshot is no longer current without mutating immutable artifacts.
|
||||
- Alternatives considered:
|
||||
- Separate lifecycle and historical-status enums. Rejected for V1 because it adds more schema and UI complexity than the spec requires.
|
||||
- Persisted `superseded` lifecycle mutation. Rejected because it conflicts with the constitution rule that snapshots and backups remain immutable.
|
||||
- Generic polymorphic artifact-state framework. Rejected because the spec explicitly limits scope to BaselineSnapshot.
|
||||
|
||||
## Decision 2: Keep staged item persistence and add explicit finalization instead of rewriting capture into one large transaction
|
||||
|
||||
- Decision: Preserve the existing deduplicated, chunked item-persistence strategy in `CaptureBaselineSnapshotJob`, but make snapshot creation begin in `building` and add an explicit finalization checkpoint before the snapshot can become `complete`.
|
||||
- Rationale: The current job already deduplicates item payloads and inserts items in chunks. Replacing that with one large transaction would be a broader persistence rewrite with higher regression risk. The core integrity defect is not the existence of staged persistence; it is the lack of explicit artifact finalization and unusable-state marking when staged persistence fails.
|
||||
- Alternatives considered:
|
||||
- Wrap snapshot row plus all item writes in one transaction. Rejected because it is a larger behavioral change, can increase lock duration, and is not necessary to satisfy the artifact-truth invariant.
|
||||
- Delete snapshot rows on failure. Rejected because the spec allows retaining incomplete artifacts for diagnostics and auditability.
|
||||
|
||||
## Decision 3: Treat `active_snapshot_id` as a cached pointer to the latest complete snapshot only
|
||||
|
||||
- Decision: Keep `baseline_profiles.active_snapshot_id`, but tighten its semantics so it may point only to a complete snapshot. Effective baseline truth resolves from complete snapshots, not from the latest attempt.
|
||||
- Rationale: Existing services, stats objects, and Filament resources already use `active_snapshot_id` heavily. Replacing it wholesale would create unnecessary churn. Tightening its meaning and adding a shared effective-snapshot resolver keeps compatibility while enforcing the new truth rule.
|
||||
- Alternatives considered:
|
||||
- Remove `active_snapshot_id` and resolve current truth only by querying snapshots. Rejected because it would require broader UI/service refactoring and lose a useful current-pointer optimization.
|
||||
- Leave `active_snapshot_id` semantics unchanged and only add compare-time checks. Rejected because profile truth and UI would still silently advance to incomplete snapshots.
|
||||
|
||||
## Decision 4: Use a centralized consumability/effective-truth service or helper for capture, compare, and UI
|
||||
|
||||
- Decision: Introduce one authoritative domain path for determining whether a snapshot is consumable and which snapshot is the effective current baseline for a profile.
|
||||
- Rationale: The current code assumes snapshot validity in multiple places: capture promotion, compare start, compare execution, stats, and UI. Duplicated state checks would drift. A central resolver keeps the rule enforceable and testable.
|
||||
- Alternatives considered:
|
||||
- Inline `status === complete` checks in each service/page. Rejected because that duplicates rules and invites inconsistent handling of superseded or legacy snapshots.
|
||||
- Put all logic only on the Eloquent model. Rejected because effective-truth resolution involves profile-level fallback rules, legacy handling, and operator-safe reason codes that fit better in a domain service/helper.
|
||||
|
||||
## Decision 5: Backfill legacy snapshots conservatively using proof, not assumption
|
||||
|
||||
- Decision: Backfill historical snapshots as `complete` only when completion can be proven from persisted item counts, `summary_jsonb`, and producing-run context where available. Mark ambiguous rows `incomplete`.
|
||||
- Rationale: The defect being fixed is silent trust in partial artifacts. Blindly backfilling historical rows as complete would reintroduce the same governance-integrity problem under a new label. Conservative backfill is aligned with the spec and with the platform’s fail-safe posture.
|
||||
- Alternatives considered:
|
||||
- Mark every legacy snapshot `complete`. Rejected because it makes historical ambiguity look trustworthy.
|
||||
- Mark every legacy snapshot `incomplete`. Rejected because it would unnecessarily invalidate proven-good history and create avoidable recapture churn.
|
||||
|
||||
## Decision 6: Completion proof must allow valid empty captures only when emptiness is explicitly proven
|
||||
|
||||
- Decision: Allow a snapshot to become `complete` with zero items only when the capture completed cleanly and the scope/result proves there were zero subjects to capture. Otherwise, zero items are not enough to infer completeness.
|
||||
- Rationale: Existing tests allow empty captures for zero-subject scopes, and a zero-subject baseline can still be a valid outcome. The important distinction is between intentionally empty and ambiguously incomplete.
|
||||
- Alternatives considered:
|
||||
- Require at least one item for every complete snapshot. Rejected because it would make legitimate empty captures impossible.
|
||||
- Treat any zero-item snapshot as complete. Rejected because it would misclassify failures that occurred before meaningful assembly.
|
||||
|
||||
## Decision 7: Reuse the existing artifact-truth and badge infrastructure for presentation
|
||||
|
||||
- Decision: Extend the existing `ArtifactTruthPresenter` and badge registration patterns for BaselineSnapshot lifecycle and usability rather than creating a new snapshot-specific presentation system.
|
||||
- Rationale: The repo already centralizes artifact truth across EvidenceSnapshot, TenantReview, ReviewPack, and OperationRun. Reusing that system keeps state labels, colors, and operator explanations consistent and satisfies BADGE-001.
|
||||
- Alternatives considered:
|
||||
- Add ad-hoc state rendering in each Filament resource/page. Rejected because it would violate badge centralization and drift across surfaces.
|
||||
- Introduce a separate baseline-only presenter. Rejected because the existing artifact-truth envelope already models the required dimensions.
|
||||
168
specs/159-baseline-snapshot-truth/spec.md
Normal file
168
specs/159-baseline-snapshot-truth/spec.md
Normal file
@ -0,0 +1,168 @@
|
||||
# Feature Specification: Artifact Truth & Downstream Consumption Guards for BaselineSnapshot
|
||||
|
||||
**Feature Branch**: `159-baseline-snapshot-truth`
|
||||
**Created**: 2026-03-23
|
||||
**Status**: Draft
|
||||
**Input**: User description: "Introduce explicit artifact-truth semantics for BaselineSnapshot so the platform no longer conflates operation success with artifact completeness and usability."
|
||||
|
||||
## Spec Scope Fields *(mandatory)*
|
||||
|
||||
- **Scope**: workspace, tenant, canonical-view
|
||||
- **Primary Routes**: `/admin/baseline-profiles`, `/admin/baseline-profiles/{record}`, `/admin/baseline-snapshots`, `/admin/baseline-snapshots/{record}`, `/admin/t/{tenant}/baseline-compare`, Monitoring → Operations → Run Detail for `baseline.capture` and `baseline.compare`
|
||||
- **Data Ownership**: Workspace-owned records: `BaselineProfile`, `BaselineSnapshot`, `BaselineSnapshotItem`, profile-to-current-snapshot truth. Tenant-owned consumers: `OperationRun` records for compare/capture execution and compare outputs that depend on baseline truth.
|
||||
- **RBAC**: Workspace membership plus `WORKSPACE_BASELINES_VIEW` for snapshot/profile truth surfaces; `WORKSPACE_BASELINES_MANAGE` for capture-start and profile mutation surfaces; tenant membership plus tenant compare capability for compare-start surfaces. Non-members remain 404, members without capability remain 403.
|
||||
- **Canonical View Default Filter Behavior**: Monitoring → Operations → Run Detail follows the active tenant context when one is selected; without tenant context, canonical Monitoring access may resolve baseline capture/compare runs only after workspace entitlement is established and any referenced tenant-owned run is filtered by tenant entitlement before disclosure.
|
||||
- **Canonical View Entitlement Checks**: Canonical Monitoring routes MUST deny-as-not-found for actors lacking workspace membership or lacking entitlement to the tenant referenced by a tenant-owned `OperationRun`. No canonical run detail may reveal cross-tenant baseline operation existence.
|
||||
- **List Surface Review Standard**: Because this feature changes the Baseline Profiles and Baseline Snapshots list surfaces, implementation and review must follow `docs/product/standards/list-surface-review-checklist.md`.
|
||||
|
||||
## Operator Surface Contract *(mandatory when operator-facing surfaces are changed)*
|
||||
|
||||
| Surface | Primary Persona | Surface Type | Primary Operator Question | Default-visible Information | Diagnostics-only Information | Status Dimensions Used | Mutation Scope | Primary Actions | Dangerous Actions |
|
||||
|---|---|---|---|---|---|---|---|---|---|
|
||||
| Baselines list/detail | Workspace manager | List/detail | Which baseline is current, and is its current snapshot trustworthy? | Active baseline profile, latest complete snapshot, latest attempted snapshot when it differs, compare readiness, clear next step | Item-level gap reasons, retry details, diagnostic counts, run context payloads | baseline lifecycle, snapshot completeness, snapshot usability, execution outcome | TenantPilot only | Create baseline, Edit baseline, Capture baseline, Compare now, View current snapshot | Archive baseline profile |
|
||||
| Baseline Snapshots list/detail | Workspace manager | List/detail | Can this snapshot be trusted as baseline truth, and if not, why not? | Lifecycle state, consumability label, current vs historical status, captured time, profile linkage, next-step guidance | Partial item details, integrity diagnostics, gap breakdowns, related run diagnostics | lifecycle, usability, derived historical status, execution outcome | TenantPilot only | View snapshot, Open related record | None |
|
||||
| Baseline Compare landing | Tenant operator or manager | Tenant-scoped landing page | Can this tenant be compared right now, and which baseline truth will be used? | Assigned baseline profile, effective baseline snapshot, compare availability, current warning/block reason, last compare summary | Detailed evidence-gap reasons, operation-run diagnostics, duplicate-subject diagnostics | compare readiness, snapshot usability, evidence coverage, execution outcome | Simulation only | Compare now, View findings, View run | None |
|
||||
| Monitoring run detail for baseline capture/compare | Workspace manager or tenant operator with run access | Canonical run detail | Did the run finish, and did it produce a consumable snapshot? | Separate run outcome and produced-artifact truth, final snapshot state, snapshot link when present, operator-safe explanation | Failure summary, gap counts, retry context, resume metadata | execution outcome, artifact existence, artifact completeness, artifact usability | TenantPilot only | View related snapshot, View related profile | Resume capture remains separately governed |
|
||||
|
||||
## User Scenarios & Testing *(mandatory)*
|
||||
|
||||
### User Story 1 - Trust only complete baselines (Priority: P1)
|
||||
|
||||
A workspace manager captures a baseline and needs the system to promote it as effective baseline truth only when the snapshot is explicitly complete and safe for downstream comparison.
|
||||
|
||||
**Why this priority**: This closes the core governance-integrity gap. If this story fails, downstream findings can be materially wrong even when the UI appears healthy.
|
||||
|
||||
**Independent Test**: Start a baseline capture that succeeds, then verify the resulting snapshot is marked complete, becomes the effective current snapshot, and is eligible for compare without relying on run status inference.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** an active baseline profile without a current snapshot, **When** a capture completes successfully, **Then** the produced snapshot is marked complete, is consumable, and becomes the profile's effective current baseline snapshot.
|
||||
2. **Given** an active baseline profile with an older complete snapshot, **When** a newer capture creates a snapshot row but fails before completion, **Then** the new snapshot is marked incomplete, is not consumable, and the profile continues to point to the older complete snapshot as effective truth.
|
||||
|
||||
---
|
||||
|
||||
### User Story 2 - Block unsafe compare input (Priority: P2)
|
||||
|
||||
A tenant operator starts baseline compare and needs the platform to refuse building or incomplete baseline snapshots instead of producing untrustworthy drift findings.
|
||||
|
||||
**Why this priority**: Unsafe compare input turns a partial capture defect into false governance output. Blocking compare is safer than silently producing findings from incomplete truth.
|
||||
|
||||
**Independent Test**: Attempt compare against a building snapshot, an incomplete snapshot, and a complete snapshot; verify only the complete snapshot is accepted and every blocked case returns a clear operator-safe reason.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** a tenant assigned to a baseline profile whose latest attempt is incomplete and no explicit override is provided, **When** compare starts, **Then** the system uses the latest complete snapshot if one exists, or blocks with a clear no-consumable-snapshot reason.
|
||||
2. **Given** an explicit snapshot selection that is building, incomplete, or a historically superseded complete snapshot that is no longer the effective current truth, **When** compare starts or the compare job resolves its input, **Then** the compare flow refuses to proceed and records the rejection reason without generating normal drift output.
|
||||
|
||||
---
|
||||
|
||||
### User Story 3 - See run truth and artifact truth separately (Priority: P3)
|
||||
|
||||
An operator reviewing baselines, snapshots, or runs needs to distinguish execution outcome from artifact usability without opening low-level diagnostics.
|
||||
|
||||
**Why this priority**: Operators need to act on the correct truth quickly. A failed run with an incomplete snapshot should not look equivalent to a failed run with no artifact, and a completed run with a complete snapshot should read as trustworthy.
|
||||
|
||||
**Independent Test**: Review the baseline profile detail, baseline snapshot detail, compare landing page, and run detail for successful, building, and incomplete cases; verify each surface shows run outcome and snapshot usability as separate concepts.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** a run that failed after partial snapshot creation, **When** an operator opens the related snapshot or run detail, **Then** the UI shows the run as failed and the snapshot as incomplete and not usable for compare.
|
||||
2. **Given** a snapshot that has been replaced by a newer complete snapshot, **When** an operator opens the older snapshot, **Then** the UI labels it as superseded or historical through derived presentation status and does not present it as current baseline truth.
|
||||
|
||||
### Edge Cases
|
||||
|
||||
- A capture creates the snapshot row and some items, then fails during later item persistence. The snapshot must end incomplete and must never become the effective current snapshot.
|
||||
- A retry or rerun encounters already persisted logical subjects. The resulting snapshot must not be marked complete unless duplicates are handled deterministically and the final artifact passes completion checks.
|
||||
- The latest attempted snapshot is building while an older complete snapshot exists. Compare and profile truth must continue to resolve to the older complete snapshot until the new attempt is finalized complete.
|
||||
- Legacy snapshots that cannot be proven complete during backfill must default to incomplete or unavailable-for-compare rather than being assumed trustworthy.
|
||||
- Unknown or ambiguous completion state, including interrupted finalization, must be treated as not consumable.
|
||||
|
||||
## Requirements *(mandatory)*
|
||||
|
||||
**Constitution alignment (required):** This feature changes long-running baseline capture and compare behavior but does not introduce new Microsoft Graph endpoints or new contract-registry object types. Existing baseline capture and compare safety gates remain in place: capture and compare start actions stay confirmation-gated, execution remains auditable, tenant/workspace isolation is unchanged, and downstream baseline truth moves from inferred run outcome to explicit artifact state. No new DB-only security mutation is introduced.
|
||||
|
||||
**Constitution alignment (OPS-UX):** `baseline.capture` and `baseline.compare` continue to use the existing three feedback surfaces only: queued toast, active progress surfaces, and one terminal DB notification per run. `OperationRun.status` and `OperationRun.outcome` remain service-owned via `OperationRunService`. `summary_counts` remain numeric-only and continue to describe execution progress rather than artifact completeness. Scheduled or initiator-null behavior remains unchanged: no terminal DB notification is emitted without an initiator, and monitoring stays the audit surface. Regression coverage must include at least one guard proving artifact finalization does not bypass service-owned run transitions.
|
||||
|
||||
**Constitution alignment (RBAC-UX):** This feature touches the workspace-admin plane (`/admin/baseline-profiles`, `/admin/baseline-snapshots`), the tenant plane (`/admin/t/{tenant}/baseline-compare`), and the canonical Monitoring view for run detail. Cross-plane access remains deny-as-not-found. Non-members of the relevant workspace or tenant scope receive 404. Members lacking the required capability receive 403. Server-side enforcement remains required for capture start, compare start, profile mutation, and any related snapshot navigation. Canonical Monitoring run detail must additionally enforce tenant entitlement before revealing tenant-owned baseline runs when no tenant route segment is present. No raw capability strings or role-name checks may be introduced. Global search remains disabled for the affected baseline resources.
|
||||
|
||||
**Constitution alignment (OPS-EX-AUTH-001):** Not applicable beyond reaffirming that this feature does not add any auth-handshake HTTP behavior.
|
||||
|
||||
**Constitution alignment (BADGE-001):** Snapshot lifecycle, derived historical-status, and usability labels must be driven by centralized badge or presenter mappings. No page may introduce ad-hoc color, icon, or wording decisions for `building`, `complete`, `incomplete`, derived `superseded`, or compare-usability states. Tests must cover the new or changed mappings.
|
||||
|
||||
**Constitution alignment (UI-NAMING-001):** Operator-facing copy must consistently use baseline vocabulary: `Capture baseline`, `Compare now`, `Building`, `Complete`, `Incomplete`, `Superseded`, `Not usable for compare`, and `Current baseline unavailable`. Internal terms such as `partial write`, `resume token`, or `integrity meta` may appear only in diagnostics, not as primary labels.
|
||||
|
||||
**Constitution alignment (OPSURF-001):** The modified surfaces remain operator-first by showing default-visible truth in this order: effective baseline snapshot, lifecycle/usability, and the operator next step. Diagnostics such as gap reasons, duplicate handling, and resume metadata remain secondary. Status dimensions must be shown separately where relevant: execution outcome, artifact existence, artifact completeness, and compare readiness. Capture and compare continue to communicate their mutation scope before execution: `TenantPilot only` for profile/snapshot truth updates and `simulation only` for compare.
|
||||
|
||||
**Constitution alignment (Filament Action Surfaces):** The Action Surface Contract remains satisfied for the modified baseline resources and page. Existing exemptions remain valid for the immutable snapshot resource: no list-header actions, no bulk actions, and no empty-state CTA. This feature changes truth presentation and action availability, not the overall action topology.
|
||||
|
||||
**Constitution alignment (UI-STD-001):** The modified Baseline Profiles and Baseline Snapshots list surfaces MUST be reviewed against `docs/product/standards/list-surface-review-checklist.md` as part of implementation and review.
|
||||
|
||||
**Constitution alignment (UX-001 — Layout & Information Architecture):** Existing Baseline Profile create/edit and view layouts remain in place. Snapshot and run detail pages continue to use structured detail sections or infolist-style presentation. This feature adds or changes status sections, labels, and action availability states rather than introducing new free-form inputs. Existing immutable-snapshot exemptions remain documented.
|
||||
|
||||
### Functional Requirements
|
||||
|
||||
- **FR-001**: The system MUST add an explicit lifecycle state to every BaselineSnapshot with the supported V1 values `building`, `complete`, and `incomplete`.
|
||||
- **FR-002**: The system MUST create every new BaselineSnapshot in `building` state before item assembly begins.
|
||||
- **FR-003**: The system MUST finalize a BaselineSnapshot to `complete` only as the final successful step after the snapshot has passed this feature's minimum completion check: the persisted BaselineSnapshotItem count matches the expected deduplicated item count, no unresolved assembly or finalization error remains, and the finalization step records successful completion metadata.
|
||||
- **FR-004**: The system MUST finalize a BaselineSnapshot to `incomplete` whenever capture fails or terminates after snapshot creation but before successful completion, including failures after only part of the snapshot items have been persisted.
|
||||
- **FR-005**: The system MUST treat consumability as a single authoritative rule derived from snapshot lifecycle state, where only `complete` is consumable in V1.
|
||||
- **FR-006**: The system MUST provide a single domain-level helper for snapshot consumability so compare flows, profile truth resolution, presenters, and UI surfaces do not duplicate lifecycle checks.
|
||||
- **FR-007**: The system MUST prevent any compare start path or compare execution path from consuming a BaselineSnapshot whose lifecycle state is not `complete` or whose complete snapshot is no longer the effective current truth for its profile.
|
||||
- **FR-008**: The system MUST return or record a clear operator-safe reason when compare is blocked because the selected or resolved snapshot is `building`, `incomplete`, missing, historically superseded, or otherwise not consumable.
|
||||
- **FR-009**: The system MUST resolve effective baseline truth for a profile as the latest `complete` snapshot, never merely the latest attempted snapshot.
|
||||
- **FR-010**: The system MUST NOT advance a profile's current or active snapshot pointer to a `building` or `incomplete` snapshot.
|
||||
- **FR-011**: The system MUST derive the previously effective complete snapshot as `superseded` or historical in operator-facing truth presentation only after a newer snapshot for the same profile becomes `complete`, without mutating the older snapshot away from its recorded terminal lifecycle state.
|
||||
- **FR-012**: The system MUST ensure a derived superseded or historical snapshot remains viewable while remaining non-consumable as current baseline truth.
|
||||
- **FR-013**: The system MUST use a deterministic persistence strategy for snapshot item assembly so retries or reruns do not create duplicate logical-subject rows that could falsely imply completion.
|
||||
- **FR-014**: The system MUST treat database uniqueness rules as safety nets, not as the primary definition of snapshot completeness.
|
||||
- **FR-015**: The system MUST preserve the completion-proof metadata needed to justify the `complete` transition and later backfill decisions, including at minimum the expected deduplicated item count, the persisted item count, the producing operation run identifier or linkable reference, the completion or failure timestamp, and the best-available incomplete reason when the snapshot becomes `incomplete`.
|
||||
- **FR-016**: The system MUST backfill pre-existing BaselineSnapshot rows conservatively according to a deterministic decision table. The first matching rule wins, contradictory or partial evidence is treated as no proof, and rows without proof MUST default to `incomplete` or require recapture before compare.
|
||||
- **FR-017**: The system MUST keep run truth and artifact truth separate on operator-facing surfaces so a run can be shown as failed, successful, or in progress independently from snapshot usability.
|
||||
- **FR-018**: The system MUST show baseline compare availability from effective snapshot consumability, not merely from snapshot existence.
|
||||
- **FR-019**: The system MUST preserve existing capture and compare start confirmations and existing authorization boundaries while updating the reasons and availability states shown to operators, including keeping `->requiresConfirmation()` on the existing start actions and preserving the existing 404-for-non-members and 403-for-members-without-capability behavior on all affected entry points.
|
||||
- **FR-020**: The system MUST keep snapshot lifecycle and artifact-truth badge semantics centralized so list, detail, compare, and monitoring surfaces render the same state labels and meanings.
|
||||
- **FR-021**: The system MUST preserve auditability for snapshot lifecycle transitions by retaining which run produced the snapshot, whether the snapshot became consumable, the best-available reason when it became incomplete, and whether it is later rendered historical because a newer complete snapshot exists.
|
||||
- **FR-022**: The system MUST cover the motivating regression path in automated tests: snapshot row exists, item persistence fails partway, snapshot is not complete, profile truth does not advance, and compare refuses the snapshot.
|
||||
|
||||
### Assumptions
|
||||
|
||||
- V1 remains intentionally local to `BaselineSnapshot` and `BaselineSnapshotItem`; it does not introduce a generic artifact-lifecycle framework.
|
||||
- The existing profile-level current snapshot pointer remains the main way the product resolves effective baseline truth, but its semantics are tightened so it can reference only a complete snapshot.
|
||||
- Resume or repair flows remain separate follow-up work. This feature only guarantees that incomplete artifacts are explicitly marked unusable and blocked from downstream consumption.
|
||||
- Existing compare assignment rules and baseline scope rules remain unchanged.
|
||||
|
||||
### Legacy Backfill Decision Table
|
||||
|
||||
| Priority | Proof Rule | Required Evidence | Classification | Notes |
|
||||
|---|---|---|---|---|
|
||||
| 1 | Count proof | `summary_jsonb.total_items` or equivalent expected-item metadata is present, persisted BaselineSnapshotItem count is known, and both counts match exactly | `complete` | Use only when counts are non-null and non-contradictory |
|
||||
| 2 | Producing-run success proof | Linkable producing run exists, its terminal outcome proves successful capture finalization, expected item count is present, and persisted item count matches that expected item count | `complete` | Run success alone is insufficient without count reconciliation |
|
||||
| 3 | Proven empty capture proof | Linkable producing run exists, its terminal outcome proves successful capture finalization for the recorded scope, persisted item count is `0`, and the scope evidence proves zero items were expected | `complete` | Empty snapshots require explicit proof that zero items were expected |
|
||||
| 4 | Contradictory or partial evidence | Any required evidence above is missing, null, inconsistent, or disagrees with persisted item counts | `incomplete` | No tie-breaker may elevate contradictory evidence to `complete` |
|
||||
| 5 | No proof available | No qualifying summary metadata or producing-run proof is available | `incomplete` | Operator guidance must point to recapture before compare |
|
||||
|
||||
## UI Action Matrix *(mandatory when Filament is changed)*
|
||||
|
||||
| Surface | Location | Header Actions | Inspect Affordance (List/Table) | Row Actions (max 2 visible) | Bulk Actions (grouped) | Empty-State CTA(s) | View Header Actions | Create/Edit Save+Cancel | Audit log? | Notes / Exemptions |
|
||||
|---|---|---|---|---|---|---|---|---|---|---|
|
||||
| Baseline profiles resource | `app/Filament/Resources/BaselineProfileResource.php` and `app/Filament/Resources/BaselineProfileResource/Pages/ViewBaselineProfile.php` | `Create baseline profile` on list | Dedicated `View` inspect affordance on list | `View`; `Edit` and `Archive` remain under `More` | None | Existing create CTA remains | `View current snapshot`, `Capture baseline`, `Compare now`, `Edit` | Existing save/cancel unchanged | Yes | `Capture baseline` and `Compare now` remain confirmation-gated. This spec changes when `View current snapshot` resolves and when compare is allowed. `Archive baseline profile` remains the only destructive action and still requires confirmation. |
|
||||
| Baseline snapshots resource | `app/Filament/Resources/BaselineSnapshotResource.php` and `app/Filament/Resources/BaselineSnapshotResource/Pages/ViewBaselineSnapshot.php` | None | Clickable row | `Open related record` | None | None by design | `Open related record` | Not applicable | No direct mutation audit | Action Surface Contract remains satisfied through existing immutable-resource exemptions. This spec changes lifecycle/usability badges, filters, and explanatory text only. |
|
||||
| Baseline compare landing | `app/Filament/Pages/BaselineCompareLanding.php` | `Compare now` | Not applicable | None | None | Existing empty or blocked guidance remains, but messaging must distinguish `no consumable baseline` from `no assignment` or `in progress` | Not applicable | Not applicable | Yes, via compare run | `Compare now` remains confirmation-gated and simulation-only. The page must disable or block it when no consumable snapshot exists and explain the next operator step. |
|
||||
| Monitoring run detail | Existing operation run detail surface for baseline capture and baseline compare | Existing run-detail header actions unchanged | Not applicable | Not applicable | Not applicable | Not applicable | Existing related-artifact navigation unchanged | Not applicable | Yes | No new actions are introduced. The detail body must show run outcome separately from produced snapshot lifecycle and compare usability. |
|
||||
|
||||
### Key Entities *(include if feature involves data)*
|
||||
|
||||
- **BaselineSnapshot**: The workspace-owned artifact that represents a captured baseline for a profile, including lifecycle state, completion timestamps, integrity summary, and whether it can serve as effective baseline truth.
|
||||
- **BaselineSnapshotItem**: The immutable or deterministic item set that makes up a snapshot's captured baseline content for specific logical subjects.
|
||||
- **BaselineProfile**: The workspace-owned governance definition whose effective baseline truth resolves to the latest complete snapshot and whose compare/capture actions depend on snapshot consumability.
|
||||
- **OperationRun**: The execution record for capture and compare. It remains the source of truth for execution history but not for artifact completeness.
|
||||
|
||||
## Success Criteria *(mandatory)*
|
||||
|
||||
### Measurable Outcomes
|
||||
|
||||
- **SC-001**: In automated regression coverage, 100% of compare attempts targeting `building` or `incomplete` snapshots are blocked before normal drift output is produced.
|
||||
- **SC-002**: In automated regression coverage, 100% of partial-capture failure scenarios leave the produced snapshot non-consumable and preserve the previously effective complete snapshot when one exists.
|
||||
- **SC-003**: In automated surface coverage, the modified baseline profile detail shows the effective baseline truth label and next-step guidance, the baseline snapshot detail shows lifecycle plus current-vs-historical status, the compare landing shows compare availability plus a block reason or effective-snapshot label, and the run detail shows run outcome and artifact truth as separate visible assertions without opening diagnostics.
|
||||
- **SC-004**: Historical backfill classifies existing baseline snapshots conservatively enough that ambiguous legacy rows do not become effective baseline truth without a rule-based proof of completeness.
|
||||
- **SC-005**: The product no longer derives effective baseline truth from run outcome alone anywhere in the baseline capture or baseline compare workflow.
|
||||
214
specs/159-baseline-snapshot-truth/tasks.md
Normal file
214
specs/159-baseline-snapshot-truth/tasks.md
Normal file
@ -0,0 +1,214 @@
|
||||
# Tasks: BaselineSnapshot Artifact Truth & Downstream Consumption Guards
|
||||
|
||||
**Input**: Design documents from `/specs/159-baseline-snapshot-truth/`
|
||||
**Prerequisites**: plan.md, spec.md, research.md, data-model.md, contracts/openapi.yaml, quickstart.md
|
||||
|
||||
**Tests**: Tests are REQUIRED for this feature because it changes runtime behavior across capture, compare, UI truth presentation, and historical backfill.
|
||||
**Operations**: `baseline.capture` and `baseline.compare` already use canonical `OperationRun` flows. Tasks below preserve queued-only toasts, progress-only active surfaces, terminal `OperationRunCompleted`, service-owned run transitions, and numeric-only `summary_counts`.
|
||||
**RBAC**: Workspace-plane and tenant-plane authorization behavior is preserved. Non-members remain 404, members without capability remain 403, and destructive-like actions keep `->requiresConfirmation()`.
|
||||
**Badges**: All new lifecycle/usability status mapping must stay inside `BadgeCatalog` / `BadgeRenderer` / `ArtifactTruthPresenter`; no ad-hoc Filament mappings.
|
||||
**List Surface Review**: Modified Baseline Profiles and Baseline Snapshots list surfaces must be reviewed against `docs/product/standards/list-surface-review-checklist.md`.
|
||||
|
||||
## Phase 1: Setup (Shared Infrastructure)
|
||||
|
||||
**Purpose**: Create the shared lifecycle scaffolding this feature needs before any story-specific behavior can be implemented.
|
||||
|
||||
- [X] T001 Add BaselineSnapshot lifecycle/backfill schema changes in `database/migrations/2026_03_23_000001_add_lifecycle_state_to_baseline_snapshots_table.php`
|
||||
- [X] T002 [P] Create the BaselineSnapshot lifecycle enum in `app/Support/Baselines/BaselineSnapshotLifecycleState.php`
|
||||
- [X] T003 [P] Extend snapshot lifecycle factory states in `database/factories/BaselineSnapshotFactory.php`
|
||||
|
||||
---
|
||||
|
||||
## Phase 2: Foundational (Blocking Prerequisites)
|
||||
|
||||
**Purpose**: Add the shared domain, reason, and badge infrastructure that all user stories depend on.
|
||||
|
||||
**⚠️ CRITICAL**: No user story work should start until this phase is complete.
|
||||
|
||||
- [X] T004 Update lifecycle casts, transition helpers, and consumable scopes in `app/Models/BaselineSnapshot.php`
|
||||
- [X] T005 [P] Add effective-snapshot and consumability resolution in `app/Services/Baselines/BaselineSnapshotTruthResolver.php`
|
||||
- [X] T006 [P] Add non-consumable baseline reason codes in `app/Support/Baselines/BaselineReasonCodes.php`
|
||||
- [X] T007 [P] Add lifecycle/operator-safe reason translations in `app/Support/ReasonTranslation/ReasonTranslator.php`
|
||||
- [X] T008 [P] Register the new lifecycle badge domain in `app/Support/Badges/BadgeDomain.php` and `app/Support/Badges/BadgeCatalog.php`
|
||||
- [X] T009 [P] Add lifecycle taxonomy and badge mapping in `app/Support/Badges/OperatorOutcomeTaxonomy.php` and `app/Support/Badges/Domains/BaselineSnapshotLifecycleBadge.php`
|
||||
|
||||
**Checkpoint**: Shared lifecycle, resolver, and badge infrastructure are ready for story work.
|
||||
|
||||
---
|
||||
|
||||
## Phase 3: User Story 1 - Trust Only Complete Baselines (Priority: P1) 🎯 MVP
|
||||
|
||||
**Goal**: Capture must only promote complete snapshots as effective baseline truth, while partial or failed captures remain explicitly unusable.
|
||||
|
||||
**Independent Test**: Start a successful capture and verify the snapshot ends `complete` and becomes current truth; simulate a partial-write failure and verify the snapshot ends `incomplete` and the previous complete snapshot remains current.
|
||||
|
||||
### Tests for User Story 1 ⚠️
|
||||
|
||||
> **NOTE: Write these tests first and confirm they fail before implementation.**
|
||||
|
||||
- [X] T010 [P] [US1] Add BaselineSnapshot lifecycle domain tests in `tests/Unit/Baselines/BaselineSnapshotLifecycleTest.php`
|
||||
- [X] T011 [P] [US1] Update capture lifecycle and partial-write regression coverage in `tests/Feature/Baselines/BaselineCaptureTest.php`
|
||||
|
||||
### Implementation for User Story 1
|
||||
|
||||
- [X] T012 [US1] Add current-consumable snapshot helpers to `app/Models/BaselineProfile.php`
|
||||
- [X] T013 [US1] Create `building` snapshots and completion-proof finalization in `app/Jobs/CaptureBaselineSnapshotJob.php`
|
||||
- [X] T014 [US1] Guard `active_snapshot_id` promotion and current-snapshot rollover in `app/Jobs/CaptureBaselineSnapshotJob.php`
|
||||
|
||||
**Checkpoint**: Capture produces explicit lifecycle truth and only complete snapshots become effective current baselines.
|
||||
|
||||
---
|
||||
|
||||
## Phase 4: User Story 2 - Block Unsafe Compare Input (Priority: P2)
|
||||
|
||||
**Goal**: Compare must resolve only consumable snapshots and fail safely when the selected or implicit baseline is building, incomplete, or otherwise unusable.
|
||||
|
||||
**Independent Test**: Attempt compare with a complete snapshot, a building snapshot, and an incomplete snapshot; only the complete path should proceed while blocked cases return clear operator-safe reasons.
|
||||
|
||||
### Tests for User Story 2 ⚠️
|
||||
|
||||
- [X] T015 [P] [US2] Update compare precondition coverage for building/incomplete snapshots in `tests/Feature/Baselines/BaselineComparePreconditionsTest.php`
|
||||
- [X] T016 [P] [US2] Add compare execution guard regression coverage in `tests/Feature/Baselines/BaselineCompareExecutionGuardTest.php`
|
||||
- [X] T017 [P] [US2] Update effective-snapshot fallback and blocked-state stats coverage in `tests/Feature/Baselines/BaselineCompareStatsTest.php`
|
||||
- [X] T018 [P] [US2] Add compare/capture confirmation and authorization regression coverage in `tests/Feature/Filament/BaselineActionAuthorizationTest.php`
|
||||
|
||||
### Implementation for User Story 2
|
||||
|
||||
- [X] T019 [US2] Resolve only effective consumable snapshots and block historical explicit overrides in `app/Services/Baselines/BaselineCompareService.php`
|
||||
- [X] T020 [US2] Reject non-consumable snapshots during job execution in `app/Jobs/CompareBaselineToTenantJob.php`
|
||||
- [X] T021 [US2] Derive compare availability from effective snapshot truth in `app/Support/Baselines/BaselineCompareStats.php`
|
||||
- [X] T022 [US2] Surface blocked compare reasons while preserving confirmation and authorization rules on the tenant compare page in `app/Filament/Pages/BaselineCompareLanding.php`
|
||||
- [X] T023 [US2] Sync compare warning widgets with blocked-state stats in `app/Filament/Widgets/Tenant/BaselineCompareCoverageBanner.php` and `app/Filament/Widgets/Dashboard/BaselineCompareNow.php`
|
||||
|
||||
**Checkpoint**: Compare can only run against complete snapshots and reports clear reasons when no consumable baseline exists.
|
||||
|
||||
---
|
||||
|
||||
## Phase 5: User Story 3 - See Run Truth And Artifact Truth Separately (Priority: P3)
|
||||
|
||||
**Goal**: Operators can distinguish run outcome, snapshot lifecycle, snapshot usability, and historical status across the affected workspace and tenant surfaces.
|
||||
|
||||
**Independent Test**: Review baseline profile detail, baseline snapshot list/detail, compare landing, and run detail for complete, incomplete, and historically superseded snapshots; each surface must present run truth and artifact truth as separate concepts.
|
||||
|
||||
### Tests for User Story 3 ⚠️
|
||||
|
||||
- [X] T024 [P] [US3] Extend governance artifact-truth badge coverage in `tests/Unit/Badges/GovernanceArtifactTruthTest.php`
|
||||
- [X] T025 [P] [US3] Add BaselineSnapshot artifact-truth presenter tests in `tests/Unit/Support/GovernanceArtifactTruth/BaselineSnapshotArtifactTruthTest.php`
|
||||
- [X] T026 [P] [US3] Add baseline profile, snapshot, and compare truth-surface coverage in `tests/Feature/Filament/BaselineSnapshotTruthSurfaceTest.php`
|
||||
- [X] T027 [P] [US3] Add monitoring run-detail baseline truth coverage in `tests/Feature/Filament/OperationRunBaselineTruthSurfaceTest.php`
|
||||
|
||||
### Implementation for User Story 3
|
||||
|
||||
- [X] T028 [US3] Update BaselineSnapshot truth envelopes in `app/Support/Ui/GovernanceArtifactTruth/ArtifactTruthPresenter.php`
|
||||
- [X] T029 [US3] Show lifecycle and usability state in `app/Filament/Resources/BaselineSnapshotResource.php`
|
||||
- [X] T030 [US3] Show effective-vs-latest snapshot truth in `app/Filament/Resources/BaselineProfileResource.php`
|
||||
- [X] T031 [US3] Align capture and compare header actions with current-truth, confirmation, and authorization rules in `app/Filament/Resources/BaselineProfileResource/Pages/ViewBaselineProfile.php`
|
||||
- [X] T032 [US3] Update snapshot detail enterprise state in `app/Filament/Resources/BaselineSnapshotResource/Pages/ViewBaselineSnapshot.php`
|
||||
- [X] T033 [US3] Separate run outcome from snapshot truth on monitoring run detail in `app/Filament/Resources/OperationRunResource.php`
|
||||
|
||||
**Checkpoint**: Operators can read baseline trust accurately without inferring artifact completeness from run outcome alone.
|
||||
|
||||
---
|
||||
|
||||
## Phase 6: Polish & Cross-Cutting Concerns
|
||||
|
||||
**Purpose**: Finish legacy handling, regression coverage, and validation for the complete feature.
|
||||
|
||||
- [X] T034 [P] Add deterministic expected-item decision-table coverage for legacy backfill in `tests/Feature/Baselines/BaselineSnapshotBackfillTest.php`
|
||||
- [X] T035 Update expected-item decision-table legacy backfill branches in `database/migrations/2026_03_23_000001_add_lifecycle_state_to_baseline_snapshots_table.php`
|
||||
- [X] T036 [P] Refresh lifecycle taxonomy regression coverage in `tests/Unit/Badges/OperatorOutcomeTaxonomyTest.php`
|
||||
- [X] T037 [P] Add lifecycle auditability coverage for producing-run links, incomplete reasons, and derived historical truth in `tests/Feature/Baselines/BaselineSnapshotLifecycleAuditabilityTest.php`
|
||||
- [X] T038 [P] Add Ops-UX guard coverage for service-owned run transitions and canonical terminal notifications in `tests/Feature/Operations/BaselineOperationRunGuardTest.php`
|
||||
|
||||
---
|
||||
|
||||
## Dependencies & Execution Order
|
||||
|
||||
### Phase Dependencies
|
||||
|
||||
- **Setup (Phase 1)**: Starts immediately.
|
||||
- **Foundational (Phase 2)**: Depends on Setup completion and blocks all user stories.
|
||||
- **User Stories (Phases 3-5)**: All depend on Foundational completion.
|
||||
- **Polish (Phase 6)**: Depends on the desired user stories being complete.
|
||||
|
||||
### User Story Dependencies
|
||||
|
||||
- **User Story 1 (P1)**: Starts after Foundational and delivers the MVP.
|
||||
- **User Story 2 (P2)**: Starts after Foundational; it depends on shared lifecycle and resolver infrastructure but not on US1 implementation order.
|
||||
- **User Story 3 (P3)**: Starts after Foundational; it depends on shared lifecycle and badge infrastructure but can proceed independently of US1 and US2 if teams split work.
|
||||
|
||||
### Within Each User Story
|
||||
|
||||
- Write tests first and confirm they fail before implementation.
|
||||
- Update shared/domain helpers before wiring them into services, jobs, or Filament surfaces.
|
||||
- Finish model/service/job behavior before final UI and stats integration.
|
||||
- Keep each story independently verifiable against its stated checkpoint.
|
||||
|
||||
## Parallel Opportunities
|
||||
|
||||
- `T002` and `T003` can run in parallel after `T001`.
|
||||
- `T005` through `T009` can run in parallel once `T004` defines the model lifecycle contract.
|
||||
- Story test tasks marked `[P]` can run in parallel within each user story.
|
||||
- UI tasks in US3 can split across separate resources/pages once `T026` lands the shared artifact-truth envelope changes.
|
||||
|
||||
## Parallel Example: User Story 1
|
||||
|
||||
```bash
|
||||
# Write and run the capture-focused tests in parallel:
|
||||
T010 tests/Unit/Baselines/BaselineSnapshotLifecycleTest.php
|
||||
T011 tests/Feature/Baselines/BaselineCaptureTest.php
|
||||
|
||||
# After tests exist, split the model/job work:
|
||||
T012 app/Models/BaselineProfile.php
|
||||
T013 app/Jobs/CaptureBaselineSnapshotJob.php
|
||||
```
|
||||
|
||||
## Parallel Example: User Story 2
|
||||
|
||||
```bash
|
||||
# Compare guard tests can be prepared together:
|
||||
T015 tests/Feature/Baselines/BaselineComparePreconditionsTest.php
|
||||
T016 tests/Feature/Baselines/BaselineCompareExecutionGuardTest.php
|
||||
T017 tests/Feature/Baselines/BaselineCompareStatsTest.php
|
||||
T018 tests/Feature/Filament/BaselineActionAuthorizationTest.php
|
||||
|
||||
# Then service/job/page updates can be split by file:
|
||||
T019 app/Services/Baselines/BaselineCompareService.php
|
||||
T020 app/Jobs/CompareBaselineToTenantJob.php
|
||||
T022 app/Filament/Pages/BaselineCompareLanding.php
|
||||
```
|
||||
|
||||
## Parallel Example: User Story 3
|
||||
|
||||
```bash
|
||||
# Shared truth tests can be prepared together:
|
||||
T024 tests/Unit/Badges/GovernanceArtifactTruthTest.php
|
||||
T025 tests/Unit/Support/GovernanceArtifactTruth/BaselineSnapshotArtifactTruthTest.php
|
||||
T026 tests/Feature/Filament/BaselineSnapshotTruthSurfaceTest.php
|
||||
T027 tests/Feature/Filament/OperationRunBaselineTruthSurfaceTest.php
|
||||
|
||||
# After T028 lands shared presenter changes, UI files can split:
|
||||
T029 app/Filament/Resources/BaselineSnapshotResource.php
|
||||
T030 app/Filament/Resources/BaselineProfileResource.php
|
||||
T033 app/Filament/Resources/OperationRunResource.php
|
||||
```
|
||||
|
||||
## Implementation Strategy
|
||||
|
||||
### MVP First
|
||||
|
||||
1. Complete Phase 1 and Phase 2.
|
||||
2. Deliver User Story 1 as the MVP so capture truth is no longer ambiguous.
|
||||
3. Validate US1 with the focused capture lifecycle tests before moving on.
|
||||
|
||||
### Incremental Delivery
|
||||
|
||||
1. Add User Story 2 to block unsafe compare input once capture truth is explicit.
|
||||
2. Add User Story 3 to make the new truth model visible and operator-safe across the affected surfaces.
|
||||
3. Finish Phase 6 for deterministic legacy backfill and regression hardening.
|
||||
|
||||
### Suggested MVP Scope
|
||||
|
||||
- Phase 1: Setup
|
||||
- Phase 2: Foundational
|
||||
- Phase 3: User Story 1 only
|
||||
@ -0,0 +1,36 @@
|
||||
# Specification Quality Checklist: Operation Lifecycle Guarantees & Queue-to-Domain Failure Reconciliation
|
||||
|
||||
**Purpose**: Validate specification completeness and quality before proceeding to planning
|
||||
**Created**: 2026-03-23
|
||||
**Feature**: [spec.md](../spec.md)
|
||||
|
||||
## Content Quality
|
||||
|
||||
- [x] No implementation details (languages, frameworks, APIs)
|
||||
- [x] Focused on user value and business needs
|
||||
- [x] Written for non-technical stakeholders
|
||||
- [x] All mandatory sections completed
|
||||
|
||||
## Requirement Completeness
|
||||
|
||||
- [x] No [NEEDS CLARIFICATION] markers remain
|
||||
- [x] Requirements are testable and unambiguous
|
||||
- [x] Success criteria are measurable
|
||||
- [x] Success criteria are technology-agnostic (no implementation details)
|
||||
- [x] All acceptance scenarios are defined
|
||||
- [x] Edge cases are identified
|
||||
- [x] Scope is clearly bounded
|
||||
- [x] Dependencies and assumptions identified
|
||||
|
||||
## Feature Readiness
|
||||
|
||||
- [x] All functional requirements have clear acceptance criteria
|
||||
- [x] User scenarios cover primary flows
|
||||
- [x] Feature meets measurable outcomes defined in Success Criteria
|
||||
- [x] No implementation details leak into specification
|
||||
|
||||
## Notes
|
||||
|
||||
- Validation pass completed on 2026-03-23.
|
||||
- No open clarification markers remain.
|
||||
- The spec intentionally names domain objects already established in the product, but avoids prescribing implementation structure.
|
||||
@ -0,0 +1,171 @@
|
||||
openapi: 3.1.0
|
||||
info:
|
||||
title: Operation Run Lifecycle Monitoring Contract
|
||||
version: 0.1.0
|
||||
summary: Canonical Monitoring data contract for lifecycle freshness and reconciliation semantics on covered OperationRun records.
|
||||
servers:
|
||||
- url: https://tenantpilot.local
|
||||
paths:
|
||||
/api/admin/operations:
|
||||
get:
|
||||
summary: List operation runs with lifecycle freshness semantics
|
||||
operationId: listOperationRuns
|
||||
parameters:
|
||||
- in: query
|
||||
name: status
|
||||
schema:
|
||||
type: string
|
||||
enum: [queued, running, completed]
|
||||
- in: query
|
||||
name: outcome
|
||||
schema:
|
||||
type: string
|
||||
enum: [pending, succeeded, partially_succeeded, blocked, failed]
|
||||
- in: query
|
||||
name: type
|
||||
schema:
|
||||
type: string
|
||||
- in: query
|
||||
name: tenant_id
|
||||
schema:
|
||||
type: integer
|
||||
- in: query
|
||||
name: freshness_state
|
||||
schema:
|
||||
type: string
|
||||
enum: [fresh_active, likely_stale, reconciled_failed, terminal_normal]
|
||||
- in: query
|
||||
name: reconciled
|
||||
schema:
|
||||
type: boolean
|
||||
responses:
|
||||
'200':
|
||||
description: Paginated collection of operation runs
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
required: [data]
|
||||
properties:
|
||||
data:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/OperationRunListItem'
|
||||
/api/admin/operations/{operationRun}:
|
||||
get:
|
||||
summary: Get one operation run with lifecycle reconciliation detail
|
||||
operationId: getOperationRun
|
||||
parameters:
|
||||
- in: path
|
||||
name: operationRun
|
||||
required: true
|
||||
schema:
|
||||
type: integer
|
||||
responses:
|
||||
'200':
|
||||
description: Operation run detail
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/OperationRunDetail'
|
||||
'404':
|
||||
description: Run not found or caller not entitled to the run scope
|
||||
components:
|
||||
schemas:
|
||||
OperationRunListItem:
|
||||
type: object
|
||||
required:
|
||||
- id
|
||||
- type
|
||||
- status
|
||||
- outcome
|
||||
- freshness_state
|
||||
- reconciled
|
||||
- created_at
|
||||
properties:
|
||||
id:
|
||||
type: integer
|
||||
type:
|
||||
type: string
|
||||
type_label:
|
||||
type: string
|
||||
status:
|
||||
type: string
|
||||
enum: [queued, running, completed]
|
||||
outcome:
|
||||
type: string
|
||||
enum: [pending, succeeded, partially_succeeded, blocked, failed]
|
||||
freshness_state:
|
||||
type: string
|
||||
enum: [fresh_active, likely_stale, reconciled_failed, terminal_normal]
|
||||
reconciled:
|
||||
type: boolean
|
||||
tenant_id:
|
||||
type: integer
|
||||
nullable: true
|
||||
initiator_name:
|
||||
type: string
|
||||
nullable: true
|
||||
operator_message:
|
||||
type: string
|
||||
description: Operator-safe summary line shown by default on Monitoring surfaces.
|
||||
created_at:
|
||||
type: string
|
||||
format: date-time
|
||||
started_at:
|
||||
type: string
|
||||
format: date-time
|
||||
nullable: true
|
||||
completed_at:
|
||||
type: string
|
||||
format: date-time
|
||||
nullable: true
|
||||
OperationRunDetail:
|
||||
allOf:
|
||||
- $ref: '#/components/schemas/OperationRunListItem'
|
||||
- type: object
|
||||
properties:
|
||||
summary_counts:
|
||||
type: object
|
||||
additionalProperties:
|
||||
type: number
|
||||
failure_summary:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/FailureSummaryItem'
|
||||
reconciliation:
|
||||
$ref: '#/components/schemas/ReconciliationRecord'
|
||||
diagnostics:
|
||||
type: object
|
||||
description: Secondary lifecycle evidence shown only when diagnostics are revealed.
|
||||
properties:
|
||||
threshold_seconds:
|
||||
type: integer
|
||||
evidence:
|
||||
type: object
|
||||
additionalProperties: true
|
||||
FailureSummaryItem:
|
||||
type: object
|
||||
required: [code, message]
|
||||
properties:
|
||||
code:
|
||||
type: string
|
||||
message:
|
||||
type: string
|
||||
ReconciliationRecord:
|
||||
type: object
|
||||
nullable: true
|
||||
properties:
|
||||
reconciled_at:
|
||||
type: string
|
||||
format: date-time
|
||||
source:
|
||||
type: string
|
||||
enum: [failed_callback, scheduled_reconciler, adapter_reconciler]
|
||||
reason_code:
|
||||
type: string
|
||||
reason_message:
|
||||
type: string
|
||||
evidence:
|
||||
type: object
|
||||
additionalProperties: true
|
||||
199
specs/160-operation-lifecycle-guarantees/data-model.md
Normal file
199
specs/160-operation-lifecycle-guarantees/data-model.md
Normal file
@ -0,0 +1,199 @@
|
||||
# Phase 1 Data Model: Operation Lifecycle Guarantees & Queue-to-Domain Failure Reconciliation
|
||||
|
||||
## Overview
|
||||
|
||||
This feature does not require a new database table in the first implementation slice. The primary data-model work is the formalization of existing `OperationRun` persistence plus new derived lifecycle-policy and freshness concepts that make queue truth, domain truth, and operator-visible truth converge deterministically.
|
||||
|
||||
## Persistent Domain Entities
|
||||
|
||||
### OperationRun
|
||||
|
||||
**Purpose**: Canonical workspace-scoped operational record for long-running, queued, scheduled, or otherwise operator-visible work.
|
||||
|
||||
**Key fields**:
|
||||
- `id`
|
||||
- `workspace_id`
|
||||
- `tenant_id` nullable
|
||||
- `user_id` nullable
|
||||
- `type`
|
||||
- `status` with canonical values `queued`, `running`, `completed`
|
||||
- `outcome` with canonical values including `pending`, `succeeded`, `partially_succeeded`, `blocked`, `failed`
|
||||
- `run_identity_hash`
|
||||
- `summary_counts` JSONB
|
||||
- `failure_summary` JSONB array
|
||||
- `context` JSONB
|
||||
- `started_at`
|
||||
- `completed_at`
|
||||
- `created_at`
|
||||
- `updated_at`
|
||||
|
||||
**Relationships**:
|
||||
- Belongs to one workspace
|
||||
- Optionally belongs to one tenant
|
||||
- Optionally belongs to one initiating user
|
||||
|
||||
**Validation rules relevant to this feature**:
|
||||
- `status` and `outcome` transitions remain service-owned via `OperationRunService`.
|
||||
- Non-terminal active runs remain constrained by the existing active-run unique index semantics.
|
||||
- `summary_counts` keys remain from the canonical summary key catalog and values remain numeric-only.
|
||||
- Reconciliation metadata must be stored in a standardized structure inside `context` and `failure_summary` without persisting secrets.
|
||||
|
||||
**State transitions relevant to this feature**:
|
||||
- `queued/pending` → `running/pending`
|
||||
- `queued/pending` → `completed/failed` when stale queued reconciliation or direct queue-failure bridging resolves an orphaned queued run
|
||||
- `running/pending` → `completed/succeeded|partially_succeeded|blocked|failed`
|
||||
- `running/pending` → `completed/failed` when stale running reconciliation resolves an orphaned active run
|
||||
- `completed/*` is terminal and must never be mutated by reconciliation
|
||||
|
||||
### Failed Job Record (`failed_jobs`)
|
||||
|
||||
**Purpose**: Infrastructure-level evidence that a queued job exhausted attempts, timed out, or otherwise failed.
|
||||
|
||||
**Key fields used conceptually**:
|
||||
- UUID or failed-job identifier
|
||||
- connection
|
||||
- queue
|
||||
- payload
|
||||
- exception
|
||||
- failed_at
|
||||
|
||||
**Relationships**:
|
||||
- Not directly related through a foreign key to `OperationRun`
|
||||
- Linked back to `OperationRun` through job-owned identity resolution or reconciliation evidence
|
||||
|
||||
**Validation rules relevant to this feature**:
|
||||
- A failed-job record is evidence, not operator-facing truth by itself.
|
||||
- Evidence may inform reconciliation or diagnostics but must not replace the domain transition on `OperationRun`.
|
||||
|
||||
### Queue Job Definition
|
||||
|
||||
**Purpose**: The queued class that owns or advances a covered `OperationRun`.
|
||||
|
||||
**Key lifecycle-relevant properties**:
|
||||
- `operationRun` reference or `getOperationRun()` contract
|
||||
- optional `$timeout`
|
||||
- optional `$failOnTimeout`
|
||||
- optional `$tries` or `retryUntil()`
|
||||
- `middleware()` including `TrackOperationRun` and other queue middleware
|
||||
- optional `failed(Throwable $e)` callback
|
||||
|
||||
**Validation rules relevant to this feature**:
|
||||
- Covered jobs must provide a credible path to terminal truth through direct failure bridging, fallback reconciliation, or both.
|
||||
- Covered long-running jobs must have intentional timeout behavior and must be compatible with queue timing invariants.
|
||||
|
||||
## New Derived Domain Objects
|
||||
|
||||
### OperationLifecyclePolicy
|
||||
|
||||
**Purpose**: Configuration-backed policy describing which operation types are in scope for V1 and how their lifecycle should be evaluated.
|
||||
|
||||
**Fields**:
|
||||
- `operationType`
|
||||
- `covered` boolean
|
||||
- `queuedStaleAfterSeconds`
|
||||
- `runningStaleAfterSeconds`
|
||||
- `expectedMaxRuntimeSeconds`
|
||||
- `requiresDirectFailedBridge` boolean
|
||||
- `supportsReconciliation` boolean
|
||||
|
||||
**Validation rules**:
|
||||
- `queuedStaleAfterSeconds` and `runningStaleAfterSeconds` must be positive integers.
|
||||
- `expectedMaxRuntimeSeconds` must stay below effective queue `retry_after` with safety margin.
|
||||
- Only covered operation types participate in the generic reconciler for V1.
|
||||
|
||||
### OperationRunFreshnessAssessment
|
||||
|
||||
**Purpose**: Derived classification used by reconcilers and Monitoring surfaces to determine whether a non-terminal run is still trustworthy as active.
|
||||
|
||||
**Fields**:
|
||||
- `operationRunId`
|
||||
- `status`
|
||||
- `freshnessState` with canonical values `fresh`, `likely_stale`, `terminal`, `unknown`
|
||||
- `evaluatedAt`
|
||||
- `thresholdSeconds`
|
||||
- `evidence` key-value map
|
||||
|
||||
**Behavior**:
|
||||
- For `queued` runs, freshness is typically derived from `created_at`, absence of `started_at`, and policy threshold.
|
||||
- For `running` runs, freshness is derived from `started_at`, last meaningful update evidence available in persisted state, and policy threshold.
|
||||
- `completed` runs always assess as terminal and are excluded from stale reconciliation.
|
||||
|
||||
### LifecycleReconciliationRecord
|
||||
|
||||
**Purpose**: Structured reconciliation evidence stored in `OperationRun.context` and mirrored in `failure_summary`.
|
||||
|
||||
**Fields**:
|
||||
- `reconciledAt`
|
||||
- `reconciliationKind` such as `stale_queued`, `stale_running`, `queue_failure_bridge`, `adapter_sync`
|
||||
- `reasonCode`
|
||||
- `reasonMessage`
|
||||
- `evidence` key-value map
|
||||
- `source` such as `failed_callback`, `scheduled_reconciler`, `adapter_reconciler`
|
||||
|
||||
**Validation rules**:
|
||||
- Must only be added when the feature force-resolves or directly bridges a run.
|
||||
- Must be idempotent; repeat reconciliation must not append conflicting terminal truth.
|
||||
- Must be operator-safe and sanitized.
|
||||
|
||||
### OperationQueueFailureBridge
|
||||
|
||||
**Purpose**: Derived mapping between a queued job failure and the owning `OperationRun`.
|
||||
|
||||
**Fields**:
|
||||
- `operationRunId`
|
||||
- `jobClass`
|
||||
- `bridgeSource` such as `failed_callback` or `reconciler`
|
||||
- `exceptionClass`
|
||||
- `reasonCode`
|
||||
- `terminalOutcome`
|
||||
|
||||
**Behavior**:
|
||||
- Exists conceptually as a design contract, not necessarily as a standalone stored table.
|
||||
- Bridges queue truth into service-owned `OperationRun` terminal transitions.
|
||||
|
||||
## Supporting Catalogs
|
||||
|
||||
### Reconciliation Reason Codes
|
||||
|
||||
**Purpose**: Stable reason-code catalog for lifecycle healing.
|
||||
|
||||
**Initial values**:
|
||||
- `run.stale_queued`
|
||||
- `run.stale_running`
|
||||
- `run.infrastructure_timeout_or_abandonment`
|
||||
- `run.queue_failure_bridge`
|
||||
- `run.adapter_out_of_sync`
|
||||
|
||||
**Validation rules**:
|
||||
- Operator-facing text must be derived from centralized presenters or reason translation helpers.
|
||||
- Codes remain stable enough for regression assertions and audit review.
|
||||
|
||||
### Monitoring Freshness State
|
||||
|
||||
**Purpose**: Derived presentation state for Operations surfaces.
|
||||
|
||||
**Initial values**:
|
||||
- `fresh_active`
|
||||
- `likely_stale`
|
||||
- `reconciled_failed`
|
||||
- `terminal_normal`
|
||||
|
||||
**Behavior**:
|
||||
- Not stored as a new top-level database enum in V1.
|
||||
- Derived centrally so tables, detail pages, and notifications do not drift.
|
||||
|
||||
## Consumer Mapping
|
||||
|
||||
| Consumer | Primary data it needs |
|
||||
|---|---|
|
||||
| Generic lifecycle reconciler | Covered operation policy, active non-terminal runs, freshness assessment, standardized reconciliation transition |
|
||||
| Covered queued jobs | Owning `OperationRun`, timeout behavior, direct `failed()` bridge path |
|
||||
| Operations index | Current status, outcome, freshness assessment, reconciliation evidence summary |
|
||||
| Operation run detail | Full reconciliation record, translated reason, run timing, summary counts, failure details |
|
||||
| Runtime invariant validation | Queue connection `retry_after`, effective job timeout, covered operation lifecycle policy |
|
||||
|
||||
## Migration Notes
|
||||
|
||||
- No schema migration is required for the first implementation slice.
|
||||
- Existing `context` and `failure_summary` structures should be normalized for reconciliation evidence rather than replaced.
|
||||
- If later observability needs require indexed reconciliation metrics, a follow-up slice can promote reconciliation metadata into first-class columns or projections.
|
||||
212
specs/160-operation-lifecycle-guarantees/plan.md
Normal file
212
specs/160-operation-lifecycle-guarantees/plan.md
Normal file
@ -0,0 +1,212 @@
|
||||
# Implementation Plan: Operation Lifecycle Guarantees & Queue-to-Domain Failure Reconciliation
|
||||
|
||||
**Branch**: `160-operation-lifecycle-guarantees` | **Date**: 2026-03-23 | **Spec**: [specs/160-operation-lifecycle-guarantees/spec.md](./spec.md)
|
||||
**Input**: Feature specification from `/specs/160-operation-lifecycle-guarantees/spec.md`
|
||||
|
||||
**Note**: This template is filled in by the `/speckit.plan` command. See `.specify/scripts/` for helper scripts.
|
||||
|
||||
## Summary
|
||||
|
||||
Guarantee eventual terminal truth for covered queued `OperationRun` executions by hardening three seams that already exist but are incomplete: service-owned lifecycle transitions, queue-failure bridging, and stale-run healing. The implementation will keep the existing `queued` → `running` → `completed` lifecycle model, extend `OperationRunService` with generic stale-running reconciliation and structured reconciliation metadata, introduce a configuration-backed lifecycle policy for covered operation types and timing thresholds, add reusable failed-job bridging for priority queued jobs, and expose freshness or reconciliation semantics on the existing Monitoring surfaces without changing the overall Operations information architecture.
|
||||
|
||||
This is a reliability-hardening feature, not a queue-platform rewrite. The plan therefore avoids a new orchestration subsystem, avoids a second run-state model, and avoids a resumability design. Instead it builds on current repo seams such as `OperationRunService`, `TrackOperationRun`, the existing restore adapter reconciler, the backup-schedule reconcile command, `OperationUxPresenter`, centralized badge rendering, and the canonical Monitoring pages.
|
||||
|
||||
## Technical Context
|
||||
|
||||
**Language/Version**: PHP 8.4.15
|
||||
**Primary Dependencies**: Laravel 12, Filament 5, Livewire 4, Pest 4, Laravel queue workers, existing `OperationRunService`, `TrackOperationRun`, `OperationUxPresenter`, `ReasonPresenter`, `BadgeCatalog` domain badges, and current Operations Monitoring pages
|
||||
**Storage**: 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`
|
||||
**Testing**: Pest 4 feature, unit, Filament or Livewire-focused tests, and focused queue interaction tests run through Laravel Sail
|
||||
**Target Platform**: Laravel Sail web application serving the Filament admin panel and queued worker processes
|
||||
**Project Type**: Laravel monolith web application
|
||||
**Performance Goals**: Keep Monitoring render DB-only; ensure reconciliation scans active runs without material query explosion; guarantee deterministic convergence to terminal truth within scheduler cadence; avoid regressions in active-run list responsiveness
|
||||
**Constraints**: Preserve service-owned `OperationRun` transitions, existing `queued`/`running`/`completed` statuses, existing outcome enums, and exactly-three-surface Ops-UX feedback; keep `retry_after` greater than job timeouts with safety margin; no new external calls during Monitoring render; no new panel provider or asset pipeline changes; destructive operations remain confirmation-backed under their originating features
|
||||
**Scale/Scope**: V1 covers the exact operator-visible queued run types `baseline_capture`, `baseline_compare`, `inventory_sync`, `policy.sync`, `policy.sync_one`, `entra_group_sync`, `directory_role_definitions.sync`, `backup_schedule_run`, `restore.execute`, `tenant.review_pack.generate`, `tenant.review.compose`, and `tenant.evidence.snapshot.generate`, plus the shared queue-lifecycle infrastructure those types depend on
|
||||
|
||||
## Constitution Check
|
||||
|
||||
*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.*
|
||||
|
||||
**Pre-Phase 0 Gate: PASS**
|
||||
|
||||
- Inventory-first: PASS. The feature does not alter inventory-versus-snapshot ownership and only hardens operational truth for queued work that already exists.
|
||||
- Read/write separation: PASS. No new remote write workflow is introduced. Existing write-capable operations such as restore keep their preview, confirmation, audit, and authorization requirements from their originating specs.
|
||||
- Graph contract path: PASS. No new Microsoft Graph contract or endpoint is introduced by this feature.
|
||||
- Deterministic capabilities: PASS. Existing operation start permissions and any dangerous follow-up actions remain tied to the canonical capability registry.
|
||||
- RBAC-UX planes: PASS. This feature stays in the admin `/admin` plane. Cross-plane behavior remains unchanged. Canonical Monitoring routes remain tenant-safe.
|
||||
- Workspace isolation: PASS. `OperationRun` remains workspace-scoped with optional tenant linkage, and workspace-level Monitoring continues to authorize from the run and its related workspace.
|
||||
- Tenant isolation: PASS. Tenant-linked runs shown on canonical Monitoring routes still require tenant entitlement and must remain 404 for non-entitled users.
|
||||
- Destructive confirmation: PASS. No new destructive action is added in V1. Existing destructive actions such as restore remain `->action(...)->requiresConfirmation()` and server-authorized.
|
||||
- Global search safety: PASS. This feature does not introduce or alter globally searchable resources. Existing global-search page contracts remain unchanged.
|
||||
- Run observability and Ops-UX: PASS WITH REQUIRED HARDENING. The feature strengthens the existing `OperationRun` contract instead of bypassing it. Start surfaces remain enqueue-only. Monitoring remains DB-only. Terminal truth remains service-owned.
|
||||
- Ops-UX lifecycle ownership: PASS. Research confirms `OperationRunService` is already the canonical transition path and the plan preserves that boundary.
|
||||
- Ops-UX summary counts: PASS. No new summary-count shape is introduced; numeric-only summary rules remain unchanged.
|
||||
- Ops-UX guards: PASS WITH REQUIRED TEST UPDATES. The feature must extend regression guards to cover reconciliation transitions and failed-job bridging without permitting direct status mutation.
|
||||
- Ops-UX system runs: PASS. Initiator-null behavior remains unchanged; reconciled system runs remain auditable via Monitoring without terminal DB notification fan-out.
|
||||
- Automation and idempotency: PASS WITH REQUIRED EXPANSION. Existing active-run dedupe and `WithoutOverlapping` patterns can be reused, but the plan adds generic stale reconciliation and queue-failure bridging for more run types.
|
||||
- Badge semantics (BADGE-001): PASS WITH REQUIRED CENTRALIZATION. Freshness and reconciliation display must be derived through centralized presenters or badges rather than ad hoc table mappings.
|
||||
- UI naming (UI-NAMING-001): PASS. Operator-facing copy will use domain terms such as `stale`, `reconciled`, and `infrastructure failure`; low-level queue exceptions remain diagnostics-only.
|
||||
- Operator surfaces (OPSURF-001): PASS. The Operations index and run detail remain the canonical operator-first surfaces. Diagnostics stay secondary. No new dangerous UI workflow is introduced.
|
||||
- Filament Action Surface Contract: PASS. The feature changes semantics on existing Monitoring surfaces, not their action inventory.
|
||||
- Filament UX-001: PASS. Existing Operations pages remain in place and only gain new status semantics and diagnostics.
|
||||
- Livewire and Filament version safety: PASS. The plan remains Livewire v4 compliant and does not change Filament v5 panel registration in `bootstrap/providers.php`.
|
||||
- Asset strategy: PASS. No new global or panel-specific assets are planned, so deployment steps for `filament:assets` remain unchanged.
|
||||
|
||||
## Project Structure
|
||||
|
||||
### Documentation (this feature)
|
||||
|
||||
```text
|
||||
specs/160-operation-lifecycle-guarantees/
|
||||
├── plan.md
|
||||
├── research.md
|
||||
├── data-model.md
|
||||
├── quickstart.md
|
||||
├── contracts/
|
||||
│ └── operation-run-lifecycle.openapi.yaml
|
||||
└── tasks.md
|
||||
```
|
||||
|
||||
### Source Code (repository root)
|
||||
|
||||
```text
|
||||
app/
|
||||
├── Console/
|
||||
│ └── Commands/
|
||||
├── Filament/
|
||||
│ ├── Pages/
|
||||
│ │ └── Monitoring/
|
||||
│ ├── Resources/
|
||||
│ │ └── OperationRunResource.php
|
||||
│ └── Widgets/
|
||||
│ └── Operations/
|
||||
├── Jobs/
|
||||
│ └── Middleware/
|
||||
├── Models/
|
||||
│ └── OperationRun.php
|
||||
├── Notifications/
|
||||
├── Policies/
|
||||
│ └── OperationRunPolicy.php
|
||||
├── Services/
|
||||
│ ├── Operations/
|
||||
│ ├── Providers/
|
||||
│ ├── SystemConsole/
|
||||
│ └── OperationRunService.php
|
||||
└── Support/
|
||||
├── Badges/
|
||||
├── OpsUx/
|
||||
├── OperationRun*.php
|
||||
└── ReasonTranslation/
|
||||
config/
|
||||
├── queue.php
|
||||
└── tenantpilot.php
|
||||
routes/
|
||||
└── web.php
|
||||
tests/
|
||||
├── Feature/
|
||||
│ ├── Operations/
|
||||
│ ├── Filament/
|
||||
│ └── Rbac/
|
||||
└── Unit/
|
||||
├── Jobs/
|
||||
├── Operations/
|
||||
└── Support/
|
||||
```
|
||||
|
||||
**Structure Decision**: Use the existing Laravel monolith and strengthen the current operational support layer rather than introducing a separate orchestration subsystem. The implementation seam stays centered on `OperationRunService`, queue job classes and middleware under `app/Jobs`, generic reconciliation services or commands under `app/Services` and `app/Console/Commands`, and semantic presentation on the existing Monitoring pages, presenters, and badges.
|
||||
|
||||
## Phase 0 Research Summary
|
||||
|
||||
- `TrackOperationRun` correctly transitions a run to `running`, catches in-handle exceptions, and marks success when the job returns cleanly, but it does not protect against failures that happen before middleware entry or after infrastructure has already declared the job dead.
|
||||
- `OperationRunService` already owns transitions and already has reusable stale-queued detection via `isStaleQueuedRun()` plus `failStaleQueuedRun()`, but there is no generic stale-running detection or stale-running fail helper.
|
||||
- The repo already has two partial reconciliation patterns: `TenantpilotReconcileBackupScheduleOperationRuns` for `backup_schedule_run` and `AdapterRunReconciler` plus `ops:reconcile-adapter-runs` for `restore.execute`. Both prove the repo accepts DB-driven healing, but both are too type-specific for Spec 160.
|
||||
- Research confirmed only one queued job currently implements a `failed(Throwable $e)` bridge: `BulkBackupSetRestoreJob`. Priority operation jobs such as baseline capture, baseline compare, inventory sync, policy sync, restore execution, and tenant review generation do not yet bridge failed queue truth back to `OperationRun` through `failed()`.
|
||||
- Queue timing defaults currently use `retry_after = 600` seconds for database, Redis, and Beanstalk connections, while at least some long-running jobs declare `public int $timeout = 300;`. The repo does not yet centralize or validate the timing invariant per covered operation type.
|
||||
- Laravel queue documentation for version 12 confirms the relevant design rules: `failed()` is invoked when a job exhausts attempts or times out, the exception may be `MaxAttemptsExceededException` or `TimeoutExceededException`, attempts can be consumed without `handle()` executing, and job timeout must remain shorter than `retry_after` to avoid duplicate or orphaned processing.
|
||||
- Existing operator-facing Monitoring already has reusable seams for safe UI semantics: `OperationUxPresenter`, `ReasonPresenter`, `OperationRunStatusBadge`, `OperationRunOutcomeBadge`, and canonical Operations pages. These should be extended rather than bypassed.
|
||||
- Existing `OperationRunTriageService` already proves the repo is willing to store triage metadata in `context['triage']` and keep service-owned terminal transitions. The same pattern can be reused for reconciliation metadata without requiring a schema change in the first slice.
|
||||
|
||||
## Phase 1 Design
|
||||
|
||||
### Implementation Approach
|
||||
|
||||
1. Extend the existing `OperationRunService` into a generic lifecycle-healing seam.
|
||||
- Add generic running-freshness assessment parallel to `isStaleQueuedRun()`.
|
||||
- Add a service-owned stale-running failure transition parallel to `failStaleQueuedRun()`.
|
||||
- Normalize reconciliation metadata and reason codes through the same service-owned transition path.
|
||||
|
||||
2. Introduce a configuration-backed lifecycle policy for covered operation types.
|
||||
- Define which run types are in scope for V1.
|
||||
- Define status-specific stale thresholds and expected timeout bounds per type.
|
||||
- Record for each covered type whether terminal truth is guaranteed by a direct failed-job bridge, scheduled reconciliation, or both.
|
||||
- Keep this policy in configuration rather than a new table so rollout remains schema-light.
|
||||
|
||||
3. Use a layered truth-bridge strategy instead of a single mechanism.
|
||||
- First-line bridge: reusable `failed(Throwable $e)` handling for covered jobs that can deterministically resolve their owning `OperationRun`.
|
||||
- Safety-net bridge: scheduled stale-run reconciliation that heals queued or running runs when direct failure handling never executes.
|
||||
- Reject a failed-jobs-parser-first design for V1 because payload parsing is brittle compared with job-owned identity plus stale reconciliation.
|
||||
|
||||
4. Generalize current type-specific reconcile patterns into one lifecycle reconciler.
|
||||
- Keep restore adapter reconciliation and backup schedule reconciliation as precedents.
|
||||
- Introduce a generic active-run reconciliation service or command that scans covered `queued` and `running` runs, evaluates freshness, and force-resolves only when evidence justifies it.
|
||||
- Preserve idempotency so repeated runs do not overwrite already terminal records.
|
||||
|
||||
5. Keep the current status model and enrich semantics through derived freshness state.
|
||||
- Preserve `queued`, `running`, and `completed` plus existing outcomes.
|
||||
- Introduce derived freshness states such as `fresh`, `likely_stale`, and `reconciled_failed` at the presenter and contract layer, not as new top-level database statuses.
|
||||
- Centralize that mapping through existing Ops-UX and badge helpers.
|
||||
|
||||
6. Validate runtime timing as part of product correctness.
|
||||
- Capture the invariant that worker timeout and per-job timeout must stay below queue `retry_after` with safety margin.
|
||||
- Add focused validation or tests that fail when a covered lifecycle policy violates that invariant.
|
||||
- Keep deployment documentation explicit about queue worker restart and stop-wait expectations.
|
||||
|
||||
### Planned Workstreams
|
||||
|
||||
- **Workstream A: Lifecycle policy core**
|
||||
- Add a configuration-backed coverage and threshold registry for V1 operation types.
|
||||
- Extend `OperationRunService` with generic stale-running assessment and reconciliation helpers.
|
||||
|
||||
- **Workstream B: Queue-failure bridges**
|
||||
- Introduce a reusable failed-job bridge pattern for priority covered jobs.
|
||||
- Normalize the existing restore bridge and apply the direct bridge pattern first to baseline capture, baseline compare, inventory sync, policy sync, and tenant review composition paths while the lifecycle policy explicitly marks which covered types rely on scheduled reconciliation.
|
||||
|
||||
- **Workstream C: Generic stale-run reconciliation**
|
||||
- Create a lifecycle reconciler service and scheduled command for covered `queued` and `running` runs.
|
||||
- Fold current type-specific healing logic into reusable primitives where possible.
|
||||
|
||||
- **Workstream D: Monitoring semantics**
|
||||
- Extend existing presenter and badge seams to distinguish fresh active, likely stale, and reconciled-failed semantics.
|
||||
- Update the Operations index and run detail to surface those meanings without changing route authority or action inventory.
|
||||
- Expose minimal aggregate reconciliation visibility through Monitoring filters or queryable summaries instead of a new dashboard.
|
||||
|
||||
- **Workstream E: Runtime invariant enforcement**
|
||||
- Add validation for timeout and `retry_after` alignment and document deployment expectations for workers.
|
||||
- Keep the validation focused on covered operation types rather than every queued class in the repo.
|
||||
|
||||
- **Workstream F: Regression hardening**
|
||||
- Add focused Pest coverage for stale queued reconciliation, stale running reconciliation, idempotency, failed-job bridging, UI truth semantics, and queue timing guards.
|
||||
|
||||
### Testing Strategy
|
||||
|
||||
- Add unit tests for `OperationRunService` stale-running assessment and stale-running failure transitions alongside existing stale-queued behavior.
|
||||
- Add unit or feature tests for the generic lifecycle reconciler covering stale queued runs, stale running runs, fresh queued runs, fresh running runs, already completed runs, and idempotent repeat execution.
|
||||
- Add focused queue-lifecycle tests proving that a covered job with a `failed()` callback transitions the owning run to terminal failed truth via `OperationRunService`.
|
||||
- Add a Run-126-style regression test where a run is left `running`, normal finalization never executes, time advances beyond threshold, and reconciliation marks it terminal failed.
|
||||
- Add Monitoring-focused Filament or feature tests proving the Operations index and run detail distinguish fresh activity from stale or reconciled failure semantics without implying indefinite active progress.
|
||||
- Add focused Monitoring coverage for minimal aggregate reconciliation visibility, such as filters or summaries that show how many runs were reconciled and which covered types are most affected.
|
||||
- Add focused authorization coverage to confirm canonical Monitoring access remains 404 for non-entitled users and capability denial remains 403 where relevant.
|
||||
- Add timing guard tests or assertions that fail when covered lifecycle policy timeouts are not safely below `retry_after`.
|
||||
- Add representative start-surface coverage proving queued intent still uses `OperationUxPresenter`, and all `View run` affordances still resolve to the canonical tenantless Monitoring run detail.
|
||||
- Run the minimum focused Pest suite through Sail; full-suite execution is not required for planning artifacts.
|
||||
|
||||
**Post-Phase 1 Re-check: PASS**
|
||||
|
||||
- The design extends existing service, presenter, and command seams instead of introducing a second orchestration stack.
|
||||
- No new panel provider, no Livewire version change, and no Filament asset change is required; provider registration remains unchanged in `bootstrap/providers.php`.
|
||||
- No new global-search behavior is introduced. Existing globally searchable resources remain governed by their current View or Edit page contracts.
|
||||
- Destructive actions remain confirmation-backed under existing feature flows; this feature only hardens eventual run truth.
|
||||
|
||||
## Complexity Tracking
|
||||
|
||||
No constitution violations or exceptional complexity are planned at this stage.
|
||||
105
specs/160-operation-lifecycle-guarantees/quickstart.md
Normal file
105
specs/160-operation-lifecycle-guarantees/quickstart.md
Normal file
@ -0,0 +1,105 @@
|
||||
# Quickstart: Operation Lifecycle Guarantees & Queue-to-Domain Failure Reconciliation
|
||||
|
||||
## Goal
|
||||
|
||||
Validate that covered queued `OperationRun` executions always converge to trustworthy terminal truth and that Monitoring surfaces no longer imply indefinite normal activity for orphaned runs.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
1. Start Sail.
|
||||
2. Ensure the queue worker is running through Sail.
|
||||
3. Ensure the database contains at least one workspace with operator-visible operation runs for covered types.
|
||||
4. Ensure test fixtures or factories can create `OperationRun` records in `queued`, `running`, and `completed` states.
|
||||
|
||||
## Implementation Validation Order
|
||||
|
||||
### 1. Run focused lifecycle service tests
|
||||
|
||||
```bash
|
||||
vendor/bin/sail artisan test --compact --filter=OperationRunService
|
||||
```
|
||||
|
||||
Expected outcome:
|
||||
- Stale queued reconciliation still works.
|
||||
- Stale running reconciliation is added and service-owned.
|
||||
- Terminal runs are not mutated.
|
||||
|
||||
### 2. Run focused reconciler tests
|
||||
|
||||
```bash
|
||||
vendor/bin/sail artisan test --compact --filter=LifecycleReconciler
|
||||
vendor/bin/sail artisan test --compact --filter=stale
|
||||
```
|
||||
|
||||
Expected outcome:
|
||||
- Stale queued runs are force-resolved to `completed/failed`.
|
||||
- Stale running runs are force-resolved to `completed/failed`.
|
||||
- Fresh runs remain untouched.
|
||||
- Reconciliation is idempotent across repeated execution.
|
||||
|
||||
### 3. Run focused failed-job bridge tests
|
||||
|
||||
```bash
|
||||
vendor/bin/sail artisan test --compact --filter=failed
|
||||
vendor/bin/sail artisan test --compact --filter=MaxAttempts
|
||||
vendor/bin/sail artisan test --compact --filter=TimeoutExceeded
|
||||
```
|
||||
|
||||
Expected outcome:
|
||||
- Covered jobs with direct `failed()` bridges map queue failure truth back to `OperationRun`.
|
||||
- Queue failures that never complete normal middleware finalization still converge through reconciliation.
|
||||
|
||||
### 4. Run the Run-126 regression scenario
|
||||
|
||||
```bash
|
||||
vendor/bin/sail artisan test --compact --filter=Run126
|
||||
vendor/bin/sail artisan test --compact --filter=orphaned
|
||||
```
|
||||
|
||||
Expected outcome:
|
||||
- A run left in `running` without `completeRun()` or `failRun()` is marked terminal failed once the stale threshold is exceeded.
|
||||
- The operator-facing state no longer implies normal active work.
|
||||
|
||||
### 5. Run focused Monitoring UX tests
|
||||
|
||||
```bash
|
||||
vendor/bin/sail artisan test --compact tests/Feature/Operations
|
||||
vendor/bin/sail artisan test --compact --filter=Operations
|
||||
```
|
||||
|
||||
Expected outcome:
|
||||
- The Operations index distinguishes fresh activity from stale or reconciled failure semantics.
|
||||
- The run detail distinguishes normal failure from reconciled lifecycle failure.
|
||||
- Canonical Monitoring authorization semantics remain intact.
|
||||
|
||||
### 6. Run runtime timing guard tests
|
||||
|
||||
```bash
|
||||
vendor/bin/sail artisan test --compact --filter=retry_after
|
||||
vendor/bin/sail artisan test --compact --filter=timeout
|
||||
```
|
||||
|
||||
Expected outcome:
|
||||
- Covered lifecycle policy timeouts stay safely below effective `retry_after`.
|
||||
- Misaligned timing assumptions fail validation instead of remaining implicit.
|
||||
|
||||
## Runtime notes
|
||||
|
||||
- Covered lifecycle jobs now declare explicit `timeout` values and set `failOnTimeout = true`.
|
||||
- The lifecycle validator expects covered job timeouts and expected runtimes to stay below queue `retry_after` with a safety margin.
|
||||
- If queue worker settings change during rollout, run `vendor/bin/sail artisan queue:restart` so workers pick up the new lifecycle contract.
|
||||
- Production and staging stop-wait expectations must stay above the longest covered timeout so workers can exit cleanly instead of orphaning in-flight runs.
|
||||
|
||||
### 7. Manual smoke-check in the browser
|
||||
|
||||
1. Open `/admin/operations` and inspect a fresh active run.
|
||||
2. Inspect a deliberately stale or reconciled run and confirm the list no longer presents it as ordinary in-progress work.
|
||||
3. Open `/admin/operations/{run}` for a reconciled run and confirm the detail page shows operator-safe lifecycle explanation plus secondary diagnostics.
|
||||
4. Confirm existing `View run` navigation remains canonical and no new destructive action is introduced.
|
||||
|
||||
## Non-Goals For This Slice
|
||||
|
||||
- No resumable execution or checkpoint recovery.
|
||||
- No queue backend replacement or Horizon adoption.
|
||||
- No new manual retry or re-drive UI.
|
||||
- No new `OperationRun` status enum.
|
||||
58
specs/160-operation-lifecycle-guarantees/research.md
Normal file
58
specs/160-operation-lifecycle-guarantees/research.md
Normal file
@ -0,0 +1,58 @@
|
||||
# Phase 0 Research: Operation Lifecycle Guarantees & Queue-to-Domain Failure Reconciliation
|
||||
|
||||
## Decision: Extend the existing `OperationRunService` and reconciliation seams instead of creating a new orchestration subsystem
|
||||
|
||||
**Rationale**: The repo already treats `OperationRunService` as the canonical owner of lifecycle transitions and already contains two partial healing patterns: stale queued reconciliation in `OperationRunService`, plus type-specific reconciliation for backup schedule runs and restore adapter-backed runs. Extending these seams keeps lifecycle truth service-owned, avoids a second state machine, and aligns with the constitution rule that `OperationRun.status` and `OperationRun.outcome` transitions remain centralized.
|
||||
|
||||
**Alternatives considered**:
|
||||
- Create a new orchestration or workflow engine for all queued operations: rejected because Spec 160 is a reliability-hardening feature, not a queue-platform redesign.
|
||||
- Let each operation type keep bespoke reconcile code forever: rejected because the current type-by-type pattern already left major gaps, including the Run-126 class of failure.
|
||||
|
||||
## Decision: Use a layered truth-bridge strategy: direct `failed()` callbacks for covered jobs plus scheduled stale-run reconciliation as the safety net
|
||||
|
||||
**Rationale**: Laravel queue documentation confirms that `failed()` is invoked for jobs that exhaust attempts or time out, even when the final exception is `MaxAttemptsExceededException` or `TimeoutExceededException`. That makes job-owned `failed()` bridging the cleanest direct path for covered jobs that can resolve their owning `OperationRun`. However, process death, worker kill, or other infrastructure interruption can still prevent any callback from running. Scheduled stale-run reconciliation is therefore still required as the final guarantee.
|
||||
|
||||
**Alternatives considered**:
|
||||
- Rely only on `TrackOperationRun`: rejected because middleware cannot handle failures that occur before the middleware pipeline is entered or after the infrastructure already declared the job failed.
|
||||
- Rely only on stale-run reconciliation: rejected because direct failure bridging gives faster truth convergence and better structured failure reasons when the queue does provide a terminal failure callback.
|
||||
- Parse `failed_jobs` records as the primary bridge: rejected for V1 because payload parsing and job-class introspection are more brittle than job-owned identity plus domain reconciliation.
|
||||
|
||||
## Decision: Preserve the existing `queued` / `running` / `completed` lifecycle model and add derived freshness semantics instead of new top-level statuses
|
||||
|
||||
**Rationale**: The current domain model already separates lifecycle state from execution outcome. The real gap is not missing statuses but missing convergence and missing freshness interpretation. Preserving the existing model avoids broad downstream breakage while still allowing the UI and contracts to distinguish fresh active work, likely stale work, and reconciled failure through centralized presenters and reason codes.
|
||||
|
||||
**Alternatives considered**:
|
||||
- Add new top-level statuses such as `stale` or `reconciled`: rejected because this would spread persistence and presentation changes across the codebase for limited benefit.
|
||||
- Leave stale interpretation implicit: rejected because operators need explicit liveness truth and tests need stable semantics.
|
||||
|
||||
## Decision: Define V1 coverage and stale thresholds through configuration-backed lifecycle policy, not ad hoc hardcoded checks
|
||||
|
||||
**Rationale**: The repo already has one hardcoded stale-queued default and one type-specific backup-schedule reconcile command. Spec 160 needs explicit V1 coverage, status-specific thresholds, and timing expectations across multiple run types. Configuration-backed lifecycle policy keeps the first slice schema-light, auditable, and easier to validate in tests while preventing logic from being scattered across multiple jobs and commands.
|
||||
|
||||
**Alternatives considered**:
|
||||
- Keep a single global stale threshold for every operation type: rejected because long-running baseline or report jobs and shorter sync jobs have different legitimate runtime envelopes.
|
||||
- Store lifecycle policy in a new database table: rejected for V1 because rollout speed and deterministic config review matter more than runtime mutability.
|
||||
|
||||
## Decision: Store reconciliation evidence in existing `context` and `failure_summary` structures for the first slice
|
||||
|
||||
**Rationale**: `OperationRun` already stores structured JSONB context and failure arrays, and existing triage flows already record structured metadata under `context['triage']`. Reusing these structures keeps the first slice migration-free while still allowing operator-safe explanation, auditability, and later observability extraction. The key requirement is to standardize the reconciliation metadata shape and reason codes.
|
||||
|
||||
**Alternatives considered**:
|
||||
- Add top-level `reconciled_at` and `reconciliation_reason` columns immediately: rejected for V1 because the repo already has a structured metadata pattern that can support the feature without schema churn.
|
||||
- Store only a free-text failure message: rejected because stable reason codes and timestamps are required for auditability and future metrics.
|
||||
|
||||
## Decision: Reuse centralized Ops-UX and badge presentation seams for stale and reconciled semantics
|
||||
|
||||
**Rationale**: The repo already centralizes run-facing language through `OperationUxPresenter`, `ReasonPresenter`, and badge domain helpers for status and outcome. Extending those seams keeps stale or reconciled semantics consistent across the Operations index, run detail, and notifications while honoring BADGE-001 and UI-NAMING-001. It also avoids ad hoc table-only mappings that would drift.
|
||||
|
||||
**Alternatives considered**:
|
||||
- Add custom inline labels only on the Operations table: rejected because run detail, widgets, and notifications would then drift semantically.
|
||||
- Surface low-level queue exceptions directly in primary badges: rejected because operator-facing copy must stay domain-safe and infrastructure details must remain diagnostics-only.
|
||||
|
||||
## Decision: Treat queue timing alignment as product correctness and validate it explicitly
|
||||
|
||||
**Rationale**: Current queue connections use `retry_after = 600`, while at least some covered jobs explicitly set `$timeout = 300`. Laravel documentation is explicit that job timeout must remain shorter than `retry_after`; otherwise, the same job may be retried before the worker times out. Spec 160 is driven by exactly this truth-divergence problem, so timing alignment must become a documented and testable lifecycle invariant rather than an informal deployment note.
|
||||
|
||||
**Alternatives considered**:
|
||||
- Document timing rules only in deployment notes: rejected because silent drift in job or worker settings would recreate the same incident class.
|
||||
- Validate only global worker timeout and ignore per-job timeout: rejected because covered jobs already override timeout values and the invariant has to hold at the job-policy level.
|
||||
200
specs/160-operation-lifecycle-guarantees/spec.md
Normal file
200
specs/160-operation-lifecycle-guarantees/spec.md
Normal file
@ -0,0 +1,200 @@
|
||||
# Feature Specification: Operation Lifecycle Guarantees & Queue-to-Domain Failure Reconciliation
|
||||
|
||||
**Feature Branch**: `160-operation-lifecycle-guarantees`
|
||||
**Created**: 2026-03-23
|
||||
**Status**: Draft
|
||||
**Input**: User description: "Introduce explicit lifecycle guarantees for queued `OperationRun` executions so no operation can remain indefinitely ambiguous, orphaned, or misleadingly active without the platform eventually forcing a deterministic terminal truth."
|
||||
|
||||
## Spec Scope Fields *(mandatory)*
|
||||
|
||||
- **Scope**: workspace
|
||||
- **Primary Routes**:
|
||||
- `/admin/operations`
|
||||
- `/admin/operations/{run}`
|
||||
- Existing operator-facing start surfaces that enqueue covered `OperationRun` work, including baseline capture, baseline compare, inventory sync, policy sync, Entra group sync, directory role-definition sync, backup schedule execution, restore execution, review pack generation, tenant review composition, and evidence snapshot generation flows
|
||||
- **Data Ownership**:
|
||||
- `OperationRun` remains the canonical operational record for queued, operator-visible work.
|
||||
- `OperationRun` remains a workspace-scoped monitoring record with optional tenant linkage for tenant-bound runs.
|
||||
- Queue infrastructure evidence remains operational support data and must be mappable back to the owning `OperationRun` without changing workspace or tenant ownership boundaries.
|
||||
- Reconciliation outcomes, failure reasons, and freshness semantics remain part of the same operational truth model rather than a separate shadow state.
|
||||
- **RBAC**:
|
||||
- Authorization plane involved: admin `/admin` including workspace-level Monitoring and tenant-context entry points that can start or inspect covered operations.
|
||||
- Non-members or actors lacking workspace or tenant entitlement for a run receive deny-as-not-found behavior.
|
||||
- Members who are allowed to view a run but lack capability to start or re-trigger a dangerous operation receive forbidden behavior.
|
||||
- Existing capability registry remains canonical for operation start permissions and any dangerous follow-up actions.
|
||||
- This feature does not broaden `/system` access and does not weaken tenant-safe run visibility.
|
||||
|
||||
For canonical-view specs, the spec MUST define:
|
||||
|
||||
- **Default filter behavior when tenant-context is active**: `/admin/operations` may continue to prefilter to the selected tenant as a convenience, but lifecycle legitimacy, stale detection, and terminal truth must not depend on selected tenant context. `/admin/operations/{run}` remains record-authoritative even when tenant context is mismatched, stale, or empty.
|
||||
- **Explicit entitlement checks preventing cross-tenant leakage**: The canonical run viewer must authorize from the resolved `OperationRun`, its workspace relationship, and tenant entitlement for any referenced tenant. Lifecycle reconciliation or stale labeling must not reveal tenant-linked run details to non-members or non-entitled actors.
|
||||
|
||||
## Operator Surface Contract *(mandatory when operator-facing surfaces are changed)*
|
||||
|
||||
| Surface | Primary Persona | Surface Type | Primary Operator Question | Default-visible Information | Diagnostics-only Information | Status Dimensions Used | Mutation Scope | Primary Actions | Dangerous Actions |
|
||||
|---|---|---|---|---|---|---|---|---|---|
|
||||
| Operations index | Workspace operator | List | Which operations are truly active, which are stale, and which require attention? | Run type, initiator, tenant or workspace scope, current lifecycle state, freshness signal, terminal outcome when available | Failure reason codes, reconciliation timestamps, queue-origin evidence, stale-threshold rationale | lifecycle, execution outcome, freshness, tenant scope | TenantPilot only | View run, filter active or stale runs | None on the index in V1 |
|
||||
| Operation run detail | Workspace operator | Detail | Did this run finish normally, fail normally, or get force-reconciled after losing lifecycle truth? | Run identity, started or completed timing, outcome, plain-language failure explanation, whether the platform reconciled the run automatically | Structured infrastructure reason, stale classification evidence, queue failure linkage, reconciliation audit context | lifecycle, execution outcome, freshness, auditability | TenantPilot only | Back to Operations, inspect run history and failure detail | Any future retry or re-drive affordance remains out of scope for V1 |
|
||||
| Existing operation start surfaces | Tenant operator or workspace operator | Action entry | If I start work now, will the platform later tell me the truth even when infrastructure goes wrong? | Existing queued intent messaging, run title, mutation scope, confirmation language where already required | Lifecycle contract diagnostics stay secondary to avoid cluttering start surfaces | lifecycle readiness, mutation scope | TenantPilot only, Microsoft tenant, or simulation only depending on the initiating feature | Start operation, view run | Existing destructive operations such as restore remain confirmation-protected and auditable |
|
||||
|
||||
## User Scenarios & Testing *(mandatory)*
|
||||
|
||||
### User Story 1 - Force Terminal Truth For Orphaned Runs (Priority: P1)
|
||||
|
||||
As an operator, I need every covered queued operation to end in a trustworthy terminal state even when the queue infrastructure fails before normal execution cleanup happens, so that no run remains indefinitely ambiguous.
|
||||
|
||||
**Why this priority**: This is the core reliability failure. If terminal truth is not guaranteed, every higher-level governance workflow inherits false operational state.
|
||||
|
||||
**Independent Test**: Can be fully tested by creating a covered `OperationRun`, preventing the normal completion or failure path from finalizing it, advancing time past the stale threshold, and verifying that the system reconciles it to a terminal failed truth with an operator-safe reason.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** a covered queued run moves to `running` and its normal lifecycle completion path never executes, **When** the platform detects that the run is stale, **Then** the run is forced to terminal failed truth with an explicit infrastructure-oriented reason.
|
||||
2. **Given** a covered queued run is failed by queue infrastructure before normal middleware finalization can update the domain record, **When** reconciliation or a direct failure bridge runs, **Then** the queue failure is reflected in the owning run instead of remaining only in infrastructure evidence.
|
||||
|
||||
---
|
||||
|
||||
### User Story 2 - Show Honest Liveness In Monitoring (Priority: P2)
|
||||
|
||||
As an operator viewing Monitoring, I need the UI to stop implying that obviously stale work is still normally active, so that I can decide whether to investigate, retry manually later, or move on.
|
||||
|
||||
**Why this priority**: The current misleading spinner is the visible symptom of the underlying integrity bug. Honest operator truth is required for trust and supportability.
|
||||
|
||||
**Independent Test**: Can be fully tested by presenting fresh active runs, stale non-terminal runs, and reconciled failed runs on the Operations surfaces and verifying that each state is represented distinctly without indefinite optimistic activity cues.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** a run is fresh and plausibly progressing, **When** the operator opens the Operations index or run detail, **Then** the UI presents it as legitimately active.
|
||||
2. **Given** a run has exceeded the accepted freshness window without credible progress evidence, **When** the operator views it before or after reconciliation, **Then** the UI no longer implies normal active progress and surfaces stale or reconciled failure semantics clearly.
|
||||
|
||||
---
|
||||
|
||||
### User Story 3 - Prevent Repeat Incidents Through Lifecycle Contracts (Priority: P3)
|
||||
|
||||
As a platform owner, I need covered queued jobs and runtime defaults to satisfy explicit lifecycle and timing guarantees, so that reservation expiry, timeout misalignment, and silent lifecycle divergence stop being accepted as normal behavior.
|
||||
|
||||
**Why this priority**: Reconciliation heals truth after failure, but the platform also needs guardrails that reduce the chance of creating orphaned runs in the first place.
|
||||
|
||||
**Independent Test**: Can be fully tested by verifying that covered jobs declare explicit lifecycle bounds, that runtime timing relationships are documented and validated, and that misaligned or ambiguous lifecycle settings are caught by automated checks.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** a covered long-running operation type is introduced or updated, **When** its lifecycle contract is reviewed, **Then** the job has an explicit timeout strategy and a credible path to terminal failure truth.
|
||||
2. **Given** deployment or worker timing values would allow legitimate work to outlive queue reservation semantics, **When** the platform validates lifecycle runtime assumptions, **Then** the mismatch is detected or documented as invalid rather than silently accepted.
|
||||
|
||||
### Edge Cases
|
||||
|
||||
- A run remains in `queued` because a worker never starts it, the worker crashes before status handoff, or dispatch-level failure evidence exists without a domain transition.
|
||||
- A run remains in `running` because the process dies, is killed, or times out after setting active state but before normal finalization.
|
||||
- Queue infrastructure records a decisive terminal failure but no matching middleware or in-job cleanup path executes.
|
||||
- A run becomes terminal through the normal lifecycle path shortly before reconciliation inspects it; reconciliation must not overwrite or flap a legitimately completed run.
|
||||
- A long-running but healthy job approaches the stale threshold; thresholds must be conservative enough to avoid false terminal failure for legitimate work.
|
||||
- A workspace-level run with no tenant reference must still converge to terminal truth without being mistaken for a tenant-leakage exception.
|
||||
- Scheduled or system-initiated runs with no initiator must still become terminally truthful while respecting the initiator-null notification rule.
|
||||
|
||||
## Requirements *(mandatory)*
|
||||
|
||||
**Constitution alignment (required):** This feature governs long-running, queued, and scheduled `OperationRun` work. It introduces no new Microsoft Graph contracts by itself, but it does require explicit lifecycle guarantees for covered `OperationRun` types, run observability on Monitoring surfaces, audit-safe failure semantics, and regression tests. Existing operation start surfaces must preserve their current safety gates, previews, confirmations, and audit requirements. If a covered flow performs a dangerous mutation such as restore, that mutation remains confirmation-protected and auditable; this feature only guarantees that its run truth cannot remain orphaned.
|
||||
|
||||
**Constitution alignment (OPS-UX):** This feature reuses existing `OperationRun` records and remains fully subject to the three-surface Ops-UX contract. Queued intent feedback remains toast-only. Active awareness remains limited to the active-operations widget and Monitoring run views. Terminal truth remains represented by the canonical run record and terminal notification policy. `OperationRun.status` and `OperationRun.outcome` transitions remain service-owned and must continue to occur only through `OperationRunService`, including reconciliation transitions. `summary_counts` remain numeric-only and keyed from the canonical registry. Scheduled and system runs continue to omit terminal DB notifications when there is no initiator, while Monitoring remains the authoritative audit surface. Regression coverage must include service-owned lifecycle transitions, stale-run reconciliation, and failure bridging without reintroducing direct status mutation.
|
||||
|
||||
**Constitution alignment (RBAC-UX):** This feature does not broaden authorization scope but does change the truth semantics shown on `/admin/operations` and `/admin/operations/{run}`. The admin `/admin` plane remains the only plane involved. Cross-plane access remains deny-as-not-found. For this feature, 404 means the actor is not entitled to the workspace or tenant scope of the run; 403 means the actor is in scope but lacks a capability for an operation-start or dangerous follow-up action. Authorization remains server-side through existing Gates, Policies, and the canonical capability registry. Global search and linked Monitoring access remain tenant-safe. Existing destructive-like actions such as restore remain confirmation-required. Validation must include at least one positive Monitoring access test and one negative tenant-entitlement or capability test alongside the lifecycle tests.
|
||||
|
||||
**Constitution alignment (OPS-EX-AUTH-001):** Not applicable beyond reaffirming that lifecycle bridging and stale reconciliation belong to Monitoring and queued operation execution only. Authentication handshake exceptions on `/auth/*` remain unrelated and must not be used as an exception path here.
|
||||
|
||||
**Constitution alignment (BADGE-001):** This feature changes the meaning shown by lifecycle and outcome badges on Operations surfaces. Badge semantics for `queued`, `running`, `completed`, `failed`, and any stale or reconciled indicators must remain centralized so the same run state is not mapped differently across the index, detail view, and widgets. Tests must cover any newly exposed stale or reconciled display values.
|
||||
|
||||
**Constitution alignment (UI-NAMING-001):** The target object is the `OperationRun`. Primary operator verbs remain `View run` and existing start verbs from initiating surfaces. New operator-facing copy must favor domain language such as `stale`, `reconciled`, `infrastructure failure`, and `no longer active` over implementation-first phrasing such as `MaxAttemptsExceededException` or `retry_after mismatch` in primary labels. Low-level queue or reservation details may appear only as secondary diagnostics. The same lifecycle vocabulary must be preserved across run titles, status presentation, notifications, and audit prose.
|
||||
|
||||
**Constitution alignment (OPSURF-001):** This feature materially refactors the meaning of the Operations index and run detail without replacing their overall layout. Default-visible content on `/admin` must remain operator-first: what ran, whether it is still trustworthy as active, how it ended, and whether the platform reconciled it automatically. Raw queue evidence and stale-threshold reasoning remain diagnostics-only. Status dimensions must remain distinct: execution outcome, lifecycle state, and freshness or reconciliation state must not collapse into one misleading badge. Mutating start surfaces continue to disclose mutation scope before execution through their originating specs. Dangerous actions continue to follow the existing safe-execution pattern; this feature does not introduce new dangerous actions.
|
||||
|
||||
**Constitution alignment (Filament Action Surfaces):** This feature modifies existing Filament Monitoring surfaces and therefore includes the UI Action Matrix below. The Action Surface Contract is satisfied. No exemption is needed because the feature changes state semantics and diagnostics, not the basic action inventory.
|
||||
|
||||
**Constitution alignment (UX-001 — Layout & Information Architecture):** Existing Operations list and run detail layouts remain in place. UX-001 stays satisfied so long as the run detail continues to present grouped operational sections instead of raw dumps, and the Operations table preserves search, sort, and filtering over core dimensions such as type, status, outcome, freshness, and tenant scope. Empty states remain single-CTA and explanatory. This feature changes semantic truth, not form layout.
|
||||
|
||||
### Functional Requirements
|
||||
|
||||
- **FR-160-001**: The system MUST guarantee that every covered queued `OperationRun` reaches eventual terminal domain truth or remains demonstrably fresh and legitimately active.
|
||||
- **FR-160-002**: The system MUST define the covered `OperationRun` operation types for V1 as baseline capture, baseline compare, inventory sync, policy sync including single-policy sync, Entra group sync, directory role-definition sync, backup schedule execution, restore execution, review pack generation, tenant review composition, and evidence snapshot generation.
|
||||
- **FR-160-003**: The system MUST provide at least one deterministic bridge from queue or infrastructure terminal failure evidence back to the owning `OperationRun` for every covered run type.
|
||||
- **FR-160-004**: A queue-level terminal failure for a covered run MUST eventually be reflected on the owning `OperationRun` as terminal failed truth rather than remaining only in infrastructure records or logs.
|
||||
- **FR-160-005**: The system MUST detect stale covered runs in `queued` or `running` states using explicit freshness bounds that distinguish fresh active work from orphaned or abandoned work.
|
||||
- **FR-160-006**: The system MUST reconcile stale covered `queued` runs to terminal failed truth when no credible evidence exists that the run is still legitimately progressing.
|
||||
- **FR-160-007**: The system MUST reconcile stale covered `running` runs to terminal failed truth when no credible evidence exists that the run is still legitimately progressing.
|
||||
- **FR-160-008**: Reconciliation MUST be conservative, idempotent, and limited to non-terminal runs; it MUST never mutate a legitimately completed run.
|
||||
- **FR-160-009**: Reconciled terminal failures MUST preserve operator-safe, infrastructure-oriented reason semantics that distinguish normal execution failure from stale or orphaned lifecycle healing.
|
||||
- **FR-160-010**: Covered queued jobs MUST satisfy a minimum lifecycle contract that provides either a direct terminal-failure bridge, a shared inherited failure bridge, or a documented fallback reconciliation path to eventual terminal truth.
|
||||
- **FR-160-011**: Covered long-running jobs MUST declare explicit runtime bounds rather than relying solely on implicit worker defaults.
|
||||
- **FR-160-012**: The timeout behavior for covered long-running jobs MUST be intentionally defined so that timeout-related failure semantics are not ambiguous.
|
||||
- **FR-160-013**: The platform MUST preserve traceable linkage between a failed or reconciled infrastructure event and the owning `OperationRun`.
|
||||
- **FR-160-014**: The platform MUST establish and document a runtime timing invariant that keeps queue reservation timing safely above legitimate covered job execution duration.
|
||||
- **FR-160-015**: Lifecycle runtime validation MUST make misaligned timing relationships detectable rather than allowing silent divergence between queue truth and domain truth.
|
||||
- **FR-160-016**: The Operations index and run detail MUST distinguish legitimately active runs from likely stale runs and reconciled failures without implying indefinite normal activity for obviously stale work.
|
||||
- **FR-160-017**: Operator-facing Monitoring surfaces MUST communicate whether a run ended normally or was force-resolved by lifecycle reconciliation.
|
||||
- **FR-160-018**: Existing happy-path lifecycle handling such as middleware-based run tracking MUST remain valid but MUST no longer be treated as the only mechanism that can guarantee terminal truth.
|
||||
- **FR-160-019**: The system MUST support evidence-based reconciliation using available operational signals such as run freshness, queue failure evidence, and other non-render-time lifecycle evidence that does not require external calls during Monitoring page render.
|
||||
- **FR-160-020**: The system MUST preserve the current top-level lifecycle model of `queued`, `running`, and `completed` while enriching failure reason and freshness interpretation instead of introducing a second terminal-state model for V1.
|
||||
- **FR-160-021**: Reconciliation and direct failure bridging MUST remain auditable, including when reconciliation happened, why the run was judged stale or orphaned, and whether the normal lifecycle path was bypassed.
|
||||
- **FR-160-022**: The system SHOULD make it possible to recover aggregate visibility into how many runs were force-reconciled and which operation types are most affected, even if V1 does not add a dedicated observability dashboard.
|
||||
- **FR-160-023**: Manual database or Tinker intervention MUST no longer be the normal recovery path for orphaned covered runs.
|
||||
- **FR-160-024**: Validation coverage MUST include stale queued reconciliation, stale running reconciliation, idempotency, fresh-run non-interference, direct failure bridging, normal failure-path coexistence, a Run-126-style orphaned running regression, and runtime timing guard coverage where practical.
|
||||
|
||||
## UI Action Matrix *(mandatory when Filament is changed)*
|
||||
|
||||
If this feature adds/modifies any Filament Resource / RelationManager / Page, fill out the matrix below.
|
||||
|
||||
For each surface, list the exact action labels, whether they are destructive (confirmation? typed confirmation?),
|
||||
RBAC gating (capability + enforcement helper), and whether the mutation writes an audit log.
|
||||
|
||||
| Surface | Location | Header Actions | Inspect Affordance (List/Table) | Row Actions (max 2 visible) | Bulk Actions (grouped) | Empty-State CTA(s) | View Header Actions | Create/Edit Save+Cancel | Audit log? | Notes / Exemptions |
|
||||
|---|---|---|---|---|---|---|---|---|---|---|
|
||||
| Operations index | `/admin/operations` | Existing filter and navigation actions remain | Existing linked rows to run detail remain the inspect affordance | `View run` remains the primary inspect action | Existing grouped bulk actions unchanged if present | Existing empty-state CTA remains | Not applicable | Not applicable | No direct mutation from index in V1 | Table semantics change to show freshness and reconciliation truth without changing the core action surface. |
|
||||
| Operation run detail | `/admin/operations/{run}` | `Back to Operations` and existing contextual navigation remain | Route-resolved record inspection | No new row actions | None | Not applicable | Existing view actions remain; no new destructive action is introduced | Not applicable | No direct mutation from the page in V1 | Detail page exposes reconciled or stale semantics and diagnostics but does not add retry or re-drive actions in V1. |
|
||||
| Existing operation start surfaces | Existing baseline, restore, backup schedule, inventory sync, and review generation surfaces | Existing start or preview actions remain | Existing route or view affordances remain | Existing `View run` or equivalent inspect affordance remains where already present | Existing grouped actions remain | Existing empty-state CTA remains | Existing view header actions remain | Existing save and cancel behavior unchanged | Yes where the originating feature already writes audit logs | Exemption: this spec changes lifecycle guarantees behind existing start actions, not the visible action inventory. Existing destructive operations remain confirmation-required under their originating specs. |
|
||||
|
||||
### Key Entities *(include if feature involves data)*
|
||||
|
||||
- **Covered Operation Run**: An operator-visible queued run whose lifecycle truth must converge even when queue infrastructure fails outside the normal happy path.
|
||||
- **Queue Failure Evidence**: Any infrastructure-originated signal that indicates a covered queued job terminally failed, timed out, exhausted attempts, or became otherwise non-viable.
|
||||
- **Lifecycle Reconciliation Decision**: The platform decision that a non-terminal run is stale or orphaned and must be force-resolved to terminal failed truth.
|
||||
- **Freshness Window**: The accepted age or progress boundary that separates plausibly active `queued` or `running` work from stale work.
|
||||
- **Lifecycle Contract**: The minimum expectations a covered queued job must satisfy so the platform can map infrastructure truth back to domain truth.
|
||||
- **Runtime Timing Invariant**: The deployment and worker relationship that prevents legitimate covered work from outliving queue reservation semantics.
|
||||
|
||||
## Success Criteria *(mandatory)*
|
||||
|
||||
### Measurable Outcomes
|
||||
|
||||
- **SC-160-001**: In focused lifecycle regression coverage, 100% of covered stale `queued` runs and stale `running` runs are force-resolved to terminal failed truth within the configured reconciliation window.
|
||||
- **SC-160-002**: In focused lifecycle regression coverage, 0 fresh covered runs are incorrectly reconciled while still within their accepted freshness window.
|
||||
- **SC-160-003**: In focused Run-126-style regression coverage, 100% of simulated orphaned runs that never receive normal completion or failure callbacks stop appearing as indefinitely active work.
|
||||
- **SC-160-004**: In focused Monitoring UX coverage, operators can distinguish normal failure from reconciled lifecycle failure on 100% of covered scenarios exercised by the spec tests.
|
||||
- **SC-160-005**: In focused lifecycle contract coverage, 100% of V1-covered queued operation types demonstrate a credible terminal-truth path through either direct failure bridging or reconciliation.
|
||||
- **SC-160-006**: In focused runtime guard coverage, timing relationships that would allow legitimate covered work to outlive queue reservation semantics are detected or documented rather than silently accepted.
|
||||
|
||||
## Assumptions
|
||||
|
||||
- Existing `OperationRun` top-level statuses and outcomes remain sufficient for V1 if failure reasons and freshness semantics are enriched.
|
||||
- Existing Monitoring pages remain the canonical operator-facing surfaces for run truth and do not perform external calls during render.
|
||||
- Existing happy-path middleware and service-based lifecycle handling remain useful and are preserved as first-line execution handling.
|
||||
- V1 prioritizes deterministic terminal truth over resumability, automatic re-drive, or advanced checkpoint-based recovery.
|
||||
- Covered operation types are limited to operator-visible runs that materially own or advance business-relevant work.
|
||||
|
||||
## Dependencies
|
||||
|
||||
- Existing `OperationRun` domain model and `OperationRunService` remain the canonical ownership boundary for lifecycle transitions.
|
||||
- Existing Monitoring and Operations surfaces remain the canonical render surfaces for run truth.
|
||||
- Existing initiating specs for restore, baseline, backup scheduling, inventory synchronization, and review generation remain the source of truth for their mutation scope and confirmation policy.
|
||||
|
||||
## Risks
|
||||
|
||||
- Stale thresholds that are too aggressive could force-fail legitimate long-running work.
|
||||
- A reconciliation safety net could hide recurring infrastructure weaknesses if aggregate visibility is not preserved.
|
||||
- Partial adoption across covered operation types could leave some orphaned paths untreated and create a false sense of completion.
|
||||
- Teams may mistake lifecycle reconciliation for full resiliency even though resumability and advanced retry orchestration remain out of scope.
|
||||
|
||||
## Summary
|
||||
|
||||
Run 126 showed that queue truth, domain truth, and operator-visible truth can diverge when infrastructure-level failure happens before the normal lifecycle path finishes its cleanup. This feature closes that gap by establishing a platform rule: no covered queued `OperationRun` may remain indefinitely ambiguous. If the system cannot prove that a run is still legitimately active, it must eventually reconcile that run to deterministic terminal truth.
|
||||
|
||||
V1 delivers that guarantee through three coordinated changes: a minimum lifecycle contract for covered queued jobs, a deterministic bridge from queue or infrastructure failure back to the owning run, and conservative stale-run reconciliation that heals orphaned `queued` or `running` runs. Monitoring surfaces then present that truth honestly by distinguishing fresh activity, likely stale work, and reconciled lifecycle failure without adding a second status model or promising resumability.
|
||||
220
specs/160-operation-lifecycle-guarantees/tasks.md
Normal file
220
specs/160-operation-lifecycle-guarantees/tasks.md
Normal file
@ -0,0 +1,220 @@
|
||||
# Tasks: Operation Lifecycle Guarantees & Queue-to-Domain Failure Reconciliation
|
||||
|
||||
**Input**: Design documents from `/specs/160-operation-lifecycle-guarantees/`
|
||||
**Prerequisites**: `plan.md` (required), `spec.md` (required), `research.md`, `data-model.md`, `contracts/`, `quickstart.md`
|
||||
|
||||
**Tests**: Runtime behavior changes in this repo require Pest coverage. This feature changes queue lifecycle handling, Monitoring semantics, authorization-adjacent Monitoring truth, and Ops-UX guarantees, so tests are required for every user story.
|
||||
**Operations**: This feature hardens long-running and queued `OperationRun` execution. Tasks below preserve the Ops-UX 3-surface feedback contract, keep terminal truth service-owned through `OperationRunService`, keep `summary_counts` numeric-only, prevent queued or running DB notifications, preserve initiator-null notification behavior for system runs, and keep canonical `View run` navigation pointed at `/admin/operations/{run}`.
|
||||
**RBAC**: This feature changes Monitoring truth semantics in the admin `/admin` plane. Tasks below preserve deny-as-not-found for non-entitled workspace or tenant access, keep capability denial as `403` where applicable, continue using the capability registry, and add positive and negative authorization coverage for canonical run viewing.
|
||||
**UI Naming**: Lifecycle copy must use operator-safe domain language such as `stale`, `reconciled`, and `infrastructure failure` in primary UI surfaces and keep low-level queue exceptions in diagnostics only.
|
||||
**Filament UI Action Surfaces**: This feature modifies existing Filament Monitoring pages and resources without changing their core action inventory. Tasks below preserve existing inspect affordances, keep destructive-action rules unchanged, and retrofit lifecycle semantics into current header, row, and empty-state behavior.
|
||||
**Filament UI UX-001**: This feature is not a layout redesign. Tasks below keep the current Operations layouts intact while updating badges, diagnostics, and operator-first truth messaging inside the existing pages.
|
||||
**Badges**: Status-like semantics must continue to flow through `BadgeCatalog`-backed domain badge mappers in `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/app/Support/Badges/Domains/OperationRunStatusBadge.php` and `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/app/Support/Badges/Domains/OperationRunOutcomeBadge.php`.
|
||||
**Contract Artifact**: `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/specs/160-operation-lifecycle-guarantees/contracts/operation-run-lifecycle.openapi.yaml` is an internal Monitoring contract for freshness and reconciliation semantics, not a requirement to add new public controller endpoints.
|
||||
|
||||
**Organization**: Tasks are grouped by user story so each story can be implemented and tested independently.
|
||||
|
||||
## Phase 1: Setup (Shared Infrastructure)
|
||||
|
||||
**Purpose**: Prepare the regression targets and touchpoints for lifecycle hardening.
|
||||
|
||||
- [X] T001 [P] Create or extend lifecycle service and middleware regression targets in `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/tests/Feature/OperationRunServiceTest.php`, `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/tests/Feature/OperationRunServiceStaleQueuedRunTest.php`, and `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/tests/Feature/TrackOperationRunMiddlewareTest.php`
|
||||
- [X] T002 [P] Create or extend reconciliation and scheduler regression targets in `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/tests/Feature/Console/ReconcileBackupScheduleOperationRunsCommandTest.php`, `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/tests/Feature/OpsUx/AdapterRunReconcilerTest.php`, `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/tests/Feature/Operations/ReconcileAdapterRunsJobTrackingTest.php`, `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/tests/Feature/Operations/OperationLifecycleReconciliationTest.php`, and `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/tests/Feature/Console/ReconcileOperationRunsCommandTest.php`
|
||||
- [X] T003 [P] Create or extend Monitoring and badge regression targets in `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/tests/Feature/Monitoring/MonitoringOperationsTest.php`, `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/tests/Feature/Filament/OperationRunEnterpriseDetailPageTest.php`, `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/tests/Feature/Monitoring/OperationsDbOnlyRenderTest.php`, and `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/tests/Unit/Badges/OperationRunBadgesTest.php`
|
||||
- [X] T004 [P] Create or extend authorization, queued-intent, canonical View run, and Ops-UX guard targets in `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/tests/Feature/Operations/TenantlessOperationRunViewerTest.php`, `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/tests/Feature/RunAuthorizationTenantIsolationTest.php`, `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/tests/Feature/Notifications/OperationRunNotificationTest.php`, `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/tests/Feature/OpsUx/QueuedToastCopyTest.php`, `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/tests/Feature/OpsUx/NotificationViewRunLinkTest.php`, and `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/tests/Feature/Guards/OperationLifecycleOpsUxGuardTest.php`
|
||||
|
||||
---
|
||||
|
||||
## Phase 2: Foundational (Blocking Prerequisites)
|
||||
|
||||
**Purpose**: Build the shared lifecycle policy and reconciliation infrastructure that all user stories depend on.
|
||||
|
||||
**⚠️ CRITICAL**: No user story work should begin until this phase is complete.
|
||||
|
||||
- [X] T005 Define the config-backed lifecycle coverage, terminal-truth-path matrix, and threshold registry for `baseline_capture`, `baseline_compare`, `inventory_sync`, `policy.sync`, `policy.sync_one`, `entra_group_sync`, `directory_role_definitions.sync`, `backup_schedule_run`, `restore.execute`, `tenant.review_pack.generate`, `tenant.review.compose`, and `tenant.evidence.snapshot.generate` in `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/config/tenantpilot.php` and align queue timing defaults in `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/config/queue.php`
|
||||
- [X] T006 Create shared lifecycle policy and freshness support types in `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/app/Support/Operations/OperationLifecyclePolicy.php`, `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/app/Support/Operations/OperationRunFreshnessState.php`, and `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/app/Support/Operations/LifecycleReconciliationReason.php`
|
||||
- [X] T007 Extend `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/app/Services/OperationRunService.php` and `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/app/Models/OperationRun.php` with generic stale-running assessment, standardized reconciliation metadata, and idempotent service-owned force-fail helpers for non-terminal runs
|
||||
- [X] T008 Create the generic active-run reconciler in `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/app/Services/Operations/OperationLifecycleReconciler.php` and reuse existing legitimacy signals from `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/app/Services/Operations/QueuedExecutionLegitimacyGate.php`
|
||||
- [X] T009 Register the generic reconciliation entry point in `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/app/Console/Commands/TenantpilotReconcileOperationRuns.php` and schedule it from `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/routes/console.php`
|
||||
- [X] T010 Add foundational coverage for lifecycle policy parsing, stale-running service transitions, and idempotent reconciliation in `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/tests/Feature/OperationRunServiceStaleQueuedRunTest.php`, `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/tests/Feature/Operations/OperationLifecycleReconciliationTest.php`, and `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/tests/Feature/Console/ReconcileOperationRunsCommandTest.php`
|
||||
|
||||
**Checkpoint**: Foundation ready. The repo has one shared lifecycle policy, one generic reconciliation seam, and service-owned APIs that stories can adopt independently.
|
||||
|
||||
---
|
||||
|
||||
## Phase 3: User Story 1 - Force Terminal Truth For Orphaned Runs (Priority: P1) 🎯 MVP
|
||||
|
||||
**Goal**: Ensure every covered queued operation converges to deterministic terminal truth when normal queue cleanup does not.
|
||||
|
||||
**Independent Test**: Create covered `OperationRun` records in `queued` and `running`, prevent normal finalization, advance time past the configured threshold, run the generic reconciler or direct failure bridge, and verify the run becomes `completed/failed` with operator-safe reconciliation evidence.
|
||||
|
||||
### Tests for User Story 1
|
||||
|
||||
- [X] T011 [P] [US1] Add stale queued, stale running, fresh-run non-interference, and idempotency coverage in `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/tests/Feature/OperationRunServiceStaleQueuedRunTest.php` and `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/tests/Feature/Operations/OperationLifecycleReconciliationTest.php`
|
||||
- [X] T012 [P] [US1] Add direct queue-failure bridge coverage for exhausted-attempt and timeout paths in `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/tests/Feature/TrackOperationRunMiddlewareTest.php` and `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/tests/Feature/Operations/OperationRunFailedJobBridgeTest.php`
|
||||
- [X] T013 [P] [US1] Add reconciliation command and coexistence coverage for scheduled healing paths in `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/tests/Feature/Console/ReconcileOperationRunsCommandTest.php`, `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/tests/Feature/Console/ReconcileBackupScheduleOperationRunsCommandTest.php`, and `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/tests/Feature/OpsUx/AdapterRunReconcilerTest.php`
|
||||
|
||||
### Implementation for User Story 1
|
||||
|
||||
- [X] T014 [US1] Implement service-owned stale queued and stale running force-fail transitions in `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/app/Services/OperationRunService.php`
|
||||
- [X] T015 [US1] Implement the generic lifecycle reconciliation flow and structured reconciliation payloads in `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/app/Services/Operations/OperationLifecycleReconciler.php` and `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/app/Console/Commands/TenantpilotReconcileOperationRuns.php`
|
||||
- [X] T016 [US1] Integrate the new generic reconciler with existing type-specific healing in `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/app/Console/Commands/TenantpilotReconcileBackupScheduleOperationRuns.php`, `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/app/Services/AdapterRunReconciler.php`, and `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/app/Jobs/ReconcileAdapterRunsJob.php`
|
||||
- [X] T017 [US1] Create a reusable failed-job bridge in `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/app/Jobs/Concerns/BridgesFailedOperationRun.php`, normalize the existing direct bridge in `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/app/Jobs/BulkBackupSetRestoreJob.php`, and add missing direct bridges for `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/app/Jobs/CaptureBaselineSnapshotJob.php`, `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/app/Jobs/CompareBaselineToTenantJob.php`, `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/app/Jobs/BulkTenantSyncJob.php`, `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/app/Jobs/SyncPoliciesJob.php`, and `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/app/Jobs/ComposeTenantReviewJob.php` while preserving scheduled reconciliation for covered types marked fallback-only in `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/config/tenantpilot.php`
|
||||
- [X] T018 [US1] Preserve queued-intent, canonical `View run`, completion, and initiator-only notification guarantees across representative start surfaces in `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/app/Filament/Pages/BaselineCompareLanding.php`, `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/app/Filament/Resources/BackupScheduleResource.php`, `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/app/Filament/Resources/RestoreRunResource.php`, `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/app/Filament/Resources/InventoryItemResource/Pages/ListInventoryItems.php`, `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/app/Filament/Resources/ReviewPackResource.php`, `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/app/Jobs/Middleware/TrackOperationRun.php`, and `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/app/Notifications/OperationRunCompleted.php`
|
||||
|
||||
**Checkpoint**: User Story 1 is complete when orphaned covered runs no longer require manual DB repair and always converge to terminal failed truth through direct failure bridging or reconciliation.
|
||||
|
||||
---
|
||||
|
||||
## Phase 4: User Story 2 - Show Honest Liveness In Monitoring (Priority: P2)
|
||||
|
||||
**Goal**: Make Monitoring distinguish fresh activity, likely stale activity, and reconciled failure without implying indefinite normal progress.
|
||||
|
||||
**Independent Test**: Seed fresh active runs, stale runs, and reconciled-failed runs, then verify the Operations index and canonical run detail show distinct operator-safe semantics while canonical authorization remains intact.
|
||||
|
||||
### Tests for User Story 2
|
||||
|
||||
- [X] T019 [P] [US2] Add Operations index, aggregate reconciliation visibility, and run-detail truth-semantics coverage in `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/tests/Feature/Monitoring/MonitoringOperationsTest.php`, `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/tests/Feature/Monitoring/OperationLifecycleAggregateVisibilityTest.php`, and `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/tests/Feature/Filament/OperationRunEnterpriseDetailPageTest.php`
|
||||
- [X] T020 [P] [US2] Add freshness-state and badge mapping coverage in `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/tests/Unit/Badges/OperationRunBadgesTest.php` and `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/tests/Feature/Monitoring/OperationLifecycleFreshnessPresentationTest.php`
|
||||
- [X] T021 [P] [US2] Add positive and negative canonical Monitoring authorization coverage for stale or reconciled runs in `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/tests/Feature/Operations/TenantlessOperationRunViewerTest.php` and `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/tests/Feature/RunAuthorizationTenantIsolationTest.php`
|
||||
|
||||
### Implementation for User Story 2
|
||||
|
||||
- [X] T022 [US2] Extend centralized lifecycle presentation in `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/app/Support/OpsUx/OperationUxPresenter.php`, `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/app/Support/OpsUx/RunDurationInsights.php`, and `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/app/Support/ReasonTranslation/ReasonPresenter.php`
|
||||
- [X] T023 [US2] Implement fresh, stale, and reconciled badge semantics in `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/app/Support/Badges/Domains/OperationRunStatusBadge.php` and `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/app/Support/Badges/Domains/OperationRunOutcomeBadge.php`
|
||||
- [X] T024 [US2] Update Operations list filtering, minimal aggregate reconciliation visibility, query semantics, and default-visible lifecycle truth in `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/app/Filament/Pages/Monitoring/Operations.php`, `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/app/Filament/Resources/OperationRunResource.php`, and `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/resources/views/filament/pages/monitoring/operations.blade.php`
|
||||
- [X] T025 [US2] Update canonical run detail messaging and diagnostics disclosure in `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/app/Filament/Pages/Operations/TenantlessOperationRunViewer.php` and `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/resources/views/filament/pages/operations/tenantless-operation-run-viewer.blade.php`
|
||||
- [X] T026 [US2] Keep `/admin/operations` and `/admin/operations/{run}` DB-only and canonical-navigation-safe while exposing lifecycle truth in `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/app/Filament/Pages/Monitoring/Operations.php`, `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/app/Filament/Pages/Operations/TenantlessOperationRunViewer.php`, `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/app/Filament/Resources/OperationRunResource.php`, and `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/tests/Feature/Monitoring/OperationsDbOnlyRenderTest.php`
|
||||
|
||||
**Checkpoint**: User Story 2 is complete when operators can distinguish normal active work from stale or reconciled runs on Monitoring surfaces without losing canonical authorization or DB-only rendering guarantees.
|
||||
|
||||
---
|
||||
|
||||
## Phase 5: User Story 3 - Prevent Repeat Incidents Through Lifecycle Contracts (Priority: P3)
|
||||
|
||||
**Goal**: Enforce explicit lifecycle policy, timeout strategy, and guardrails so covered jobs cannot silently drift back into ambiguous run truth.
|
||||
|
||||
**Independent Test**: Verify that the covered lifecycle policy rejects misaligned timeout versus `retry_after` settings, covered jobs declare explicit lifecycle behavior, and Ops-UX guard tests fail if service ownership or notification constraints regress.
|
||||
|
||||
### Tests for User Story 3
|
||||
|
||||
- [X] T027 [P] [US3] Add lifecycle policy and timeout invariant coverage in `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/tests/Feature/Operations/OperationLifecycleTimingGuardTest.php` and `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/tests/Unit/Operations/OperationLifecyclePolicyValidatorTest.php`
|
||||
- [X] T028 [P] [US3] Add covered-job lifecycle contract coverage in `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/tests/Feature/Operations/BaselineOperationRunGuardTest.php`, `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/tests/Feature/Inventory/RunInventorySyncJobTest.php`, `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/tests/Feature/BackupScheduling/RunBackupScheduleJobCompatibilityTest.php`, and `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/tests/Feature/TenantReview/TenantReviewOperationsUxTest.php`
|
||||
- [X] T029 [P] [US3] Add Ops-UX regression guard coverage for service-owned transitions, notification discipline, and initiator-null behavior in `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/tests/Feature/Notifications/OperationRunNotificationTest.php` and `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/tests/Feature/Guards/OperationLifecycleOpsUxGuardTest.php`
|
||||
|
||||
### Implementation for User Story 3
|
||||
|
||||
- [X] T030 [US3] Implement lifecycle policy validation and timeout-versus-`retry_after` enforcement for the exact covered V1 operation set in `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/app/Services/Operations/OperationLifecyclePolicyValidator.php`, `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/config/tenantpilot.php`, and `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/config/queue.php`
|
||||
- [X] T031 [US3] Align covered job timeout and failure-contract declarations in `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/app/Jobs/CaptureBaselineSnapshotJob.php`, `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/app/Jobs/CompareBaselineToTenantJob.php`, `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/app/Jobs/BulkBackupSetRestoreJob.php`, `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/app/Jobs/BulkTenantSyncJob.php`, `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/app/Jobs/SyncPoliciesJob.php`, and `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/app/Jobs/ComposeTenantReviewJob.php`
|
||||
- [X] T032 [US3] Preserve canonical Monitoring authorization and capability semantics for reconciled lifecycle states in `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/app/Policies/OperationRunPolicy.php` and `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/app/Support/Operations/OperationRunCapabilityResolver.php`
|
||||
- [X] T033 [US3] Normalize operator-safe lifecycle copy and diagnostics boundaries in `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/app/Support/ReasonTranslation/ReasonPresenter.php`, `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/app/Support/OpsUx/OperationUxPresenter.php`, `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/resources/views/filament/pages/monitoring/operations.blade.php`, and `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/resources/views/filament/pages/operations/tenantless-operation-run-viewer.blade.php`
|
||||
|
||||
**Checkpoint**: User Story 3 is complete when covered jobs and runtime settings have explicit lifecycle contracts and guard tests catch timing or ownership regressions before they reintroduce orphaned runs.
|
||||
|
||||
---
|
||||
|
||||
## Phase 6: Polish & Cross-Cutting Concerns
|
||||
|
||||
**Purpose**: Validate the full feature slice, format touched files, and complete the manual smoke pass.
|
||||
|
||||
- [X] T034 [P] Run the focused Pest suites from `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/specs/160-operation-lifecycle-guarantees/quickstart.md` covering `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/tests/Feature/OperationRunServiceTest.php`, `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/tests/Feature/OperationRunServiceStaleQueuedRunTest.php`, `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/tests/Feature/TrackOperationRunMiddlewareTest.php`, `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/tests/Feature/Operations/OperationLifecycleReconciliationTest.php`, `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/tests/Feature/Console/ReconcileOperationRunsCommandTest.php`, `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/tests/Feature/Operations/OperationRunFailedJobBridgeTest.php`, `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/tests/Feature/Monitoring/MonitoringOperationsTest.php`, `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/tests/Feature/Filament/OperationRunEnterpriseDetailPageTest.php`, `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/tests/Feature/Operations/TenantlessOperationRunViewerTest.php`, `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/tests/Feature/RunAuthorizationTenantIsolationTest.php`, `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/tests/Feature/Operations/OperationLifecycleTimingGuardTest.php`, `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/tests/Unit/Badges/OperationRunBadgesTest.php`, and `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/tests/Unit/Operations/OperationLifecyclePolicyValidatorTest.php`
|
||||
- [X] T035 Run formatting for touched files under `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/app`, `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/config`, and `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/tests` with `vendor/bin/sail bin pint --dirty --format agent`
|
||||
- [X] T036 [P] Validate the manual smoke checklist in `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/specs/160-operation-lifecycle-guarantees/quickstart.md` against `/admin/operations`, `/admin/operations/{run}`, and the affected operation start surfaces for baseline capture, baseline compare, restore execution, backup schedule execution, inventory sync, and tenant review generation
|
||||
- [X] T037 [P] Document worker timeout, `retry_after`, `queue:restart`, and stop-wait expectations in `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/specs/160-operation-lifecycle-guarantees/quickstart.md` and `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/docs/HANDOVER.md`
|
||||
|
||||
---
|
||||
|
||||
## Dependencies & Execution Order
|
||||
|
||||
### Phase Dependencies
|
||||
|
||||
- **Phase 1: Setup** has no dependencies and can start immediately.
|
||||
- **Phase 2: Foundational** depends on Phase 1 and blocks all user story work.
|
||||
- **Phase 3: User Story 1** depends on Phase 2 and delivers the MVP.
|
||||
- **Phase 4: User Story 2** depends on Phase 2 and benefits from User Story 1 because it needs reconciled lifecycle evidence to display.
|
||||
- **Phase 5: User Story 3** depends on Phase 2 and is safest after User Story 1 because it formalizes the lifecycle policy used by the reconciliation and failed-job bridge paths.
|
||||
- **Phase 6: Polish** depends on all desired user stories being complete.
|
||||
|
||||
### User Story Dependencies
|
||||
|
||||
- **User Story 1 (P1)** can start immediately after the foundational phase and is the MVP slice.
|
||||
- **User Story 2 (P2)** can start after the foundational phase but is easiest once User Story 1 provides the reconciled lifecycle states to present.
|
||||
- **User Story 3 (P3)** can start after the foundational phase but should land after User Story 1 to avoid validating contracts against outdated bridge behavior.
|
||||
|
||||
### Within Each User Story
|
||||
|
||||
- Write or extend tests first and confirm they fail before implementation.
|
||||
- Policy and support-layer changes should land before command, job, and UI adoption.
|
||||
- Reconciliation flow should stabilize before UI semantics consume its metadata.
|
||||
- Story-level regression coverage should pass before moving to the next priority story.
|
||||
|
||||
### Parallel Opportunities
|
||||
|
||||
- `T001`, `T002`, `T003`, and `T004` can run in parallel because they prepare separate regression targets.
|
||||
- `T005` and `T006` can run in parallel before the service and reconciler wiring tasks.
|
||||
- `T011`, `T012`, and `T013` can run in parallel within User Story 1.
|
||||
- `T019`, `T020`, and `T021` can run in parallel within User Story 2.
|
||||
- `T027`, `T028`, and `T029` can run in parallel within User Story 3.
|
||||
- `T034`, `T036`, and `T037` can run in parallel after implementation is complete.
|
||||
|
||||
---
|
||||
|
||||
## Parallel Example: User Story 1
|
||||
|
||||
```bash
|
||||
# Run the P1 regression additions together:
|
||||
Task: "Add stale queued, stale running, fresh-run non-interference, and idempotency coverage in tests/Feature/OperationRunServiceStaleQueuedRunTest.php and tests/Feature/Operations/OperationLifecycleReconciliationTest.php"
|
||||
Task: "Add direct queue-failure bridge coverage for exhausted-attempt and timeout paths in tests/Feature/TrackOperationRunMiddlewareTest.php and tests/Feature/Operations/OperationRunFailedJobBridgeTest.php"
|
||||
Task: "Add reconciliation command and coexistence coverage for scheduled healing paths in tests/Feature/Console/ReconcileOperationRunsCommandTest.php, tests/Feature/Console/ReconcileBackupScheduleOperationRunsCommandTest.php, and tests/Feature/OpsUx/AdapterRunReconcilerTest.php"
|
||||
```
|
||||
|
||||
## Parallel Example: User Story 2
|
||||
|
||||
```bash
|
||||
# Split list/detail truth semantics, badge semantics, and auth coverage:
|
||||
Task: "Add Operations index and run-detail truth-semantics coverage in tests/Feature/Monitoring/MonitoringOperationsTest.php and tests/Feature/Filament/OperationRunEnterpriseDetailPageTest.php"
|
||||
Task: "Add freshness-state and badge mapping coverage in tests/Unit/Badges/OperationRunBadgesTest.php and tests/Feature/Monitoring/OperationLifecycleFreshnessPresentationTest.php"
|
||||
Task: "Add positive and negative canonical Monitoring authorization coverage for stale or reconciled runs in tests/Feature/Operations/TenantlessOperationRunViewerTest.php and tests/Feature/RunAuthorizationTenantIsolationTest.php"
|
||||
```
|
||||
|
||||
## Parallel Example: User Story 3
|
||||
|
||||
```bash
|
||||
# Split policy, job-contract, and Ops-UX guard work:
|
||||
Task: "Add lifecycle policy and timeout invariant coverage in tests/Feature/Operations/OperationLifecycleTimingGuardTest.php and tests/Unit/Operations/OperationLifecyclePolicyValidatorTest.php"
|
||||
Task: "Add covered-job lifecycle contract coverage in tests/Feature/Operations/BaselineOperationRunGuardTest.php, tests/Feature/Inventory/RunInventorySyncJobTest.php, tests/Feature/BackupScheduling/RunBackupScheduleJobCompatibilityTest.php, and tests/Feature/TenantReview/TenantReviewOperationsUxTest.php"
|
||||
Task: "Add Ops-UX regression guard coverage for service-owned transitions, notification discipline, and initiator-null behavior in tests/Feature/Notifications/OperationRunNotificationTest.php and tests/Feature/Guards/OperationLifecycleOpsUxGuardTest.php"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Implementation Strategy
|
||||
|
||||
### MVP First
|
||||
|
||||
1. Complete Phase 1: Setup.
|
||||
2. Complete Phase 2: Foundational.
|
||||
3. Complete Phase 3: User Story 1.
|
||||
4. **Stop and validate** that orphaned queued and running runs now converge to terminal failed truth without manual intervention.
|
||||
|
||||
### Incremental Delivery
|
||||
|
||||
1. Deliver User Story 1 to close the integrity gap and establish direct failure bridging plus stale-run healing.
|
||||
2. Deliver User Story 2 to make Monitoring surfaces reflect that lifecycle truth honestly.
|
||||
3. Deliver User Story 3 to formalize covered-job contracts and timing guards so the same incident class does not recur.
|
||||
4. Finish with Phase 6 regression execution, formatting, and manual smoke validation.
|
||||
|
||||
### Team Strategy
|
||||
|
||||
1. One engineer should own the foundational lifecycle policy and reconciliation seam in `app/Services/OperationRunService.php`, `app/Services/Operations/OperationLifecycleReconciler.php`, and `config/tenantpilot.php`.
|
||||
2. A second engineer can prepare User Story 1 regression coverage in parallel during the foundational phase.
|
||||
3. Monitoring and badge semantics for User Story 2 can be developed separately once the reconciled metadata contract is stable.
|
||||
|
||||
---
|
||||
|
||||
## Notes
|
||||
|
||||
- `[P]` tasks touch separate files and can be executed in parallel.
|
||||
- Each user story remains independently testable after the foundational phase.
|
||||
- This feature does not require a schema migration in the first slice.
|
||||
- Keep lifecycle truth service-owned and operator-facing copy domain-safe across every touched surface.
|
||||
@ -41,10 +41,9 @@
|
||||
|
||||
Livewire::actingAs($user)->test(AuditLogPage::class)
|
||||
->assertCanSeeTableRecords([$audit])
|
||||
->callTableAction('inspect', $audit)
|
||||
->assertSet('selectedAuditLogId', (int) $audit->getKey())
|
||||
->assertSee('Drift finding #'.$finding->getKey())
|
||||
->assertSee('Open finding');
|
||||
->mountTableAction('inspect', $audit)
|
||||
->assertMountedActionModalSee('Drift finding #'.$finding->getKey())
|
||||
->assertMountedActionModalSee('Open finding');
|
||||
});
|
||||
|
||||
it('keeps deleted findings readable while suppressing finding drill-down links', function (): void {
|
||||
@ -81,10 +80,9 @@
|
||||
|
||||
Livewire::actingAs($user)->test(AuditLogPage::class)
|
||||
->assertCanSeeTableRecords([$audit])
|
||||
->callTableAction('inspect', $audit)
|
||||
->assertSet('selectedAuditLogId', (int) $audit->getKey())
|
||||
->assertSee('Permission posture finding #'.$findingId)
|
||||
->assertDontSee('Open finding');
|
||||
->mountTableAction('inspect', $audit)
|
||||
->assertMountedActionModalSee('Permission posture finding #'.$findingId)
|
||||
->assertMountedActionModalDontSee('Open finding');
|
||||
});
|
||||
|
||||
it('does not render internal audit bookkeeping metadata in the inspection view', function (): void {
|
||||
@ -120,12 +118,11 @@
|
||||
|
||||
Livewire::actingAs($user)->test(AuditLogPage::class)
|
||||
->assertCanSeeTableRecords([$audit])
|
||||
->callTableAction('inspect', $audit)
|
||||
->assertSet('selectedAuditLogId', (int) $audit->getKey())
|
||||
->assertDontSee('_dedupe_key')
|
||||
->assertDontSee('internal-bookkeeping-marker')
|
||||
->assertDontSee('_actor_type')
|
||||
->assertDontSee('hidden-actor-marker');
|
||||
->mountTableAction('inspect', $audit)
|
||||
->assertMountedActionModalDontSee('_dedupe_key')
|
||||
->assertMountedActionModalDontSee('internal-bookkeeping-marker')
|
||||
->assertMountedActionModalDontSee('_actor_type')
|
||||
->assertMountedActionModalDontSee('hidden-actor-marker');
|
||||
});
|
||||
|
||||
it('hides finding audit rows for tenants outside the viewer entitlement scope', function (): void {
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
<?php
|
||||
|
||||
use App\Jobs\Concerns\BridgesFailedOperationRun;
|
||||
use App\Jobs\RunBackupScheduleJob;
|
||||
|
||||
it('serializes without the legacy backup schedule run id field', function (): void {
|
||||
@ -21,3 +22,11 @@
|
||||
expect($job)->toBeInstanceOf(RunBackupScheduleJob::class)
|
||||
->and($job->backupScheduleId)->toBe(42);
|
||||
});
|
||||
|
||||
it('declares the backup schedule lifecycle contract explicitly', function (): void {
|
||||
$job = new RunBackupScheduleJob(operationRun: null, backupScheduleId: 42);
|
||||
|
||||
expect(class_uses_recursive($job))->not->toContain(BridgesFailedOperationRun::class)
|
||||
->and($job->timeout)->toBe(300)
|
||||
->and($job->failOnTimeout)->toBeTrue();
|
||||
});
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user