feat: add baseline snapshot truth guards
This commit is contained in:
parent
e7c9b4b853
commit
5a9f11b14f
2
.github/agents/copilot-instructions.md
vendored
2
.github/agents/copilot-instructions.md
vendored
@ -119,8 +119,8 @@ ## Code Style
|
||||
PHP 8.4.15: Follow standard conventions
|
||||
|
||||
## Recent Changes
|
||||
- 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 -->
|
||||
|
||||
@ -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();
|
||||
|
||||
|
||||
@ -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);
|
||||
|
||||
|
||||
@ -260,6 +260,17 @@ private static function enterpriseDetailPage(OperationRun $record): \App\Support
|
||||
$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 +305,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 +326,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 +347,9 @@ private static function enterpriseDetailPage(OperationRun $record): \App\Support
|
||||
$referencedTenantLifecycle?->contextNote !== null
|
||||
? $factory->keyFact('Viewer context', $referencedTenantLifecycle->contextNote)
|
||||
: null,
|
||||
$artifactTruth !== null
|
||||
? $factory->keyFact('Artifact next step', $artifactTruth->nextStepText())
|
||||
: null,
|
||||
OperationUxPresenter::surfaceGuidance($record) !== null
|
||||
? $factory->keyFact('Next step', OperationUxPresenter::surfaceGuidance($record))
|
||||
: null,
|
||||
|
||||
@ -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),
|
||||
|
||||
@ -21,7 +21,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,6 +35,7 @@
|
||||
use Illuminate\Queue\InteractsWithQueue;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
use RuntimeException;
|
||||
use Throwable;
|
||||
|
||||
class CaptureBaselineSnapshotJob implements ShouldQueue
|
||||
{
|
||||
@ -208,16 +211,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 +262,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 +513,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 +676,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();
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@ -18,6 +18,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 +38,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;
|
||||
@ -84,6 +86,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 +95,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 +282,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 +1047,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.
|
||||
*
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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();
|
||||
}
|
||||
}
|
||||
@ -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(),
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -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,
|
||||
|
||||
@ -3,6 +3,7 @@
|
||||
namespace App\Support\OpsUx;
|
||||
|
||||
use App\Services\Intune\SecretClassificationService;
|
||||
use App\Support\Baselines\BaselineReasonCodes;
|
||||
use App\Support\Operations\ExecutionDenialReasonCode;
|
||||
use App\Support\Providers\ProviderReasonCodes;
|
||||
|
||||
@ -130,7 +131,9 @@ public static function isStructuredOperatorReasonCode(string $candidate): bool
|
||||
ExecutionDenialReasonCode::cases(),
|
||||
);
|
||||
|
||||
return ProviderReasonCodes::isKnown($candidate) || in_array($candidate, $executionDenialReasonCodes, true);
|
||||
return ProviderReasonCodes::isKnown($candidate)
|
||||
|| BaselineReasonCodes::isKnown($candidate)
|
||||
|| in_array($candidate, $executionDenialReasonCodes, true);
|
||||
}
|
||||
|
||||
public static function sanitizeMessage(string $message): string
|
||||
|
||||
@ -4,6 +4,7 @@
|
||||
|
||||
namespace App\Support\ReasonTranslation;
|
||||
|
||||
use App\Support\Baselines\BaselineReasonCodes;
|
||||
use App\Support\Operations\ExecutionDenialReasonCode;
|
||||
use App\Support\Providers\ProviderReasonCodes;
|
||||
use App\Support\Providers\ProviderReasonTranslator;
|
||||
@ -43,6 +44,8 @@ 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 === self::TENANT_OPERABILITY_ARTIFACT,
|
||||
@ -74,4 +77,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,
|
||||
],
|
||||
};
|
||||
|
||||
|
||||
@ -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,
|
||||
};
|
||||
}
|
||||
};
|
||||
@ -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>
|
||||
|
||||
|
||||
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
|
||||
@ -13,6 +13,8 @@
|
||||
use App\Services\Intune\AuditLogger;
|
||||
use App\Services\OperationRunService;
|
||||
use App\Support\Baselines\BaselineProfileStatus;
|
||||
use App\Support\Baselines\BaselineReasonCodes;
|
||||
use App\Support\Baselines\BaselineSnapshotLifecycleState;
|
||||
use App\Support\Baselines\BaselineSubjectKey;
|
||||
use Illuminate\Support\Facades\Queue;
|
||||
|
||||
@ -206,6 +208,11 @@
|
||||
->first();
|
||||
|
||||
expect($snapshot)->not->toBeNull();
|
||||
expect($snapshot?->lifecycleState())->toBe(BaselineSnapshotLifecycleState::Complete);
|
||||
expect($snapshot?->completed_at)->not->toBeNull();
|
||||
expect($snapshot?->failed_at)->toBeNull();
|
||||
expect(data_get($snapshot?->completion_meta_jsonb ?? [], 'expected_items'))->toBe(3);
|
||||
expect(data_get($snapshot?->completion_meta_jsonb ?? [], 'persisted_items'))->toBe(3);
|
||||
expect(BaselineSnapshotItem::query()->where('baseline_snapshot_id', $snapshot->getKey())->count())->toBe(3);
|
||||
|
||||
$builder = app(InventoryMetaContract::class);
|
||||
@ -266,6 +273,37 @@
|
||||
expect($profile->active_snapshot_id)->toBe((int) $snapshot->getKey());
|
||||
});
|
||||
|
||||
it('preserves the previous complete snapshot when a newer snapshot is incomplete', function () {
|
||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||
|
||||
$profile = BaselineProfile::factory()->active()->create([
|
||||
'workspace_id' => $tenant->workspace_id,
|
||||
]);
|
||||
|
||||
$previousSnapshot = BaselineSnapshot::factory()->complete()->create([
|
||||
'workspace_id' => $tenant->workspace_id,
|
||||
'baseline_profile_id' => $profile->getKey(),
|
||||
'captured_at' => now()->subDay(),
|
||||
'completed_at' => now()->subDay(),
|
||||
]);
|
||||
|
||||
$newSnapshot = BaselineSnapshot::factory()->incomplete(BaselineReasonCodes::SNAPSHOT_CAPTURE_FAILED)->create([
|
||||
'workspace_id' => $tenant->workspace_id,
|
||||
'baseline_profile_id' => $profile->getKey(),
|
||||
'captured_at' => now(),
|
||||
]);
|
||||
|
||||
$profile->update(['active_snapshot_id' => (int) $previousSnapshot->getKey()]);
|
||||
$profile->refresh();
|
||||
|
||||
expect($newSnapshot->lifecycleState())->toBe(BaselineSnapshotLifecycleState::Incomplete)
|
||||
->and(data_get($newSnapshot->completion_meta_jsonb, 'finalization_reason_code'))->toBe(BaselineReasonCodes::SNAPSHOT_CAPTURE_FAILED);
|
||||
|
||||
expect($profile->resolveCurrentConsumableSnapshot()?->is($previousSnapshot))->toBeTrue()
|
||||
->and($profile->resolveLatestAttemptedSnapshot()?->is($newSnapshot))->toBeTrue()
|
||||
->and($profile->active_snapshot_id)->toBe((int) $previousSnapshot->getKey());
|
||||
});
|
||||
|
||||
it('dedupes snapshots when content is unchanged', function () {
|
||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||
|
||||
|
||||
115
tests/Feature/Baselines/BaselineCompareExecutionGuardTest.php
Normal file
115
tests/Feature/Baselines/BaselineCompareExecutionGuardTest.php
Normal file
@ -0,0 +1,115 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Jobs\CompareBaselineToTenantJob;
|
||||
use App\Models\BaselineProfile;
|
||||
use App\Models\BaselineSnapshot;
|
||||
use App\Models\OperationRun;
|
||||
use App\Services\Baselines\BaselineSnapshotIdentity;
|
||||
use App\Services\Intune\AuditLogger;
|
||||
use App\Services\OperationRunService;
|
||||
use App\Support\Baselines\BaselineReasonCodes;
|
||||
use App\Support\OperationRunOutcome;
|
||||
use App\Support\OperationRunStatus;
|
||||
use App\Support\OperationRunType;
|
||||
|
||||
it('blocks compare execution when the queued snapshot is incomplete', function (): void {
|
||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||
|
||||
$profile = BaselineProfile::factory()->active()->create([
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'scope_jsonb' => ['policy_types' => ['deviceConfiguration'], 'foundation_types' => []],
|
||||
]);
|
||||
|
||||
$snapshot = BaselineSnapshot::factory()->incomplete()->create([
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'baseline_profile_id' => (int) $profile->getKey(),
|
||||
]);
|
||||
|
||||
createInventorySyncOperationRunWithCoverage($tenant, ['deviceConfiguration' => 'succeeded']);
|
||||
|
||||
$run = OperationRun::factory()->create([
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'user_id' => (int) $user->getKey(),
|
||||
'type' => OperationRunType::BaselineCompare->value,
|
||||
'status' => OperationRunStatus::Queued->value,
|
||||
'outcome' => OperationRunOutcome::Pending->value,
|
||||
'context' => [
|
||||
'baseline_profile_id' => (int) $profile->getKey(),
|
||||
'baseline_snapshot_id' => (int) $snapshot->getKey(),
|
||||
'effective_scope' => ['policy_types' => ['deviceConfiguration'], 'foundation_types' => []],
|
||||
],
|
||||
]);
|
||||
|
||||
(new CompareBaselineToTenantJob($run))->handle(
|
||||
app(BaselineSnapshotIdentity::class),
|
||||
app(AuditLogger::class),
|
||||
app(OperationRunService::class),
|
||||
);
|
||||
|
||||
$run->refresh();
|
||||
$context = is_array($run->context) ? $run->context : [];
|
||||
|
||||
expect($run->status)->toBe(OperationRunStatus::Completed->value)
|
||||
->and($run->outcome)->toBe(OperationRunOutcome::Blocked->value)
|
||||
->and(data_get($context, 'baseline_compare.reason_code'))->toBe(BaselineReasonCodes::COMPARE_SNAPSHOT_INCOMPLETE)
|
||||
->and(data_get($context, 'result.snapshot_id'))->toBe((int) $snapshot->getKey())
|
||||
->and(data_get($run->failure_summary, '0.reason_code'))->toBe(BaselineReasonCodes::COMPARE_SNAPSHOT_INCOMPLETE);
|
||||
});
|
||||
|
||||
it('blocks compare execution when the queued snapshot is no longer the effective current baseline', function (): void {
|
||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||
|
||||
$profile = BaselineProfile::factory()->active()->create([
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'scope_jsonb' => ['policy_types' => ['deviceConfiguration'], 'foundation_types' => []],
|
||||
]);
|
||||
|
||||
$historicalSnapshot = BaselineSnapshot::factory()->complete()->create([
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'baseline_profile_id' => (int) $profile->getKey(),
|
||||
'captured_at' => now()->subDay(),
|
||||
'completed_at' => now()->subDay(),
|
||||
]);
|
||||
|
||||
$currentSnapshot = BaselineSnapshot::factory()->complete()->create([
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'baseline_profile_id' => (int) $profile->getKey(),
|
||||
'captured_at' => now(),
|
||||
'completed_at' => now(),
|
||||
]);
|
||||
|
||||
$profile->update(['active_snapshot_id' => (int) $currentSnapshot->getKey()]);
|
||||
createInventorySyncOperationRunWithCoverage($tenant, ['deviceConfiguration' => 'succeeded']);
|
||||
|
||||
$run = OperationRun::factory()->create([
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'user_id' => (int) $user->getKey(),
|
||||
'type' => OperationRunType::BaselineCompare->value,
|
||||
'status' => OperationRunStatus::Queued->value,
|
||||
'outcome' => OperationRunOutcome::Pending->value,
|
||||
'context' => [
|
||||
'baseline_profile_id' => (int) $profile->getKey(),
|
||||
'baseline_snapshot_id' => (int) $historicalSnapshot->getKey(),
|
||||
'effective_scope' => ['policy_types' => ['deviceConfiguration'], 'foundation_types' => []],
|
||||
],
|
||||
]);
|
||||
|
||||
(new CompareBaselineToTenantJob($run))->handle(
|
||||
app(BaselineSnapshotIdentity::class),
|
||||
app(AuditLogger::class),
|
||||
app(OperationRunService::class),
|
||||
);
|
||||
|
||||
$run->refresh();
|
||||
$context = is_array($run->context) ? $run->context : [];
|
||||
|
||||
expect($run->outcome)->toBe(OperationRunOutcome::Blocked->value)
|
||||
->and(data_get($context, 'baseline_compare.reason_code'))->toBe(BaselineReasonCodes::COMPARE_SNAPSHOT_SUPERSEDED)
|
||||
->and(data_get($context, 'baseline_compare.effective_snapshot_id'))->toBe((int) $currentSnapshot->getKey())
|
||||
->and(data_get($context, 'baseline_compare.latest_attempted_snapshot_id'))->toBe((int) $currentSnapshot->getKey())
|
||||
->and(data_get($run->failure_summary, '0.reason_code'))->toBe(BaselineReasonCodes::COMPARE_SNAPSHOT_SUPERSEDED);
|
||||
});
|
||||
@ -79,7 +79,7 @@
|
||||
expect(OperationRun::query()->where('type', OperationRunType::BaselineCompare->value)->count())->toBe(0);
|
||||
});
|
||||
|
||||
it('rejects compare when profile has no active snapshot', function () {
|
||||
it('rejects compare when profile has no consumable snapshot', function () {
|
||||
Queue::fake();
|
||||
|
||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||
@ -99,12 +99,83 @@
|
||||
$result = $service->startCompare($tenant, $user);
|
||||
|
||||
expect($result['ok'])->toBeFalse();
|
||||
expect($result['reason_code'])->toBe(BaselineReasonCodes::COMPARE_NO_ACTIVE_SNAPSHOT);
|
||||
expect($result['reason_code'])->toBe(BaselineReasonCodes::COMPARE_NO_CONSUMABLE_SNAPSHOT);
|
||||
|
||||
Queue::assertNotPushed(CompareBaselineToTenantJob::class);
|
||||
expect(OperationRun::query()->where('type', OperationRunType::BaselineCompare->value)->count())->toBe(0);
|
||||
});
|
||||
|
||||
it('rejects compare when the latest attempted snapshot is still building and no complete snapshot exists', function () {
|
||||
Queue::fake();
|
||||
|
||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||
|
||||
$profile = BaselineProfile::factory()->active()->create([
|
||||
'workspace_id' => $tenant->workspace_id,
|
||||
'active_snapshot_id' => null,
|
||||
]);
|
||||
|
||||
BaselineSnapshot::factory()->building()->create([
|
||||
'workspace_id' => $tenant->workspace_id,
|
||||
'baseline_profile_id' => $profile->getKey(),
|
||||
]);
|
||||
|
||||
BaselineTenantAssignment::create([
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'baseline_profile_id' => (int) $profile->getKey(),
|
||||
]);
|
||||
|
||||
$service = app(BaselineCompareService::class);
|
||||
$result = $service->startCompare($tenant, $user);
|
||||
|
||||
expect($result['ok'])->toBeFalse();
|
||||
expect($result['reason_code'])->toBe(BaselineReasonCodes::COMPARE_SNAPSHOT_BUILDING);
|
||||
|
||||
Queue::assertNotPushed(CompareBaselineToTenantJob::class);
|
||||
});
|
||||
|
||||
it('rejects explicit snapshot overrides when the snapshot is historically superseded', function () {
|
||||
Queue::fake();
|
||||
|
||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||
|
||||
$profile = BaselineProfile::factory()->active()->create([
|
||||
'workspace_id' => $tenant->workspace_id,
|
||||
'scope_jsonb' => ['policy_types' => ['deviceConfiguration'], 'foundation_types' => []],
|
||||
]);
|
||||
|
||||
$historicalSnapshot = BaselineSnapshot::factory()->create([
|
||||
'workspace_id' => $tenant->workspace_id,
|
||||
'baseline_profile_id' => $profile->getKey(),
|
||||
'captured_at' => now()->subHour(),
|
||||
'completed_at' => now()->subHour(),
|
||||
]);
|
||||
|
||||
$currentSnapshot = BaselineSnapshot::factory()->create([
|
||||
'workspace_id' => $tenant->workspace_id,
|
||||
'baseline_profile_id' => $profile->getKey(),
|
||||
'captured_at' => now(),
|
||||
'completed_at' => now(),
|
||||
]);
|
||||
|
||||
$profile->update(['active_snapshot_id' => $currentSnapshot->getKey()]);
|
||||
|
||||
BaselineTenantAssignment::create([
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'baseline_profile_id' => (int) $profile->getKey(),
|
||||
]);
|
||||
|
||||
$service = app(BaselineCompareService::class);
|
||||
$result = $service->startCompare($tenant, $user, baselineSnapshotId: (int) $historicalSnapshot->getKey());
|
||||
|
||||
expect($result['ok'])->toBeFalse();
|
||||
expect($result['reason_code'])->toBe(BaselineReasonCodes::COMPARE_SNAPSHOT_SUPERSEDED);
|
||||
|
||||
Queue::assertNotPushed(CompareBaselineToTenantJob::class);
|
||||
});
|
||||
|
||||
it('enqueues compare successfully when all preconditions are met', function () {
|
||||
Queue::fake();
|
||||
|
||||
|
||||
@ -25,7 +25,7 @@
|
||||
->and($stats->profileName)->toBeNull();
|
||||
});
|
||||
|
||||
it('returns no_snapshot state when profile has no active snapshot', function (): void {
|
||||
it('returns no_snapshot state when profile has no consumable snapshot', function (): void {
|
||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||
|
||||
$profile = BaselineProfile::factory()->active()->create([
|
||||
@ -42,7 +42,70 @@
|
||||
$stats = BaselineCompareStats::forTenant($tenant);
|
||||
|
||||
expect($stats->state)->toBe('no_snapshot')
|
||||
->and($stats->profileName)->toBe($profile->name);
|
||||
->and($stats->profileName)->toBe($profile->name)
|
||||
->and($stats->reasonCode)->toBe('baseline.compare.no_consumable_snapshot');
|
||||
});
|
||||
|
||||
it('falls back to the latest complete snapshot when a newer attempt is incomplete', function (): void {
|
||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||
|
||||
$profile = BaselineProfile::factory()->active()->create([
|
||||
'workspace_id' => $tenant->workspace_id,
|
||||
'active_snapshot_id' => null,
|
||||
]);
|
||||
|
||||
$completeSnapshot = BaselineSnapshot::factory()->complete()->create([
|
||||
'workspace_id' => $tenant->workspace_id,
|
||||
'baseline_profile_id' => $profile->getKey(),
|
||||
'captured_at' => now()->subHour(),
|
||||
'completed_at' => now()->subHour(),
|
||||
]);
|
||||
|
||||
BaselineSnapshot::factory()->incomplete()->create([
|
||||
'workspace_id' => $tenant->workspace_id,
|
||||
'baseline_profile_id' => $profile->getKey(),
|
||||
'captured_at' => now(),
|
||||
]);
|
||||
|
||||
$profile->update(['active_snapshot_id' => $completeSnapshot->getKey()]);
|
||||
|
||||
BaselineTenantAssignment::factory()->create([
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'baseline_profile_id' => (int) $profile->getKey(),
|
||||
]);
|
||||
|
||||
$stats = BaselineCompareStats::forTenant($tenant);
|
||||
|
||||
expect($stats->state)->toBe('idle')
|
||||
->and($stats->snapshotId)->toBe((int) $completeSnapshot->getKey())
|
||||
->and($stats->reasonCode)->toBeNull();
|
||||
});
|
||||
|
||||
it('reports building as the blocking reason when no complete snapshot exists yet', function (): void {
|
||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||
|
||||
$profile = BaselineProfile::factory()->active()->create([
|
||||
'workspace_id' => $tenant->workspace_id,
|
||||
'active_snapshot_id' => null,
|
||||
]);
|
||||
|
||||
BaselineSnapshot::factory()->building()->create([
|
||||
'workspace_id' => $tenant->workspace_id,
|
||||
'baseline_profile_id' => $profile->getKey(),
|
||||
]);
|
||||
|
||||
BaselineTenantAssignment::factory()->create([
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'baseline_profile_id' => (int) $profile->getKey(),
|
||||
]);
|
||||
|
||||
$stats = BaselineCompareStats::forTenant($tenant);
|
||||
|
||||
expect($stats->state)->toBe('no_snapshot')
|
||||
->and($stats->reasonCode)->toBe('baseline.compare.snapshot_building')
|
||||
->and($stats->reasonMessage)->toContain('building');
|
||||
});
|
||||
|
||||
it('returns comparing state when a run is queued', function (): void {
|
||||
|
||||
113
tests/Feature/Baselines/BaselineSnapshotBackfillTest.php
Normal file
113
tests/Feature/Baselines/BaselineSnapshotBackfillTest.php
Normal file
@ -0,0 +1,113 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Models\BaselineProfile;
|
||||
use App\Models\BaselineSnapshot;
|
||||
use App\Models\BaselineSnapshotItem;
|
||||
use App\Models\OperationRun;
|
||||
use App\Models\Workspace;
|
||||
use App\Support\Baselines\BaselineReasonCodes;
|
||||
use App\Support\Baselines\BaselineSnapshotLifecycleState;
|
||||
use App\Support\OperationRunOutcome;
|
||||
use App\Support\OperationRunType;
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
|
||||
function classifyLegacySnapshotForTest(BaselineSnapshot $snapshot): array
|
||||
{
|
||||
/** @var Migration $migration */
|
||||
$migration = require base_path('database/migrations/2026_03_23_000001_add_lifecycle_state_to_baseline_snapshots_table.php');
|
||||
|
||||
$reflection = new ReflectionMethod($migration, 'classifyLegacySnapshot');
|
||||
$reflection->setAccessible(true);
|
||||
|
||||
return $reflection->invoke(
|
||||
$migration,
|
||||
(object) [
|
||||
'id' => (int) $snapshot->getKey(),
|
||||
'workspace_id' => (int) $snapshot->workspace_id,
|
||||
'baseline_profile_id' => (int) $snapshot->baseline_profile_id,
|
||||
'captured_at' => $snapshot->captured_at,
|
||||
'created_at' => $snapshot->created_at,
|
||||
'updated_at' => $snapshot->updated_at,
|
||||
'summary_jsonb' => json_encode($snapshot->summary_jsonb),
|
||||
],
|
||||
is_array($snapshot->summary_jsonb) ? $snapshot->summary_jsonb : [],
|
||||
BaselineSnapshotItem::query()->where('baseline_snapshot_id', (int) $snapshot->getKey())->count(),
|
||||
);
|
||||
}
|
||||
|
||||
it('classifies legacy snapshots as complete when summary counts prove completion', function (): void {
|
||||
$workspace = Workspace::factory()->create();
|
||||
$profile = BaselineProfile::factory()->active()->create([
|
||||
'workspace_id' => (int) $workspace->getKey(),
|
||||
]);
|
||||
|
||||
$snapshot = BaselineSnapshot::factory()->complete()->create([
|
||||
'workspace_id' => (int) $workspace->getKey(),
|
||||
'baseline_profile_id' => (int) $profile->getKey(),
|
||||
'summary_jsonb' => ['total_items' => 2],
|
||||
]);
|
||||
|
||||
BaselineSnapshotItem::factory()->count(2)->create([
|
||||
'baseline_snapshot_id' => (int) $snapshot->getKey(),
|
||||
]);
|
||||
|
||||
$classification = classifyLegacySnapshotForTest($snapshot);
|
||||
|
||||
expect($classification['lifecycle_state'])->toBe(BaselineSnapshotLifecycleState::Complete->value)
|
||||
->and(data_get($classification, 'completion_meta.expected_items'))->toBe(2)
|
||||
->and(data_get($classification, 'completion_meta.persisted_items'))->toBe(2);
|
||||
});
|
||||
|
||||
it('classifies proven empty legacy captures as complete when the producer run confirms zero subjects', function (): void {
|
||||
$workspace = Workspace::factory()->create();
|
||||
$profile = BaselineProfile::factory()->active()->create([
|
||||
'workspace_id' => (int) $workspace->getKey(),
|
||||
]);
|
||||
|
||||
$snapshot = BaselineSnapshot::factory()->complete()->create([
|
||||
'workspace_id' => (int) $workspace->getKey(),
|
||||
'baseline_profile_id' => (int) $profile->getKey(),
|
||||
'summary_jsonb' => ['total_items' => 0],
|
||||
]);
|
||||
|
||||
OperationRun::factory()->create([
|
||||
'workspace_id' => (int) $workspace->getKey(),
|
||||
'type' => OperationRunType::BaselineCapture->value,
|
||||
'outcome' => OperationRunOutcome::Succeeded->value,
|
||||
'context' => [
|
||||
'baseline_profile_id' => (int) $profile->getKey(),
|
||||
'result' => ['snapshot_id' => (int) $snapshot->getKey()],
|
||||
'baseline_capture' => ['subjects_total' => 0],
|
||||
],
|
||||
'completed_at' => now(),
|
||||
]);
|
||||
|
||||
$classification = classifyLegacySnapshotForTest($snapshot);
|
||||
|
||||
expect($classification['lifecycle_state'])->toBe(BaselineSnapshotLifecycleState::Complete->value)
|
||||
->and(data_get($classification, 'completion_meta.was_empty_capture'))->toBeTrue();
|
||||
});
|
||||
|
||||
it('classifies ambiguous legacy snapshots as incomplete with a conservative reason code', function (): void {
|
||||
$workspace = Workspace::factory()->create();
|
||||
$profile = BaselineProfile::factory()->active()->create([
|
||||
'workspace_id' => (int) $workspace->getKey(),
|
||||
]);
|
||||
|
||||
$snapshot = BaselineSnapshot::factory()->complete()->create([
|
||||
'workspace_id' => (int) $workspace->getKey(),
|
||||
'baseline_profile_id' => (int) $profile->getKey(),
|
||||
'summary_jsonb' => ['total_items' => 2],
|
||||
]);
|
||||
|
||||
BaselineSnapshotItem::factory()->create([
|
||||
'baseline_snapshot_id' => (int) $snapshot->getKey(),
|
||||
]);
|
||||
|
||||
$classification = classifyLegacySnapshotForTest($snapshot);
|
||||
|
||||
expect($classification['lifecycle_state'])->toBe(BaselineSnapshotLifecycleState::Incomplete->value)
|
||||
->and(data_get($classification, 'completion_meta.finalization_reason_code'))->toBe(BaselineReasonCodes::SNAPSHOT_LEGACY_CONTRADICTORY);
|
||||
});
|
||||
@ -0,0 +1,59 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Models\BaselineProfile;
|
||||
use App\Models\BaselineSnapshot;
|
||||
use App\Models\Workspace;
|
||||
use App\Support\Baselines\BaselineReasonCodes;
|
||||
use App\Support\Ui\GovernanceArtifactTruth\ArtifactTruthPresenter;
|
||||
|
||||
it('keeps incomplete lifecycle reasons and producing-run links queryable on the snapshot record', function (): void {
|
||||
$workspace = Workspace::factory()->create();
|
||||
$profile = BaselineProfile::factory()->active()->create([
|
||||
'workspace_id' => (int) $workspace->getKey(),
|
||||
]);
|
||||
|
||||
$snapshot = BaselineSnapshot::factory()->incomplete(BaselineReasonCodes::SNAPSHOT_CAPTURE_FAILED)->create([
|
||||
'workspace_id' => (int) $workspace->getKey(),
|
||||
'baseline_profile_id' => (int) $profile->getKey(),
|
||||
'completion_meta_jsonb' => [
|
||||
'producer_run_id' => 42,
|
||||
'finalization_reason_code' => BaselineReasonCodes::SNAPSHOT_CAPTURE_FAILED,
|
||||
'persisted_items' => 17,
|
||||
],
|
||||
]);
|
||||
|
||||
expect(data_get($snapshot->completion_meta_jsonb, 'producer_run_id'))->toBe(42)
|
||||
->and(data_get($snapshot->completion_meta_jsonb, 'finalization_reason_code'))->toBe(BaselineReasonCodes::SNAPSHOT_CAPTURE_FAILED)
|
||||
->and(data_get($snapshot->completion_meta_jsonb, 'persisted_items'))->toBe(17);
|
||||
});
|
||||
|
||||
it('derives historical baseline truth from the latest complete snapshot instead of mutating older lifecycle state', function (): void {
|
||||
$workspace = Workspace::factory()->create();
|
||||
$profile = BaselineProfile::factory()->active()->create([
|
||||
'workspace_id' => (int) $workspace->getKey(),
|
||||
]);
|
||||
|
||||
$historicalSnapshot = BaselineSnapshot::factory()->complete()->create([
|
||||
'workspace_id' => (int) $workspace->getKey(),
|
||||
'baseline_profile_id' => (int) $profile->getKey(),
|
||||
'captured_at' => now()->subDay(),
|
||||
'completed_at' => now()->subDay(),
|
||||
]);
|
||||
|
||||
$currentSnapshot = BaselineSnapshot::factory()->complete()->create([
|
||||
'workspace_id' => (int) $workspace->getKey(),
|
||||
'baseline_profile_id' => (int) $profile->getKey(),
|
||||
'captured_at' => now(),
|
||||
'completed_at' => now(),
|
||||
]);
|
||||
|
||||
$profile->update(['active_snapshot_id' => (int) $currentSnapshot->getKey()]);
|
||||
|
||||
$truth = app(ArtifactTruthPresenter::class)->forBaselineSnapshot($historicalSnapshot->fresh());
|
||||
|
||||
expect($historicalSnapshot->lifecycleState()->value)->toBe('complete')
|
||||
->and($truth->artifactExistence)->toBe('historical_only')
|
||||
->and($truth->diagnosticLabel)->toBe('Superseded');
|
||||
});
|
||||
72
tests/Feature/Filament/BaselineActionAuthorizationTest.php
Normal file
72
tests/Feature/Filament/BaselineActionAuthorizationTest.php
Normal file
@ -0,0 +1,72 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Filament\Pages\BaselineCompareLanding;
|
||||
use App\Filament\Resources\BaselineProfileResource\Pages\ViewBaselineProfile;
|
||||
use App\Models\BaselineProfile;
|
||||
use App\Models\BaselineSnapshot;
|
||||
use App\Models\BaselineTenantAssignment;
|
||||
use App\Support\Workspaces\WorkspaceContext;
|
||||
use Filament\Facades\Filament;
|
||||
use Livewire\Livewire;
|
||||
|
||||
it('keeps baseline capture and compare actions capability-gated on the profile detail page', function (): void {
|
||||
[$readonlyUser, $tenant] = createUserWithTenant(role: 'readonly');
|
||||
[$ownerUser] = createUserWithTenant(tenant: $tenant, role: 'owner');
|
||||
|
||||
$profile = BaselineProfile::factory()->active()->create([
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
]);
|
||||
|
||||
$snapshot = BaselineSnapshot::factory()->complete()->create([
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'baseline_profile_id' => (int) $profile->getKey(),
|
||||
]);
|
||||
|
||||
$profile->update(['active_snapshot_id' => (int) $snapshot->getKey()]);
|
||||
session()->put(WorkspaceContext::SESSION_KEY, (int) $tenant->workspace_id);
|
||||
|
||||
Livewire::actingAs($readonlyUser)
|
||||
->test(ViewBaselineProfile::class, ['record' => $profile->getKey()])
|
||||
->assertActionDisabled('capture')
|
||||
->assertActionDisabled('compareNow');
|
||||
|
||||
Livewire::actingAs($ownerUser)
|
||||
->test(ViewBaselineProfile::class, ['record' => $profile->getKey()])
|
||||
->assertActionEnabled('capture')
|
||||
->assertActionDisabled('compareNow');
|
||||
});
|
||||
|
||||
it('keeps tenant compare actions disabled for users without tenant.sync and enabled for owners', function (): void {
|
||||
[$readonlyUser, $tenant] = createUserWithTenant(role: 'readonly');
|
||||
[$ownerUser] = createUserWithTenant(tenant: $tenant, role: 'owner');
|
||||
|
||||
$profile = BaselineProfile::factory()->active()->create([
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
]);
|
||||
|
||||
$snapshot = BaselineSnapshot::factory()->complete()->create([
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'baseline_profile_id' => (int) $profile->getKey(),
|
||||
]);
|
||||
|
||||
$profile->update(['active_snapshot_id' => (int) $snapshot->getKey()]);
|
||||
|
||||
BaselineTenantAssignment::factory()->create([
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'baseline_profile_id' => (int) $profile->getKey(),
|
||||
]);
|
||||
|
||||
$tenant->makeCurrent();
|
||||
Filament::setTenant($tenant, true);
|
||||
|
||||
Livewire::actingAs($readonlyUser)
|
||||
->test(BaselineCompareLanding::class)
|
||||
->assertActionDisabled('compareNow');
|
||||
|
||||
Livewire::actingAs($ownerUser)
|
||||
->test(BaselineCompareLanding::class)
|
||||
->assertActionEnabled('compareNow');
|
||||
});
|
||||
@ -88,7 +88,8 @@
|
||||
$this->actingAs($user)
|
||||
->get(BaselineSnapshotResource::getUrl('view', ['record' => $snapshot], panel: 'admin'))
|
||||
->assertOk()
|
||||
->assertSee('Snapshot status')
|
||||
->assertSee('Snapshot truth')
|
||||
->assertSee('Coverage')
|
||||
->assertSee('Capture timing')
|
||||
->assertSee('Related context')
|
||||
->assertSee(BaselineProfileResource::getUrl('view', ['record' => $profile], panel: 'admin'), false)
|
||||
|
||||
120
tests/Feature/Filament/BaselineSnapshotTruthSurfaceTest.php
Normal file
120
tests/Feature/Filament/BaselineSnapshotTruthSurfaceTest.php
Normal file
@ -0,0 +1,120 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Filament\Resources\BaselineProfileResource;
|
||||
use App\Filament\Resources\BaselineSnapshotResource;
|
||||
use App\Models\BaselineProfile;
|
||||
use App\Models\BaselineSnapshot;
|
||||
use App\Support\Workspaces\WorkspaceContext;
|
||||
|
||||
it('shows effective and latest baseline truth separately on the baseline profile detail page', function (): void {
|
||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||
|
||||
$profile = BaselineProfile::factory()->active()->create([
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'name' => 'Security Baseline',
|
||||
]);
|
||||
|
||||
$currentSnapshot = BaselineSnapshot::factory()->complete()->create([
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'baseline_profile_id' => (int) $profile->getKey(),
|
||||
'captured_at' => now()->subHour(),
|
||||
'completed_at' => now()->subHour(),
|
||||
]);
|
||||
|
||||
$latestAttempt = BaselineSnapshot::factory()->incomplete()->create([
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'baseline_profile_id' => (int) $profile->getKey(),
|
||||
'captured_at' => now(),
|
||||
]);
|
||||
|
||||
$profile->update(['active_snapshot_id' => (int) $currentSnapshot->getKey()]);
|
||||
session()->put(WorkspaceContext::SESSION_KEY, (int) $tenant->workspace_id);
|
||||
|
||||
$this->actingAs($user)
|
||||
->get(BaselineProfileResource::getUrl('view', ['record' => $profile], panel: 'admin'))
|
||||
->assertOk()
|
||||
->assertSee('Baseline truth')
|
||||
->assertSee('Current snapshot')
|
||||
->assertSee('Snapshot #'.$currentSnapshot->getKey().' (Complete)')
|
||||
->assertSee('Latest attempt')
|
||||
->assertSee('Snapshot #'.$latestAttempt->getKey().' (Incomplete)')
|
||||
->assertSee('Compare readiness')
|
||||
->assertSee('No eligible compare target')
|
||||
->assertSee('Assign this baseline to a tenant you can compare, or use an account with access to an assigned tenant.');
|
||||
});
|
||||
|
||||
it('shows compare readiness as ready when a consumable snapshot and eligible target tenant exist', function (): void {
|
||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||
|
||||
$profile = BaselineProfile::factory()->active()->create([
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'name' => 'Security Baseline',
|
||||
]);
|
||||
|
||||
$currentSnapshot = BaselineSnapshot::factory()->complete()->create([
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'baseline_profile_id' => (int) $profile->getKey(),
|
||||
'captured_at' => now()->subHour(),
|
||||
'completed_at' => now()->subHour(),
|
||||
]);
|
||||
|
||||
$profile->update(['active_snapshot_id' => (int) $currentSnapshot->getKey()]);
|
||||
|
||||
\App\Models\BaselineTenantAssignment::factory()->create([
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'baseline_profile_id' => (int) $profile->getKey(),
|
||||
]);
|
||||
|
||||
session()->put(WorkspaceContext::SESSION_KEY, (int) $tenant->workspace_id);
|
||||
|
||||
$this->actingAs($user)
|
||||
->get(BaselineProfileResource::getUrl('view', ['record' => $profile], panel: 'admin'))
|
||||
->assertOk()
|
||||
->assertSee('Compare readiness')
|
||||
->assertSee('Ready')
|
||||
->assertSee('No action needed.');
|
||||
});
|
||||
|
||||
it('shows lifecycle and current-truth state across baseline snapshot list and detail surfaces', function (): void {
|
||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||
|
||||
$profile = BaselineProfile::factory()->active()->create([
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'name' => 'Security Baseline',
|
||||
]);
|
||||
|
||||
$historicalSnapshot = BaselineSnapshot::factory()->complete()->create([
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'baseline_profile_id' => (int) $profile->getKey(),
|
||||
'captured_at' => now()->subDay(),
|
||||
'completed_at' => now()->subDay(),
|
||||
]);
|
||||
|
||||
$currentSnapshot = BaselineSnapshot::factory()->complete()->create([
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'baseline_profile_id' => (int) $profile->getKey(),
|
||||
'captured_at' => now(),
|
||||
'completed_at' => now(),
|
||||
]);
|
||||
|
||||
$profile->update(['active_snapshot_id' => (int) $currentSnapshot->getKey()]);
|
||||
session()->put(WorkspaceContext::SESSION_KEY, (int) $tenant->workspace_id);
|
||||
|
||||
$this->actingAs($user)
|
||||
->get(BaselineSnapshotResource::getUrl(panel: 'admin'))
|
||||
->assertOk()
|
||||
->assertSee('Lifecycle')
|
||||
->assertSee('Current truth')
|
||||
->assertSee('Historical trace')
|
||||
->assertSee('Current baseline');
|
||||
|
||||
$this->actingAs($user)
|
||||
->get(BaselineSnapshotResource::getUrl('view', ['record' => $historicalSnapshot], panel: 'admin'))
|
||||
->assertOk()
|
||||
->assertSee('Snapshot truth')
|
||||
->assertSee('Historical trace')
|
||||
->assertSee('Superseded');
|
||||
});
|
||||
@ -0,0 +1,59 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Filament\Pages\Operations\TenantlessOperationRunViewer;
|
||||
use App\Models\BaselineProfile;
|
||||
use App\Models\BaselineSnapshot;
|
||||
use App\Models\OperationRun;
|
||||
use App\Support\Baselines\BaselineReasonCodes;
|
||||
use App\Support\Workspaces\WorkspaceContext;
|
||||
use Filament\Facades\Filament;
|
||||
use Livewire\Livewire;
|
||||
|
||||
it('shows run outcome and baseline artifact truth as separate facts on the run detail page', function (): void {
|
||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||
|
||||
$profile = BaselineProfile::factory()->active()->create([
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
]);
|
||||
|
||||
$snapshot = BaselineSnapshot::factory()->incomplete(BaselineReasonCodes::SNAPSHOT_CAPTURE_FAILED)->create([
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'baseline_profile_id' => (int) $profile->getKey(),
|
||||
]);
|
||||
|
||||
$run = OperationRun::factory()->create([
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'type' => 'baseline_capture',
|
||||
'status' => 'completed',
|
||||
'outcome' => 'failed',
|
||||
'context' => [
|
||||
'baseline_profile_id' => (int) $profile->getKey(),
|
||||
'baseline_snapshot_id' => (int) $snapshot->getKey(),
|
||||
'result' => [
|
||||
'snapshot_id' => (int) $snapshot->getKey(),
|
||||
'snapshot_lifecycle' => 'incomplete',
|
||||
],
|
||||
'reason_code' => BaselineReasonCodes::SNAPSHOT_CAPTURE_FAILED,
|
||||
],
|
||||
'failure_summary' => [
|
||||
['reason_code' => BaselineReasonCodes::SNAPSHOT_CAPTURE_FAILED, 'message' => 'Snapshot capture stopped after persistence failed.'],
|
||||
],
|
||||
'completed_at' => now(),
|
||||
]);
|
||||
|
||||
Filament::setTenant(null, true);
|
||||
$this->withSession([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id]);
|
||||
session([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id]);
|
||||
|
||||
Livewire::actingAs($user)
|
||||
->test(TenantlessOperationRunViewer::class, ['run' => $run])
|
||||
->assertSee('Outcome')
|
||||
->assertSee('Artifact truth')
|
||||
->assertSee('Execution failed')
|
||||
->assertSee('Artifact not usable')
|
||||
->assertSee('Artifact next step')
|
||||
->assertSee('Inspect the related capture diagnostics before using this snapshot');
|
||||
});
|
||||
61
tests/Feature/Operations/BaselineOperationRunGuardTest.php
Normal file
61
tests/Feature/Operations/BaselineOperationRunGuardTest.php
Normal file
@ -0,0 +1,61 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Jobs\CompareBaselineToTenantJob;
|
||||
use App\Models\BaselineProfile;
|
||||
use App\Models\BaselineSnapshot;
|
||||
use App\Models\OperationRun;
|
||||
use App\Services\Baselines\BaselineSnapshotIdentity;
|
||||
use App\Services\Intune\AuditLogger;
|
||||
use App\Services\OperationRunService;
|
||||
use App\Support\Baselines\BaselineReasonCodes;
|
||||
use App\Support\OperationRunOutcome;
|
||||
use App\Support\OperationRunStatus;
|
||||
use App\Support\OperationRunType;
|
||||
|
||||
it('finalizes blocked baseline compare runs through the canonical completed-blocked transition', function (): void {
|
||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||
|
||||
$profile = BaselineProfile::factory()->active()->create([
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'scope_jsonb' => ['policy_types' => ['deviceConfiguration'], 'foundation_types' => []],
|
||||
]);
|
||||
|
||||
$snapshot = BaselineSnapshot::factory()->incomplete()->create([
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'baseline_profile_id' => (int) $profile->getKey(),
|
||||
]);
|
||||
|
||||
createInventorySyncOperationRunWithCoverage($tenant, ['deviceConfiguration' => 'succeeded']);
|
||||
|
||||
$run = OperationRun::factory()->create([
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'user_id' => (int) $user->getKey(),
|
||||
'type' => OperationRunType::BaselineCompare->value,
|
||||
'status' => OperationRunStatus::Queued->value,
|
||||
'outcome' => OperationRunOutcome::Pending->value,
|
||||
'summary_counts' => ['total' => 0],
|
||||
'context' => [
|
||||
'baseline_profile_id' => (int) $profile->getKey(),
|
||||
'baseline_snapshot_id' => (int) $snapshot->getKey(),
|
||||
'effective_scope' => ['policy_types' => ['deviceConfiguration'], 'foundation_types' => []],
|
||||
],
|
||||
]);
|
||||
|
||||
(new CompareBaselineToTenantJob($run))->handle(
|
||||
app(BaselineSnapshotIdentity::class),
|
||||
app(AuditLogger::class),
|
||||
app(OperationRunService::class),
|
||||
);
|
||||
|
||||
$run->refresh();
|
||||
|
||||
expect($run->status)->toBe(OperationRunStatus::Completed->value)
|
||||
->and($run->outcome)->toBe(OperationRunOutcome::Blocked->value)
|
||||
->and(data_get($run->context, 'reason_code'))->toBe(BaselineReasonCodes::COMPARE_SNAPSHOT_INCOMPLETE)
|
||||
->and(data_get($run->failure_summary, '0.code'))->toBe('operation.blocked')
|
||||
->and(data_get($run->failure_summary, '0.reason_code'))->toBe(BaselineReasonCodes::COMPARE_SNAPSHOT_INCOMPLETE)
|
||||
->and(data_get($run->summary_counts, 'total'))->toBeInt();
|
||||
});
|
||||
@ -2,9 +2,13 @@
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Models\BaselineProfile;
|
||||
use App\Models\BaselineSnapshot;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\Workspace;
|
||||
use App\Support\Badges\BadgeCatalog;
|
||||
use App\Support\Badges\BadgeDomain;
|
||||
use App\Support\Baselines\BaselineReasonCodes;
|
||||
use App\Support\Ui\GovernanceArtifactTruth\ArtifactTruthPresenter;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Tests\Feature\Concerns\BuildsGovernanceArtifactTruthFixtures;
|
||||
@ -70,3 +74,42 @@
|
||||
->and($truth->primaryLabel)->toBe('Publishable')
|
||||
->and($truth->nextStepText())->toBe('No action needed');
|
||||
});
|
||||
|
||||
it('keeps baseline snapshot lifecycle truth separate from current-vs-historical artifact truth', function (): void {
|
||||
$workspace = Workspace::factory()->create();
|
||||
$profile = BaselineProfile::factory()->active()->create([
|
||||
'workspace_id' => (int) $workspace->getKey(),
|
||||
]);
|
||||
|
||||
$historicalSnapshot = BaselineSnapshot::factory()->complete()->create([
|
||||
'workspace_id' => (int) $workspace->getKey(),
|
||||
'baseline_profile_id' => (int) $profile->getKey(),
|
||||
'captured_at' => now()->subDay(),
|
||||
'completed_at' => now()->subDay(),
|
||||
]);
|
||||
|
||||
$currentSnapshot = BaselineSnapshot::factory()->complete()->create([
|
||||
'workspace_id' => (int) $workspace->getKey(),
|
||||
'baseline_profile_id' => (int) $profile->getKey(),
|
||||
'captured_at' => now(),
|
||||
'completed_at' => now(),
|
||||
]);
|
||||
|
||||
$incompleteSnapshot = BaselineSnapshot::factory()->incomplete(BaselineReasonCodes::SNAPSHOT_CAPTURE_FAILED)->create([
|
||||
'workspace_id' => (int) $workspace->getKey(),
|
||||
'baseline_profile_id' => (int) $profile->getKey(),
|
||||
]);
|
||||
|
||||
$profile->update(['active_snapshot_id' => (int) $currentSnapshot->getKey()]);
|
||||
|
||||
$historicalTruth = app(ArtifactTruthPresenter::class)->forBaselineSnapshot($historicalSnapshot->fresh());
|
||||
$incompleteTruth = app(ArtifactTruthPresenter::class)->forBaselineSnapshot($incompleteSnapshot->fresh());
|
||||
|
||||
expect($historicalTruth->artifactExistence)->toBe('historical_only')
|
||||
->and($historicalTruth->diagnosticLabel)->toBe('Superseded')
|
||||
->and(BadgeCatalog::spec(BadgeDomain::BaselineSnapshotLifecycle, $historicalSnapshot->lifecycleState()->value)->label)->toBe('Complete');
|
||||
|
||||
expect($incompleteTruth->artifactExistence)->toBe('created_but_not_usable')
|
||||
->and($incompleteTruth->diagnosticLabel)->toBe('Incomplete')
|
||||
->and($incompleteTruth->reason?->reasonCode)->toBe(BaselineReasonCodes::SNAPSHOT_CAPTURE_FAILED);
|
||||
});
|
||||
|
||||
@ -11,7 +11,7 @@
|
||||
use App\Support\Badges\OperatorStateClassification;
|
||||
|
||||
it('defines curated examples for the first-slice adoption set', function (): void {
|
||||
expect(OperatorOutcomeTaxonomy::curatedExamples())->toHaveCount(12);
|
||||
expect(OperatorOutcomeTaxonomy::curatedExamples())->toHaveCount(17);
|
||||
});
|
||||
|
||||
it('registers taxonomy metadata for every first-slice entry', function (): void {
|
||||
@ -32,6 +32,8 @@
|
||||
->and(BadgeCatalog::spec(BadgeDomain::EvidenceCompleteness, 'missing')->label)->toBe('Not collected yet')
|
||||
->and(BadgeCatalog::spec(BadgeDomain::TenantReviewCompleteness, 'stale')->label)->toBe('Refresh review inputs')
|
||||
->and(BadgeCatalog::spec(BadgeDomain::BaselineSnapshotFidelity, 'unsupported')->label)->toBe('Support limited')
|
||||
->and(BadgeCatalog::spec(BadgeDomain::BaselineSnapshotLifecycle, 'building')->label)->toBe('Building')
|
||||
->and(BadgeCatalog::spec(BadgeDomain::BaselineSnapshotLifecycle, 'superseded')->label)->toBe('Superseded')
|
||||
->and(BadgeCatalog::spec(BadgeDomain::RestoreRunStatus, 'partial')->label)->toBe('Applied with follow-up')
|
||||
->and(BadgeCatalog::spec(BadgeDomain::RestoreResultStatus, 'manual_required')->label)->toBe('Manual follow-up needed')
|
||||
->and(BadgeCatalog::spec(BadgeDomain::RestoreCheckSeverity, 'warning')->label)->toBe('Review before running');
|
||||
|
||||
67
tests/Unit/Baselines/BaselineSnapshotLifecycleTest.php
Normal file
67
tests/Unit/Baselines/BaselineSnapshotLifecycleTest.php
Normal file
@ -0,0 +1,67 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Models\BaselineProfile;
|
||||
use App\Models\BaselineSnapshot;
|
||||
use App\Models\Workspace;
|
||||
use App\Support\Baselines\BaselineReasonCodes;
|
||||
use App\Support\Baselines\BaselineSnapshotLifecycleState;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
it('defaults factory snapshots to complete and consumable', function (): void {
|
||||
$snapshot = BaselineSnapshot::factory()->make();
|
||||
|
||||
expect($snapshot->lifecycleState())->toBe(BaselineSnapshotLifecycleState::Complete)
|
||||
->and($snapshot->isConsumable())->toBeTrue()
|
||||
->and($snapshot->isComplete())->toBeTrue();
|
||||
});
|
||||
|
||||
it('tracks lifecycle transitions and completion proof metadata', function (): void {
|
||||
$workspace = Workspace::factory()->create();
|
||||
$profile = BaselineProfile::factory()->active()->create([
|
||||
'workspace_id' => (int) $workspace->getKey(),
|
||||
]);
|
||||
|
||||
$snapshot = BaselineSnapshot::factory()->complete()->create([
|
||||
'workspace_id' => (int) $workspace->getKey(),
|
||||
'baseline_profile_id' => (int) $profile->getKey(),
|
||||
]);
|
||||
|
||||
$snapshot->markBuilding(['expected_items' => 3]);
|
||||
$snapshot->refresh();
|
||||
|
||||
expect($snapshot->lifecycleState())->toBe(BaselineSnapshotLifecycleState::Building)
|
||||
->and($snapshot->completed_at)->toBeNull()
|
||||
->and(data_get($snapshot->completion_meta_jsonb, 'expected_items'))->toBe(3);
|
||||
|
||||
$snapshot->markComplete('truth-hash', ['persisted_items' => 3]);
|
||||
$snapshot->refresh();
|
||||
|
||||
expect($snapshot->lifecycleState())->toBe(BaselineSnapshotLifecycleState::Complete)
|
||||
->and($snapshot->snapshot_identity_hash)->toBe('truth-hash')
|
||||
->and($snapshot->completed_at)->not->toBeNull()
|
||||
->and($snapshot->failed_at)->toBeNull()
|
||||
->and(data_get($snapshot->completion_meta_jsonb, 'persisted_items'))->toBe(3);
|
||||
});
|
||||
|
||||
it('marks snapshots incomplete with a persisted reason code', function (): void {
|
||||
$snapshot = BaselineSnapshot::factory()->building()->create();
|
||||
|
||||
$snapshot->markIncomplete(BaselineReasonCodes::SNAPSHOT_COMPLETION_PROOF_FAILED, ['persisted_items' => 1]);
|
||||
$snapshot->refresh();
|
||||
|
||||
expect($snapshot->lifecycleState())->toBe(BaselineSnapshotLifecycleState::Incomplete)
|
||||
->and($snapshot->failed_at)->not->toBeNull()
|
||||
->and($snapshot->completed_at)->toBeNull()
|
||||
->and(data_get($snapshot->completion_meta_jsonb, 'finalization_reason_code'))->toBe(BaselineReasonCodes::SNAPSHOT_COMPLETION_PROOF_FAILED)
|
||||
->and(data_get($snapshot->completion_meta_jsonb, 'persisted_items'))->toBe(1);
|
||||
});
|
||||
|
||||
it('refuses to transition incomplete snapshots back to complete', function (): void {
|
||||
$snapshot = BaselineSnapshot::factory()->incomplete()->create();
|
||||
|
||||
expect(fn () => $snapshot->markComplete('retry-hash'))->toThrow(RuntimeException::class);
|
||||
});
|
||||
@ -0,0 +1,90 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Models\BaselineProfile;
|
||||
use App\Models\BaselineSnapshot;
|
||||
use App\Models\Workspace;
|
||||
use App\Support\Baselines\BaselineReasonCodes;
|
||||
use App\Support\Ui\GovernanceArtifactTruth\ArtifactTruthPresenter;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
it('marks complete snapshots as trustworthy current baseline truth', function (): void {
|
||||
$workspace = Workspace::factory()->create();
|
||||
$profile = BaselineProfile::factory()->active()->create([
|
||||
'workspace_id' => (int) $workspace->getKey(),
|
||||
]);
|
||||
|
||||
$snapshot = BaselineSnapshot::factory()->complete()->create([
|
||||
'workspace_id' => (int) $workspace->getKey(),
|
||||
'baseline_profile_id' => (int) $profile->getKey(),
|
||||
'summary_jsonb' => [
|
||||
'total_items' => 0,
|
||||
'fidelity_counts' => ['content' => 0, 'meta' => 0],
|
||||
'gaps' => ['count' => 0, 'by_reason' => []],
|
||||
],
|
||||
]);
|
||||
|
||||
$profile->update(['active_snapshot_id' => (int) $snapshot->getKey()]);
|
||||
|
||||
$truth = app(ArtifactTruthPresenter::class)->forBaselineSnapshot($snapshot->fresh());
|
||||
|
||||
expect($truth->primaryLabel)->toBe('Trustworthy artifact')
|
||||
->and($truth->artifactExistence)->toBe('created')
|
||||
->and($truth->freshnessState)->toBe('current')
|
||||
->and($truth->diagnosticLabel)->toBe('Complete')
|
||||
->and($truth->nextStepText())->toBe('No action needed');
|
||||
});
|
||||
|
||||
it('marks incomplete snapshots as unusable and preserves the stored reason code', function (): void {
|
||||
$workspace = Workspace::factory()->create();
|
||||
$profile = BaselineProfile::factory()->active()->create([
|
||||
'workspace_id' => (int) $workspace->getKey(),
|
||||
]);
|
||||
|
||||
$snapshot = BaselineSnapshot::factory()->incomplete(BaselineReasonCodes::SNAPSHOT_CAPTURE_FAILED)->create([
|
||||
'workspace_id' => (int) $workspace->getKey(),
|
||||
'baseline_profile_id' => (int) $profile->getKey(),
|
||||
]);
|
||||
|
||||
$truth = app(ArtifactTruthPresenter::class)->forBaselineSnapshot($snapshot);
|
||||
|
||||
expect($truth->primaryLabel)->toBe('Artifact not usable')
|
||||
->and($truth->artifactExistence)->toBe('created_but_not_usable')
|
||||
->and($truth->contentState)->toBe('missing_input')
|
||||
->and($truth->diagnosticLabel)->toBe('Incomplete')
|
||||
->and($truth->reason?->reasonCode)->toBe(BaselineReasonCodes::SNAPSHOT_CAPTURE_FAILED);
|
||||
});
|
||||
|
||||
it('marks older complete snapshots as historical after a newer complete snapshot becomes current', function (): void {
|
||||
$workspace = Workspace::factory()->create();
|
||||
$profile = BaselineProfile::factory()->active()->create([
|
||||
'workspace_id' => (int) $workspace->getKey(),
|
||||
]);
|
||||
|
||||
$historicalSnapshot = BaselineSnapshot::factory()->complete()->create([
|
||||
'workspace_id' => (int) $workspace->getKey(),
|
||||
'baseline_profile_id' => (int) $profile->getKey(),
|
||||
'captured_at' => now()->subDay(),
|
||||
'completed_at' => now()->subDay(),
|
||||
]);
|
||||
|
||||
$currentSnapshot = BaselineSnapshot::factory()->complete()->create([
|
||||
'workspace_id' => (int) $workspace->getKey(),
|
||||
'baseline_profile_id' => (int) $profile->getKey(),
|
||||
'captured_at' => now(),
|
||||
'completed_at' => now(),
|
||||
]);
|
||||
|
||||
$profile->update(['active_snapshot_id' => (int) $currentSnapshot->getKey()]);
|
||||
|
||||
$truth = app(ArtifactTruthPresenter::class)->forBaselineSnapshot($historicalSnapshot->fresh());
|
||||
|
||||
expect($truth->primaryLabel)->toBe('Historical artifact')
|
||||
->and($truth->artifactExistence)->toBe('historical_only')
|
||||
->and($truth->freshnessState)->toBe('stale')
|
||||
->and($truth->diagnosticLabel)->toBe('Superseded')
|
||||
->and($truth->reason?->reasonCode)->toBe(BaselineReasonCodes::SNAPSHOT_SUPERSEDED);
|
||||
});
|
||||
Loading…
Reference in New Issue
Block a user